also looking at this
feat: expose verified metadata persistence and failure phases
#45
30 files
+1358
-133
| @@ -34,6 +34,10 @@ 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" | |
| 39 | - name: Verify typed SDK failures in a minified copied standalone executable | |
| 40 | run: bun tests/fixtures/updates/qualify-sdk.ts "$RUNNER_TEMP/runtime-sdk-qualification" | |
| 37 | 41 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 38 | 42 | working-directory: ${{ runner.temp }} |
| 39 | 43 | - 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) |
bun.lockmodified+3-0
| @@ -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, |
| @@ -52,6 +98,53 @@ Metadata versions must be positive safe integers. Expiration dates must be real | ||
| 52 | 98 | UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes |
| 53 | 99 | expiry comparisons; malformed or automatically normalized dates are rejected. |
| 54 | 100 | |
| 101 | ### Maintained SDK persistence seam | |
| 102 | ||
| 103 | This repository applies `patches/tuf-js@6.0.0.patch` through Bun's root | |
| 104 | `patchedDependencies`. It extends the pinned MIT-licensed client; these are | |
| 105 | repository-maintained options, not options supplied by unmodified upstream 6.0.0. | |
| 106 | The patch retains upstream signature, role and target verification. | |
| 107 | ||
| 108 | `onVerifiedMetadata(role, bytes)` receives an owned byte copy after the SDK | |
| 109 | accepts remote metadata. The SDK awaits it before writing that cache file or | |
| 110 | advancing to the next role. A rejected observer aborts refresh, including when | |
| 111 | its exception is an HTTP 404. Cache-preferred refresh also propagates persistence | |
| 112 | errors instead of retrying past the rejected observer. Initial root loading and | |
| 113 | cached metadata do not | |
| 114 | emit new observations. An authenticated intermediate root may be observed | |
| 115 | before a later final-root expiry check rejects the refresh. Discard an updater | |
| 116 | after any refresh failure: its upstream in-memory trust set may already have | |
| 117 | advanced. Reusing that instance is not a recovery or persistence retry protocol. | |
| 118 | ||
| 119 | `referenceTime` optionally supplies a finite, copied fixed start time for future | |
| 120 | recovery replay. It does not establish a trusted clock. Historical replay must | |
| 121 | never authorize firmware use: a coordinator must perform fresh verification at | |
| 122 | a trusted current wall time before using a candidate. Existing staging and | |
| 123 | inspection pass neither extension and continue to use the current wall clock. | |
| 124 | ||
| 125 | The maintained public exports `MetadataVerificationError`, `PersistError`, | |
| 126 | `DownloadError` and `DownloadHTTPError` identify operational failure phases. | |
| 127 | Snapshot, targets and persistence wrappers retain the original `Error.cause`. | |
| 128 | A caught value is never coerced to build a wrapper message, including a `Symbol` | |
| 129 | or an object whose string conversion throws. Persistence rejection must still | |
| 130 | abort a cache-preferred refresh in those cases. | |
| 131 | A remote metadata verification error includes the role being admitted and its | |
| 132 | original cause. Equal timestamp versions keep the normal unchanged-metadata | |
| 133 | path. Initial-root and cached-metadata errors retain upstream behavior. | |
| 134 | ||
| 135 | Classify a `PersistError` before examining its cause: an observer can itself | |
| 136 | throw a verification error, which is still failed persistence. A verification | |
| 137 | phase error alone does not prove that input was invalid, that a prefix is safe | |
| 138 | to discard, or that recovery may continue. Unknown crypto, resource or other | |
| 139 | causes must fail closed pending investigation. Error objects and type names do | |
| 140 | not establish authenticated trust or permit firmware use. The recovery | |
| 141 | coordinator and its full failure classification remain to be implemented. | |
| 142 | ||
| 143 | The seam alone does not persist a protected prefix or make SDK cache writes | |
| 144 | transactional. Its implementation and tests are a prerequisite for the protected | |
| 145 | store and recovery coordinator in issue #42. They do not complete that issue or | |
| 146 | permit flashing, boot selection or physical deployment. | |
| 147 | ||
| 55 | 148 | The selected target must have SHA-256, a nonzero length of at most 16 MiB and |
| 56 | 149 | exactly these fields in its signed `custom.drone` object: |
| 57 | 150 | |
| @@ -104,7 +197,8 @@ must supply the trusted release floor every time. TUF expiry uses host wall | ||
| 104 | 197 | time; trusted clock acquisition and protection against clock/storage rollback |
| 105 | 198 | remain open. Control-loop freshness still uses its separate monotonic clock. |
| 106 | 199 | |
| 107 | Next, implement persistent trust enrollment/rotation and release floors, an | |
| 200 | Initial enrollment is available above. Next, implement protected trust rotation | |
| 201 | and persistent metadata/release floors, an | |
| 108 | 202 | interruption-safe coordinator, installed/known-good slot integrity, explicit |
| 109 | 203 | health confirmation and rollback without lowering security floors. Then add |
| 110 | 204 | the board bootloader/install protocol and measured power-loss, full-medium, |
| @@ -114,7 +208,8 @@ anti-rollback. See [TUF's workflow and threat model](https://theupdateframework. | ||
| 114 | 208 | |
| 115 | 209 | Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes. |
| 116 | 210 | 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 | |
| 211 | release floors, tampering, bounded inputs, incomplete candidates, enrollment | |
| 212 | readback, competing enrollment processes, an actual host write-limit failure and | |
| 213 | native/portable CLI behavior. They establish host behavior, not actual firmware | |
| 119 | 214 | installation, board boot recovery or commissioning. The diagnostic supervisor's |
| 120 | 215 | update/rollback check remains blocked and `commissioningReady` stays false. |
| @@ -0,0 +1,35 @@ | ||
| 1 | # Maintained dependency patches | |
| 2 | ||
| 3 | ## tuf-js 6.0.0 | |
| 4 | ||
| 5 | `tuf-js@6.0.0.patch` applies to the npm 6.0.0 release from | |
| 6 | [the TUF JavaScript client](https://github.com/theupdateframework/tuf-js), upstream | |
| 7 | commit `c30653d07f0c41fbe5a2030d25eca6fd04f8547f`. The original MIT copyright and permission | |
| 8 | notice is retained in `tuf-js.LICENSE` and in the installed package. | |
| 9 | ||
| 10 | The patch adds an awaited, copy-owning `onVerifiedMetadata` observer before the | |
| 11 | SDK writes each newly verified remote metadata file, plus an owned finite | |
| 12 | `referenceTime` for recovery replay. Default callers keep upstream verification rules and the current fixed start | |
| 13 | time. Persistence failure aborts rather than being interpreted as a missing | |
| 14 | remote root or a cache miss under `forceCache`. Discard failed updater instances; | |
| 15 | their in-memory trust set is not rolled back. It does not make cache writes | |
| 16 | transactional or implement a protected trust store. Public operational error | |
| 17 | exports retain causes and identify remote verification, persistence and download | |
| 18 | phases. Verification errors identify an attempted role, not permission to discard | |
| 19 | state. Classify persistence first, including when an observer throws a | |
| 20 | verification error; unknown causes require maintenance rather than an assumed | |
| 21 | invalid-input verdict. | |
| 22 | Wrapper messages never coerce caught values, so opaque observer rejections keep | |
| 23 | their persistence classification and exact cause under cache-preferred refresh. | |
| 24 | ||
| 25 | Bun applies the patch through root `patchedDependencies` and the lockfile. | |
| 26 | Always prepare with `bun patch tuf-js@6.0.0` before editing an installed package, | |
| 27 | then generate changes with `bun patch --commit tuf-js@6.0.0`. Do not edit shared | |
| 28 | package caches. Exclude empty generated `.bun-tag-*` entries from the patch; | |
| 29 | they are installer cache markers rather than package source. For upgrades, review the upstream change against every patch | |
| 30 | hunk and rerun the SDK observer cases, update/enrollment regressions, clean | |
| 31 | frozen installation and copied standalone CLI qualification. Never silently | |
| 32 | carry a patch onto another version. | |
| 33 | ||
| 34 | See `docs/runtime-updates.md` for the trust and clock limits. The extensions are | |
| 35 | maintained here and are not claimed to be unmodified upstream options. | |
| @@ -0,0 +1,21 @@ | ||
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2022 GitHub and the TUF Contributors | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. | |
| @@ -0,0 +1,255 @@ | ||
| 1 | diff --git a/dist/error.d.ts b/dist/error.d.ts | |
| 2 | index 3a42f0441b68f961d6e367efee45b4f4158bbccd..727dd12095b649a8d6203e7eba6d9ebb59be68d6 100644 | |
| 3 | --- a/dist/error.d.ts | |
| 4 | +++ b/dist/error.d.ts | |
| 5 | @@ -6,6 +6,10 @@ export declare class PersistError extends Error { | |
| 6 | } | |
| 7 | export declare class RepositoryError extends Error { | |
| 8 | } | |
| 9 | +export declare class MetadataVerificationError extends Error { | |
| 10 | + readonly role: string; | |
| 11 | + constructor(role: string, message: string, options?: ErrorOptions); | |
| 12 | +} | |
| 13 | export declare class BadVersionError extends RepositoryError { | |
| 14 | } | |
| 15 | export declare class EqualVersionError extends BadVersionError { | |
| 16 | diff --git a/dist/error.js b/dist/error.js | |
| 17 | index 3a3c26a068a953998e16e552faf96b6fbe956f90..e4c07310caa36177ce390e6e3d82dd76178432f4 100644 | |
| 18 | --- a/dist/error.js | |
| 19 | +++ b/dist/error.js | |
| 20 | @@ -17,6 +17,15 @@ exports.PersistError = PersistError; | |
| 21 | class RepositoryError extends Error { | |
| 22 | } | |
| 23 | exports.RepositoryError = RepositoryError; | |
| 24 | +// Repository-maintained public distinction for pure remote metadata validation. | |
| 25 | +class MetadataVerificationError extends Error { | |
| 26 | + role; | |
| 27 | + constructor(role, message, options) { | |
| 28 | + super(message, options); | |
| 29 | + this.role = role; | |
| 30 | + } | |
| 31 | +} | |
| 32 | +exports.MetadataVerificationError = MetadataVerificationError; | |
| 33 | // An error for metadata that contains an invalid version number. | |
| 34 | class BadVersionError extends RepositoryError { | |
| 35 | } | |
| 36 | diff --git a/dist/index.d.ts b/dist/index.d.ts | |
| 37 | index 895fdf4b57c27baf4571e8d06bb93de69166f574..aa1e170b7832c2df49cf2628f77d7895a5f0cbf1 100644 | |
| 38 | --- a/dist/index.d.ts | |
| 39 | +++ b/dist/index.d.ts | |
| 40 | @@ -4,3 +4,4 @@ export { Updater } from './updater'; | |
| 41 | export type { Config } from './config'; | |
| 42 | export type { Fetcher } from './fetcher'; | |
| 43 | export type { UpdaterOptions } from './updater'; | |
| 44 | +export { DownloadError, DownloadHTTPError, MetadataVerificationError, PersistError } from './error'; | |
| 45 | diff --git a/dist/index.js b/dist/index.js | |
| 46 | index 5a83b91f355d88c27615d2bc07310958b18d0142..645728da0d96110d323b0f5e5b2c5f0e839c6fce 100644 | |
| 47 | --- a/dist/index.js | |
| 48 | +++ b/dist/index.js | |
| 49 | @@ -7,3 +7,10 @@ var fetcher_1 = require("./fetcher"); | |
| 50 | Object.defineProperty(exports, "BaseFetcher", { enumerable: true, get: function () { return fetcher_1.BaseFetcher; } }); | |
| 51 | var updater_1 = require("./updater"); | |
| 52 | Object.defineProperty(exports, "Updater", { enumerable: true, get: function () { return updater_1.Updater; } }); | |
| 53 | + | |
| 54 | +// Repository-maintained operational error exports; use causes rather than message matching. | |
| 55 | +var error_1 = require("./error"); | |
| 56 | +Object.defineProperty(exports, "DownloadError", { enumerable: true, get: function () { return error_1.DownloadError; } }); | |
| 57 | +Object.defineProperty(exports, "DownloadHTTPError", { enumerable: true, get: function () { return error_1.DownloadHTTPError; } }); | |
| 58 | +Object.defineProperty(exports, "MetadataVerificationError", { enumerable: true, get: function () { return error_1.MetadataVerificationError; } }); | |
| 59 | +Object.defineProperty(exports, "PersistError", { enumerable: true, get: function () { return error_1.PersistError; } }); | |
| 60 | diff --git a/dist/store.d.ts b/dist/store.d.ts | |
| 61 | index 20f835c29dee82ea3e61c775c83d8a34fad787dc..3b9c2f88cbe9082b45927ccabccb44f1d56d8739 100644 | |
| 62 | --- a/dist/store.d.ts | |
| 63 | +++ b/dist/store.d.ts | |
| 64 | @@ -2,7 +2,7 @@ import { Metadata, Root, Snapshot, Targets, Timestamp } from '@tufjs/models'; | |
| 65 | export declare class TrustedMetadataStore { | |
| 66 | private trustedSet; | |
| 67 | private referenceTime; | |
| 68 | - constructor(rootData: Buffer); | |
| 69 | + constructor(rootData: Buffer, referenceTime?: Date); | |
| 70 | get root(): Metadata<Root>; | |
| 71 | get timestamp(): Metadata<Timestamp> | undefined; | |
| 72 | get snapshot(): Metadata<Snapshot> | undefined; | |
| 73 | diff --git a/dist/store.js b/dist/store.js | |
| 74 | index 1b1669029a8db00b7cc594ef202984f3d243a0e4..0ef50b3d8b599b7d49392d7d8c5f63d3c6f0ecce 100644 | |
| 75 | --- a/dist/store.js | |
| 76 | +++ b/dist/store.js | |
| 77 | @@ -6,9 +6,9 @@ const error_1 = require("./error"); | |
| 78 | class TrustedMetadataStore { | |
| 79 | trustedSet = {}; | |
| 80 | referenceTime; | |
| 81 | - constructor(rootData) { | |
| 82 | + constructor(rootData, referenceTime = new Date()) { | |
| 83 | // Client workflow 5.1: record fixed update start time | |
| 84 | - this.referenceTime = new Date(); | |
| 85 | + this.referenceTime = new Date(referenceTime.getTime()); | |
| 86 | // Client workflow 5.2: load trusted root metadata | |
| 87 | this.loadTrustedRoot(rootData); | |
| 88 | } | |
| 89 | diff --git a/dist/updater.d.ts b/dist/updater.d.ts | |
| 90 | index 883df0fe6a6628ef5d16bdb0de17c0850d7d19ec..0fe93d96f9a7912c6708bfdcd6f8bcc7330c9e2e 100644 | |
| 91 | --- a/dist/updater.d.ts | |
| 92 | +++ b/dist/updater.d.ts | |
| 93 | @@ -9,6 +9,10 @@ export interface UpdaterOptions { | |
| 94 | fetcher?: Fetcher; | |
| 95 | forceCache?: boolean; | |
| 96 | config?: Partial<Config>; | |
| 97 | + /** Repository-maintained extension: called after verification, awaited before cache persistence. */ | |
| 98 | + onVerifiedMetadata?: (role: string, bytes: Buffer) => void | Promise<void>; | |
| 99 | + /** Repository-maintained extension: copied fixed start time for recovery replay, not a trusted clock source. */ | |
| 100 | + referenceTime?: Date; | |
| 101 | } | |
| 102 | export declare class Updater { | |
| 103 | private dir; | |
| 104 | @@ -19,6 +23,7 @@ export declare class Updater { | |
| 105 | private trustedSet; | |
| 106 | private config; | |
| 107 | private fetcher; | |
| 108 | + private onVerifiedMetadata; | |
| 109 | constructor(options: UpdaterOptions); | |
| 110 | refresh(): Promise<void>; | |
| 111 | getTargetInfo(targetPath: string): Promise<TargetFile | undefined>; | |
| 112 | @@ -31,5 +36,6 @@ export declare class Updater { | |
| 113 | private loadTargets; | |
| 114 | private preorderDepthFirstWalk; | |
| 115 | private generateTargetPath; | |
| 116 | + private verifyMetadata; | |
| 117 | private persistMetadata; | |
| 118 | } | |
| 119 | diff --git a/dist/updater.js b/dist/updater.js | |
| 120 | index 94a7658155740fee3f3b7b32937905cc7f791e90..ce62cfdc83eeec41479089aa301cbea250d8d116 100644 | |
| 121 | --- a/dist/updater.js | |
| 122 | +++ b/dist/updater.js | |
| 123 | @@ -57,6 +57,7 @@ class Updater { | |
| 124 | trustedSet; | |
| 125 | config; | |
| 126 | fetcher; | |
| 127 | + onVerifiedMetadata; | |
| 128 | constructor(options) { | |
| 129 | const { metadataDir, metadataBaseUrl, targetDir, targetBaseUrl, fetcher, config, } = options; | |
| 130 | this.dir = metadataDir; | |
| 131 | @@ -64,8 +65,16 @@ class Updater { | |
| 132 | this.targetDir = targetDir; | |
| 133 | this.targetBaseUrl = targetBaseUrl; | |
| 134 | this.forceCache = options.forceCache ?? false; | |
| 135 | + const referenceTime = options.referenceTime ?? new Date(); | |
| 136 | + if (!(referenceTime instanceof Date) || !Number.isFinite(referenceTime.getTime())) { | |
| 137 | + throw new error_1.ValueError('Invalid fixed update reference time'); | |
| 138 | + } | |
| 139 | + if (options.onVerifiedMetadata !== undefined && typeof options.onVerifiedMetadata !== 'function') { | |
| 140 | + throw new error_1.ValueError('Invalid verified metadata observer'); | |
| 141 | + } | |
| 142 | + this.onVerifiedMetadata = options.onVerifiedMetadata; | |
| 143 | const data = this.loadLocalMetadata(models_1.MetadataKind.Root); | |
| 144 | - this.trustedSet = new store_1.TrustedMetadataStore(data); | |
| 145 | + this.trustedSet = new store_1.TrustedMetadataStore(data, new Date(referenceTime.getTime())); | |
| 146 | this.config = { ...config_1.defaultConfig, ...config }; | |
| 147 | const userAgent = config?.userAgent | |
| 148 | ? `${config.userAgent} tuf-js/${package_json_1.version}` | |
| 149 | @@ -92,6 +101,10 @@ class Updater { | |
| 150 | await this.loadTimestamp({ checkRemote: false }); | |
| 151 | } | |
| 152 | catch (error) { | |
| 153 | + // A failed persistence observer/cache write is not a cache miss. | |
| 154 | + if (error instanceof error_1.PersistError) { | |
| 155 | + throw error; | |
| 156 | + } | |
| 157 | await this.loadRoot(); | |
| 158 | await this.loadTimestamp(); | |
| 159 | } | |
| 160 | @@ -173,9 +186,9 @@ class Updater { | |
| 161 | // Client workflow 5.3.3: download new root metadata file | |
| 162 | const bytesData = await this.fetcher.downloadBytes(rootUrl, this.config.rootMaxLength); | |
| 163 | // Client workflow 5.3.4 - 5.4.7 | |
| 164 | - this.trustedSet.updateRoot(bytesData); | |
| 165 | + this.verifyMetadata(models_1.MetadataKind.Root, () => this.trustedSet.updateRoot(bytesData)); | |
| 166 | // Client workflow 5.3.8: persist root metadata file | |
| 167 | - this.persistMetadata(models_1.MetadataKind.Root, bytesData); | |
| 168 | + await this.persistMetadata(models_1.MetadataKind.Root, bytesData); | |
| 169 | } | |
| 170 | catch (error) { | |
| 171 | if (error instanceof error_1.DownloadHTTPError) { | |
| 172 | @@ -210,7 +223,7 @@ class Updater { | |
| 173 | const bytesData = await this.fetcher.downloadBytes(timestampUrl, this.config.timestampMaxLength); | |
| 174 | try { | |
| 175 | // Client workflow 5.4.2 - 5.4.4 | |
| 176 | - this.trustedSet.updateTimestamp(bytesData); | |
| 177 | + this.verifyMetadata(models_1.MetadataKind.Timestamp, () => this.trustedSet.updateTimestamp(bytesData)); | |
| 178 | } | |
| 179 | catch (error) { | |
| 180 | // If new timestamp version is same as current, discardd the new one. | |
| 181 | @@ -222,7 +235,7 @@ class Updater { | |
| 182 | throw error; | |
| 183 | } | |
| 184 | // Client workflow 5.4.5: persist timestamp metadata | |
| 185 | - this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData); | |
| 186 | + await this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData); | |
| 187 | } | |
| 188 | // Load local and remote snapshot metadata. | |
| 189 | // Client workflow 5.5: update snapshot role | |
| 190 | @@ -246,12 +259,12 @@ class Updater { | |
| 191 | // Client workflow 5.5.1: download snapshot metadata file | |
| 192 | const bytesData = await this.fetcher.downloadBytes(snapshotUrl, maxLength); | |
| 193 | // Client workflow 5.5.2 - 5.5.6 | |
| 194 | - this.trustedSet.updateSnapshot(bytesData); | |
| 195 | + this.verifyMetadata(models_1.MetadataKind.Snapshot, () => this.trustedSet.updateSnapshot(bytesData)); | |
| 196 | // Client workflow 5.5.7: persist snapshot metadata file | |
| 197 | - this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData); | |
| 198 | + await this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData); | |
| 199 | } | |
| 200 | catch (error) { | |
| 201 | - throw new error_1.RuntimeError(`Unable to load snapshot metadata error ${error}`); | |
| 202 | + throw new error_1.RuntimeError('Unable to load snapshot metadata', { cause: error }); | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | @@ -282,12 +295,12 @@ class Updater { | |
| 207 | // Client workflow 5.6.1: download targets metadata file | |
| 208 | const bytesData = await this.fetcher.downloadBytes(metadataUrl, maxLength); | |
| 209 | // Client workflow 5.6.2 - 5.6.6 | |
| 210 | - this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole); | |
| 211 | + this.verifyMetadata(role, () => this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole)); | |
| 212 | // Client workflow 5.6.7: persist targets metadata file | |
| 213 | - this.persistMetadata(role, bytesData); | |
| 214 | + await this.persistMetadata(role, bytesData); | |
| 215 | } | |
| 216 | catch (error) { | |
| 217 | - throw new error_1.RuntimeError(`Unable to load targets error ${error}`); | |
| 218 | + throw new error_1.RuntimeError('Unable to load targets metadata', { cause: error }); | |
| 219 | } | |
| 220 | } | |
| 221 | return this.trustedSet.getRole(role); | |
| 222 | @@ -358,15 +371,31 @@ class Updater { | |
| 223 | const filePath = encodeURIComponent(targetInfo.path); | |
| 224 | return path.join(this.targetDir, filePath); | |
| 225 | } | |
| 226 | - persistMetadata(metaDataName, bytesData) { | |
| 227 | + verifyMetadata(role, operation) { | |
| 228 | + try { | |
| 229 | + return operation(); | |
| 230 | + } | |
| 231 | + catch (error) { | |
| 232 | + // Equal timestamp versions retain the existing normal no-new-metadata path. | |
| 233 | + if (error instanceof error_1.EqualVersionError) { | |
| 234 | + throw error; | |
| 235 | + } | |
| 236 | + throw new error_1.MetadataVerificationError(role, `Failed to verify metadata ${encodeURIComponent(role)}`, { cause: error }); | |
| 237 | + } | |
| 238 | + } | |
| 239 | + async persistMetadata(metaDataName, bytesData) { | |
| 240 | const encodedName = encodeURIComponent(metaDataName); | |
| 241 | try { | |
| 242 | + // The observer owns its copy and must finish before this cache or the next role advances. | |
| 243 | + if (this.onVerifiedMetadata) { | |
| 244 | + await this.onVerifiedMetadata(metaDataName, Buffer.from(bytesData)); | |
| 245 | + } | |
| 246 | const filePath = path.join(this.dir, `${encodedName}.json`); | |
| 247 | log('WRITE %s', filePath); | |
| 248 | fs.writeFileSync(filePath, bytesData.toString('utf8')); | |
| 249 | } | |
| 250 | catch (error) { | |
| 251 | - throw new error_1.PersistError(`Failed to persist metadata ${encodedName} error: ${error}`); | |
| 252 | + throw new error_1.PersistError(`Failed to persist metadata ${encodedName}`, { cause: error }); | |
| 253 | } | |
| 254 | } | |
| 255 | } | |
| @@ -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,33 @@ | ||
| 1 | import { constants } from 'node:fs' | |
| 2 | import { createHash } from 'node:crypto' | |
| 3 | import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | ||
| 6 | async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') } | |
| 7 | async function qualify(directory: string) { | |
| 8 | await mkdir(directory, { mode: 0o700 }) | |
| 9 | const built = join(directory, 'sdk-built'), outside = join(directory, 'portable') | |
| 10 | const result = await Bun.build({ entrypoints: [join(import.meta.dir, 'sdk-outcomes-worker.ts')], target: 'bun', minify: true, compile: { outfile: built, autoloadBunfig: false, autoloadDotenv: false, autoloadTsconfig: false, autoloadPackageJson: false } }) | |
| 11 | if (!result.success) throw new AggregateError(result.logs, 'SDK qualifier compilation failed') | |
| 12 | const sourceSha256 = await hash(built) | |
| 13 | await mkdir(outside, { mode: 0o700 }) | |
| 14 | const binary = join(outside, 'sdk') | |
| 15 | await copyFile(built, binary, constants.COPYFILE_EXCL) | |
| 16 | await chmod(binary, 0o700) | |
| 17 | if (await hash(binary) !== sourceSha256 || await hash(built) !== sourceSha256) throw new Error('SDK qualifier executable changed') | |
| 18 | const child = Bun.spawn([binary, join(outside, 'data')], { cwd: outside, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 19 | const timer = setTimeout(() => child.kill('SIGKILL'), 20000) | |
| 20 | try { | |
| 21 | const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]) | |
| 22 | await writeFile(join(directory, 'qualification.json'), JSON.stringify({ sourceSha256, exit, stdout, stderr }, null, 2)) | |
| 23 | if (exit !== 0) throw new Error('Copied standalone SDK outcome qualification failed') | |
| 24 | const observed = JSON.parse(stdout) | |
| 25 | if (observed.evidence !== 'synthetic-sdk-operational-errors-v1' || observed.physicalDeploymentAllowed !== false || observed.outcomes.length !== 4) throw new Error('Invalid SDK outcome qualification') | |
| 26 | return { ...observed, sourceSha256, copiedStandaloneExecutable: true } | |
| 27 | } | |
| 28 | finally { clearTimeout(timer) } | |
| 29 | } | |
| 30 | if (import.meta.main) { | |
| 31 | if (process.argv.length !== 3) throw new Error('Use qualify-sdk.ts NEW_DIRECTORY') | |
| 32 | console.log(JSON.stringify(await qualify(resolve(process.argv[2]!)))) | |
| 33 | } | |
| @@ -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,72 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { mkdir, writeFile, readFile } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { DownloadError, DownloadHTTPError, MetadataVerificationError, PersistError, Updater } from 'tuf-js' | |
| 6 | import { repository } from './repository' | |
| 7 | ||
| 8 | /** Synthetic metadata only. Compiled and run outside the checkout to verify public error identities. */ | |
| 9 | async function run(directory: string) { | |
| 10 | await mkdir(directory, { mode: 0o700 }) | |
| 11 | const results: { kind: string, role?: string, statusCode?: number, causePreserved?: boolean }[] = [] | |
| 12 | async function fixture(name: string, options: Parameters<typeof repository>[1] = {}, missing = false) { | |
| 13 | const repo = join(directory, name, 'repo'), cache = join(directory, name, 'cache') | |
| 14 | const data = await repository(repo, options) | |
| 15 | await mkdir(cache, { mode: 0o700 }) | |
| 16 | await writeFile(join(cache, 'root.json'), await readFile(data.rootPath), { mode: 0o600 }) | |
| 17 | const fetcher = { | |
| 18 | async downloadBytes(url: string, limit: number) { | |
| 19 | const path = new URL(url).pathname | |
| 20 | assert(/^\/metadata\/(?:[0-9]+\.)?(?:root|timestamp|snapshot|targets)\.json$/.test(path)) | |
| 21 | if (missing && path === '/metadata/timestamp.json') throw new DownloadHTTPError('Synthetic missing timestamp', 404) | |
| 22 | try { | |
| 23 | const bytes = readFileSync(join(repo, path)) | |
| 24 | assert(bytes.length <= limit) | |
| 25 | return bytes | |
| 26 | } | |
| 27 | catch (error) { | |
| 28 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new DownloadHTTPError('Synthetic missing metadata', 404) | |
| 29 | throw error | |
| 30 | } | |
| 31 | }, | |
| 32 | async downloadFile(): Promise<never> { throw new Error('This qualifier never downloads image bytes') }, | |
| 33 | } | |
| 34 | return { metadataDir: cache, metadataBaseUrl: 'https://offline.invalid/metadata/', fetcher } | |
| 35 | } | |
| 36 | const metadata = await fixture('metadata', { badTargetsSignature: true }) | |
| 37 | const prefix: string[] = [] | |
| 38 | const rejected = await new Updater({ ...metadata, onVerifiedMetadata(role) { prefix.push(role) } }).refresh().then(() => undefined, error => error) | |
| 39 | assert(rejected instanceof Error && rejected.cause instanceof MetadataVerificationError) | |
| 40 | assert.equal(rejected.cause.role, 'targets') | |
| 41 | assert(rejected.cause.cause instanceof Error) | |
| 42 | assert.deepEqual(prefix, ['timestamp', 'snapshot']) | |
| 43 | results.push({ kind: 'metadata-verification-phase', role: rejected.cause.role, causePreserved: true }) | |
| 44 | ||
| 45 | const persistence = await fixture('persistence') | |
| 46 | const marker = new MetadataVerificationError('timestamp', 'Synthetic observer failure, not an SDK verdict') | |
| 47 | const observed: string[] = [] | |
| 48 | const failed = await new Updater({ ...persistence, forceCache: true, onVerifiedMetadata(role) { observed.push(role); throw marker } }).refresh().then(() => undefined, error => error) | |
| 49 | assert(failed instanceof PersistError && failed.cause === marker) | |
| 50 | assert(!(failed instanceof MetadataVerificationError)) | |
| 51 | assert.deepEqual(observed, ['timestamp']) | |
| 52 | results.push({ kind: 'persistence-first', role: 'timestamp', causePreserved: true }) | |
| 53 | ||
| 54 | const opaque = await fixture('opaque') | |
| 55 | const opaqueMarker = Symbol('Synthetic opaque observer rejection') | |
| 56 | const opaqueSeen: string[] = [] | |
| 57 | const opaqueFailure = await new Updater({ ...opaque, forceCache: true, onVerifiedMetadata(role) { opaqueSeen.push(role); throw opaqueMarker } }).refresh().then(() => undefined, error => error) | |
| 58 | assert(opaqueFailure instanceof PersistError && opaqueFailure.cause === opaqueMarker) | |
| 59 | assert.deepEqual(opaqueSeen, ['timestamp']) | |
| 60 | results.push({ kind: 'opaque-persistence', role: 'timestamp', causePreserved: true }) | |
| 61 | ||
| 62 | const absent = await fixture('missing', {}, true) | |
| 63 | const missing = await new Updater(absent).refresh().then(() => undefined, error => error) | |
| 64 | assert(missing instanceof DownloadHTTPError && missing instanceof DownloadError) | |
| 65 | assert.equal(missing.statusCode, 404) | |
| 66 | results.push({ kind: 'download-phase', statusCode: missing.statusCode }) | |
| 67 | return { evidence: 'synthetic-sdk-operational-errors-v1', outcomes: results, physicalDeploymentAllowed: false } | |
| 68 | } | |
| 69 | if (import.meta.main) { | |
| 70 | if (process.argv.length !== 3) throw new Error('Use sdk-outcomes-worker NEW_DIRECTORY') | |
| 71 | console.log(JSON.stringify(await run(process.argv[2]!))) | |
| 72 | } | |
| @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' | ||
| 2 | 2 | import { mkdir, readFile } from 'node:fs/promises' |
| 3 | 3 | import { basename, join } from 'node:path' |
| 4 | 4 | import { startWorkspaceBrowser } from './workspace-browser' |
| 5 | import { settleExecutionDiagnostics } from './execution-diagnostics' | |
| 5 | 6 | |
| 6 | 7 | // This driver owns only browser transport. Native credentials stay in a private fixture file. |
| 7 | 8 | const root = process.cwd() |
| @@ -23,8 +24,6 @@ let diagnosticPage: Awaited<ReturnType<typeof browser.page>> | undefined | ||
| 23 | 24 | let startBaseline: number | null = null |
| 24 | 25 | async function failureDiagnostics() { |
| 25 | 26 | const abort = new AbortController() |
| 26 | let timer: ReturnType<typeof setTimeout> | undefined | |
| 27 | const timeout = new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), 1000) }) | |
| 28 | 27 | try { |
| 29 | 28 | const snapshot = async () => { |
| 30 | 29 | const response = await fetch(`${data.url}/__fixture/diagnostics`, { headers: { 'x-fixture-control': data.control }, signal: abort.signal }) |
| @@ -46,19 +45,18 @@ async function failureDiagnostics() { | ||
| 46 | 45 | hasSourceExists: !!document.querySelector('[data-execution-source-exists]'), hasExecutionError: !!document.querySelector('[data-execution-error]') |
| 47 | 46 | }; |
| 48 | 47 | })()`) : Promise.resolve(null) |
| 49 | const collect = Promise.allSettled([snapshot(), dom]).then(([server, page]) => ({ | |
| 48 | const parts = await settleExecutionDiagnostics(snapshot(), dom) | |
| 49 | const base = { | |
| 50 | 50 | label: 'execution-browser-failure-diagnostics', phase, startBaseline, |
| 51 | server: server.status === 'fulfilled' ? server.value : { available: false }, | |
| 52 | dom: page.status === 'fulfilled' ? page.value : null, browserErrorCount: diagnosticPage?.errors.length ?? null, | |
| 51 | browserErrorCount: diagnosticPage?.errors.length ?? null, | |
| 52 | executionStartRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution')).length : null, | |
| 53 | 53 | admissionRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution/admission')).length : null, |
| 54 | })) | |
| 55 | const captured = await Promise.race([collect, timeout]) | |
| 56 | const text = `${JSON.stringify(captured ?? { label: 'execution-browser-failure-diagnostics', available: false })}\n` | |
| 57 | console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text.trimEnd() : '{"label":"execution-browser-failure-diagnostics","available":false}') | |
| 54 | } | |
| 55 | const text = JSON.stringify({ ...base, ...parts }) | |
| 56 | console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text : JSON.stringify({ ...base, server: { available: false }, dom: null, byteBudgetExceeded: true })) | |
| 58 | 57 | } |
| 59 | catch { console.error('{"label":"execution-browser-failure-diagnostics","available":false}') } | |
| 58 | catch { console.error(JSON.stringify({ label: 'execution-browser-failure-diagnostics', phase, available: false })) } | |
| 60 | 59 | finally { |
| 61 | clearTimeout(timer) | |
| 62 | 60 | abort.abort() |
| 63 | 61 | // The owned socket close rejects pending CDP commands and clears their timers in the existing helper. |
| 64 | 62 | diagnosticPage?.close() |
| @@ -0,0 +1,24 @@ | ||
| 1 | type DiagnosticStatus = 'pending' | 'fulfilled' | 'rejected' | |
| 2 | export interface ExecutionDiagnosticParts { | |
| 3 | server: unknown | |
| 4 | dom: unknown | |
| 5 | serverStatus: DiagnosticStatus | |
| 6 | domStatus: DiagnosticStatus | |
| 7 | } | |
| 8 | ||
| 9 | /** Preserve each completed observation within the existing one-second diagnostic budget. */ | |
| 10 | export async function settleExecutionDiagnostics(server: Promise<unknown>, dom: Promise<unknown>): Promise<ExecutionDiagnosticParts> { | |
| 11 | const parts: ExecutionDiagnosticParts = { server: { available: false }, dom: null, serverStatus: 'pending', domStatus: 'pending' } | |
| 12 | let timer: ReturnType<typeof setTimeout> | undefined | |
| 13 | const timeout = new Promise<void>(resolve => { timer = setTimeout(resolve, 1000) }) | |
| 14 | const tasks = [ | |
| 15 | server.then(value => { parts.server = value; parts.serverStatus = 'fulfilled' }, () => { parts.serverStatus = 'rejected' }), | |
| 16 | dom.then(value => { parts.dom = value; parts.domStatus = 'fulfilled' }, () => { parts.domStatus = 'rejected' }), | |
| 17 | ] | |
| 18 | try { | |
| 19 | await Promise.race([Promise.all(tasks), timeout]) | |
| 20 | // Late responses may update the private accumulator, never this returned observation. | |
| 21 | return { ...parts } | |
| 22 | } | |
| 23 | finally { clearTimeout(timer) } | |
| 24 | } | |
| @@ -0,0 +1,40 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { settleExecutionDiagnostics } from '../helpers/execution-diagnostics' | |
| 3 | ||
| 4 | test('a pending DOM diagnostic cannot erase an already received server observation', async () => { | |
| 5 | const result = await settleExecutionDiagnostics(Promise.resolve({ available: true, state: 'running' }), new Promise(() => {})) | |
| 6 | expect(result?.server).toEqual({ available: true, state: 'running' }) | |
| 7 | expect(result?.dom).toBeNull() | |
| 8 | }) | |
| 9 | ||
| 10 | ||
| 11 | test('a pending server diagnostic cannot erase an already received DOM observation', async () => { | |
| 12 | const result = await settleExecutionDiagnostics(new Promise(() => {}), Promise.resolve({ executionPhase: 'completed' })) | |
| 13 | expect(result.dom).toEqual({ executionPhase: 'completed' }) | |
| 14 | expect(result.serverStatus).toBe('pending') | |
| 15 | expect(result.domStatus).toBe('fulfilled') | |
| 16 | }) | |
| 17 | ||
| 18 | test('a rejected arm stays unavailable without discarding the other settled observation', async () => { | |
| 19 | const result = await settleExecutionDiagnostics(Promise.reject(new Error('not logged')), Promise.resolve({ executionPhase: 'running' })) | |
| 20 | expect(result.server).toEqual({ available: false }) | |
| 21 | expect(result.dom).toEqual({ executionPhase: 'running' }) | |
| 22 | expect(result.serverStatus).toBe('rejected') | |
| 23 | expect(result.domStatus).toBe('fulfilled') | |
| 24 | }) | |
| 25 | ||
| 26 | test('two stalled arms return explicit unavailability without inventing an observation', async () => { | |
| 27 | const result = await settleExecutionDiagnostics(new Promise(() => {}), new Promise(() => {})) | |
| 28 | expect(result).toEqual({ server: { available: false }, dom: null, serverStatus: 'pending', domStatus: 'pending' }) | |
| 29 | }) | |
| 30 | ||
| 31 | test('a response after the deadline cannot mutate the returned failure observation', async () => { | |
| 32 | let resolveDom!: (value: unknown) => void | |
| 33 | const dom = new Promise(resolve => { resolveDom = resolve }) | |
| 34 | const result = await settleExecutionDiagnostics(Promise.resolve({ available: true }), dom) | |
| 35 | resolveDom({ executionPhase: 'completed' }) | |
| 36 | await dom | |
| 37 | expect(result.dom).toBeNull() | |
| 38 | expect(result.domStatus).toBe('pending') | |
| 39 | expect(result.serverStatus).toBe('fulfilled') | |
| 40 | }) | |
| @@ -0,0 +1,33 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 7 | ||
| 8 | const roots: string[] = [] | |
| 9 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 10 | ||
| 11 | test('native trust commands enroll explicit policy and require the independent enrollment pin for readback', async () => { | |
| 12 | const root = await mkdtemp(join(tmpdir(), 'drone-trust-cli-')); roots.push(root) | |
| 13 | const fixture = await repository(join(root, 'repository')), output = join(root, 'trust') | |
| 14 | const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'] | |
| 15 | const enrolled = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags]).withTimeout(20000) | |
| 16 | expect(enrolled.exitCode).toBe(0) | |
| 17 | expect(enrolled.timedOut).toBe(false) | |
| 18 | const value = JSON.parse(enrolled.stdout) | |
| 19 | expect(value.policy.minimumReleaseSequence).toBe(7) | |
| 20 | expect(value.metadataState).toBe('initial-root-only') | |
| 21 | expect(value.firmwareSelectionPerformed).toBe(false) | |
| 22 | const inspected = await command(['updates:trust', output, '--enrollment-sha256', value.enrollmentSha256]).withTimeout(20000) | |
| 23 | expect(inspected.exitCode).toBe(0) | |
| 24 | expect(JSON.parse(inspected.stdout)).toEqual(value) | |
| 25 | const rejected = await command(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)]).withTimeout(20000) | |
| 26 | expect(rejected.exitCode).toBe(1) | |
| 27 | expect(rejected.stdout).toBe('') | |
| 28 | expect(rejected.stderr).toBe('Runtime trust unavailable: invalid enrollment pin or protected state\n') | |
| 29 | const missing = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', join(root, 'missing-policy')]).withTimeout(20000) | |
| 30 | expect(missing.exitCode).toBe(1) | |
| 31 | expect(missing.stdout).toBe('') | |
| 32 | expect(await Bun.file(join(root, 'missing-policy/enrollment.json')).exists()).toBe(false) | |
| 33 | }, 100000) | |
| @@ -0,0 +1,153 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { spawnSync } from 'node:child_process' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { digest, platform, repository, target } from '../fixtures/updates/repository' | |
| 7 | import type { RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging' | |
| 8 | import { enrollRuntimeTrust, inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 9 | ||
| 10 | const roots: string[] = [] | |
| 11 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 12 | ||
| 13 | test('independently pinned root and release policy can be enrolled and verified after readback', async () => { | |
| 14 | const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root) | |
| 15 | const fixture = await repository(join(root, 'repository')), output = join(root, 'trust') | |
| 16 | const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy | |
| 17 | const result = enrollRuntimeTrust({ ...policy, trustedRoot: fixture.rootPath, output }) | |
| 18 | expect(result.policy).toEqual(policy) | |
| 19 | expect(result.rootVersion).toBe(1) | |
| 20 | expect(result.metadataState).toBe('initial-root-only') | |
| 21 | expect(result.metadataRefreshPerformed).toBe(false) | |
| 22 | expect(result.firmwareSelectionPerformed).toBe(false) | |
| 23 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 24 | expect(result.enrollmentSha256).toBe(digest(await readFile(join(output, 'enrollment.json')))) | |
| 25 | expect(await readFile(join(output, 'root.json'))).toEqual(await readFile(fixture.rootPath)) | |
| 26 | expect(inspectRuntimeTrust({ directory: output, enrollmentSha256: result.enrollmentSha256 })).toEqual(result) | |
| 27 | expect((await readdir(output)).sort()).toEqual(['enrollment.json', 'root.json']) | |
| 28 | expect((await stat(output)).mode & 0o777).toBe(0o700) | |
| 29 | expect((await stat(join(output, 'enrollment.json'))).mode & 0o777).toBe(0o600) | |
| 30 | expect((await stat(join(output, 'root.json'))).mode & 0o777).toBe(0o600) | |
| 31 | }) | |
| 32 | ||
| 33 | ||
| 34 | test('trust readback requires an owned parent protected from other principals', async () => { | |
| 35 | const root = await mkdtemp(join(tmpdir(), 'drone-trust-parent-')); roots.push(root) | |
| 36 | const fixture = await repository(join(root, 'repository')), directory = join(root, 'trust') | |
| 37 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: directory }) | |
| 38 | await chmod(root, 0o777) | |
| 39 | try { expect(() => inspectRuntimeTrust({ directory, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() } | |
| 40 | finally { await chmod(root, 0o700) } | |
| 41 | }) | |
| 42 | ||
| 43 | ||
| 44 | async function setup(options: Parameters<typeof repository>[1] = {}) { | |
| 45 | const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root) | |
| 46 | const fixture = await repository(join(root, 'repository'), options), output = join(root, 'trust') | |
| 47 | return { root, fixture, output, input: { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output } satisfies Parameters<typeof enrollRuntimeTrust>[0] } | |
| 48 | } | |
| 49 | ||
| 50 | test('bad pins, unsafe policy and malformed roots cannot complete enrollment', async () => { | |
| 51 | const item = await setup() | |
| 52 | for (const [i, change] of [{ trustedRootSha256: '0'.repeat(64) }, { trustedRootSha256: '' }, { minimumReleaseSequence: 0 }, { minimumReleaseSequence: Number.NaN }, { minimumReleaseSequence: 7.5 }, { target: '../image.bin' }].entries()) { | |
| 53 | const output = join(item.root, `invalid-${i}`) | |
| 54 | expect(() => enrollRuntimeTrust({ ...item.input, ...change, output })).toThrow() | |
| 55 | expect(await Bun.file(join(output, 'enrollment.json')).exists()).toBe(false) | |
| 56 | } | |
| 57 | for (const options of [{ nonCanonicalRoot: true }, { metadataFields: { root: { expires: 'not-a-date' } } }, { metadataFields: { root: { version: 0 } } }]) { | |
| 58 | const bad = await setup(options) | |
| 59 | expect(() => enrollRuntimeTrust(bad.input)).toThrow() | |
| 60 | expect(await Bun.file(join(bad.output, 'enrollment.json')).exists()).toBe(false) | |
| 61 | } | |
| 62 | }) | |
| 63 | ||
| 64 | test('a pinned root still requires its authorized self-signatures', async () => { | |
| 65 | const item = await setup(), root = JSON.parse(await readFile(item.fixture.rootPath, 'utf8')) | |
| 66 | root.signatures[0].sig = '0'.repeat(128) | |
| 67 | const bytes = Buffer.from(JSON.stringify(root)) | |
| 68 | await writeFile(item.fixture.rootPath, bytes) | |
| 69 | expect(() => enrollRuntimeTrust({ ...item.input, trustedRootSha256: digest(bytes) })).toThrow() | |
| 70 | expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false) | |
| 71 | }) | |
| 72 | ||
| 73 | test('readback rejects a wrong independent pin, modified root, altered record and invalid schema', async () => { | |
| 74 | const item = await setup(), enrolled = enrollRuntimeTrust(item.input) | |
| 75 | const manifestPath = join(item.output, 'enrollment.json'), original = await readFile(manifestPath), record = JSON.parse(original.toString()) | |
| 76 | for (const enrollmentSha256 of ['', '0'.repeat(64)]) expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256 })).toThrow() | |
| 77 | const changed = { ...record, policy: { ...record.policy, minimumReleaseSequence: 1 } } | |
| 78 | await writeFile(manifestPath, JSON.stringify(changed)) | |
| 79 | expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() | |
| 80 | const invalid = [{ ...record, schema: 'unknown' }, { ...record, extra: true }, { ...record, enrollmentId: '../bad' }, { ...record, rootVersion: 2 }, { ...record, policy: { ...record.policy, minimumReleaseSequence: 0 } }, { ...record, policy: { ...record.policy, extra: true } }] | |
| 81 | for (const value of invalid) { | |
| 82 | const bytes = Buffer.from(JSON.stringify(value)); await writeFile(manifestPath, bytes) | |
| 83 | expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: digest(bytes) })).toThrow() | |
| 84 | } | |
| 85 | await writeFile(manifestPath, original) | |
| 86 | await writeFile(join(item.output, 'root.json'), '{}') | |
| 87 | expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() | |
| 88 | }) | |
| 89 | ||
| 90 | test('source symlinks and shared writable storage are rejected', async () => { | |
| 91 | const linked = await setup(), link = join(linked.root, 'root-link.json') | |
| 92 | await symlink(linked.fixture.rootPath, link) | |
| 93 | expect(() => enrollRuntimeTrust({ ...linked.input, trustedRoot: link })).toThrow() | |
| 94 | const shared = await setup() | |
| 95 | await chmod(shared.root, 0o777) | |
| 96 | expect(() => enrollRuntimeTrust(shared.input)).toThrow() | |
| 97 | await chmod(shared.root, 0o700) | |
| 98 | const result = enrollRuntimeTrust(shared.input) | |
| 99 | await chmod(shared.output, 0o777) | |
| 100 | expect(() => inspectRuntimeTrust({ directory: shared.output, enrollmentSha256: result.enrollmentSha256 })).toThrow() | |
| 101 | await chmod(shared.output, 0o700) | |
| 102 | }) | |
| 103 | ||
| 104 | test('an existing enrollment or partial directory is never overwritten or implicitly repaired', async () => { | |
| 105 | const item = await setup(), enrolled = enrollRuntimeTrust(item.input) | |
| 106 | const marker = await readFile(join(item.output, 'enrollment.json')) | |
| 107 | expect(() => enrollRuntimeTrust(item.input)).toThrow() | |
| 108 | expect(await readFile(join(item.output, 'enrollment.json'))).toEqual(marker) | |
| 109 | expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled) | |
| 110 | const partial = await setup(); await mkdir(partial.output) | |
| 111 | await writeFile(join(partial.output, 'root.json'), await readFile(partial.fixture.rootPath)) | |
| 112 | expect(() => enrollRuntimeTrust(partial.input)).toThrow() | |
| 113 | expect(() => inspectRuntimeTrust({ directory: partial.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() | |
| 114 | }) | |
| 115 | ||
| 116 | test('owned policy survives caller mutation and a valid expired root is only an initial anchor', async () => { | |
| 117 | const item = await setup({ metadataFields: { root: { expires: '2020-01-01T00:00:00Z' } } }) | |
| 118 | const enrolled = enrollRuntimeTrust(item.input) | |
| 119 | item.input.minimumReleaseSequence = 1 | |
| 120 | item.input.trustedRootSha256 = '0'.repeat(64) | |
| 121 | const checked = inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 }) | |
| 122 | expect(checked.policy.minimumReleaseSequence).toBe(7) | |
| 123 | expect(checked.policy.trustedRootSha256).toBe(item.fixture.rootSha256) | |
| 124 | expect(checked.metadataState).toBe('initial-root-only') | |
| 125 | expect(checked.metadataRefreshPerformed).toBe(false) | |
| 126 | expect(checked.firmwareSelectionPerformed).toBe(false) | |
| 127 | }) | |
| 128 | ||
| 129 | test('two real processes cannot enroll into the same output', async () => { | |
| 130 | const item = await setup(), worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts') | |
| 131 | const processes = [Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 }), Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 })] | |
| 132 | const results = await Promise.all(processes.map(async child => ({ exit: await child.exited, stdout: await new Response(child.stdout).text(), stderr: await new Response(child.stderr).text() }))) | |
| 133 | expect(results.map(r => r.exit).sort()).toEqual([0, 1]) | |
| 134 | const success = results.find(r => r.exit === 0)! | |
| 135 | const enrolled = JSON.parse(success.stdout) | |
| 136 | expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled) | |
| 137 | expect(results.find(r => r.exit === 1)!.stdout).toBe('') | |
| 138 | }, 60000) | |
| 139 | ||
| 140 | test('an actual child write-limit failure leaves incomplete evidence and preserves the trusted source', async () => { | |
| 141 | const item = await setup(), source = await readFile(item.fixture.rootPath) | |
| 142 | const worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts') | |
| 143 | const code = 'import os, resource, signal, sys; signal.signal(signal.SIGXFSZ, signal.SIG_IGN); resource.setrlimit(resource.RLIMIT_FSIZE, (256,256)); os.execv(sys.argv[1],sys.argv[1:])' | |
| 144 | const failed = spawnSync('python3', ['-c', code, process.execPath, worker, JSON.stringify(item.input)], { timeout: 20000, encoding: 'utf8' }) | |
| 145 | expect(failed.error).toBeUndefined() | |
| 146 | expect(failed.status).toBe(1) | |
| 147 | expect(failed.stderr).toContain('Trust enrollment rejected') | |
| 148 | expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false) | |
| 149 | expect((await stat(join(item.output, 'root.json'))).size).toBe(256) | |
| 150 | expect(await readFile(item.fixture.rootPath)).toEqual(source) | |
| 151 | expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: '0'.repeat(64) })).toThrow() | |
| 152 | expect(() => enrollRuntimeTrust(item.input)).toThrow() | |
| 153 | }, 60000) | |
| @@ -0,0 +1,261 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { basename, join } from 'node:path' | |
| 5 | import { Updater, type UpdaterOptions } from 'tuf-js' | |
| 6 | import * as Tuf from 'tuf-js' | |
| 7 | import { repository } from '../fixtures/updates/repository' | |
| 8 | ||
| 9 | const roots: string[] = [] | |
| 10 | const servers: ReturnType<typeof Bun.serve>[] = [] | |
| 11 | afterEach(async () => { | |
| 12 | for (const server of servers.splice(0)) await server.stop(true) | |
| 13 | for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) | |
| 14 | }) | |
| 15 | ||
| 16 | async function setup(options: Parameters<typeof repository>[1] = { rotate: true }, missing?: string) { | |
| 17 | const root = await mkdtemp(join(tmpdir(), 'drone-sdk-test-')); roots.push(root) | |
| 18 | const repo = join(root, 'repo'), cache = join(root, 'cache'), requests: string[] = [] | |
| 19 | const fixture = await repository(repo, options) | |
| 20 | await mkdir(cache, { mode: 0o700 }) | |
| 21 | await copyFile(fixture.rootPath, join(cache, 'root.json')) | |
| 22 | const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) { | |
| 23 | const path = new URL(request.url).pathname | |
| 24 | requests.push(path) | |
| 25 | if (!/^\/metadata\/(?:[0-9]+\.)?(?:root|timestamp|snapshot|targets)\.json$/.test(path) || basename(path) === missing) return new Response(null, { status: 404 }) | |
| 26 | const file = Bun.file(join(repo, 'metadata', basename(path))) | |
| 27 | return await file.exists() ? new Response(file) : new Response(null, { status: 404 }) | |
| 28 | } }); servers.push(server) | |
| 29 | const input = { metadataDir: cache, metadataBaseUrl: `http://127.0.0.1:${server.port}/metadata`, config: { fetchRetries: 0, fetchTimeout: 1000 } } satisfies UpdaterOptions | |
| 30 | return { root, repo, cache, fixture, requests, input } | |
| 31 | } | |
| 32 | ||
| 33 | const roles = ['root', 'timestamp', 'snapshot', 'targets'] as const | |
| 34 | ||
| 35 | test('the maintained SDK observer receives exact verified bytes before each cache write', async () => { | |
| 36 | const item = await setup(), seen: string[] = [] | |
| 37 | const updater = new Updater({ ...item.input, async onVerifiedMetadata(role, bytes) { | |
| 38 | seen.push(role) | |
| 39 | if (role === 'root') expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1) | |
| 40 | else expect(await Bun.file(join(item.cache, `${role}.json`)).exists()).toBe(false) | |
| 41 | const file = role === 'root' ? '2.root.json' : role === 'timestamp' ? 'timestamp.json' : `1.${role}.json` | |
| 42 | expect(bytes).toEqual(await readFile(join(item.repo, 'metadata', file))) | |
| 43 | } }) | |
| 44 | await updater.refresh() | |
| 45 | expect(seen).toEqual([...roles]) | |
| 46 | expect((await readdir(item.cache)).sort()).toEqual(['root.json', 'snapshot.json', 'targets.json', 'timestamp.json']) | |
| 47 | }) | |
| 48 | ||
| 49 | test('a pending observer prevents the cache write and the next metadata request', async () => { | |
| 50 | const item = await setup() | |
| 51 | let release!: () => void, entered!: () => void | |
| 52 | const gate = new Promise<void>(resolve => { release = resolve }) | |
| 53 | const seen = new Promise<void>(resolve => { entered = resolve }) | |
| 54 | const pending = new Updater({ ...item.input, async onVerifiedMetadata(role) { | |
| 55 | if (role === 'root') { entered(); await gate } | |
| 56 | } }).refresh() | |
| 57 | try { | |
| 58 | await seen | |
| 59 | expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1) | |
| 60 | expect(item.requests).toEqual(['/metadata/2.root.json']) | |
| 61 | } | |
| 62 | finally { release(); await pending } | |
| 63 | }) | |
| 64 | ||
| 65 | for (const failedRole of roles) { | |
| 66 | test(`an observer rejection for ${failedRole} aborts before that cache or a later role advances`, async () => { | |
| 67 | const item = await setup(), seen: string[] = [], cause = new Error('protected persistence rejected') | |
| 68 | const updater = new Updater({ ...item.input, async onVerifiedMetadata(role) { | |
| 69 | seen.push(role) | |
| 70 | if (role === failedRole) throw cause | |
| 71 | } }) | |
| 72 | const failure = await updater.refresh().then(() => undefined, error => error) | |
| 73 | const persistence = failedRole === 'snapshot' || failedRole === 'targets' ? failure.cause : failure | |
| 74 | expect(persistence).toBeInstanceOf(Tuf.PersistError) | |
| 75 | expect(persistence.message).toBe(`Failed to persist metadata ${failedRole}`) | |
| 76 | expect(persistence.cause).toBe(cause) | |
| 77 | expect(seen).toEqual(roles.slice(0, roles.indexOf(failedRole) + 1)) | |
| 78 | if (failedRole === 'root') expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1) | |
| 79 | else expect(await Bun.file(join(item.cache, `${failedRole}.json`)).exists()).toBe(false) | |
| 80 | }) | |
| 81 | } | |
| 82 | ||
| 83 | test('a genuine SDK HTTP404 thrown by the observer cannot be mistaken for the end of root rotation', async () => { | |
| 84 | const source = await setup({ rotate: false }, 'timestamp.json') | |
| 85 | const downloadFailure = await new Updater(source.input).refresh().then(() => { throw new Error('Missing metadata unexpectedly succeeded') }, error => error) | |
| 86 | expect(downloadFailure.statusCode).toBe(404) | |
| 87 | const item = await setup(), seen: string[] = [] | |
| 88 | await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role); throw downloadFailure } }).refresh()).rejects.toThrow('Failed to persist metadata root') | |
| 89 | expect(seen).toEqual(['root']) | |
| 90 | expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1) | |
| 91 | }) | |
| 92 | ||
| 93 | test('the observer owns a byte copy and cannot mutate SDK verification or cache bytes', async () => { | |
| 94 | const item = await setup(), observed: Record<string, Buffer> = {} | |
| 95 | await new Updater({ ...item.input, onVerifiedMetadata(role, bytes) { observed[role] = Buffer.from(bytes); bytes.fill(0) } }).refresh() | |
| 96 | for (const role of roles) expect((await readFile(join(item.cache, `${role}.json`))).equals(observed[role]!)).toBe(true) | |
| 97 | }) | |
| 98 | ||
| 99 | test('an unauthorized root never reaches the observer and leaves the initial cache intact', async () => { | |
| 100 | const item = await setup({ rotate: true, badRotation: true }), seen: string[] = [] | |
| 101 | await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()).rejects.toThrow() | |
| 102 | expect(seen).toEqual([]) | |
| 103 | expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1) | |
| 104 | }) | |
| 105 | ||
| 106 | test('a later invalid target signature preserves only the earlier verified metadata observations', async () => { | |
| 107 | const item = await setup({ rotate: true, badTargetsSignature: true }), seen: string[] = [] | |
| 108 | await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()).rejects.toThrow() | |
| 109 | expect(seen).toEqual(['root', 'timestamp', 'snapshot']) | |
| 110 | expect(await Bun.file(join(item.cache, 'targets.json')).exists()).toBe(false) | |
| 111 | }) | |
| 112 | ||
| 113 | test('snapshot-bound tampering is rejected before it reaches the observer', async () => { | |
| 114 | const item = await setup(), seen: string[] = [] | |
| 115 | await writeFile(join(item.repo, 'metadata/1.targets.json'), '{}') | |
| 116 | await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()).rejects.toThrow() | |
| 117 | expect(seen).toEqual(['root', 'timestamp', 'snapshot']) | |
| 118 | }) | |
| 119 | ||
| 120 | test('the supplied fixed start time is owned before callers can mutate it', async () => { | |
| 121 | const item = await setup(), time = new Date('2030-01-01T00:00:00Z') | |
| 122 | const updater = new Updater({ ...item.input, referenceTime: time }) | |
| 123 | time.setUTCFullYear(2040) | |
| 124 | await updater.refresh() | |
| 125 | expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(2) | |
| 126 | }) | |
| 127 | ||
| 128 | test('fixed-time recovery replay is distinct from current-time authorization', async () => { | |
| 129 | const past = await setup({ expires: '2025-01-01T00:00:00Z' }) | |
| 130 | await new Updater({ ...past.input, referenceTime: new Date('2024-01-01T00:00:00Z') }).refresh() | |
| 131 | const current = await setup({ expires: '2025-01-01T00:00:00Z' }) | |
| 132 | const failure = await new Updater(current.input).refresh().then(() => undefined, error => error) | |
| 133 | expect(failure).toBeInstanceOf(Tuf.MetadataVerificationError) | |
| 134 | expect(failure.role).toBe('timestamp') | |
| 135 | expect(failure.cause).toBeInstanceOf(Error) | |
| 136 | expect(failure.cause.message).toContain('expired') | |
| 137 | }) | |
| 138 | ||
| 139 | test('invalid fixed time is rejected before loading even a missing bootstrap root', () => { | |
| 140 | expect(() => new Updater({ metadataDir: '/missing-drone-sdk-bootstrap', metadataBaseUrl: 'http://127.0.0.1:1', referenceTime: new Date(Number.NaN) })).toThrow('Invalid fixed update reference time') | |
| 141 | }) | |
| 142 | ||
| 143 | ||
| 144 | test('a fully cached trusted refresh does not emit observations for cache bytes', async () => { | |
| 145 | const item = await setup() | |
| 146 | await new Updater(item.input).refresh() | |
| 147 | const seen: string[] = [] | |
| 148 | await new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh() | |
| 149 | expect(seen).toEqual([]) | |
| 150 | }) | |
| 151 | ||
| 152 | test('an authenticated intermediate root is observed before final-root expiry rejects the refresh', async () => { | |
| 153 | const item = await setup({ rotate: true, metadataFields: { rotatedRoot: { expires: '2020-01-01T00:00:00Z' } } }), seen: string[] = [] | |
| 154 | const failure = await new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh().then(() => undefined, error => error) | |
| 155 | expect(failure).toBeInstanceOf(Tuf.MetadataVerificationError) | |
| 156 | expect(failure.role).toBe('timestamp') | |
| 157 | expect(failure.cause).toBeInstanceOf(Error) | |
| 158 | expect(failure.cause.message).toContain('expired') | |
| 159 | expect(seen).toEqual(['root']) | |
| 160 | expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(2) | |
| 161 | }) | |
| 162 | ||
| 163 | ||
| 164 | test('forceCache cannot swallow a rejected remote timestamp observer when its local cache is missing', async () => { | |
| 165 | const item = await setup({ rotate: false }), seen: string[] = [] | |
| 166 | await expect(new Updater({ ...item.input, forceCache: true, onVerifiedMetadata(role) { | |
| 167 | seen.push(role) | |
| 168 | if (role === 'timestamp') throw new Error('protected timestamp persistence rejected') | |
| 169 | } }).refresh()).rejects.toThrow('Failed to persist metadata timestamp') | |
| 170 | expect(seen).toEqual(['timestamp']) | |
| 171 | expect(item.requests).toEqual(['/metadata/timestamp.json']) | |
| 172 | expect((await readdir(item.cache)).sort()).toEqual(['root.json']) | |
| 173 | }) | |
| 174 | ||
| 175 | ||
| 176 | test('a signed metadata rejection retains its original typed cause through the SDK wrapper', async () => { | |
| 177 | const item = await setup({ badTargetsSignature: true }) | |
| 178 | const failure = await new Updater(item.input).refresh().then(() => undefined, error => error) | |
| 179 | expect(failure).toBeInstanceOf(Error) | |
| 180 | expect(failure.cause).toBeInstanceOf(Error) | |
| 181 | }) | |
| 182 | ||
| 183 | ||
| 184 | test('recovery can identify a remote metadata rejection through a public typed cause', async () => { | |
| 185 | const item = await setup({ badTargetsSignature: true }) | |
| 186 | const failure = await new Updater(item.input).refresh().then(() => undefined, error => error) | |
| 187 | expect(Tuf.MetadataVerificationError).toBeFunction() | |
| 188 | expect(failure.cause).toBeInstanceOf(Tuf.MetadataVerificationError) | |
| 189 | expect(failure.cause.role).toBe('targets') | |
| 190 | }) | |
| 191 | ||
| 192 | ||
| 193 | for (const role of roles) { | |
| 194 | test(`remote ${role} verification failure has a typed role and original cause`, async () => { | |
| 195 | const options: Parameters<typeof repository>[1] = role === 'root' ? { rotate: true, badRotation: true } : { metadataFields: { [role]: { expires: '2020-01-01T00:00:00Z' } } } | |
| 196 | const item = await setup(options) | |
| 197 | const failure = await new Updater(item.input).refresh().then(() => undefined, error => error) | |
| 198 | const verification = failure instanceof Tuf.MetadataVerificationError ? failure : failure.cause | |
| 199 | expect(verification).toBeInstanceOf(Tuf.MetadataVerificationError) | |
| 200 | expect(verification.role).toBe(role) | |
| 201 | expect(verification.cause).toBeInstanceOf(Error) | |
| 202 | }) | |
| 203 | } | |
| 204 | ||
| 205 | test('observer failure stays a persistence failure and preserves its exact cause', async () => { | |
| 206 | const item = await setup(), cause = new Error('protected storage failure') | |
| 207 | const failure = await new Updater({ ...item.input, onVerifiedMetadata() { throw cause } }).refresh().then(() => undefined, error => error) | |
| 208 | expect(failure).toBeInstanceOf(Tuf.PersistError) | |
| 209 | expect(failure).not.toBeInstanceOf(Tuf.MetadataVerificationError) | |
| 210 | expect(failure.cause).toBe(cause) | |
| 211 | expect(item.requests).toEqual(['/metadata/2.root.json']) | |
| 212 | }) | |
| 213 | ||
| 214 | test('an observer-supplied verification error is still classified as failed persistence first', async () => { | |
| 215 | const item = await setup({ rotate: false }), cause = new Tuf.MetadataVerificationError('timestamp', 'caller error, not an SDK verification verdict') | |
| 216 | const failure = await new Updater({ ...item.input, forceCache: true, onVerifiedMetadata() { throw cause } }).refresh().then(() => undefined, error => error) | |
| 217 | expect(failure).toBeInstanceOf(Tuf.PersistError) | |
| 218 | expect(failure.cause).toBe(cause) | |
| 219 | expect(item.requests).toEqual(['/metadata/timestamp.json']) | |
| 220 | }) | |
| 221 | ||
| 222 | test('the public HTTP failure class matches actual missing-metadata failures', async () => { | |
| 223 | const item = await setup({ rotate: false }, 'timestamp.json') | |
| 224 | const failure = await new Updater(item.input).refresh().then(() => undefined, error => error) | |
| 225 | expect(failure).toBeInstanceOf(Tuf.DownloadHTTPError) | |
| 226 | expect(failure).toBeInstanceOf(Tuf.DownloadError) | |
| 227 | expect(failure.statusCode).toBe(404) | |
| 228 | expect(failure).not.toBeInstanceOf(Tuf.MetadataVerificationError) | |
| 229 | }) | |
| 230 | ||
| 231 | test('a Symbol observer rejection remains a persistence failure under forceCache', async () => { | |
| 232 | const item = await setup({ rotate: false }), seen: string[] = [], cause = Symbol('opaque storage failure') | |
| 233 | const failure = await new Updater({ ...item.input, forceCache: true, onVerifiedMetadata(role) { | |
| 234 | seen.push(role) | |
| 235 | if (role === 'timestamp') throw cause | |
| 236 | } }).refresh().then(() => undefined, error => error) | |
| 237 | expect(failure).toBeInstanceOf(Tuf.PersistError) | |
| 238 | expect(failure.cause).toBe(cause) | |
| 239 | expect(seen).toEqual(['timestamp']) | |
| 240 | expect(item.requests).toEqual(['/metadata/timestamp.json']) | |
| 241 | expect((await readdir(item.cache)).sort()).toEqual(['root.json']) | |
| 242 | }) | |
| 243 | ||
| 244 | test('observer failures are never coerced while wrapping any metadata role', async () => { | |
| 245 | for (const failedRole of roles) { | |
| 246 | const item = await setup(), seen: string[] = [] | |
| 247 | let coercions = 0 | |
| 248 | const cause = { toString() { coercions++; throw new Error('Cause coercion must not run') } } | |
| 249 | const failure = await new Updater({ ...item.input, onVerifiedMetadata(role) { | |
| 250 | seen.push(role) | |
| 251 | if (role === failedRole) throw cause | |
| 252 | } }).refresh().then(() => undefined, error => error) | |
| 253 | const persistence = failedRole === 'snapshot' || failedRole === 'targets' ? failure.cause : failure | |
| 254 | expect(persistence).toBeInstanceOf(Tuf.PersistError) | |
| 255 | expect(persistence.cause).toBe(cause) | |
| 256 | expect(coercions).toBe(0) | |
| 257 | expect(seen).toEqual(roles.slice(0, roles.indexOf(failedRole) + 1)) | |
| 258 | if (failedRole === 'root') expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1) | |
| 259 | else expect(await Bun.file(join(item.cache, `${failedRole}.json`)).exists()).toBe(false) | |
| 260 | } | |
| 261 | }) | |