ReviewOS

also looking at this

stacks/drone

feat: enroll protected runtime trust anchors

#43
Merged chris wants to merge codex/runtime-trust-enrollment into main
33 files +1297 -18

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.

.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -34,6 +34,8 @@ jobs:
3434 - run: bun run runtime:cli
3535 - name: Verify signed test-image staging through the portable CLI outside the checkout
3636 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"
3739 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
3840 working-directory: ${{ runner.temp }}
3941 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+4-2
Changes to README.md
@@ -264,8 +264,10 @@ The viewer opens files locally in the browser. The v1 format excludes imagery, l
264264operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md).
265265The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF
266266candidates through `updates:stage` and `updates:inspect`, with explicit root pins,
267compatibility and externally supplied release floors. Physical logging/commissioning,
268persistent trust state, image installation and known-good rollback remain pending.
267compatibility and externally supplied release floors. `updates:enroll` and
268`updates:trust` preserve and independently verify an initial root/policy record.
269Physical logging/commissioning, persistent refresh metadata/release floors, image
270installation and known-good rollback remain pending.
269271
270272## Sensor acquisition and replay
271273
app/Commands/UpdatesEnroll.tsadded+22-0
Changes to app/Commands/UpdatesEnroll.ts
@@ -0,0 +1,22 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command'
4import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
5
6export default defineCommand({
7 name: 'updates:enroll',
8 description: 'Preserve an independently trusted initial root and explicit policy without refreshing or installing firmware',
9 options: { ...firmwarePolicyFlags, '--trusted-root <path>': 'Independently authenticated initial root file', '--output <path>': 'New exclusive protected enrollment directory' },
10 async handle(options) {
11 try {
12 const policy = firmwareCommandPolicy(options)
13 if (typeof options.trustedRoot !== 'string' || typeof options.output !== 'string') throw new Error('Explicit root path and output required')
14 await printAsync(JSON.stringify(enrollRuntimeTrust({ ...policy, trustedRoot: options.trustedRoot, output: options.output })))
15 }
16 catch {
17 process.exitCode = 1
18 try { await printErrorAsync('Runtime trust enrollment unavailable: invalid anchor, policy or output failure') }
19 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
20 }
21 },
22})
app/Commands/UpdatesTrust.tsadded+20-0
Changes to app/Commands/UpdatesTrust.ts
@@ -0,0 +1,20 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
4
5export default defineCommand({
6 name: 'updates:trust <directory>',
7 description: 'Verify an initial trust enrollment against its independently retained record hash',
8 options: { '--enrollment-sha256 <hex>': 'Independently retained SHA-256 from trusted enrollment' },
9 async handle(options, directory) {
10 try {
11 if (typeof options.enrollmentSha256 !== 'string') throw new Error('An independently retained enrollment pin is required')
12 await printAsync(JSON.stringify(inspectRuntimeTrust({ directory, enrollmentSha256: options.enrollmentSha256 })))
13 }
14 catch {
15 process.exitCode = 1
16 try { await printErrorAsync('Runtime trust unavailable: invalid enrollment pin or protected state') }
17 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
18 }
19 },
20})
bin/drone.tsmodified+4-0
Changes to bin/drone.ts
@@ -21,6 +21,8 @@ import DiagnosticsInspect from '../app/Commands/DiagnosticsInspect'
2121import DiagnosticsExport from '../app/Commands/DiagnosticsExport'
2222import UpdatesStage from '../app/Commands/UpdatesStage'
2323import UpdatesInspect from '../app/Commands/UpdatesInspect'
24import UpdatesEnroll from '../app/Commands/UpdatesEnroll'
25import UpdatesTrust from '../app/Commands/UpdatesTrust'
2426
2527import MissionsSimulate from '../app/Commands/MissionsSimulate'
2628import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -85,6 +87,8 @@ DiagnosticsInspect(app)
8587DiagnosticsExport(app)
8688UpdatesStage(app)
8789UpdatesInspect(app)
90UpdatesEnroll(app)
91UpdatesTrust(app)
8892MissionsSimulate(app)
8993MissionsValidate(app)
9094MissionsReplay(app)
config/docs.tsmodified+1-1
Changes to config/docs.ts
@@ -67,7 +67,7 @@ const config: BunPressOptions = {
6767 { text: 'Owned mission engine', link: '/missions' },
6868 { text: 'Owned range-backed navigation', link: '/navigation' },
6969 { text: 'Owned runtime diagnostics, journals and replay', link: '/diagnostics' },
70 { text: 'Signed runtime image staging', link: '/runtime-updates' },
70 { text: 'Runtime trust and image staging', link: '/runtime-updates' },
7171 ],
7272 },
7373 {
docs/diagnostics.mdmodified+3-2
Changes to docs/diagnostics.md
@@ -156,8 +156,9 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
156156 Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on
157157 the actual Pi/Pico setup. Disabled loads and independent authority remain required.
158158- [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
161162 recovery and a known-good rollback slot before flashing. Each staging invocation
162163 currently starts from the independently supplied root; it is not full anti-rollback.
163164- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -47,7 +47,7 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md),
4747[navigation](navigation.md) have separate simulation and hardware acceptance gates.
4848
4949[Diagnostics and replay](diagnostics.md) retain explicit loss and provenance.
50[Signed image staging](runtime-updates.md) authenticates offline candidates;
50[Runtime trust and image staging](runtime-updates.md) preserves initial trust and authenticates offline candidates;
5151persistent trust, boot selection and rollback remain required before installation.
5252The [library API](api.md) describes behavior extensions. The
5353[shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground,
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, 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) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/runtime-updates.mdmodified+57-9
Changes to docs/runtime-updates.md
@@ -1,17 +1,19 @@
1# Signed runtime image staging
1# Runtime trust and signed image staging
22
33Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
4staging and independent candidate verification. These commands prepare files on
5the host. Persistent trust state, installed images, boot selection and known-good
6rollback slots remain to be implemented. Physical deployment is disabled.
4staging, independent candidate verification and protected initial trust enrollment.
5These commands prepare files on the host. Cross-update metadata/release floors,
6installed images, boot selection and known-good rollback slots remain to be
7implemented. Physical deployment is disabled.
78
89## Explicit trust and policy
910
1011Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted
1112channel. Do not calculate a pin from an untrusted bundle and call it trusted.
1213The release floor must likewise come from externally trusted release policy.
13Every invocation requires the root pin, exact target, expected bench platform
14and positive minimum release sequence. There are no permissive defaults.
14Every staging or candidate-inspection invocation requires the root pin, exact
15target, expected bench platform and positive minimum release sequence. There are
16no permissive defaults.
1517
1618```sh
1719./buddy updates:stage /path/to/offline-repository \
@@ -36,6 +38,50 @@ be group/world writable. Their parent paths must also be protected against
3638replacement by other users. Same-user privileged filesystem mutation is outside
3739the protection model; this is not an adversarial-filesystem sandbox.
3840
41## Protected initial trust enrollment
42
43Store the independently authenticated root and explicit policy in a new private
44directory with native Buddy or the compiled CLI:
45
46```sh
47./buddy updates:enroll --trusted-root /path/to/enrolled/root.json \
48 --root-sha256 "$ENROLLED_ROOT_SHA256" \
49 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
50 --minimum-release-sequence 7 --output /path/to/new-trust
51
52./buddy updates:trust /path/to/new-trust \
53 --enrollment-sha256 "$INDEPENDENTLY_RETAINED_ENROLLMENT_SHA256"
54```
55
56Retain the returned `enrollmentSha256` through your trusted configuration or
57backup channel. Readback requires that independent expected hash; a hash taken
58from the same untrusted directory does not establish trust. The record binds a
59random enrollment identity, exact root hash/version and copied release policy.
60Readback checks the actual root bytes, schema, policy and root self-signatures.
61
62Enrollment creates `root.json` and a final `enrollment.json` record, using an
63exclusive 0700 directory and 0600 files. The directory and immediate parent must
64be owned and protected from other principals' writes; higher parent paths must
65also be protected against replacement. Existing, partial or corrupt enrollments
66are preserved and rejected. Files/directories are synced, but the record is a
67logical completion marker, not proof of physical power-loss durability. A command
68can fail after creating the record, including if output fails. Preserve those
69files and independently verify the record before relying on them.
70
71The result reports `metadataState: "initial-root-only"`,
72`metadataRefreshPerformed: false`, `firmwareSelectionPerformed: false` and
73`physicalDeploymentAllowed: false`. An authenticated expired initial root may be
74enrolled for later sequential rotation; this does not make an expired final root
75valid for a repository refresh. No refresh or network operation runs here.
76
77Keep this immutable anchor separate from an SDK working cache. The current SDK
78writes cache files in place; a host write-limit probe truncated its cached root.
79Never refresh against the enrollment directory. Staging/inspection still require
80their explicit policy and do not load or advance this enrollment automatically.
81Persistent verified metadata/release floors, key lifecycle, interruption recovery
82and known-good coordination remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
83Do not discard or re-enroll over failed state to reset a security floor.
84
3985## Repository and signed image contract
4086
4187Use 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
104150time; trusted clock acquisition and protection against clock/storage rollback
105151remain open. Control-loop freshness still uses its separate monotonic clock.
106152
107Next, implement persistent trust enrollment/rotation and release floors, an
153Initial enrollment is available above. Next, implement protected trust rotation
154and persistent metadata/release floors, an
108155interruption-safe coordinator, installed/known-good slot integrity, explicit
109156health confirmation and rollback without lowering security floors. Then add
110157the 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.
114161
115162Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes.
116163They cover real role signatures/rotation, expiration, compatibility, supplied
117release floors, tampering, bounded inputs, incomplete candidates and native/
118portable CLI behavior. They establish host behavior, not actual firmware
164release floors, tampering, bounded inputs, incomplete candidates, enrollment
165readback, competing enrollment processes, an actual host write-limit failure and
166native/portable CLI behavior. They establish host behavior, not actual firmware
119167installation, board boot recovery or commissioning. The diagnostic supervisor's
120168update/rollback check remains blocked and `commissioningReady` stays false.
resources/functions/drone/updates/enrollment.tsadded+77-0
Changes to resources/functions/drone/updates/enrollment.ts
@@ -0,0 +1,77 @@
1import { randomBytes } from 'node:crypto'
2import { mkdirSync } from 'node:fs'
3import { dirname, join, resolve } from 'node:path'
4import { Updater } from 'tuf-js'
5import { directory, metadataLimit, regularFile, sha256, syncDirectory, writeExclusive } from './files'
6import { object, validateMetadata } from './metadata'
7import { admitPolicy } from './policy'
8import type { RuntimeFirmwarePolicy } from './policy'
9
10interface Enrollment {
11 schema: 'drone-runtime-trust-enrollment-v1'
12 enrollmentId: string
13 policy: Readonly<RuntimeFirmwarePolicy>
14 rootVersion: number
15}
16export interface RuntimeTrustSummary {
17 schema: 'drone-runtime-trust-v1'
18 enrollmentId: string
19 enrollmentSha256: string
20 policy: Readonly<RuntimeFirmwarePolicy>
21 rootVersion: number
22 metadataState: 'initial-root-only'
23 metadataRefreshPerformed: false
24 firmwareSelectionPerformed: false
25 physicalDeploymentAllowed: false
26}
27
28function authenticatedRoot(path: string, policy: RuntimeFirmwarePolicy) {
29 const root = regularFile(join(path, 'root.json'), metadataLimit)
30 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
31 validateMetadata(root)
32 // The public constructor verifies root self-signatures. Never refresh against the
33 // enrollment directory: it is the immutable anchor, not the SDK's writable cache.
34 new Updater({ metadataDir: path, metadataBaseUrl: 'http://127.0.0.1/unused-enrollment-only/' })
35 return object(object(JSON.parse(root.toString('utf8'))).signed).version as number
36}
37function summary(enrollment: Enrollment, enrollmentSha256: string): Readonly<RuntimeTrustSummary> {
38 return Object.freeze({ ...enrollment, schema: 'drone-runtime-trust-v1', enrollmentSha256, metadataState: 'initial-root-only', metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalDeploymentAllowed: false })
39}
40
41/** Persist an independently authenticated initial anchor and policy, without metadata refresh or deployment. */
42export function enrollRuntimeTrust(options: RuntimeFirmwarePolicy & { trustedRoot: string, output: string }): Readonly<RuntimeTrustSummary> {
43 const policy = admitPolicy(options), output = resolve(options.output)
44 const root = regularFile(options.trustedRoot, metadataLimit)
45 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
46 validateMetadata(root)
47 directory(dirname(output))
48 mkdirSync(output, { mode: 0o700 })
49 writeExclusive(join(output, 'root.json'), root)
50 const rootVersion = authenticatedRoot(output, policy)
51 const enrollment: Enrollment = { schema: 'drone-runtime-trust-enrollment-v1', enrollmentId: randomBytes(16).toString('hex'), policy, rootVersion }
52 syncDirectory(output)
53 const bytes = Buffer.from(`${JSON.stringify(enrollment, null, 2)}\n`)
54 writeExclusive(join(output, 'enrollment.json'), bytes)
55 syncDirectory(output)
56 syncDirectory(dirname(output))
57 return summary(enrollment, sha256(bytes))
58}
59
60/** Independently verify an enrollment. A hash read from the same directory is not an independent trust pin. */
61export function inspectRuntimeTrust(options: { directory: string, enrollmentSha256: string }): Readonly<RuntimeTrustSummary> {
62 const path = resolve(options.directory), expected = options.enrollmentSha256
63 if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) throw new TypeError('Provide the independently retained enrollment SHA-256')
64 directory(dirname(path))
65 directory(path)
66 const bytes = regularFile(join(path, 'enrollment.json'), 16384)
67 if (sha256(bytes) !== expected) throw new Error('Enrollment SHA-256 does not match the independent pin')
68 const record = object(JSON.parse(bytes.toString('utf8')))
69 const keys = ['schema', 'enrollmentId', 'policy', 'rootVersion']
70 if (record.schema !== 'drone-runtime-trust-enrollment-v1' || Object.keys(record).length !== keys.length || !keys.every(key => Object.hasOwn(record, key)) || typeof record.enrollmentId !== 'string' || !/^[a-f0-9]{32}$/.test(record.enrollmentId)) throw new Error('Invalid runtime trust enrollment')
71 const input = object(record.policy), policyKeys = ['target', 'platform', 'trustedRootSha256', 'minimumReleaseSequence']
72 if (Object.keys(input).length !== policyKeys.length || !policyKeys.every(key => Object.hasOwn(input, key)) || typeof input.target !== 'string' || typeof input.trustedRootSha256 !== 'string' || typeof input.minimumReleaseSequence !== 'number' || (input.platform !== 'pico2-neutral-bench-v1' && input.platform !== 'host-neutral-bench-v1')) throw new Error('Invalid enrolled trust policy')
73 const policy = admitPolicy({ target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence })
74 const rootVersion = authenticatedRoot(path, policy)
75 if (rootVersion !== record.rootVersion) throw new Error('Enrolled root version does not match authenticated bytes')
76 return summary({ schema: 'drone-runtime-trust-enrollment-v1', enrollmentId: record.enrollmentId, policy, rootVersion }, expected)
77}
resources/functions/drone/updates/files.tsadded+74-0
Changes to resources/functions/drone/updates/files.ts
@@ -0,0 +1,74 @@
1import { constants, closeSync, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, writeSync } from 'node:fs'
2import { join } from 'node:path'
3import { createHash } from 'node:crypto'
4
5export const metadataLimit = 512 * 1024
6export const imageLimit = 16 * 1024 * 1024
7export const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
8
9export function safePath(path: string) {
10 if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path')
11}
12export function regularFile(path: string, limit: number): Buffer {
13 const before = lstatSync(path)
14 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
15 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
16 try {
17 const stat = fstatSync(fd)
18 if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file')
19 const bytes = Buffer.alloc(stat.size + 1)
20 let offset = 0
21 while (offset < bytes.length) {
22 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
23 if (count === 0) break
24 offset += count
25 }
26 if (offset !== stat.size) throw new Error('Source file changed length while reading')
27 return bytes.subarray(0, offset)
28 }
29 finally { closeSync(fd) }
30}
31export function directory(path: string) {
32 const stat = lstatSync(path)
33 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
34 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Directories must be owned and not writable by other principals')
35}
36export function repositoryFile(root: string, path: string, limit: number) {
37 safePath(path)
38 directory(root)
39 const parts = path.split('/')
40 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
41 return regularFile(join(root, ...parts), limit)
42}
43export function writeExclusive(path: string, bytes: Uint8Array) {
44 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
45 try {
46 let offset = 0
47 while (offset < bytes.length) {
48 const count = writeSync(fd, bytes, offset, bytes.length - offset)
49 if (count <= 0) throw new Error('No progress writing protected runtime data')
50 offset += count
51 }
52 fsyncSync(fd)
53 }
54 finally { closeSync(fd) }
55}
56export function syncDirectory(path: string) {
57 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
58 try { fsyncSync(fd) }
59 finally { closeSync(fd) }
60}
61export function syncTree(path: string) {
62 directory(path)
63 for (const name of readdirSync(path)) {
64 const child = join(path, name), stat = lstatSync(child)
65 if (stat.isDirectory()) syncTree(child)
66 else {
67 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
68 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
69 try { fsyncSync(fd) }
70 finally { closeSync(fd) }
71 }
72 }
73 syncDirectory(path)
74}
resources/functions/drone/updates/metadata.tsadded+21-0
Changes to resources/functions/drone/updates/metadata.ts
@@ -0,0 +1,21 @@
1import { canonicalize } from '@tufjs/canonical-json'
2import { sha256 } from './files'
3
4export function object(value: unknown): Record<string, unknown> {
5 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
6 return value as Record<string, unknown>
7}
8export function validateMetadata(bytes: Buffer) {
9 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
10 if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version')
11 // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the
12 // specified UTC calendar representation before the SDK performs its expiry comparison.
13 const expires = signed.expires
14 if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date')
15 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
16 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
17 // identifier aliases by binding each declared ID to the canonical key object.
18 for (const [id, key] of Object.entries(keys ?? {})) {
19 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
20 }
21}
resources/functions/drone/updates/policy.tsadded+18-0
Changes to resources/functions/drone/updates/policy.ts
@@ -0,0 +1,18 @@
1import { safePath } from './files'
2
3export interface RuntimeFirmwarePolicy {
4 target: string
5 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
6 trustedRootSha256: string
7 /** Explicit externally trusted floor. Staging never advances it. */
8 minimumReleaseSequence: number
9}
10export function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
11 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
12 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
13 safePath(policy.target)
14 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
15 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
16 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
17 return Object.freeze(policy)
18}
resources/functions/drone/updates/staging.tsmodified+7-106
Changes to resources/functions/drone/updates/staging.ts
@@ -1,25 +1,19 @@
1import { createHash, randomBytes } from 'node:crypto'
2import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeSync } from 'node:fs'
1import { randomBytes } from 'node:crypto'
2import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
33import { dirname, join, relative, resolve, sep } from 'node:path'
44import { tmpdir } from 'node:os'
55import { Readable } from 'node:stream'
66import { isDeepStrictEqual } from 'node:util'
7import { canonicalize } from '@tufjs/canonical-json'
87import { Updater } from 'tuf-js'
98import type { TargetFile } from 'tuf-js'
9import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files'
10import { object, validateMetadata } from './metadata'
11import { admitPolicy } from './policy'
12import type { RuntimeFirmwarePolicy } from './policy'
13export type { RuntimeFirmwarePolicy } from './policy'
1014
11const metadataLimit = 512 * 1024
12const imageLimit = 16 * 1024 * 1024
1315const originPrefix = '/offline-'
14const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
1516
16export interface RuntimeFirmwarePolicy {
17 target: string
18 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
19 trustedRootSha256: string
20 /** Explicit externally trusted floor. This module does not persist or advance it. */
21 minimumReleaseSequence: number
22}
2317export interface RuntimeFirmwareStage {
2418 schema: 'drone-runtime-stage-v1'
2519 target: string
@@ -36,99 +30,6 @@ export interface RuntimeFirmwareStage {
3630 trustStatePersisted: false
3731}
3832
39function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
40 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
41 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
42 safePath(policy.target)
43 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
44 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
45 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
46 return Object.freeze(policy)
47}
48function safePath(path: string) {
49 if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path')
50}
51function regularFile(path: string, limit: number): Buffer {
52 const before = lstatSync(path)
53 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
54 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
55 try {
56 const stat = fstatSync(fd)
57 if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file')
58 const bytes = Buffer.alloc(stat.size + 1)
59 let offset = 0
60 while (offset < bytes.length) {
61 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
62 if (count === 0) break
63 offset += count
64 }
65 if (offset !== stat.size) throw new Error('Source file changed length while reading')
66 return bytes.subarray(0, offset)
67 }
68 finally { closeSync(fd) }
69}
70function directory(path: string) {
71 const stat = lstatSync(path)
72 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
73 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Repository and candidate directories must be owned and not writable by other principals')
74}
75function repositoryFile(root: string, path: string, limit: number) {
76 safePath(path)
77 directory(root)
78 const parts = path.split('/')
79 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
80 return regularFile(join(root, ...parts), limit)
81}
82function writeExclusive(path: string, bytes: Uint8Array) {
83 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
84 try {
85 let offset = 0
86 while (offset < bytes.length) {
87 const count = writeSync(fd, bytes, offset, bytes.length - offset)
88 if (count <= 0) throw new Error('No progress writing firmware candidate')
89 offset += count
90 }
91 fsyncSync(fd)
92 }
93 finally { closeSync(fd) }
94}
95function syncDirectory(path: string) {
96 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
97 try { fsyncSync(fd) }
98 finally { closeSync(fd) }
99}
100function syncTree(path: string) {
101 directory(path)
102 for (const name of readdirSync(path)) {
103 const child = join(path, name), stat = lstatSync(child)
104 if (stat.isDirectory()) syncTree(child)
105 else {
106 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
107 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
108 try { fsyncSync(fd) }
109 finally { closeSync(fd) }
110 }
111 }
112 syncDirectory(path)
113}
114function object(value: unknown): Record<string, unknown> {
115 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
116 return value as Record<string, unknown>
117}
118function validateMetadata(bytes: Buffer) {
119 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
120 if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version')
121 // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the
122 // specified UTC calendar representation before the SDK performs its expiry comparison.
123 const expires = signed.expires
124 if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date')
125 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
126 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
127 // identifier aliases by binding each declared ID to the canonical key object.
128 for (const [id, key] of Object.entries(keys ?? {})) {
129 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
130 }
131}
13233function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
13334 const data = object(info.custom.drone)
13435 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
tests/fixtures/updates/enroll-worker.tsadded+4-0
Changes to tests/fixtures/updates/enroll-worker.ts
@@ -0,0 +1,4 @@
1import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
2
3try { console.log(JSON.stringify(enrollRuntimeTrust(JSON.parse(process.argv[2] ?? '')))) }
4catch { console.error('Trust enrollment rejected'); process.exitCode = 1 }
tests/fixtures/updates/qualify-trust.tsadded+51-0
Changes to tests/fixtures/updates/qualify-trust.ts
@@ -0,0 +1,51 @@
1import { constants } from 'node:fs'
2import { chmod, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
3import { createReadStream } from 'node:fs'
4import { createHash } from 'node:crypto'
5import { resolve, join } from 'node:path'
6import { repository, platform, target } from './repository'
7
8async function hashFile(path: string) {
9 const hash = createHash('sha256')
10 for await (const chunk of createReadStream(path)) hash.update(chunk)
11 return hash.digest('hex')
12}
13
14/** Real copied standalone CLI, generated throwaway keys, no firmware deployment. */
15export async function qualifyRuntimeTrust(directory: string, source: string) {
16 await mkdir(directory, { mode: 0o700 })
17 const binary = join(directory, 'drone'), sourceSha256 = await hashFile(source)
18 await copyFile(source, binary, constants.COPYFILE_EXCL)
19 await chmod(binary, 0o700)
20 if (await hashFile(binary) !== sourceSha256 || await hashFile(source) !== sourceSha256) throw new Error('Changed portable trust executable')
21 const fixture = await repository(join(directory, 'repo')), output = join(directory, 'trust')
22 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
23 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
24 async function run(args: string[], expected: number) {
25 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
26 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
27 try {
28 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
29 results.push({ command: args[0]!, exit, stdout, stderr })
30 if (exit !== expected) throw new Error(`Unexpected portable trust exit: ${exit}`)
31 return stdout
32 }
33 finally { clearTimeout(timer) }
34 }
35 try {
36 const enrolled = JSON.parse(await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags], 0))
37 if (enrolled.policy.trustedRootSha256 !== fixture.rootSha256 || enrolled.policy.minimumReleaseSequence !== 7 || enrolled.metadataState !== 'initial-root-only' || enrolled.metadataRefreshPerformed !== false || enrolled.firmwareSelectionPerformed !== false || enrolled.physicalDeploymentAllowed !== false) throw new Error('Invalid portable trust enrollment')
38 const inspected = JSON.parse(await run(['updates:trust', output, '--enrollment-sha256', enrolled.enrollmentSha256], 0))
39 if (JSON.stringify(inspected) !== JSON.stringify(enrolled)) throw new Error('Portable enrollment mismatch')
40 const marker = await readFile(join(output, 'enrollment.json'))
41 const rejected = await run(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)], 1)
42 if (rejected !== '' || !(await readFile(join(output, 'root.json'))).equals(await readFile(fixture.rootPath))) throw new Error('Rejected trust readback changed the anchor')
43 if (await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags], 1) !== '' || !(await readFile(join(output, 'enrollment.json'))).equals(marker)) throw new Error('Existing enrollment was overwritten')
44 }
45 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'generated-initial-trust-only', sourceSha256, copiedExecutableBytes: (await stat(binary)).size, metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalFirmwareInstalled: false, results }, null, 2)) }
46 return { evidence: 'generated-initial-trust-only', operations: results.length, sourceSha256, metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalFirmwareInstalled: false }
47}
48if (import.meta.main) {
49 if (process.argv.length !== 4) throw new Error('Use qualify-trust.ts NEW_DIRECTORY COMPILED_BINARY')
50 console.log(JSON.stringify(await qualifyRuntimeTrust(resolve(process.argv[2]!), resolve(process.argv[3]!))))
51}
tests/unit/updates-enrollment-cli.test.tsadded+33-0
Changes to tests/unit/updates-enrollment-cli.test.ts
@@ -0,0 +1,33 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { platform, repository, target } from '../fixtures/updates/repository'
7
8const roots: string[] = []
9afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
10
11test('native trust commands enroll explicit policy and require the independent enrollment pin for readback', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-trust-cli-')); roots.push(root)
13 const fixture = await repository(join(root, 'repository')), output = join(root, 'trust')
14 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
15 const enrolled = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags]).withTimeout(20000)
16 expect(enrolled.exitCode).toBe(0)
17 expect(enrolled.timedOut).toBe(false)
18 const value = JSON.parse(enrolled.stdout)
19 expect(value.policy.minimumReleaseSequence).toBe(7)
20 expect(value.metadataState).toBe('initial-root-only')
21 expect(value.firmwareSelectionPerformed).toBe(false)
22 const inspected = await command(['updates:trust', output, '--enrollment-sha256', value.enrollmentSha256]).withTimeout(20000)
23 expect(inspected.exitCode).toBe(0)
24 expect(JSON.parse(inspected.stdout)).toEqual(value)
25 const rejected = await command(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)]).withTimeout(20000)
26 expect(rejected.exitCode).toBe(1)
27 expect(rejected.stdout).toBe('')
28 expect(rejected.stderr).toBe('Runtime trust unavailable: invalid enrollment pin or protected state\n')
29 const missing = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', join(root, 'missing-policy')]).withTimeout(20000)
30 expect(missing.exitCode).toBe(1)
31 expect(missing.stdout).toBe('')
32 expect(await Bun.file(join(root, 'missing-policy/enrollment.json')).exists()).toBe(false)
33}, 100000)
tests/unit/updates-enrollment.test.tsadded+153-0
Changes to tests/unit/updates-enrollment.test.ts
@@ -0,0 +1,153 @@
1import { afterEach, expect, test } from 'bun:test'
2import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { spawnSync } from 'node:child_process'
5import { join } from 'node:path'
6import { digest, platform, repository, target } from '../fixtures/updates/repository'
7import type { RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging'
8import { enrollRuntimeTrust, inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('independently pinned root and release policy can be enrolled and verified after readback', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root)
15 const fixture = await repository(join(root, 'repository')), output = join(root, 'trust')
16 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
17 const result = enrollRuntimeTrust({ ...policy, trustedRoot: fixture.rootPath, output })
18 expect(result.policy).toEqual(policy)
19 expect(result.rootVersion).toBe(1)
20 expect(result.metadataState).toBe('initial-root-only')
21 expect(result.metadataRefreshPerformed).toBe(false)
22 expect(result.firmwareSelectionPerformed).toBe(false)
23 expect(result.physicalDeploymentAllowed).toBe(false)
24 expect(result.enrollmentSha256).toBe(digest(await readFile(join(output, 'enrollment.json'))))
25 expect(await readFile(join(output, 'root.json'))).toEqual(await readFile(fixture.rootPath))
26 expect(inspectRuntimeTrust({ directory: output, enrollmentSha256: result.enrollmentSha256 })).toEqual(result)
27 expect((await readdir(output)).sort()).toEqual(['enrollment.json', 'root.json'])
28 expect((await stat(output)).mode & 0o777).toBe(0o700)
29 expect((await stat(join(output, 'enrollment.json'))).mode & 0o777).toBe(0o600)
30 expect((await stat(join(output, 'root.json'))).mode & 0o777).toBe(0o600)
31})
32
33
34test('trust readback requires an owned parent protected from other principals', async () => {
35 const root = await mkdtemp(join(tmpdir(), 'drone-trust-parent-')); roots.push(root)
36 const fixture = await repository(join(root, 'repository')), directory = join(root, 'trust')
37 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: directory })
38 await chmod(root, 0o777)
39 try { expect(() => inspectRuntimeTrust({ directory, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() }
40 finally { await chmod(root, 0o700) }
41})
42
43
44async function setup(options: Parameters<typeof repository>[1] = {}) {
45 const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root)
46 const fixture = await repository(join(root, 'repository'), options), output = join(root, 'trust')
47 return { root, fixture, output, input: { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output } satisfies Parameters<typeof enrollRuntimeTrust>[0] }
48}
49
50test('bad pins, unsafe policy and malformed roots cannot complete enrollment', async () => {
51 const item = await setup()
52 for (const [i, change] of [{ trustedRootSha256: '0'.repeat(64) }, { trustedRootSha256: '' }, { minimumReleaseSequence: 0 }, { minimumReleaseSequence: Number.NaN }, { minimumReleaseSequence: 7.5 }, { target: '../image.bin' }].entries()) {
53 const output = join(item.root, `invalid-${i}`)
54 expect(() => enrollRuntimeTrust({ ...item.input, ...change, output })).toThrow()
55 expect(await Bun.file(join(output, 'enrollment.json')).exists()).toBe(false)
56 }
57 for (const options of [{ nonCanonicalRoot: true }, { metadataFields: { root: { expires: 'not-a-date' } } }, { metadataFields: { root: { version: 0 } } }]) {
58 const bad = await setup(options)
59 expect(() => enrollRuntimeTrust(bad.input)).toThrow()
60 expect(await Bun.file(join(bad.output, 'enrollment.json')).exists()).toBe(false)
61 }
62})
63
64test('a pinned root still requires its authorized self-signatures', async () => {
65 const item = await setup(), root = JSON.parse(await readFile(item.fixture.rootPath, 'utf8'))
66 root.signatures[0].sig = '0'.repeat(128)
67 const bytes = Buffer.from(JSON.stringify(root))
68 await writeFile(item.fixture.rootPath, bytes)
69 expect(() => enrollRuntimeTrust({ ...item.input, trustedRootSha256: digest(bytes) })).toThrow()
70 expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false)
71})
72
73test('readback rejects a wrong independent pin, modified root, altered record and invalid schema', async () => {
74 const item = await setup(), enrolled = enrollRuntimeTrust(item.input)
75 const manifestPath = join(item.output, 'enrollment.json'), original = await readFile(manifestPath), record = JSON.parse(original.toString())
76 for (const enrollmentSha256 of ['', '0'.repeat(64)]) expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256 })).toThrow()
77 const changed = { ...record, policy: { ...record.policy, minimumReleaseSequence: 1 } }
78 await writeFile(manifestPath, JSON.stringify(changed))
79 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
80 const invalid = [{ ...record, schema: 'unknown' }, { ...record, extra: true }, { ...record, enrollmentId: '../bad' }, { ...record, rootVersion: 2 }, { ...record, policy: { ...record.policy, minimumReleaseSequence: 0 } }, { ...record, policy: { ...record.policy, extra: true } }]
81 for (const value of invalid) {
82 const bytes = Buffer.from(JSON.stringify(value)); await writeFile(manifestPath, bytes)
83 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: digest(bytes) })).toThrow()
84 }
85 await writeFile(manifestPath, original)
86 await writeFile(join(item.output, 'root.json'), '{}')
87 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
88})
89
90test('source symlinks and shared writable storage are rejected', async () => {
91 const linked = await setup(), link = join(linked.root, 'root-link.json')
92 await symlink(linked.fixture.rootPath, link)
93 expect(() => enrollRuntimeTrust({ ...linked.input, trustedRoot: link })).toThrow()
94 const shared = await setup()
95 await chmod(shared.root, 0o777)
96 expect(() => enrollRuntimeTrust(shared.input)).toThrow()
97 await chmod(shared.root, 0o700)
98 const result = enrollRuntimeTrust(shared.input)
99 await chmod(shared.output, 0o777)
100 expect(() => inspectRuntimeTrust({ directory: shared.output, enrollmentSha256: result.enrollmentSha256 })).toThrow()
101 await chmod(shared.output, 0o700)
102})
103
104test('an existing enrollment or partial directory is never overwritten or implicitly repaired', async () => {
105 const item = await setup(), enrolled = enrollRuntimeTrust(item.input)
106 const marker = await readFile(join(item.output, 'enrollment.json'))
107 expect(() => enrollRuntimeTrust(item.input)).toThrow()
108 expect(await readFile(join(item.output, 'enrollment.json'))).toEqual(marker)
109 expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled)
110 const partial = await setup(); await mkdir(partial.output)
111 await writeFile(join(partial.output, 'root.json'), await readFile(partial.fixture.rootPath))
112 expect(() => enrollRuntimeTrust(partial.input)).toThrow()
113 expect(() => inspectRuntimeTrust({ directory: partial.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
114})
115
116test('owned policy survives caller mutation and a valid expired root is only an initial anchor', async () => {
117 const item = await setup({ metadataFields: { root: { expires: '2020-01-01T00:00:00Z' } } })
118 const enrolled = enrollRuntimeTrust(item.input)
119 item.input.minimumReleaseSequence = 1
120 item.input.trustedRootSha256 = '0'.repeat(64)
121 const checked = inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })
122 expect(checked.policy.minimumReleaseSequence).toBe(7)
123 expect(checked.policy.trustedRootSha256).toBe(item.fixture.rootSha256)
124 expect(checked.metadataState).toBe('initial-root-only')
125 expect(checked.metadataRefreshPerformed).toBe(false)
126 expect(checked.firmwareSelectionPerformed).toBe(false)
127})
128
129test('two real processes cannot enroll into the same output', async () => {
130 const item = await setup(), worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts')
131 const processes = [Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 }), Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 })]
132 const results = await Promise.all(processes.map(async child => ({ exit: await child.exited, stdout: await new Response(child.stdout).text(), stderr: await new Response(child.stderr).text() })))
133 expect(results.map(r => r.exit).sort()).toEqual([0, 1])
134 const success = results.find(r => r.exit === 0)!
135 const enrolled = JSON.parse(success.stdout)
136 expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled)
137 expect(results.find(r => r.exit === 1)!.stdout).toBe('')
138}, 60000)
139
140test('an actual child write-limit failure leaves incomplete evidence and preserves the trusted source', async () => {
141 const item = await setup(), source = await readFile(item.fixture.rootPath)
142 const worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts')
143 const code = 'import os, resource, signal, sys; signal.signal(signal.SIGXFSZ, signal.SIG_IGN); resource.setrlimit(resource.RLIMIT_FSIZE, (256,256)); os.execv(sys.argv[1],sys.argv[1:])'
144 const failed = spawnSync('python3', ['-c', code, process.execPath, worker, JSON.stringify(item.input)], { timeout: 20000, encoding: 'utf8' })
145 expect(failed.error).toBeUndefined()
146 expect(failed.status).toBe(1)
147 expect(failed.stderr).toContain('Trust enrollment rejected')
148 expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false)
149 expect((await stat(join(item.output, 'root.json'))).size).toBe(256)
150 expect(await readFile(item.fixture.rootPath)).toEqual(source)
151 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: '0'.repeat(64) })).toThrow()
152 expect(() => enrollRuntimeTrust(item.input)).toThrow()
153}, 60000)