ReviewOS

also looking at this

stacks/drone

feat: persist runtime metadata trust and replay

#46
Merged chris wants to merge codex/runtime-trust-store into main
18 files +1159 -31
.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -38,6 +38,8 @@ jobs:
3838 run: bun tests/fixtures/updates/qualify-trust.ts "$RUNNER_TEMP/runtime-trust-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
3939 - name: Verify typed SDK failures in a minified copied standalone executable
4040 run: bun tests/fixtures/updates/qualify-sdk.ts "$RUNNER_TEMP/runtime-sdk-qualification"
41 - name: Verify persistent metadata and recovery with a copied standalone CLI
42 run: bun tests/fixtures/updates/qualify-store.ts "$RUNNER_TEMP/runtime-store-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4143 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4244 working-directory: ${{ runner.temp }}
4345 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
app/Commands/UpdatesRecover.tsadded+14-0
Changes to app/Commands/UpdatesRecover.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { recoverRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:recover <directory>',
8 description: 'Replay exact durable metadata inputs to retain floors, without selecting or installing firmware',
9 options: storeReferenceFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await recoverRuntimeTrustStore(storeCommandReference(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesRefresh.tsadded+17-0
Changes to app/Commands/UpdatesRefresh.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:refresh <directory>',
8 description: 'Verify offline metadata at an independently trusted system time and retain its accepted prefix',
9 options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline metadata repository', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository and independently trusted system-clock source required')
13 await printAsync(JSON.stringify(await refreshRuntimeTrustStore({ ...storeCommandReference(options, directory), repository: options.repository, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
app/Commands/UpdatesStore.tsadded+14-0
Changes to app/Commands/UpdatesStore.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { inspectRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:store <directory>',
8 description: 'Inspect independently pinned metadata state under writer exclusion',
9 options: storeReferenceFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectRuntimeTrustStore(storeCommandReference(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesStoreInit.tsadded+17-0
Changes to app/Commands/UpdatesStoreInit.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandEnrollment, storeCommandFailure, storeEnrollmentFlags } from '../../resources/functions/drone/updates/command'
4import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:store-init',
8 description: 'Create a new independently bound metadata protocol store without selecting firmware',
9 options: { ...storeEnrollmentFlags, '--output <path>': 'New exclusive private protocol-state directory' },
10 async handle(options) {
11 try {
12 if (typeof options.output !== 'string') throw new Error('Explicit new store output required')
13 await printAsync(JSON.stringify(await createRuntimeTrustStore({ ...storeCommandEnrollment(options), output: options.output })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
bin/drone.tsmodified+8-0
Changes to bin/drone.ts
@@ -23,6 +23,10 @@ import UpdatesStage from '../app/Commands/UpdatesStage'
2323import UpdatesInspect from '../app/Commands/UpdatesInspect'
2424import UpdatesEnroll from '../app/Commands/UpdatesEnroll'
2525import UpdatesTrust from '../app/Commands/UpdatesTrust'
26import UpdatesStoreInit from '../app/Commands/UpdatesStoreInit'
27import UpdatesStore from '../app/Commands/UpdatesStore'
28import UpdatesRefresh from '../app/Commands/UpdatesRefresh'
29import UpdatesRecover from '../app/Commands/UpdatesRecover'
2630
2731import MissionsSimulate from '../app/Commands/MissionsSimulate'
2832import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -89,6 +93,10 @@ UpdatesStage(app)
8993UpdatesInspect(app)
9094UpdatesEnroll(app)
9195UpdatesTrust(app)
96UpdatesStoreInit(app)
97UpdatesStore(app)
98UpdatesRefresh(app)
99UpdatesRecover(app)
92100MissionsSimulate(app)
93101MissionsValidate(app)
94102MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdadded+53-0
Changes to docs/adr/0028-runtime-trust-store.md
@@ -0,0 +1,53 @@
1# Runtime trust store protocol
2
3Status: implementation in progress for #42. This decision does not qualify deployment.
4
5The update coordinator needs a private, independently bound state directory. An
6immutable initial enrollment remains the anchor. A separately pinned store identity
7binds its mutable protocol state to that enrollment. Missing or corrupt existing
8state is an error, never a reason to initialize a replacement or lower a floor.
9
10Use Bun's built-in SQLite for this protocol file. This is a deliberate exception
11to model-derived application migrations: these records are a bounded local update
12protocol, not application CRUD. The installed Stacks database factory uses global
13configuration and its query facade resolves that configuration lazily. It does not
14expose the isolated synchronous connection and commit ordering this protocol needs.
15Native Stacks commands remain the entrypoints. No generic framework abstraction is
16introduced for this single consumer, and no application database is reconfigured.
17
18Two databases have separate responsibilities. A writer database holds an immediate
19transaction across awaited work, excluding another coordinator. The protocol
20database commits inputs and accepted metadata independently while that lease is
21held. Both use a rollback journal, FULL synchronous commits, fullfsync where the
22host supports it, zero busy timeout and verified configuration. The file format
23has an application ID, version, exact schema and explicit size/count limits.
24Existing databases are opened without creation. Symlinks, shared writable paths,
25wrong ownership, extra hard links and unexpected schema are rejected.
26
27Before an SDK fetch returns metadata, commit the exact bounded input or missing
28response. Before its private cache write, commit each accepted metadata observation
29and its high-water state. The cache is disposable; the anchor and protocol history
30are not. Every attempt records its base generation and independently supplied fixed
31wall time. New attempts cannot skip an unresolved attempt. Historical recovery
32replays exact recorded inputs at that original time and can only retain floors.
33Only a new verification at a trusted current time can authorize candidate use.
34Unknown verification, crypto, persistence or resource failures remain maintenance
35conditions. Operational error types alone do not prove invalid input.
36
37Root-authorized timestamp or snapshot key rotation starts a new active metadata
38epoch and invalidates both working-cache files, as required by TUF 5.3.11. Retain
39the previous epochs and their floors. The global release-security floor never
40decreases across an epoch change. Root rotation alone, without either authority
41change, does not reset role floors.
42
43The schema and recovery implementation must be exercised with signed repositories,
44actual competing processes, SIGKILL cuts around input/prefix commits, corrupt and
45missing files, clock reversal, and real host storage failures. SQLite commit or
46process-crash evidence is not measured physical power-loss durability. Same-user
47privileged disk rollback and malicious kernel/filesystem behavior require separate
48hardware protection and are outside this host protocol's claims.
49
50Installed, candidate and known-good slots, authenticated image integration and
51health confirmation bound to an exact image and boot/session identity remain part
52of #42. No receipt or healthy boolean can select firmware. Physical installation,
53bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/runtime-updates.mdmodified+122-18
Changes to docs/runtime-updates.md
@@ -1,10 +1,11 @@
11# Runtime trust and signed image staging
22
33Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
4staging, independent candidate verification and protected initial trust enrollment.
5These commands prepare files on the host. Cross-update metadata/release floors,
6installed images, boot selection and known-good rollback slots remain to be
7implemented. Physical deployment is disabled.
4staging, independent candidate verification, protected initial trust enrollment
5and a persistent host metadata protocol. These commands prepare and verify host
6state. Integrating that protocol with authenticated image staging, revocation,
7installed images, boot selection and known-good rollback slots remains part of
8the full coordinator. Physical deployment is disabled.
89
910## Explicit trust and policy
1011
@@ -78,10 +79,109 @@ Keep this immutable anchor separate from an SDK working cache. The current SDK
7879writes cache files in place; a host write-limit probe truncated its cached root.
7980Never refresh against the enrollment directory. Staging/inspection still require
8081their explicit policy and do not load or advance this enrollment automatically.
81Persistent verified metadata/release floors, key lifecycle, interruption recovery
82and known-good coordination remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
82The metadata-store functions below retain verified metadata/release floors.
83The complete key lifecycle, storage-fault campaigns, image integration and
84known-good coordinator remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
8385Do not discard or re-enroll over failed state to reset a security floor.
8486
87## Persistent host metadata protocol
88
89Initialize a separate new private directory and retain its returned `storeSha256`
90independently, alongside the enrollment pin:
91
92```sh
93./buddy updates:store-init --enrollment /path/to/trust \
94 --enrollment-sha256 "$ENROLLMENT_SHA256" --output /path/to/new-store
95
96./buddy updates:store /path/to/new-store --enrollment /path/to/trust \
97 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256"
98
99./buddy updates:refresh /path/to/new-store --enrollment /path/to/trust \
100 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
101 --repository /path/to/offline-repository \
102 --trusted-system-clock-source operator-managed-utc-clock
103
104./buddy updates:recover /path/to/new-store --enrollment /path/to/trust \
105 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256"
106```
107
108Native Buddy and the copied standalone CLI use the same functions under
109`resources/functions/drone/updates/store.ts`. The independent store pin binds an
110immutable identity to the authenticated enrollment. It is not a hash of the
111mutable database or a candidate receipt. Wrong pins, existing initialization
112outputs, missing/corrupt files, foreign schemas, unexpected entries, symlinks,
113extra hard links and unsafe ownership/permissions fail closed. Existing files
114are never recreated as a way to recover a floor. See the
115[protocol decision](adr/0028-runtime-trust-store.md).
116
117`writer.sqlite` holds a transaction across awaited work. `state.sqlite` commits
118each input and accepted metadata prefix independently while that writer is
119excluded. The exact schema, application ID and version are checked before
120configuration that could rewrite a foreign database's format. Both connections
121require DELETE journals, FULL synchronous commits and configuration readback.
122SQLite and directory sync evidence concerns this host, not electrical power loss.
123
124An attempt records its base generation, clock source and fixed start time. Before
125the SDK receives an input, its bounded bytes or missing-file response are committed
126and read back. The awaited SDK observer commits accepted metadata before its
127private cache write. Later verification, compatibility or output failure does not
128discard that prefix. Working caches are separate disposable temporary directories.
129Progress-callback rejection aborts refresh and preserves its original cause. A
130callback's HTTP error cannot stand in for an absent repository file.
131`generation` and `retainedMetadataRecords` describe the append-only metadata history;
132status lists the active roles and the latest attempt.
133
134The global `releaseFloor` starts with enrolled policy and advances from the selected
135compatible target's verified signed declaration. Refresh verifies metadata and
136declared compatibility; it does not download or authenticate the actual image
137bytes. An attempt marked `verified` means this metadata operation completed. It
138does not mean an image is staged, installed, booted or confirmed healthy. Existing
139`updates:stage` and `updates:inspect` remain separate and still report
140`trustStatePersisted: false`; integrating them with this store is unfinished.
141
142Timestamp or snapshot authority rotation, authenticated through sequential root
143metadata, creates a new active epoch and invalidates both working-cache entries.
144Historical epochs remain in the database. A test changes only the snapshot key,
145keeps the timestamp key, accepts metadata counters dropping from 100 to 1 under
146the new authority, and still rejects a release below the global release floor.
147Changing only the root signing key does not reset active role floors.
148
149### Clock and interrupted attempts
150
151The function interface requires a `RuntimeUpdateClock` with a source label and
152`read(): Date`. The adapter must obtain independently trusted current wall time;
153there is no repository-supplied time or default adapter. Read once, copy that
154instant, and reject a decrease from the persisted clock floor. The native command
155uses the system UTC clock only with the explicit source option above. That name
156is an audit label, not an attestation that the system clock is synchronized or
157protected. Configure and verify that trust externally before invoking it. Trusted
158clock acquisition and hardware clock/storage rollback protection remain separate
159requirements. Control-loop freshness keeps its own monotonic clock.
160
161A pending or maintenance attempt blocks another refresh. Recovery reconstructs
162its exact base cache, replays only durable inputs at the recorded historical time,
163and compares every earlier accepted observation with its committed bytes. It can
164finish at an owned pre-delivery end-of-recorded-input marker, or after complete
165verification followed by a known rejection from our explicit manifest policy.
166The latter retains all metadata and the release floor without crediting a rejected
167release. Signature, crypto, input-integrity, persistence and unknown SDK errors do
168not become permission to discard a journal: they remain maintenance conditions.
169No automatic repair for those conditions or corrupt state is supplied yet.
170Preserve the directory for investigation.
171
172Recovery can advance floors from a previously journaled valid input that never
173reached the SDK before interruption. Its `recovered` status never authorizes an
174image; a new trusted-current-time verification is required. Host tests perform
175actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a
176competing process, restart and retain the timestamp before a fresh refresh. These
177cuts do not cover every write/sync boundary or actual physical power loss.
178
179The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
180state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per
181file. Exhaustion fails closed; history is not automatically deleted or compacted.
182Capacity migration, the full storage-fault campaign, authenticated repair/key
183revocation, image/slot integration and session-bound health remain in #42.
184
85185## Repository and signed image contract
86186
87187Use a standard TUF repository with `metadata/` and `targets/` directories,
@@ -138,11 +238,14 @@ phase error alone does not prove that input was invalid, that a prefix is safe
138238to discard, or that recovery may continue. Unknown crypto, resource or other
139239causes must fail closed pending investigation. Error objects and type names do
140240not establish authenticated trust or permit firmware use. The recovery
141coordinator and its full failure classification remain to be implemented.
241coordinator's broader failure classification and authenticated maintenance repair
242remain to be implemented. The metadata protocol above only recognizes its own
243pre-delivery replay marker and completed domain-policy rejection.
142244
143245The seam alone does not persist a protected prefix or make SDK cache writes
144246transactional. Its implementation and tests are a prerequisite for the protected
145store and recovery coordinator in issue #42. They do not complete that issue or
247store and recovery coordinator in issue #42. The bounded metadata protocol above
248uses that seam; neither completes the full coordinator or
146249permit flashing, boot selection or physical deployment.
147250
148251The selected target must have SHA-256, a nonzero length of at most 16 MiB and
@@ -190,16 +293,17 @@ treat marker existence alone as permission to install or boot it.
190293
191294## Remaining update and operations acceptance
192295
193Each verification starts from the supplied root. `trustStatePersisted: false`
194means this module has no protected cross-invocation metadata or release
195high-water state. It is not a complete anti-rollback implementation. The caller
196must supply the trusted release floor every time. TUF expiry uses host wall
197time; trusted clock acquisition and protection against clock/storage rollback
198remain open. Control-loop freshness still uses its separate monotonic clock.
199
200Initial enrollment is available above. Next, implement protected trust rotation
201and persistent metadata/release floors, an
202interruption-safe coordinator, installed/known-good slot integrity, explicit
296The separate image-stage/inspection path still starts from its supplied root.
297Its `trustStatePersisted: false` result means it does not use the metadata store's
298cross-invocation floors. The caller must supply its trusted release floor each
299time. Neither path is a complete anti-rollback coordinator. Trusted clock
300acquisition and protection against privileged clock/storage rollback remain
301open. Control-loop freshness still uses its separate monotonic clock.
302
303Initial enrollment and the bounded persistent metadata protocol are available
304above. Complete authenticated maintenance/key revocation, the full storage-fault
305campaign and integration with signed image staging, then installed/known-good
306slot integrity, explicit
203307health confirmation and rollback without lowering security floors. Then add
204308the board bootloader/install protocol and measured power-loss, full-medium,
205309storage-latency and recovery campaigns. Local privileged filesystem tampering
resources/functions/drone/updates/command.tsmodified+21-0
Changes to resources/functions/drone/updates/command.ts
@@ -1,4 +1,5 @@
11import type { RuntimeFirmwarePolicy } from './staging'
2import { printErrorAsync } from '../../cli/output'
23
34export const firmwarePolicyFlags = {
45 '--target <path>': 'Explicit signed target path inside the offline repository',
@@ -12,3 +13,23 @@ export function firmwareCommandPolicy(options: { target?: unknown, platform?: un
1213 if (options.platform !== 'pico2-neutral-bench-v1' && options.platform !== 'host-neutral-bench-v1') throw new Error('Explicit implemented bench platform required')
1314 return { target: options.target, platform: options.platform, trustedRootSha256: options.rootSha256, minimumReleaseSequence: Number(options.minimumReleaseSequence) }
1415}
16
17export const storeEnrollmentFlags = {
18 '--enrollment <path>': 'Immutable independently authenticated initial trust enrollment',
19 '--enrollment-sha256 <hex>': 'Independently retained enrollment record SHA-256',
20} as const
21export const storeReferenceFlags = { ...storeEnrollmentFlags, '--store-sha256 <hex>': 'Independently retained update-store identity SHA-256' } as const
22
23export function storeCommandEnrollment(options: { enrollment?: unknown, enrollmentSha256?: unknown }) {
24 if (typeof options.enrollment !== 'string' || typeof options.enrollmentSha256 !== 'string') throw new Error('Explicit enrollment and independent enrollment pin required')
25 return { enrollmentDirectory: options.enrollment, enrollmentSha256: options.enrollmentSha256 }
26}
27export function storeCommandReference(options: { enrollment?: unknown, enrollmentSha256?: unknown, storeSha256?: unknown }, directory: string) {
28 if (typeof options.storeSha256 !== 'string') throw new Error('Explicit independent store pin required')
29 return { ...storeCommandEnrollment(options), directory, storeSha256: options.storeSha256 }
30}
31export async function storeCommandFailure() {
32 process.exitCode = 1
33 try { await printErrorAsync('Runtime update store unavailable: invalid trust, clock, state or storage failure') }
34 catch { /* Keep exit 1 if the diagnostic pipe also fails. */ }
35}
resources/functions/drone/updates/manifest.tsadded+19-0
Changes to resources/functions/drone/updates/manifest.ts
@@ -0,0 +1,19 @@
1import type { TargetFile } from 'tuf-js'
2import { imageLimit } from './files'
3import type { RuntimeFirmwarePolicy } from './policy'
4
5/** A completed metadata verification can still be refused by our explicit image policy. */
6export class RuntimeManifestRejected extends Error {}
7
8export function admitSignedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
9 const value = info.custom.drone
10 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RuntimeManifestRejected('Unsupported signed runtime manifest')
11 const data = value as Record<string, unknown>
12 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
13 if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new RuntimeManifestRejected('Unsupported signed runtime manifest')
14 if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new RuntimeManifestRejected('Signed runtime platform or protocol does not match policy')
15 if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new RuntimeManifestRejected('Signed release is below the trusted minimum release sequence')
16 if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new RuntimeManifestRejected('Invalid signed source revision')
17 if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new RuntimeManifestRejected('Runtime image requires a bounded length and SHA-256')
18 return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision }
19}
resources/functions/drone/updates/staging.tsmodified+2-12
Changes to resources/functions/drone/updates/staging.ts
@@ -5,9 +5,9 @@ import { tmpdir } from 'node:os'
55import { Readable } from 'node:stream'
66import { isDeepStrictEqual } from 'node:util'
77import { Updater } from 'tuf-js'
8import type { TargetFile } from 'tuf-js'
98import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files'
109import { object, validateMetadata } from './metadata'
10import { admitSignedRuntime } from './manifest'
1111import { admitPolicy } from './policy'
1212import type { RuntimeFirmwarePolicy } from './policy'
1313export type { RuntimeFirmwarePolicy } from './policy'
@@ -30,16 +30,6 @@ export interface RuntimeFirmwareStage {
3030 trustStatePersisted: false
3131}
3232
33function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
34 const data = object(info.custom.drone)
35 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
36 if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new Error('Unsupported signed runtime manifest')
37 if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new Error('Signed runtime platform or protocol does not match policy')
38 if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new Error('Signed release is below the trusted minimum release sequence')
39 if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new Error('Invalid signed source revision')
40 if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new Error('Runtime image requires a bounded length and SHA-256')
41 return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision }
42}
4333
4434/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
4535async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
@@ -81,7 +71,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string,
8171 await updater.refresh()
8272 const info = await updater.getTargetInfo(policy.target)
8373 if (!info) throw new Error('Selected firmware target is absent')
84 const runtime = signedRuntime(info, policy)
74 const runtime = admitSignedRuntime(info, policy)
8575 if (download) await updater.downloadTarget(info, download)
8676 const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8')))
8777 const rootVersion = object(latest.signed).version
resources/functions/drone/updates/store.tsadded+405-0
Changes to resources/functions/drone/updates/store.ts
@@ -0,0 +1,405 @@
1import { Database } from 'bun:sqlite'
2import { randomBytes } from 'node:crypto'
3import { lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, unlinkSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { dirname, join, resolve } from 'node:path'
6import { DownloadHTTPError, Updater } from 'tuf-js'
7import { inspectRuntimeTrust } from './enrollment'
8import type { RuntimeTrustSummary } from './enrollment'
9import { directory, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files'
10import { object, validateMetadata } from './metadata'
11import { admitSignedRuntime, RuntimeManifestRejected } from './manifest'
12
13const applicationId = 0x44525453
14const maximumGeneration = 10000
15const maximumDatabaseBytes = 128 * 1024 * 1024
16const activeStores = new Set<string>()
17const writerSchema = 'CREATE TABLE writer (id INTEGER PRIMARY KEY CHECK(id = 1), store_id TEXT NOT NULL CHECK(length(store_id) = 32)) STRICT'
18const schema = [
19 `CREATE TABLE store_state (id INTEGER PRIMARY KEY CHECK(id = 1), store_id TEXT NOT NULL CHECK(length(store_id) = 32), enrollment_sha256 TEXT NOT NULL CHECK(length(enrollment_sha256) = 64), generation INTEGER NOT NULL CHECK(generation BETWEEN 0 AND ${maximumGeneration}), release_floor INTEGER NOT NULL CHECK(release_floor BETWEEN 1 AND 9007199254740991), clock_floor_ms INTEGER NOT NULL CHECK(clock_floor_ms BETWEEN 0 AND 253402300799000), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT`,
20 `CREATE TABLE attempts (id TEXT PRIMARY KEY CHECK(length(id) = 32), base_generation INTEGER NOT NULL, started_ms INTEGER NOT NULL CHECK(started_ms BETWEEN 1 AND 253402300799000), clock_source TEXT NOT NULL CHECK(length(clock_source) BETWEEN 1 AND 128), phase TEXT NOT NULL CHECK(phase IN ('pending', 'verified', 'recovered', 'maintenance')), accepted_release INTEGER CHECK(accepted_release BETWEEN 1 AND 9007199254740991), CHECK(phase != 'verified' OR accepted_release IS NOT NULL), FOREIGN KEY(base_generation) REFERENCES metadata(generation)) STRICT`,
21 `CREATE TABLE inputs (attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), path TEXT NOT NULL CHECK(length(path) BETWEEN 1 AND 256), status INTEGER NOT NULL CHECK(status IN (200, 404)), bytes BLOB, sha256 TEXT, PRIMARY KEY(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id), CHECK((status = 404 AND bytes IS NULL AND sha256 IS NULL) OR (status = 200 AND bytes IS NOT NULL AND sha256 IS NOT NULL AND length(bytes) BETWEEN 1 AND ${metadataLimit} AND length(sha256) = 64))) STRICT`,
22 `CREATE TABLE metadata (generation INTEGER PRIMARY KEY CHECK(generation BETWEEN 0 AND ${maximumGeneration}), attempt_id TEXT, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), role TEXT NOT NULL CHECK(length(role) BETWEEN 1 AND 180), version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 9007199254740991), epoch INTEGER NOT NULL CHECK(epoch BETWEEN 0 AND generation), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND ${metadataLimit}), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), UNIQUE(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id)) STRICT`,
23]
24
25interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
26interface StoreReference extends TrustReference { directory: string, storeSha256: string }
27interface Binding { schema: 'drone-runtime-store-v1', storeId: string, enrollmentId: string, enrollmentSha256: string }
28interface State { store_id: string, enrollment_sha256: string, generation: number, release_floor: number, clock_floor_ms: number }
29interface MetadataRow { generation: number, attempt_id: string | null, ordinal: number, role: string, version: number, epoch: number, bytes: Uint8Array, sha256: string }
30interface Attempt { id: string, base_generation: number, started_ms: number, clock_source: string, phase: 'pending' | 'verified' | 'recovered' | 'maintenance', accepted_release: number | null }
31export interface RuntimeTrustStoreSummary {
32 schema: 'drone-runtime-store-status-v1'
33 storeId: string
34 storeSha256: string
35 enrollmentSha256: string
36 generation: number
37 epoch: number
38 releaseFloor: number
39 clockFloorMs: number
40 retainedMetadataRecords: number
41 metadata: ReadonlyArray<Readonly<{ role: string, version: number, sha256: string }>>
42 attempt: Readonly<{ id: string, phase: Attempt['phase'], clockSource: string, startedMs: number }> | null
43 physicalDeploymentAllowed: false
44}
45
46function protectedFile(path: string, limit: number, allowEmpty = false) {
47 const stat = lstatSync(path)
48 if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.size > limit || (!allowEmpty && stat.size === 0)) throw new Error('Invalid protected update-store file')
49 return stat
50}
51function protectedDirectory(path: string) {
52 directory(dirname(path)); directory(path)
53 if ((lstatSync(path).mode & 0o777) !== 0o700) throw new Error('Update-store directory must be private')
54}
55function configure(db: Database) {
56 db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA fullfsync=ON; PRAGMA page_size=4096; PRAGMA max_page_count=32768;')
57 for (const [name, expected] of [['trusted_schema', 0], ['foreign_keys', 1], ['busy_timeout', 0], ['journal_mode', 'delete'], ['synchronous', 2], ['fullfsync', 1], ['page_size', 4096], ['max_page_count', 32768]] as const) {
58 const result = db.query(`PRAGMA ${name}`).get() as Record<string, unknown> | null
59 if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable')
60 }
61}
62function openDatabase(path: string, expectedSchema: string[]) {
63 const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes)
64 const db = new Database(path, { create: false, strict: true })
65 try {
66 const after = lstatSync(path)
67 if (before.dev !== after.dev || before.ino !== after.ino) throw new Error('Update-store file replaced while opening')
68 db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;')
69 // Authenticate the protocol format before any configuration that could
70 // rewrite a foreign database's journal/header format.
71 checkSchema(db, expectedSchema)
72 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
73 configure(db)
74 return db
75 }
76 catch (error) { db.close(); throw error }
77}
78function checkSchema(db: Database, expected: string[]) {
79 const definitions = db.query<{ sql: string }, []>("SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").all().map(row => row.sql).sort()
80 if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema')
81 const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get()
82 const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()
83 if (values?.application_id !== applicationId || version?.user_version !== 1 || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
84}
85function createDatabase(path: string, statements: string[]) {
86 writeExclusive(path, Buffer.alloc(0))
87 const db = new Database(path, { create: false, strict: true })
88 try {
89 configure(db)
90 db.exec(`PRAGMA application_id=${applicationId}; PRAGMA user_version=1;`)
91 db.transaction(() => { for (const sql of statements) db.exec(sql) })()
92 return db
93 }
94 catch (error) { db.close(); throw error }
95}
96function admitBinding(bytes: Buffer, trusted: Readonly<RuntimeTrustSummary>): Binding {
97 const item = object(JSON.parse(bytes.toString('utf8')))
98 const keys = ['schema', 'storeId', 'enrollmentId', 'enrollmentSha256']
99 if (Object.keys(item).length !== keys.length || !keys.every(key => Object.hasOwn(item, key)) || item.schema !== 'drone-runtime-store-v1' || typeof item.storeId !== 'string' || !/^[a-f0-9]{32}$/.test(item.storeId) || item.enrollmentId !== trusted.enrollmentId || item.enrollmentSha256 !== trusted.enrollmentSha256) throw new Error('Update-store binding does not match enrollment')
100 return item as unknown as Binding
101}
102function state(db: Database, binding: Binding) {
103 const rows = db.query<State, []>('SELECT store_id, enrollment_sha256, generation, release_floor, clock_floor_ms FROM store_state').all()
104 if (rows.length !== 1 || rows[0]!.store_id !== binding.storeId || rows[0]!.enrollment_sha256 !== binding.enrollmentSha256) throw new Error('Invalid update-store identity')
105 return rows[0]!
106}
107function activeMetadata(db: Database, generation: number) {
108 const root = db.query<MetadataRow, [number]>("SELECT * FROM metadata WHERE role = 'root' AND generation <= ? ORDER BY generation DESC LIMIT 1").get(generation)
109 if (!root) throw new Error('Missing protected root')
110 return db.query<MetadataRow, [number, number]>("SELECT * FROM metadata WHERE generation IN (SELECT max(generation) FROM metadata WHERE generation <= ? AND (role = 'root' OR epoch = ?) GROUP BY role) ORDER BY role").all(generation, root.epoch)
111}
112function checkedMetadataBytes(row: MetadataRow) {
113 const bytes = Buffer.from(row.bytes)
114 validateMetadata(bytes)
115 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
116 const kind = ['root', 'timestamp', 'snapshot'].includes(row.role) ? row.role : 'targets'
117 if (!/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(row.role) || sha256(bytes) !== row.sha256 || signed.version !== row.version || signed._type !== kind) throw new Error('Protected metadata integrity mismatch')
118 return bytes
119}
120function summary(db: Database, binding: Binding, storeSha256: string): Readonly<RuntimeTrustStoreSummary> {
121 const current = state(db, binding), metadata = activeMetadata(db, current.generation)
122 const anchor = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
123 if (!anchor || anchor.role !== 'root' || anchor.attempt_id !== null) throw new Error('Missing initial update-store anchor')
124 for (const row of [anchor, ...metadata]) checkedMetadataBytes(row)
125 const counts = db.query<{ attempts: number, metadata: number, inputs: number }, []>('SELECT (SELECT count(*) FROM attempts) AS attempts, (SELECT count(*) FROM metadata) AS metadata, (SELECT count(*) FROM inputs) AS inputs').get()!
126 if (counts.attempts > 256 || counts.metadata !== current.generation + 1 || counts.metadata > maximumGeneration + 1 || counts.inputs > 16384) throw new Error('Update-store history budget exceeded or metadata history is incomplete')
127 const floors = db.query<{ clock: number, release: number, generation: number }, []>('SELECT coalesce((SELECT max(started_ms) FROM attempts), 0) AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation').get()!
128 if (current.clock_floor_ms !== floors.clock || current.release_floor < floors.release || current.generation !== floors.generation || db.query<{ count: number }, []>("SELECT count(*) AS count FROM attempts WHERE phase IN ('pending', 'maintenance')").get()!.count > 1) throw new Error('Inconsistent update-store floors or attempts')
129 const attempt = db.query<Attempt, []>('SELECT * FROM attempts ORDER BY rowid DESC LIMIT 1').get()
130 return Object.freeze({ schema: 'drone-runtime-store-status-v1', storeId: binding.storeId, storeSha256, enrollmentSha256: binding.enrollmentSha256, generation: current.generation, epoch: metadata.find(row => row.role === 'root')!.epoch, releaseFloor: current.release_floor, clockFloorMs: current.clock_floor_ms, retainedMetadataRecords: counts.metadata, metadata: Object.freeze(metadata.map(row => Object.freeze({ role: row.role, version: row.version, sha256: row.sha256 }))), attempt: attempt ? Object.freeze({ id: attempt.id, phase: attempt.phase, clockSource: attempt.clock_source, startedMs: attempt.started_ms }) : null, physicalDeploymentAllowed: false })
131}
132
133async function withStore<T>(options: StoreReference, operation: (db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string) => T | Promise<T>): Promise<T> {
134 const trusted = inspectRuntimeTrust({ directory: options.enrollmentDirectory, enrollmentSha256: options.enrollmentSha256 })
135 const path = resolve(options.directory)
136 protectedDirectory(path)
137 if (typeof options.storeSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(options.storeSha256)) throw new Error('Independent update-store pin required')
138 protectedFile(join(path, 'store.json'), 16384)
139 const bytes = regularFile(join(path, 'store.json'), 16384)
140 if (sha256(bytes) !== options.storeSha256) throw new Error('Update-store pin mismatch')
141 const binding = admitBinding(bytes, trusted), key = realpathSync(path)
142 if (activeStores.has(key)) throw new Error('Update-store writer already active')
143 activeStores.add(key)
144 let writer: Database | undefined, db: Database | undefined
145 try {
146 for (const name of readdirSync(path)) {
147 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry')
148 if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true)
149 }
150 writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema])
151 writer.exec('BEGIN IMMEDIATE')
152 const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all()
153 if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity')
154 db = openDatabase(join(path, 'state.sqlite'), schema)
155 const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
156 if (initial?.sha256 !== trusted.policy.trustedRootSha256 || initial.version !== trusted.rootVersion || state(db, binding).release_floor < trusted.policy.minimumReleaseSequence) throw new Error('Update-store anchor or floor mismatch')
157 summary(db, binding, options.storeSha256)
158 return await operation(db, binding, trusted, path)
159 }
160 finally {
161 try { db?.close() }
162 finally {
163 try { if (writer?.inTransaction) writer.exec('ROLLBACK') }
164 finally { try { writer?.close() } finally { activeStores.delete(key) } }
165 }
166 }
167}
168
169/** Create a new host protocol store. Keep its returned pin independently; existing state is never reset. */
170export async function createRuntimeTrustStore(options: TrustReference & { output: string }): Promise<Readonly<RuntimeTrustStoreSummary>> {
171 const trusted = inspectRuntimeTrust({ directory: options.enrollmentDirectory, enrollmentSha256: options.enrollmentSha256 })
172 const output = resolve(options.output)
173 directory(dirname(output)); mkdirSync(output, { mode: 0o700 })
174 const binding: Binding = { schema: 'drone-runtime-store-v1', storeId: randomBytes(16).toString('hex'), enrollmentId: trusted.enrollmentId, enrollmentSha256: trusted.enrollmentSha256 }
175 const writer = createDatabase(join(output, 'writer.sqlite'), [writerSchema])
176 try { writer.query('INSERT INTO writer VALUES(1, ?)').run(binding.storeId) }
177 finally { writer.close() }
178 const db = createDatabase(join(output, 'state.sqlite'), schema)
179 try {
180 const root = regularFile(join(options.enrollmentDirectory, 'root.json'), metadataLimit)
181 if (sha256(root) !== trusted.policy.trustedRootSha256) throw new Error('Enrolled root changed during store initialization')
182 db.transaction(() => {
183 db.query("INSERT INTO metadata VALUES(0, NULL, 0, 'root', ?, 0, ?, ?)").run(trusted.rootVersion, root, sha256(root))
184 db.query('INSERT INTO store_state VALUES(1, ?, ?, 0, ?, 0)').run(binding.storeId, binding.enrollmentSha256, trusted.policy.minimumReleaseSequence)
185 })()
186 }
187 finally { db.close() }
188 syncDirectory(output)
189 const bytes = Buffer.from(`${JSON.stringify(binding)}\n`), storeSha256 = sha256(bytes)
190 writeExclusive(join(output, 'store.json'), bytes); syncDirectory(output); syncDirectory(dirname(output))
191 return inspectRuntimeTrustStore({ ...options, directory: output, storeSha256 })
192}
193
194/** Inspect protected host state under the same writer exclusion used by update operations. */
195export async function inspectRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> {
196 return withStore(options, (db, binding) => summary(db, binding, options.storeSha256))
197}
198
199export interface RuntimeUpdateClock {
200 /** Audit label for an independently trusted clock. The label itself is not attestation. */
201 source: string
202 read: () => Date
203}
204
205export interface RuntimeUpdateProgress {
206 phase: 'input-committed' | 'metadata-committed'
207 ordinal: number
208}
209type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
210interface InputRow { ordinal: number, path: string, status: 200 | 404, bytes: Uint8Array | null, sha256: string | null }
211
212function clockReading(clock: RuntimeUpdateClock, floor: number) {
213 if (!clock || typeof clock.source !== 'string' || !/^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(clock.source) || typeof clock.read !== 'function') throw new Error('An independently trusted update clock is required')
214 const source = clock.source, time = clock.read()
215 const milliseconds = time instanceof Date ? Date.prototype.getTime.call(time) : Number.NaN
216 if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 || milliseconds > 253402300799000 || milliseconds < floor) throw new Error('Invalid or reversed update clock')
217 return { source, time: new Date(milliseconds) }
218}
219function authorities(bytes: Uint8Array) {
220 const roles = object(object(object(JSON.parse(Buffer.from(bytes).toString('utf8'))).signed).roles)
221 return JSON.stringify(['timestamp', 'snapshot'].map(name => {
222 const role = object(roles[name])
223 if (!Array.isArray(role.keyids) || !role.keyids.every(id => typeof id === 'string')) throw new Error('Invalid verified role authority')
224 return { keyids: [...role.keyids].sort(), threshold: role.threshold }
225 }))
226}
227function workingCache(db: Database, generation: number) {
228 const path = mkdtempSync(join(tmpdir(), 'drone-update-refresh-'))
229 try {
230 for (const row of activeMetadata(db, generation)) {
231 if (!/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(row.role)) throw new Error('Unsafe protected metadata role')
232 writeExclusive(join(path, `${row.role}.json`), checkedMetadataBytes(row))
233 }
234 return path
235 }
236 catch (error) { rmSync(path, { recursive: true, force: true }); throw error }
237}
238function invalidateActiveCache(cache: string) {
239 for (const role of ['timestamp', 'snapshot']) {
240 try { unlinkSync(join(cache, `${role}.json`)) }
241 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
242 }
243}
244function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal: number, role: string, bytes: Buffer) {
245 validateMetadata(bytes)
246 if (bytes.length > metadataLimit || !/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(role)) throw new Error('Invalid verified metadata observation')
247 const version = object(object(JSON.parse(bytes.toString('utf8'))).signed).version as number
248 const current = state(db, binding), rows = activeMetadata(db, current.generation), prior = rows.find(row => row.role === role)!
249 const root = rows.find(row => row.role === 'root')!
250 if (current.generation >= maximumGeneration || (role === 'root' && version !== root.version + 1) || (prior && (version < prior.version || (version === prior.version && sha256(bytes) !== prior.sha256)))) throw new Error('Protected metadata floor or history limit rejected advancement')
251 const changed = role === 'root' && authorities(root.bytes) !== authorities(bytes), epoch = root.epoch + (changed ? 1 : 0)
252 db.transaction(() => {
253 db.query('INSERT INTO metadata VALUES(?, ?, ?, ?, ?, ?, ?, ?)').run(current.generation + 1, attempt, ordinal, role, version, epoch, bytes, sha256(bytes))
254 db.query('UPDATE store_state SET generation = ? WHERE id = 1').run(current.generation + 1)
255 })()
256 const readback = db.query<MetadataRow, [number]>('SELECT * FROM metadata WHERE generation = ?').get(current.generation + 1)
257 if (!readback || readback.sha256 !== sha256(bytes) || !Buffer.from(readback.bytes).equals(bytes)) throw new Error('Verified metadata commit readback failed')
258 return changed
259}
260
261/** Verify bounded offline metadata and retain its accepted prefix. This does not stage, select or install an image. */
262export async function refreshRuntimeTrustStore(options: StoreReference & { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeTrustStoreSummary>> {
263 options = { ...options }
264 return withStore(options, async (db, binding, trusted) => {
265 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
266 const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms)
267 if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached')
268 const source = realpathSync(options.repository); directory(source)
269 const cache = workingCache(db, current.generation), attempt = randomBytes(16).toString('hex')
270 let requests = 0, received = 0, observations = 0
271 try {
272 db.transaction(() => {
273 db.query("INSERT INTO attempts VALUES(?, ?, ?, ?, 'pending', NULL)").run(attempt, current.generation, now.time.getTime(), now.source)
274 db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(now.time.getTime())
275 })()
276 const fetcher = {
277 async downloadBytes(url: string, limit: number) {
278 const address = new URL(url), path = address.pathname.slice('/metadata/'.length)
279 if (address.origin !== 'https://offline.invalid' || !address.pathname.startsWith('/metadata/') || address.search || address.hash || !/^(?:[0-9]+\.)?[a-z0-9][a-z0-9._-]{0,179}\.json$/i.test(path) || requests >= 64) throw new Error('Unsupported or excessive metadata request')
280 safePath(path)
281 let bytes: Buffer | null = null
282 try { bytes = repositoryFile(source, `metadata/${path}`, Math.min(limit, metadataLimit)); validateMetadata(bytes) }
283 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
284 received += bytes?.length ?? 0
285 if (received > 32 * 1024 * 1024) throw new Error('Metadata input budget exceeded')
286 const index = requests++
287 db.transaction(() => db.query('INSERT INTO inputs VALUES(?, ?, ?, ?, ?, ?)').run(attempt, index, path, bytes ? 200 : 404, bytes, bytes ? sha256(bytes) : null))()
288 const saved = db.query<InputRow, [string, number]>('SELECT * FROM inputs WHERE attempt_id = ? AND ordinal = ?').get(attempt, index)
289 if (!saved || saved.path !== path || saved.status !== (bytes ? 200 : 404) || (bytes ? !saved.bytes || !Buffer.from(saved.bytes).equals(bytes) || saved.sha256 !== sha256(bytes) : saved.bytes !== null || saved.sha256 !== null)) throw new Error('Metadata input commit readback failed')
290 // Only our absent-file response may enter the SDK's HTTP classification.
291 // Observer failures must abort even when their raw cause is an HTTP error.
292 try { await options.onProgress?.(Object.freeze({ phase: 'input-committed', ordinal: index })) }
293 catch (cause) { throw new Error('Update input progress observer rejected', { cause }) }
294 if (!bytes) throw new DownloadHTTPError('Offline metadata is absent', 404)
295 return Buffer.from(bytes)
296 },
297 async downloadFile(): Promise<never> { throw new Error('Metadata refresh never downloads an image') },
298 }
299 const updater = new Updater({ metadataDir: cache, metadataBaseUrl: 'https://offline.invalid/metadata/', fetcher, referenceTime: now.time, config: { maxRootRotations: 16, maxDelegations: 16, rootMaxLength: metadataLimit, timestampMaxLength: metadataLimit, snapshotMaxLength: metadataLimit, targetsMaxLength: metadataLimit }, async onVerifiedMetadata(role, bytes) {
300 const index = observations++
301 if (commitMetadata(db, binding, attempt, index, role, bytes)) invalidateActiveCache(cache)
302 await options.onProgress?.(Object.freeze({ phase: 'metadata-committed', ordinal: index }))
303 } })
304 await updater.refresh()
305 const info = await updater.getTargetInfo(trusted.policy.target)
306 if (!info) throw new Error('Selected runtime target is absent')
307 const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor })
308 db.transaction(() => {
309 db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence)
310 db.query("UPDATE attempts SET phase = 'verified', accepted_release = ? WHERE id = ?").run(runtime.releaseSequence, attempt)
311 })()
312 return summary(db, binding, options.storeSha256)
313 }
314 catch (error) {
315 try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) }
316 catch (persistence) { throw new AggregateError([error, persistence], 'Update failed and maintenance state could not be recorded') }
317 throw error
318 }
319 finally { rmSync(cache, { recursive: true, force: true }) }
320 })
321}
322
323function causedBy(error: unknown, marker: Error) {
324 const seen = new Set<unknown>()
325 for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) {
326 if (value === marker) return true
327 seen.add(value)
328 }
329 return false
330}
331
332/** Replay an interrupted attempt from its exact base and durable inputs. This only retains floors, never authorizes image use. */
333export async function recoverRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> {
334 options = { ...options }
335 return withStore(options, async (db, binding, trusted) => {
336 const pending = db.query<Attempt, []>("SELECT * FROM attempts WHERE phase IN ('pending', 'maintenance')").all()
337 if (pending.length !== 1) throw new Error('Exactly one unresolved attempt is required for recovery')
338 const attempt = pending[0]!, current = state(db, binding)
339 const inputs = db.query<InputRow, [string]>('SELECT * FROM inputs WHERE attempt_id = ? ORDER BY ordinal').all(attempt.id)
340 const committed = db.query<MetadataRow, [number]>('SELECT * FROM metadata WHERE generation > ? ORDER BY generation').all(attempt.base_generation)
341 if (inputs.length > 64 || inputs.some((row, index) => row.ordinal !== index) || committed.some((row, index) => row.attempt_id !== attempt.id || row.ordinal !== index || row.generation !== attempt.base_generation + index + 1) || current.generation !== attempt.base_generation + committed.length) throw new Error('Interrupted attempt history is inconsistent')
342 const cache = workingCache(db, attempt.base_generation), end = new Error('Recorded metadata input boundary reached')
343 let replayRoot = activeMetadata(db, attempt.base_generation).find(row => row.role === 'root')!
344 let delivered = 0, observed = 0, received = 0, release: number | null = null
345 try {
346 const fetcher = {
347 async downloadBytes(url: string, limit: number) {
348 const address = new URL(url), path = address.pathname.slice('/metadata/'.length)
349 if (address.origin !== 'https://offline.invalid' || !address.pathname.startsWith('/metadata/') || address.search || address.hash) throw new Error('Unexpected recovery request')
350 const item = inputs[delivered]
351 if (!item) throw end
352 if (item.path !== path) throw new Error('Recovery request differs from recorded input')
353 delivered++
354 if (item.status === 404) {
355 if (item.bytes !== null || item.sha256 !== null) throw new Error('Invalid recorded missing response')
356 throw new DownloadHTTPError('Recorded offline metadata is absent', 404)
357 }
358 if (!item.bytes) throw new Error('Missing recorded metadata bytes')
359 const bytes = Buffer.from(item.bytes)
360 received += bytes.length
361 if (bytes.length < 1 || bytes.length > Math.min(limit, metadataLimit) || received > 32 * 1024 * 1024 || sha256(bytes) !== item.sha256) throw new Error('Recorded metadata input integrity or budget failure')
362 validateMetadata(bytes)
363 return bytes
364 },
365 async downloadFile(): Promise<never> { throw new Error('Recovery never downloads an image') },
366 }
367 const updater = new Updater({ metadataDir: cache, metadataBaseUrl: 'https://offline.invalid/metadata/', fetcher, referenceTime: new Date(attempt.started_ms), config: { maxRootRotations: 16, maxDelegations: 16, rootMaxLength: metadataLimit, timestampMaxLength: metadataLimit, snapshotMaxLength: metadataLimit, targetsMaxLength: metadataLimit }, onVerifiedMetadata(role, bytes) {
368 const old = committed[observed], changed = role === 'root' && authorities(replayRoot.bytes) !== authorities(bytes)
369 const epoch = replayRoot.epoch + (changed ? 1 : 0)
370 if (old) {
371 if (old.role !== role || old.sha256 !== sha256(bytes) || old.epoch !== epoch || !Buffer.from(old.bytes).equals(bytes)) throw new Error('Recovered metadata differs from committed prefix')
372 }
373 else commitMetadata(db, binding, attempt.id, observed, role, bytes)
374 if (role === 'root') replayRoot = { ...replayRoot, bytes, epoch }
375 if (changed) invalidateActiveCache(cache)
376 observed++
377 } })
378 try {
379 await updater.refresh()
380 const info = await updater.getTargetInfo(trusted.policy.target)
381 if (!info) throw new Error('Selected runtime target is absent during recovery')
382 release = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor }).releaseSequence
383 }
384 catch (error) {
385 // Only this owned pre-delivery marker can end replay without completing
386 // metadata verification. SDK validation/IO/crypto errors stay maintenance.
387 // A policy rejection raised here after complete SDK verification can end
388 // the attempt without crediting a release. SDK/IO failures cannot.
389 if (!causedBy(error, end) && !(error instanceof RuntimeManifestRejected)) throw error
390 }
391 if (delivered !== inputs.length || observed < committed.length) throw new Error('Recovery did not reproduce the full recorded prefix')
392 db.transaction(() => {
393 if (release !== null) db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(release)
394 db.query("UPDATE attempts SET phase = 'recovered', accepted_release = ? WHERE id = ?").run(release, attempt.id)
395 })()
396 return summary(db, binding, options.storeSha256)
397 }
398 catch (error) {
399 try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt.id) }
400 catch (persistence) { throw new AggregateError([error, persistence], 'Recovery failed and maintenance state could not be recorded') }
401 throw error
402 }
403 finally { rmSync(cache, { recursive: true, force: true }) }
404 })
405}
tests/fixtures/updates/epochs.tsadded+37-0
Changes to tests/fixtures/updates/epochs.ts
@@ -0,0 +1,37 @@
1import { mkdir, writeFile } from 'node:fs/promises'
2import { join } from 'node:path'
3import { Metadata, MetaFile, Root, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models'
4import { digest, platform, signer, target } from './repository'
5
6/** The same timestamp key survives a snapshot-only authority rotation. All keys are throwaway. */
7export async function epochRepository(directory: string, duplicateSnapshotKey = false) {
8 await mkdir(join(directory, 'metadata'), { recursive: true })
9 const keys = { root: signer(), timestamp: signer(), snapshot: signer(), nextSnapshot: signer(), targets: signer() }
10 const fields = { specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' }
11 function root(version: number) {
12 const data = new Root({ ...fields, version })
13 for (const role of ['root', 'timestamp', 'snapshot', 'targets'] as const) data.addKey(version === 2 && role === 'snapshot' && !duplicateSnapshotKey ? keys.nextSnapshot.key : keys[role].key, role)
14 if (version === 2 && duplicateSnapshotKey) data.roles.snapshot!.keyIDs.push(keys.snapshot.key.keyID)
15 const metadata = new Metadata(data); metadata.sign(keys.root.sign)
16 return Buffer.from(JSON.stringify(metadata.toJSON()))
17 }
18 const rootPath = join(directory, 'anchor.json'), anchor = root(1)
19 await writeFile(rootPath, anchor)
20 async function publish(epoch: 1 | 2, version: number, releaseSequence: number) {
21 if (epoch === 2) await writeFile(join(directory, 'metadata/2.root.json'), root(2))
22 const image = Buffer.from('synthetic epoch image, never execute or flash\n')
23 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench' }
24 const targets = new Metadata(new Targets({ ...fields, version, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: digest(image) }, unrecognizedFields: { custom: { drone: custom } } }) } }))
25 targets.sign(keys.targets.sign)
26 const targetBytes = Buffer.from(JSON.stringify(targets.toJSON()))
27 const snapshot = new Metadata(new Snapshot({ ...fields, version, meta: { 'targets.json': new MetaFile({ version, length: targetBytes.length, hashes: { sha256: digest(targetBytes) } }) } }))
28 snapshot.sign(epoch === 1 || duplicateSnapshotKey ? keys.snapshot.sign : keys.nextSnapshot.sign)
29 const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON()))
30 const timestamp = new Metadata(new Timestamp({ ...fields, version, snapshotMeta: new MetaFile({ version, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) }))
31 timestamp.sign(keys.timestamp.sign)
32 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
33 await writeFile(join(directory, `metadata/${version}.snapshot.json`), snapshotBytes)
34 await writeFile(join(directory, `metadata/${version}.targets.json`), targetBytes)
35 }
36 return { rootPath, rootSha256: digest(anchor), publish }
37}
tests/fixtures/updates/qualify-store.tsadded+59-0
Changes to tests/fixtures/updates/qualify-store.ts
@@ -0,0 +1,59 @@
1import assert from 'node:assert/strict'
2import { constants } from 'node:fs'
3import { createHash } from 'node:crypto'
4import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
5import { join, resolve } from 'node:path'
6import { refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
7import { platform, repository, target } from './repository'
8
9async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
10
11/** Actual copied CLI plus a source-side interrupted input to exercise the same persisted recovery format. */
12export async function qualifyPortableStore(directory: string, source: string) {
13 await mkdir(directory, { mode: 0o700 })
14 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
15 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
16 assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256)
17 const repo = join(directory, 'repo'), fixture = await repository(repo, { custom: { releaseSequence: 9 } })
18 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store')
19 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
20 async function run(args: string[], expected = 0) {
21 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
22 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
23 try {
24 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
25 results.push({ command: args[0]!, exit, stdout, stderr })
26 assert.equal(exit, expected)
27 if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') }
28 return stdout ? JSON.parse(stdout) : undefined
29 }
30 finally { clearTimeout(timer) }
31 }
32 try {
33 const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'])
34 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
35 const created = await run(['updates:store-init', ...flags, '--output', store])
36 assert.equal(created.generation, 0); assert.equal(created.releaseFloor, 7)
37 const pinned = [...flags, '--store-sha256', created.storeSha256]
38 await run(['updates:refresh', store, ...pinned, '--repository', repo], 1)
39 const marker = new Error('Synthetic interruption after timestamp input commit')
40 const rejected = await refreshRuntimeTrustStore({ directory: store, storeSha256: created.storeSha256, enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, repository: repo, clock: { source: 'synthetic-host-current-clock', read: () => new Date() }, onProgress(event) {
41 if (event.phase === 'input-committed' && event.ordinal === 1) throw marker
42 } }).then(() => undefined, error => error)
43 assert.ok(rejected instanceof Error)
44 assert.equal(rejected.cause, marker)
45 const recovered = await run(['updates:recover', store, ...pinned])
46 assert.equal(recovered.generation, 1); assert.equal(recovered.releaseFloor, 7); assert.equal(recovered.attempt.phase, 'recovered')
47 const refreshed = await run(['updates:refresh', store, ...pinned, '--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock'])
48 assert.equal(refreshed.generation, 3); assert.equal(refreshed.releaseFloor, 9); assert.equal(refreshed.attempt.phase, 'verified'); assert.equal(refreshed.physicalDeploymentAllowed, false)
49 assert.deepEqual(await run(['updates:store', store, ...pinned]), refreshed)
50 await run(['updates:store', store, ...flags, '--store-sha256', '0'.repeat(64)], 1)
51 await run(['updates:recover', store, ...pinned], 1)
52 }
53 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-store-v1', sourceSha256, sourceSideInterruption: 'callback after committed input, not a process kill', results, physicalDeploymentAllowed: false }, null, 2)) }
54 return { evidence: 'copied-standalone-store-v1', operations: results.length, sourceSha256, physicalDeploymentAllowed: false }
55}
56if (import.meta.main) {
57 if (process.argv.length !== 4) throw new Error('Use qualify-store.ts NEW_DIRECTORY COMPILED_BINARY')
58 console.log(JSON.stringify(await qualifyPortableStore(resolve(process.argv[2]!), resolve(process.argv[3]!))))
59}
tests/fixtures/updates/repository.tsmodified+1-1
Changes to tests/fixtures/updates/repository.ts
@@ -9,7 +9,7 @@ export const platform = 'pico2-neutral-bench-v1'
99export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
1010const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' }
1111
12function signer(nonCanonical = false) {
12export function signer(nonCanonical = false) {
1313 const pair = generateKeyPairSync('ed25519')
1414 const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex')
1515 const keyObject = { keytype: 'ed25519', scheme: 'ed25519', keyval: { public: publicKey } }
tests/fixtures/updates/store-interruptions.tsadded+94-0
Changes to tests/fixtures/updates/store-interruptions.ts
@@ -0,0 +1,94 @@
1import assert from 'node:assert/strict'
2import { spawnSync } from 'node:child_process'
3import { mkdir, readFile, writeFile } from 'node:fs/promises'
4import { join, resolve } from 'node:path'
5import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
6import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
7import { platform, repository, target } from './repository'
8
9type Reference = Parameters<typeof inspectRuntimeTrustStore>[0]
10type Cut = 'input-committed' | 'metadata-committed'
11interface WorkerInput { reference: Reference, repository: string, cut: Cut }
12const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
13
14async function worker(mode: string, file: string) {
15 const input = JSON.parse(await readFile(file, 'utf8')) as WorkerInput
16 if (mode === 'contend') {
17 try { await inspectRuntimeTrustStore(input.reference); process.exitCode = 18 }
18 catch (error) {
19 const code = (error as NodeJS.ErrnoException).code
20 console.log(JSON.stringify({ blocked: code === 'SQLITE_BUSY', code }))
21 process.exitCode = code === 'SQLITE_BUSY' ? 17 : 19
22 }
23 return
24 }
25 if (mode !== 'hold') throw new Error('Unknown interruption worker mode')
26 await refreshRuntimeTrustStore({ ...input.reference, repository: input.repository, clock, async onProgress(event) {
27 if (event.phase !== input.cut || event.ordinal !== (input.cut === 'input-committed' ? 1 : 0)) return
28 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
29 await Bun.stdin.text()
30 } })
31 throw new Error('Interruption worker unexpectedly finished')
32}
33
34/** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */
35export async function qualifyStoreInterruptions(directory: string) {
36 await mkdir(directory, { mode: 0o700 })
37 const results = []
38 for (const cut of ['input-committed', 'metadata-committed'] as const) {
39 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
40 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
41 const enrollmentDirectory = join(root, 'enrollment')
42 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
43 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
44 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
45 const file = join(root, 'worker.json')
46 await writeFile(file, JSON.stringify({ reference, repository: source, cut } satisfies WorkerInput), { mode: 0o600 })
47 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
48 const timer = setTimeout(() => child.kill('SIGKILL'), 15000)
49 const reader = child.stdout.getReader(), decoder = new TextDecoder()
50 let stdout = ''
51 try {
52 while (!stdout.includes('\n')) {
53 const part = await reader.read()
54 if (part.done) throw new Error('Interruption worker exited before readiness')
55 stdout += decoder.decode(part.value, { stream: true })
56 if (stdout.length > 4096) throw new Error('Interruption worker output exceeded its bound')
57 }
58 const ready = JSON.parse(stdout.trim())
59 assert.equal(ready.ready, true); assert.equal(ready.phase, cut)
60 const contender = spawnSync(process.execPath, [import.meta.path, 'contend', file], { encoding: 'utf8', timeout: 10000 })
61 assert.equal(contender.status, 17)
62 assert.equal(JSON.parse(contender.stdout).blocked, true)
63 child.kill('SIGKILL')
64 const exit = await child.exited, signal = child.signalCode
65 assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0)
66 const interrupted = await inspectRuntimeTrustStore(reference)
67 assert.equal(interrupted.attempt?.phase, 'pending')
68 assert.equal(interrupted.generation, cut === 'input-committed' ? 0 : 1)
69 const recovered = await recoverRuntimeTrustStore(reference)
70 assert.equal(recovered.generation, 1); assert.equal(recovered.attempt?.phase, 'recovered')
71 assert.equal(recovered.releaseFloor, 7)
72 const fresh = await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
73 assert.equal(fresh.generation, 3); assert.equal(fresh.releaseFloor, 9)
74 assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false)
75 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, interrupted, recovered, fresh })
76 }
77 finally {
78 clearTimeout(timer)
79 if (child.exitCode === null) child.kill('SIGKILL')
80 await child.exited
81 reader.releaseLock()
82 await writeFile(join(root, 'worker.stdout'), stdout)
83 await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text())
84 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', results, physicalDeploymentAllowed: false }, null, 2))
85 }
86 }
87 return { evidence: 'host-process-interruption-only', cuts: results.length, physicalDeploymentAllowed: false }
88}
89
90if (import.meta.main) {
91 if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
92 else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!))))
93 else throw new Error('Use store-interruptions.ts NEW_DIRECTORY')
94}
tests/unit/updates-store-cli.test.tsadded+38-0
Changes to tests/unit/updates-store-cli.test.ts
@@ -0,0 +1,38 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { repository, platform, target } from '../fixtures/updates/repository'
8
9const roots: string[] = []
10afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
11
12test('native store commands require independent pins and an explicit trusted system-clock source', async () => {
13 const root = await mkdtemp(join(tmpdir(), 'drone-store-cli-')); roots.push(root)
14 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
15 const enrollment = join(root, 'enrollment'), output = join(root, 'store')
16 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollment })
17 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
18 const initialized = await command(['updates:store-init', ...flags, '--output', output]).withTimeout(20000)
19 expect(initialized.exitCode).toBe(0)
20 const created = JSON.parse(initialized.stdout)
21 expect(created.releaseFloor).toBe(7)
22 const pinned = [...flags, '--store-sha256', created.storeSha256]
23 const missingClock = await command(['updates:refresh', output, ...pinned, '--repository', source]).withTimeout(20000)
24 expect(missingClock.exitCode).toBe(1)
25 expect(missingClock.stdout).toBe('')
26 const refreshed = await command(['updates:refresh', output, ...pinned, '--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000)
27 expect(refreshed.exitCode).toBe(0)
28 const value = JSON.parse(refreshed.stdout)
29 expect(value.generation).toBe(3)
30 expect(value.releaseFloor).toBe(9)
31 expect(value.physicalDeploymentAllowed).toBe(false)
32 const inspected = await command(['updates:store', output, ...pinned]).withTimeout(20000)
33 expect(inspected.exitCode).toBe(0)
34 expect(JSON.parse(inspected.stdout)).toEqual(value)
35 const wrongPin = await command(['updates:store', output, ...flags, '--store-sha256', '0'.repeat(64)]).withTimeout(20000)
36 expect(wrongPin.exitCode).toBe(1)
37 expect(wrongPin.stdout).toBe('')
38}, 120000)
tests/unit/updates-store.test.tsadded+236-0
Changes to tests/unit/updates-store.test.ts
@@ -0,0 +1,236 @@
1import { afterEach, expect, test } from 'bun:test'
2import { Database } from 'bun:sqlite'
3import { DownloadHTTPError, MetadataVerificationError } from 'tuf-js'
4import { mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
5import { tmpdir } from 'node:os'
6import { join } from 'node:path'
7import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
8import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
9import { platform, repository, target } from '../fixtures/updates/repository'
10import { qualifyStoreInterruptions } from '../fixtures/updates/store-interruptions'
11import { epochRepository } from '../fixtures/updates/epochs'
12
13const roots: string[] = []
14afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
15
16async function setup(options: Parameters<typeof repository>[1] = {}) {
17 const root = await mkdtemp(join(tmpdir(), 'drone-update-store-')); roots.push(root)
18 const source = join(root, 'repository'), fixture = await repository(source, options)
19 const enrollmentDirectory = join(root, 'enrollment')
20 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
21 return { root, source, fixture, enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }
22}
23
24test('a new protocol store binds the independently pinned enrollment and retains its initial floors', async () => {
25 const item = await setup(), created = await createRuntimeTrustStore(item)
26 expect(created.enrollmentSha256).toBe(item.enrollmentSha256)
27 expect(created.releaseFloor).toBe(7)
28 expect(created.clockFloorMs).toBe(0)
29 expect(created.generation).toBe(0)
30 expect(created.epoch).toBe(0)
31 expect(created.metadata).toEqual([{ role: 'root', version: 1, sha256: item.fixture.rootSha256 }])
32 expect(created.attempt).toBeNull()
33 expect(created.physicalDeploymentAllowed).toBe(false)
34 expect(await inspectRuntimeTrustStore({ ...item, directory: item.output, storeSha256: created.storeSha256 })).toEqual(created)
35 expect((await stat(item.output)).mode & 0o777).toBe(0o700)
36 for (const file of ['store.json', 'writer.sqlite', 'state.sqlite']) expect((await stat(join(item.output, file))).mode & 0o777).toBe(0o600)
37 await expect(createRuntimeTrustStore(item)).rejects.toThrow()
38})
39
40test('signed refresh commits metadata and release floors that independent readback retains', async () => {
41 const item = await setup({ custom: { releaseSequence: 9 } }), created = await createRuntimeTrustStore(item)
42 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
43 const time = new Date('2030-01-01T00:00:00Z')
44 const result = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-independent-clock', read: () => time } })
45 expect(result.generation).toBe(3)
46 expect(result.releaseFloor).toBe(9)
47 expect(result.clockFloorMs).toBe(time.getTime())
48 expect(result.metadata.map(row => [row.role, row.version])).toEqual([['root', 1], ['snapshot', 1], ['targets', 1], ['timestamp', 1]])
49 expect(result.attempt?.phase).toBe('verified')
50 expect(result.physicalDeploymentAllowed).toBe(false)
51 expect(await inspectRuntimeTrustStore(reference)).toEqual(result)
52})
53
54test('a later signature failure preserves the accepted prefix and blocks a fresh attempt', async () => {
55 const item = await setup({ badTargetsSignature: true }), created = await createRuntimeTrustStore(item)
56 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
57 const clock = { source: 'test-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
58 await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock })).rejects.toThrow()
59 const result = await inspectRuntimeTrustStore(reference)
60 expect(result.generation).toBe(2)
61 expect(result.metadata.map(row => row.role)).toEqual(['root', 'snapshot', 'timestamp'])
62 expect(result.releaseFloor).toBe(7)
63 expect(result.attempt?.phase).toBe('maintenance')
64 await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock })).rejects.toThrow('requires recovery')
65 await expect(recoverRuntimeTrustStore(reference)).rejects.toThrow()
66 expect(await inspectRuntimeTrustStore(reference)).toEqual(result)
67})
68
69test('reversed clock and a wrong independent store pin cannot mutate an accepted state', async () => {
70 const item = await setup(), created = await createRuntimeTrustStore(item)
71 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
72 const result = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } })
73 await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2029-01-01T00:00:00Z') } })).rejects.toThrow('reversed')
74 await expect(inspectRuntimeTrustStore({ ...reference, storeSha256: '0'.repeat(64) })).rejects.toThrow('pin mismatch')
75 expect(await inspectRuntimeTrustStore(reference)).toEqual(result)
76})
77
78test('missing or corrupt protocol state is preserved and never recreated from the marker', async () => {
79 for (const missing of [false, true]) {
80 const item = await setup(), created = await createRuntimeTrustStore(item)
81 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
82 const path = join(item.output, 'state.sqlite'), original = await readFile(path)
83 if (missing) await unlink(path)
84 else await writeFile(path, Buffer.from('corrupt protocol state'))
85 await expect(inspectRuntimeTrustStore(reference)).rejects.toThrow()
86 await expect(createRuntimeTrustStore(item)).rejects.toThrow()
87 if (missing) expect(await Bun.file(path).exists()).toBe(false)
88 else expect(await readFile(path)).toEqual(Buffer.from('corrupt protocol state'))
89 await writeFile(path, original, { mode: 0o600 })
90 expect(await inspectRuntimeTrustStore(reference)).toEqual(created)
91 }
92})
93
94test('recovery verifies a journaled input that was never delivered, without consulting the repository', async () => {
95 const item = await setup({ custom: { releaseSequence: 9 } }), created = await createRuntimeTrustStore(item)
96 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
97 const clock = { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
98 await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock, onProgress(event) {
99 if (event.phase === 'input-committed' && event.ordinal === 1) throw new Error('Interrupted before timestamp delivery')
100 } })).rejects.toThrow('Update input progress observer rejected')
101 expect((await inspectRuntimeTrustStore(reference)).generation).toBe(0)
102 await rename(item.source, join(item.root, 'offline'))
103 const recovered = await recoverRuntimeTrustStore(reference)
104 expect(recovered.attempt?.phase).toBe('recovered')
105 expect(recovered.generation).toBe(1)
106 expect(recovered.metadata.map(row => row.role)).toEqual(['root', 'timestamp'])
107 expect(recovered.clockFloorMs).toBe(clock.read().getTime())
108 expect(recovered.releaseFloor).toBe(7)
109 expect(recovered.physicalDeploymentAllowed).toBe(false)
110 await rename(join(item.root, 'offline'), item.source)
111 const fresh = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock })
112 expect(fresh.generation).toBe(3)
113 expect(fresh.releaseFloor).toBe(9)
114 expect(fresh.attempt?.phase).toBe('verified')
115})
116
117test('real killed writers retain durable inputs and prefixes while contenders stay excluded', async () => {
118 const root = await mkdtemp(join(tmpdir(), 'drone-store-host-cuts-')); roots.push(root)
119 const result = await qualifyStoreInterruptions(join(root, 'campaign'))
120 expect(result.cuts).toBe(2)
121 expect(result.physicalDeploymentAllowed).toBe(false)
122}, 45000)
123
124test('a signed target rejected by release policy can finish recovery without lowering any floor', async () => {
125 const item = await setup({ custom: { releaseSequence: 6 } }), created = await createRuntimeTrustStore(item)
126 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
127 await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } })).rejects.toThrow('below the trusted minimum')
128 expect((await inspectRuntimeTrustStore(reference)).generation).toBe(3)
129 const result = await recoverRuntimeTrustStore(reference)
130 expect(result.attempt?.phase).toBe('recovered')
131 expect(result.generation).toBe(3)
132 expect(result.releaseFloor).toBe(7)
133 expect(result.physicalDeploymentAllowed).toBe(false)
134})
135
136test('a foreign database is rejected without changing its journal format or bytes', async () => {
137 const item = await setup(), created = await createRuntimeTrustStore(item)
138 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
139 const foreign = join(item.root, 'foreign.sqlite'), db = new Database(foreign)
140 try { db.exec('PRAGMA journal_mode=WAL; CREATE TABLE foreign_data(value TEXT); INSERT INTO foreign_data VALUES(\'preserve me\')') }
141 finally { db.close() }
142 const before = await readFile(foreign), path = join(item.output, 'state.sqlite')
143 await writeFile(path, before)
144 await expect(inspectRuntimeTrustStore(reference)).rejects.toThrow()
145 expect((await readFile(path)).equals(before)).toBe(true)
146})
147
148test('authenticated snapshot-key rotation permits a new metadata epoch while retaining the global release floor', async () => {
149 const root = await mkdtemp(join(tmpdir(), 'drone-store-epochs-')); roots.push(root)
150 const source = join(root, 'repo'), fixture = await epochRepository(source)
151 const enrollmentDirectory = join(root, 'enrollment')
152 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
153 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: '' }
154 reference.storeSha256 = (await createRuntimeTrustStore({ ...reference, output: reference.directory })).storeSha256
155 const clock = { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
156 await fixture.publish(1, 100, 9)
157 const before = await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
158 expect(before.releaseFloor).toBe(9); expect(before.epoch).toBe(0)
159 await fixture.publish(2, 1, 8)
160 await expect(refreshRuntimeTrustStore({ ...reference, repository: source, clock })).rejects.toThrow('below the trusted minimum')
161 const after = await inspectRuntimeTrustStore(reference)
162 expect(after.epoch).toBe(1)
163 expect(after.metadata.map(row => [row.role, row.version])).toEqual([['root', 2], ['snapshot', 1], ['targets', 1], ['timestamp', 1]])
164 expect(after.releaseFloor).toBe(9)
165 expect(after.generation).toBe(7)
166 expect(after.retainedMetadataRecords).toBe(8)
167 const recovered = await recoverRuntimeTrustStore(reference)
168 expect(recovered.attempt?.phase).toBe('recovered'); expect(recovered.releaseFloor).toBe(9)
169 await fixture.publish(2, 2, 10)
170 const fresh = await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
171 expect(fresh.epoch).toBe(1); expect(fresh.releaseFloor).toBe(10); expect(fresh.generation).toBe(10)
172 expect(fresh.retainedMetadataRecords).toBe(11)
173})
174
175test('a clock adapter can be a class instance and is read exactly once', async () => {
176 const item = await setup(), created = await createRuntimeTrustStore(item)
177 class Clock {
178 source = 'test-owned-clock'
179 calls = 0
180 read() { this.calls++; return new Date('2030-01-01T00:00:00Z') }
181 }
182 const clock = new Clock()
183 const result = await refreshRuntimeTrustStore({ ...item, directory: item.output, storeSha256: created.storeSha256, repository: item.source, clock })
184 expect(clock.calls).toBe(1)
185 expect(result.clockFloorMs).toBe(Date.parse('2030-01-01T00:00:00Z'))
186})
187
188test('duplicate role key IDs are rejected before a root can reset metadata floors', async () => {
189 const root = await mkdtemp(join(tmpdir(), 'drone-store-same-authority-')); roots.push(root)
190 const source = join(root, 'repo'), fixture = await epochRepository(source, true)
191 const enrollmentDirectory = join(root, 'enrollment')
192 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
193 const initial = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
194 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: initial.storeSha256 }
195 const clock = { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
196 await fixture.publish(1, 100, 9)
197 await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
198 await fixture.publish(2, 1, 9)
199 const failure = await refreshRuntimeTrustStore({ ...reference, repository: source, clock }).then(() => undefined, error => error)
200 expect(failure).toBeInstanceOf(MetadataVerificationError)
201 expect(failure.role).toBe('root')
202 expect(failure.cause.message).toBe('duplicate key IDs found')
203 const result = await inspectRuntimeTrustStore(reference)
204 expect(result.epoch).toBe(0)
205 expect(result.generation).toBe(3)
206 expect(result.releaseFloor).toBe(9)
207 expect(result.metadata.map(row => [row.role, row.version])).toEqual([['root', 1], ['snapshot', 100], ['targets', 100], ['timestamp', 100]])
208})
209
210for (const status of [404, 403]) {
211 test(`input progress HTTP ${status} rejection cannot masquerade as an absent rotated root`, async () => {
212 const item = await setup({ rotate: true }), created = await createRuntimeTrustStore(item)
213 const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 }
214 const sentinel = new DownloadHTTPError('Progress observer interrupted', status), events: string[] = []
215 const failure = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') }, onProgress(event) {
216 events.push(`${event.phase}:${event.ordinal}`)
217 if (event.phase === 'input-committed' && event.ordinal === 0) throw sentinel
218 } }).then(() => undefined, error => error)
219 expect(failure).toBeInstanceOf(Error)
220 expect(failure).not.toBeInstanceOf(DownloadHTTPError)
221 expect(failure.cause).toBe(sentinel)
222 expect(events).toEqual(['input-committed:0'])
223 const stopped = await inspectRuntimeTrustStore(reference)
224 expect(stopped.generation).toBe(0)
225 expect(stopped.metadata).toEqual(created.metadata)
226 expect(stopped.releaseFloor).toBe(7)
227 expect(stopped.attempt?.phase).toBe('maintenance')
228 await rename(item.source, join(item.root, 'offline'))
229 const recovered = await recoverRuntimeTrustStore(reference)
230 expect(recovered.generation).toBe(1)
231 expect(recovered.metadata.map(row => [row.role, row.version])).toEqual([['root', 2]])
232 expect(recovered.attempt?.phase).toBe('recovered')
233 expect(recovered.releaseFloor).toBe(7)
234 expect(recovered.physicalDeploymentAllowed).toBe(false)
235 })
236}