also looking at this
feat: enroll protected runtime trust anchors
#43
33 files
+1297
-18
| @@ -32,6 +32,10 @@ jobs: | ||
| 32 | 32 | - run: python3 -m unittest discover -s tests/hardware |
| 33 | 33 | - run: ./buddy test |
| 34 | 34 | - 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" | |
| 35 | 39 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 36 | 40 | working-directory: ${{ runner.temp }} |
| 37 | 41 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -262,7 +262,12 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/ | ||
| 262 | 262 | |
| 263 | 263 | The viewer opens files locally in the browser. The v1 format excludes imagery, location and |
| 264 | 264 | operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md). |
| 265 | Physical logging/commissioning and signed firmware update/rollback remain pending. | |
| 265 | The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF | |
| 266 | candidates through `updates:stage` and `updates:inspect`, with explicit root pins, | |
| 267 | compatibility and externally supplied release floors. `updates:enroll` and | |
| 268 | `updates:trust` preserve and independently verify an initial root/policy record. | |
| 269 | Physical logging/commissioning, persistent refresh metadata/release floors, image | |
| 270 | installation and known-good rollback remain pending. | |
| 266 | 271 | |
| 267 | 272 | ## Sensor acquisition and replay |
| 268 | 273 | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,18 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { stageRuntimeFirmware } from '../../resources/functions/drone/updates/staging' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,20 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -19,6 +19,10 @@ import DiagnosticsRecord from '../app/Commands/DiagnosticsRecord' | ||
| 19 | 19 | import DiagnosticsReplay from '../app/Commands/DiagnosticsReplay' |
| 20 | 20 | import DiagnosticsInspect from '../app/Commands/DiagnosticsInspect' |
| 21 | 21 | import DiagnosticsExport from '../app/Commands/DiagnosticsExport' |
| 22 | import UpdatesStage from '../app/Commands/UpdatesStage' | |
| 23 | import UpdatesInspect from '../app/Commands/UpdatesInspect' | |
| 24 | import UpdatesEnroll from '../app/Commands/UpdatesEnroll' | |
| 25 | import UpdatesTrust from '../app/Commands/UpdatesTrust' | |
| 22 | 26 | |
| 23 | 27 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 24 | 28 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -81,6 +85,10 @@ DiagnosticsRecord(app) | ||
| 81 | 85 | DiagnosticsReplay(app) |
| 82 | 86 | DiagnosticsInspect(app) |
| 83 | 87 | DiagnosticsExport(app) |
| 88 | UpdatesStage(app) | |
| 89 | UpdatesInspect(app) | |
| 90 | UpdatesEnroll(app) | |
| 91 | UpdatesTrust(app) | |
| 84 | 92 | MissionsSimulate(app) |
| 85 | 93 | MissionsValidate(app) |
| 86 | 94 | MissionsReplay(app) |
bun.lockmodified+21-0
| @@ -22,11 +22,14 @@ | ||
| 22 | 22 | "@stacksjs/stx": "0.2.564", |
| 23 | 23 | "@stacksjs/testing": "^0.75.221", |
| 24 | 24 | "@stacksjs/validation": "^0.75.221", |
| 25 | "@tufjs/canonical-json": "2.0.0", | |
| 25 | 26 | "stacks": "^0.75.221", |
| 26 | 27 | "ts-images": "0.2.31", |
| 28 | "tuf-js": "6.0.0", | |
| 27 | 29 | }, |
| 28 | 30 | "devDependencies": { |
| 29 | 31 | "@stacksjs/query-builder": "0.75.221", |
| 32 | "@tufjs/models": "5.0.0", | |
| 30 | 33 | "better-dx": "^0.2.37", |
| 31 | 34 | }, |
| 32 | 35 | }, |
| @@ -45,6 +48,8 @@ | ||
| 45 | 48 | |
| 46 | 49 | "@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=="], |
| 47 | 50 | |
| 51 | "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], | |
| 52 | ||
| 48 | 53 | "@iconify-json/f7": ["@iconify-json/f7@1.2.2", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-7+RhvnncD+LRu0smuHOun4emOUu48XUGG6G0tYYbrXS7n4Io2xntx6nxa09/iI41t9hnH2oHHU3Dg7Ee60Ovow=="], |
| 49 | 54 | |
| 50 | 55 | "@iconify-json/hugeicons": ["@iconify-json/hugeicons@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-c06tY90JZnKhOLBAKoQ1z2pqXMRdATuYXslTKwaaIAjLzKsTIsE+GGknY6+qo/SP0U/NG9xQjr4N+zfDqOeQiQ=="], |
| @@ -333,6 +338,10 @@ | ||
| 333 | 338 | |
| 334 | 339 | "@ts-videos/webm": ["@ts-videos/webm@0.1.2", "", { "peerDependencies": { "ts-videos": "0.1.2" } }, "sha512-/aeM2LKeyF35pRRQLJdBK/0SJFpmdAHOnT5zbbgc4u+akY3LR7TdEr5GA2vubUKk5bqATx/isCMYPJogv1OPNA=="], |
| 335 | 340 | |
| 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 | ||
| 336 | 345 | "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], |
| 337 | 346 | |
| 338 | 347 | "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], |
| @@ -359,8 +368,12 @@ | ||
| 359 | 368 | |
| 360 | 369 | "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], |
| 361 | 370 | |
| 371 | "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], | |
| 372 | ||
| 362 | 373 | "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=="], |
| 363 | 374 | |
| 375 | "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], | |
| 376 | ||
| 364 | 377 | "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=="], |
| 365 | 378 | |
| 366 | 379 | "bun-plugin-auto-imports": ["bun-plugin-auto-imports@0.4.4", "", {}, "sha512-j1KXTDBEB/w95UWTvl8qQPjE8U+6jpm8UdCrIm+zDxnZM97ENhUpJjBYVB85oF14NsQpDcDnQYZdgLRckD4pKg=="], |
| @@ -383,6 +396,8 @@ | ||
| 383 | 396 | |
| 384 | 397 | "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], |
| 385 | 398 | |
| 399 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], | |
| 400 | ||
| 386 | 401 | "dynamodb-tooling": ["dynamodb-tooling@0.3.6", "", { "bin": { "dbtooling": "./dist/cli.js" } }, "sha512-G/yixeLt3lawdwXVQCz+IKIPEb+yT21YjDdG1UGZA8YQwS8BwdUoGxBwJJytZfn0LouZ8Sq/YbdNiV2R/VsYnQ=="], |
| 387 | 402 | |
| 388 | 403 | "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], |
| @@ -397,6 +412,10 @@ | ||
| 397 | 412 | |
| 398 | 413 | "localtunnels": ["localtunnels@0.2.13", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "localtunnel": "./dist/bin/cli.js", "lt": "./dist/bin/cli.js" } }, "sha512-zcRWeFJSK1g2h5nblm/MiUc7eM7Sa1MRYoCdRiLseDCtX2Ha16YWjtGptwKjyq5Kd6egv3tfZbqmRA0elKZPrA=="], |
| 399 | 414 | |
| 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 | ||
| 400 | 419 | "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=="], |
| 401 | 420 | |
| 402 | 421 | "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], |
| @@ -443,6 +462,8 @@ | ||
| 443 | 462 | |
| 444 | 463 | "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=="], |
| 445 | 464 | |
| 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 | ||
| 446 | 467 | "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=="], |
| 447 | 468 | |
| 448 | 469 | "undici-types": ["undici-types@8.12.2", "", {}, "sha512-VoMeD2O0KdHhcn+oPqOrpivN+7aDOklVSwOLD7JNjbzV1CboAFek9IFOXnreoufTUtAsCsOK0iETiERPNR/fDA=="], |
| @@ -67,6 +67,7 @@ const config: BunPressOptions = { | ||
| 67 | 67 | { text: 'Owned mission engine', link: '/missions' }, |
| 68 | 68 | { text: 'Owned range-backed navigation', link: '/navigation' }, |
| 69 | 69 | { text: 'Owned runtime diagnostics, journals and replay', link: '/diagnostics' }, |
| 70 | { text: 'Runtime trust and image staging', link: '/runtime-updates' }, | |
| 70 | 71 | ], |
| 71 | 72 | }, |
| 72 | 73 | { |
| @@ -155,8 +155,12 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st | ||
| 155 | 155 | - Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load. |
| 156 | 156 | Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on |
| 157 | 157 | the actual Pi/Pico setup. Disabled loads and independent authority remain required. |
| 158 | - 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. | |
| 160 | 164 | - Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog, |
| 161 | 165 | estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6. |
| 162 | 166 | |
| @@ -47,6 +47,8 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md), | ||
| 47 | 47 | [navigation](navigation.md) have separate simulation and hardware acceptance gates. |
| 48 | 48 | |
| 49 | 49 | [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; | |
| 51 | persistent trust, boot selection and rollback remain required before installation. | |
| 50 | 52 | The [library API](api.md) describes behavior extensions. The |
| 51 | 53 | [shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground, |
| 52 | 54 | surface and subsea profiles. [Optional external drivers](drivers.md) have |
| @@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 13 | 13 | | Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) | |
| 14 | 14 | | MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) | |
| 15 | 15 | | Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) | |
| 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, 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) | | |
| 17 | 17 | | Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) | |
| 18 | 18 | | Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) | |
| 19 | 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | |
| @@ -0,0 +1,168 @@ | ||
| 1 | # Runtime trust and signed image staging | |
| 2 | ||
| 3 | Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image | |
| 4 | staging, independent candidate verification and protected initial trust enrollment. | |
| 5 | These commands prepare files on the host. Cross-update metadata/release floors, | |
| 6 | installed images, boot selection and known-good rollback slots remain to be | |
| 7 | implemented. Physical deployment is disabled. | |
| 8 | ||
| 9 | ## Explicit trust and policy | |
| 10 | ||
| 11 | Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted | |
| 12 | channel. Do not calculate a pin from an untrusted bundle and call it trusted. | |
| 13 | The release floor must likewise come from externally trusted release policy. | |
| 14 | Every staging or candidate-inspection invocation requires the root pin, exact | |
| 15 | target, expected bench platform and positive minimum release sequence. There are | |
| 16 | no 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 | ||
| 31 | The same commands are registered in the [compiled standalone CLI](runtime.md). | |
| 32 | Run them as offline maintenance work, separately from a control-loop process. | |
| 33 | The implementation requires a POSIX filesystem and bounded regular files. | |
| 34 | Parent directories must already exist. An output directory must be new and | |
| 35 | separate from the source repository; existing candidates are never overwritten. | |
| 36 | Repository/candidate directories must belong to the invoking user and must not | |
| 37 | be group/world writable. Their parent paths must also be protected against | |
| 38 | replacement by other users. Same-user privileged filesystem mutation is outside | |
| 39 | the protection model; this is not an adversarial-filesystem sandbox. | |
| 40 | ||
| 41 | ## Protected initial trust enrollment | |
| 42 | ||
| 43 | Store the independently authenticated root and explicit policy in a new private | |
| 44 | directory 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 | ||
| 56 | Retain the returned `enrollmentSha256` through your trusted configuration or | |
| 57 | backup channel. Readback requires that independent expected hash; a hash taken | |
| 58 | from the same untrusted directory does not establish trust. The record binds a | |
| 59 | random enrollment identity, exact root hash/version and copied release policy. | |
| 60 | Readback checks the actual root bytes, schema, policy and root self-signatures. | |
| 61 | ||
| 62 | Enrollment creates `root.json` and a final `enrollment.json` record, using an | |
| 63 | exclusive 0700 directory and 0600 files. The directory and immediate parent must | |
| 64 | be owned and protected from other principals' writes; higher parent paths must | |
| 65 | also be protected against replacement. Existing, partial or corrupt enrollments | |
| 66 | are preserved and rejected. Files/directories are synced, but the record is a | |
| 67 | logical completion marker, not proof of physical power-loss durability. A command | |
| 68 | can fail after creating the record, including if output fails. Preserve those | |
| 69 | files and independently verify the record before relying on them. | |
| 70 | ||
| 71 | The result reports `metadataState: "initial-root-only"`, | |
| 72 | `metadataRefreshPerformed: false`, `firmwareSelectionPerformed: false` and | |
| 73 | `physicalDeploymentAllowed: false`. An authenticated expired initial root may be | |
| 74 | enrolled for later sequential rotation; this does not make an expired final root | |
| 75 | valid for a repository refresh. No refresh or network operation runs here. | |
| 76 | ||
| 77 | Keep this immutable anchor separate from an SDK working cache. The current SDK | |
| 78 | writes cache files in place; a host write-limit probe truncated its cached root. | |
| 79 | Never refresh against the enrollment directory. Staging/inspection still require | |
| 80 | their explicit policy and do not load or advance this enrollment automatically. | |
| 81 | Persistent verified metadata/release floors, key lifecycle, interruption recovery | |
| 82 | and known-good coordination remain in [issue #42](https://github.com/stacksjs/drone/issues/42). | |
| 83 | Do not discard or re-enroll over failed state to reset a security floor. | |
| 84 | ||
| 85 | ## Repository and signed image contract | |
| 86 | ||
| 87 | Use a standard TUF repository with `metadata/` and `targets/` directories, | |
| 88 | including consistent-snapshot names when enabled. Verification uses the pinned | |
| 89 | [`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root | |
| 90 | rotation, signature thresholds, expiration, inter-role versions/hashes and | |
| 91 | target length/hashes. A private, randomly addressed loopback server supplies | |
| 92 | bounded local files to the client's public HTTP interface. It is stopped after | |
| 93 | verification. No external network fetch or arbitrary URL option is provided. | |
| 94 | The host additionally verifies that root and delegation key IDs equal SHA-256 | |
| 95 | of their canonical key objects, using the upstream canonical JSON library. | |
| 96 | This rejects arbitrary key-ID aliases before role verification. | |
| 97 | Metadata versions must be positive safe integers. Expiration dates must be real | |
| 98 | UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes | |
| 99 | expiry comparisons; malformed or automatically normalized dates are rejected. | |
| 100 | ||
| 101 | The selected target must have SHA-256, a nonzero length of at most 16 MiB and | |
| 102 | exactly 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 | ||
| 115 | The other supported platform label is `host-neutral-bench-v1`. Target and metadata | |
| 116 | paths use bounded ASCII names without traversal, escapes or symlinks. Metadata | |
| 117 | is limited to 512 KiB per file, 16 root rotations, 16 delegations, 64 requests and | |
| 118 | 32 MiB of distinct fetched bytes. FIFO, device, symlink and oversized inputs fail. | |
| 119 | The neutral profile is a **signer's declaration**, not an inspection of machine | |
| 120 | code or proof of neutral outputs. This module authenticates bytes and declared | |
| 121 | compatibility; it does not parse UF2/ELF or execute an image. | |
| 122 | ||
| 123 | ## Candidate integrity and interruption | |
| 124 | ||
| 125 | The private candidate contains the original `trust-root.json`, verified | |
| 126 | `image.bin`, fetched repository files, SDK metadata cache and final `stage.json`. | |
| 127 | The image is verified again after copying. Files/directories are synced and the | |
| 128 | completion marker is written last. Failures propagate and leave evidence in the | |
| 129 | new directory. A partial directory without a valid marker fails inspection. | |
| 130 | Do not reuse or automatically resume that directory. | |
| 131 | ||
| 132 | Inspection starts a fresh verifier with the independently supplied root pin and | |
| 133 | policy. It verifies the saved repository's signed metadata and actual image | |
| 134 | again, then compares the receipt with authenticated values. It does not trust | |
| 135 | the receipt's claimed hash, platform, release or authority. A raised release | |
| 136 | floor can reject a previously accepted candidate. Inspection does not modify | |
| 137 | the candidate. | |
| 138 | ||
| 139 | The marker means logical completeness. It cannot prove that a final sync or | |
| 140 | subsequent power loss preserved bytes. A process can fail after the marker was | |
| 141 | written. Inspection must always re-authenticate the candidate; no consumer may | |
| 142 | treat marker existence alone as permission to install or boot it. | |
| 143 | ||
| 144 | ## Remaining update and operations acceptance | |
| 145 | ||
| 146 | Each verification starts from the supplied root. `trustStatePersisted: false` | |
| 147 | means this module has no protected cross-invocation metadata or release | |
| 148 | high-water state. It is not a complete anti-rollback implementation. The caller | |
| 149 | must supply the trusted release floor every time. TUF expiry uses host wall | |
| 150 | time; trusted clock acquisition and protection against clock/storage rollback | |
| 151 | remain open. Control-loop freshness still uses its separate monotonic clock. | |
| 152 | ||
| 153 | Initial enrollment is available above. Next, implement protected trust rotation | |
| 154 | and persistent metadata/release floors, an | |
| 155 | interruption-safe coordinator, installed/known-good slot integrity, explicit | |
| 156 | health confirmation and rollback without lowering security floors. Then add | |
| 157 | the board bootloader/install protocol and measured power-loss, full-medium, | |
| 158 | storage-latency and recovery campaigns. Local privileged filesystem tampering | |
| 159 | is outside this host module's protection; it is not secure boot or hardware | |
| 160 | anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.github.io/specification/latest/). | |
| 161 | ||
| 162 | Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes. | |
| 163 | They cover real role signatures/rotation, expiration, compatibility, supplied | |
| 164 | release floors, tampering, bounded inputs, incomplete candidates, enrollment | |
| 165 | readback, competing enrollment processes, an actual host write-limit failure and | |
| 166 | native/portable CLI behavior. They establish host behavior, not actual firmware | |
| 167 | installation, board boot recovery or commissioning. The diagnostic supervisor's | |
| 168 | update/rollback check remains blocked and `commissioningReady` stays false. | |
| @@ -49,7 +49,9 @@ | ||
| 49 | 49 | "@stacksjs/testing": "^0.75.221", |
| 50 | 50 | "@stacksjs/validation": "^0.75.221", |
| 51 | 51 | "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" | |
| 53 | 55 | }, |
| 54 | 56 | "workspaces": [ |
| 55 | 57 | "storage/framework", |
| @@ -66,7 +68,8 @@ | ||
| 66 | 68 | }, |
| 67 | 69 | "devDependencies": { |
| 68 | 70 | "@stacksjs/query-builder": "0.75.221", |
| 69 | "better-dx": "^0.2.37" | |
| 71 | "better-dx": "^0.2.37", | |
| 72 | "@tufjs/models": "5.0.0" | |
| 70 | 73 | }, |
| 71 | 74 | "author": "Chris Breuer <chris@stacksjs.com>", |
| 72 | 75 | "homepage": "https://github.com/stacksjs/drone", |
| @@ -59,7 +59,7 @@ export function diagnoseDiagnostics(journal: DiagnosticJournal) { | ||
| 59 | 59 | { id: 'runtime-status', state: result.latest && !result.statusStale ? 'pass' : 'fail', detail: result.latest ? (result.statusStale ? 'stale' : 'recorded') : 'missing' }, |
| 60 | 60 | { id: 'runtime-health', state: result.health.length || result.replayedStatus || result.wireGaps ? 'fail' : 'pass', detail: result.health.join(', ') || (result.wireGaps ? 'status-gaps' : 'no-observed-fault') }, |
| 61 | 61 | { 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' }, | |
| 63 | 63 | ] as const |
| 64 | 64 | 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 } |
| 65 | 65 | } |
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { RuntimeFirmwarePolicy } from './staging' | |
| 2 | ||
| 3 | export 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 | ||
| 10 | export 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 | } | |
| @@ -0,0 +1,77 @@ | ||
| 1 | import { randomBytes } from 'node:crypto' | |
| 2 | import { mkdirSync } from 'node:fs' | |
| 3 | import { dirname, join, resolve } from 'node:path' | |
| 4 | import { Updater } from 'tuf-js' | |
| 5 | import { directory, metadataLimit, regularFile, sha256, syncDirectory, writeExclusive } from './files' | |
| 6 | import { object, validateMetadata } from './metadata' | |
| 7 | import { admitPolicy } from './policy' | |
| 8 | import type { RuntimeFirmwarePolicy } from './policy' | |
| 9 | ||
| 10 | interface Enrollment { | |
| 11 | schema: 'drone-runtime-trust-enrollment-v1' | |
| 12 | enrollmentId: string | |
| 13 | policy: Readonly<RuntimeFirmwarePolicy> | |
| 14 | rootVersion: number | |
| 15 | } | |
| 16 | export 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 | ||
| 28 | function 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 | } | |
| 37 | function 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. */ | |
| 42 | export 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. */ | |
| 61 | export 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 | } | |
| @@ -0,0 +1,74 @@ | ||
| 1 | import { constants, closeSync, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, writeSync } from 'node:fs' | |
| 2 | import { join } from 'node:path' | |
| 3 | import { createHash } from 'node:crypto' | |
| 4 | ||
| 5 | export const metadataLimit = 512 * 1024 | |
| 6 | export const imageLimit = 16 * 1024 * 1024 | |
| 7 | export const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') | |
| 8 | ||
| 9 | export 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 | } | |
| 12 | export 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 | } | |
| 31 | export 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 | } | |
| 36 | export 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 | } | |
| 43 | export 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 | } | |
| 56 | export 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 | } | |
| 61 | export 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 | } | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { canonicalize } from '@tufjs/canonical-json' | |
| 2 | import { sha256 } from './files' | |
| 3 | ||
| 4 | export 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 | } | |
| 8 | export 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 | } | |
| @@ -0,0 +1,18 @@ | ||
| 1 | import { safePath } from './files' | |
| 2 | ||
| 3 | export 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 | } | |
| 10 | export 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 | } | |
| @@ -0,0 +1,139 @@ | ||
| 1 | import { randomBytes } from 'node:crypto' | |
| 2 | import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' | |
| 3 | import { dirname, join, relative, resolve, sep } from 'node:path' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { Readable } from 'node:stream' | |
| 6 | import { isDeepStrictEqual } from 'node:util' | |
| 7 | import { Updater } from 'tuf-js' | |
| 8 | import type { TargetFile } from 'tuf-js' | |
| 9 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files' | |
| 10 | import { object, validateMetadata } from './metadata' | |
| 11 | import { admitPolicy } from './policy' | |
| 12 | import type { RuntimeFirmwarePolicy } from './policy' | |
| 13 | export type { RuntimeFirmwarePolicy } from './policy' | |
| 14 | ||
| 15 | const originPrefix = '/offline-' | |
| 16 | ||
| 17 | export 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 | ||
| 33 | function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { | |
| 34 | const data = object(info.custom.drone) | |
| 35 | const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile'] | |
| 36 | if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new Error('Unsupported signed runtime manifest') | |
| 37 | if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new Error('Signed runtime platform or protocol does not match policy') | |
| 38 | if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new Error('Signed release is below the trusted minimum release sequence') | |
| 39 | if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new Error('Invalid signed source revision') | |
| 40 | if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new Error('Runtime image requires a bounded length and SHA-256') | |
| 41 | return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision } | |
| 42 | } | |
| 43 | ||
| 44 | /** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */ | |
| 45 | async 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. */ | |
| 96 | export 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. */ | |
| 125 | export 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 | } | |
| @@ -0,0 +1,4 @@ | ||
| 1 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' | |
| 2 | ||
| 3 | try { console.log(JSON.stringify(enrollRuntimeTrust(JSON.parse(process.argv[2] ?? '')))) } | |
| 4 | catch { console.error('Trust enrollment rejected'); process.exitCode = 1 } | |
| @@ -0,0 +1,51 @@ | ||
| 1 | import { constants } from 'node:fs' | |
| 2 | import { chmod, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises' | |
| 3 | import { createReadStream } from 'node:fs' | |
| 4 | import { createHash } from 'node:crypto' | |
| 5 | import { resolve, join } from 'node:path' | |
| 6 | import { repository, platform, target } from './repository' | |
| 7 | ||
| 8 | async 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. */ | |
| 15 | export 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 | } | |
| 48 | if (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 | } | |
| @@ -0,0 +1,36 @@ | ||
| 1 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 2 | import { resolve, join } from 'node:path' | |
| 3 | import { repository, platform, target } from './repository' | |
| 4 | ||
| 5 | /** Qualification uses generated test payloads/keys and never executes an image. */ | |
| 6 | export 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 | } | |
| 33 | if (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 | } | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { generateKeyPairSync, sign, createHash } from 'node:crypto' | |
| 2 | import { mkdir, writeFile } from 'node:fs/promises' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { Key, Metadata, MetaFile, Root, Signature, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models' | |
| 5 | import { canonicalize } from '@tufjs/canonical-json' | |
| 6 | ||
| 7 | export const target = 'runtime/pico2-bench.bin' | |
| 8 | export const platform = 'pico2-neutral-bench-v1' | |
| 9 | export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') | |
| 10 | const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' } | |
| 11 | ||
| 12 | function 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. */ | |
| 22 | export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) { | |
| 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 | } | |
| @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' | ||
| 2 | 2 | import { mkdir, readFile } from 'node:fs/promises' |
| 3 | 3 | import { basename, join } from 'node:path' |
| 4 | 4 | import { startWorkspaceBrowser } from './workspace-browser' |
| 5 | import { settleExecutionDiagnostics } from './execution-diagnostics' | |
| 5 | 6 | |
| 6 | 7 | // This driver owns only browser transport. Native credentials stay in a private fixture file. |
| 7 | 8 | const root = process.cwd() |
| @@ -23,8 +24,6 @@ let diagnosticPage: Awaited<ReturnType<typeof browser.page>> | undefined | ||
| 23 | 24 | let startBaseline: number | null = null |
| 24 | 25 | async function failureDiagnostics() { |
| 25 | 26 | const abort = new AbortController() |
| 26 | let timer: ReturnType<typeof setTimeout> | undefined | |
| 27 | const timeout = new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), 1000) }) | |
| 28 | 27 | try { |
| 29 | 28 | const snapshot = async () => { |
| 30 | 29 | const response = await fetch(`${data.url}/__fixture/diagnostics`, { headers: { 'x-fixture-control': data.control }, signal: abort.signal }) |
| @@ -46,19 +45,18 @@ async function failureDiagnostics() { | ||
| 46 | 45 | hasSourceExists: !!document.querySelector('[data-execution-source-exists]'), hasExecutionError: !!document.querySelector('[data-execution-error]') |
| 47 | 46 | }; |
| 48 | 47 | })()`) : Promise.resolve(null) |
| 49 | const collect = Promise.allSettled([snapshot(), dom]).then(([server, page]) => ({ | |
| 48 | const parts = await settleExecutionDiagnostics(snapshot(), dom) | |
| 49 | const base = { | |
| 50 | 50 | 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, | |
| 53 | 53 | 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 })) | |
| 58 | 57 | } |
| 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 })) } | |
| 60 | 59 | finally { |
| 61 | clearTimeout(timer) | |
| 62 | 60 | abort.abort() |
| 63 | 61 | // The owned socket close rejects pending CDP commands and clears their timers in the existing helper. |
| 64 | 62 | diagnosticPage?.close() |
| @@ -0,0 +1,24 @@ | ||
| 1 | type DiagnosticStatus = 'pending' | 'fulfilled' | 'rejected' | |
| 2 | export 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. */ | |
| 10 | export 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 | } | |
| @@ -30,6 +30,9 @@ | ||
| 30 | 30 | "./unit/payloads.test.ts", |
| 31 | 31 | "./fixtures/payloads/**/*.ts", |
| 32 | 32 | "../resources/functions/drone/diagnostics/**/*.ts", |
| 33 | "../resources/functions/drone/updates/**/*.ts", | |
| 34 | "./unit/updates*.test.ts", | |
| 35 | "./fixtures/updates/**/*.ts", | |
| 33 | 36 | "./unit/diagnostics.test.ts", |
| 34 | 37 | "./fixtures/diagnostics/**/*.ts", |
| 35 | 38 | "../resources/functions/drone/missions/**/*.ts", |
| @@ -0,0 +1,40 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { settleExecutionDiagnostics } from '../helpers/execution-diagnostics' | |
| 3 | ||
| 4 | test('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 | ||
| 11 | test('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 | ||
| 18 | test('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 | ||
| 26 | test('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 | ||
| 31 | test('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 | }) | |
| @@ -0,0 +1,31 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { repository, platform, target } from '../fixtures/updates/repository' | |
| 7 | ||
| 8 | const roots: string[] = [] | |
| 9 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 10 | ||
| 11 | test('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) | |
| @@ -0,0 +1,33 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 7 | ||
| 8 | const roots: string[] = [] | |
| 9 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 10 | ||
| 11 | test('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) | |
| @@ -0,0 +1,153 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { spawnSync } from 'node:child_process' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { digest, platform, repository, target } from '../fixtures/updates/repository' | |
| 7 | import type { RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging' | |
| 8 | import { enrollRuntimeTrust, inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 9 | ||
| 10 | const roots: string[] = [] | |
| 11 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 12 | ||
| 13 | test('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 | ||
| 34 | test('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 | ||
| 44 | async 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 | ||
| 50 | test('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 | ||
| 64 | test('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 | ||
| 73 | test('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 | ||
| 90 | test('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 | ||
| 104 | test('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 | ||
| 116 | test('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 | ||
| 129 | test('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 | ||
| 140 | test('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) | |
| @@ -0,0 +1,204 @@ | ||
| 1 | import { test, expect, afterEach } from 'bun:test' | |
| 2 | import { mkdtemp, rm, readFile, writeFile, mkdir, symlink, stat, truncate, chmod } from 'node:fs/promises' | |
| 3 | import { spawnSync } from 'node:child_process' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { repository, platform, target } from '../fixtures/updates/repository' | |
| 7 | import { stageRuntimeFirmware, inspectStagedRuntimeFirmware, type RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging' | |
| 8 | ||
| 9 | const roots: string[] = [] | |
| 10 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 11 | ||
| 12 | test('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 | ||
| 28 | async 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 | } | |
| 34 | async function incomplete(output: string) { | |
| 35 | expect(await Bun.file(join(output, 'stage.json')).exists()).toBe(false) | |
| 36 | } | |
| 37 | ||
| 38 | test('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 | ||
| 47 | test('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 | ||
| 55 | test('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 | ||
| 64 | test('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 | ||
| 77 | test('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 | ||
| 86 | test('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 | ||
| 97 | test('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 | ||
| 114 | test('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 | ||
| 131 | test('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 | ||
| 138 | test('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 | ||
| 145 | test('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 | ||
| 153 | test('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 | ||
| 173 | test('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 | ||
| 181 | test('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 | ||
| 189 | test('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 | ||
| 200 | test('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 | }) | |