ReviewOS

also looking at this

stacks/drone

docs: align update acceptance with protected preparation

#48
Merged chris wants to merge codex/docs-update-preparation-status into main
27 files +1676 -74
.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -38,6 +38,10 @@ 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"
43 - name: Verify protected signed-image preparation with a copied standalone CLI
44 run: bun tests/fixtures/updates/qualify-preparation.ts "$RUNNER_TEMP/runtime-preparation-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4145 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4246 working-directory: ${{ runner.temp }}
4347 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+6-4
Changes to README.md
@@ -262,12 +262,14 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/
262262
263263The viewer opens files locally in the browser. The v1 format excludes imagery, location and
264264operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md).
265The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF
265The [signed runtime image workflow](docs/runtime-updates.md) verifies offline TUF
266266candidates through `updates:stage` and `updates:inspect`, with explicit root pins,
267267compatibility and externally supplied release floors. `updates:enroll` and
268`updates:trust` preserve and independently verify an initial root/policy record.
269Physical logging/commissioning, persistent refresh metadata/release floors, image
270installation and known-good rollback remain pending.
268`updates:trust` preserve the initial root/policy record. The independently pinned
269store retains verified metadata, clock/release floors and historical recovery;
270`updates:prepare` authenticates actual image bytes under that protected state.
271Physical logging/commissioning, key revocation/repair, installed/known-good slots
272and image-bound boot/session health remain pending.
271273
272274## Sensor acquisition and replay
273275
app/Commands/UpdatesPrepare.tsadded+21-0
Changes to app/Commands/UpdatesPrepare.ts
@@ -0,0 +1,21 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { prepareRuntimeFirmwareUpdate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:prepare <directory>',
8 description: 'Authenticate a signed image under protected metadata into a new candidate without installing firmware',
9 options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image repository', '--output <path>': 'New exclusive candidate directory', '--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.output !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository, output and independently trusted system-clock source required')
13 await printAsync(JSON.stringify(await prepareRuntimeFirmwareUpdate({ ...storeCommandReference(options, directory), repository: options.repository, output: options.output, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
14 }
15 catch {
16 process.exitCode = 1
17 try { await printErrorAsync('Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure') }
18 catch { /* Preserve failure when the diagnostic pipe also fails. */ }
19 }
20 },
21})
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+10-0
Changes to bin/drone.ts
@@ -23,6 +23,11 @@ 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'
30import UpdatesPrepare from '../app/Commands/UpdatesPrepare'
2631
2732import MissionsSimulate from '../app/Commands/MissionsSimulate'
2833import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -89,6 +94,11 @@ UpdatesStage(app)
8994UpdatesInspect(app)
9095UpdatesEnroll(app)
9196UpdatesTrust(app)
97UpdatesStoreInit(app)
98UpdatesStore(app)
99UpdatesRefresh(app)
100UpdatesRecover(app)
101UpdatesPrepare(app)
92102MissionsSimulate(app)
93103MissionsValidate(app)
94104MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdadded+59-0
Changes to docs/adr/0028-runtime-trust-store.md
@@ -0,0 +1,59 @@
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
50Protected preparation now uses this metadata operation to authenticate actual
51image bytes before and after writing the existing signed-candidate format. The
52same file writer serves standalone staging. Candidate inspection requires a root
53pin from authenticated store readback when preparation uses the current rotated
54root. A candidate receipt supplies no current trust or selection authority.
55
56This v1 database journals metadata, not durable candidate ownership or slot
57transitions. Installed, candidate and known-good slots and health confirmation
58bound to an exact image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
59bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/diagnostics.mdmodified+8-6
Changes to docs/diagnostics.md
@@ -155,12 +155,14 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
155155- Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load.
156156 Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on
157157 the actual Pi/Pico setup. Disabled loads and independent authority remain required.
158- [Offline signed image staging](runtime-updates.md) now authenticates TUF roles,
159 declared target compatibility and a caller-supplied release floor. Protected initial
160 trust enrollment is available; build trust/key lifecycle and persistent
161 metadata/release high-water state, interrupted update
162 recovery and a known-good rollback slot before flashing. Each staging invocation
163 currently starts from the independently supplied root; it is not full anti-rollback.
158- [Signed image staging and protected preparation](runtime-updates.md) authenticate
159 TUF roles, declared compatibility and actual image bytes. Independently pinned
160 enrollment/store records retain metadata and clock/release floors; historical
161 recovery retains floors only. Complete key revocation/repair, installed and
162 known-good slot coordination, current-policy rollback and exact-image boot/session
163 health before flashing. Standalone staging/inspection still need explicit trust
164 and are separate from protected-current-policy selection. Host preparation does
165 not satisfy full update/rollback or hardware anti-rollback acceptance.
164166- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
165167 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
166168
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [offline signed image staging and initial trust enrollment](../runtime-updates.md); full mission correlation, hardware evidence, persistent refresh floors and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors and actual image preparation](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, slot coordination and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/roadmap/issues.jsonmodified+6-2
Changes to docs/roadmap/issues.json
@@ -267,13 +267,17 @@
267267 "resources/components/DiagnosticsReplay.stx",
268268 "docs/diagnostics.md",
269269 "public/diagnostics/runtime-deadline.ndjson",
270 "tests/unit/diagnostics.test.ts"
270 "tests/unit/diagnostics.test.ts",
271 "resources/functions/drone/updates/",
272 "app/Commands/UpdatesPrepare.ts",
273 "docs/runtime-updates.md",
274 "tests/unit/updates-preparation.test.ts"
271275 ],
272276 "remaining": [
273277 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
274278 "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence",
275279 "Measured board logging deadlines, memory, rotation and long-mission retention",
276 "Signed firmware updates, trust/key lifecycle, anti-rollback and known-good recovery slot",
280 "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health",
277281 "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence"
278282 ]
279283 },
docs/runtime-updates.mdmodified+190-22
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 with protected image preparation. These
6commands prepare and verify host state and actual candidate image bytes. Key
7revocation/repair, installed images, boot selection and known-good rollback slots
8remain part of the full coordinator. Physical deployment is disabled.
89
910## Explicit trust and policy
1011
@@ -78,19 +79,182 @@ 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 standalone and still report
140`trustStatePersisted: false`. The protected `updates:prepare` path below shares
141that candidate file format and actual-image readback verification.
142
143Timestamp or snapshot authority rotation, authenticated through sequential root
144metadata, creates a new active epoch and invalidates both working-cache entries.
145Historical epochs remain in the database. A test changes only the snapshot key,
146keeps the timestamp key, accepts metadata counters dropping from 100 to 1 under
147the new authority, and still rejects a release below the global release floor.
148Changing only the root signing key does not reset active role floors.
149
150### Clock and interrupted attempts
151
152The function interface requires a `RuntimeUpdateClock` with a source label and
153`read(): Date`. The adapter must obtain independently trusted current wall time;
154there is no repository-supplied time or default adapter. Read once, copy that
155instant, and reject a decrease from the persisted clock floor. The native command
156uses the system UTC clock only with the explicit source option above. That name
157is an audit label, not an attestation that the system clock is synchronized or
158protected. Configure and verify that trust externally before invoking it. Trusted
159clock acquisition and hardware clock/storage rollback protection remain separate
160requirements. Control-loop freshness keeps its own monotonic clock.
161
162A pending or maintenance attempt blocks another refresh. Recovery reconstructs
163its exact base cache, replays only durable inputs at the recorded historical time,
164and compares every earlier accepted observation with its committed bytes. It can
165finish at an owned pre-delivery end-of-recorded-input marker, or after complete
166verification followed by a known rejection from our explicit manifest policy.
167The latter retains all metadata and the release floor without crediting a rejected
168release. Signature, crypto, input-integrity, persistence and unknown SDK errors do
169not become permission to discard a journal: they remain maintenance conditions.
170No automatic repair for those conditions or corrupt state is supplied yet.
171Preserve the directory for investigation.
172
173Recovery can advance floors from a previously journaled valid input that never
174reached the SDK before interruption. Its `recovered` status never authorizes an
175image; a new trusted-current-time verification is required. Host tests perform
176actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a
177competing process, restart and retain the timestamp before a fresh refresh. These
178cuts do not cover every write/sync boundary or actual physical power loss.
179
180The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
181state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per
182file. Exhaustion fails closed; history is not automatically deleted or compacted.
183Capacity migration, the full storage-fault campaign, authenticated repair/key
184revocation, durable slot coordination and session-bound health remain in #42.
185
186## Protected signed-image preparation
187
188Use the pinned store and independently trusted current clock to authenticate an
189actual image into a new candidate directory:
190
191```sh
192./buddy updates:prepare /path/to/store --enrollment /path/to/trust \
193 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
194 --repository /path/to/offline-repository --output /path/to/new-candidate \
195 --trusted-system-clock-source operator-managed-utc-clock
196```
197
198The library function is `prepareRuntimeFirmwareUpdate`. Native Buddy and the
199compiled CLI use the same function. One writer lease covers metadata verification,
200release-floor commit, image verification and candidate output. It uses one SDK
201metadata pass with the protected cache and journal. It does not perform a second
202unjournaled refresh through the standalone stager.
203
204The signed release floor is committed before reading the image. A bad hash,
205missing image or later output failure cannot reopen a release downgrade. The SDK
206checks actual payload length and SHA-256 before writing, then checks the actual
207written `image.bin` again. The shared candidate file writer emits the same
208`stage.json` format as standalone staging only after repository files and image
209readback complete. Its file helpers do not grant firmware-selection authority.
210
211The candidate's `trust-root.json` is the currently authenticated protected root,
212which can differ from the immutable enrollment root after rotation. Its metadata
213archive includes protected active roles, including cached roles that this attempt
214did not need to fetch again. Both consistent-snapshot filename modes are supported.
215For independent standalone inspection, obtain the current root hash and release
216floor from independently pinned store readback, never from a candidate receipt.
217The candidate does not need to replay the entire initial root chain: the protected
218store has already authenticated that chain and retains its history.
219
220The result schema is `drone-runtime-prepared-image-v1`, with `store` status,
221`candidate` declaration, `trustStatePersisted: true` and
222`physicalDeploymentAllowed: false`. The nested standalone candidate declaration
223still has `trustStatePersisted: false`: that portable receipt is not the protected
224store or current authorization. A successful preparation authenticates these
225candidate bytes at this operation's trusted time. It does not install, select,
226boot or confirm them, and does not promise future trust/expiry policy will allow
227selection. Durable candidate ownership, installed/known-good slots and current
228policy revalidation before selection remain coordinator work in #42.
229
230Image or candidate-output failure during an attempt leaves accepted metadata and
231release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never
232reconstructs image success or confirms an existing candidate. Preserve partial
233outputs and use a new output directory for a fresh preparation after recovery.
234A complete candidate marker can exist when a later callback or process failure
235prevents the operation returning. Marker existence alone is not authorization.
236Repository, enrollment and protocol-store directories cannot contain the candidate
237output or be contained by it. Existing output directories are not overwritten.
238
239Host tests kill actual competing-writer-excluded processes at `image-written`
240and `candidate-completed` progress points. The first follows image-file sync;
241the second follows the shared file writer's final marker and directory syncs.
242After restart, metadata recovery retains release floor 9 and returns only a store
243summary; a fresh preparation uses another new directory. These measured process
244cuts do not establish every write/sync boundary, electrical power-loss behavior
245or hardware anti-rollback.
246
85247## Repository and signed image contract
86248
87249Use a standard TUF repository with `metadata/` and `targets/` directories,
88250including consistent-snapshot names when enabled. Verification uses the pinned
89251[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
90252rotation, signature thresholds, expiration, inter-role versions/hashes and
91target length/hashes. A private, randomly addressed loopback server supplies
92bounded local files to the client's public HTTP interface. It is stopped after
93verification. No external network fetch or arbitrary URL option is provided.
253target length/hashes. Standalone staging/inspection use a private, randomly
254addressed loopback server supplying bounded local files to the public HTTP
255interface, then stop it. Protected refresh/preparation use the public Fetcher
256interface with bounded local files and the durable input journal. Neither path
257provides an external network fetch or arbitrary URL option.
94258The host additionally verifies that root and delegation key IDs equal SHA-256
95259of their canonical key objects, using the upstream canonical JSON library.
96260This rejects arbitrary key-ID aliases before role verification.
@@ -138,11 +302,14 @@ phase error alone does not prove that input was invalid, that a prefix is safe
138302to discard, or that recovery may continue. Unknown crypto, resource or other
139303causes must fail closed pending investigation. Error objects and type names do
140304not establish authenticated trust or permit firmware use. The recovery
141coordinator and its full failure classification remain to be implemented.
305coordinator's broader failure classification and authenticated maintenance repair
306remain to be implemented. The metadata protocol above only recognizes its own
307pre-delivery replay marker and completed domain-policy rejection.
142308
143309The seam alone does not persist a protected prefix or make SDK cache writes
144310transactional. Its implementation and tests are a prerequisite for the protected
145store and recovery coordinator in issue #42. They do not complete that issue or
311store and recovery coordinator in issue #42. The bounded metadata protocol above
312uses that seam; neither completes the full coordinator or
146313permit flashing, boot selection or physical deployment.
147314
148315The selected target must have SHA-256, a nonzero length of at most 16 MiB and
@@ -190,17 +357,18 @@ treat marker existence alone as permission to install or boot it.
190357
191358## Remaining update and operations acceptance
192359
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
203health confirmation and rollback without lowering security floors. Then add
360The separate image-stage/inspection path still starts from its supplied root.
361Its `trustStatePersisted: false` result means it does not use the metadata store's
362cross-invocation floors. The caller must supply its trusted release floor each
363time. Neither path is a complete anti-rollback coordinator. Trusted clock
364acquisition and protection against privileged clock/storage rollback remain
365open. Control-loop freshness still uses its separate monotonic clock.
366
367Initial enrollment, the bounded persistent metadata protocol and protected
368signed-image preparation are available above. Complete authenticated
369maintenance/key revocation and the full storage-fault campaign, then durable
370candidate ownership, installed/known-good slot integrity, explicit health
371confirmation and rollback without lowering security floors. Then add
204372the board bootloader/install protocol and measured power-loss, full-medium,
205373storage-latency and recovery campaigns. Local privileged filesystem tampering
206374is outside this host module's protection; it is not secure boot or hardware
resources/functions/drone/updates/candidate-files.tsadded+36-0
Changes to resources/functions/drone/updates/candidate-files.ts
@@ -0,0 +1,36 @@
1import { mkdirSync, realpathSync } from 'node:fs'
2import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3import { Readable } from 'node:stream'
4import type { TargetFile } from 'tuf-js'
5import { directory, imageLimit, regularFile, safePath, syncDirectory, syncTree, writeExclusive } from './files'
6
7/** Internal candidate file-format helpers. They do not grant firmware selection authority. */
8export function runtimeCandidateOutput(output: string, excluded: readonly string[]) {
9 const requested = resolve(output), parent = realpathSync(dirname(requested))
10 directory(parent)
11 const destination = join(parent, basename(requested))
12 for (const source of excluded) {
13 const root = realpathSync(source)
14 const child = relative(root, destination), ancestor = relative(destination, root)
15 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!ancestor.startsWith(`..${sep}`) && ancestor !== '..')) throw new Error('Candidate output must be separate from repository and protected trust state')
16 }
17 return destination
18}
19
20/** Caller owns SDK verification. Recheck actual image bytes before writing the completion marker. */
21export async function finishRuntimeCandidate(output: string, info: TargetFile, receipt: unknown, files: ReadonlyMap<string, Buffer>) {
22 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
23 let total = 0
24 for (const [path, bytes] of files) {
25 safePath(path)
26 if (!path.startsWith('metadata/') && !path.startsWith('targets/')) throw new Error('Invalid candidate repository entry')
27 total += bytes.length
28 if (total > 32 * 1024 * 1024 || files.size > 64) throw new Error('Candidate archive budget exceeded')
29 const file = join(output, 'repository', path)
30 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
31 writeExclusive(file, bytes)
32 }
33 syncTree(output)
34 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`))
35 syncDirectory(output); syncDirectory(dirname(output))
36}
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+7-31
Changes to resources/functions/drone/updates/staging.ts
@@ -1,13 +1,14 @@
11import { randomBytes } from 'node:crypto'
22import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
3import { dirname, join, relative, resolve, sep } from 'node:path'
3import { join, resolve } from 'node:path'
44import { 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'
9import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files'
8import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, writeExclusive } from './files'
109import { object, validateMetadata } from './metadata'
10import { admitSignedRuntime } from './manifest'
11import { finishRuntimeCandidate, runtimeCandidateOutput } from './candidate-files'
1112import { admitPolicy } from './policy'
1213import type { RuntimeFirmwarePolicy } from './policy'
1314export type { RuntimeFirmwarePolicy } from './policy'
@@ -30,16 +31,6 @@ export interface RuntimeFirmwareStage {
3031 trustStatePersisted: false
3132}
3233
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}
4334
4435/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
4536async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
@@ -81,7 +72,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string,
8172 await updater.refresh()
8273 const info = await updater.getTargetInfo(policy.target)
8374 if (!info) throw new Error('Selected firmware target is absent')
84 const runtime = signedRuntime(info, policy)
75 const runtime = admitSignedRuntime(info, policy)
8576 if (download) await updater.downloadTarget(info, download)
8677 const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8')))
8778 const rootVersion = object(latest.signed).version
@@ -95,29 +86,14 @@ async function verifyRepository(repository: string, root: Buffer, work: string,
9586/** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */
9687export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> {
9788 const policy = admitPolicy(options)
98 const repository = realpathSync(options.repository), output = resolve(options.output)
89 const repository = realpathSync(options.repository), output = runtimeCandidateOutput(options.output, [repository])
9990 directory(repository)
10091 const root = regularFile(options.trustedRoot, metadataLimit)
10192 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
102 // Resolve the existing parent before creating anything, so a symlinked parent cannot hide overlap.
103 const destination = join(realpathSync(dirname(output)), output.slice(dirname(output).length + 1))
104 const child = relative(repository, destination), parent = relative(destination, repository)
105 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!parent.startsWith(`..${sep}`) && parent !== '..')) throw new Error('Candidate and source repository must be separate')
10693 mkdirSync(output, { mode: 0o700 })
10794 writeExclusive(join(output, 'trust-root.json'), root)
10895 const { result, fetched, info } = await verifyRepository(repository, root, join(output, '.verification'), policy, join(output, 'image.bin'))
109 // Re-read the actual staged bytes, rather than trusting a successful copy or receipt.
110 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
111 for (const [path, bytes] of fetched) {
112 const file = join(output, 'repository', path)
113 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
114 writeExclusive(file, bytes)
115 }
116 syncTree(output)
117 // A final marker means logical completeness. It cannot attest to power-loss durability after fsync.
118 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(result, null, 2)}\n`))
119 syncDirectory(output)
120 syncDirectory(dirname(output))
96 await finishRuntimeCandidate(output, info, result, fetched)
12197 return result
12298}
12399
resources/functions/drone/updates/store.tsadded+462-0
Changes to resources/functions/drone/updates/store.ts
@@ -0,0 +1,462 @@
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 type { TargetFile } from 'tuf-js'
8import { Readable } from 'node:stream'
9import { finishRuntimeCandidate, runtimeCandidateOutput } from './candidate-files'
10import type { RuntimeFirmwareStage } from './staging'
11import { inspectRuntimeTrust } from './enrollment'
12import type { RuntimeTrustSummary } from './enrollment'
13import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files'
14import { object, validateMetadata } from './metadata'
15import { admitSignedRuntime, RuntimeManifestRejected } from './manifest'
16
17const applicationId = 0x44525453
18const maximumGeneration = 10000
19const maximumDatabaseBytes = 128 * 1024 * 1024
20const activeStores = new Set<string>()
21const writerSchema = 'CREATE TABLE writer (id INTEGER PRIMARY KEY CHECK(id = 1), store_id TEXT NOT NULL CHECK(length(store_id) = 32)) STRICT'
22const schema = [
23 `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`,
24 `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`,
25 `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`,
26 `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`,
27]
28
29interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
30interface StoreReference extends TrustReference { directory: string, storeSha256: string }
31interface Binding { schema: 'drone-runtime-store-v1', storeId: string, enrollmentId: string, enrollmentSha256: string }
32interface State { store_id: string, enrollment_sha256: string, generation: number, release_floor: number, clock_floor_ms: number }
33interface MetadataRow { generation: number, attempt_id: string | null, ordinal: number, role: string, version: number, epoch: number, bytes: Uint8Array, sha256: string }
34interface Attempt { id: string, base_generation: number, started_ms: number, clock_source: string, phase: 'pending' | 'verified' | 'recovered' | 'maintenance', accepted_release: number | null }
35export interface RuntimeTrustStoreSummary {
36 schema: 'drone-runtime-store-status-v1'
37 storeId: string
38 storeSha256: string
39 enrollmentSha256: string
40 generation: number
41 epoch: number
42 releaseFloor: number
43 clockFloorMs: number
44 retainedMetadataRecords: number
45 metadata: ReadonlyArray<Readonly<{ role: string, version: number, sha256: string }>>
46 attempt: Readonly<{ id: string, phase: Attempt['phase'], clockSource: string, startedMs: number }> | null
47 physicalDeploymentAllowed: false
48}
49
50function protectedFile(path: string, limit: number, allowEmpty = false) {
51 const stat = lstatSync(path)
52 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')
53 return stat
54}
55function protectedDirectory(path: string) {
56 directory(dirname(path)); directory(path)
57 if ((lstatSync(path).mode & 0o777) !== 0o700) throw new Error('Update-store directory must be private')
58}
59function configure(db: Database) {
60 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;')
61 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) {
62 const result = db.query(`PRAGMA ${name}`).get() as Record<string, unknown> | null
63 if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable')
64 }
65}
66function openDatabase(path: string, expectedSchema: string[]) {
67 const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes)
68 const db = new Database(path, { create: false, strict: true })
69 try {
70 const after = lstatSync(path)
71 if (before.dev !== after.dev || before.ino !== after.ino) throw new Error('Update-store file replaced while opening')
72 db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;')
73 // Authenticate the protocol format before any configuration that could
74 // rewrite a foreign database's journal/header format.
75 checkSchema(db, expectedSchema)
76 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
77 configure(db)
78 return db
79 }
80 catch (error) { db.close(); throw error }
81}
82function checkSchema(db: Database, expected: string[]) {
83 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()
84 if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema')
85 const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get()
86 const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()
87 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')
88}
89function createDatabase(path: string, statements: string[]) {
90 writeExclusive(path, Buffer.alloc(0))
91 const db = new Database(path, { create: false, strict: true })
92 try {
93 configure(db)
94 db.exec(`PRAGMA application_id=${applicationId}; PRAGMA user_version=1;`)
95 db.transaction(() => { for (const sql of statements) db.exec(sql) })()
96 return db
97 }
98 catch (error) { db.close(); throw error }
99}
100function admitBinding(bytes: Buffer, trusted: Readonly<RuntimeTrustSummary>): Binding {
101 const item = object(JSON.parse(bytes.toString('utf8')))
102 const keys = ['schema', 'storeId', 'enrollmentId', 'enrollmentSha256']
103 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')
104 return item as unknown as Binding
105}
106function state(db: Database, binding: Binding) {
107 const rows = db.query<State, []>('SELECT store_id, enrollment_sha256, generation, release_floor, clock_floor_ms FROM store_state').all()
108 if (rows.length !== 1 || rows[0]!.store_id !== binding.storeId || rows[0]!.enrollment_sha256 !== binding.enrollmentSha256) throw new Error('Invalid update-store identity')
109 return rows[0]!
110}
111function activeMetadata(db: Database, generation: number) {
112 const root = db.query<MetadataRow, [number]>("SELECT * FROM metadata WHERE role = 'root' AND generation <= ? ORDER BY generation DESC LIMIT 1").get(generation)
113 if (!root) throw new Error('Missing protected root')
114 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)
115}
116function checkedMetadataBytes(row: MetadataRow) {
117 const bytes = Buffer.from(row.bytes)
118 validateMetadata(bytes)
119 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
120 const kind = ['root', 'timestamp', 'snapshot'].includes(row.role) ? row.role : 'targets'
121 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')
122 return bytes
123}
124function summary(db: Database, binding: Binding, storeSha256: string): Readonly<RuntimeTrustStoreSummary> {
125 const current = state(db, binding), metadata = activeMetadata(db, current.generation)
126 const anchor = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
127 if (!anchor || anchor.role !== 'root' || anchor.attempt_id !== null) throw new Error('Missing initial update-store anchor')
128 for (const row of [anchor, ...metadata]) checkedMetadataBytes(row)
129 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()!
130 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')
131 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()!
132 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')
133 const attempt = db.query<Attempt, []>('SELECT * FROM attempts ORDER BY rowid DESC LIMIT 1').get()
134 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 })
135}
136
137async function withStore<T>(options: StoreReference, operation: (db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string) => T | Promise<T>): Promise<T> {
138 const trusted = inspectRuntimeTrust({ directory: options.enrollmentDirectory, enrollmentSha256: options.enrollmentSha256 })
139 const path = resolve(options.directory)
140 protectedDirectory(path)
141 if (typeof options.storeSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(options.storeSha256)) throw new Error('Independent update-store pin required')
142 protectedFile(join(path, 'store.json'), 16384)
143 const bytes = regularFile(join(path, 'store.json'), 16384)
144 if (sha256(bytes) !== options.storeSha256) throw new Error('Update-store pin mismatch')
145 const binding = admitBinding(bytes, trusted), key = realpathSync(path)
146 if (activeStores.has(key)) throw new Error('Update-store writer already active')
147 activeStores.add(key)
148 let writer: Database | undefined, db: Database | undefined
149 try {
150 for (const name of readdirSync(path)) {
151 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry')
152 if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true)
153 }
154 writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema])
155 writer.exec('BEGIN IMMEDIATE')
156 const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all()
157 if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity')
158 db = openDatabase(join(path, 'state.sqlite'), schema)
159 const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
160 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')
161 summary(db, binding, options.storeSha256)
162 return await operation(db, binding, trusted, path)
163 }
164 finally {
165 try { db?.close() }
166 finally {
167 try { if (writer?.inTransaction) writer.exec('ROLLBACK') }
168 finally { try { writer?.close() } finally { activeStores.delete(key) } }
169 }
170 }
171}
172
173/** Create a new host protocol store. Keep its returned pin independently; existing state is never reset. */
174export async function createRuntimeTrustStore(options: TrustReference & { output: string }): Promise<Readonly<RuntimeTrustStoreSummary>> {
175 const trusted = inspectRuntimeTrust({ directory: options.enrollmentDirectory, enrollmentSha256: options.enrollmentSha256 })
176 const output = resolve(options.output)
177 directory(dirname(output)); mkdirSync(output, { mode: 0o700 })
178 const binding: Binding = { schema: 'drone-runtime-store-v1', storeId: randomBytes(16).toString('hex'), enrollmentId: trusted.enrollmentId, enrollmentSha256: trusted.enrollmentSha256 }
179 const writer = createDatabase(join(output, 'writer.sqlite'), [writerSchema])
180 try { writer.query('INSERT INTO writer VALUES(1, ?)').run(binding.storeId) }
181 finally { writer.close() }
182 const db = createDatabase(join(output, 'state.sqlite'), schema)
183 try {
184 const root = regularFile(join(options.enrollmentDirectory, 'root.json'), metadataLimit)
185 if (sha256(root) !== trusted.policy.trustedRootSha256) throw new Error('Enrolled root changed during store initialization')
186 db.transaction(() => {
187 db.query("INSERT INTO metadata VALUES(0, NULL, 0, 'root', ?, 0, ?, ?)").run(trusted.rootVersion, root, sha256(root))
188 db.query('INSERT INTO store_state VALUES(1, ?, ?, 0, ?, 0)').run(binding.storeId, binding.enrollmentSha256, trusted.policy.minimumReleaseSequence)
189 })()
190 }
191 finally { db.close() }
192 syncDirectory(output)
193 const bytes = Buffer.from(`${JSON.stringify(binding)}\n`), storeSha256 = sha256(bytes)
194 writeExclusive(join(output, 'store.json'), bytes); syncDirectory(output); syncDirectory(dirname(output))
195 return inspectRuntimeTrustStore({ ...options, directory: output, storeSha256 })
196}
197
198/** Inspect protected host state under the same writer exclusion used by update operations. */
199export async function inspectRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> {
200 return withStore(options, (db, binding) => summary(db, binding, options.storeSha256))
201}
202
203export interface RuntimeUpdateClock {
204 /** Audit label for an independently trusted clock. The label itself is not attestation. */
205 source: string
206 read: () => Date
207}
208
209export interface RuntimeUpdateProgress {
210 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
211 ordinal: number
212}
213type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
214interface InputRow { ordinal: number, path: string, status: 200 | 404, bytes: Uint8Array | null, sha256: string | null }
215
216function clockReading(clock: RuntimeUpdateClock, floor: number) {
217 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')
218 const source = clock.source, time = clock.read()
219 const milliseconds = time instanceof Date ? Date.prototype.getTime.call(time) : Number.NaN
220 if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 || milliseconds > 253402300799000 || milliseconds < floor) throw new Error('Invalid or reversed update clock')
221 return { source, time: new Date(milliseconds) }
222}
223function authorities(bytes: Uint8Array) {
224 const roles = object(object(object(JSON.parse(Buffer.from(bytes).toString('utf8'))).signed).roles)
225 return JSON.stringify(['timestamp', 'snapshot'].map(name => {
226 const role = object(roles[name])
227 if (!Array.isArray(role.keyids) || !role.keyids.every(id => typeof id === 'string')) throw new Error('Invalid verified role authority')
228 return { keyids: [...role.keyids].sort(), threshold: role.threshold }
229 }))
230}
231function workingCache(db: Database, generation: number) {
232 const path = mkdtempSync(join(tmpdir(), 'drone-update-refresh-'))
233 try {
234 for (const row of activeMetadata(db, generation)) {
235 if (!/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(row.role)) throw new Error('Unsafe protected metadata role')
236 writeExclusive(join(path, `${row.role}.json`), checkedMetadataBytes(row))
237 }
238 return path
239 }
240 catch (error) { rmSync(path, { recursive: true, force: true }); throw error }
241}
242function invalidateActiveCache(cache: string) {
243 for (const role of ['timestamp', 'snapshot']) {
244 try { unlinkSync(join(cache, `${role}.json`)) }
245 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
246 }
247}
248function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal: number, role: string, bytes: Buffer) {
249 validateMetadata(bytes)
250 if (bytes.length > metadataLimit || !/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(role)) throw new Error('Invalid verified metadata observation')
251 const version = object(object(JSON.parse(bytes.toString('utf8'))).signed).version as number
252 const current = state(db, binding), rows = activeMetadata(db, current.generation), prior = rows.find(row => row.role === role)!
253 const root = rows.find(row => row.role === 'root')!
254 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')
255 const changed = role === 'root' && authorities(root.bytes) !== authorities(bytes), epoch = root.epoch + (changed ? 1 : 0)
256 db.transaction(() => {
257 db.query('INSERT INTO metadata VALUES(?, ?, ?, ?, ?, ?, ?, ?)').run(current.generation + 1, attempt, ordinal, role, version, epoch, bytes, sha256(bytes))
258 db.query('UPDATE store_state SET generation = ? WHERE id = 1').run(current.generation + 1)
259 })()
260 const readback = db.query<MetadataRow, [number]>('SELECT * FROM metadata WHERE generation = ?').get(current.generation + 1)
261 if (!readback || readback.sha256 !== sha256(bytes) || !Buffer.from(readback.bytes).equals(bytes)) throw new Error('Verified metadata commit readback failed')
262 return changed
263}
264
265interface RefreshOptions extends StoreReference { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }
266interface VerifiedSelection {
267 db: Database
268 binding: Binding
269 trusted: Readonly<RuntimeTrustSummary>
270 source: string
271 info: TargetFile
272 runtime: ReturnType<typeof admitSignedRuntime>
273}
274
275async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
276 options = { ...options }
277 return withStore(options, async (db, binding, trusted) => {
278 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
279 const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms)
280 if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached')
281 const source = realpathSync(options.repository); directory(source)
282 const cache = workingCache(db, current.generation), attempt = randomBytes(16).toString('hex')
283 let requests = 0, received = 0, observations = 0
284 try {
285 db.transaction(() => {
286 db.query("INSERT INTO attempts VALUES(?, ?, ?, ?, 'pending', NULL)").run(attempt, current.generation, now.time.getTime(), now.source)
287 db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(now.time.getTime())
288 })()
289 const fetcher = {
290 async downloadBytes(url: string, limit: number) {
291 const address = new URL(url), path = address.pathname.slice('/metadata/'.length)
292 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')
293 safePath(path)
294 let bytes: Buffer | null = null
295 try { bytes = repositoryFile(source, `metadata/${path}`, Math.min(limit, metadataLimit)); validateMetadata(bytes) }
296 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
297 received += bytes?.length ?? 0
298 if (received > 32 * 1024 * 1024) throw new Error('Metadata input budget exceeded')
299 const index = requests++
300 db.transaction(() => db.query('INSERT INTO inputs VALUES(?, ?, ?, ?, ?, ?)').run(attempt, index, path, bytes ? 200 : 404, bytes, bytes ? sha256(bytes) : null))()
301 const saved = db.query<InputRow, [string, number]>('SELECT * FROM inputs WHERE attempt_id = ? AND ordinal = ?').get(attempt, index)
302 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')
303 // Only our absent-file response may enter the SDK's HTTP classification.
304 // Observer failures must abort even when their raw cause is an HTTP error.
305 try { await options.onProgress?.(Object.freeze({ phase: 'input-committed', ordinal: index })) }
306 catch (cause) { throw new Error('Update input progress observer rejected', { cause }) }
307 if (!bytes) throw new DownloadHTTPError('Offline metadata is absent', 404)
308 return Buffer.from(bytes)
309 },
310 async downloadFile(): Promise<never> { throw new Error('Metadata refresh never downloads an image') },
311 }
312 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) {
313 const index = observations++
314 if (commitMetadata(db, binding, attempt, index, role, bytes)) invalidateActiveCache(cache)
315 await options.onProgress?.(Object.freeze({ phase: 'metadata-committed', ordinal: index }))
316 } })
317 await updater.refresh()
318 const info = await updater.getTargetInfo(trusted.policy.target)
319 if (!info) throw new Error('Selected runtime target is absent')
320 const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor })
321 // The signed release floor survives later image or candidate-output failure.
322 db.transaction(() => {
323 db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence)
324 db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt)
325 })()
326 const value = await complete({ db, binding, trusted, source, info, runtime })
327 db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt)
328 return { store: summary(db, binding, options.storeSha256), value }
329 }
330 catch (error) {
331 try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) }
332 catch (persistence) { throw new AggregateError([error, persistence], 'Update failed and maintenance state could not be recorded') }
333 throw error
334 }
335 finally { rmSync(cache, { recursive: true, force: true }) }
336 })
337}
338
339/** Verify bounded offline metadata and retain its accepted prefix. This never downloads an image. */
340export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise<Readonly<RuntimeTrustStoreSummary>> {
341 return (await refreshAndComplete(options, async () => undefined)).store
342}
343
344export interface PreparedRuntimeFirmware {
345 schema: 'drone-runtime-prepared-image-v1'
346 store: Readonly<RuntimeTrustStoreSummary>
347 candidate: Readonly<RuntimeFirmwareStage>
348 trustStatePersisted: true
349 physicalDeploymentAllowed: false
350}
351
352/** Authenticate an image under current protected metadata. Never installs, selects or confirms firmware. */
353export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> {
354 options = { ...options }
355 const output = runtimeCandidateOutput(options.output, [options.repository, options.directory, options.enrollmentDirectory])
356 const result = await refreshAndComplete(options, async ({ db, binding, trusted, source, info, runtime }) => {
357 const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')!
358 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
359 const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path
360 const image = repositoryFile(source, `targets/${path}`, imageLimit)
361 await info.verify(Readable.from(image))
362 const files = new Map<string, Buffer>()
363 for (const row of rows) {
364 if (row.role === 'root') continue
365 files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
366 }
367 files.set(`targets/${path}`, image)
368 const candidate: RuntimeFirmwareStage = { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false }
369 mkdirSync(output, { mode: 0o700 })
370 writeExclusive(join(output, 'trust-root.json'), rootBytes)
371 writeExclusive(join(output, 'image.bin'), image)
372 await options.onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 }))
373 await finishRuntimeCandidate(output, info, candidate, files)
374 await options.onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 }))
375 return Object.freeze(candidate)
376 })
377 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
378}
379
380function causedBy(error: unknown, marker: Error) {
381 const seen = new Set<unknown>()
382 for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) {
383 if (value === marker) return true
384 seen.add(value)
385 }
386 return false
387}
388
389/** Replay an interrupted attempt from its exact base and durable inputs. This only retains floors, never authorizes image use. */
390export async function recoverRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> {
391 options = { ...options }
392 return withStore(options, async (db, binding, trusted) => {
393 const pending = db.query<Attempt, []>("SELECT * FROM attempts WHERE phase IN ('pending', 'maintenance')").all()
394 if (pending.length !== 1) throw new Error('Exactly one unresolved attempt is required for recovery')
395 const attempt = pending[0]!, current = state(db, binding)
396 const inputs = db.query<InputRow, [string]>('SELECT * FROM inputs WHERE attempt_id = ? ORDER BY ordinal').all(attempt.id)
397 const committed = db.query<MetadataRow, [number]>('SELECT * FROM metadata WHERE generation > ? ORDER BY generation').all(attempt.base_generation)
398 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')
399 const cache = workingCache(db, attempt.base_generation), end = new Error('Recorded metadata input boundary reached')
400 let replayRoot = activeMetadata(db, attempt.base_generation).find(row => row.role === 'root')!
401 let delivered = 0, observed = 0, received = 0, release: number | null = null
402 try {
403 const fetcher = {
404 async downloadBytes(url: string, limit: number) {
405 const address = new URL(url), path = address.pathname.slice('/metadata/'.length)
406 if (address.origin !== 'https://offline.invalid' || !address.pathname.startsWith('/metadata/') || address.search || address.hash) throw new Error('Unexpected recovery request')
407 const item = inputs[delivered]
408 if (!item) throw end
409 if (item.path !== path) throw new Error('Recovery request differs from recorded input')
410 delivered++
411 if (item.status === 404) {
412 if (item.bytes !== null || item.sha256 !== null) throw new Error('Invalid recorded missing response')
413 throw new DownloadHTTPError('Recorded offline metadata is absent', 404)
414 }
415 if (!item.bytes) throw new Error('Missing recorded metadata bytes')
416 const bytes = Buffer.from(item.bytes)
417 received += bytes.length
418 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')
419 validateMetadata(bytes)
420 return bytes
421 },
422 async downloadFile(): Promise<never> { throw new Error('Recovery never downloads an image') },
423 }
424 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) {
425 const old = committed[observed], changed = role === 'root' && authorities(replayRoot.bytes) !== authorities(bytes)
426 const epoch = replayRoot.epoch + (changed ? 1 : 0)
427 if (old) {
428 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')
429 }
430 else commitMetadata(db, binding, attempt.id, observed, role, bytes)
431 if (role === 'root') replayRoot = { ...replayRoot, bytes, epoch }
432 if (changed) invalidateActiveCache(cache)
433 observed++
434 } })
435 try {
436 await updater.refresh()
437 const info = await updater.getTargetInfo(trusted.policy.target)
438 if (!info) throw new Error('Selected runtime target is absent during recovery')
439 release = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor }).releaseSequence
440 }
441 catch (error) {
442 // Only this owned pre-delivery marker can end replay without completing
443 // metadata verification. SDK validation/IO/crypto errors stay maintenance.
444 // A policy rejection raised here after complete SDK verification can end
445 // the attempt without crediting a release. SDK/IO failures cannot.
446 if (!causedBy(error, end) && !(error instanceof RuntimeManifestRejected)) throw error
447 }
448 if (delivered !== inputs.length || observed < committed.length) throw new Error('Recovery did not reproduce the full recorded prefix')
449 db.transaction(() => {
450 if (release !== null) db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(release)
451 db.query("UPDATE attempts SET phase = 'recovered', accepted_release = ? WHERE id = ?").run(release, attempt.id)
452 })()
453 return summary(db, binding, options.storeSha256)
454 }
455 catch (error) {
456 try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt.id) }
457 catch (persistence) { throw new AggregateError([error, persistence], 'Recovery failed and maintenance state could not be recorded') }
458 throw error
459 }
460 finally { rmSync(cache, { recursive: true, force: true }) }
461 })
462}
tests/fixtures/updates/epochs.tsadded+41-0
Changes to tests/fixtures/updates/epochs.ts
@@ -0,0 +1,41 @@
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 imageHash = digest(image), imagePath = join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`)
24 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
25 await writeFile(imagePath, image)
26 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench' }
27 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 } } }) } }))
28 targets.sign(keys.targets.sign)
29 const targetBytes = Buffer.from(JSON.stringify(targets.toJSON()))
30 const snapshot = new Metadata(new Snapshot({ ...fields, version, meta: { 'targets.json': new MetaFile({ version, length: targetBytes.length, hashes: { sha256: digest(targetBytes) } }) } }))
31 snapshot.sign(epoch === 1 || duplicateSnapshotKey ? keys.snapshot.sign : keys.nextSnapshot.sign)
32 const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON()))
33 const timestamp = new Metadata(new Timestamp({ ...fields, version, snapshotMeta: new MetaFile({ version, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) }))
34 timestamp.sign(keys.timestamp.sign)
35 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
36 await writeFile(join(directory, `metadata/${version}.snapshot.json`), snapshotBytes)
37 await writeFile(join(directory, `metadata/${version}.targets.json`), targetBytes)
38 return { image, imageHash, imagePath }
39 }
40 return { rootPath, rootSha256: digest(anchor), publish }
41}
tests/fixtures/updates/qualify-preparation.tsadded+61-0
Changes to tests/fixtures/updates/qualify-preparation.ts
@@ -0,0 +1,61 @@
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 { platform, repository, target } from './repository'
7
8async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
9
10/** Exercise actual copied standalone commands. Signed payloads are synthetic and are never executed. */
11export async function qualifyPortablePreparation(directory: string, source: string) {
12 await mkdir(directory, { mode: 0o700 })
13 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
14 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
15 assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256)
16 const repo = join(directory, 'repo'), fixture = await repository(repo, { rotate: true, custom: { releaseSequence: 9 } })
17 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store'), output = join(directory, 'candidate')
18 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
19 async function run(args: string[], expected = 0) {
20 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
21 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
22 try {
23 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
24 results.push({ command: args[0]!, exit, stdout, stderr }); assert.equal(exit, expected)
25 if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure\n') }
26 return stdout ? JSON.parse(stdout) : undefined
27 }
28 finally { clearTimeout(timer) }
29 }
30 try {
31 const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'])
32 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
33 const created = await run(['updates:store-init', ...flags, '--output', store])
34 const pinned = [...flags, '--store-sha256', created.storeSha256]
35 const repositoryFlags = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']
36 await run(['updates:prepare', store, ...pinned, '--repository', repo, '--output', join(directory, 'missing-clock')], 1)
37 const prepared = await run(['updates:prepare', store, ...pinned, ...repositoryFlags, '--output', output])
38 assert.equal(prepared.store.releaseFloor, 9); assert.equal(prepared.store.attempt.phase, 'verified')
39 assert.equal(prepared.candidate.sha256, fixture.imageHash); assert.equal(prepared.trustStatePersisted, true); assert.equal(prepared.physicalDeploymentAllowed, false)
40 const root = prepared.store.metadata.find((row: { role: string }) => row.role === 'root')
41 assert.equal(root.version, 2); assert.equal(prepared.candidate.trustedRootSha256, root.sha256)
42 assert.deepEqual(await run(['updates:inspect', output, '--target', target, '--platform', platform, '--root-sha256', root.sha256, '--minimum-release-sequence', '9']), prepared.candidate)
43 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
44 await run(['updates:prepare', store, ...pinned, ...repositoryFlags, '--output', join(directory, 'bad-image')], 1)
45 const stopped = await run(['updates:store', store, ...pinned])
46 assert.equal(stopped.releaseFloor, 9); assert.equal(stopped.attempt.phase, 'maintenance')
47 const recovered = await run(['updates:recover', store, ...pinned])
48 assert.equal(recovered.releaseFloor, 9); assert.equal(recovered.attempt.phase, 'recovered'); assert.equal(Object.hasOwn(recovered, 'candidate'), false); assert.equal(recovered.physicalDeploymentAllowed, false)
49 await writeFile(fixture.imagePath, fixture.image)
50 const fresh = await run(['updates:prepare', store, ...pinned, ...repositoryFlags, '--output', join(directory, 'fresh-candidate')])
51 assert.equal(fresh.candidate.sha256, fixture.imageHash); assert.equal(fresh.store.releaseFloor, 9); assert.equal(fresh.physicalDeploymentAllowed, false)
52 await run(['updates:prepare', store, ...flags, '--store-sha256', '0'.repeat(64), ...repositoryFlags, '--output', join(directory, 'wrong-pin')], 1)
53 assert.deepEqual(await run(['updates:store', store, ...pinned]), fresh.store)
54 }
55 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-preparation-v1', sourceSha256, results, physicalDeploymentAllowed: false }, null, 2)) }
56 return { evidence: 'copied-standalone-preparation-v1', operations: results.length, sourceSha256, physicalDeploymentAllowed: false }
57}
58if (import.meta.main) {
59 if (process.argv.length !== 4) throw new Error('Use qualify-preparation.ts NEW_DIRECTORY COMPILED_BINARY')
60 console.log(JSON.stringify(await qualifyPortablePreparation(resolve(process.argv[2]!), resolve(process.argv[3]!))))
61}
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+9-8
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 } }
@@ -19,18 +19,18 @@ function signer(nonCanonical = false) {
1919}
2020
2121/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
22export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
22export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, consistentSnapshot?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
2323 await mkdir(join(directory, 'metadata'), { recursive: true })
2424 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
2525 const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() }
26 const root = new Root({ ...fields, ...options.metadataFields?.root })
26 const root = new Root({ ...fields, ...options.metadataFields?.root, consistentSnapshot: options.consistentSnapshot ?? true })
2727 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role)
2828 const rootMetadata = new Metadata(root)
2929 rootMetadata.sign(keys.root.sign)
3030 const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON()))
3131 await writeFile(join(directory, 'trusted-root.json'), rootBytes)
3232 if (options.rotate) {
33 const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2, ...options.metadataFields?.rotatedRoot })
33 const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2, ...options.metadataFields?.rotatedRoot, consistentSnapshot: options.consistentSnapshot ?? true })
3434 next.addKey(nextKey.key, 'root')
3535 for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role)
3636 const rotated = new Metadata(next)
@@ -54,8 +54,9 @@ export async function repository(directory: string, options: { custom?: Record<s
5454 const timestamp = new Metadata(new Timestamp({ ...fields, expires: options.expires ?? fields.expires, ...options.metadataFields?.timestamp, snapshotMeta: new MetaFile({ version: 1, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) }))
5555 timestamp.sign(keys.timestamp.sign)
5656 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
57 await writeFile(join(directory, 'metadata/1.snapshot.json'), snapshotBytes)
58 await writeFile(join(directory, 'metadata/1.targets.json'), targetsBytes)
59 await writeFile(join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`), image)
60 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath: join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`) }
57 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/snapshot.json' : 'metadata/1.snapshot.json'), snapshotBytes)
58 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/targets.json' : 'metadata/1.targets.json'), targetsBytes)
59 const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${imageHash}.pico2-bench.bin`)
60 await writeFile(imagePath, image)
61 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath }
6162}
tests/fixtures/updates/store-interruptions.tsadded+112-0
Changes to tests/fixtures/updates/store-interruptions.ts
@@ -0,0 +1,112 @@
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, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
7import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store'
8import { platform, repository, target } from './repository'
9
10type Reference = Parameters<typeof inspectRuntimeTrustStore>[0]
11type Cut = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string }
13const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
14
15async function worker(mode: string, file: string) {
16 const input = JSON.parse(await readFile(file, 'utf8')) as WorkerInput
17 if (mode === 'contend') {
18 try { await inspectRuntimeTrustStore(input.reference); process.exitCode = 18 }
19 catch (error) {
20 const code = (error as NodeJS.ErrnoException).code
21 console.log(JSON.stringify({ blocked: code === 'SQLITE_BUSY', code }))
22 process.exitCode = code === 'SQLITE_BUSY' ? 17 : 19
23 }
24 return
25 }
26 if (mode !== 'hold') throw new Error('Unknown interruption worker mode')
27 const options = { ...input.reference, repository: input.repository, clock, async onProgress(event: Readonly<RuntimeUpdateProgress>) {
28 if (event.phase !== input.cut || event.ordinal !== (input.cut === 'input-committed' ? 1 : 0)) return
29 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
30 await Bun.stdin.text()
31 } }
32 if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
33 else await refreshRuntimeTrustStore(options)
34 throw new Error('Interruption worker unexpectedly finished')
35}
36
37/** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */
38async function qualifyInterruptions(directory: string, preparation: boolean) {
39 await mkdir(directory, { mode: 0o700 })
40 const results = []
41 const cuts: Cut[] = preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
42 for (const cut of cuts) {
43 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
44 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
45 const enrollmentDirectory = join(root, 'enrollment')
46 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
47 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
48 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
49 const file = join(root, 'worker.json'), output = preparation ? join(root, 'candidate') : undefined
50 await writeFile(file, JSON.stringify({ reference, repository: source, cut, output } satisfies WorkerInput), { mode: 0o600 })
51 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
52 const timer = setTimeout(() => child.kill('SIGKILL'), 15000)
53 const reader = child.stdout.getReader(), decoder = new TextDecoder()
54 let stdout = ''
55 try {
56 while (!stdout.includes('\n')) {
57 const part = await reader.read()
58 if (part.done) throw new Error('Interruption worker exited before readiness')
59 stdout += decoder.decode(part.value, { stream: true })
60 if (stdout.length > 4096) throw new Error('Interruption worker output exceeded its bound')
61 }
62 const ready = JSON.parse(stdout.trim())
63 assert.equal(ready.ready, true); assert.equal(ready.phase, cut)
64 const contender = spawnSync(process.execPath, [import.meta.path, 'contend', file], { encoding: 'utf8', timeout: 10000 })
65 assert.equal(contender.status, 17)
66 assert.equal(JSON.parse(contender.stdout).blocked, true)
67 child.kill('SIGKILL')
68 const exit = await child.exited, signal = child.signalCode
69 assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0)
70 const interrupted = await inspectRuntimeTrustStore(reference)
71 assert.equal(interrupted.attempt?.phase, 'pending')
72 assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1)
73 assert.equal(interrupted.releaseFloor, preparation ? 9 : 7)
74 const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false
75 if (output) {
76 assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
77 assert.equal(candidateStagePresent, cut === 'candidate-completed')
78 }
79 const recovered = await recoverRuntimeTrustStore(reference)
80 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, 'recovered')
81 assert.equal(recovered.releaseFloor, preparation ? 9 : 7)
82 assert.equal(Object.hasOwn(recovered, 'candidate'), false)
83 const completed = preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
84 const fresh = completed.schema === 'drone-runtime-prepared-image-v1' ? completed.store : completed
85 assert.equal(fresh.generation >= interrupted.generation, true)
86 if (!preparation) assert.equal(fresh.generation, 3)
87 assert.equal(fresh.releaseFloor, 9)
88 assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false)
89 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, interrupted, recovered, fresh })
90 }
91 finally {
92 clearTimeout(timer)
93 if (child.exitCode === null) child.kill('SIGKILL')
94 await child.exited
95 reader.releaseLock()
96 await writeFile(join(root, 'worker.stdout'), stdout)
97 await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text())
98 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', results, physicalDeploymentAllowed: false }, null, 2))
99 }
100 }
101 return { evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', cuts: results.length, physicalDeploymentAllowed: false }
102}
103
104export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) }
105export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) }
106
107if (import.meta.main) {
108 if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
109 else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
110 else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!))))
111 else throw new Error('Use store-interruptions.ts [prepare] NEW_DIRECTORY')
112}
tests/unit/updates-preparation-cli.test.tsadded+45-0
Changes to tests/unit/updates-preparation-cli.test.ts
@@ -0,0 +1,45 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
8import { repository, platform, target } from '../fixtures/updates/repository'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('native preparation requires protected trust and an explicit clock, authenticates the image and preserves failure status', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-cli-')); roots.push(root)
15 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
16 const enrollmentDirectory = join(root, 'enrollment')
17 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
18 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
19 const flags = ['--enrollment', enrollmentDirectory, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256]
20 const sourceFlags = ['--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']
21 const prepared = await command(['updates:prepare', join(root, 'store'), ...flags, ...sourceFlags, '--output', join(root, 'candidate')]).withTimeout(20000)
22 expect(prepared.exitCode).toBe(0)
23 const value = JSON.parse(prepared.stdout)
24 expect(value.store.releaseFloor).toBe(9)
25 expect(value.candidate.sha256).toBe(fixture.imageHash)
26 expect(value.trustStatePersisted).toBe(true)
27 expect(value.physicalDeploymentAllowed).toBe(false)
28 const inspected = await command(['updates:inspect', join(root, 'candidate'), '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '9']).withTimeout(20000)
29 expect(inspected.exitCode).toBe(0)
30 expect(JSON.parse(inspected.stdout)).toEqual(value.candidate)
31 const missingClock = await command(['updates:prepare', join(root, 'store'), ...flags, '--repository', source, '--output', join(root, 'missing-clock')]).withTimeout(20000)
32 expect(missingClock.exitCode).toBe(1)
33 expect(missingClock.stdout).toBe('')
34 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
35 const rejected = await command(['updates:prepare', join(root, 'store'), ...flags, ...sourceFlags, '--output', join(root, 'bad-image')]).withTimeout(20000)
36 expect(rejected.exitCode).toBe(1)
37 expect(rejected.stdout).toBe('')
38 expect(rejected.stderr).toBe('Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure\n')
39 const status = await command(['updates:store', join(root, 'store'), ...flags]).withTimeout(20000)
40 expect(status.exitCode).toBe(0)
41 const stopped = JSON.parse(status.stdout)
42 expect(stopped.releaseFloor).toBe(9)
43 expect(stopped.attempt.phase).toBe('maintenance')
44 expect(stopped.physicalDeploymentAllowed).toBe(false)
45}, 120000)
tests/unit/updates-preparation.test.tsadded+163-0
Changes to tests/unit/updates-preparation.test.ts
@@ -0,0 +1,163 @@
1import { afterEach, expect, test } from 'bun:test'
2import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
6import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging'
7import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
8import { platform, repository, target } from '../fixtures/updates/repository'
9import { qualifyPreparationInterruptions } from '../fixtures/updates/store-interruptions'
10import { epochRepository } from '../fixtures/updates/epochs'
11
12const roots: string[] = []
13afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
14const clock = { source: 'test-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
15async function setup(options: Parameters<typeof repository>[1] = {}) {
16 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-update-')); roots.push(root)
17 const source = join(root, 'repo'), fixture = await repository(source, { ...options, custom: { releaseSequence: 9, ...options.custom } })
18 const enrollmentDirectory = join(root, 'enrollment')
19 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
20 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
21 return { root, source, fixture, reference: { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }, output: join(root, 'candidate') }
22}
23
24test('protected preparation authenticates actual image bytes and writes an independently verifiable signed candidate', async () => {
25 const item = await setup()
26 const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
27 expect(result.schema).toBe('drone-runtime-prepared-image-v1')
28 expect(result.store.generation).toBe(3)
29 expect(result.store.releaseFloor).toBe(9)
30 expect(result.store.attempt?.phase).toBe('verified')
31 expect(result.candidate.sha256).toBe(item.fixture.imageHash)
32 expect(result.candidate.length).toBe(item.fixture.image.length)
33 expect(result.trustStatePersisted).toBe(true)
34 expect(result.physicalDeploymentAllowed).toBe(false)
35 expect(result.candidate.trustStatePersisted).toBe(false)
36 expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: item.fixture.rootSha256, minimumReleaseSequence: 9 })).toEqual(result.candidate)
37 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(result.store)
38})
39
40test('a rotated protected root and cached metadata produce a candidate without depending on the initial root chain', async () => {
41 const item = await setup({ rotate: true })
42 const current = await refreshRuntimeTrustStore({ ...item.reference, repository: item.source, clock })
43 const protectedRoot = current.metadata.find(row => row.role === 'root')!
44 expect(protectedRoot.version).toBe(2)
45 const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
46 expect(result.candidate.trustedRootSha256).toBe(protectedRoot.sha256)
47 expect(result.candidate.rootVersion).toBe(2)
48 expect(result.store.releaseFloor).toBe(9)
49 await rename(item.source, join(item.root, 'offline'))
50 expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: protectedRoot.sha256, minimumReleaseSequence: 9 })).toEqual(result.candidate)
51 await expect(inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: item.fixture.rootSha256, minimumReleaseSequence: 9 })).rejects.toThrow('independent pin')
52})
53
54test('an actual output collision after metadata acceptance preserves the release floor and the competing files', async () => {
55 const item = await setup()
56 const failure = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock, async onProgress(event) {
57 if (event.phase === 'metadata-committed' && event.ordinal === 2) {
58 await mkdir(item.output)
59 await writeFile(join(item.output, 'contender.txt'), 'Preserve the competing output\n')
60 }
61 } }).then(() => undefined, error => error)
62 expect(failure.code).toBe('EEXIST')
63 expect(await readFile(join(item.output, 'contender.txt'), 'utf8')).toBe('Preserve the competing output\n')
64 await expect(stat(join(item.output, 'stage.json'))).rejects.toHaveProperty('code', 'ENOENT')
65 const stopped = await inspectRuntimeTrustStore(item.reference)
66 expect(stopped.generation).toBe(3)
67 expect(stopped.releaseFloor).toBe(9)
68 expect(stopped.attempt?.phase).toBe('maintenance')
69 const recovered = await recoverRuntimeTrustStore(item.reference)
70 expect(recovered.releaseFloor).toBe(9)
71 expect(recovered.attempt?.phase).toBe('recovered')
72 expect(recovered.physicalDeploymentAllowed).toBe(false)
73})
74
75test('candidate paths cannot overlap the repository, enrollment or protocol store', async () => {
76 const item = await setup(), initial = await inspectRuntimeTrustStore(item.reference)
77 for (const source of [item.source, item.reference.enrollmentDirectory, item.reference.directory]) {
78 await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: join(source, 'candidate'), clock })).rejects.toThrow('separate')
79 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(initial)
80 }
81})
82
83test('newer signed metadata cannot prepare a release below the protected floor or overwrite an earlier candidate', async () => {
84 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-floor-')); roots.push(root)
85 const source = join(root, 'repo'), fixture = await epochRepository(source)
86 const enrollmentDirectory = join(root, 'enrollment')
87 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
88 const store = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
89 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: store.storeSha256 }
90 const original = await fixture.publish(1, 1, 9), output = join(root, 'candidate')
91 await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, output, clock })
92 const receipt = await readFile(join(output, 'stage.json'))
93 await fixture.publish(1, 2, 8)
94 await expect(prepareRuntimeFirmwareUpdate({ ...reference, repository: source, output: join(root, 'downgrade'), clock })).rejects.toThrow('below the trusted minimum')
95 const stopped = await inspectRuntimeTrustStore(reference)
96 expect(stopped.releaseFloor).toBe(9)
97 expect(stopped.metadata.find(row => row.role === 'targets')?.version).toBe(2)
98 expect(stopped.attempt?.phase).toBe('maintenance')
99 expect(await readFile(join(output, 'image.bin'))).toEqual(original.image)
100 expect(await readFile(join(output, 'stage.json'))).toEqual(receipt)
101 const recovered = await recoverRuntimeTrustStore(reference)
102 expect(recovered.releaseFloor).toBe(9)
103 expect(recovered.attempt?.phase).toBe('recovered')
104 expect(recovered.physicalDeploymentAllowed).toBe(false)
105})
106
107test('bad image bytes cannot discard accepted metadata or release floors, including after offline recovery', async () => {
108 const item = await setup()
109 await writeFile(item.fixture.imagePath, Buffer.alloc(item.fixture.image.length, 0xff))
110 await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })).rejects.toThrow()
111 const stopped = await inspectRuntimeTrustStore(item.reference)
112 expect(stopped.generation).toBe(3)
113 expect(stopped.releaseFloor).toBe(9)
114 expect(stopped.attempt?.phase).toBe('maintenance')
115 await expect(stat(item.output)).rejects.toHaveProperty('code', 'ENOENT')
116 await rename(item.source, join(item.root, 'offline'))
117 const recovered = await recoverRuntimeTrustStore(item.reference)
118 expect(recovered.generation).toBe(3)
119 expect(recovered.releaseFloor).toBe(9)
120 expect(recovered.attempt?.phase).toBe('recovered')
121 expect(recovered.physicalDeploymentAllowed).toBe(false)
122 await rename(join(item.root, 'offline'), item.source)
123 await writeFile(item.fixture.imagePath, item.fixture.image)
124 const fresh = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
125 expect(fresh.store.releaseFloor).toBe(9)
126 expect(fresh.candidate.sha256).toBe(item.fixture.imageHash)
127 expect(fresh.physicalDeploymentAllowed).toBe(false)
128})
129
130test('real preparation process cuts retain accepted floors and never turn recovery into candidate authorization', async () => {
131 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-host-cuts-')); roots.push(root)
132 const result = await qualifyPreparationInterruptions(join(root, 'campaign'))
133 expect(result.cuts).toBe(2)
134 expect(result.physicalDeploymentAllowed).toBe(false)
135}, 45000)
136
137test('protected preparation also archives repositories without consistent-snapshot filename prefixes', async () => {
138 const item = await setup({ rotate: true, consistentSnapshot: false })
139 const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
140 const current = await inspectRuntimeTrustStore(item.reference), root = current.metadata.find(row => row.role === 'root')!
141 expect(root.version).toBe(2)
142 expect(result.candidate.sha256).toBe(item.fixture.imageHash)
143 expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: root.sha256, minimumReleaseSequence: current.releaseFloor })).toEqual(result.candidate)
144 expect(result.physicalDeploymentAllowed).toBe(false)
145})
146
147test('actual candidate-image corruption after file sync is rejected by readback before a completion marker', async () => {
148 const item = await setup(), damaged = Buffer.alloc(item.fixture.image.length, 0xff)
149 let changedAfterWrite = false
150 await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock, async onProgress(event) {
151 if (event.phase === 'image-written') {
152 await writeFile(join(item.output, 'image.bin'), damaged)
153 changedAfterWrite = true
154 }
155 } })).rejects.toThrow()
156 expect(changedAfterWrite).toBe(true)
157 expect(await readFile(join(item.output, 'image.bin'))).toEqual(damaged)
158 expect(await Bun.file(join(item.output, 'stage.json')).exists()).toBe(false)
159 const stopped = await inspectRuntimeTrustStore(item.reference)
160 expect(stopped.releaseFloor).toBe(9)
161 expect(stopped.attempt?.phase).toBe('maintenance')
162 expect(stopped.physicalDeploymentAllowed).toBe(false)
163})
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}