also looking at this
feat: enroll protected runtime trust anchors
#43
33 files
+1297
-18
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -34,6 +34,8 @@ jobs: | ||
| 34 | 34 | - run: bun run runtime:cli |
| 35 | 35 | - name: Verify signed test-image staging through the portable CLI outside the checkout |
| 36 | 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" | |
| 37 | 39 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 38 | 40 | working-directory: ${{ runner.temp }} |
| 39 | 41 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -264,8 +264,10 @@ The viewer opens files locally in the browser. The v1 format excludes imagery, l | ||
| 264 | 264 | operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md). |
| 265 | 265 | The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF |
| 266 | 266 | candidates through `updates:stage` and `updates:inspect`, with explicit root pins, |
| 267 | compatibility and externally supplied release floors. Physical logging/commissioning, | |
| 268 | persistent trust state, image installation and known-good rollback remain pending. | |
| 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. | |
| 269 | 271 | |
| 270 | 272 | ## Sensor acquisition and replay |
| 271 | 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,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 | }) | |
| @@ -21,6 +21,8 @@ import DiagnosticsInspect from '../app/Commands/DiagnosticsInspect' | ||
| 21 | 21 | import DiagnosticsExport from '../app/Commands/DiagnosticsExport' |
| 22 | 22 | import UpdatesStage from '../app/Commands/UpdatesStage' |
| 23 | 23 | import UpdatesInspect from '../app/Commands/UpdatesInspect' |
| 24 | import UpdatesEnroll from '../app/Commands/UpdatesEnroll' | |
| 25 | import UpdatesTrust from '../app/Commands/UpdatesTrust' | |
| 24 | 26 | |
| 25 | 27 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 26 | 28 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -85,6 +87,8 @@ DiagnosticsInspect(app) | ||
| 85 | 87 | DiagnosticsExport(app) |
| 86 | 88 | UpdatesStage(app) |
| 87 | 89 | UpdatesInspect(app) |
| 90 | UpdatesEnroll(app) | |
| 91 | UpdatesTrust(app) | |
| 88 | 92 | MissionsSimulate(app) |
| 89 | 93 | MissionsValidate(app) |
| 90 | 94 | MissionsReplay(app) |
| @@ -67,7 +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: 'Signed runtime image staging', link: '/runtime-updates' }, | |
| 70 | { text: 'Runtime trust and image staging', link: '/runtime-updates' }, | |
| 71 | 71 | ], |
| 72 | 72 | }, |
| 73 | 73 | { |
| @@ -156,8 +156,9 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st | ||
| 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 | 158 | - [Offline signed image staging](runtime-updates.md) now authenticates TUF roles, |
| 159 | declared target compatibility and a caller-supplied release floor. Build persistent | |
| 160 | trust/key lifecycle and metadata/release high-water state, interrupted update | |
| 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 | |
| 161 | 162 | recovery and a known-good rollback slot before flashing. Each staging invocation |
| 162 | 163 | currently starts from the independently supplied root; it is not full anti-rollback. |
| 163 | 164 | - Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog, |
| @@ -47,7 +47,7 @@ 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 | [Signed image staging](runtime-updates.md) authenticates offline candidates; | |
| 50 | [Runtime trust and image staging](runtime-updates.md) preserves initial trust and authenticates offline candidates; | |
| 51 | 51 | persistent trust, boot selection and rollback remain required before installation. |
| 52 | 52 | The [library API](api.md) describes behavior extensions. The |
| 53 | 53 | [shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground, |
| @@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 13 | 13 | | Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) | |
| 14 | 14 | | MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) | |
| 15 | 15 | | Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) | |
| 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [offline signed image staging](../runtime-updates.md); full mission correlation, hardware evidence, persistent trust and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) | | |
| 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) | |
| @@ -1,17 +1,19 @@ | ||
| 1 | # Signed runtime image staging | |
| 1 | # Runtime trust and signed image staging | |
| 2 | 2 | |
| 3 | 3 | Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image |
| 4 | staging and independent candidate verification. These commands prepare files on | |
| 5 | the host. Persistent trust state, installed images, boot selection and known-good | |
| 6 | rollback slots remain to be implemented. Physical deployment is disabled. | |
| 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. | |
| 7 | 8 | |
| 8 | 9 | ## Explicit trust and policy |
| 9 | 10 | |
| 10 | 11 | Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted |
| 11 | 12 | channel. Do not calculate a pin from an untrusted bundle and call it trusted. |
| 12 | 13 | The release floor must likewise come from externally trusted release policy. |
| 13 | Every invocation requires the root pin, exact target, expected bench platform | |
| 14 | and positive minimum release sequence. There are no permissive defaults. | |
| 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. | |
| 15 | 17 | |
| 16 | 18 | ```sh |
| 17 | 19 | ./buddy updates:stage /path/to/offline-repository \ |
| @@ -36,6 +38,50 @@ be group/world writable. Their parent paths must also be protected against | ||
| 36 | 38 | replacement by other users. Same-user privileged filesystem mutation is outside |
| 37 | 39 | the protection model; this is not an adversarial-filesystem sandbox. |
| 38 | 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 | ||
| 39 | 85 | ## Repository and signed image contract |
| 40 | 86 | |
| 41 | 87 | Use a standard TUF repository with `metadata/` and `targets/` directories, |
| @@ -104,7 +150,8 @@ must supply the trusted release floor every time. TUF expiry uses host wall | ||
| 104 | 150 | time; trusted clock acquisition and protection against clock/storage rollback |
| 105 | 151 | remain open. Control-loop freshness still uses its separate monotonic clock. |
| 106 | 152 | |
| 107 | Next, implement persistent trust enrollment/rotation and release floors, an | |
| 153 | Initial enrollment is available above. Next, implement protected trust rotation | |
| 154 | and persistent metadata/release floors, an | |
| 108 | 155 | interruption-safe coordinator, installed/known-good slot integrity, explicit |
| 109 | 156 | health confirmation and rollback without lowering security floors. Then add |
| 110 | 157 | the board bootloader/install protocol and measured power-loss, full-medium, |
| @@ -114,7 +161,8 @@ anti-rollback. See [TUF's workflow and threat model](https://theupdateframework. | ||
| 114 | 161 | |
| 115 | 162 | Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes. |
| 116 | 163 | They cover real role signatures/rotation, expiration, compatibility, supplied |
| 117 | release floors, tampering, bounded inputs, incomplete candidates and native/ | |
| 118 | portable CLI behavior. They establish host behavior, not actual firmware | |
| 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 | |
| 119 | 167 | installation, board boot recovery or commissioning. The diagnostic supervisor's |
| 120 | 168 | update/rollback check remains blocked and `commissioningReady` stays false. |
| @@ -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 | } | |
| @@ -1,25 +1,19 @@ | ||
| 1 | import { createHash, randomBytes } from 'node:crypto' | |
| 2 | import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeSync } from 'node:fs' | |
| 1 | import { randomBytes } from 'node:crypto' | |
| 2 | import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' | |
| 3 | 3 | import { dirname, join, relative, resolve, sep } from 'node:path' |
| 4 | 4 | import { tmpdir } from 'node:os' |
| 5 | 5 | import { Readable } from 'node:stream' |
| 6 | 6 | import { isDeepStrictEqual } from 'node:util' |
| 7 | import { canonicalize } from '@tufjs/canonical-json' | |
| 8 | 7 | import { Updater } from 'tuf-js' |
| 9 | 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' | |
| 10 | 14 | |
| 11 | const metadataLimit = 512 * 1024 | |
| 12 | const imageLimit = 16 * 1024 * 1024 | |
| 13 | 15 | const originPrefix = '/offline-' |
| 14 | const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') | |
| 15 | 16 | |
| 16 | export interface RuntimeFirmwarePolicy { | |
| 17 | target: string | |
| 18 | platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1' | |
| 19 | trustedRootSha256: string | |
| 20 | /** Explicit externally trusted floor. This module does not persist or advance it. */ | |
| 21 | minimumReleaseSequence: number | |
| 22 | } | |
| 23 | 17 | export interface RuntimeFirmwareStage { |
| 24 | 18 | schema: 'drone-runtime-stage-v1' |
| 25 | 19 | target: string |
| @@ -36,99 +30,6 @@ export interface RuntimeFirmwareStage { | ||
| 36 | 30 | trustStatePersisted: false |
| 37 | 31 | } |
| 38 | 32 | |
| 39 | function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> { | |
| 40 | const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence } | |
| 41 | if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem') | |
| 42 | safePath(policy.target) | |
| 43 | if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform') | |
| 44 | if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256') | |
| 45 | if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence') | |
| 46 | return Object.freeze(policy) | |
| 47 | } | |
| 48 | function safePath(path: string) { | |
| 49 | if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path') | |
| 50 | } | |
| 51 | function regularFile(path: string, limit: number): Buffer { | |
| 52 | const before = lstatSync(path) | |
| 53 | if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file') | |
| 54 | const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY) | |
| 55 | try { | |
| 56 | const stat = fstatSync(fd) | |
| 57 | if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file') | |
| 58 | const bytes = Buffer.alloc(stat.size + 1) | |
| 59 | let offset = 0 | |
| 60 | while (offset < bytes.length) { | |
| 61 | const count = readSync(fd, bytes, offset, bytes.length - offset, null) | |
| 62 | if (count === 0) break | |
| 63 | offset += count | |
| 64 | } | |
| 65 | if (offset !== stat.size) throw new Error('Source file changed length while reading') | |
| 66 | return bytes.subarray(0, offset) | |
| 67 | } | |
| 68 | finally { closeSync(fd) } | |
| 69 | } | |
| 70 | function directory(path: string) { | |
| 71 | const stat = lstatSync(path) | |
| 72 | if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory') | |
| 73 | if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Repository and candidate directories must be owned and not writable by other principals') | |
| 74 | } | |
| 75 | function repositoryFile(root: string, path: string, limit: number) { | |
| 76 | safePath(path) | |
| 77 | directory(root) | |
| 78 | const parts = path.split('/') | |
| 79 | for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i))) | |
| 80 | return regularFile(join(root, ...parts), limit) | |
| 81 | } | |
| 82 | function writeExclusive(path: string, bytes: Uint8Array) { | |
| 83 | const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600) | |
| 84 | try { | |
| 85 | let offset = 0 | |
| 86 | while (offset < bytes.length) { | |
| 87 | const count = writeSync(fd, bytes, offset, bytes.length - offset) | |
| 88 | if (count <= 0) throw new Error('No progress writing firmware candidate') | |
| 89 | offset += count | |
| 90 | } | |
| 91 | fsyncSync(fd) | |
| 92 | } | |
| 93 | finally { closeSync(fd) } | |
| 94 | } | |
| 95 | function syncDirectory(path: string) { | |
| 96 | const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW) | |
| 97 | try { fsyncSync(fd) } | |
| 98 | finally { closeSync(fd) } | |
| 99 | } | |
| 100 | function syncTree(path: string) { | |
| 101 | directory(path) | |
| 102 | for (const name of readdirSync(path)) { | |
| 103 | const child = join(path, name), stat = lstatSync(child) | |
| 104 | if (stat.isDirectory()) syncTree(child) | |
| 105 | else { | |
| 106 | if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry') | |
| 107 | const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW) | |
| 108 | try { fsyncSync(fd) } | |
| 109 | finally { closeSync(fd) } | |
| 110 | } | |
| 111 | } | |
| 112 | syncDirectory(path) | |
| 113 | } | |
| 114 | function object(value: unknown): Record<string, unknown> { | |
| 115 | if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata') | |
| 116 | return value as Record<string, unknown> | |
| 117 | } | |
| 118 | function validateMetadata(bytes: Buffer) { | |
| 119 | const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed) | |
| 120 | if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version') | |
| 121 | // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the | |
| 122 | // specified UTC calendar representation before the SDK performs its expiry comparison. | |
| 123 | const expires = signed.expires | |
| 124 | if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date') | |
| 125 | const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined | |
| 126 | // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary | |
| 127 | // identifier aliases by binding each declared ID to the canonical key object. | |
| 128 | for (const [id, key] of Object.entries(keys ?? {})) { | |
| 129 | if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data') | |
| 130 | } | |
| 131 | } | |
| 132 | 33 | function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { |
| 133 | 34 | const data = object(info.custom.drone) |
| 134 | 35 | const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile'] |
| @@ -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,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) | |