also looking at this
docs: align update acceptance with protected preparation
#48
27 files
+1676
-74
| @@ -38,6 +38,10 @@ jobs: | ||
| 38 | 38 | run: bun tests/fixtures/updates/qualify-trust.ts "$RUNNER_TEMP/runtime-trust-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" |
| 39 | 39 | - name: Verify typed SDK failures in a minified copied standalone executable |
| 40 | 40 | 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" | |
| 41 | 45 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 42 | 46 | working-directory: ${{ runner.temp }} |
| 43 | 47 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -262,12 +262,14 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/ | ||
| 262 | 262 | |
| 263 | 263 | The viewer opens files locally in the browser. The v1 format excludes imagery, location and |
| 264 | 264 | operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md). |
| 265 | The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF | |
| 265 | The [signed runtime image workflow](docs/runtime-updates.md) verifies offline TUF | |
| 266 | 266 | candidates through `updates:stage` and `updates:inspect`, with explicit root pins, |
| 267 | 267 | compatibility and externally supplied release floors. `updates:enroll` and |
| 268 | `updates:trust` preserve and independently verify an initial root/policy record. | |
| 269 | Physical logging/commissioning, persistent refresh metadata/release floors, image | |
| 270 | installation and known-good rollback remain pending. | |
| 268 | `updates:trust` preserve the initial root/policy record. The independently pinned | |
| 269 | store retains verified metadata, clock/release floors and historical recovery; | |
| 270 | `updates:prepare` authenticates actual image bytes under that protected state. | |
| 271 | Physical logging/commissioning, key revocation/repair, installed/known-good slots | |
| 272 | and image-bound boot/session health remain pending. | |
| 271 | 273 | |
| 272 | 274 | ## Sensor acquisition and replay |
| 273 | 275 | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { prepareRuntimeFirmwareUpdate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { recoverRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { inspectRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandEnrollment, storeCommandFailure, storeEnrollmentFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -23,6 +23,11 @@ import UpdatesStage from '../app/Commands/UpdatesStage' | ||
| 23 | 23 | import UpdatesInspect from '../app/Commands/UpdatesInspect' |
| 24 | 24 | import UpdatesEnroll from '../app/Commands/UpdatesEnroll' |
| 25 | 25 | import UpdatesTrust from '../app/Commands/UpdatesTrust' |
| 26 | import UpdatesStoreInit from '../app/Commands/UpdatesStoreInit' | |
| 27 | import UpdatesStore from '../app/Commands/UpdatesStore' | |
| 28 | import UpdatesRefresh from '../app/Commands/UpdatesRefresh' | |
| 29 | import UpdatesRecover from '../app/Commands/UpdatesRecover' | |
| 30 | import UpdatesPrepare from '../app/Commands/UpdatesPrepare' | |
| 26 | 31 | |
| 27 | 32 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 28 | 33 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -89,6 +94,11 @@ UpdatesStage(app) | ||
| 89 | 94 | UpdatesInspect(app) |
| 90 | 95 | UpdatesEnroll(app) |
| 91 | 96 | UpdatesTrust(app) |
| 97 | UpdatesStoreInit(app) | |
| 98 | UpdatesStore(app) | |
| 99 | UpdatesRefresh(app) | |
| 100 | UpdatesRecover(app) | |
| 101 | UpdatesPrepare(app) | |
| 92 | 102 | MissionsSimulate(app) |
| 93 | 103 | MissionsValidate(app) |
| 94 | 104 | MissionsReplay(app) |
| @@ -0,0 +1,59 @@ | ||
| 1 | # Runtime trust store protocol | |
| 2 | ||
| 3 | Status: implementation in progress for #42. This decision does not qualify deployment. | |
| 4 | ||
| 5 | The update coordinator needs a private, independently bound state directory. An | |
| 6 | immutable initial enrollment remains the anchor. A separately pinned store identity | |
| 7 | binds its mutable protocol state to that enrollment. Missing or corrupt existing | |
| 8 | state is an error, never a reason to initialize a replacement or lower a floor. | |
| 9 | ||
| 10 | Use Bun's built-in SQLite for this protocol file. This is a deliberate exception | |
| 11 | to model-derived application migrations: these records are a bounded local update | |
| 12 | protocol, not application CRUD. The installed Stacks database factory uses global | |
| 13 | configuration and its query facade resolves that configuration lazily. It does not | |
| 14 | expose the isolated synchronous connection and commit ordering this protocol needs. | |
| 15 | Native Stacks commands remain the entrypoints. No generic framework abstraction is | |
| 16 | introduced for this single consumer, and no application database is reconfigured. | |
| 17 | ||
| 18 | Two databases have separate responsibilities. A writer database holds an immediate | |
| 19 | transaction across awaited work, excluding another coordinator. The protocol | |
| 20 | database commits inputs and accepted metadata independently while that lease is | |
| 21 | held. Both use a rollback journal, FULL synchronous commits, fullfsync where the | |
| 22 | host supports it, zero busy timeout and verified configuration. The file format | |
| 23 | has an application ID, version, exact schema and explicit size/count limits. | |
| 24 | Existing databases are opened without creation. Symlinks, shared writable paths, | |
| 25 | wrong ownership, extra hard links and unexpected schema are rejected. | |
| 26 | ||
| 27 | Before an SDK fetch returns metadata, commit the exact bounded input or missing | |
| 28 | response. Before its private cache write, commit each accepted metadata observation | |
| 29 | and its high-water state. The cache is disposable; the anchor and protocol history | |
| 30 | are not. Every attempt records its base generation and independently supplied fixed | |
| 31 | wall time. New attempts cannot skip an unresolved attempt. Historical recovery | |
| 32 | replays exact recorded inputs at that original time and can only retain floors. | |
| 33 | Only a new verification at a trusted current time can authorize candidate use. | |
| 34 | Unknown verification, crypto, persistence or resource failures remain maintenance | |
| 35 | conditions. Operational error types alone do not prove invalid input. | |
| 36 | ||
| 37 | Root-authorized timestamp or snapshot key rotation starts a new active metadata | |
| 38 | epoch and invalidates both working-cache files, as required by TUF 5.3.11. Retain | |
| 39 | the previous epochs and their floors. The global release-security floor never | |
| 40 | decreases across an epoch change. Root rotation alone, without either authority | |
| 41 | change, does not reset role floors. | |
| 42 | ||
| 43 | The schema and recovery implementation must be exercised with signed repositories, | |
| 44 | actual competing processes, SIGKILL cuts around input/prefix commits, corrupt and | |
| 45 | missing files, clock reversal, and real host storage failures. SQLite commit or | |
| 46 | process-crash evidence is not measured physical power-loss durability. Same-user | |
| 47 | privileged disk rollback and malicious kernel/filesystem behavior require separate | |
| 48 | hardware protection and are outside this host protocol's claims. | |
| 49 | ||
| 50 | Protected preparation now uses this metadata operation to authenticate actual | |
| 51 | image bytes before and after writing the existing signed-candidate format. The | |
| 52 | same file writer serves standalone staging. Candidate inspection requires a root | |
| 53 | pin from authenticated store readback when preparation uses the current rotated | |
| 54 | root. A candidate receipt supplies no current trust or selection authority. | |
| 55 | ||
| 56 | This v1 database journals metadata, not durable candidate ownership or slot | |
| 57 | transitions. Installed, candidate and known-good slots and health confirmation | |
| 58 | bound to an exact image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 59 | bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9. | |
| @@ -155,12 +155,14 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st | ||
| 155 | 155 | - Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load. |
| 156 | 156 | Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on |
| 157 | 157 | 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. | |
| 164 | 166 | - Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog, |
| 165 | 167 | estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6. |
| 166 | 168 | |
| @@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 13 | 13 | | 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) | |
| 14 | 14 | | 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) | |
| 15 | 15 | | 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) | | |
| 17 | 17 | | 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) | |
| 18 | 18 | | 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) | |
| 19 | 19 | | 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) | |
| @@ -267,13 +267,17 @@ | ||
| 267 | 267 | "resources/components/DiagnosticsReplay.stx", |
| 268 | 268 | "docs/diagnostics.md", |
| 269 | 269 | "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" | |
| 271 | 275 | ], |
| 272 | 276 | "remaining": [ |
| 273 | 277 | "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations", |
| 274 | 278 | "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence", |
| 275 | 279 | "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", | |
| 277 | 281 | "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence" |
| 278 | 282 | ] |
| 279 | 283 | }, |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | # Runtime trust and signed image staging |
| 2 | 2 | |
| 3 | 3 | Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image |
| 4 | staging, independent candidate verification and protected initial trust enrollment. | |
| 5 | These commands prepare files on the host. Cross-update metadata/release floors, | |
| 6 | installed images, boot selection and known-good rollback slots remain to be | |
| 7 | implemented. Physical deployment is disabled. | |
| 4 | staging, independent candidate verification, protected initial trust enrollment | |
| 5 | and a persistent host metadata protocol with protected image preparation. These | |
| 6 | commands prepare and verify host state and actual candidate image bytes. Key | |
| 7 | revocation/repair, installed images, boot selection and known-good rollback slots | |
| 8 | remain part of the full coordinator. Physical deployment is disabled. | |
| 8 | 9 | |
| 9 | 10 | ## Explicit trust and policy |
| 10 | 11 | |
| @@ -78,19 +79,182 @@ Keep this immutable anchor separate from an SDK working cache. The current SDK | ||
| 78 | 79 | writes cache files in place; a host write-limit probe truncated its cached root. |
| 79 | 80 | Never refresh against the enrollment directory. Staging/inspection still require |
| 80 | 81 | their explicit policy and do not load or advance this enrollment automatically. |
| 81 | Persistent verified metadata/release floors, key lifecycle, interruption recovery | |
| 82 | and known-good coordination remain in [issue #42](https://github.com/stacksjs/drone/issues/42). | |
| 82 | The metadata-store functions below retain verified metadata/release floors. | |
| 83 | The complete key lifecycle, storage-fault campaigns, image integration and | |
| 84 | known-good coordinator remain in [issue #42](https://github.com/stacksjs/drone/issues/42). | |
| 83 | 85 | Do not discard or re-enroll over failed state to reset a security floor. |
| 84 | 86 | |
| 87 | ## Persistent host metadata protocol | |
| 88 | ||
| 89 | Initialize a separate new private directory and retain its returned `storeSha256` | |
| 90 | independently, 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 | ||
| 108 | Native Buddy and the copied standalone CLI use the same functions under | |
| 109 | `resources/functions/drone/updates/store.ts`. The independent store pin binds an | |
| 110 | immutable identity to the authenticated enrollment. It is not a hash of the | |
| 111 | mutable database or a candidate receipt. Wrong pins, existing initialization | |
| 112 | outputs, missing/corrupt files, foreign schemas, unexpected entries, symlinks, | |
| 113 | extra hard links and unsafe ownership/permissions fail closed. Existing files | |
| 114 | are 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 | |
| 118 | each input and accepted metadata prefix independently while that writer is | |
| 119 | excluded. The exact schema, application ID and version are checked before | |
| 120 | configuration that could rewrite a foreign database's format. Both connections | |
| 121 | require DELETE journals, FULL synchronous commits and configuration readback. | |
| 122 | SQLite and directory sync evidence concerns this host, not electrical power loss. | |
| 123 | ||
| 124 | An attempt records its base generation, clock source and fixed start time. Before | |
| 125 | the SDK receives an input, its bounded bytes or missing-file response are committed | |
| 126 | and read back. The awaited SDK observer commits accepted metadata before its | |
| 127 | private cache write. Later verification, compatibility or output failure does not | |
| 128 | discard that prefix. Working caches are separate disposable temporary directories. | |
| 129 | Progress-callback rejection aborts refresh and preserves its original cause. A | |
| 130 | callback's HTTP error cannot stand in for an absent repository file. | |
| 131 | `generation` and `retainedMetadataRecords` describe the append-only metadata history; | |
| 132 | status lists the active roles and the latest attempt. | |
| 133 | ||
| 134 | The global `releaseFloor` starts with enrolled policy and advances from the selected | |
| 135 | compatible target's verified signed declaration. Refresh verifies metadata and | |
| 136 | declared compatibility; it does not download or authenticate the actual image | |
| 137 | bytes. An attempt marked `verified` means this metadata operation completed. It | |
| 138 | does 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 | |
| 141 | that candidate file format and actual-image readback verification. | |
| 142 | ||
| 143 | Timestamp or snapshot authority rotation, authenticated through sequential root | |
| 144 | metadata, creates a new active epoch and invalidates both working-cache entries. | |
| 145 | Historical epochs remain in the database. A test changes only the snapshot key, | |
| 146 | keeps the timestamp key, accepts metadata counters dropping from 100 to 1 under | |
| 147 | the new authority, and still rejects a release below the global release floor. | |
| 148 | Changing only the root signing key does not reset active role floors. | |
| 149 | ||
| 150 | ### Clock and interrupted attempts | |
| 151 | ||
| 152 | The function interface requires a `RuntimeUpdateClock` with a source label and | |
| 153 | `read(): Date`. The adapter must obtain independently trusted current wall time; | |
| 154 | there is no repository-supplied time or default adapter. Read once, copy that | |
| 155 | instant, and reject a decrease from the persisted clock floor. The native command | |
| 156 | uses the system UTC clock only with the explicit source option above. That name | |
| 157 | is an audit label, not an attestation that the system clock is synchronized or | |
| 158 | protected. Configure and verify that trust externally before invoking it. Trusted | |
| 159 | clock acquisition and hardware clock/storage rollback protection remain separate | |
| 160 | requirements. Control-loop freshness keeps its own monotonic clock. | |
| 161 | ||
| 162 | A pending or maintenance attempt blocks another refresh. Recovery reconstructs | |
| 163 | its exact base cache, replays only durable inputs at the recorded historical time, | |
| 164 | and compares every earlier accepted observation with its committed bytes. It can | |
| 165 | finish at an owned pre-delivery end-of-recorded-input marker, or after complete | |
| 166 | verification followed by a known rejection from our explicit manifest policy. | |
| 167 | The latter retains all metadata and the release floor without crediting a rejected | |
| 168 | release. Signature, crypto, input-integrity, persistence and unknown SDK errors do | |
| 169 | not become permission to discard a journal: they remain maintenance conditions. | |
| 170 | No automatic repair for those conditions or corrupt state is supplied yet. | |
| 171 | Preserve the directory for investigation. | |
| 172 | ||
| 173 | Recovery can advance floors from a previously journaled valid input that never | |
| 174 | reached the SDK before interruption. Its `recovered` status never authorizes an | |
| 175 | image; a new trusted-current-time verification is required. Host tests perform | |
| 176 | actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a | |
| 177 | competing process, restart and retain the timestamp before a fresh refresh. These | |
| 178 | cuts do not cover every write/sync boundary or actual physical power loss. | |
| 179 | ||
| 180 | The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB | |
| 181 | state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per | |
| 182 | file. Exhaustion fails closed; history is not automatically deleted or compacted. | |
| 183 | Capacity migration, the full storage-fault campaign, authenticated repair/key | |
| 184 | revocation, durable slot coordination and session-bound health remain in #42. | |
| 185 | ||
| 186 | ## Protected signed-image preparation | |
| 187 | ||
| 188 | Use the pinned store and independently trusted current clock to authenticate an | |
| 189 | actual 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 | ||
| 198 | The library function is `prepareRuntimeFirmwareUpdate`. Native Buddy and the | |
| 199 | compiled CLI use the same function. One writer lease covers metadata verification, | |
| 200 | release-floor commit, image verification and candidate output. It uses one SDK | |
| 201 | metadata pass with the protected cache and journal. It does not perform a second | |
| 202 | unjournaled refresh through the standalone stager. | |
| 203 | ||
| 204 | The signed release floor is committed before reading the image. A bad hash, | |
| 205 | missing image or later output failure cannot reopen a release downgrade. The SDK | |
| 206 | checks actual payload length and SHA-256 before writing, then checks the actual | |
| 207 | written `image.bin` again. The shared candidate file writer emits the same | |
| 208 | `stage.json` format as standalone staging only after repository files and image | |
| 209 | readback complete. Its file helpers do not grant firmware-selection authority. | |
| 210 | ||
| 211 | The candidate's `trust-root.json` is the currently authenticated protected root, | |
| 212 | which can differ from the immutable enrollment root after rotation. Its metadata | |
| 213 | archive includes protected active roles, including cached roles that this attempt | |
| 214 | did not need to fetch again. Both consistent-snapshot filename modes are supported. | |
| 215 | For independent standalone inspection, obtain the current root hash and release | |
| 216 | floor from independently pinned store readback, never from a candidate receipt. | |
| 217 | The candidate does not need to replay the entire initial root chain: the protected | |
| 218 | store has already authenticated that chain and retains its history. | |
| 219 | ||
| 220 | The 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 | |
| 223 | still has `trustStatePersisted: false`: that portable receipt is not the protected | |
| 224 | store or current authorization. A successful preparation authenticates these | |
| 225 | candidate bytes at this operation's trusted time. It does not install, select, | |
| 226 | boot or confirm them, and does not promise future trust/expiry policy will allow | |
| 227 | selection. Durable candidate ownership, installed/known-good slots and current | |
| 228 | policy revalidation before selection remain coordinator work in #42. | |
| 229 | ||
| 230 | Image or candidate-output failure during an attempt leaves accepted metadata and | |
| 231 | release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never | |
| 232 | reconstructs image success or confirms an existing candidate. Preserve partial | |
| 233 | outputs and use a new output directory for a fresh preparation after recovery. | |
| 234 | A complete candidate marker can exist when a later callback or process failure | |
| 235 | prevents the operation returning. Marker existence alone is not authorization. | |
| 236 | Repository, enrollment and protocol-store directories cannot contain the candidate | |
| 237 | output or be contained by it. Existing output directories are not overwritten. | |
| 238 | ||
| 239 | Host tests kill actual competing-writer-excluded processes at `image-written` | |
| 240 | and `candidate-completed` progress points. The first follows image-file sync; | |
| 241 | the second follows the shared file writer's final marker and directory syncs. | |
| 242 | After restart, metadata recovery retains release floor 9 and returns only a store | |
| 243 | summary; a fresh preparation uses another new directory. These measured process | |
| 244 | cuts do not establish every write/sync boundary, electrical power-loss behavior | |
| 245 | or hardware anti-rollback. | |
| 246 | ||
| 85 | 247 | ## Repository and signed image contract |
| 86 | 248 | |
| 87 | 249 | Use a standard TUF repository with `metadata/` and `targets/` directories, |
| 88 | 250 | including consistent-snapshot names when enabled. Verification uses the pinned |
| 89 | 251 | [`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root |
| 90 | 252 | rotation, signature thresholds, expiration, inter-role versions/hashes and |
| 91 | target length/hashes. A private, randomly addressed loopback server supplies | |
| 92 | bounded local files to the client's public HTTP interface. It is stopped after | |
| 93 | verification. No external network fetch or arbitrary URL option is provided. | |
| 253 | target length/hashes. Standalone staging/inspection use a private, randomly | |
| 254 | addressed loopback server supplying bounded local files to the public HTTP | |
| 255 | interface, then stop it. Protected refresh/preparation use the public Fetcher | |
| 256 | interface with bounded local files and the durable input journal. Neither path | |
| 257 | provides an external network fetch or arbitrary URL option. | |
| 94 | 258 | The host additionally verifies that root and delegation key IDs equal SHA-256 |
| 95 | 259 | of their canonical key objects, using the upstream canonical JSON library. |
| 96 | 260 | This 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 | ||
| 138 | 302 | to discard, or that recovery may continue. Unknown crypto, resource or other |
| 139 | 303 | causes must fail closed pending investigation. Error objects and type names do |
| 140 | 304 | not establish authenticated trust or permit firmware use. The recovery |
| 141 | coordinator and its full failure classification remain to be implemented. | |
| 305 | coordinator's broader failure classification and authenticated maintenance repair | |
| 306 | remain to be implemented. The metadata protocol above only recognizes its own | |
| 307 | pre-delivery replay marker and completed domain-policy rejection. | |
| 142 | 308 | |
| 143 | 309 | The seam alone does not persist a protected prefix or make SDK cache writes |
| 144 | 310 | transactional. Its implementation and tests are a prerequisite for the protected |
| 145 | store and recovery coordinator in issue #42. They do not complete that issue or | |
| 311 | store and recovery coordinator in issue #42. The bounded metadata protocol above | |
| 312 | uses that seam; neither completes the full coordinator or | |
| 146 | 313 | permit flashing, boot selection or physical deployment. |
| 147 | 314 | |
| 148 | 315 | The 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. | ||
| 190 | 357 | |
| 191 | 358 | ## Remaining update and operations acceptance |
| 192 | 359 | |
| 193 | Each verification starts from the supplied root. `trustStatePersisted: false` | |
| 194 | means this module has no protected cross-invocation metadata or release | |
| 195 | high-water state. It is not a complete anti-rollback implementation. The caller | |
| 196 | must supply the trusted release floor every time. TUF expiry uses host wall | |
| 197 | time; trusted clock acquisition and protection against clock/storage rollback | |
| 198 | remain open. Control-loop freshness still uses its separate monotonic clock. | |
| 199 | ||
| 200 | Initial enrollment is available above. Next, implement protected trust rotation | |
| 201 | and persistent metadata/release floors, an | |
| 202 | interruption-safe coordinator, installed/known-good slot integrity, explicit | |
| 203 | health confirmation and rollback without lowering security floors. Then add | |
| 360 | The separate image-stage/inspection path still starts from its supplied root. | |
| 361 | Its `trustStatePersisted: false` result means it does not use the metadata store's | |
| 362 | cross-invocation floors. The caller must supply its trusted release floor each | |
| 363 | time. Neither path is a complete anti-rollback coordinator. Trusted clock | |
| 364 | acquisition and protection against privileged clock/storage rollback remain | |
| 365 | open. Control-loop freshness still uses its separate monotonic clock. | |
| 366 | ||
| 367 | Initial enrollment, the bounded persistent metadata protocol and protected | |
| 368 | signed-image preparation are available above. Complete authenticated | |
| 369 | maintenance/key revocation and the full storage-fault campaign, then durable | |
| 370 | candidate ownership, installed/known-good slot integrity, explicit health | |
| 371 | confirmation and rollback without lowering security floors. Then add | |
| 204 | 372 | the board bootloader/install protocol and measured power-loss, full-medium, |
| 205 | 373 | storage-latency and recovery campaigns. Local privileged filesystem tampering |
| 206 | 374 | is outside this host module's protection; it is not secure boot or hardware |
| @@ -0,0 +1,36 @@ | ||
| 1 | import { mkdirSync, realpathSync } from 'node:fs' | |
| 2 | import { basename, dirname, join, relative, resolve, sep } from 'node:path' | |
| 3 | import { Readable } from 'node:stream' | |
| 4 | import type { TargetFile } from 'tuf-js' | |
| 5 | import { directory, imageLimit, regularFile, safePath, syncDirectory, syncTree, writeExclusive } from './files' | |
| 6 | ||
| 7 | /** Internal candidate file-format helpers. They do not grant firmware selection authority. */ | |
| 8 | export 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. */ | |
| 21 | export 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 | } | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { RuntimeFirmwarePolicy } from './staging' |
| 2 | import { printErrorAsync } from '../../cli/output' | |
| 2 | 3 | |
| 3 | 4 | export const firmwarePolicyFlags = { |
| 4 | 5 | '--target <path>': 'Explicit signed target path inside the offline repository', |
| @@ -12,3 +13,23 @@ export function firmwareCommandPolicy(options: { target?: unknown, platform?: un | ||
| 12 | 13 | if (options.platform !== 'pico2-neutral-bench-v1' && options.platform !== 'host-neutral-bench-v1') throw new Error('Explicit implemented bench platform required') |
| 13 | 14 | return { target: options.target, platform: options.platform, trustedRootSha256: options.rootSha256, minimumReleaseSequence: Number(options.minimumReleaseSequence) } |
| 14 | 15 | } |
| 16 | ||
| 17 | export const storeEnrollmentFlags = { | |
| 18 | '--enrollment <path>': 'Immutable independently authenticated initial trust enrollment', | |
| 19 | '--enrollment-sha256 <hex>': 'Independently retained enrollment record SHA-256', | |
| 20 | } as const | |
| 21 | export const storeReferenceFlags = { ...storeEnrollmentFlags, '--store-sha256 <hex>': 'Independently retained update-store identity SHA-256' } as const | |
| 22 | ||
| 23 | export 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 | } | |
| 27 | export 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 | } | |
| 31 | export 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 | } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import type { TargetFile } from 'tuf-js' | |
| 2 | import { imageLimit } from './files' | |
| 3 | import type { RuntimeFirmwarePolicy } from './policy' | |
| 4 | ||
| 5 | /** A completed metadata verification can still be refused by our explicit image policy. */ | |
| 6 | export class RuntimeManifestRejected extends Error {} | |
| 7 | ||
| 8 | export 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 | } | |
| @@ -1,13 +1,14 @@ | ||
| 1 | 1 | import { randomBytes } from 'node:crypto' |
| 2 | 2 | import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' |
| 3 | import { dirname, join, relative, resolve, sep } from 'node:path' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | 4 | import { tmpdir } from 'node:os' |
| 5 | 5 | import { Readable } from 'node:stream' |
| 6 | 6 | import { isDeepStrictEqual } from 'node:util' |
| 7 | 7 | import { Updater } from 'tuf-js' |
| 8 | import type { TargetFile } from 'tuf-js' | |
| 9 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files' | |
| 8 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, writeExclusive } from './files' | |
| 10 | 9 | import { object, validateMetadata } from './metadata' |
| 10 | import { admitSignedRuntime } from './manifest' | |
| 11 | import { finishRuntimeCandidate, runtimeCandidateOutput } from './candidate-files' | |
| 11 | 12 | import { admitPolicy } from './policy' |
| 12 | 13 | import type { RuntimeFirmwarePolicy } from './policy' |
| 13 | 14 | export type { RuntimeFirmwarePolicy } from './policy' |
| @@ -30,16 +31,6 @@ export interface RuntimeFirmwareStage { | ||
| 30 | 31 | trustStatePersisted: false |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | function 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 | } | |
| 43 | 34 | |
| 44 | 35 | /** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */ |
| 45 | 36 | async 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, | ||
| 81 | 72 | await updater.refresh() |
| 82 | 73 | const info = await updater.getTargetInfo(policy.target) |
| 83 | 74 | if (!info) throw new Error('Selected firmware target is absent') |
| 84 | const runtime = signedRuntime(info, policy) | |
| 75 | const runtime = admitSignedRuntime(info, policy) | |
| 85 | 76 | if (download) await updater.downloadTarget(info, download) |
| 86 | 77 | const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8'))) |
| 87 | 78 | const rootVersion = object(latest.signed).version |
| @@ -95,29 +86,14 @@ async function verifyRepository(repository: string, root: Buffer, work: string, | ||
| 95 | 86 | /** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */ |
| 96 | 87 | export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> { |
| 97 | 88 | 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]) | |
| 99 | 90 | directory(repository) |
| 100 | 91 | const root = regularFile(options.trustedRoot, metadataLimit) |
| 101 | 92 | 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') | |
| 106 | 93 | mkdirSync(output, { mode: 0o700 }) |
| 107 | 94 | writeExclusive(join(output, 'trust-root.json'), root) |
| 108 | 95 | 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) | |
| 121 | 97 | return result |
| 122 | 98 | } |
| 123 | 99 | |
| @@ -0,0 +1,462 @@ | ||
| 1 | import { Database } from 'bun:sqlite' | |
| 2 | import { randomBytes } from 'node:crypto' | |
| 3 | import { lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, unlinkSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { dirname, join, resolve } from 'node:path' | |
| 6 | import { DownloadHTTPError, Updater } from 'tuf-js' | |
| 7 | import type { TargetFile } from 'tuf-js' | |
| 8 | import { Readable } from 'node:stream' | |
| 9 | import { finishRuntimeCandidate, runtimeCandidateOutput } from './candidate-files' | |
| 10 | import type { RuntimeFirmwareStage } from './staging' | |
| 11 | import { inspectRuntimeTrust } from './enrollment' | |
| 12 | import type { RuntimeTrustSummary } from './enrollment' | |
| 13 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files' | |
| 14 | import { object, validateMetadata } from './metadata' | |
| 15 | import { admitSignedRuntime, RuntimeManifestRejected } from './manifest' | |
| 16 | ||
| 17 | const applicationId = 0x44525453 | |
| 18 | const maximumGeneration = 10000 | |
| 19 | const maximumDatabaseBytes = 128 * 1024 * 1024 | |
| 20 | const activeStores = new Set<string>() | |
| 21 | const writerSchema = 'CREATE TABLE writer (id INTEGER PRIMARY KEY CHECK(id = 1), store_id TEXT NOT NULL CHECK(length(store_id) = 32)) STRICT' | |
| 22 | const 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 | ||
| 29 | interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string } | |
| 30 | interface StoreReference extends TrustReference { directory: string, storeSha256: string } | |
| 31 | interface Binding { schema: 'drone-runtime-store-v1', storeId: string, enrollmentId: string, enrollmentSha256: string } | |
| 32 | interface State { store_id: string, enrollment_sha256: string, generation: number, release_floor: number, clock_floor_ms: number } | |
| 33 | interface MetadataRow { generation: number, attempt_id: string | null, ordinal: number, role: string, version: number, epoch: number, bytes: Uint8Array, sha256: string } | |
| 34 | interface Attempt { id: string, base_generation: number, started_ms: number, clock_source: string, phase: 'pending' | 'verified' | 'recovered' | 'maintenance', accepted_release: number | null } | |
| 35 | export 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 | ||
| 50 | function 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 | } | |
| 55 | function 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 | } | |
| 59 | function 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 | } | |
| 66 | function 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 | } | |
| 82 | function 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 | } | |
| 89 | function 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 | } | |
| 100 | function 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 | } | |
| 106 | function 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 | } | |
| 111 | function 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 | } | |
| 116 | function 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 | } | |
| 124 | function 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 | ||
| 137 | async 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. */ | |
| 174 | export 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. */ | |
| 199 | export async function inspectRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 200 | return withStore(options, (db, binding) => summary(db, binding, options.storeSha256)) | |
| 201 | } | |
| 202 | ||
| 203 | export interface RuntimeUpdateClock { | |
| 204 | /** Audit label for an independently trusted clock. The label itself is not attestation. */ | |
| 205 | source: string | |
| 206 | read: () => Date | |
| 207 | } | |
| 208 | ||
| 209 | export interface RuntimeUpdateProgress { | |
| 210 | phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | |
| 211 | ordinal: number | |
| 212 | } | |
| 213 | type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void> | |
| 214 | interface InputRow { ordinal: number, path: string, status: 200 | 404, bytes: Uint8Array | null, sha256: string | null } | |
| 215 | ||
| 216 | function 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 | } | |
| 223 | function 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 | } | |
| 231 | function 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 | } | |
| 242 | function 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 | } | |
| 248 | function 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 | ||
| 265 | interface RefreshOptions extends StoreReference { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver } | |
| 266 | interface VerifiedSelection { | |
| 267 | db: Database | |
| 268 | binding: Binding | |
| 269 | trusted: Readonly<RuntimeTrustSummary> | |
| 270 | source: string | |
| 271 | info: TargetFile | |
| 272 | runtime: ReturnType<typeof admitSignedRuntime> | |
| 273 | } | |
| 274 | ||
| 275 | async 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. */ | |
| 340 | export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 341 | return (await refreshAndComplete(options, async () => undefined)).store | |
| 342 | } | |
| 343 | ||
| 344 | export 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. */ | |
| 353 | export 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 | ||
| 380 | function 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. */ | |
| 390 | export 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 | } | |
| @@ -0,0 +1,41 @@ | ||
| 1 | import { mkdir, writeFile } from 'node:fs/promises' | |
| 2 | import { join } from 'node:path' | |
| 3 | import { Metadata, MetaFile, Root, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models' | |
| 4 | import { digest, platform, signer, target } from './repository' | |
| 5 | ||
| 6 | /** The same timestamp key survives a snapshot-only authority rotation. All keys are throwaway. */ | |
| 7 | export 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 | } | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { constants } from 'node:fs' | |
| 3 | import { createHash } from 'node:crypto' | |
| 4 | import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 5 | import { join, resolve } from 'node:path' | |
| 6 | import { platform, repository, target } from './repository' | |
| 7 | ||
| 8 | async 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. */ | |
| 11 | export 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 | } | |
| 58 | if (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 | } | |
| @@ -0,0 +1,59 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { constants } from 'node:fs' | |
| 3 | import { createHash } from 'node:crypto' | |
| 4 | import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 5 | import { join, resolve } from 'node:path' | |
| 6 | import { refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store' | |
| 7 | import { platform, repository, target } from './repository' | |
| 8 | ||
| 9 | async 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. */ | |
| 12 | export 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 | } | |
| 56 | if (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 | } | |
| @@ -9,7 +9,7 @@ export const platform = 'pico2-neutral-bench-v1' | ||
| 9 | 9 | export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') |
| 10 | 10 | const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' } |
| 11 | 11 | |
| 12 | function signer(nonCanonical = false) { | |
| 12 | export function signer(nonCanonical = false) { | |
| 13 | 13 | const pair = generateKeyPairSync('ed25519') |
| 14 | 14 | const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex') |
| 15 | 15 | const keyObject = { keytype: 'ed25519', scheme: 'ed25519', keyval: { public: publicKey } } |
| @@ -19,18 +19,18 @@ function signer(nonCanonical = false) { | ||
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | /** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */ |
| 22 | export 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 } = {}) { | |
| 22 | export 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 } = {}) { | |
| 23 | 23 | await mkdir(join(directory, 'metadata'), { recursive: true }) |
| 24 | 24 | await mkdir(join(directory, 'targets/runtime'), { recursive: true }) |
| 25 | 25 | 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 }) | |
| 27 | 27 | for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role) |
| 28 | 28 | const rootMetadata = new Metadata(root) |
| 29 | 29 | rootMetadata.sign(keys.root.sign) |
| 30 | 30 | const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON())) |
| 31 | 31 | await writeFile(join(directory, 'trusted-root.json'), rootBytes) |
| 32 | 32 | 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 }) | |
| 34 | 34 | next.addKey(nextKey.key, 'root') |
| 35 | 35 | for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role) |
| 36 | 36 | const rotated = new Metadata(next) |
| @@ -54,8 +54,9 @@ export async function repository(directory: string, options: { custom?: Record<s | ||
| 54 | 54 | 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) } }) })) |
| 55 | 55 | timestamp.sign(keys.timestamp.sign) |
| 56 | 56 | 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 } | |
| 61 | 62 | } |
| @@ -0,0 +1,112 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { spawnSync } from 'node:child_process' | |
| 3 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' | |
| 6 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store' | |
| 7 | import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, target } from './repository' | |
| 9 | ||
| 10 | type Reference = Parameters<typeof inspectRuntimeTrustStore>[0] | |
| 11 | type Cut = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | |
| 12 | interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string } | |
| 13 | const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 14 | ||
| 15 | async 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. */ | |
| 38 | async 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 | ||
| 104 | export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) } | |
| 105 | export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) } | |
| 106 | ||
| 107 | if (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 | } | |
| @@ -0,0 +1,45 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 7 | import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 8 | import { repository, platform, target } from '../fixtures/updates/repository' | |
| 9 | ||
| 10 | const roots: string[] = [] | |
| 11 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 12 | ||
| 13 | test('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) | |
| @@ -0,0 +1,163 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 6 | import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging' | |
| 7 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 9 | import { qualifyPreparationInterruptions } from '../fixtures/updates/store-interruptions' | |
| 10 | import { epochRepository } from '../fixtures/updates/epochs' | |
| 11 | ||
| 12 | const roots: string[] = [] | |
| 13 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 14 | const clock = { source: 'test-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 15 | async 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 | ||
| 24 | test('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 | ||
| 40 | test('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 | ||
| 54 | test('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 | ||
| 75 | test('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 | ||
| 83 | test('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 | ||
| 107 | test('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 | ||
| 130 | test('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 | ||
| 137 | test('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 | ||
| 147 | test('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 | }) | |
| @@ -0,0 +1,38 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 7 | import { repository, platform, target } from '../fixtures/updates/repository' | |
| 8 | ||
| 9 | const roots: string[] = [] | |
| 10 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 11 | ||
| 12 | test('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) | |
| @@ -0,0 +1,236 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { Database } from 'bun:sqlite' | |
| 3 | import { DownloadHTTPError, MetadataVerificationError } from 'tuf-js' | |
| 4 | import { mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | import { join } from 'node:path' | |
| 7 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 8 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 9 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 10 | import { qualifyStoreInterruptions } from '../fixtures/updates/store-interruptions' | |
| 11 | import { epochRepository } from '../fixtures/updates/epochs' | |
| 12 | ||
| 13 | const roots: string[] = [] | |
| 14 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 15 | ||
| 16 | async 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 | ||
| 24 | test('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 | ||
| 40 | test('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 | ||
| 54 | test('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 | ||
| 69 | test('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 | ||
| 78 | test('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 | ||
| 94 | test('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 | ||
| 117 | test('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 | ||
| 124 | test('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 | ||
| 136 | test('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 | ||
| 148 | test('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 | ||
| 175 | test('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 | ||
| 188 | test('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 | ||
| 210 | for (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 | } | |