ReviewOS

also looking at this

stacks/drone

feat: enroll protected runtime trust anchors

#43
Merged chris wants to merge codex/runtime-trust-enrollment into main
33 files +1297 -18
.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -32,6 +32,10 @@ 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"
37 - name: Verify protected initial trust through a copied standalone CLI
38 run: bun tests/fixtures/updates/qualify-trust.ts "$RUNNER_TEMP/runtime-trust-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
3539 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
3640 working-directory: ${{ runner.temp }}
3741 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+6-1
Changes to README.md
@@ -262,7 +262,12 @@ 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. `updates:enroll` and
268`updates:trust` preserve and independently verify an initial root/policy record.
269Physical logging/commissioning, persistent refresh metadata/release floors, image
270installation and known-good rollback remain pending.
266271
267272## Sensor acquisition and replay
268273
app/Commands/UpdatesEnroll.tsadded+22-0
Changes to app/Commands/UpdatesEnroll.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 { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
5
6export default defineCommand({
7 name: 'updates:enroll',
8 description: 'Preserve an independently trusted initial root and explicit policy without refreshing or installing firmware',
9 options: { ...firmwarePolicyFlags, '--trusted-root <path>': 'Independently authenticated initial root file', '--output <path>': 'New exclusive protected enrollment directory' },
10 async handle(options) {
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(enrollRuntimeTrust({ ...policy, trustedRoot: options.trustedRoot, output: options.output })))
15 }
16 catch {
17 process.exitCode = 1
18 try { await printErrorAsync('Runtime trust enrollment unavailable: invalid anchor, policy or output failure') }
19 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
20 }
21 },
22})
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})
app/Commands/UpdatesTrust.tsadded+20-0
Changes to app/Commands/UpdatesTrust.ts
@@ -0,0 +1,20 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
4
5export default defineCommand({
6 name: 'updates:trust <directory>',
7 description: 'Verify an initial trust enrollment against its independently retained record hash',
8 options: { '--enrollment-sha256 <hex>': 'Independently retained SHA-256 from trusted enrollment' },
9 async handle(options, directory) {
10 try {
11 if (typeof options.enrollmentSha256 !== 'string') throw new Error('An independently retained enrollment pin is required')
12 await printAsync(JSON.stringify(inspectRuntimeTrust({ directory, enrollmentSha256: options.enrollmentSha256 })))
13 }
14 catch {
15 process.exitCode = 1
16 try { await printErrorAsync('Runtime trust unavailable: invalid enrollment pin or protected state') }
17 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
18 }
19 },
20})
bin/drone.tsmodified+8-0
Changes to bin/drone.ts
@@ -19,6 +19,10 @@ 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'
24import UpdatesEnroll from '../app/Commands/UpdatesEnroll'
25import UpdatesTrust from '../app/Commands/UpdatesTrust'
2226
2327import MissionsSimulate from '../app/Commands/MissionsSimulate'
2428import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -81,6 +85,10 @@ DiagnosticsRecord(app)
8185DiagnosticsReplay(app)
8286DiagnosticsInspect(app)
8387DiagnosticsExport(app)
88UpdatesStage(app)
89UpdatesInspect(app)
90UpdatesEnroll(app)
91UpdatesTrust(app)
8492MissionsSimulate(app)
8593MissionsValidate(app)
8694MissionsReplay(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: 'Runtime trust and image staging', link: '/runtime-updates' },
7071 ],
7172 },
7273 {
docs/diagnostics.mdmodified+6-2
Changes to docs/diagnostics.md
@@ -155,8 +155,12 @@ 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. 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.
160164- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
161165 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
162166
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[Runtime trust and image staging](runtime-updates.md) preserves initial trust and 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 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) |
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+168-0
Changes to docs/runtime-updates.md
@@ -0,0 +1,168 @@
1# Runtime trust and signed image staging
2
3Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
4staging, independent candidate verification and protected initial trust enrollment.
5These commands prepare files on the host. Cross-update metadata/release floors,
6installed images, boot selection and known-good rollback slots remain to be
7implemented. Physical deployment is disabled.
8
9## Explicit trust and policy
10
11Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted
12channel. Do not calculate a pin from an untrusted bundle and call it trusted.
13The release floor must likewise come from externally trusted release policy.
14Every staging or candidate-inspection invocation requires the root pin, exact
15target, expected bench platform and positive minimum release sequence. There are
16no permissive defaults.
17
18```sh
19./buddy updates:stage /path/to/offline-repository \
20 --trusted-root /path/to/enrolled/root.json \
21 --root-sha256 "$ENROLLED_ROOT_SHA256" \
22 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
23 --minimum-release-sequence 7 --output /path/to/new-candidate
24
25./buddy updates:inspect /path/to/new-candidate \
26 --root-sha256 "$ENROLLED_ROOT_SHA256" \
27 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
28 --minimum-release-sequence 7
29```
30
31The same commands are registered in the [compiled standalone CLI](runtime.md).
32Run them as offline maintenance work, separately from a control-loop process.
33The implementation requires a POSIX filesystem and bounded regular files.
34Parent directories must already exist. An output directory must be new and
35separate from the source repository; existing candidates are never overwritten.
36Repository/candidate directories must belong to the invoking user and must not
37be group/world writable. Their parent paths must also be protected against
38replacement by other users. Same-user privileged filesystem mutation is outside
39the protection model; this is not an adversarial-filesystem sandbox.
40
41## Protected initial trust enrollment
42
43Store the independently authenticated root and explicit policy in a new private
44directory with native Buddy or the compiled CLI:
45
46```sh
47./buddy updates:enroll --trusted-root /path/to/enrolled/root.json \
48 --root-sha256 "$ENROLLED_ROOT_SHA256" \
49 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
50 --minimum-release-sequence 7 --output /path/to/new-trust
51
52./buddy updates:trust /path/to/new-trust \
53 --enrollment-sha256 "$INDEPENDENTLY_RETAINED_ENROLLMENT_SHA256"
54```
55
56Retain the returned `enrollmentSha256` through your trusted configuration or
57backup channel. Readback requires that independent expected hash; a hash taken
58from the same untrusted directory does not establish trust. The record binds a
59random enrollment identity, exact root hash/version and copied release policy.
60Readback checks the actual root bytes, schema, policy and root self-signatures.
61
62Enrollment creates `root.json` and a final `enrollment.json` record, using an
63exclusive 0700 directory and 0600 files. The directory and immediate parent must
64be owned and protected from other principals' writes; higher parent paths must
65also be protected against replacement. Existing, partial or corrupt enrollments
66are preserved and rejected. Files/directories are synced, but the record is a
67logical completion marker, not proof of physical power-loss durability. A command
68can fail after creating the record, including if output fails. Preserve those
69files and independently verify the record before relying on them.
70
71The result reports `metadataState: "initial-root-only"`,
72`metadataRefreshPerformed: false`, `firmwareSelectionPerformed: false` and
73`physicalDeploymentAllowed: false`. An authenticated expired initial root may be
74enrolled for later sequential rotation; this does not make an expired final root
75valid for a repository refresh. No refresh or network operation runs here.
76
77Keep this immutable anchor separate from an SDK working cache. The current SDK
78writes cache files in place; a host write-limit probe truncated its cached root.
79Never refresh against the enrollment directory. Staging/inspection still require
80their explicit policy and do not load or advance this enrollment automatically.
81Persistent verified metadata/release floors, key lifecycle, interruption recovery
82and known-good coordination remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
83Do not discard or re-enroll over failed state to reset a security floor.
84
85## Repository and signed image contract
86
87Use a standard TUF repository with `metadata/` and `targets/` directories,
88including consistent-snapshot names when enabled. Verification uses the pinned
89[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
90rotation, signature thresholds, expiration, inter-role versions/hashes and
91target length/hashes. A private, randomly addressed loopback server supplies
92bounded local files to the client's public HTTP interface. It is stopped after
93verification. No external network fetch or arbitrary URL option is provided.
94The host additionally verifies that root and delegation key IDs equal SHA-256
95of their canonical key objects, using the upstream canonical JSON library.
96This rejects arbitrary key-ID aliases before role verification.
97Metadata versions must be positive safe integers. Expiration dates must be real
98UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes
99expiry comparisons; malformed or automatically normalized dates are rejected.
100
101The selected target must have SHA-256, a nonzero length of at most 16 MiB and
102exactly these fields in its signed `custom.drone` object:
103
104```json
105{
106 "schema": "drone-runtime-image-v1",
107 "platform": "pico2-neutral-bench-v1",
108 "protocolVersion": 1,
109 "releaseSequence": 7,
110 "sourceRevision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
111 "profile": "neutral-bench"
112}
113```
114
115The other supported platform label is `host-neutral-bench-v1`. Target and metadata
116paths use bounded ASCII names without traversal, escapes or symlinks. Metadata
117is limited to 512 KiB per file, 16 root rotations, 16 delegations, 64 requests and
11832 MiB of distinct fetched bytes. FIFO, device, symlink and oversized inputs fail.
119The neutral profile is a **signer's declaration**, not an inspection of machine
120code or proof of neutral outputs. This module authenticates bytes and declared
121compatibility; it does not parse UF2/ELF or execute an image.
122
123## Candidate integrity and interruption
124
125The private candidate contains the original `trust-root.json`, verified
126`image.bin`, fetched repository files, SDK metadata cache and final `stage.json`.
127The image is verified again after copying. Files/directories are synced and the
128completion marker is written last. Failures propagate and leave evidence in the
129new directory. A partial directory without a valid marker fails inspection.
130Do not reuse or automatically resume that directory.
131
132Inspection starts a fresh verifier with the independently supplied root pin and
133policy. It verifies the saved repository's signed metadata and actual image
134again, then compares the receipt with authenticated values. It does not trust
135the receipt's claimed hash, platform, release or authority. A raised release
136floor can reject a previously accepted candidate. Inspection does not modify
137the candidate.
138
139The marker means logical completeness. It cannot prove that a final sync or
140subsequent power loss preserved bytes. A process can fail after the marker was
141written. Inspection must always re-authenticate the candidate; no consumer may
142treat marker existence alone as permission to install or boot it.
143
144## Remaining update and operations acceptance
145
146Each verification starts from the supplied root. `trustStatePersisted: false`
147means this module has no protected cross-invocation metadata or release
148high-water state. It is not a complete anti-rollback implementation. The caller
149must supply the trusted release floor every time. TUF expiry uses host wall
150time; trusted clock acquisition and protection against clock/storage rollback
151remain open. Control-loop freshness still uses its separate monotonic clock.
152
153Initial enrollment is available above. Next, implement protected trust rotation
154and persistent metadata/release floors, an
155interruption-safe coordinator, installed/known-good slot integrity, explicit
156health confirmation and rollback without lowering security floors. Then add
157the board bootloader/install protocol and measured power-loss, full-medium,
158storage-latency and recovery campaigns. Local privileged filesystem tampering
159is outside this host module's protection; it is not secure boot or hardware
160anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.github.io/specification/latest/).
161
162Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes.
163They cover real role signatures/rotation, expiration, compatibility, supplied
164release floors, tampering, bounded inputs, incomplete candidates, enrollment
165readback, competing enrollment processes, an actual host write-limit failure and
166native/portable CLI behavior. They establish host behavior, not actual firmware
167installation, board boot recovery or commissioning. The diagnostic supervisor's
168update/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/enrollment.tsadded+77-0
Changes to resources/functions/drone/updates/enrollment.ts
@@ -0,0 +1,77 @@
1import { randomBytes } from 'node:crypto'
2import { mkdirSync } from 'node:fs'
3import { dirname, join, resolve } from 'node:path'
4import { Updater } from 'tuf-js'
5import { directory, metadataLimit, regularFile, sha256, syncDirectory, writeExclusive } from './files'
6import { object, validateMetadata } from './metadata'
7import { admitPolicy } from './policy'
8import type { RuntimeFirmwarePolicy } from './policy'
9
10interface Enrollment {
11 schema: 'drone-runtime-trust-enrollment-v1'
12 enrollmentId: string
13 policy: Readonly<RuntimeFirmwarePolicy>
14 rootVersion: number
15}
16export interface RuntimeTrustSummary {
17 schema: 'drone-runtime-trust-v1'
18 enrollmentId: string
19 enrollmentSha256: string
20 policy: Readonly<RuntimeFirmwarePolicy>
21 rootVersion: number
22 metadataState: 'initial-root-only'
23 metadataRefreshPerformed: false
24 firmwareSelectionPerformed: false
25 physicalDeploymentAllowed: false
26}
27
28function authenticatedRoot(path: string, policy: RuntimeFirmwarePolicy) {
29 const root = regularFile(join(path, 'root.json'), metadataLimit)
30 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
31 validateMetadata(root)
32 // The public constructor verifies root self-signatures. Never refresh against the
33 // enrollment directory: it is the immutable anchor, not the SDK's writable cache.
34 new Updater({ metadataDir: path, metadataBaseUrl: 'http://127.0.0.1/unused-enrollment-only/' })
35 return object(object(JSON.parse(root.toString('utf8'))).signed).version as number
36}
37function summary(enrollment: Enrollment, enrollmentSha256: string): Readonly<RuntimeTrustSummary> {
38 return Object.freeze({ ...enrollment, schema: 'drone-runtime-trust-v1', enrollmentSha256, metadataState: 'initial-root-only', metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalDeploymentAllowed: false })
39}
40
41/** Persist an independently authenticated initial anchor and policy, without metadata refresh or deployment. */
42export function enrollRuntimeTrust(options: RuntimeFirmwarePolicy & { trustedRoot: string, output: string }): Readonly<RuntimeTrustSummary> {
43 const policy = admitPolicy(options), output = resolve(options.output)
44 const root = regularFile(options.trustedRoot, metadataLimit)
45 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
46 validateMetadata(root)
47 directory(dirname(output))
48 mkdirSync(output, { mode: 0o700 })
49 writeExclusive(join(output, 'root.json'), root)
50 const rootVersion = authenticatedRoot(output, policy)
51 const enrollment: Enrollment = { schema: 'drone-runtime-trust-enrollment-v1', enrollmentId: randomBytes(16).toString('hex'), policy, rootVersion }
52 syncDirectory(output)
53 const bytes = Buffer.from(`${JSON.stringify(enrollment, null, 2)}\n`)
54 writeExclusive(join(output, 'enrollment.json'), bytes)
55 syncDirectory(output)
56 syncDirectory(dirname(output))
57 return summary(enrollment, sha256(bytes))
58}
59
60/** Independently verify an enrollment. A hash read from the same directory is not an independent trust pin. */
61export function inspectRuntimeTrust(options: { directory: string, enrollmentSha256: string }): Readonly<RuntimeTrustSummary> {
62 const path = resolve(options.directory), expected = options.enrollmentSha256
63 if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) throw new TypeError('Provide the independently retained enrollment SHA-256')
64 directory(dirname(path))
65 directory(path)
66 const bytes = regularFile(join(path, 'enrollment.json'), 16384)
67 if (sha256(bytes) !== expected) throw new Error('Enrollment SHA-256 does not match the independent pin')
68 const record = object(JSON.parse(bytes.toString('utf8')))
69 const keys = ['schema', 'enrollmentId', 'policy', 'rootVersion']
70 if (record.schema !== 'drone-runtime-trust-enrollment-v1' || Object.keys(record).length !== keys.length || !keys.every(key => Object.hasOwn(record, key)) || typeof record.enrollmentId !== 'string' || !/^[a-f0-9]{32}$/.test(record.enrollmentId)) throw new Error('Invalid runtime trust enrollment')
71 const input = object(record.policy), policyKeys = ['target', 'platform', 'trustedRootSha256', 'minimumReleaseSequence']
72 if (Object.keys(input).length !== policyKeys.length || !policyKeys.every(key => Object.hasOwn(input, key)) || typeof input.target !== 'string' || typeof input.trustedRootSha256 !== 'string' || typeof input.minimumReleaseSequence !== 'number' || (input.platform !== 'pico2-neutral-bench-v1' && input.platform !== 'host-neutral-bench-v1')) throw new Error('Invalid enrolled trust policy')
73 const policy = admitPolicy({ target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence })
74 const rootVersion = authenticatedRoot(path, policy)
75 if (rootVersion !== record.rootVersion) throw new Error('Enrolled root version does not match authenticated bytes')
76 return summary({ schema: 'drone-runtime-trust-enrollment-v1', enrollmentId: record.enrollmentId, policy, rootVersion }, expected)
77}
resources/functions/drone/updates/files.tsadded+74-0
Changes to resources/functions/drone/updates/files.ts
@@ -0,0 +1,74 @@
1import { constants, closeSync, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, writeSync } from 'node:fs'
2import { join } from 'node:path'
3import { createHash } from 'node:crypto'
4
5export const metadataLimit = 512 * 1024
6export const imageLimit = 16 * 1024 * 1024
7export const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
8
9export function safePath(path: string) {
10 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')
11}
12export function regularFile(path: string, limit: number): Buffer {
13 const before = lstatSync(path)
14 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
15 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
16 try {
17 const stat = fstatSync(fd)
18 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')
19 const bytes = Buffer.alloc(stat.size + 1)
20 let offset = 0
21 while (offset < bytes.length) {
22 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
23 if (count === 0) break
24 offset += count
25 }
26 if (offset !== stat.size) throw new Error('Source file changed length while reading')
27 return bytes.subarray(0, offset)
28 }
29 finally { closeSync(fd) }
30}
31export function directory(path: string) {
32 const stat = lstatSync(path)
33 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
34 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Directories must be owned and not writable by other principals')
35}
36export function repositoryFile(root: string, path: string, limit: number) {
37 safePath(path)
38 directory(root)
39 const parts = path.split('/')
40 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
41 return regularFile(join(root, ...parts), limit)
42}
43export function writeExclusive(path: string, bytes: Uint8Array) {
44 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
45 try {
46 let offset = 0
47 while (offset < bytes.length) {
48 const count = writeSync(fd, bytes, offset, bytes.length - offset)
49 if (count <= 0) throw new Error('No progress writing protected runtime data')
50 offset += count
51 }
52 fsyncSync(fd)
53 }
54 finally { closeSync(fd) }
55}
56export function syncDirectory(path: string) {
57 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
58 try { fsyncSync(fd) }
59 finally { closeSync(fd) }
60}
61export function syncTree(path: string) {
62 directory(path)
63 for (const name of readdirSync(path)) {
64 const child = join(path, name), stat = lstatSync(child)
65 if (stat.isDirectory()) syncTree(child)
66 else {
67 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
68 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
69 try { fsyncSync(fd) }
70 finally { closeSync(fd) }
71 }
72 }
73 syncDirectory(path)
74}
resources/functions/drone/updates/metadata.tsadded+21-0
Changes to resources/functions/drone/updates/metadata.ts
@@ -0,0 +1,21 @@
1import { canonicalize } from '@tufjs/canonical-json'
2import { sha256 } from './files'
3
4export function object(value: unknown): Record<string, unknown> {
5 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
6 return value as Record<string, unknown>
7}
8export function validateMetadata(bytes: Buffer) {
9 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
10 if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version')
11 // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the
12 // specified UTC calendar representation before the SDK performs its expiry comparison.
13 const expires = signed.expires
14 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')
15 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
16 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
17 // identifier aliases by binding each declared ID to the canonical key object.
18 for (const [id, key] of Object.entries(keys ?? {})) {
19 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
20 }
21}
resources/functions/drone/updates/policy.tsadded+18-0
Changes to resources/functions/drone/updates/policy.ts
@@ -0,0 +1,18 @@
1import { safePath } from './files'
2
3export interface RuntimeFirmwarePolicy {
4 target: string
5 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
6 trustedRootSha256: string
7 /** Explicit externally trusted floor. Staging never advances it. */
8 minimumReleaseSequence: number
9}
10export function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
11 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
12 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
13 safePath(policy.target)
14 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
15 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
16 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
17 return Object.freeze(policy)
18}
resources/functions/drone/updates/staging.tsadded+139-0
Changes to resources/functions/drone/updates/staging.ts
@@ -0,0 +1,139 @@
1import { randomBytes } from 'node:crypto'
2import { mkdirSync, mkdtempSync, realpathSync, rmSync } 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 { Updater } from 'tuf-js'
8import type { TargetFile } from 'tuf-js'
9import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files'
10import { object, validateMetadata } from './metadata'
11import { admitPolicy } from './policy'
12import type { RuntimeFirmwarePolicy } from './policy'
13export type { RuntimeFirmwarePolicy } from './policy'
14
15const originPrefix = '/offline-'
16
17export interface RuntimeFirmwareStage {
18 schema: 'drone-runtime-stage-v1'
19 target: string
20 platform: RuntimeFirmwarePolicy['platform']
21 trustedRootSha256: string
22 rootVersion: number
23 releaseSequence: number
24 protocolVersion: 1
25 sourceRevision: string
26 signerDeclaredProfile: 'neutral-bench'
27 length: number
28 sha256: string
29 physicalDeploymentAllowed: false
30 trustStatePersisted: false
31}
32
33function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
34 const data = object(info.custom.drone)
35 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
36 if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new Error('Unsupported signed runtime manifest')
37 if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new Error('Signed runtime platform or protocol does not match policy')
38 if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new Error('Signed release is below the trusted minimum release sequence')
39 if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new Error('Invalid signed source revision')
40 if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new Error('Runtime image requires a bounded length and SHA-256')
41 return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision }
42}
43
44/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
45async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
46 validateMetadata(root)
47 const fetched = new Map<string, Buffer>()
48 let requests = 0, received = 0
49 const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/`
50 const server = Bun.serve({
51 hostname: '127.0.0.1', port: 0,
52 fetch(request) {
53 const url = new URL(request.url)
54 if (request.method !== 'GET' || url.search || !url.pathname.startsWith(prefix)) return new Response(null, { status: 404 })
55 if (++requests > 64) return new Response(null, { status: 429 })
56 const path = url.pathname.slice(prefix.length)
57 try {
58 safePath(path)
59 if (!path.startsWith('targets/') && !/^metadata\/[a-z0-9][a-z0-9._-]{0,179}\.json$/i.test(path)) throw new Error('Unsupported metadata path')
60 let bytes = fetched.get(path)
61 if (!bytes) {
62 bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit)
63 if (path.startsWith('metadata/')) validateMetadata(bytes)
64 received += bytes.length
65 if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded')
66 fetched.set(path, bytes)
67 }
68 return new Response(new Uint8Array(bytes), { headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' } })
69 }
70 catch (error) {
71 // Only an absent file is a TUF 404. Rejected devices, symlinks and IO failures must fail refresh.
72 return new Response(null, { status: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500 })
73 }
74 },
75 })
76 try {
77 mkdirSync(work, { mode: 0o700 })
78 writeExclusive(join(work, 'root.json'), root)
79 const base = `http://127.0.0.1:${server.port}${prefix}`
80 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 } })
81 await updater.refresh()
82 const info = await updater.getTargetInfo(policy.target)
83 if (!info) throw new Error('Selected firmware target is absent')
84 const runtime = signedRuntime(info, policy)
85 if (download) await updater.downloadTarget(info, download)
86 const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8')))
87 const rootVersion = object(latest.signed).version
88 if (!Number.isSafeInteger(rootVersion)) throw new Error('Invalid verified root version')
89 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 }
90 return { result, fetched, info }
91 }
92 finally { await server.stop(true) }
93}
94
95/** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */
96export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> {
97 const policy = admitPolicy(options)
98 const repository = realpathSync(options.repository), output = resolve(options.output)
99 directory(repository)
100 const root = regularFile(options.trustedRoot, metadataLimit)
101 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
102 // Resolve the existing parent before creating anything, so a symlinked parent cannot hide overlap.
103 const destination = join(realpathSync(dirname(output)), output.slice(dirname(output).length + 1))
104 const child = relative(repository, destination), parent = relative(destination, repository)
105 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!parent.startsWith(`..${sep}`) && parent !== '..')) throw new Error('Candidate and source repository must be separate')
106 mkdirSync(output, { mode: 0o700 })
107 writeExclusive(join(output, 'trust-root.json'), root)
108 const { result, fetched, info } = await verifyRepository(repository, root, join(output, '.verification'), policy, join(output, 'image.bin'))
109 // Re-read the actual staged bytes, rather than trusting a successful copy or receipt.
110 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
111 for (const [path, bytes] of fetched) {
112 const file = join(output, 'repository', path)
113 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
114 writeExclusive(file, bytes)
115 }
116 syncTree(output)
117 // A final marker means logical completeness. It cannot attest to power-loss durability after fsync.
118 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(result, null, 2)}\n`))
119 syncDirectory(output)
120 syncDirectory(dirname(output))
121 return result
122}
123
124/** Re-authenticate staged metadata and image using caller-supplied trust/policy, never a self-asserted receipt. */
125export async function inspectStagedRuntimeFirmware(options: RuntimeFirmwarePolicy & { directory: string }): Promise<RuntimeFirmwareStage> {
126 const policy = admitPolicy(options), candidate = resolve(options.directory)
127 directory(candidate)
128 const receipt: unknown = JSON.parse(regularFile(join(candidate, 'stage.json'), 16384).toString('utf8'))
129 const root = regularFile(join(candidate, 'trust-root.json'), metadataLimit)
130 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
131 const work = mkdtempSync(join(tmpdir(), 'drone-update-inspect-'))
132 try {
133 const { result, info } = await verifyRepository(join(candidate, 'repository'), root, join(work, 'metadata'), policy)
134 await info.verify(Readable.from(regularFile(join(candidate, 'image.bin'), imageLimit)))
135 if (!isDeepStrictEqual(receipt, result)) throw new Error('Staged receipt does not match re-authenticated image metadata')
136 return result
137 }
138 finally { rmSync(work, { recursive: true, force: true }) }
139}
tests/fixtures/updates/enroll-worker.tsadded+4-0
Changes to tests/fixtures/updates/enroll-worker.ts
@@ -0,0 +1,4 @@
1import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
2
3try { console.log(JSON.stringify(enrollRuntimeTrust(JSON.parse(process.argv[2] ?? '')))) }
4catch { console.error('Trust enrollment rejected'); process.exitCode = 1 }
tests/fixtures/updates/qualify-trust.tsadded+51-0
Changes to tests/fixtures/updates/qualify-trust.ts
@@ -0,0 +1,51 @@
1import { constants } from 'node:fs'
2import { chmod, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
3import { createReadStream } from 'node:fs'
4import { createHash } from 'node:crypto'
5import { resolve, join } from 'node:path'
6import { repository, platform, target } from './repository'
7
8async function hashFile(path: string) {
9 const hash = createHash('sha256')
10 for await (const chunk of createReadStream(path)) hash.update(chunk)
11 return hash.digest('hex')
12}
13
14/** Real copied standalone CLI, generated throwaway keys, no firmware deployment. */
15export async function qualifyRuntimeTrust(directory: string, source: string) {
16 await mkdir(directory, { mode: 0o700 })
17 const binary = join(directory, 'drone'), sourceSha256 = await hashFile(source)
18 await copyFile(source, binary, constants.COPYFILE_EXCL)
19 await chmod(binary, 0o700)
20 if (await hashFile(binary) !== sourceSha256 || await hashFile(source) !== sourceSha256) throw new Error('Changed portable trust executable')
21 const fixture = await repository(join(directory, 'repo')), output = join(directory, 'trust')
22 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
23 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
24 async function run(args: string[], expected: number) {
25 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
26 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
27 try {
28 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
29 results.push({ command: args[0]!, exit, stdout, stderr })
30 if (exit !== expected) throw new Error(`Unexpected portable trust exit: ${exit}`)
31 return stdout
32 }
33 finally { clearTimeout(timer) }
34 }
35 try {
36 const enrolled = JSON.parse(await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags], 0))
37 if (enrolled.policy.trustedRootSha256 !== fixture.rootSha256 || enrolled.policy.minimumReleaseSequence !== 7 || enrolled.metadataState !== 'initial-root-only' || enrolled.metadataRefreshPerformed !== false || enrolled.firmwareSelectionPerformed !== false || enrolled.physicalDeploymentAllowed !== false) throw new Error('Invalid portable trust enrollment')
38 const inspected = JSON.parse(await run(['updates:trust', output, '--enrollment-sha256', enrolled.enrollmentSha256], 0))
39 if (JSON.stringify(inspected) !== JSON.stringify(enrolled)) throw new Error('Portable enrollment mismatch')
40 const marker = await readFile(join(output, 'enrollment.json'))
41 const rejected = await run(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)], 1)
42 if (rejected !== '' || !(await readFile(join(output, 'root.json'))).equals(await readFile(fixture.rootPath))) throw new Error('Rejected trust readback changed the anchor')
43 if (await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags], 1) !== '' || !(await readFile(join(output, 'enrollment.json'))).equals(marker)) throw new Error('Existing enrollment was overwritten')
44 }
45 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'generated-initial-trust-only', sourceSha256, copiedExecutableBytes: (await stat(binary)).size, metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalFirmwareInstalled: false, results }, null, 2)) }
46 return { evidence: 'generated-initial-trust-only', operations: results.length, sourceSha256, metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalFirmwareInstalled: false }
47}
48if (import.meta.main) {
49 if (process.argv.length !== 4) throw new Error('Use qualify-trust.ts NEW_DIRECTORY COMPILED_BINARY')
50 console.log(JSON.stringify(await qualifyRuntimeTrust(resolve(process.argv[2]!), resolve(process.argv[3]!))))
51}
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/helpers/execution-browser-driver.tsmodified+9-11
Changes to tests/helpers/execution-browser-driver.ts
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
22import { mkdir, readFile } from 'node:fs/promises'
33import { basename, join } from 'node:path'
44import { startWorkspaceBrowser } from './workspace-browser'
5import { settleExecutionDiagnostics } from './execution-diagnostics'
56
67// This driver owns only browser transport. Native credentials stay in a private fixture file.
78const root = process.cwd()
@@ -23,8 +24,6 @@ let diagnosticPage: Awaited<ReturnType<typeof browser.page>> | undefined
2324let startBaseline: number | null = null
2425async function failureDiagnostics() {
2526 const abort = new AbortController()
26 let timer: ReturnType<typeof setTimeout> | undefined
27 const timeout = new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), 1000) })
2827 try {
2928 const snapshot = async () => {
3029 const response = await fetch(`${data.url}/__fixture/diagnostics`, { headers: { 'x-fixture-control': data.control }, signal: abort.signal })
@@ -46,19 +45,18 @@ async function failureDiagnostics() {
4645 hasSourceExists: !!document.querySelector('[data-execution-source-exists]'), hasExecutionError: !!document.querySelector('[data-execution-error]')
4746 };
4847 })()`) : Promise.resolve(null)
49 const collect = Promise.allSettled([snapshot(), dom]).then(([server, page]) => ({
48 const parts = await settleExecutionDiagnostics(snapshot(), dom)
49 const base = {
5050 label: 'execution-browser-failure-diagnostics', phase, startBaseline,
51 server: server.status === 'fulfilled' ? server.value : { available: false },
52 dom: page.status === 'fulfilled' ? page.value : null, browserErrorCount: diagnosticPage?.errors.length ?? null,
51 browserErrorCount: diagnosticPage?.errors.length ?? null,
52 executionStartRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution')).length : null,
5353 admissionRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution/admission')).length : null,
54 }))
55 const captured = await Promise.race([collect, timeout])
56 const text = `${JSON.stringify(captured ?? { label: 'execution-browser-failure-diagnostics', available: false })}\n`
57 console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text.trimEnd() : '{"label":"execution-browser-failure-diagnostics","available":false}')
54 }
55 const text = JSON.stringify({ ...base, ...parts })
56 console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text : JSON.stringify({ ...base, server: { available: false }, dom: null, byteBudgetExceeded: true }))
5857 }
59 catch { console.error('{"label":"execution-browser-failure-diagnostics","available":false}') }
58 catch { console.error(JSON.stringify({ label: 'execution-browser-failure-diagnostics', phase, available: false })) }
6059 finally {
61 clearTimeout(timer)
6260 abort.abort()
6361 // The owned socket close rejects pending CDP commands and clears their timers in the existing helper.
6462 diagnosticPage?.close()
tests/helpers/execution-diagnostics.tsadded+24-0
Changes to tests/helpers/execution-diagnostics.ts
@@ -0,0 +1,24 @@
1type DiagnosticStatus = 'pending' | 'fulfilled' | 'rejected'
2export interface ExecutionDiagnosticParts {
3 server: unknown
4 dom: unknown
5 serverStatus: DiagnosticStatus
6 domStatus: DiagnosticStatus
7}
8
9/** Preserve each completed observation within the existing one-second diagnostic budget. */
10export async function settleExecutionDiagnostics(server: Promise<unknown>, dom: Promise<unknown>): Promise<ExecutionDiagnosticParts> {
11 const parts: ExecutionDiagnosticParts = { server: { available: false }, dom: null, serverStatus: 'pending', domStatus: 'pending' }
12 let timer: ReturnType<typeof setTimeout> | undefined
13 const timeout = new Promise<void>(resolve => { timer = setTimeout(resolve, 1000) })
14 const tasks = [
15 server.then(value => { parts.server = value; parts.serverStatus = 'fulfilled' }, () => { parts.serverStatus = 'rejected' }),
16 dom.then(value => { parts.dom = value; parts.domStatus = 'fulfilled' }, () => { parts.domStatus = 'rejected' }),
17 ]
18 try {
19 await Promise.race([Promise.all(tasks), timeout])
20 // Late responses may update the private accumulator, never this returned observation.
21 return { ...parts }
22 }
23 finally { clearTimeout(timer) }
24}
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/public-safety-execution-diagnostics.test.tsadded+40-0
Changes to tests/unit/public-safety-execution-diagnostics.test.ts
@@ -0,0 +1,40 @@
1import { expect, test } from 'bun:test'
2import { settleExecutionDiagnostics } from '../helpers/execution-diagnostics'
3
4test('a pending DOM diagnostic cannot erase an already received server observation', async () => {
5 const result = await settleExecutionDiagnostics(Promise.resolve({ available: true, state: 'running' }), new Promise(() => {}))
6 expect(result?.server).toEqual({ available: true, state: 'running' })
7 expect(result?.dom).toBeNull()
8})
9
10
11test('a pending server diagnostic cannot erase an already received DOM observation', async () => {
12 const result = await settleExecutionDiagnostics(new Promise(() => {}), Promise.resolve({ executionPhase: 'completed' }))
13 expect(result.dom).toEqual({ executionPhase: 'completed' })
14 expect(result.serverStatus).toBe('pending')
15 expect(result.domStatus).toBe('fulfilled')
16})
17
18test('a rejected arm stays unavailable without discarding the other settled observation', async () => {
19 const result = await settleExecutionDiagnostics(Promise.reject(new Error('not logged')), Promise.resolve({ executionPhase: 'running' }))
20 expect(result.server).toEqual({ available: false })
21 expect(result.dom).toEqual({ executionPhase: 'running' })
22 expect(result.serverStatus).toBe('rejected')
23 expect(result.domStatus).toBe('fulfilled')
24})
25
26test('two stalled arms return explicit unavailability without inventing an observation', async () => {
27 const result = await settleExecutionDiagnostics(new Promise(() => {}), new Promise(() => {}))
28 expect(result).toEqual({ server: { available: false }, dom: null, serverStatus: 'pending', domStatus: 'pending' })
29})
30
31test('a response after the deadline cannot mutate the returned failure observation', async () => {
32 let resolveDom!: (value: unknown) => void
33 const dom = new Promise(resolve => { resolveDom = resolve })
34 const result = await settleExecutionDiagnostics(Promise.resolve({ available: true }), dom)
35 resolveDom({ executionPhase: 'completed' })
36 await dom
37 expect(result.dom).toBeNull()
38 expect(result.domStatus).toBe('pending')
39 expect(result.serverStatus).toBe('fulfilled')
40})
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-enrollment-cli.test.tsadded+33-0
Changes to tests/unit/updates-enrollment-cli.test.ts
@@ -0,0 +1,33 @@
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 { platform, repository, 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 trust commands enroll explicit policy and require the independent enrollment pin for readback', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-trust-cli-')); roots.push(root)
13 const fixture = await repository(join(root, 'repository')), output = join(root, 'trust')
14 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
15 const enrolled = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags]).withTimeout(20000)
16 expect(enrolled.exitCode).toBe(0)
17 expect(enrolled.timedOut).toBe(false)
18 const value = JSON.parse(enrolled.stdout)
19 expect(value.policy.minimumReleaseSequence).toBe(7)
20 expect(value.metadataState).toBe('initial-root-only')
21 expect(value.firmwareSelectionPerformed).toBe(false)
22 const inspected = await command(['updates:trust', output, '--enrollment-sha256', value.enrollmentSha256]).withTimeout(20000)
23 expect(inspected.exitCode).toBe(0)
24 expect(JSON.parse(inspected.stdout)).toEqual(value)
25 const rejected = await command(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)]).withTimeout(20000)
26 expect(rejected.exitCode).toBe(1)
27 expect(rejected.stdout).toBe('')
28 expect(rejected.stderr).toBe('Runtime trust unavailable: invalid enrollment pin or protected state\n')
29 const missing = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', join(root, 'missing-policy')]).withTimeout(20000)
30 expect(missing.exitCode).toBe(1)
31 expect(missing.stdout).toBe('')
32 expect(await Bun.file(join(root, 'missing-policy/enrollment.json')).exists()).toBe(false)
33}, 100000)
tests/unit/updates-enrollment.test.tsadded+153-0
Changes to tests/unit/updates-enrollment.test.ts
@@ -0,0 +1,153 @@
1import { afterEach, expect, test } from 'bun:test'
2import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { spawnSync } from 'node:child_process'
5import { join } from 'node:path'
6import { digest, platform, repository, target } from '../fixtures/updates/repository'
7import type { RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging'
8import { enrollRuntimeTrust, inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('independently pinned root and release policy can be enrolled and verified after readback', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root)
15 const fixture = await repository(join(root, 'repository')), output = join(root, 'trust')
16 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
17 const result = enrollRuntimeTrust({ ...policy, trustedRoot: fixture.rootPath, output })
18 expect(result.policy).toEqual(policy)
19 expect(result.rootVersion).toBe(1)
20 expect(result.metadataState).toBe('initial-root-only')
21 expect(result.metadataRefreshPerformed).toBe(false)
22 expect(result.firmwareSelectionPerformed).toBe(false)
23 expect(result.physicalDeploymentAllowed).toBe(false)
24 expect(result.enrollmentSha256).toBe(digest(await readFile(join(output, 'enrollment.json'))))
25 expect(await readFile(join(output, 'root.json'))).toEqual(await readFile(fixture.rootPath))
26 expect(inspectRuntimeTrust({ directory: output, enrollmentSha256: result.enrollmentSha256 })).toEqual(result)
27 expect((await readdir(output)).sort()).toEqual(['enrollment.json', 'root.json'])
28 expect((await stat(output)).mode & 0o777).toBe(0o700)
29 expect((await stat(join(output, 'enrollment.json'))).mode & 0o777).toBe(0o600)
30 expect((await stat(join(output, 'root.json'))).mode & 0o777).toBe(0o600)
31})
32
33
34test('trust readback requires an owned parent protected from other principals', async () => {
35 const root = await mkdtemp(join(tmpdir(), 'drone-trust-parent-')); roots.push(root)
36 const fixture = await repository(join(root, 'repository')), directory = join(root, 'trust')
37 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: directory })
38 await chmod(root, 0o777)
39 try { expect(() => inspectRuntimeTrust({ directory, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() }
40 finally { await chmod(root, 0o700) }
41})
42
43
44async function setup(options: Parameters<typeof repository>[1] = {}) {
45 const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root)
46 const fixture = await repository(join(root, 'repository'), options), output = join(root, 'trust')
47 return { root, fixture, output, input: { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output } satisfies Parameters<typeof enrollRuntimeTrust>[0] }
48}
49
50test('bad pins, unsafe policy and malformed roots cannot complete enrollment', async () => {
51 const item = await setup()
52 for (const [i, change] of [{ trustedRootSha256: '0'.repeat(64) }, { trustedRootSha256: '' }, { minimumReleaseSequence: 0 }, { minimumReleaseSequence: Number.NaN }, { minimumReleaseSequence: 7.5 }, { target: '../image.bin' }].entries()) {
53 const output = join(item.root, `invalid-${i}`)
54 expect(() => enrollRuntimeTrust({ ...item.input, ...change, output })).toThrow()
55 expect(await Bun.file(join(output, 'enrollment.json')).exists()).toBe(false)
56 }
57 for (const options of [{ nonCanonicalRoot: true }, { metadataFields: { root: { expires: 'not-a-date' } } }, { metadataFields: { root: { version: 0 } } }]) {
58 const bad = await setup(options)
59 expect(() => enrollRuntimeTrust(bad.input)).toThrow()
60 expect(await Bun.file(join(bad.output, 'enrollment.json')).exists()).toBe(false)
61 }
62})
63
64test('a pinned root still requires its authorized self-signatures', async () => {
65 const item = await setup(), root = JSON.parse(await readFile(item.fixture.rootPath, 'utf8'))
66 root.signatures[0].sig = '0'.repeat(128)
67 const bytes = Buffer.from(JSON.stringify(root))
68 await writeFile(item.fixture.rootPath, bytes)
69 expect(() => enrollRuntimeTrust({ ...item.input, trustedRootSha256: digest(bytes) })).toThrow()
70 expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false)
71})
72
73test('readback rejects a wrong independent pin, modified root, altered record and invalid schema', async () => {
74 const item = await setup(), enrolled = enrollRuntimeTrust(item.input)
75 const manifestPath = join(item.output, 'enrollment.json'), original = await readFile(manifestPath), record = JSON.parse(original.toString())
76 for (const enrollmentSha256 of ['', '0'.repeat(64)]) expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256 })).toThrow()
77 const changed = { ...record, policy: { ...record.policy, minimumReleaseSequence: 1 } }
78 await writeFile(manifestPath, JSON.stringify(changed))
79 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
80 const invalid = [{ ...record, schema: 'unknown' }, { ...record, extra: true }, { ...record, enrollmentId: '../bad' }, { ...record, rootVersion: 2 }, { ...record, policy: { ...record.policy, minimumReleaseSequence: 0 } }, { ...record, policy: { ...record.policy, extra: true } }]
81 for (const value of invalid) {
82 const bytes = Buffer.from(JSON.stringify(value)); await writeFile(manifestPath, bytes)
83 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: digest(bytes) })).toThrow()
84 }
85 await writeFile(manifestPath, original)
86 await writeFile(join(item.output, 'root.json'), '{}')
87 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
88})
89
90test('source symlinks and shared writable storage are rejected', async () => {
91 const linked = await setup(), link = join(linked.root, 'root-link.json')
92 await symlink(linked.fixture.rootPath, link)
93 expect(() => enrollRuntimeTrust({ ...linked.input, trustedRoot: link })).toThrow()
94 const shared = await setup()
95 await chmod(shared.root, 0o777)
96 expect(() => enrollRuntimeTrust(shared.input)).toThrow()
97 await chmod(shared.root, 0o700)
98 const result = enrollRuntimeTrust(shared.input)
99 await chmod(shared.output, 0o777)
100 expect(() => inspectRuntimeTrust({ directory: shared.output, enrollmentSha256: result.enrollmentSha256 })).toThrow()
101 await chmod(shared.output, 0o700)
102})
103
104test('an existing enrollment or partial directory is never overwritten or implicitly repaired', async () => {
105 const item = await setup(), enrolled = enrollRuntimeTrust(item.input)
106 const marker = await readFile(join(item.output, 'enrollment.json'))
107 expect(() => enrollRuntimeTrust(item.input)).toThrow()
108 expect(await readFile(join(item.output, 'enrollment.json'))).toEqual(marker)
109 expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled)
110 const partial = await setup(); await mkdir(partial.output)
111 await writeFile(join(partial.output, 'root.json'), await readFile(partial.fixture.rootPath))
112 expect(() => enrollRuntimeTrust(partial.input)).toThrow()
113 expect(() => inspectRuntimeTrust({ directory: partial.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
114})
115
116test('owned policy survives caller mutation and a valid expired root is only an initial anchor', async () => {
117 const item = await setup({ metadataFields: { root: { expires: '2020-01-01T00:00:00Z' } } })
118 const enrolled = enrollRuntimeTrust(item.input)
119 item.input.minimumReleaseSequence = 1
120 item.input.trustedRootSha256 = '0'.repeat(64)
121 const checked = inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })
122 expect(checked.policy.minimumReleaseSequence).toBe(7)
123 expect(checked.policy.trustedRootSha256).toBe(item.fixture.rootSha256)
124 expect(checked.metadataState).toBe('initial-root-only')
125 expect(checked.metadataRefreshPerformed).toBe(false)
126 expect(checked.firmwareSelectionPerformed).toBe(false)
127})
128
129test('two real processes cannot enroll into the same output', async () => {
130 const item = await setup(), worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts')
131 const processes = [Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 }), Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 })]
132 const results = await Promise.all(processes.map(async child => ({ exit: await child.exited, stdout: await new Response(child.stdout).text(), stderr: await new Response(child.stderr).text() })))
133 expect(results.map(r => r.exit).sort()).toEqual([0, 1])
134 const success = results.find(r => r.exit === 0)!
135 const enrolled = JSON.parse(success.stdout)
136 expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled)
137 expect(results.find(r => r.exit === 1)!.stdout).toBe('')
138}, 60000)
139
140test('an actual child write-limit failure leaves incomplete evidence and preserves the trusted source', async () => {
141 const item = await setup(), source = await readFile(item.fixture.rootPath)
142 const worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts')
143 const code = 'import os, resource, signal, sys; signal.signal(signal.SIGXFSZ, signal.SIG_IGN); resource.setrlimit(resource.RLIMIT_FSIZE, (256,256)); os.execv(sys.argv[1],sys.argv[1:])'
144 const failed = spawnSync('python3', ['-c', code, process.execPath, worker, JSON.stringify(item.input)], { timeout: 20000, encoding: 'utf8' })
145 expect(failed.error).toBeUndefined()
146 expect(failed.status).toBe(1)
147 expect(failed.stderr).toContain('Trust enrollment rejected')
148 expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false)
149 expect((await stat(join(item.output, 'root.json'))).size).toBe(256)
150 expect(await readFile(item.fixture.rootPath)).toEqual(source)
151 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: '0'.repeat(64) })).toThrow()
152 expect(() => enrollRuntimeTrust(item.input)).toThrow()
153}, 60000)
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})