ReviewOS

also looking at this

stacks/drone

feat: verify and stage signed runtime images

#41
Merged chris wants to merge codex/runtime-updates into main
20 files +793 -7
.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -32,6 +32,8 @@ jobs:
3232 - run: python3 -m unittest discover -s tests/hardware
3333 - run: ./buddy test
3434 - run: bun run runtime:cli
35 - name: Verify signed test-image staging through the portable CLI outside the checkout
36 run: bun tests/fixtures/updates/qualify.ts "$RUNNER_TEMP/runtime-update-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
3537 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
3638 working-directory: ${{ runner.temp }}
3739 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+4-1
Changes to README.md
@@ -262,7 +262,10 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/
262262
263263The viewer opens files locally in the browser. The v1 format excludes imagery, location and
264264operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md).
265Physical logging/commissioning and signed firmware update/rollback remain pending.
265The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF
266candidates through `updates:stage` and `updates:inspect`, with explicit root pins,
267compatibility and externally supplied release floors. Physical logging/commissioning,
268persistent trust state, image installation and known-good rollback remain pending.
266269
267270## Sensor acquisition and replay
268271
app/Commands/UpdatesInspect.tsadded+18-0
Changes to app/Commands/UpdatesInspect.ts
@@ -0,0 +1,18 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command'
4import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging'
5
6export default defineCommand({
7 name: 'updates:inspect <directory>',
8 description: 'Re-authenticate a staged bench image against explicit trust and compatibility policy',
9 options: firmwarePolicyFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectStagedRuntimeFirmware({ ...firmwareCommandPolicy(options), directory }))) }
12 catch {
13 process.exitCode = 1
14 try { await printErrorAsync('Firmware candidate unavailable: invalid trust, rejected image or output failure') }
15 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
16 }
17 },
18})
app/Commands/UpdatesStage.tsadded+22-0
Changes to app/Commands/UpdatesStage.ts
@@ -0,0 +1,22 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command'
4import { stageRuntimeFirmware } from '../../resources/functions/drone/updates/staging'
5
6export default defineCommand({
7 name: 'updates:stage <repository>',
8 description: 'Verify an offline signed bench image into a new candidate without installing firmware',
9 options: { ...firmwarePolicyFlags, '--trusted-root <path>': 'Initial root file enrolled through an independent trusted channel', '--output <path>': 'New exclusive candidate directory' },
10 async handle(options, repository) {
11 try {
12 const policy = firmwareCommandPolicy(options)
13 if (typeof options.trustedRoot !== 'string' || typeof options.output !== 'string') throw new Error('Explicit root path and output required')
14 await printAsync(JSON.stringify(await stageRuntimeFirmware({ ...policy, repository, trustedRoot: options.trustedRoot, output: options.output })))
15 }
16 catch {
17 process.exitCode = 1
18 try { await printErrorAsync('Firmware staging unavailable: invalid trust, rejected image or output failure') }
19 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
20 }
21 },
22})
bin/drone.tsmodified+4-0
Changes to bin/drone.ts
@@ -19,6 +19,8 @@ import DiagnosticsRecord from '../app/Commands/DiagnosticsRecord'
1919import DiagnosticsReplay from '../app/Commands/DiagnosticsReplay'
2020import DiagnosticsInspect from '../app/Commands/DiagnosticsInspect'
2121import DiagnosticsExport from '../app/Commands/DiagnosticsExport'
22import UpdatesStage from '../app/Commands/UpdatesStage'
23import UpdatesInspect from '../app/Commands/UpdatesInspect'
2224
2325import MissionsSimulate from '../app/Commands/MissionsSimulate'
2426import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -81,6 +83,8 @@ DiagnosticsRecord(app)
8183DiagnosticsReplay(app)
8284DiagnosticsInspect(app)
8385DiagnosticsExport(app)
86UpdatesStage(app)
87UpdatesInspect(app)
8488MissionsSimulate(app)
8589MissionsValidate(app)
8690MissionsReplay(app)
bun.lockmodified+21-0
Changes to bun.lock
@@ -22,11 +22,14 @@
2222 "@stacksjs/stx": "0.2.564",
2323 "@stacksjs/testing": "^0.75.221",
2424 "@stacksjs/validation": "^0.75.221",
25 "@tufjs/canonical-json": "2.0.0",
2526 "stacks": "^0.75.221",
2627 "ts-images": "0.2.31",
28 "tuf-js": "6.0.0",
2729 },
2830 "devDependencies": {
2931 "@stacksjs/query-builder": "0.75.221",
32 "@tufjs/models": "5.0.0",
3033 "better-dx": "^0.2.37",
3134 },
3235 },
@@ -45,6 +48,8 @@
4548
4649 "@cwcss/crosswind": ["@cwcss/crosswind@0.2.19", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "bunfig": "^0.15.17" }, "bin": { "crosswind": "./dist/cli.js" } }, "sha512-NswUZPNHbdkW4rxvmwjjIM7jyfQ/rr0X1J1uwjTUwixc3BXQs1Fk7pP5cC7MxkGGnmRTFs2O9J+U9tJ36ZJ7fQ=="],
4750
51 "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
52
4853 "@iconify-json/f7": ["@iconify-json/f7@1.2.2", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-7+RhvnncD+LRu0smuHOun4emOUu48XUGG6G0tYYbrXS7n4Io2xntx6nxa09/iI41t9hnH2oHHU3Dg7Ee60Ovow=="],
4954
5055 "@iconify-json/hugeicons": ["@iconify-json/hugeicons@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-c06tY90JZnKhOLBAKoQ1z2pqXMRdATuYXslTKwaaIAjLzKsTIsE+GGknY6+qo/SP0U/NG9xQjr4N+zfDqOeQiQ=="],
@@ -333,6 +338,10 @@
333338
334339 "@ts-videos/webm": ["@ts-videos/webm@0.1.2", "", { "peerDependencies": { "ts-videos": "0.1.2" } }, "sha512-/aeM2LKeyF35pRRQLJdBK/0SJFpmdAHOnT5zbbgc4u+akY3LR7TdEr5GA2vubUKk5bqATx/isCMYPJogv1OPNA=="],
335340
341 "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
342
343 "@tufjs/models": ["@tufjs/models@5.0.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.2.1" } }, "sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ=="],
344
336345 "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
337346
338347 "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
@@ -359,8 +368,12 @@
359368
360369 "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
361370
371 "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
372
362373 "better-dx": ["better-dx@0.2.38", "", { "dependencies": { "@buddysh/buddy": "^0.11.13", "@stacksjs/bumpx": "^0.2.14", "@stacksjs/bunpress": "^0.2.8", "@stacksjs/clapp": "^0.2.12", "@stacksjs/clarity": "^0.3.31", "@stacksjs/gitlint": "^0.1.7", "@stacksjs/logsmith": "^0.2.6", "@types/bun": "^1.3.14", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.10", "bunfig": "^0.15.15", "pickier": "^0.1.67", "ts-error-handling": "^0.1.3", "typescript": "7.1.0-dev.20261011.1" } }, "sha512-ewOtXp5r5xxrlwqKTN8aJ/uk/Ra1hofyPdN+feue+jM6ICZe6IwiVS1CRGobPhu4I7mLObzDmB7mmeCeP3svsg=="],
363374
375 "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
376
364377 "bun-git-hooks": ["bun-git-hooks@0.3.3", "", { "bin": { "git-hooks": "./dist/bin/cli.js", "bun-git-hooks": "./dist/bin/cli.js" } }, "sha512-kcg8teRh/cMs8uZyt/+A/bBuJnmc6EvjnBJ12pWEJ61L9z3uSuqm0mvjrYX4Iu7SZPAwpvAK5fFKmqevS4WBkA=="],
365378
366379 "bun-plugin-auto-imports": ["bun-plugin-auto-imports@0.4.4", "", {}, "sha512-j1KXTDBEB/w95UWTvl8qQPjE8U+6jpm8UdCrIm+zDxnZM97ENhUpJjBYVB85oF14NsQpDcDnQYZdgLRckD4pKg=="],
@@ -383,6 +396,8 @@
383396
384397 "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
385398
399 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
400
386401 "dynamodb-tooling": ["dynamodb-tooling@0.3.6", "", { "bin": { "dbtooling": "./dist/cli.js" } }, "sha512-G/yixeLt3lawdwXVQCz+IKIPEb+yT21YjDdG1UGZA8YQwS8BwdUoGxBwJJytZfn0LouZ8Sq/YbdNiV2R/VsYnQ=="],
387402
388403 "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -397,6 +412,10 @@
397412
398413 "localtunnels": ["localtunnels@0.2.13", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "localtunnel": "./dist/bin/cli.js", "lt": "./dist/bin/cli.js" } }, "sha512-zcRWeFJSK1g2h5nblm/MiUc7eM7Sa1MRYoCdRiLseDCtX2Ha16YWjtGptwKjyq5Kd6egv3tfZbqmRA0elKZPrA=="],
399414
415 "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
416
417 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
418
400419 "pickier": ["pickier@0.1.73", "", { "dependencies": { "@stacksjs/clapp": "^0.2.3", "@stacksjs/clarity": "^0.3.24" }, "optionalDependencies": { "@stacksjs/ts-spell-check": "^0.1.0" }, "bin": { "pickier": "./dist/bin/cli.js" } }, "sha512-iuJsAIAqSfye9WFz8cWPG93Knvf28giYOlJxuj1y+0ZELjIlkihd/NkdasRc13EyUgbs939VHs1GjLxT8fhvlg=="],
401420
402421 "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
@@ -443,6 +462,8 @@
443462
444463 "ts-videos": ["ts-videos@0.1.2", "", { "dependencies": { "bunfig": "^0.15.6" }, "optionalDependencies": { "@ts-videos/mp4": "0.1.2", "@ts-videos/webm": "0.1.2" }, "bin": { "ts-videos": "./dist/bin/cli.js" } }, "sha512-J3s3hd6om0y7f7cANlCY72dTYReS/V/D6x3jKUQa358Y1cw5hdggF5LV/6BAYjhMFK5m6Ss7x2GbPyvKmoNimQ=="],
445464
465 "tuf-js": ["tuf-js@6.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ=="],
466
446467 "typescript": ["typescript@7.1.0-dev.20261011.1", "", { "optionalDependencies": { "@typescript/typescript-darwin-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-darwin-x64": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-arm": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-x64": "7.1.0-dev.20261011.1", "@typescript/typescript-win32-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-win32-x64": "7.1.0-dev.20261011.1" }, "bin": { "tsc": "bin/tsc" } }, "sha512-Oestzc61TT5cqxAyVxRsbL+pmMF8pzPlsnLcbCqAWqvC0eYQcp8EJrLWatIgjdUh4RiL79avUCHfM8hdkv07xg=="],
447468
448469 "undici-types": ["undici-types@8.12.2", "", {}, "sha512-VoMeD2O0KdHhcn+oPqOrpivN+7aDOklVSwOLD7JNjbzV1CboAFek9IFOXnreoufTUtAsCsOK0iETiERPNR/fDA=="],
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -67,6 +67,7 @@ const config: BunPressOptions = {
6767 { text: 'Owned mission engine', link: '/missions' },
6868 { text: 'Owned range-backed navigation', link: '/navigation' },
6969 { text: 'Owned runtime diagnostics, journals and replay', link: '/diagnostics' },
70 { text: 'Signed runtime image staging', link: '/runtime-updates' },
7071 ],
7172 },
7273 {
docs/diagnostics.mdmodified+5-2
Changes to docs/diagnostics.md
@@ -155,8 +155,11 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
155155- Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load.
156156 Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on
157157 the actual Pi/Pico setup. Disabled loads and independent authority remain required.
158- Build signed firmware manifests and trust/key lifecycle, target/version checks, anti-rollback,
159 verified staging, interrupted update recovery and a known-good rollback slot before flashing.
158- [Offline signed image staging](runtime-updates.md) now authenticates TUF roles,
159 declared target compatibility and a caller-supplied release floor. Build persistent
160 trust/key lifecycle and metadata/release high-water state, interrupted update
161 recovery and a known-good rollback slot before flashing. Each staging invocation
162 currently starts from the independently supplied root; it is not full anti-rollback.
160163- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
161164 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
162165
docs/index.mdmodified+2-0
Changes to docs/index.md
@@ -47,6 +47,8 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md),
4747[navigation](navigation.md) have separate simulation and hardware acceptance gates.
4848
4949[Diagnostics and replay](diagnostics.md) retain explicit loss and provenance.
50[Signed image staging](runtime-updates.md) authenticates offline candidates;
51persistent trust, boot selection and rollback remain required before installation.
5052The [library API](api.md) describes behavior extensions. The
5153[shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground,
5254surface and subsea profiles. [Optional external drivers](drivers.md) have
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, hash verification, crash-prefix recovery, pure replay, CLI and local stx viewer; full mission correlation, hardware storage/crash evidence and signed updates 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 [offline signed image staging](../runtime-updates.md); full mission correlation, hardware evidence, persistent trust and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/runtime-updates.mdadded+120-0
Changes to docs/runtime-updates.md
@@ -0,0 +1,120 @@
1# Signed runtime image staging
2
3Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
4staging and independent candidate verification. These commands prepare files on
5the host. Persistent trust state, installed images, boot selection and known-good
6rollback slots remain to be implemented. Physical deployment is disabled.
7
8## Explicit trust and policy
9
10Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted
11channel. Do not calculate a pin from an untrusted bundle and call it trusted.
12The release floor must likewise come from externally trusted release policy.
13Every invocation requires the root pin, exact target, expected bench platform
14and positive minimum release sequence. There are no permissive defaults.
15
16```sh
17./buddy updates:stage /path/to/offline-repository \
18 --trusted-root /path/to/enrolled/root.json \
19 --root-sha256 "$ENROLLED_ROOT_SHA256" \
20 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
21 --minimum-release-sequence 7 --output /path/to/new-candidate
22
23./buddy updates:inspect /path/to/new-candidate \
24 --root-sha256 "$ENROLLED_ROOT_SHA256" \
25 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
26 --minimum-release-sequence 7
27```
28
29The same commands are registered in the [compiled standalone CLI](runtime.md).
30Run them as offline maintenance work, separately from a control-loop process.
31The implementation requires a POSIX filesystem and bounded regular files.
32Parent directories must already exist. An output directory must be new and
33separate from the source repository; existing candidates are never overwritten.
34Repository/candidate directories must belong to the invoking user and must not
35be group/world writable. Their parent paths must also be protected against
36replacement by other users. Same-user privileged filesystem mutation is outside
37the protection model; this is not an adversarial-filesystem sandbox.
38
39## Repository and signed image contract
40
41Use a standard TUF repository with `metadata/` and `targets/` directories,
42including consistent-snapshot names when enabled. Verification uses the pinned
43[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
44rotation, signature thresholds, expiration, inter-role versions/hashes and
45target length/hashes. A private, randomly addressed loopback server supplies
46bounded local files to the client's public HTTP interface. It is stopped after
47verification. No external network fetch or arbitrary URL option is provided.
48The host additionally verifies that root and delegation key IDs equal SHA-256
49of their canonical key objects, using the upstream canonical JSON library.
50This rejects arbitrary key-ID aliases before role verification.
51Metadata versions must be positive safe integers. Expiration dates must be real
52UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes
53expiry comparisons; malformed or automatically normalized dates are rejected.
54
55The selected target must have SHA-256, a nonzero length of at most 16 MiB and
56exactly these fields in its signed `custom.drone` object:
57
58```json
59{
60 "schema": "drone-runtime-image-v1",
61 "platform": "pico2-neutral-bench-v1",
62 "protocolVersion": 1,
63 "releaseSequence": 7,
64 "sourceRevision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
65 "profile": "neutral-bench"
66}
67```
68
69The other supported platform label is `host-neutral-bench-v1`. Target and metadata
70paths use bounded ASCII names without traversal, escapes or symlinks. Metadata
71is limited to 512 KiB per file, 16 root rotations, 16 delegations, 64 requests and
7232 MiB of distinct fetched bytes. FIFO, device, symlink and oversized inputs fail.
73The neutral profile is a **signer's declaration**, not an inspection of machine
74code or proof of neutral outputs. This module authenticates bytes and declared
75compatibility; it does not parse UF2/ELF or execute an image.
76
77## Candidate integrity and interruption
78
79The private candidate contains the original `trust-root.json`, verified
80`image.bin`, fetched repository files, SDK metadata cache and final `stage.json`.
81The image is verified again after copying. Files/directories are synced and the
82completion marker is written last. Failures propagate and leave evidence in the
83new directory. A partial directory without a valid marker fails inspection.
84Do not reuse or automatically resume that directory.
85
86Inspection starts a fresh verifier with the independently supplied root pin and
87policy. It verifies the saved repository's signed metadata and actual image
88again, then compares the receipt with authenticated values. It does not trust
89the receipt's claimed hash, platform, release or authority. A raised release
90floor can reject a previously accepted candidate. Inspection does not modify
91the candidate.
92
93The marker means logical completeness. It cannot prove that a final sync or
94subsequent power loss preserved bytes. A process can fail after the marker was
95written. Inspection must always re-authenticate the candidate; no consumer may
96treat marker existence alone as permission to install or boot it.
97
98## Remaining update and operations acceptance
99
100Each verification starts from the supplied root. `trustStatePersisted: false`
101means this module has no protected cross-invocation metadata or release
102high-water state. It is not a complete anti-rollback implementation. The caller
103must supply the trusted release floor every time. TUF expiry uses host wall
104time; trusted clock acquisition and protection against clock/storage rollback
105remain open. Control-loop freshness still uses its separate monotonic clock.
106
107Next, implement persistent trust enrollment/rotation and release floors, an
108interruption-safe coordinator, installed/known-good slot integrity, explicit
109health confirmation and rollback without lowering security floors. Then add
110the board bootloader/install protocol and measured power-loss, full-medium,
111storage-latency and recovery campaigns. Local privileged filesystem tampering
112is outside this host module's protection; it is not secure boot or hardware
113anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.github.io/specification/latest/).
114
115Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes.
116They cover real role signatures/rotation, expiration, compatibility, supplied
117release floors, tampering, bounded inputs, incomplete candidates and native/
118portable CLI behavior. They establish host behavior, not actual firmware
119installation, board boot recovery or commissioning. The diagnostic supervisor's
120update/rollback check remains blocked and `commissioningReady` stays false.
package.jsonmodified+5-2
Changes to package.json
@@ -49,7 +49,9 @@
4949 "@stacksjs/testing": "^0.75.221",
5050 "@stacksjs/validation": "^0.75.221",
5151 "stacks": "^0.75.221",
52 "ts-images": "0.2.31"
52 "ts-images": "0.2.31",
53 "tuf-js": "6.0.0",
54 "@tufjs/canonical-json": "2.0.0"
5355 },
5456 "workspaces": [
5557 "storage/framework",
@@ -66,7 +68,8 @@
6668 },
6769 "devDependencies": {
6870 "@stacksjs/query-builder": "0.75.221",
69 "better-dx": "^0.2.37"
71 "better-dx": "^0.2.37",
72 "@tufjs/models": "5.0.0"
7073 },
7174 "author": "Chris Breuer <chris@stacksjs.com>",
7275 "homepage": "https://github.com/stacksjs/drone",
resources/functions/drone/diagnostics/replay.tsmodified+1-1
Changes to resources/functions/drone/diagnostics/replay.ts
@@ -59,7 +59,7 @@ export function diagnoseDiagnostics(journal: DiagnosticJournal) {
5959 { id: 'runtime-status', state: result.latest && !result.statusStale ? 'pass' : 'fail', detail: result.latest ? (result.statusStale ? 'stale' : 'recorded') : 'missing' },
6060 { id: 'runtime-health', state: result.health.length || result.replayedStatus || result.wireGaps ? 'fail' : 'pass', detail: result.health.join(', ') || (result.wireGaps ? 'status-gaps' : 'no-observed-fault') },
6161 { id: 'physical-commissioning', state: 'blocked', detail: 'independent-hardware-evidence-required' },
62 { id: 'signed-update-rollback', state: 'blocked', detail: 'not-implemented' },
62 { id: 'signed-update-rollback', state: 'blocked', detail: 'offline-staging-only; persistent-trust-and-rollback-required' },
6363 ] as const
6464 return { version: 1, runId: journal.header.runId, evidence: journal.header.evidence, verifiedHash: journal.verifiedHash, checks, diagnosticFailures: checks.filter(check => check.state === 'fail').length, commissioningReady: false as const, replay: result, physicalFlightEnabled: false as const }
6565}
resources/functions/drone/updates/command.tsadded+14-0
Changes to resources/functions/drone/updates/command.ts
@@ -0,0 +1,14 @@
1import type { RuntimeFirmwarePolicy } from './staging'
2
3export const firmwarePolicyFlags = {
4 '--target <path>': 'Explicit signed target path inside the offline repository',
5 '--platform <name>': 'Expected pico2-neutral-bench-v1 or host-neutral-bench-v1 platform',
6 '--root-sha256 <hex>': 'Independently trusted SHA-256 of the initial root metadata',
7 '--minimum-release-sequence <n>': 'Explicit externally trusted positive release floor',
8} as const
9
10export function firmwareCommandPolicy(options: { target?: unknown, platform?: unknown, rootSha256?: unknown, minimumReleaseSequence?: unknown }): RuntimeFirmwarePolicy {
11 if (typeof options.target !== 'string' || typeof options.rootSha256 !== 'string' || typeof options.minimumReleaseSequence !== 'string' || !/^[1-9]\d{0,15}$/.test(options.minimumReleaseSequence)) throw new Error('Explicit target, root pin and trusted release floor are required')
12 if (options.platform !== 'pico2-neutral-bench-v1' && options.platform !== 'host-neutral-bench-v1') throw new Error('Explicit implemented bench platform required')
13 return { target: options.target, platform: options.platform, trustedRootSha256: options.rootSha256, minimumReleaseSequence: Number(options.minimumReleaseSequence) }
14}
resources/functions/drone/updates/staging.tsadded+238-0
Changes to resources/functions/drone/updates/staging.ts
@@ -0,0 +1,238 @@
1import { createHash, randomBytes } from 'node:crypto'
2import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeSync } from 'node:fs'
3import { dirname, join, relative, resolve, sep } from 'node:path'
4import { tmpdir } from 'node:os'
5import { Readable } from 'node:stream'
6import { isDeepStrictEqual } from 'node:util'
7import { canonicalize } from '@tufjs/canonical-json'
8import { Updater } from 'tuf-js'
9import type { TargetFile } from 'tuf-js'
10
11const metadataLimit = 512 * 1024
12const imageLimit = 16 * 1024 * 1024
13const originPrefix = '/offline-'
14const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
15
16export interface RuntimeFirmwarePolicy {
17 target: string
18 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
19 trustedRootSha256: string
20 /** Explicit externally trusted floor. This module does not persist or advance it. */
21 minimumReleaseSequence: number
22}
23export interface RuntimeFirmwareStage {
24 schema: 'drone-runtime-stage-v1'
25 target: string
26 platform: RuntimeFirmwarePolicy['platform']
27 trustedRootSha256: string
28 rootVersion: number
29 releaseSequence: number
30 protocolVersion: 1
31 sourceRevision: string
32 signerDeclaredProfile: 'neutral-bench'
33 length: number
34 sha256: string
35 physicalDeploymentAllowed: false
36 trustStatePersisted: false
37}
38
39function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
40 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
41 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
42 safePath(policy.target)
43 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
44 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
45 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
46 return Object.freeze(policy)
47}
48function safePath(path: string) {
49 if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path')
50}
51function regularFile(path: string, limit: number): Buffer {
52 const before = lstatSync(path)
53 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
54 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
55 try {
56 const stat = fstatSync(fd)
57 if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file')
58 const bytes = Buffer.alloc(stat.size + 1)
59 let offset = 0
60 while (offset < bytes.length) {
61 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
62 if (count === 0) break
63 offset += count
64 }
65 if (offset !== stat.size) throw new Error('Source file changed length while reading')
66 return bytes.subarray(0, offset)
67 }
68 finally { closeSync(fd) }
69}
70function directory(path: string) {
71 const stat = lstatSync(path)
72 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
73 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Repository and candidate directories must be owned and not writable by other principals')
74}
75function repositoryFile(root: string, path: string, limit: number) {
76 safePath(path)
77 directory(root)
78 const parts = path.split('/')
79 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
80 return regularFile(join(root, ...parts), limit)
81}
82function writeExclusive(path: string, bytes: Uint8Array) {
83 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
84 try {
85 let offset = 0
86 while (offset < bytes.length) {
87 const count = writeSync(fd, bytes, offset, bytes.length - offset)
88 if (count <= 0) throw new Error('No progress writing firmware candidate')
89 offset += count
90 }
91 fsyncSync(fd)
92 }
93 finally { closeSync(fd) }
94}
95function syncDirectory(path: string) {
96 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
97 try { fsyncSync(fd) }
98 finally { closeSync(fd) }
99}
100function syncTree(path: string) {
101 directory(path)
102 for (const name of readdirSync(path)) {
103 const child = join(path, name), stat = lstatSync(child)
104 if (stat.isDirectory()) syncTree(child)
105 else {
106 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
107 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
108 try { fsyncSync(fd) }
109 finally { closeSync(fd) }
110 }
111 }
112 syncDirectory(path)
113}
114function object(value: unknown): Record<string, unknown> {
115 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
116 return value as Record<string, unknown>
117}
118function validateMetadata(bytes: Buffer) {
119 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
120 if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version')
121 // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the
122 // specified UTC calendar representation before the SDK performs its expiry comparison.
123 const expires = signed.expires
124 if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date')
125 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
126 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
127 // identifier aliases by binding each declared ID to the canonical key object.
128 for (const [id, key] of Object.entries(keys ?? {})) {
129 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
130 }
131}
132function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
133 const data = object(info.custom.drone)
134 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
135 if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new Error('Unsupported signed runtime manifest')
136 if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new Error('Signed runtime platform or protocol does not match policy')
137 if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new Error('Signed release is below the trusted minimum release sequence')
138 if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new Error('Invalid signed source revision')
139 if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new Error('Runtime image requires a bounded length and SHA-256')
140 return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision }
141}
142
143/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
144async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
145 validateMetadata(root)
146 const fetched = new Map<string, Buffer>()
147 let requests = 0, received = 0
148 const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/`
149 const server = Bun.serve({
150 hostname: '127.0.0.1', port: 0,
151 fetch(request) {
152 const url = new URL(request.url)
153 if (request.method !== 'GET' || url.search || !url.pathname.startsWith(prefix)) return new Response(null, { status: 404 })
154 if (++requests > 64) return new Response(null, { status: 429 })
155 const path = url.pathname.slice(prefix.length)
156 try {
157 safePath(path)
158 if (!path.startsWith('targets/') && !/^metadata\/[a-z0-9][a-z0-9._-]{0,179}\.json$/i.test(path)) throw new Error('Unsupported metadata path')
159 let bytes = fetched.get(path)
160 if (!bytes) {
161 bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit)
162 if (path.startsWith('metadata/')) validateMetadata(bytes)
163 received += bytes.length
164 if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded')
165 fetched.set(path, bytes)
166 }
167 return new Response(new Uint8Array(bytes), { headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' } })
168 }
169 catch (error) {
170 // Only an absent file is a TUF 404. Rejected devices, symlinks and IO failures must fail refresh.
171 return new Response(null, { status: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500 })
172 }
173 },
174 })
175 try {
176 mkdirSync(work, { mode: 0o700 })
177 writeExclusive(join(work, 'root.json'), root)
178 const base = `http://127.0.0.1:${server.port}${prefix}`
179 const updater = new Updater({ metadataDir: work, metadataBaseUrl: `${base}metadata/`, targetBaseUrl: `${base}targets/`, config: { maxRootRotations: 16, maxDelegations: 16, rootMaxLength: metadataLimit, timestampMaxLength: metadataLimit, snapshotMaxLength: metadataLimit, targetsMaxLength: metadataLimit, fetchTimeout: 5000, fetchRetry: false } })
180 await updater.refresh()
181 const info = await updater.getTargetInfo(policy.target)
182 if (!info) throw new Error('Selected firmware target is absent')
183 const runtime = signedRuntime(info, policy)
184 if (download) await updater.downloadTarget(info, download)
185 const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8')))
186 const rootVersion = object(latest.signed).version
187 if (!Number.isSafeInteger(rootVersion)) throw new Error('Invalid verified root version')
188 const result: RuntimeFirmwareStage = { schema: 'drone-runtime-stage-v1', target: policy.target, platform: policy.platform, trustedRootSha256: policy.trustedRootSha256, rootVersion: rootVersion as number, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, physicalDeploymentAllowed: false, trustStatePersisted: false }
189 return { result, fetched, info }
190 }
191 finally { await server.stop(true) }
192}
193
194/** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */
195export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> {
196 const policy = admitPolicy(options)
197 const repository = realpathSync(options.repository), output = resolve(options.output)
198 directory(repository)
199 const root = regularFile(options.trustedRoot, metadataLimit)
200 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
201 // Resolve the existing parent before creating anything, so a symlinked parent cannot hide overlap.
202 const destination = join(realpathSync(dirname(output)), output.slice(dirname(output).length + 1))
203 const child = relative(repository, destination), parent = relative(destination, repository)
204 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!parent.startsWith(`..${sep}`) && parent !== '..')) throw new Error('Candidate and source repository must be separate')
205 mkdirSync(output, { mode: 0o700 })
206 writeExclusive(join(output, 'trust-root.json'), root)
207 const { result, fetched, info } = await verifyRepository(repository, root, join(output, '.verification'), policy, join(output, 'image.bin'))
208 // Re-read the actual staged bytes, rather than trusting a successful copy or receipt.
209 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
210 for (const [path, bytes] of fetched) {
211 const file = join(output, 'repository', path)
212 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
213 writeExclusive(file, bytes)
214 }
215 syncTree(output)
216 // A final marker means logical completeness. It cannot attest to power-loss durability after fsync.
217 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(result, null, 2)}\n`))
218 syncDirectory(output)
219 syncDirectory(dirname(output))
220 return result
221}
222
223/** Re-authenticate staged metadata and image using caller-supplied trust/policy, never a self-asserted receipt. */
224export async function inspectStagedRuntimeFirmware(options: RuntimeFirmwarePolicy & { directory: string }): Promise<RuntimeFirmwareStage> {
225 const policy = admitPolicy(options), candidate = resolve(options.directory)
226 directory(candidate)
227 const receipt: unknown = JSON.parse(regularFile(join(candidate, 'stage.json'), 16384).toString('utf8'))
228 const root = regularFile(join(candidate, 'trust-root.json'), metadataLimit)
229 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
230 const work = mkdtempSync(join(tmpdir(), 'drone-update-inspect-'))
231 try {
232 const { result, info } = await verifyRepository(join(candidate, 'repository'), root, join(work, 'metadata'), policy)
233 await info.verify(Readable.from(regularFile(join(candidate, 'image.bin'), imageLimit)))
234 if (!isDeepStrictEqual(receipt, result)) throw new Error('Staged receipt does not match re-authenticated image metadata')
235 return result
236 }
237 finally { rmSync(work, { recursive: true, force: true }) }
238}
tests/fixtures/updates/qualify.tsadded+36-0
Changes to tests/fixtures/updates/qualify.ts
@@ -0,0 +1,36 @@
1import { mkdir, readFile, writeFile } from 'node:fs/promises'
2import { resolve, join } from 'node:path'
3import { repository, platform, target } from './repository'
4
5/** Qualification uses generated test payloads/keys and never executes an image. */
6export async function qualifyRuntimeUpdates(directory: string, binary: string) {
7 await mkdir(directory, { mode: 0o700 })
8 const repo = join(directory, 'repo'), candidate = join(directory, 'candidate'), fixture = await repository(repo)
9 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
10 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
11 async function run(args: string[], expected: number) {
12 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
13 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
14 try {
15 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
16 results.push({ command: args[0]!, exit, stdout, stderr })
17 if (exit !== expected) throw new Error(`Unexpected portable update exit: ${exit}`)
18 return stdout
19 }
20 finally { clearTimeout(timer) }
21 }
22 try {
23 const staged = JSON.parse(await run(['updates:stage', repo, '--trusted-root', fixture.rootPath, '--output', candidate, ...flags], 0))
24 if (staged.sha256 !== fixture.imageHash || staged.physicalDeploymentAllowed !== false || staged.trustStatePersisted !== false) throw new Error('Invalid portable stage result')
25 const inspected = JSON.parse(await run(['updates:inspect', candidate, ...flags], 0))
26 if (JSON.stringify(inspected) !== JSON.stringify(staged)) throw new Error('Portable re-verification mismatch')
27 const rejected = await run(['updates:inspect', candidate, ...flags.slice(0, -1), '8'], 1)
28 if (rejected !== '' || !(await readFile(join(candidate, 'image.bin'))).equals(fixture.image)) throw new Error('Rejected verification changed the candidate')
29 }
30 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'generated-signed-test-payload-only', physicalFirmwareInstalled: false, results }, null, 2)) }
31 return { evidence: 'generated-signed-test-payload-only', operations: results.length, physicalFirmwareInstalled: false }
32}
33if (import.meta.main) {
34 if (process.argv.length !== 4) throw new Error('Use qualify.ts NEW_DIRECTORY COMPILED_BINARY')
35 console.log(JSON.stringify(await qualifyRuntimeUpdates(resolve(process.argv[2]!), resolve(process.argv[3]!))))
36}
tests/fixtures/updates/repository.tsadded+61-0
Changes to tests/fixtures/updates/repository.ts
@@ -0,0 +1,61 @@
1import { generateKeyPairSync, sign, createHash } from 'node:crypto'
2import { mkdir, writeFile } from 'node:fs/promises'
3import { join } from 'node:path'
4import { Key, Metadata, MetaFile, Root, Signature, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models'
5import { canonicalize } from '@tufjs/canonical-json'
6
7export const target = 'runtime/pico2-bench.bin'
8export const platform = 'pico2-neutral-bench-v1'
9export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
10const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' }
11
12function signer(nonCanonical = false) {
13 const pair = generateKeyPairSync('ed25519')
14 const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex')
15 const keyObject = { keytype: 'ed25519', scheme: 'ed25519', keyval: { public: publicKey } }
16 const keyID = digest(nonCanonical ? Buffer.from(publicKey, 'hex') : Buffer.from(canonicalize(keyObject)))
17 const key = new Key({ keyID, keyType: 'ed25519', scheme: 'ed25519', keyVal: { public: publicKey } })
18 return { key, sign: (data: Buffer) => new Signature({ keyID: key.keyID, sig: sign(null, data, pair.privateKey).toString('hex') }) }
19}
20
21/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
22export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
23 await mkdir(join(directory, 'metadata'), { recursive: true })
24 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
25 const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() }
26 const root = new Root({ ...fields, ...options.metadataFields?.root })
27 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role)
28 const rootMetadata = new Metadata(root)
29 rootMetadata.sign(keys.root.sign)
30 const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON()))
31 await writeFile(join(directory, 'trusted-root.json'), rootBytes)
32 if (options.rotate) {
33 const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2, ...options.metadataFields?.rotatedRoot })
34 next.addKey(nextKey.key, 'root')
35 for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role)
36 const rotated = new Metadata(next)
37 if (!options.badRotation) rotated.sign(keys.root.sign)
38 rotated.sign(nextKey.sign)
39 await writeFile(join(directory, 'metadata/2.root.json'), JSON.stringify(rotated.toJSON()))
40 }
41 const image = Buffer.from('test payload only, never execute or flash\n')
42 const imageHash = digest(image)
43 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom }
44 const targets = new Metadata(new Targets({ ...fields, ...options.metadataFields?.targets, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } }))
45 if (options.nonCanonicalDelegation) {
46 const delegated = signer(true)
47 targets.signed.unrecognizedFields.delegations = { keys: { [delegated.key.keyID]: delegated.key.toJSON() }, roles: [] }
48 }
49 targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign)
50 const targetsBytes = Buffer.from(JSON.stringify(targets.toJSON()))
51 const snapshot = new Metadata(new Snapshot({ ...fields, ...options.metadataFields?.snapshot, meta: { 'targets.json': new MetaFile({ version: 1, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } }))
52 snapshot.sign(keys.snapshot.sign)
53 const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON()))
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 timestamp.sign(keys.timestamp.sign)
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`) }
61}
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -30,6 +30,9 @@
3030 "./unit/payloads.test.ts",
3131 "./fixtures/payloads/**/*.ts",
3232 "../resources/functions/drone/diagnostics/**/*.ts",
33 "../resources/functions/drone/updates/**/*.ts",
34 "./unit/updates*.test.ts",
35 "./fixtures/updates/**/*.ts",
3336 "./unit/diagnostics.test.ts",
3437 "./fixtures/diagnostics/**/*.ts",
3538 "../resources/functions/drone/missions/**/*.ts",
tests/unit/updates-cli.test.tsadded+31-0
Changes to tests/unit/updates-cli.test.ts
@@ -0,0 +1,31 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { repository, platform, target } from '../fixtures/updates/repository'
7
8const roots: string[] = []
9afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
10
11test('native update commands stage and reverify with explicit caller trust, then reject a raised floor', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-update-cli-')); roots.push(root)
13 const repo = join(root, 'repo'), output = join(root, 'candidate'), fixture = await repository(repo)
14 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
15 const staged = await command(['updates:stage', repo, '--trusted-root', fixture.rootPath, '--output', output, ...flags]).withTimeout(20000)
16 expect(staged.exitCode).toBe(0)
17 expect(staged.timedOut).toBe(false)
18 const value = JSON.parse(staged.stdout)
19 expect(value.sha256).toBe(fixture.imageHash)
20 expect(value.physicalDeploymentAllowed).toBe(false)
21 const inspected = await command(['updates:inspect', output, ...flags]).withTimeout(20000)
22 expect(inspected.exitCode).toBe(0)
23 expect(JSON.parse(inspected.stdout)).toEqual(value)
24 const rejected = await command(['updates:inspect', output, ...flags.slice(0, -1), '8']).withTimeout(20000)
25 expect(rejected.exitCode).toBe(1)
26 expect(rejected.stdout).toBe('')
27 expect(rejected.stderr).toBe('Firmware candidate unavailable: invalid trust, rejected image or output failure\n')
28 const missing = await command(['updates:inspect', output]).withTimeout(20000)
29 expect(missing.exitCode).toBe(1)
30 expect(missing.stdout).toBe('')
31}, 100000)
tests/unit/updates.test.tsadded+204-0
Changes to tests/unit/updates.test.ts
@@ -0,0 +1,204 @@
1import { test, expect, afterEach } from 'bun:test'
2import { mkdtemp, rm, readFile, writeFile, mkdir, symlink, stat, truncate, chmod } from 'node:fs/promises'
3import { spawnSync } from 'node:child_process'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { repository, platform, target } from '../fixtures/updates/repository'
7import { stageRuntimeFirmware, inspectStagedRuntimeFirmware, type RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging'
8
9const roots: string[] = []
10afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
11
12test('stage and independently reverify an explicitly selected signed runtime image', async () => {
13 const root = await mkdtemp(join(tmpdir(), 'drone-update-test-')); roots.push(root)
14 const repo = join(root, 'repo'), output = join(root, 'candidate')
15 const fixture = await repository(repo)
16 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
17 const result = await stageRuntimeFirmware({ ...policy, repository: repo, trustedRoot: fixture.rootPath, output })
18 expect(result.sha256).toBe(fixture.imageHash)
19 expect(result.releaseSequence).toBe(7)
20 expect(result.physicalDeploymentAllowed).toBe(false)
21 expect(result.trustStatePersisted).toBe(false)
22 expect(await readFile(join(output, 'image.bin'))).toEqual(fixture.image)
23 expect(await inspectStagedRuntimeFirmware({ ...policy, directory: output })).toEqual(result)
24 expect((await stat(output)).mode & 0o777).toBe(0o700)
25 expect((await stat(join(output, 'stage.json'))).mode & 0o777).toBe(0o600)
26})
27
28async function setup(options: Parameters<typeof repository>[1] = {}) {
29 const root = await mkdtemp(join(tmpdir(), 'drone-update-test-')); roots.push(root)
30 const repo = join(root, 'repo'), output = join(root, 'candidate'), fixture = await repository(repo, options)
31 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
32 return { root, repo, output, fixture, policy, input: { ...policy, repository: repo, trustedRoot: fixture.rootPath, output } }
33}
34async function incomplete(output: string) {
35 expect(await Bun.file(join(output, 'stage.json')).exists()).toBe(false)
36}
37
38test('a root rotation needs signatures from both old and new root authorities', async () => {
39 const valid = await setup({ rotate: true })
40 expect((await stageRuntimeFirmware(valid.input)).rootVersion).toBe(2)
41 expect((await inspectStagedRuntimeFirmware({ ...valid.policy, directory: valid.output })).rootVersion).toBe(2)
42 const invalid = await setup({ rotate: true, badRotation: true })
43 await expect(stageRuntimeFirmware(invalid.input)).rejects.toThrow()
44 await incomplete(invalid.output)
45})
46
47test('reject expired metadata and signatures from an unauthorized targets key', async () => {
48 for (const options of [{ expires: '2020-01-01T00:00:00Z' }, { badTargetsSignature: true }]) {
49 const item = await setup(options)
50 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
51 await incomplete(item.output)
52 }
53})
54
55test('signed platform, protocol, profile and supplied release floor must all match', async () => {
56 const rejected: Record<string, string | number | boolean>[] = [{ platform: 'host-neutral-bench-v1' }, { protocolVersion: 2 }, { releaseSequence: 6 }, { releaseSequence: 0 }, { profile: 'flight' }, { sourceRevision: 'not-a-revision' }, { extraAuthority: true }]
57 for (const custom of rejected) {
58 const item = await setup({ custom })
59 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
60 await incomplete(item.output)
61 }
62})
63
64test('image length/hash and snapshot-bound metadata are verified before the completion marker', async () => {
65 const image = await setup()
66 await writeFile(image.fixture.imagePath, Buffer.alloc(image.fixture.image.length, 0))
67 await expect(stageRuntimeFirmware(image.input)).rejects.toThrow()
68 await incomplete(image.output)
69 const metadata = await setup()
70 const path = join(metadata.repo, 'metadata/1.targets.json'), data = JSON.parse(await readFile(path, 'utf8'))
71 data.signed.targets[target].custom.drone.releaseSequence = 99
72 await writeFile(path, JSON.stringify(data))
73 await expect(stageRuntimeFirmware(metadata.input)).rejects.toThrow()
74 await incomplete(metadata.output)
75})
76
77test('root pins, target paths and explicit floors cannot be taken from untrusted input', async () => {
78 const item = await setup()
79 for (const change of [{ trustedRootSha256: '0'.repeat(64) }, { trustedRootSha256: '' }, { minimumReleaseSequence: 0 }, { minimumReleaseSequence: 7.5 }, { minimumReleaseSequence: Number.NaN }, { target: '../root' }, { target: '/runtime/image' }, { target: 'runtime/%2e%2e/image' }, { target: 'runtime\\image' }, { target: 'missing/image.bin' }]) {
80 const output = join(item.root, `candidate-${Math.random().toString(16).slice(2)}`)
81 await expect(stageRuntimeFirmware({ ...item.input, ...change, output })).rejects.toThrow()
82 await incomplete(output)
83 }
84})
85
86test('an existing output and overlapping source repository are never overwritten', async () => {
87 const item = await setup()
88 await mkdir(item.output)
89 await writeFile(join(item.output, 'keep'), 'untouched')
90 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
91 expect(await readFile(join(item.output, 'keep'), 'utf8')).toBe('untouched')
92 await expect(stageRuntimeFirmware({ ...item.input, output: join(item.repo, 'inside') })).rejects.toThrow('separate')
93 await symlink(item.repo, join(item.root, 'source-link'))
94 await expect(stageRuntimeFirmware({ ...item.input, output: join(item.root, 'source-link/inside') })).rejects.toThrow('separate')
95})
96
97test('reject source symlinks, devices and over-budget regular files without marking complete', async () => {
98 const symlinked = await setup()
99 await rm(symlinked.fixture.imagePath)
100 await symlink(symlinked.fixture.rootPath, symlinked.fixture.imagePath)
101 await expect(stageRuntimeFirmware(symlinked.input)).rejects.toThrow()
102 await incomplete(symlinked.output)
103 const oversized = await setup()
104 await truncate(oversized.fixture.imagePath, 16 * 1024 * 1024 + 1)
105 await expect(stageRuntimeFirmware(oversized.input)).rejects.toThrow()
106 await incomplete(oversized.output)
107 const fifo = await setup()
108 await rm(fifo.fixture.imagePath)
109 expect(spawnSync('mkfifo', [fifo.fixture.imagePath]).status).toBe(0)
110 await expect(stageRuntimeFirmware(fifo.input)).rejects.toThrow()
111 await incomplete(fifo.output)
112})
113
114test('inspection rejects partial candidates, modified images/receipts and a newly raised floor', async () => {
115 const partial = await setup()
116 await mkdir(partial.output)
117 await writeFile(join(partial.output, 'trust-root.json'), await readFile(partial.fixture.rootPath))
118 await expect(inspectStagedRuntimeFirmware({ ...partial.policy, directory: partial.output })).rejects.toThrow()
119 const item = await setup()
120 const original = await stageRuntimeFirmware(item.input)
121 await expect(inspectStagedRuntimeFirmware({ ...item.policy, minimumReleaseSequence: 8, directory: item.output })).rejects.toThrow()
122 await expect(inspectStagedRuntimeFirmware({ ...item.policy, target: 'other/image.bin', directory: item.output })).rejects.toThrow()
123 await expect(inspectStagedRuntimeFirmware({ ...item.policy, trustedRootSha256: '0'.repeat(64), directory: item.output })).rejects.toThrow()
124 await writeFile(join(item.output, 'stage.json'), JSON.stringify({ ...original, physicalDeploymentAllowed: true }))
125 await expect(inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).rejects.toThrow('receipt')
126 await writeFile(join(item.output, 'stage.json'), JSON.stringify(original))
127 await writeFile(join(item.output, 'image.bin'), Buffer.alloc(item.fixture.image.length, 0))
128 await expect(inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).rejects.toThrow()
129})
130
131test('concurrent attempts cannot share an output directory', async () => {
132 const item = await setup(), attempts = await Promise.allSettled([stageRuntimeFirmware(item.input), stageRuntimeFirmware(item.input)])
133 expect(attempts.filter(x => x.status === 'fulfilled')).toHaveLength(1)
134 expect(attempts.filter(x => x.status === 'rejected')).toHaveLength(1)
135 expect((await inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).sha256).toBe(item.fixture.imageHash)
136})
137
138test('repository directories writable by other principals cannot enter verification', async () => {
139 const item = await setup()
140 await chmod(join(item.repo, 'metadata'), 0o777)
141 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
142 await incomplete(item.output)
143})
144
145test('root, rotated root and delegation key identifiers must bind canonical key data', async () => {
146 for (const options of [{ nonCanonicalRoot: true }, { rotate: true, nonCanonicalRotation: true }, { nonCanonicalDelegation: true }]) {
147 const item = await setup(options)
148 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
149 await incomplete(item.output)
150 }
151})
152
153test('admission owns the trust policy and inspection directory across asynchronous verification', async () => {
154 const stage = await setup(), input = { ...stage.input, minimumReleaseSequence: 8 }
155 const pendingStage = stageRuntimeFirmware(input)
156 input.minimumReleaseSequence = 7
157 await expect(pendingStage).rejects.toThrow('minimum release sequence')
158 await incomplete(stage.output)
159 const item = await setup(), accepted = await stageRuntimeFirmware(item.input)
160 const raised = { ...item.policy, directory: item.output, minimumReleaseSequence: 8 }
161 const pendingInspect = inspectStagedRuntimeFirmware(raised)
162 raised.minimumReleaseSequence = 7
163 await expect(pendingInspect).rejects.toThrow('minimum release sequence')
164 const stable = { ...item.policy, directory: item.output }
165 const pendingStable = inspectStagedRuntimeFirmware(stable)
166 stable.directory = join(item.root, 'does-not-exist')
167 stable.trustedRootSha256 = '0'.repeat(64)
168 stable.target = 'other/image.bin'
169 expect(await pendingStable).toEqual(accepted)
170}, 60000)
171
172
173test('reject malformed or normalized expiration dates instead of treating them as unexpired', async () => {
174 for (const expires of ['not-a-date', '2035-02-30T00:00:00Z', '2035-01-01', '2035-01-01T00:00:00+01:00']) {
175 const item = await setup({ expires })
176 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
177 await incomplete(item.output)
178 }
179})
180
181test('the bootstrap root and every fetched metadata role require valid expiration dates', async () => {
182 for (const role of ['root', 'rotatedRoot', 'snapshot', 'targets'] as const) {
183 const item = await setup({ rotate: role === 'rotatedRoot', metadataFields: { [role]: { expires: 'not-a-date' } } })
184 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
185 await incomplete(item.output)
186 }
187})
188
189test('metadata versions must be positive safe integers', async () => {
190 for (const role of ['root', 'timestamp'] as const) {
191 for (const version of [0, -1, 9007199254740992]) {
192 const item = await setup({ metadataFields: { [role]: { version } } })
193 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
194 await incomplete(item.output)
195 }
196 }
197})
198
199
200test('a valid UTC leap date remains accepted and independently verifiable', async () => {
201 const item = await setup({ expires: '2036-02-29T12:34:56Z' })
202 const staged = await stageRuntimeFirmware(item.input)
203 expect(await inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).toEqual(staged)
204})