also looking at this
feat: own runtime firmware candidates
#49
24 files
+1245
-92
| @@ -40,6 +40,10 @@ jobs: | ||
| 40 | 40 | run: bun tests/fixtures/updates/qualify-sdk.ts "$RUNNER_TEMP/runtime-sdk-qualification" |
| 41 | 41 | - name: Verify persistent metadata and recovery with a copied standalone CLI |
| 42 | 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" | |
| 45 | - name: Verify owned candidate identity and retirement with a copied standalone CLI | |
| 46 | run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" | |
| 43 | 47 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 44 | 48 | working-directory: ${{ runner.temp }} |
| 45 | 49 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -262,12 +262,16 @@ 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 | `updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate | |
| 272 | identity, recorded-byte status and explicit retirement while retaining files/history. | |
| 273 | Physical logging/commissioning, key revocation/repair, installed/known-good slots | |
| 274 | and image-bound boot/session health remain pending. | |
| 271 | 275 | |
| 272 | 276 | ## Sensor acquisition and replay |
| 273 | 277 | |
| @@ -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 { inspectRuntimeFirmwareCandidates } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:candidates <directory>', | |
| 8 | description: 'Inspect owned candidate identity and recorded-byte integrity without granting selection authority', | |
| 9 | options: storeReferenceFlags, | |
| 10 | async handle(options, directory) { | |
| 11 | try { await printAsync(JSON.stringify(await inspectRuntimeFirmwareCandidates(storeCommandReference(options, directory)))) } | |
| 12 | catch { await storeCommandFailure() } | |
| 13 | }, | |
| 14 | }) | |
| @@ -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,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 { reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:reserve <directory>', | |
| 8 | description: 'Reserve an owned signed-image candidate in a pinned store without selecting or installing firmware', | |
| 9 | options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image 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 reserveRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), repository: options.repository, clock: { source: options.trustedSystemClockSource, read: () => new Date() } }))) | |
| 14 | } | |
| 15 | catch { await storeCommandFailure() } | |
| 16 | }, | |
| 17 | }) | |
| @@ -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 { retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:retire <directory>', | |
| 8 | description: 'Retire the exact active candidate reservation while retaining its history, bytes and trust floors', | |
| 9 | options: { ...storeReferenceFlags, '--candidate <id>': 'Exact active candidate identity from pinned store status' }, | |
| 10 | async handle(options, directory) { | |
| 11 | try { | |
| 12 | if (typeof options.candidate !== 'string') throw new Error('Explicit candidate identity required') | |
| 13 | await printAsync(JSON.stringify(await retireRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), candidateId: options.candidate }))) | |
| 14 | } | |
| 15 | catch { await storeCommandFailure() } | |
| 16 | }, | |
| 17 | }) | |
| @@ -27,6 +27,10 @@ import UpdatesStoreInit from '../app/Commands/UpdatesStoreInit' | ||
| 27 | 27 | import UpdatesStore from '../app/Commands/UpdatesStore' |
| 28 | 28 | import UpdatesRefresh from '../app/Commands/UpdatesRefresh' |
| 29 | 29 | import UpdatesRecover from '../app/Commands/UpdatesRecover' |
| 30 | import UpdatesPrepare from '../app/Commands/UpdatesPrepare' | |
| 31 | import UpdatesReserve from '../app/Commands/UpdatesReserve' | |
| 32 | import UpdatesCandidates from '../app/Commands/UpdatesCandidates' | |
| 33 | import UpdatesRetire from '../app/Commands/UpdatesRetire' | |
| 30 | 34 | |
| 31 | 35 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 32 | 36 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -97,6 +101,10 @@ UpdatesStoreInit(app) | ||
| 97 | 101 | UpdatesStore(app) |
| 98 | 102 | UpdatesRefresh(app) |
| 99 | 103 | UpdatesRecover(app) |
| 104 | UpdatesPrepare(app) | |
| 105 | UpdatesReserve(app) | |
| 106 | UpdatesCandidates(app) | |
| 107 | UpdatesRetire(app) | |
| 100 | 108 | MissionsSimulate(app) |
| 101 | 109 | MissionsValidate(app) |
| 102 | 110 | MissionsReplay(app) |
| @@ -47,7 +47,26 @@ process-crash evidence is not measured physical power-loss durability. Same-user | ||
| 47 | 47 | privileged disk rollback and malicious kernel/filesystem behavior require separate |
| 48 | 48 | hardware protection and are outside this host protocol's claims. |
| 49 | 49 | |
| 50 | Installed, candidate and known-good slots, authenticated image integration and | |
| 51 | health confirmation bound to an exact image and boot/session identity remain part | |
| 52 | of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 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 | The original format1 database journals metadata. Candidate reservation upgrades | |
| 57 | its exact schema to format2 in a single transaction, preserving all earlier | |
| 58 | records, floors and the independently pinned store identity. The writer database | |
| 59 | stays format1. New code reads both formats; older binaries reject format2 before | |
| 60 | rewriting it. Missing/corrupt or unknown schemas are never reinitialized. | |
| 61 | ||
| 62 | Format2 adds bounded owned candidate records and one active-reservation index. | |
| 63 | A record binds the exact attempt/generation and generated signed description, | |
| 64 | is committed before image IO, and changes to prepared only after authenticated | |
| 65 | bytes and full candidate-file readback. The files use exclusive immutable-ID | |
| 66 | directories inside the private store. Explicit retirement preserves the files | |
| 67 | and history. Metadata-only recovery cannot promote a reserved candidate based on | |
| 68 | its receipt. Recorded-byte status is not current-policy authorization. | |
| 69 | ||
| 70 | Installed and known-good slot roles and health confirmation bound to an exact | |
| 71 | image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 53 | 72 | bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9. |
| @@ -155,12 +155,15 @@ 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. Owned candidate reservation/status/retirement retain | |
| 162 | exact identities and files without granting boot authority. Complete key revocation/repair, installed and | |
| 163 | known-good slot coordination, current-policy rollback and exact-image boot/session | |
| 164 | health before flashing. Standalone staging/inspection still need explicit trust | |
| 165 | and are separate from protected-current-policy selection. Host preparation does | |
| 166 | not satisfy full update/rollback or hardware anti-rollback acceptance. | |
| 164 | 167 | - Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog, |
| 165 | 168 | estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6. |
| 166 | 169 | |
| @@ -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, actual image preparation and durable candidate ownership](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, installed/known-good slots 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,22 @@ | ||
| 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 | "app/Commands/UpdatesReserve.ts", | |
| 274 | "app/Commands/UpdatesCandidates.ts", | |
| 275 | "app/Commands/UpdatesRetire.ts", | |
| 276 | "docs/runtime-updates.md", | |
| 277 | "tests/unit/updates-preparation.test.ts", | |
| 278 | "tests/unit/updates-candidates.test.ts", | |
| 279 | "tests/unit/updates-candidates-cli.test.ts" | |
| 271 | 280 | ], |
| 272 | 281 | "remaining": [ |
| 273 | 282 | "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations", |
| 274 | 283 | "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence", |
| 275 | 284 | "Measured board logging deadlines, memory, rotation and long-mission retention", |
| 276 | "Signed firmware updates, trust/key lifecycle, anti-rollback and known-good recovery slot", | |
| 285 | "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health", | |
| 277 | 286 | "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence" |
| 278 | 287 | ] |
| 279 | 288 | }, |
| @@ -2,10 +2,10 @@ | ||
| 2 | 2 | |
| 3 | 3 | Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image |
| 4 | 4 | staging, independent candidate verification, protected initial trust enrollment |
| 5 | and a persistent host metadata protocol. These commands prepare and verify host | |
| 6 | state. Integrating that protocol with authenticated image staging, revocation, | |
| 7 | installed images, boot selection and known-good rollback slots remains part of | |
| 8 | the full coordinator. Physical deployment is disabled. | |
| 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. | |
| 9 | 9 | |
| 10 | 10 | ## Explicit trust and policy |
| 11 | 11 | |
| @@ -136,8 +136,9 @@ compatible target's verified signed declaration. Refresh verifies metadata and | ||
| 136 | 136 | declared compatibility; it does not download or authenticate the actual image |
| 137 | 137 | bytes. An attempt marked `verified` means this metadata operation completed. It |
| 138 | 138 | does not mean an image is staged, installed, booted or confirmed healthy. Existing |
| 139 | `updates:stage` and `updates:inspect` remain separate and still report | |
| 140 | `trustStatePersisted: false`; integrating them with this store is unfinished. | |
| 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. | |
| 141 | 142 | |
| 142 | 143 | Timestamp or snapshot authority rotation, authenticated through sequential root |
| 143 | 144 | metadata, creates a new active epoch and invalidates both working-cache entries. |
| @@ -176,11 +177,158 @@ actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a | ||
| 176 | 177 | competing process, restart and retain the timestamp before a fresh refresh. These |
| 177 | 178 | cuts do not cover every write/sync boundary or actual physical power loss. |
| 178 | 179 | |
| 179 | The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB | |
| 180 | The metadata format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB | |
| 180 | 181 | state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per |
| 181 | 182 | file. Exhaustion fails closed; history is not automatically deleted or compacted. |
| 182 | 183 | Capacity migration, the full storage-fault campaign, authenticated repair/key |
| 183 | revocation, image/slot integration and session-bound health remain in #42. | |
| 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. The owned-candidate operation below adds durable reservation records; | |
| 228 | installed/known-good slots and current-policy revalidation before selection remain | |
| 229 | coordinator work in #42. | |
| 230 | ||
| 231 | Image or candidate-output failure during an attempt leaves accepted metadata and | |
| 232 | release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never | |
| 233 | reconstructs image success or confirms an existing candidate. Preserve partial | |
| 234 | outputs and use a new output directory for a fresh preparation after recovery. | |
| 235 | A complete candidate marker can exist when a later callback or process failure | |
| 236 | prevents the operation returning. Marker existence alone is not authorization. | |
| 237 | Repository, enrollment and protocol-store directories cannot contain the candidate | |
| 238 | output or be contained by it. Existing output directories are not overwritten. | |
| 239 | ||
| 240 | Host tests kill actual competing-writer-excluded processes at `image-written` | |
| 241 | and `candidate-completed` progress points. The first follows image-file sync; | |
| 242 | the second follows the shared file writer's final marker and directory syncs. | |
| 243 | After restart, metadata recovery retains release floor 9 and returns only a store | |
| 244 | summary; a fresh preparation uses another new directory. These measured process | |
| 245 | cuts do not establish every write/sync boundary, electrical power-loss behavior | |
| 246 | or hardware anti-rollback. | |
| 247 | ||
| 248 | ## Owned candidate reservations and retirement | |
| 249 | ||
| 250 | Use `updates:reserve` when the pinned store should own the candidate, rather than | |
| 251 | writing a standalone output directory. Repository and enrollment directories must | |
| 252 | remain separate from the store and cannot contain it or be contained by it. | |
| 253 | ||
| 254 | ```sh | |
| 255 | ./buddy updates:reserve /path/to/store --enrollment /path/to/trust \ | |
| 256 | --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \ | |
| 257 | --repository /path/to/offline-repository \ | |
| 258 | --trusted-system-clock-source operator-managed-utc-clock | |
| 259 | ||
| 260 | ./buddy updates:candidates /path/to/store --enrollment /path/to/trust \ | |
| 261 | --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" | |
| 262 | ||
| 263 | ./buddy updates:retire /path/to/store --enrollment /path/to/trust \ | |
| 264 | --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \ | |
| 265 | --candidate "$EXACT_ACTIVE_CANDIDATE_ID" | |
| 266 | ``` | |
| 267 | ||
| 268 | The library functions are `reserveRuntimeFirmwareCandidate`, | |
| 269 | `inspectRuntimeFirmwareCandidates` and `retireRuntimeFirmwareCandidate`. The | |
| 270 | native Buddy commands and copied standalone commands share them. A writer lease | |
| 271 | covers metadata verification, reservation, image output and completion. Only one | |
| 272 | candidate can remain active; another reservation fails before starting a metadata | |
| 273 | attempt. A fresh trusted clock and the existing protected SDK operation verify | |
| 274 | the signed selection, then commit its release floor before image acquisition. | |
| 275 | ||
| 276 | Reservation upgrades an exact format1 `state.sqlite` to format2 in one SQLite | |
| 277 | transaction, adding `candidates` and a partial unique index for one active | |
| 278 | reservation. It retains every metadata/attempt record, clock/release floor and | |
| 279 | store identity. `writer.sqlite` stays format1, and the independently retained | |
| 280 | `store.json` hash is unchanged. New code still reads format1 stores without | |
| 281 | migrating them during inspection. Older binaries fail closed on format2; a | |
| 282 | retained older executable was tested before and after upgrade, including unchanged | |
| 283 | state and writer database hashes after its rejection. Do not downgrade or reset a | |
| 284 | store to make an older binary operate on it. Unknown schemas remain rejected | |
| 285 | before configuration that could rewrite their format. | |
| 286 | ||
| 287 | Each candidate record binds a random ID, exact metadata attempt/generation and | |
| 288 | the generated signed-stage description/hash. The record is committed as | |
| 289 | `reserved` before image IO. Files are exclusively created under | |
| 290 | `store/candidates/<id>` with the same actual-image verification and archive writer | |
| 291 | as protected preparation. Only complete file, image and archive readback permits | |
| 292 | the `prepared` record commit. A receipt cannot introduce an owned record. | |
| 293 | ||
| 294 | `updates:candidates` returns `drone-runtime-candidates-v1`, `stateFormat`, protected | |
| 295 | `store` status, the active `candidate`, and bounded declaration `history`. History | |
| 296 | includes retired identities and paths; retired file integrity is not inspected. | |
| 297 | There are at most 128 retained candidate records. Exhaustion fails closed rather | |
| 298 | than deleting history or images. Active candidate `integrity` is: | |
| 299 | ||
| 300 | | Value | Meaning | | |
| 301 | | --- | --- | | |
| 302 | | `incomplete` | The record is still reserved, even if a stage marker exists | | |
| 303 | | `recorded-bytes-match` | Prepared image, receipt, root and archive match the owned record and its historical metadata bytes | | |
| 304 | | `mismatch` | Available file content or layout differs from the owned candidate | | |
| 305 | | `unavailable` | Files cannot be completely read or their protected file/directory requirements fail | | |
| 306 | ||
| 307 | This status is a local byte check. It does not establish fresh TUF expiration, | |
| 308 | revocation or current-policy authorization. `currentTrustValidated` and | |
| 309 | `physicalDeploymentAllowed` remain false. No installed, booted or healthy result | |
| 310 | is invented from a prepared record or matching files. | |
| 311 | ||
| 312 | After an interrupted or failed operation, preserve the whole store and its | |
| 313 | candidate files. Resolve the pending/maintenance metadata attempt with | |
| 314 | `updates:recover` first. Recovery never promotes `reserved` to `prepared`, even | |
| 315 | when a completed stage marker exists. A process can also stop after the prepared | |
| 316 | record commit but before attempt completion; the record survives, while metadata | |
| 317 | recovery still supplies no current selection authority. Missing/corrupt files | |
| 318 | remain actionable integrity failures. Explicit retirement requires the exact | |
| 319 | active ID and a resolved metadata attempt. It changes the record to `retired`, | |
| 320 | retains all files/history/floors, and allows a new ID with a new directory. It does | |
| 321 | not erase or repair the earlier artifact. | |
| 322 | ||
| 323 | Host qualification kills actual processes after reservation, image write, stage | |
| 324 | marker completion, prepared-record commit and retirement commit. Each cut observes | |
| 325 | an actual competing process rejected with SQLITE_BUSY, then restarts, checks | |
| 326 | unchanged candidate phase/floors and prepares a distinct fresh ID after explicit | |
| 327 | recovery/retirement. This does not cover every SQLite/filesystem write or sync, | |
| 328 | ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. Installed | |
| 329 | and known-good slot roles, identity-bound runtime health, current-policy selection | |
| 330 | and rollback, authenticated key maintenance and the full fault campaign remain | |
| 331 | required by #42. | |
| 184 | 332 | |
| 185 | 333 | ## Repository and signed image contract |
| 186 | 334 | |
| @@ -188,9 +336,11 @@ Use a standard TUF repository with `metadata/` and `targets/` directories, | ||
| 188 | 336 | including consistent-snapshot names when enabled. Verification uses the pinned |
| 189 | 337 | [`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root |
| 190 | 338 | rotation, signature thresholds, expiration, inter-role versions/hashes and |
| 191 | target length/hashes. A private, randomly addressed loopback server supplies | |
| 192 | bounded local files to the client's public HTTP interface. It is stopped after | |
| 193 | verification. No external network fetch or arbitrary URL option is provided. | |
| 339 | target length/hashes. Standalone staging/inspection use a private, randomly | |
| 340 | addressed loopback server supplying bounded local files to the public HTTP | |
| 341 | interface, then stop it. Protected refresh/preparation use the public Fetcher | |
| 342 | interface with bounded local files and the durable input journal. Neither path | |
| 343 | provides an external network fetch or arbitrary URL option. | |
| 194 | 344 | The host additionally verifies that root and delegation key IDs equal SHA-256 |
| 195 | 345 | of their canonical key objects, using the upstream canonical JSON library. |
| 196 | 346 | This rejects arbitrary key-ID aliases before role verification. |
| @@ -300,11 +450,11 @@ time. Neither path is a complete anti-rollback coordinator. Trusted clock | ||
| 300 | 450 | acquisition and protection against privileged clock/storage rollback remain |
| 301 | 451 | open. Control-loop freshness still uses its separate monotonic clock. |
| 302 | 452 | |
| 303 | Initial enrollment and the bounded persistent metadata protocol are available | |
| 304 | above. Complete authenticated maintenance/key revocation, the full storage-fault | |
| 305 | campaign and integration with signed image staging, then installed/known-good | |
| 306 | slot integrity, explicit | |
| 307 | health confirmation and rollback without lowering security floors. Then add | |
| 453 | Initial enrollment, the bounded persistent metadata protocol, protected | |
| 454 | signed-image preparation and durable candidate ownership are available above. | |
| 455 | Complete authenticated maintenance/key revocation and the full storage-fault | |
| 456 | campaign, then installed/known-good slot integrity, explicit health | |
| 457 | confirmation and rollback without lowering security floors. Then add | |
| 308 | 458 | the board bootloader/install protocol and measured power-loss, full-medium, |
| 309 | 459 | storage-latency and recovery campaigns. Local privileged filesystem tampering |
| 310 | 460 | 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,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 { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files' | |
| 8 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, writeExclusive } from './files' | |
| 9 | 9 | import { object, validateMetadata } from './metadata' |
| 10 | 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' |
| @@ -85,29 +86,14 @@ async function verifyRepository(repository: string, root: Buffer, work: string, | ||
| 85 | 86 | /** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */ |
| 86 | 87 | export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> { |
| 87 | 88 | const policy = admitPolicy(options) |
| 88 | const repository = realpathSync(options.repository), output = resolve(options.output) | |
| 89 | const repository = realpathSync(options.repository), output = runtimeCandidateOutput(options.output, [repository]) | |
| 89 | 90 | directory(repository) |
| 90 | 91 | const root = regularFile(options.trustedRoot, metadataLimit) |
| 91 | 92 | if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin') |
| 92 | // Resolve the existing parent before creating anything, so a symlinked parent cannot hide overlap. | |
| 93 | const destination = join(realpathSync(dirname(output)), output.slice(dirname(output).length + 1)) | |
| 94 | const child = relative(repository, destination), parent = relative(destination, repository) | |
| 95 | if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!parent.startsWith(`..${sep}`) && parent !== '..')) throw new Error('Candidate and source repository must be separate') | |
| 96 | 93 | mkdirSync(output, { mode: 0o700 }) |
| 97 | 94 | writeExclusive(join(output, 'trust-root.json'), root) |
| 98 | 95 | const { result, fetched, info } = await verifyRepository(repository, root, join(output, '.verification'), policy, join(output, 'image.bin')) |
| 99 | // Re-read the actual staged bytes, rather than trusting a successful copy or receipt. | |
| 100 | await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit))) | |
| 101 | for (const [path, bytes] of fetched) { | |
| 102 | const file = join(output, 'repository', path) | |
| 103 | mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) | |
| 104 | writeExclusive(file, bytes) | |
| 105 | } | |
| 106 | syncTree(output) | |
| 107 | // A final marker means logical completeness. It cannot attest to power-loss durability after fsync. | |
| 108 | writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(result, null, 2)}\n`)) | |
| 109 | syncDirectory(output) | |
| 110 | syncDirectory(dirname(output)) | |
| 96 | await finishRuntimeCandidate(output, info, result, fetched) | |
| 111 | 97 | return result |
| 112 | 98 | } |
| 113 | 99 | |
| @@ -4,9 +4,13 @@ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, u | ||
| 4 | 4 | import { tmpdir } from 'node:os' |
| 5 | 5 | import { dirname, join, resolve } from 'node:path' |
| 6 | 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' | |
| 7 | 11 | import { inspectRuntimeTrust } from './enrollment' |
| 8 | 12 | import type { RuntimeTrustSummary } from './enrollment' |
| 9 | import { directory, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files' | |
| 13 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files' | |
| 10 | 14 | import { object, validateMetadata } from './metadata' |
| 11 | 15 | import { admitSignedRuntime, RuntimeManifestRejected } from './manifest' |
| 12 | 16 | |
| @@ -21,6 +25,10 @@ const schema = [ | ||
| 21 | 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`, |
| 22 | 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`, |
| 23 | 27 | ] |
| 28 | const candidateSchema = [ | |
| 29 | 'CREATE TABLE candidates (id TEXT PRIMARY KEY CHECK(length(id) = 32), attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN (\'reserved\', \'prepared\', \'retired\')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT', | |
| 30 | "CREATE UNIQUE INDEX candidate_active ON candidates ((1)) WHERE phase IN ('reserved', 'prepared')", | |
| 31 | ] | |
| 24 | 32 | |
| 25 | 33 | interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string } |
| 26 | 34 | interface StoreReference extends TrustReference { directory: string, storeSha256: string } |
| @@ -59,7 +67,7 @@ function configure(db: Database) { | ||
| 59 | 67 | if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable') |
| 60 | 68 | } |
| 61 | 69 | } |
| 62 | function openDatabase(path: string, expectedSchema: string[]) { | |
| 70 | function openDatabase(path: string, expectedSchema: string[], allowCandidates = false) { | |
| 63 | 71 | const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes) |
| 64 | 72 | const db = new Database(path, { create: false, strict: true }) |
| 65 | 73 | try { |
| @@ -68,19 +76,20 @@ function openDatabase(path: string, expectedSchema: string[]) { | ||
| 68 | 76 | db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;') |
| 69 | 77 | // Authenticate the protocol format before any configuration that could |
| 70 | 78 | // rewrite a foreign database's journal/header format. |
| 71 | checkSchema(db, expectedSchema) | |
| 79 | const version = databaseVersion(db) | |
| 80 | checkSchema(db, allowCandidates && version === 2 ? [...expectedSchema, ...candidateSchema] : expectedSchema, allowCandidates && version === 2 ? 2 : 1) | |
| 72 | 81 | if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format') |
| 73 | 82 | configure(db) |
| 74 | 83 | return db |
| 75 | 84 | } |
| 76 | 85 | catch (error) { db.close(); throw error } |
| 77 | 86 | } |
| 78 | function checkSchema(db: Database, expected: string[]) { | |
| 87 | function databaseVersion(db: Database) { return db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version } | |
| 88 | function checkSchema(db: Database, expected: string[], version = 1) { | |
| 79 | 89 | const definitions = db.query<{ sql: string }, []>("SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").all().map(row => row.sql).sort() |
| 80 | 90 | if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema') |
| 81 | 91 | const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get() |
| 82 | const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get() | |
| 83 | if (values?.application_id !== applicationId || version?.user_version !== 1 || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version') | |
| 92 | if (values?.application_id !== applicationId || databaseVersion(db) !== version || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version') | |
| 84 | 93 | } |
| 85 | 94 | function createDatabase(path: string, statements: string[]) { |
| 86 | 95 | writeExclusive(path, Buffer.alloc(0)) |
| @@ -144,17 +153,22 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b | ||
| 144 | 153 | let writer: Database | undefined, db: Database | undefined |
| 145 | 154 | try { |
| 146 | 155 | for (const name of readdirSync(path)) { |
| 147 | if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry') | |
| 156 | if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates'].includes(name)) throw new Error('Unexpected update-store entry') | |
| 148 | 157 | if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true) |
| 149 | 158 | } |
| 150 | 159 | writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema]) |
| 151 | 160 | writer.exec('BEGIN IMMEDIATE') |
| 152 | 161 | const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all() |
| 153 | 162 | if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity') |
| 154 | db = openDatabase(join(path, 'state.sqlite'), schema) | |
| 163 | db = openDatabase(join(path, 'state.sqlite'), schema, true) | |
| 164 | if (readdirSync(path).includes('candidates')) { | |
| 165 | if (databaseVersion(db) !== 2) throw new Error('Candidate files require an owned format2 store') | |
| 166 | protectedDirectory(join(path, 'candidates')) | |
| 167 | } | |
| 155 | 168 | const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get() |
| 156 | 169 | if (initial?.sha256 !== trusted.policy.trustedRootSha256 || initial.version !== trusted.rootVersion || state(db, binding).release_floor < trusted.policy.minimumReleaseSequence) throw new Error('Update-store anchor or floor mismatch') |
| 157 | 170 | summary(db, binding, options.storeSha256) |
| 171 | for (const row of candidateRows(db)) ownedDescription(db, row, trusted) | |
| 158 | 172 | return await operation(db, binding, trusted, path) |
| 159 | 173 | } |
| 160 | 174 | finally { |
| @@ -203,7 +217,7 @@ export interface RuntimeUpdateClock { | ||
| 203 | 217 | } |
| 204 | 218 | |
| 205 | 219 | export interface RuntimeUpdateProgress { |
| 206 | phase: 'input-committed' | 'metadata-committed' | |
| 220 | phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired' | |
| 207 | 221 | ordinal: number |
| 208 | 222 | } |
| 209 | 223 | type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void> |
| @@ -258,10 +272,22 @@ function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal | ||
| 258 | 272 | return changed |
| 259 | 273 | } |
| 260 | 274 | |
| 261 | /** Verify bounded offline metadata and retain its accepted prefix. This does not stage, select or install an image. */ | |
| 262 | export async function refreshRuntimeTrustStore(options: StoreReference & { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 275 | interface RefreshOptions extends StoreReference { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver } | |
| 276 | interface VerifiedSelection { | |
| 277 | db: Database | |
| 278 | binding: Binding | |
| 279 | trusted: Readonly<RuntimeTrustSummary> | |
| 280 | source: string | |
| 281 | info: TargetFile | |
| 282 | runtime: ReturnType<typeof admitSignedRuntime> | |
| 283 | attempt: string | |
| 284 | path: string | |
| 285 | } | |
| 286 | ||
| 287 | async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> { | |
| 263 | 288 | options = { ...options } |
| 264 | return withStore(options, async (db, binding, trusted) => { | |
| 289 | return withStore(options, async (db, binding, trusted, path) => { | |
| 290 | before?.(db) | |
| 265 | 291 | if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery') |
| 266 | 292 | const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms) |
| 267 | 293 | if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached') |
| @@ -305,11 +331,14 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos | ||
| 305 | 331 | const info = await updater.getTargetInfo(trusted.policy.target) |
| 306 | 332 | if (!info) throw new Error('Selected runtime target is absent') |
| 307 | 333 | const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor }) |
| 334 | // The signed release floor survives later image or candidate-output failure. | |
| 308 | 335 | db.transaction(() => { |
| 309 | 336 | db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence) |
| 310 | db.query("UPDATE attempts SET phase = 'verified', accepted_release = ? WHERE id = ?").run(runtime.releaseSequence, attempt) | |
| 337 | db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt) | |
| 311 | 338 | })() |
| 312 | return summary(db, binding, options.storeSha256) | |
| 339 | const value = await complete({ db, binding, trusted, source, info, runtime, attempt, path }) | |
| 340 | db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt) | |
| 341 | return { store: summary(db, binding, options.storeSha256), value } | |
| 313 | 342 | } |
| 314 | 343 | catch (error) { |
| 315 | 344 | try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) } |
| @@ -320,6 +349,205 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos | ||
| 320 | 349 | }) |
| 321 | 350 | } |
| 322 | 351 | |
| 352 | /** Verify bounded offline metadata and retain its accepted prefix. This never downloads an image. */ | |
| 353 | export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 354 | return (await refreshAndComplete(options, async () => undefined)).store | |
| 355 | } | |
| 356 | ||
| 357 | export interface PreparedRuntimeFirmware { | |
| 358 | schema: 'drone-runtime-prepared-image-v1' | |
| 359 | store: Readonly<RuntimeTrustStoreSummary> | |
| 360 | candidate: Readonly<RuntimeFirmwareStage> | |
| 361 | trustStatePersisted: true | |
| 362 | physicalDeploymentAllowed: false | |
| 363 | } | |
| 364 | ||
| 365 | /** Authenticate an image under current protected metadata. Never installs, selects or confirms firmware. */ | |
| 366 | export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> { | |
| 367 | options = { ...options } | |
| 368 | const output = runtimeCandidateOutput(options.output, [options.repository, options.directory, options.enrollmentDirectory]) | |
| 369 | const result = await refreshAndComplete(options, selection => writePreparedCandidate(selection, output, options.onProgress)) | |
| 370 | return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false }) | |
| 371 | } | |
| 372 | ||
| 373 | function candidateDescription({ db, binding, trusted, info, runtime }: VerifiedSelection): RuntimeFirmwareStage { | |
| 374 | const root = activeMetadata(db, state(db, binding).generation).find(row => row.role === 'root')! | |
| 375 | return { 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 } | |
| 376 | } | |
| 377 | ||
| 378 | async function writePreparedCandidate(selection: VerifiedSelection, output: string, onProgress?: ProgressObserver) { | |
| 379 | const { db, binding, source, info } = selection | |
| 380 | const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')! | |
| 381 | const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true | |
| 382 | const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path | |
| 383 | const image = repositoryFile(source, `targets/${path}`, imageLimit) | |
| 384 | await info.verify(Readable.from(image)) | |
| 385 | const files = new Map<string, Buffer>() | |
| 386 | for (const row of rows) { | |
| 387 | if (row.role === 'root') continue | |
| 388 | files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row)) | |
| 389 | } | |
| 390 | files.set(`targets/${path}`, image) | |
| 391 | const candidate = candidateDescription(selection) | |
| 392 | mkdirSync(output, { mode: 0o700 }) | |
| 393 | writeExclusive(join(output, 'trust-root.json'), rootBytes) | |
| 394 | writeExclusive(join(output, 'image.bin'), image) | |
| 395 | await onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 })) | |
| 396 | await finishRuntimeCandidate(output, info, candidate, files) | |
| 397 | await onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 })) | |
| 398 | return Object.freeze(candidate) | |
| 399 | } | |
| 400 | ||
| 401 | interface CandidateRow { id: string, attempt_id: string, generation: number, phase: 'reserved' | 'prepared' | 'retired', description: Uint8Array, sha256: string } | |
| 402 | export interface RuntimeFirmwareCandidate { | |
| 403 | id: string | |
| 404 | attemptId: string | |
| 405 | generation: number | |
| 406 | phase: CandidateRow['phase'] | |
| 407 | directory: string | |
| 408 | description: Readonly<RuntimeFirmwareStage> | |
| 409 | integrity: 'incomplete' | 'recorded-bytes-match' | 'mismatch' | 'unavailable' | |
| 410 | currentTrustValidated: false | |
| 411 | } | |
| 412 | export interface RuntimeFirmwareCandidates { | |
| 413 | schema: 'drone-runtime-candidates-v1' | |
| 414 | stateFormat: 1 | 2 | |
| 415 | store: Readonly<RuntimeTrustStoreSummary> | |
| 416 | retainedCandidates: number | |
| 417 | /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */ | |
| 418 | history: ReadonlyArray<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>> | |
| 419 | candidate: Readonly<RuntimeFirmwareCandidate> | null | |
| 420 | physicalDeploymentAllowed: false | |
| 421 | } | |
| 422 | ||
| 423 | function candidateRows(db: Database) { | |
| 424 | if (databaseVersion(db) === 1) return [] | |
| 425 | const rows = db.query<CandidateRow, []>('SELECT * FROM candidates ORDER BY rowid LIMIT 129').all() | |
| 426 | if (rows.length > 128) throw new Error('Candidate history budget exceeded') | |
| 427 | return rows | |
| 428 | } | |
| 429 | function ownedDescription(db: Database, row: CandidateRow, trusted: Readonly<RuntimeTrustSummary>): Readonly<RuntimeFirmwareStage> { | |
| 430 | const bytes = Buffer.from(row.description) | |
| 431 | if (!/^[a-f0-9]{32}$/.test(row.id) || sha256(bytes) !== row.sha256) throw new Error('Invalid owned candidate record') | |
| 432 | const value = object(JSON.parse(bytes.toString('utf8'))) | |
| 433 | const keys = ['schema', 'target', 'platform', 'trustedRootSha256', 'rootVersion', 'releaseSequence', 'protocolVersion', 'sourceRevision', 'signerDeclaredProfile', 'length', 'sha256', 'trustStatePersisted', 'physicalDeploymentAllowed'] | |
| 434 | const root = activeMetadata(db, row.generation).find(item => item.role === 'root')! | |
| 435 | checkedMetadataBytes(root) | |
| 436 | const attempt = db.query<Attempt, [string]>('SELECT * FROM attempts WHERE id = ?').get(row.attempt_id) | |
| 437 | if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key)) || value.schema !== 'drone-runtime-stage-v1' || value.target !== trusted.policy.target || value.platform !== trusted.policy.platform || value.trustedRootSha256 !== root.sha256 || value.rootVersion !== root.version || !Number.isSafeInteger(value.releaseSequence) || (value.releaseSequence as number) < trusted.policy.minimumReleaseSequence || value.protocolVersion !== 1 || typeof value.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(value.sourceRevision) || value.signerDeclaredProfile !== 'neutral-bench' || !Number.isSafeInteger(value.length) || (value.length as number) < 1 || (value.length as number) > imageLimit || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || value.trustStatePersisted !== false || value.physicalDeploymentAllowed !== false || !attempt || attempt.accepted_release !== value.releaseSequence || row.generation < attempt.base_generation) throw new Error('Owned candidate does not match protected selection') | |
| 438 | return Object.freeze(value as unknown as RuntimeFirmwareStage) | |
| 439 | } | |
| 440 | function candidateIntegrity(db: Database, generation: number, path: string, description: Readonly<RuntimeFirmwareStage>): RuntimeFirmwareCandidate['integrity'] { | |
| 441 | try { | |
| 442 | protectedDirectory(path) | |
| 443 | const rows = activeMetadata(db, generation), root = rows.find(row => row.role === 'root')! | |
| 444 | const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true | |
| 445 | const imagePath = consistent ? description.target.replace(/([^/]+)$/, `${description.sha256}.$1`) : description.target | |
| 446 | const expected = new Map<string, Buffer>([['trust-root.json', rootBytes], ['stage.json', Buffer.from(`${JSON.stringify(description, null, 2)}\n`)]]) | |
| 447 | for (const row of rows) { | |
| 448 | if (row.role !== 'root') expected.set(`repository/metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row)) | |
| 449 | } | |
| 450 | for (const file of ['image.bin', `repository/targets/${imagePath}`]) { | |
| 451 | const address = join(path, file) | |
| 452 | protectedFile(address, imageLimit) | |
| 453 | const bytes = regularFile(address, imageLimit) | |
| 454 | if (bytes.length !== description.length || sha256(bytes) !== description.sha256) return 'mismatch' | |
| 455 | expected.set(file, bytes) | |
| 456 | } | |
| 457 | const directories = new Set<string>() | |
| 458 | for (const name of expected.keys()) { | |
| 459 | for (let parent = dirname(name); parent !== '.'; parent = dirname(parent)) directories.add(parent) | |
| 460 | } | |
| 461 | const visit = (relative: string): boolean => { | |
| 462 | const base = relative ? join(path, relative) : path | |
| 463 | protectedDirectory(base) | |
| 464 | const names = readdirSync(base) | |
| 465 | if (names.length > 64) return false | |
| 466 | return names.every(name => { | |
| 467 | const child = relative ? `${relative}/${name}` : name | |
| 468 | if (directories.has(child)) return visit(child) | |
| 469 | const bytes = expected.get(child) | |
| 470 | if (!bytes) return false | |
| 471 | protectedFile(join(path, child), child.endsWith('image.bin') || child.startsWith('repository/targets/') ? imageLimit : metadataLimit) | |
| 472 | return regularFile(join(path, child), Math.max(bytes.length, 1)).equals(bytes) | |
| 473 | }) | |
| 474 | } | |
| 475 | if (!visit('')) return 'mismatch' | |
| 476 | for (const [name, bytes] of expected) { | |
| 477 | protectedFile(join(path, name), Math.max(bytes.length, 1)) | |
| 478 | if (!regularFile(join(path, name), Math.max(bytes.length, 1)).equals(bytes)) return 'mismatch' | |
| 479 | } | |
| 480 | return 'recorded-bytes-match' | |
| 481 | } | |
| 482 | catch { return 'unavailable' } | |
| 483 | } | |
| 484 | function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string, storeSha256: string): Readonly<RuntimeFirmwareCandidates> { | |
| 485 | const rows = candidateRows(db), active = rows.filter(row => row.phase !== 'retired') | |
| 486 | if (active.length > 1) throw new Error('Multiple active candidate records') | |
| 487 | let candidate: Readonly<RuntimeFirmwareCandidate> | null = null | |
| 488 | const history: Array<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>> = [] | |
| 489 | for (const row of rows) { | |
| 490 | const description = ownedDescription(db, row, trusted) | |
| 491 | const directory = join(path, 'candidates', row.id) | |
| 492 | const recorded = Object.freeze({ id: row.id, attemptId: row.attempt_id, generation: row.generation, phase: row.phase, directory, description }) | |
| 493 | history.push(recorded) | |
| 494 | if (row === active[0]) { | |
| 495 | candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false }) | |
| 496 | } | |
| 497 | } | |
| 498 | return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false }) | |
| 499 | } | |
| 500 | ||
| 501 | /** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */ | |
| 502 | export async function inspectRuntimeFirmwareCandidates(options: StoreReference): Promise<Readonly<RuntimeFirmwareCandidates>> { | |
| 503 | return withStore(options, (db, binding, trusted, path) => candidatesSummary(db, binding, trusted, path, options.storeSha256)) | |
| 504 | } | |
| 505 | ||
| 506 | /** Release only the exact active candidate reservation. Retain its records and bytes; never reset trust floors. */ | |
| 507 | export async function retireRuntimeFirmwareCandidate(options: StoreReference & { candidateId: string, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeFirmwareCandidates>> { | |
| 508 | options = { ...options } | |
| 509 | if (typeof options.candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(options.candidateId)) throw new Error('Exact active candidate identity required') | |
| 510 | return withStore(options, async (db, binding, trusted, path) => { | |
| 511 | const current = candidatesSummary(db, binding, trusted, path, options.storeSha256) | |
| 512 | if (current.store.attempt?.phase === 'pending' || current.store.attempt?.phase === 'maintenance') throw new Error('Resolve the metadata attempt before candidate retirement') | |
| 513 | if (!current.candidate || current.candidate.id !== options.candidateId) throw new Error('Exact active candidate identity required') | |
| 514 | db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase IN ('reserved', 'prepared')").run(options.candidateId) | |
| 515 | await options.onProgress?.(Object.freeze({ phase: 'candidate-retired', ordinal: 0 })) | |
| 516 | return candidatesSummary(db, binding, trusted, path, options.storeSha256) | |
| 517 | }) | |
| 518 | } | |
| 519 | ||
| 520 | /** Reserve one owned candidate and prepare signed bytes. No installed slot or health confirmation is changed. */ | |
| 521 | export async function reserveRuntimeFirmwareCandidate(options: RefreshOptions): Promise<Readonly<RuntimeFirmwareCandidates>> { | |
| 522 | options = { ...options } | |
| 523 | runtimeCandidateOutput(options.directory, [options.repository, options.enrollmentDirectory]) | |
| 524 | const result = await refreshAndComplete(options, async selection => { | |
| 525 | const { db, binding, trusted, path, attempt } = selection | |
| 526 | if (databaseVersion(db) === 1) { | |
| 527 | db.transaction(() => { for (const sql of candidateSchema) db.exec(sql); db.exec('PRAGMA user_version=2') })() | |
| 528 | checkSchema(db, [...schema, ...candidateSchema], 2) | |
| 529 | } | |
| 530 | const id = randomBytes(16).toString('hex'), description = candidateDescription(selection) | |
| 531 | const bytes = Buffer.from(`${JSON.stringify(description)}\n`), generation = state(db, binding).generation | |
| 532 | db.query("INSERT INTO candidates VALUES(?, ?, ?, 'reserved', ?, ?)").run(id, attempt, generation, bytes, sha256(bytes)) | |
| 533 | await options.onProgress?.(Object.freeze({ phase: 'candidate-reserved', ordinal: 0 })) | |
| 534 | const parent = join(path, 'candidates') | |
| 535 | if (!readdirSync(path).includes('candidates')) { mkdirSync(parent, { mode: 0o700 }); syncDirectory(path) } | |
| 536 | protectedDirectory(parent) | |
| 537 | const output = join(parent, id) | |
| 538 | await writePreparedCandidate(selection, output, options.onProgress) | |
| 539 | if (candidateIntegrity(db, generation, output, description) !== 'recorded-bytes-match') throw new Error('Owned candidate file readback failed') | |
| 540 | db.query("UPDATE candidates SET phase = 'prepared' WHERE id = ? AND phase = 'reserved'").run(id) | |
| 541 | await options.onProgress?.(Object.freeze({ phase: 'candidate-recorded', ordinal: 0 })) | |
| 542 | return candidatesSummary(db, binding, trusted, path, options.storeSha256) | |
| 543 | }, db => { | |
| 544 | const rows = candidateRows(db) | |
| 545 | if (rows.some(row => row.phase !== 'retired')) throw new Error('An active candidate requires explicit retirement or slot transition') | |
| 546 | if (rows.length >= 128) throw new Error('Candidate history budget reached') | |
| 547 | }) | |
| 548 | return Object.freeze({ ...result.value, store: result.store }) | |
| 549 | } | |
| 550 | ||
| 323 | 551 | function causedBy(error: unknown, marker: Error) { |
| 324 | 552 | const seen = new Set<unknown>() |
| 325 | 553 | for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) { |
| @@ -20,6 +20,9 @@ export async function epochRepository(directory: string, duplicateSnapshotKey = | ||
| 20 | 20 | async function publish(epoch: 1 | 2, version: number, releaseSequence: number) { |
| 21 | 21 | if (epoch === 2) await writeFile(join(directory, 'metadata/2.root.json'), root(2)) |
| 22 | 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) | |
| 23 | 26 | const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench' } |
| 24 | 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 } } }) } })) |
| 25 | 28 | targets.sign(keys.targets.sign) |
| @@ -32,6 +35,7 @@ export async function epochRepository(directory: string, duplicateSnapshotKey = | ||
| 32 | 35 | await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON())) |
| 33 | 36 | await writeFile(join(directory, `metadata/${version}.snapshot.json`), snapshotBytes) |
| 34 | 37 | await writeFile(join(directory, `metadata/${version}.targets.json`), targetBytes) |
| 38 | return { image, imageHash, imagePath } | |
| 35 | 39 | } |
| 36 | 40 | return { rootPath, rootSha256: digest(anchor), publish } |
| 37 | 41 | } |
| @@ -0,0 +1,90 @@ | ||
| 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 | /** Real copied commands, signed synthetic bytes and optional retained older binary. Never executes a candidate. */ | |
| 11 | export async function qualifyPortableCandidates(directory: string, source: string, olderBinary?: 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 olderSha256 = olderBinary ? await hash(olderBinary) : null | |
| 17 | const repo = join(directory, 'repo'), fixture = await repository(repo, { rotate: true, custom: { releaseSequence: 9 } }) | |
| 18 | const enrollment = join(directory, 'enrollment'), store = join(directory, 'store') | |
| 19 | const results: { command: string, binarySha256: string, exit: number, stdout: string, stderr: string }[] = [] | |
| 20 | async function run(args: string[], expected = 0, executable = binary) { | |
| 21 | const child = Bun.spawn([executable, ...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]!, binarySha256: executable === binary ? sourceSha256 : olderSha256!, exit, stdout, stderr }); assert.equal(exit, expected) | |
| 26 | if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') } | |
| 27 | return stdout ? JSON.parse(stdout) : undefined | |
| 28 | } | |
| 29 | finally { clearTimeout(timer) } | |
| 30 | } | |
| 31 | try { | |
| 32 | const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']) | |
| 33 | const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256] | |
| 34 | const created = await run(['updates:store-init', ...flags, '--output', store]) | |
| 35 | const pinned = [...flags, '--store-sha256', created.storeSha256] | |
| 36 | const repositoryFlags = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock'] | |
| 37 | const initialHash = await hash(join(store, 'state.sqlite')) | |
| 38 | const empty = await run(['updates:candidates', store, ...pinned]) | |
| 39 | assert.equal(empty.stateFormat, 1); assert.equal(empty.candidate, null) | |
| 40 | assert.equal(await hash(join(store, 'state.sqlite')), initialHash) | |
| 41 | if (olderBinary) assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), created) | |
| 42 | await run(['updates:reserve', store, ...pinned, '--repository', repo], 1) | |
| 43 | const prepared = await run(['updates:reserve', store, ...pinned, ...repositoryFlags]) | |
| 44 | assert.equal(prepared.stateFormat, 2); assert.equal(prepared.store.releaseFloor, 9) | |
| 45 | assert.equal(prepared.store.attempt.phase, 'verified'); assert.equal(prepared.store.metadata.find((row: { role: string }) => row.role === 'root').version, 2) | |
| 46 | assert.equal(prepared.candidate.phase, 'prepared'); assert.equal(prepared.candidate.integrity, 'recorded-bytes-match') | |
| 47 | assert.equal(prepared.candidate.description.sha256, fixture.imageHash); assert.equal(prepared.candidate.currentTrustValidated, false); assert.equal(prepared.physicalDeploymentAllowed, false) | |
| 48 | assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared) | |
| 49 | await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1) | |
| 50 | await run(['updates:retire', store, ...pinned, '--candidate', '0'.repeat(32)], 1) | |
| 51 | assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared) | |
| 52 | if (olderBinary) { | |
| 53 | const stateHash = await hash(join(store, 'state.sqlite')), writerHash = await hash(join(store, 'writer.sqlite')) | |
| 54 | await run(['updates:store', store, ...pinned], 1, olderBinary) | |
| 55 | assert.equal(await hash(join(store, 'state.sqlite')), stateHash); assert.equal(await hash(join(store, 'writer.sqlite')), writerHash) | |
| 56 | assert.equal(await hash(olderBinary), olderSha256) | |
| 57 | } | |
| 58 | await run(['updates:candidates', store, ...flags, '--store-sha256', '0'.repeat(64)], 1) | |
| 59 | const original = await readFile(join(prepared.candidate.directory, 'stage.json')) | |
| 60 | await writeFile(join(prepared.candidate.directory, 'image.bin'), Buffer.alloc(fixture.image.length, 0xff)) | |
| 61 | const damaged = await run(['updates:candidates', store, ...pinned]) | |
| 62 | assert.equal(damaged.candidate.integrity, 'mismatch'); assert.equal(damaged.candidate.currentTrustValidated, false) | |
| 63 | await writeFile(join(prepared.candidate.directory, 'image.bin'), fixture.image) | |
| 64 | const retired = await run(['updates:retire', store, ...pinned, '--candidate', prepared.candidate.id]) | |
| 65 | assert.equal(retired.candidate, null); assert.equal(retired.retainedCandidates, 1); assert.equal(retired.store.releaseFloor, 9) | |
| 66 | assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original) | |
| 67 | await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff)) | |
| 68 | await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1) | |
| 69 | const stopped = await run(['updates:candidates', store, ...pinned]) | |
| 70 | assert.equal(stopped.candidate.phase, 'reserved'); assert.equal(stopped.candidate.integrity, 'incomplete') | |
| 71 | assert.equal(stopped.store.releaseFloor, 9); assert.equal(stopped.store.attempt.phase, 'maintenance') | |
| 72 | await run(['updates:recover', store, ...pinned]) | |
| 73 | const recovered = await run(['updates:candidates', store, ...pinned]) | |
| 74 | assert.deepEqual(recovered.candidate, stopped.candidate); assert.equal(recovered.store.releaseFloor, 9) | |
| 75 | await run(['updates:retire', store, ...pinned, '--candidate', stopped.candidate.id]) | |
| 76 | await writeFile(fixture.imagePath, fixture.image) | |
| 77 | const fresh = await run(['updates:reserve', store, ...pinned, ...repositoryFlags]) | |
| 78 | assert.equal(fresh.retainedCandidates, 3); assert.notEqual(fresh.candidate.id, prepared.candidate.id) | |
| 79 | assert.equal(fresh.candidate.integrity, 'recorded-bytes-match'); assert.equal(fresh.store.releaseFloor, 9) | |
| 80 | assert.equal(fresh.candidate.currentTrustValidated, false); assert.equal(fresh.physicalDeploymentAllowed, false) | |
| 81 | assert.deepEqual(await readFile(join(prepared.candidate.directory, 'image.bin')), fixture.image) | |
| 82 | assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original) | |
| 83 | } | |
| 84 | finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-owned-candidates-v1', sourceSha256, olderSha256, results, physicalDeploymentAllowed: false }, null, 2)) } | |
| 85 | return { evidence: 'copied-standalone-owned-candidates-v1', operations: results.length, sourceSha256, olderSha256, physicalDeploymentAllowed: false } | |
| 86 | } | |
| 87 | if (import.meta.main) { | |
| 88 | if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [OLDER_BINARY]') | |
| 89 | console.log(JSON.stringify(await qualifyPortableCandidates(resolve(process.argv[2]!), resolve(process.argv[3]!), process.argv[4] ? resolve(process.argv[4]) : undefined))) | |
| 90 | } | |
| @@ -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 | } | |
| @@ -19,18 +19,18 @@ export 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 | } |
| @@ -3,12 +3,13 @@ import { spawnSync } from 'node:child_process' | ||
| 3 | 3 | import { mkdir, readFile, writeFile } from 'node:fs/promises' |
| 4 | 4 | import { join, resolve } from 'node:path' |
| 5 | 5 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' |
| 6 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store' | |
| 6 | import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store' | |
| 7 | import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store' | |
| 7 | 8 | import { platform, repository, target } from './repository' |
| 8 | 9 | |
| 9 | 10 | type Reference = Parameters<typeof inspectRuntimeTrustStore>[0] |
| 10 | type Cut = 'input-committed' | 'metadata-committed' | |
| 11 | interface WorkerInput { reference: Reference, repository: string, cut: Cut } | |
| 11 | type Cut = RuntimeUpdateProgress['phase'] | |
| 12 | interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string, managed?: boolean, candidateId?: string } | |
| 12 | 13 | const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } |
| 13 | 14 | |
| 14 | 15 | async function worker(mode: string, file: string) { |
| @@ -23,27 +24,35 @@ async function worker(mode: string, file: string) { | ||
| 23 | 24 | return |
| 24 | 25 | } |
| 25 | 26 | if (mode !== 'hold') throw new Error('Unknown interruption worker mode') |
| 26 | await refreshRuntimeTrustStore({ ...input.reference, repository: input.repository, clock, async onProgress(event) { | |
| 27 | const options = { ...input.reference, repository: input.repository, clock, async onProgress(event: Readonly<RuntimeUpdateProgress>) { | |
| 27 | 28 | if (event.phase !== input.cut || event.ordinal !== (input.cut === 'input-committed' ? 1 : 0)) return |
| 28 | 29 | console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal })) |
| 29 | 30 | await Bun.stdin.text() |
| 30 | } }) | |
| 31 | } } | |
| 32 | if (input.candidateId) await retireRuntimeFirmwareCandidate({ ...options, candidateId: input.candidateId }) | |
| 33 | else if (input.managed) await reserveRuntimeFirmwareCandidate(options) | |
| 34 | else if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output }) | |
| 35 | else await refreshRuntimeTrustStore(options) | |
| 31 | 36 | throw new Error('Interruption worker unexpectedly finished') |
| 32 | 37 | } |
| 33 | 38 | |
| 34 | 39 | /** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */ |
| 35 | export async function qualifyStoreInterruptions(directory: string) { | |
| 40 | async function qualifyInterruptions(directory: string, operation: 'refresh' | 'prepare' | 'reserve') { | |
| 36 | 41 | await mkdir(directory, { mode: 0o700 }) |
| 37 | 42 | const results = [] |
| 38 | for (const cut of ['input-committed', 'metadata-committed'] as const) { | |
| 43 | const managed = operation === 'reserve', preparation = operation !== 'refresh' | |
| 44 | const cuts: Cut[] = managed ? ['candidate-reserved', 'image-written', 'candidate-completed', 'candidate-recorded', 'candidate-retired'] : preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed'] | |
| 45 | for (const cut of cuts) { | |
| 39 | 46 | const root = join(directory, cut); await mkdir(root, { mode: 0o700 }) |
| 40 | 47 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) |
| 41 | 48 | const enrollmentDirectory = join(root, 'enrollment') |
| 42 | 49 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) |
| 43 | 50 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) |
| 44 | 51 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 } |
| 52 | const prior = cut === 'candidate-retired' ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : null | |
| 45 | 53 | const file = join(root, 'worker.json') |
| 46 | await writeFile(file, JSON.stringify({ reference, repository: source, cut } satisfies WorkerInput), { mode: 0o600 }) | |
| 54 | let output = prior?.candidate?.directory ?? (!managed && preparation ? join(root, 'candidate') : undefined) | |
| 55 | await writeFile(file, JSON.stringify({ reference, repository: source, cut, output: managed ? undefined : output, managed, candidateId: prior?.candidate?.id } satisfies WorkerInput), { mode: 0o600 }) | |
| 47 | 56 | const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) |
| 48 | 57 | const timer = setTimeout(() => child.kill('SIGKILL'), 15000) |
| 49 | 58 | const reader = child.stdout.getReader(), decoder = new TextDecoder() |
| @@ -64,15 +73,42 @@ export async function qualifyStoreInterruptions(directory: string) { | ||
| 64 | 73 | const exit = await child.exited, signal = child.signalCode |
| 65 | 74 | assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0) |
| 66 | 75 | const interrupted = await inspectRuntimeTrustStore(reference) |
| 67 | assert.equal(interrupted.attempt?.phase, 'pending') | |
| 68 | assert.equal(interrupted.generation, cut === 'input-committed' ? 0 : 1) | |
| 69 | const recovered = await recoverRuntimeTrustStore(reference) | |
| 70 | assert.equal(recovered.generation, 1); assert.equal(recovered.attempt?.phase, 'recovered') | |
| 71 | assert.equal(recovered.releaseFloor, 7) | |
| 72 | const fresh = await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 73 | assert.equal(fresh.generation, 3); assert.equal(fresh.releaseFloor, 9) | |
| 76 | assert.equal(interrupted.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'pending') | |
| 77 | assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1) | |
| 78 | assert.equal(interrupted.releaseFloor, preparation ? 9 : 7) | |
| 79 | const candidateBefore = managed ? await inspectRuntimeFirmwareCandidates(reference) : null | |
| 80 | if (managed && cut !== 'candidate-retired') { | |
| 81 | assert.equal(candidateBefore!.candidate!.phase, cut === 'candidate-recorded' ? 'prepared' : 'reserved') | |
| 82 | assert.equal(candidateBefore!.candidate!.currentTrustValidated, false) | |
| 83 | output = candidateBefore!.candidate!.directory | |
| 84 | } | |
| 85 | if (cut === 'candidate-retired') assert.equal(candidateBefore!.candidate, null) | |
| 86 | const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false | |
| 87 | if (output) { | |
| 88 | if (cut !== 'candidate-reserved') assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image) | |
| 89 | assert.equal(candidateStagePresent, ['candidate-completed', 'candidate-recorded', 'candidate-retired'].includes(cut)) | |
| 90 | } | |
| 91 | const recovered = cut === 'candidate-retired' ? await inspectRuntimeTrustStore(reference) : await recoverRuntimeTrustStore(reference) | |
| 92 | assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'recovered') | |
| 93 | assert.equal(recovered.releaseFloor, preparation ? 9 : 7) | |
| 94 | assert.equal(Object.hasOwn(recovered, 'candidate'), false) | |
| 95 | if (managed) { | |
| 96 | assert.deepEqual((await inspectRuntimeFirmwareCandidates(reference)).candidate, candidateBefore!.candidate) | |
| 97 | if (candidateBefore!.candidate) await retireRuntimeFirmwareCandidate({ ...reference, candidateId: candidateBefore!.candidate.id }) | |
| 98 | } | |
| 99 | const completed = managed ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 100 | const fresh = completed.schema === 'drone-runtime-prepared-image-v1' || completed.schema === 'drone-runtime-candidates-v1' ? completed.store : completed | |
| 101 | if (completed.schema === 'drone-runtime-candidates-v1') { | |
| 102 | assert.equal(completed.retainedCandidates, 2) | |
| 103 | assert.equal(completed.candidate!.integrity, 'recorded-bytes-match') | |
| 104 | assert.notEqual(completed.candidate!.id, candidateBefore!.candidate?.id ?? prior!.candidate!.id) | |
| 105 | if (candidateStagePresent) assert.deepEqual(await readFile(join(output!, 'image.bin')), fixture.image) | |
| 106 | } | |
| 107 | assert.equal(fresh.generation >= interrupted.generation, true) | |
| 108 | if (!preparation) assert.equal(fresh.generation, 3) | |
| 109 | assert.equal(fresh.releaseFloor, 9) | |
| 74 | 110 | assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false) |
| 75 | results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, interrupted, recovered, fresh }) | |
| 111 | results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, candidateBefore, interrupted, recovered, fresh }) | |
| 76 | 112 | } |
| 77 | 113 | finally { |
| 78 | 114 | clearTimeout(timer) |
| @@ -81,14 +117,20 @@ export async function qualifyStoreInterruptions(directory: string) { | ||
| 81 | 117 | reader.releaseLock() |
| 82 | 118 | await writeFile(join(root, 'worker.stdout'), stdout) |
| 83 | 119 | await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text()) |
| 84 | await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', results, physicalDeploymentAllowed: false }, null, 2)) | |
| 120 | await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation, results, physicalDeploymentAllowed: false }, null, 2)) | |
| 85 | 121 | } |
| 86 | 122 | } |
| 87 | return { evidence: 'host-process-interruption-only', cuts: results.length, physicalDeploymentAllowed: false } | |
| 123 | return { evidence: 'host-process-interruption-only', operation, cuts: results.length, physicalDeploymentAllowed: false } | |
| 88 | 124 | } |
| 89 | 125 | |
| 126 | export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, 'refresh') } | |
| 127 | export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, 'prepare') } | |
| 128 | export async function qualifyCandidateInterruptions(directory: string) { return qualifyInterruptions(directory, 'reserve') } | |
| 129 | ||
| 90 | 130 | if (import.meta.main) { |
| 91 | if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!) | |
| 131 | if (process.argv.length === 4 && process.argv[2] === 'reserve') console.log(JSON.stringify(await qualifyCandidateInterruptions(resolve(process.argv[3]!)))) | |
| 132 | else if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!)))) | |
| 133 | else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!) | |
| 92 | 134 | else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!)))) |
| 93 | else throw new Error('Use store-interruptions.ts NEW_DIRECTORY') | |
| 135 | else throw new Error('Use store-interruptions.ts [prepare|reserve] NEW_DIRECTORY') | |
| 94 | 136 | } |
| @@ -0,0 +1,43 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, readFile, 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 { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, 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 candidate commands require pinned trust and clock, retain identity and preserve explicit retirement semantics', async () => { | |
| 14 | const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-cli-')); roots.push(root) | |
| 15 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) | |
| 16 | const enrollment = join(root, 'enrollment'), directory = join(root, 'store') | |
| 17 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollment }) | |
| 18 | const created = await createRuntimeTrustStore({ enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 19 | const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256] | |
| 20 | const reserve = await command(['updates:reserve', directory, ...flags, '--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000) | |
| 21 | expect(reserve.exitCode).toBe(0) | |
| 22 | const value = JSON.parse(reserve.stdout) | |
| 23 | expect(value.candidate.phase).toBe('prepared') | |
| 24 | expect(value.candidate.integrity).toBe('recorded-bytes-match') | |
| 25 | expect(value.candidate.currentTrustValidated).toBe(false) | |
| 26 | expect(value.store.releaseFloor).toBe(9) | |
| 27 | expect(value.physicalDeploymentAllowed).toBe(false) | |
| 28 | const status = await command(['updates:candidates', directory, ...flags]).withTimeout(20000) | |
| 29 | expect(status.exitCode).toBe(0) | |
| 30 | expect(JSON.parse(status.stdout)).toEqual(value) | |
| 31 | const missingClock = await command(['updates:reserve', directory, ...flags, '--repository', source]).withTimeout(20000) | |
| 32 | expect(missingClock.exitCode).toBe(1) | |
| 33 | expect(missingClock.stdout).toBe('') | |
| 34 | expect(missingClock.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n') | |
| 35 | const wrong = await command(['updates:retire', directory, ...flags, '--candidate', '0'.repeat(32)]).withTimeout(20000) | |
| 36 | expect(wrong.exitCode).toBe(1) | |
| 37 | expect(wrong.stdout).toBe('') | |
| 38 | const retired = await command(['updates:retire', directory, ...flags, '--candidate', value.candidate.id]).withTimeout(20000) | |
| 39 | expect(retired.exitCode).toBe(0) | |
| 40 | expect(JSON.parse(retired.stdout).candidate).toBeNull() | |
| 41 | expect(JSON.parse(retired.stdout).store.releaseFloor).toBe(9) | |
| 42 | expect(await readFile(join(value.candidate.directory, 'image.bin'))).toEqual(fixture.image) | |
| 43 | }, 120000) | |
| @@ -0,0 +1,188 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { Database } from 'bun:sqlite' | |
| 3 | import { mkdtemp, readFile, 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, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 9 | import { qualifyCandidateInterruptions } 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: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 15 | async function setup() { | |
| 16 | const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-')); roots.push(root) | |
| 17 | const source = join(root, 'repository'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) | |
| 18 | const enrollmentDirectory = join(root, 'enrollment') | |
| 19 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 20 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 21 | return { root, source, fixture, reference: { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } } | |
| 22 | } | |
| 23 | ||
| 24 | test('the pinned store exclusively owns a prepared candidate and retains its authenticated identity across reopening', async () => { | |
| 25 | const item = await setup(), before = await inspectRuntimeTrustStore(item.reference) | |
| 26 | expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate).toBeNull() | |
| 27 | const result = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock }) | |
| 28 | expect(result.schema).toBe('drone-runtime-candidates-v1') | |
| 29 | expect(result.stateFormat).toBe(2) | |
| 30 | expect(result.store.storeId).toBe(before.storeId) | |
| 31 | expect(result.store.releaseFloor).toBe(9) | |
| 32 | expect(result.store.generation).toBe(3) | |
| 33 | expect(result.store.attempt?.phase).toBe('verified') | |
| 34 | expect(result.retainedCandidates).toBe(1) | |
| 35 | expect(result.candidate?.phase).toBe('prepared') | |
| 36 | expect(result.candidate?.integrity).toBe('recorded-bytes-match') | |
| 37 | expect(result.candidate?.description.sha256).toBe(item.fixture.imageHash) | |
| 38 | expect(result.candidate?.description.releaseSequence).toBe(9) | |
| 39 | expect(result.candidate?.currentTrustValidated).toBe(false) | |
| 40 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 41 | expect(await readFile(join(result.candidate!.directory, 'image.bin'))).toEqual(item.fixture.image) | |
| 42 | expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(result) | |
| 43 | await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })).rejects.toThrow('active candidate') | |
| 44 | expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(result) | |
| 45 | }) | |
| 46 | ||
| 47 | test('explicit retirement retains old bytes and floors while allowing a distinct new candidate identity', async () => { | |
| 48 | const item = await setup() | |
| 49 | const first = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock }) | |
| 50 | const candidate = first.candidate!, original = await readFile(join(candidate.directory, 'stage.json')) | |
| 51 | await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: '0'.repeat(32) })).rejects.toThrow('active candidate') | |
| 52 | expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(first) | |
| 53 | const retired = await retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id }) | |
| 54 | expect(retired.candidate).toBeNull() | |
| 55 | expect(retired.retainedCandidates).toBe(1) | |
| 56 | expect(retired.history.map(row => ({ id: row.id, phase: row.phase, sha256: row.description.sha256 }))).toEqual([{ id: candidate.id, phase: 'retired', sha256: item.fixture.imageHash }]) | |
| 57 | expect(retired.store.releaseFloor).toBe(9) | |
| 58 | expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original) | |
| 59 | expect(await readFile(join(candidate.directory, 'image.bin'))).toEqual(item.fixture.image) | |
| 60 | const next = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock }) | |
| 61 | expect(next.candidate?.id).not.toBe(candidate.id) | |
| 62 | expect(next.candidate?.integrity).toBe('recorded-bytes-match') | |
| 63 | expect(next.retainedCandidates).toBe(2) | |
| 64 | expect(next.store.releaseFloor).toBe(9) | |
| 65 | expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original) | |
| 66 | await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })).rejects.toThrow('active candidate') | |
| 67 | expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(next) | |
| 68 | }) | |
| 69 | ||
| 70 | test('a completed stage marker after an interrupted reservation cannot promote itself during metadata recovery', async () => { | |
| 71 | const item = await setup(), interruption = new Error('Stop after durable candidate files') | |
| 72 | await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock, onProgress(event) { | |
| 73 | if (event.phase === 'candidate-completed') throw interruption | |
| 74 | } })).rejects.toBe(interruption) | |
| 75 | const stopped = await inspectRuntimeFirmwareCandidates(item.reference), candidate = stopped.candidate! | |
| 76 | expect(candidate.phase).toBe('reserved') | |
| 77 | expect(candidate.integrity).toBe('incomplete') | |
| 78 | expect(JSON.parse(await readFile(join(candidate.directory, 'stage.json'), 'utf8')).sha256).toBe(item.fixture.imageHash) | |
| 79 | expect(stopped.store.releaseFloor).toBe(9) | |
| 80 | expect(stopped.store.attempt?.phase).toBe('maintenance') | |
| 81 | await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })).rejects.toThrow('Resolve the metadata attempt') | |
| 82 | await recoverRuntimeTrustStore(item.reference) | |
| 83 | const recovered = await inspectRuntimeFirmwareCandidates(item.reference) | |
| 84 | expect(recovered.candidate).toEqual(candidate) | |
| 85 | expect(recovered.store.attempt?.phase).toBe('recovered') | |
| 86 | expect(recovered.store.releaseFloor).toBe(9) | |
| 87 | expect(recovered.physicalDeploymentAllowed).toBe(false) | |
| 88 | const original = await readFile(join(candidate.directory, 'stage.json')) | |
| 89 | const retired = await retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id }) | |
| 90 | expect(retired.candidate).toBeNull() | |
| 91 | expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original) | |
| 92 | }) | |
| 93 | ||
| 94 | test('a rejected actual image retains the reserved identity and accepted floors without creating a prepared artifact', async () => { | |
| 95 | const item = await setup() | |
| 96 | await writeFile(item.fixture.imagePath, Buffer.alloc(item.fixture.image.length, 0xff)) | |
| 97 | await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })).rejects.toThrow() | |
| 98 | const stopped = await inspectRuntimeFirmwareCandidates(item.reference) | |
| 99 | expect(stopped.candidate?.phase).toBe('reserved') | |
| 100 | expect(stopped.candidate?.integrity).toBe('incomplete') | |
| 101 | expect(stopped.store.releaseFloor).toBe(9) | |
| 102 | expect(stopped.store.attempt?.phase).toBe('maintenance') | |
| 103 | expect(await Bun.file(join(stopped.candidate!.directory, 'stage.json')).exists()).toBe(false) | |
| 104 | await recoverRuntimeTrustStore(item.reference) | |
| 105 | expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.phase).toBe('reserved') | |
| 106 | }) | |
| 107 | ||
| 108 | test('missing or corrupt owned bytes produce actionable integrity status and never current-trust authorization', async () => { | |
| 109 | const item = await setup(), prepared = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock }) | |
| 110 | const candidate = prepared.candidate!, image = join(candidate.directory, 'image.bin') | |
| 111 | await writeFile(image, Buffer.alloc(item.fixture.image.length, 0xff)) | |
| 112 | expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.integrity).toBe('mismatch') | |
| 113 | await rm(image) | |
| 114 | const missing = await inspectRuntimeFirmwareCandidates(item.reference) | |
| 115 | expect(missing.candidate?.integrity).toBe('unavailable') | |
| 116 | expect(missing.candidate?.phase).toBe('prepared') | |
| 117 | expect(missing.candidate?.currentTrustValidated).toBe(false) | |
| 118 | expect(missing.store.releaseFloor).toBe(9) | |
| 119 | expect(missing.physicalDeploymentAllowed).toBe(false) | |
| 120 | await writeFile(image, item.fixture.image, { mode: 0o600 }) | |
| 121 | await writeFile(join(candidate.directory, 'repository/metadata/1.targets.json'), '{}\n') | |
| 122 | expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.integrity).not.toBe('recorded-bytes-match') | |
| 123 | }) | |
| 124 | ||
| 125 | test('actual killed candidate writers preserve reserved, prepared and retired states with concurrent writers excluded', async () => { | |
| 126 | const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-cuts-')); roots.push(root) | |
| 127 | const result = await qualifyCandidateInterruptions(join(root, 'campaign')) | |
| 128 | expect(result.cuts).toBe(5) | |
| 129 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 130 | }, 90000) | |
| 131 | ||
| 132 | test('managed candidates reject repository overlap before advancing state and preserve a foreign state format unchanged', async () => { | |
| 133 | const item = await setup(), initial = await inspectRuntimeTrustStore(item.reference) | |
| 134 | for (const repository of [item.root, item.reference.directory]) { | |
| 135 | await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository, clock })).rejects.toThrow('separate') | |
| 136 | expect(await inspectRuntimeTrustStore(item.reference)).toEqual(initial) | |
| 137 | } | |
| 138 | await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock }) | |
| 139 | const file = join(item.reference.directory, 'state.sqlite'), db = new Database(file, { create: false, readwrite: true }) | |
| 140 | try { db.exec('PRAGMA user_version=99') } | |
| 141 | finally { db.close() } | |
| 142 | const original = await readFile(file) | |
| 143 | await expect(inspectRuntimeFirmwareCandidates(item.reference)).rejects.toThrow('schema') | |
| 144 | expect(await readFile(file)).toEqual(original) | |
| 145 | }) | |
| 146 | ||
| 147 | test('migration retains a prior rotated-root epoch, metadata history and clock/release floors across later rejections', async () => { | |
| 148 | const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-migration-')); roots.push(root) | |
| 149 | const source = join(root, 'repository'), fixture = await epochRepository(source) | |
| 150 | const enrollmentDirectory = join(root, 'enrollment'), directory = join(root, 'store') | |
| 151 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 152 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 153 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 154 | await fixture.publish(1, 100, 12) | |
| 155 | await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 156 | const nextClock = { source: clock.source, read: () => new Date('2030-01-02T00:00:00Z') } | |
| 157 | const image = await fixture.publish(2, 1, 12) | |
| 158 | const before = await refreshRuntimeTrustStore({ ...reference, repository: source, clock: nextClock }) | |
| 159 | expect(before.epoch).toBe(1) | |
| 160 | expect(before.generation).toBe(7) | |
| 161 | expect(before.releaseFloor).toBe(12) | |
| 162 | expect((await inspectRuntimeFirmwareCandidates(reference)).stateFormat).toBe(1) | |
| 163 | const candidateClock = { source: clock.source, read: () => new Date('2030-01-03T00:00:00Z') } | |
| 164 | const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: candidateClock }) | |
| 165 | expect(prepared.stateFormat).toBe(2) | |
| 166 | expect(prepared.store.storeId).toBe(before.storeId) | |
| 167 | expect(prepared.store.storeSha256).toBe(before.storeSha256) | |
| 168 | expect(prepared.store.enrollmentSha256).toBe(before.enrollmentSha256) | |
| 169 | expect(prepared.store.metadata).toEqual(before.metadata) | |
| 170 | expect(prepared.store.retainedMetadataRecords).toBe(before.retainedMetadataRecords) | |
| 171 | expect(prepared.store.epoch).toBe(1) | |
| 172 | expect(prepared.store.releaseFloor).toBe(12) | |
| 173 | expect(prepared.store.clockFloorMs).toBe(Date.parse('2030-01-03T00:00:00Z')) | |
| 174 | expect(prepared.candidate?.description.rootVersion).toBe(2) | |
| 175 | const retired = await retireRuntimeFirmwareCandidate({ ...reference, candidateId: prepared.candidate!.id }) | |
| 176 | await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })).rejects.toThrow('clock') | |
| 177 | expect(await inspectRuntimeFirmwareCandidates(reference)).toEqual(retired) | |
| 178 | await fixture.publish(2, 2, 11) | |
| 179 | await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: candidateClock })).rejects.toThrow('below the trusted minimum') | |
| 180 | await recoverRuntimeTrustStore(reference) | |
| 181 | const recovered = await inspectRuntimeFirmwareCandidates(reference) | |
| 182 | expect(recovered.store.releaseFloor).toBe(12) | |
| 183 | expect(recovered.store.epoch).toBe(1) | |
| 184 | expect(recovered.store.attempt?.phase).toBe('recovered') | |
| 185 | expect(recovered.candidate).toBeNull() | |
| 186 | expect(recovered.history[0]?.description.releaseSequence).toBe(12) | |
| 187 | expect(await readFile(join(prepared.candidate!.directory, 'image.bin'))).toEqual(image.image) | |
| 188 | }) | |
| @@ -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 | }) | |