also looking at this
feat: prepare runtime images with protected trust
#47
27 files
+1675
-73
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -40,6 +40,8 @@ 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" | |
| 43 | 45 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 44 | 46 | working-directory: ${{ runner.temp }} |
| 45 | 47 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -262,12 +262,14 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/ | ||
| 262 | 262 | |
| 263 | 263 | The viewer opens files locally in the browser. The v1 format excludes imagery, location and |
| 264 | 264 | operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md). |
| 265 | The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF | |
| 265 | The [signed runtime image workflow](docs/runtime-updates.md) verifies offline TUF | |
| 266 | 266 | candidates through `updates:stage` and `updates:inspect`, with explicit root pins, |
| 267 | 267 | compatibility and externally supplied release floors. `updates:enroll` and |
| 268 | `updates:trust` preserve and independently verify an initial root/policy record. | |
| 269 | Physical logging/commissioning, persistent refresh metadata/release floors, image | |
| 270 | installation and known-good rollback remain pending. | |
| 268 | `updates:trust` preserve the initial root/policy record. The independently pinned | |
| 269 | store retains verified metadata, clock/release floors and historical recovery; | |
| 270 | `updates:prepare` authenticates actual image bytes under that protected state. | |
| 271 | Physical logging/commissioning, key revocation/repair, installed/known-good slots | |
| 272 | and image-bound boot/session health remain pending. | |
| 271 | 273 | |
| 272 | 274 | ## Sensor acquisition and replay |
| 273 | 275 | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { prepareRuntimeFirmwareUpdate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:prepare <directory>', | |
| 8 | description: 'Authenticate a signed image under protected metadata into a new candidate without installing firmware', | |
| 9 | options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image repository', '--output <path>': 'New exclusive candidate directory', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' }, | |
| 10 | async handle(options, directory) { | |
| 11 | try { | |
| 12 | if (typeof options.repository !== 'string' || typeof options.output !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository, output and independently trusted system-clock source required') | |
| 13 | await printAsync(JSON.stringify(await prepareRuntimeFirmwareUpdate({ ...storeCommandReference(options, directory), repository: options.repository, output: options.output, clock: { source: options.trustedSystemClockSource, read: () => new Date() } }))) | |
| 14 | } | |
| 15 | catch { | |
| 16 | process.exitCode = 1 | |
| 17 | try { await printErrorAsync('Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure') } | |
| 18 | catch { /* Preserve failure when the diagnostic pipe also fails. */ } | |
| 19 | } | |
| 20 | }, | |
| 21 | }) | |
| @@ -27,6 +27,7 @@ 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' | |
| 30 | 31 | |
| 31 | 32 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 32 | 33 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -97,6 +98,7 @@ UpdatesStoreInit(app) | ||
| 97 | 98 | UpdatesStore(app) |
| 98 | 99 | UpdatesRefresh(app) |
| 99 | 100 | UpdatesRecover(app) |
| 101 | UpdatesPrepare(app) | |
| 100 | 102 | MissionsSimulate(app) |
| 101 | 103 | MissionsValidate(app) |
| 102 | 104 | MissionsReplay(app) |
| @@ -47,7 +47,13 @@ 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 | This v1 database journals metadata, not durable candidate ownership or slot | |
| 57 | transitions. Installed, candidate and known-good slots and health confirmation | |
| 58 | bound to an exact image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 53 | 59 | bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9. |
| @@ -155,12 +155,14 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st | ||
| 155 | 155 | - Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load. |
| 156 | 156 | Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on |
| 157 | 157 | the actual Pi/Pico setup. Disabled loads and independent authority remain required. |
| 158 | - [Offline signed image staging](runtime-updates.md) now authenticates TUF roles, | |
| 159 | declared target compatibility and a caller-supplied release floor. Protected initial | |
| 160 | trust enrollment is available; build trust/key lifecycle and persistent | |
| 161 | metadata/release high-water state, interrupted update | |
| 162 | recovery and a known-good rollback slot before flashing. Each staging invocation | |
| 163 | currently starts from the independently supplied root; it is not full anti-rollback. | |
| 158 | - [Signed image staging and protected preparation](runtime-updates.md) authenticate | |
| 159 | TUF roles, declared compatibility and actual image bytes. Independently pinned | |
| 160 | enrollment/store records retain metadata and clock/release floors; historical | |
| 161 | recovery retains floors only. Complete key revocation/repair, installed and | |
| 162 | known-good slot coordination, current-policy rollback and exact-image boot/session | |
| 163 | health before flashing. Standalone staging/inspection still need explicit trust | |
| 164 | and are separate from protected-current-policy selection. Host preparation does | |
| 165 | not satisfy full update/rollback or hardware anti-rollback acceptance. | |
| 164 | 166 | - Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog, |
| 165 | 167 | estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6. |
| 166 | 168 | |
| @@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 13 | 13 | | Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) | |
| 14 | 14 | | MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) | |
| 15 | 15 | | Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) | |
| 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [offline signed image staging and initial trust enrollment](../runtime-updates.md); full mission correlation, hardware evidence, persistent refresh floors and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) | | |
| 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors and actual image preparation](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, slot coordination and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) | | |
| 17 | 17 | | Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) | |
| 18 | 18 | | Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) | |
| 19 | 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | |
| @@ -267,13 +267,17 @@ | ||
| 267 | 267 | "resources/components/DiagnosticsReplay.stx", |
| 268 | 268 | "docs/diagnostics.md", |
| 269 | 269 | "public/diagnostics/runtime-deadline.ndjson", |
| 270 | "tests/unit/diagnostics.test.ts" | |
| 270 | "tests/unit/diagnostics.test.ts", | |
| 271 | "resources/functions/drone/updates/", | |
| 272 | "app/Commands/UpdatesPrepare.ts", | |
| 273 | "docs/runtime-updates.md", | |
| 274 | "tests/unit/updates-preparation.test.ts" | |
| 271 | 275 | ], |
| 272 | 276 | "remaining": [ |
| 273 | 277 | "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations", |
| 274 | 278 | "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence", |
| 275 | 279 | "Measured board logging deadlines, memory, rotation and long-mission retention", |
| 276 | "Signed firmware updates, trust/key lifecycle, anti-rollback and known-good recovery slot", | |
| 280 | "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health", | |
| 277 | 281 | "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence" |
| 278 | 282 | ] |
| 279 | 283 | }, |
| @@ -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. |
| @@ -180,7 +181,68 @@ The v1 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. Durable candidate ownership, installed/known-good slots and current | |
| 228 | policy revalidation before selection remain coordinator work in #42. | |
| 229 | ||
| 230 | Image or candidate-output failure during an attempt leaves accepted metadata and | |
| 231 | release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never | |
| 232 | reconstructs image success or confirms an existing candidate. Preserve partial | |
| 233 | outputs and use a new output directory for a fresh preparation after recovery. | |
| 234 | A complete candidate marker can exist when a later callback or process failure | |
| 235 | prevents the operation returning. Marker existence alone is not authorization. | |
| 236 | Repository, enrollment and protocol-store directories cannot contain the candidate | |
| 237 | output or be contained by it. Existing output directories are not overwritten. | |
| 238 | ||
| 239 | Host tests kill actual competing-writer-excluded processes at `image-written` | |
| 240 | and `candidate-completed` progress points. The first follows image-file sync; | |
| 241 | the second follows the shared file writer's final marker and directory syncs. | |
| 242 | After restart, metadata recovery retains release floor 9 and returns only a store | |
| 243 | summary; a fresh preparation uses another new directory. These measured process | |
| 244 | cuts do not establish every write/sync boundary, electrical power-loss behavior | |
| 245 | or hardware anti-rollback. | |
| 184 | 246 | |
| 185 | 247 | ## Repository and signed image contract |
| 186 | 248 | |
| @@ -188,9 +250,11 @@ Use a standard TUF repository with `metadata/` and `targets/` directories, | ||
| 188 | 250 | including consistent-snapshot names when enabled. Verification uses the pinned |
| 189 | 251 | [`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root |
| 190 | 252 | 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. | |
| 253 | target length/hashes. Standalone staging/inspection use a private, randomly | |
| 254 | addressed loopback server supplying bounded local files to the public HTTP | |
| 255 | interface, then stop it. Protected refresh/preparation use the public Fetcher | |
| 256 | interface with bounded local files and the durable input journal. Neither path | |
| 257 | provides an external network fetch or arbitrary URL option. | |
| 194 | 258 | The host additionally verifies that root and delegation key IDs equal SHA-256 |
| 195 | 259 | of their canonical key objects, using the upstream canonical JSON library. |
| 196 | 260 | This rejects arbitrary key-ID aliases before role verification. |
| @@ -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 | |
| @@ -203,7 +207,7 @@ export interface RuntimeUpdateClock { | ||
| 203 | 207 | } |
| 204 | 208 | |
| 205 | 209 | export interface RuntimeUpdateProgress { |
| 206 | phase: 'input-committed' | 'metadata-committed' | |
| 210 | phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | |
| 207 | 211 | ordinal: number |
| 208 | 212 | } |
| 209 | 213 | type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void> |
| @@ -258,8 +262,17 @@ function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal | ||
| 258 | 262 | return changed |
| 259 | 263 | } |
| 260 | 264 | |
| 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>> { | |
| 265 | interface RefreshOptions extends StoreReference { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver } | |
| 266 | interface VerifiedSelection { | |
| 267 | db: Database | |
| 268 | binding: Binding | |
| 269 | trusted: Readonly<RuntimeTrustSummary> | |
| 270 | source: string | |
| 271 | info: TargetFile | |
| 272 | runtime: ReturnType<typeof admitSignedRuntime> | |
| 273 | } | |
| 274 | ||
| 275 | async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> { | |
| 263 | 276 | options = { ...options } |
| 264 | 277 | return withStore(options, async (db, binding, trusted) => { |
| 265 | 278 | if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery') |
| @@ -305,11 +318,14 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos | ||
| 305 | 318 | const info = await updater.getTargetInfo(trusted.policy.target) |
| 306 | 319 | if (!info) throw new Error('Selected runtime target is absent') |
| 307 | 320 | const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor }) |
| 321 | // The signed release floor survives later image or candidate-output failure. | |
| 308 | 322 | db.transaction(() => { |
| 309 | 323 | 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) | |
| 324 | db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt) | |
| 311 | 325 | })() |
| 312 | return summary(db, binding, options.storeSha256) | |
| 326 | const value = await complete({ db, binding, trusted, source, info, runtime }) | |
| 327 | db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt) | |
| 328 | return { store: summary(db, binding, options.storeSha256), value } | |
| 313 | 329 | } |
| 314 | 330 | catch (error) { |
| 315 | 331 | try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) } |
| @@ -320,6 +336,47 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos | ||
| 320 | 336 | }) |
| 321 | 337 | } |
| 322 | 338 | |
| 339 | /** Verify bounded offline metadata and retain its accepted prefix. This never downloads an image. */ | |
| 340 | export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 341 | return (await refreshAndComplete(options, async () => undefined)).store | |
| 342 | } | |
| 343 | ||
| 344 | export interface PreparedRuntimeFirmware { | |
| 345 | schema: 'drone-runtime-prepared-image-v1' | |
| 346 | store: Readonly<RuntimeTrustStoreSummary> | |
| 347 | candidate: Readonly<RuntimeFirmwareStage> | |
| 348 | trustStatePersisted: true | |
| 349 | physicalDeploymentAllowed: false | |
| 350 | } | |
| 351 | ||
| 352 | /** Authenticate an image under current protected metadata. Never installs, selects or confirms firmware. */ | |
| 353 | export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> { | |
| 354 | options = { ...options } | |
| 355 | const output = runtimeCandidateOutput(options.output, [options.repository, options.directory, options.enrollmentDirectory]) | |
| 356 | const result = await refreshAndComplete(options, async ({ db, binding, trusted, source, info, runtime }) => { | |
| 357 | const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')! | |
| 358 | const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true | |
| 359 | const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path | |
| 360 | const image = repositoryFile(source, `targets/${path}`, imageLimit) | |
| 361 | await info.verify(Readable.from(image)) | |
| 362 | const files = new Map<string, Buffer>() | |
| 363 | for (const row of rows) { | |
| 364 | if (row.role === 'root') continue | |
| 365 | files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row)) | |
| 366 | } | |
| 367 | files.set(`targets/${path}`, image) | |
| 368 | const candidate: RuntimeFirmwareStage = { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false } | |
| 369 | mkdirSync(output, { mode: 0o700 }) | |
| 370 | writeExclusive(join(output, 'trust-root.json'), rootBytes) | |
| 371 | writeExclusive(join(output, 'image.bin'), image) | |
| 372 | await options.onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 })) | |
| 373 | await finishRuntimeCandidate(output, info, candidate, files) | |
| 374 | await options.onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 })) | |
| 375 | return Object.freeze(candidate) | |
| 376 | }) | |
| 377 | return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false }) | |
| 378 | } | |
| 379 | ||
| 323 | 380 | function causedBy(error: unknown, marker: Error) { |
| 324 | 381 | const seen = new Set<unknown>() |
| 325 | 382 | 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,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, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } 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 = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | |
| 12 | interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: 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,30 @@ 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.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output }) | |
| 33 | else await refreshRuntimeTrustStore(options) | |
| 31 | 34 | throw new Error('Interruption worker unexpectedly finished') |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | 37 | /** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */ |
| 35 | export async function qualifyStoreInterruptions(directory: string) { | |
| 38 | async function qualifyInterruptions(directory: string, preparation: boolean) { | |
| 36 | 39 | await mkdir(directory, { mode: 0o700 }) |
| 37 | 40 | const results = [] |
| 38 | for (const cut of ['input-committed', 'metadata-committed'] as const) { | |
| 41 | const cuts: Cut[] = preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed'] | |
| 42 | for (const cut of cuts) { | |
| 39 | 43 | const root = join(directory, cut); await mkdir(root, { mode: 0o700 }) |
| 40 | 44 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) |
| 41 | 45 | const enrollmentDirectory = join(root, 'enrollment') |
| 42 | 46 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) |
| 43 | 47 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) |
| 44 | 48 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 } |
| 45 | const file = join(root, 'worker.json') | |
| 46 | await writeFile(file, JSON.stringify({ reference, repository: source, cut } satisfies WorkerInput), { mode: 0o600 }) | |
| 49 | const file = join(root, 'worker.json'), output = preparation ? join(root, 'candidate') : undefined | |
| 50 | await writeFile(file, JSON.stringify({ reference, repository: source, cut, output } satisfies WorkerInput), { mode: 0o600 }) | |
| 47 | 51 | const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) |
| 48 | 52 | const timer = setTimeout(() => child.kill('SIGKILL'), 15000) |
| 49 | 53 | const reader = child.stdout.getReader(), decoder = new TextDecoder() |
| @@ -65,14 +69,24 @@ export async function qualifyStoreInterruptions(directory: string) { | ||
| 65 | 69 | assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0) |
| 66 | 70 | const interrupted = await inspectRuntimeTrustStore(reference) |
| 67 | 71 | assert.equal(interrupted.attempt?.phase, 'pending') |
| 68 | assert.equal(interrupted.generation, cut === 'input-committed' ? 0 : 1) | |
| 72 | assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1) | |
| 73 | assert.equal(interrupted.releaseFloor, preparation ? 9 : 7) | |
| 74 | const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false | |
| 75 | if (output) { | |
| 76 | assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image) | |
| 77 | assert.equal(candidateStagePresent, cut === 'candidate-completed') | |
| 78 | } | |
| 69 | 79 | 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) | |
| 80 | assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, 'recovered') | |
| 81 | assert.equal(recovered.releaseFloor, preparation ? 9 : 7) | |
| 82 | assert.equal(Object.hasOwn(recovered, 'candidate'), false) | |
| 83 | const completed = preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 84 | const fresh = completed.schema === 'drone-runtime-prepared-image-v1' ? completed.store : completed | |
| 85 | assert.equal(fresh.generation >= interrupted.generation, true) | |
| 86 | if (!preparation) assert.equal(fresh.generation, 3) | |
| 87 | assert.equal(fresh.releaseFloor, 9) | |
| 74 | 88 | 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 }) | |
| 89 | results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, interrupted, recovered, fresh }) | |
| 76 | 90 | } |
| 77 | 91 | finally { |
| 78 | 92 | clearTimeout(timer) |
| @@ -81,14 +95,18 @@ export async function qualifyStoreInterruptions(directory: string) { | ||
| 81 | 95 | reader.releaseLock() |
| 82 | 96 | await writeFile(join(root, 'worker.stdout'), stdout) |
| 83 | 97 | 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)) | |
| 98 | await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', results, physicalDeploymentAllowed: false }, null, 2)) | |
| 85 | 99 | } |
| 86 | 100 | } |
| 87 | return { evidence: 'host-process-interruption-only', cuts: results.length, physicalDeploymentAllowed: false } | |
| 101 | return { evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', cuts: results.length, physicalDeploymentAllowed: false } | |
| 88 | 102 | } |
| 89 | 103 | |
| 104 | export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) } | |
| 105 | export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) } | |
| 106 | ||
| 90 | 107 | if (import.meta.main) { |
| 91 | if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!) | |
| 108 | if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!)))) | |
| 109 | else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!) | |
| 92 | 110 | 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') | |
| 111 | else throw new Error('Use store-interruptions.ts [prepare] NEW_DIRECTORY') | |
| 94 | 112 | } |
| @@ -0,0 +1,45 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 7 | import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 8 | import { repository, platform, target } from '../fixtures/updates/repository' | |
| 9 | ||
| 10 | const roots: string[] = [] | |
| 11 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 12 | ||
| 13 | test('native preparation requires protected trust and an explicit clock, authenticates the image and preserves failure status', async () => { | |
| 14 | const root = await mkdtemp(join(tmpdir(), 'drone-prepare-cli-')); roots.push(root) | |
| 15 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) | |
| 16 | const enrollmentDirectory = join(root, 'enrollment') | |
| 17 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 18 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) | |
| 19 | const flags = ['--enrollment', enrollmentDirectory, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256] | |
| 20 | const sourceFlags = ['--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock'] | |
| 21 | const prepared = await command(['updates:prepare', join(root, 'store'), ...flags, ...sourceFlags, '--output', join(root, 'candidate')]).withTimeout(20000) | |
| 22 | expect(prepared.exitCode).toBe(0) | |
| 23 | const value = JSON.parse(prepared.stdout) | |
| 24 | expect(value.store.releaseFloor).toBe(9) | |
| 25 | expect(value.candidate.sha256).toBe(fixture.imageHash) | |
| 26 | expect(value.trustStatePersisted).toBe(true) | |
| 27 | expect(value.physicalDeploymentAllowed).toBe(false) | |
| 28 | const inspected = await command(['updates:inspect', join(root, 'candidate'), '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '9']).withTimeout(20000) | |
| 29 | expect(inspected.exitCode).toBe(0) | |
| 30 | expect(JSON.parse(inspected.stdout)).toEqual(value.candidate) | |
| 31 | const missingClock = await command(['updates:prepare', join(root, 'store'), ...flags, '--repository', source, '--output', join(root, 'missing-clock')]).withTimeout(20000) | |
| 32 | expect(missingClock.exitCode).toBe(1) | |
| 33 | expect(missingClock.stdout).toBe('') | |
| 34 | await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff)) | |
| 35 | const rejected = await command(['updates:prepare', join(root, 'store'), ...flags, ...sourceFlags, '--output', join(root, 'bad-image')]).withTimeout(20000) | |
| 36 | expect(rejected.exitCode).toBe(1) | |
| 37 | expect(rejected.stdout).toBe('') | |
| 38 | expect(rejected.stderr).toBe('Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure\n') | |
| 39 | const status = await command(['updates:store', join(root, 'store'), ...flags]).withTimeout(20000) | |
| 40 | expect(status.exitCode).toBe(0) | |
| 41 | const stopped = JSON.parse(status.stdout) | |
| 42 | expect(stopped.releaseFloor).toBe(9) | |
| 43 | expect(stopped.attempt.phase).toBe('maintenance') | |
| 44 | expect(stopped.physicalDeploymentAllowed).toBe(false) | |
| 45 | }, 120000) | |
| @@ -0,0 +1,163 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 6 | import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging' | |
| 7 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 9 | import { qualifyPreparationInterruptions } from '../fixtures/updates/store-interruptions' | |
| 10 | import { epochRepository } from '../fixtures/updates/epochs' | |
| 11 | ||
| 12 | const roots: string[] = [] | |
| 13 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 14 | const clock = { source: 'test-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 15 | async function setup(options: Parameters<typeof repository>[1] = {}) { | |
| 16 | const root = await mkdtemp(join(tmpdir(), 'drone-prepare-update-')); roots.push(root) | |
| 17 | const source = join(root, 'repo'), fixture = await repository(source, { ...options, custom: { releaseSequence: 9, ...options.custom } }) | |
| 18 | const enrollmentDirectory = join(root, 'enrollment') | |
| 19 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 20 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) | |
| 21 | return { root, source, fixture, reference: { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }, output: join(root, 'candidate') } | |
| 22 | } | |
| 23 | ||
| 24 | test('protected preparation authenticates actual image bytes and writes an independently verifiable signed candidate', async () => { | |
| 25 | const item = await setup() | |
| 26 | const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock }) | |
| 27 | expect(result.schema).toBe('drone-runtime-prepared-image-v1') | |
| 28 | expect(result.store.generation).toBe(3) | |
| 29 | expect(result.store.releaseFloor).toBe(9) | |
| 30 | expect(result.store.attempt?.phase).toBe('verified') | |
| 31 | expect(result.candidate.sha256).toBe(item.fixture.imageHash) | |
| 32 | expect(result.candidate.length).toBe(item.fixture.image.length) | |
| 33 | expect(result.trustStatePersisted).toBe(true) | |
| 34 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 35 | expect(result.candidate.trustStatePersisted).toBe(false) | |
| 36 | expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: item.fixture.rootSha256, minimumReleaseSequence: 9 })).toEqual(result.candidate) | |
| 37 | expect(await inspectRuntimeTrustStore(item.reference)).toEqual(result.store) | |
| 38 | }) | |
| 39 | ||
| 40 | test('a rotated protected root and cached metadata produce a candidate without depending on the initial root chain', async () => { | |
| 41 | const item = await setup({ rotate: true }) | |
| 42 | const current = await refreshRuntimeTrustStore({ ...item.reference, repository: item.source, clock }) | |
| 43 | const protectedRoot = current.metadata.find(row => row.role === 'root')! | |
| 44 | expect(protectedRoot.version).toBe(2) | |
| 45 | const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock }) | |
| 46 | expect(result.candidate.trustedRootSha256).toBe(protectedRoot.sha256) | |
| 47 | expect(result.candidate.rootVersion).toBe(2) | |
| 48 | expect(result.store.releaseFloor).toBe(9) | |
| 49 | await rename(item.source, join(item.root, 'offline')) | |
| 50 | expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: protectedRoot.sha256, minimumReleaseSequence: 9 })).toEqual(result.candidate) | |
| 51 | await expect(inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: item.fixture.rootSha256, minimumReleaseSequence: 9 })).rejects.toThrow('independent pin') | |
| 52 | }) | |
| 53 | ||
| 54 | test('an actual output collision after metadata acceptance preserves the release floor and the competing files', async () => { | |
| 55 | const item = await setup() | |
| 56 | const failure = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock, async onProgress(event) { | |
| 57 | if (event.phase === 'metadata-committed' && event.ordinal === 2) { | |
| 58 | await mkdir(item.output) | |
| 59 | await writeFile(join(item.output, 'contender.txt'), 'Preserve the competing output\n') | |
| 60 | } | |
| 61 | } }).then(() => undefined, error => error) | |
| 62 | expect(failure.code).toBe('EEXIST') | |
| 63 | expect(await readFile(join(item.output, 'contender.txt'), 'utf8')).toBe('Preserve the competing output\n') | |
| 64 | await expect(stat(join(item.output, 'stage.json'))).rejects.toHaveProperty('code', 'ENOENT') | |
| 65 | const stopped = await inspectRuntimeTrustStore(item.reference) | |
| 66 | expect(stopped.generation).toBe(3) | |
| 67 | expect(stopped.releaseFloor).toBe(9) | |
| 68 | expect(stopped.attempt?.phase).toBe('maintenance') | |
| 69 | const recovered = await recoverRuntimeTrustStore(item.reference) | |
| 70 | expect(recovered.releaseFloor).toBe(9) | |
| 71 | expect(recovered.attempt?.phase).toBe('recovered') | |
| 72 | expect(recovered.physicalDeploymentAllowed).toBe(false) | |
| 73 | }) | |
| 74 | ||
| 75 | test('candidate paths cannot overlap the repository, enrollment or protocol store', async () => { | |
| 76 | const item = await setup(), initial = await inspectRuntimeTrustStore(item.reference) | |
| 77 | for (const source of [item.source, item.reference.enrollmentDirectory, item.reference.directory]) { | |
| 78 | await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: join(source, 'candidate'), clock })).rejects.toThrow('separate') | |
| 79 | expect(await inspectRuntimeTrustStore(item.reference)).toEqual(initial) | |
| 80 | } | |
| 81 | }) | |
| 82 | ||
| 83 | test('newer signed metadata cannot prepare a release below the protected floor or overwrite an earlier candidate', async () => { | |
| 84 | const root = await mkdtemp(join(tmpdir(), 'drone-prepare-floor-')); roots.push(root) | |
| 85 | const source = join(root, 'repo'), fixture = await epochRepository(source) | |
| 86 | const enrollmentDirectory = join(root, 'enrollment') | |
| 87 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 88 | const store = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) | |
| 89 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: store.storeSha256 } | |
| 90 | const original = await fixture.publish(1, 1, 9), output = join(root, 'candidate') | |
| 91 | await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, output, clock }) | |
| 92 | const receipt = await readFile(join(output, 'stage.json')) | |
| 93 | await fixture.publish(1, 2, 8) | |
| 94 | await expect(prepareRuntimeFirmwareUpdate({ ...reference, repository: source, output: join(root, 'downgrade'), clock })).rejects.toThrow('below the trusted minimum') | |
| 95 | const stopped = await inspectRuntimeTrustStore(reference) | |
| 96 | expect(stopped.releaseFloor).toBe(9) | |
| 97 | expect(stopped.metadata.find(row => row.role === 'targets')?.version).toBe(2) | |
| 98 | expect(stopped.attempt?.phase).toBe('maintenance') | |
| 99 | expect(await readFile(join(output, 'image.bin'))).toEqual(original.image) | |
| 100 | expect(await readFile(join(output, 'stage.json'))).toEqual(receipt) | |
| 101 | const recovered = await recoverRuntimeTrustStore(reference) | |
| 102 | expect(recovered.releaseFloor).toBe(9) | |
| 103 | expect(recovered.attempt?.phase).toBe('recovered') | |
| 104 | expect(recovered.physicalDeploymentAllowed).toBe(false) | |
| 105 | }) | |
| 106 | ||
| 107 | test('bad image bytes cannot discard accepted metadata or release floors, including after offline recovery', async () => { | |
| 108 | const item = await setup() | |
| 109 | await writeFile(item.fixture.imagePath, Buffer.alloc(item.fixture.image.length, 0xff)) | |
| 110 | await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })).rejects.toThrow() | |
| 111 | const stopped = await inspectRuntimeTrustStore(item.reference) | |
| 112 | expect(stopped.generation).toBe(3) | |
| 113 | expect(stopped.releaseFloor).toBe(9) | |
| 114 | expect(stopped.attempt?.phase).toBe('maintenance') | |
| 115 | await expect(stat(item.output)).rejects.toHaveProperty('code', 'ENOENT') | |
| 116 | await rename(item.source, join(item.root, 'offline')) | |
| 117 | const recovered = await recoverRuntimeTrustStore(item.reference) | |
| 118 | expect(recovered.generation).toBe(3) | |
| 119 | expect(recovered.releaseFloor).toBe(9) | |
| 120 | expect(recovered.attempt?.phase).toBe('recovered') | |
| 121 | expect(recovered.physicalDeploymentAllowed).toBe(false) | |
| 122 | await rename(join(item.root, 'offline'), item.source) | |
| 123 | await writeFile(item.fixture.imagePath, item.fixture.image) | |
| 124 | const fresh = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock }) | |
| 125 | expect(fresh.store.releaseFloor).toBe(9) | |
| 126 | expect(fresh.candidate.sha256).toBe(item.fixture.imageHash) | |
| 127 | expect(fresh.physicalDeploymentAllowed).toBe(false) | |
| 128 | }) | |
| 129 | ||
| 130 | test('real preparation process cuts retain accepted floors and never turn recovery into candidate authorization', async () => { | |
| 131 | const root = await mkdtemp(join(tmpdir(), 'drone-prepare-host-cuts-')); roots.push(root) | |
| 132 | const result = await qualifyPreparationInterruptions(join(root, 'campaign')) | |
| 133 | expect(result.cuts).toBe(2) | |
| 134 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 135 | }, 45000) | |
| 136 | ||
| 137 | test('protected preparation also archives repositories without consistent-snapshot filename prefixes', async () => { | |
| 138 | const item = await setup({ rotate: true, consistentSnapshot: false }) | |
| 139 | const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock }) | |
| 140 | const current = await inspectRuntimeTrustStore(item.reference), root = current.metadata.find(row => row.role === 'root')! | |
| 141 | expect(root.version).toBe(2) | |
| 142 | expect(result.candidate.sha256).toBe(item.fixture.imageHash) | |
| 143 | expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: root.sha256, minimumReleaseSequence: current.releaseFloor })).toEqual(result.candidate) | |
| 144 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 145 | }) | |
| 146 | ||
| 147 | test('actual candidate-image corruption after file sync is rejected by readback before a completion marker', async () => { | |
| 148 | const item = await setup(), damaged = Buffer.alloc(item.fixture.image.length, 0xff) | |
| 149 | let changedAfterWrite = false | |
| 150 | await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock, async onProgress(event) { | |
| 151 | if (event.phase === 'image-written') { | |
| 152 | await writeFile(join(item.output, 'image.bin'), damaged) | |
| 153 | changedAfterWrite = true | |
| 154 | } | |
| 155 | } })).rejects.toThrow() | |
| 156 | expect(changedAfterWrite).toBe(true) | |
| 157 | expect(await readFile(join(item.output, 'image.bin'))).toEqual(damaged) | |
| 158 | expect(await Bun.file(join(item.output, 'stage.json')).exists()).toBe(false) | |
| 159 | const stopped = await inspectRuntimeTrustStore(item.reference) | |
| 160 | expect(stopped.releaseFloor).toBe(9) | |
| 161 | expect(stopped.attempt?.phase).toBe('maintenance') | |
| 162 | expect(stopped.physicalDeploymentAllowed).toBe(false) | |
| 163 | }) | |