also looking at this
feat: prepare runtime images with protected trust
#47
27 files
+1675
-73
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -38,6 +38,8 @@ jobs: | ||
| 38 | 38 | run: bun tests/fixtures/updates/qualify-trust.ts "$RUNNER_TEMP/runtime-trust-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" |
| 39 | 39 | - name: Verify typed SDK failures in a minified copied standalone executable |
| 40 | 40 | run: bun tests/fixtures/updates/qualify-sdk.ts "$RUNNER_TEMP/runtime-sdk-qualification" |
| 41 | - name: Verify persistent metadata and recovery with a copied standalone CLI | |
| 42 | run: bun tests/fixtures/updates/qualify-store.ts "$RUNNER_TEMP/runtime-store-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" | |
| 41 | 43 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 42 | 44 | working-directory: ${{ runner.temp }} |
| 43 | 45 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { recoverRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:recover <directory>', | |
| 8 | description: 'Replay exact durable metadata inputs to retain floors, without selecting or installing firmware', | |
| 9 | options: storeReferenceFlags, | |
| 10 | async handle(options, directory) { | |
| 11 | try { await printAsync(JSON.stringify(await recoverRuntimeTrustStore(storeCommandReference(options, directory)))) } | |
| 12 | catch { await storeCommandFailure() } | |
| 13 | }, | |
| 14 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:refresh <directory>', | |
| 8 | description: 'Verify offline metadata at an independently trusted system time and retain its accepted prefix', | |
| 9 | options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline metadata repository', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' }, | |
| 10 | async handle(options, directory) { | |
| 11 | try { | |
| 12 | if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository and independently trusted system-clock source required') | |
| 13 | await printAsync(JSON.stringify(await refreshRuntimeTrustStore({ ...storeCommandReference(options, directory), repository: options.repository, clock: { source: options.trustedSystemClockSource, read: () => new Date() } }))) | |
| 14 | } | |
| 15 | catch { await storeCommandFailure() } | |
| 16 | }, | |
| 17 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { inspectRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:store <directory>', | |
| 8 | description: 'Inspect independently pinned metadata state under writer exclusion', | |
| 9 | options: storeReferenceFlags, | |
| 10 | async handle(options, directory) { | |
| 11 | try { await printAsync(JSON.stringify(await inspectRuntimeTrustStore(storeCommandReference(options, directory)))) } | |
| 12 | catch { await storeCommandFailure() } | |
| 13 | }, | |
| 14 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandEnrollment, storeCommandFailure, storeEnrollmentFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:store-init', | |
| 8 | description: 'Create a new independently bound metadata protocol store without selecting firmware', | |
| 9 | options: { ...storeEnrollmentFlags, '--output <path>': 'New exclusive private protocol-state directory' }, | |
| 10 | async handle(options) { | |
| 11 | try { | |
| 12 | if (typeof options.output !== 'string') throw new Error('Explicit new store output required') | |
| 13 | await printAsync(JSON.stringify(await createRuntimeTrustStore({ ...storeCommandEnrollment(options), output: options.output }))) | |
| 14 | } | |
| 15 | catch { await storeCommandFailure() } | |
| 16 | }, | |
| 17 | }) | |
| @@ -23,6 +23,10 @@ import UpdatesStage from '../app/Commands/UpdatesStage' | ||
| 23 | 23 | import UpdatesInspect from '../app/Commands/UpdatesInspect' |
| 24 | 24 | import UpdatesEnroll from '../app/Commands/UpdatesEnroll' |
| 25 | 25 | import UpdatesTrust from '../app/Commands/UpdatesTrust' |
| 26 | import UpdatesStoreInit from '../app/Commands/UpdatesStoreInit' | |
| 27 | import UpdatesStore from '../app/Commands/UpdatesStore' | |
| 28 | import UpdatesRefresh from '../app/Commands/UpdatesRefresh' | |
| 29 | import UpdatesRecover from '../app/Commands/UpdatesRecover' | |
| 26 | 30 | |
| 27 | 31 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 28 | 32 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -89,6 +93,10 @@ UpdatesStage(app) | ||
| 89 | 93 | UpdatesInspect(app) |
| 90 | 94 | UpdatesEnroll(app) |
| 91 | 95 | UpdatesTrust(app) |
| 96 | UpdatesStoreInit(app) | |
| 97 | UpdatesStore(app) | |
| 98 | UpdatesRefresh(app) | |
| 99 | UpdatesRecover(app) | |
| 92 | 100 | MissionsSimulate(app) |
| 93 | 101 | MissionsValidate(app) |
| 94 | 102 | MissionsReplay(app) |
| @@ -0,0 +1,53 @@ | ||
| 1 | # Runtime trust store protocol | |
| 2 | ||
| 3 | Status: implementation in progress for #42. This decision does not qualify deployment. | |
| 4 | ||
| 5 | The update coordinator needs a private, independently bound state directory. An | |
| 6 | immutable initial enrollment remains the anchor. A separately pinned store identity | |
| 7 | binds its mutable protocol state to that enrollment. Missing or corrupt existing | |
| 8 | state is an error, never a reason to initialize a replacement or lower a floor. | |
| 9 | ||
| 10 | Use Bun's built-in SQLite for this protocol file. This is a deliberate exception | |
| 11 | to model-derived application migrations: these records are a bounded local update | |
| 12 | protocol, not application CRUD. The installed Stacks database factory uses global | |
| 13 | configuration and its query facade resolves that configuration lazily. It does not | |
| 14 | expose the isolated synchronous connection and commit ordering this protocol needs. | |
| 15 | Native Stacks commands remain the entrypoints. No generic framework abstraction is | |
| 16 | introduced for this single consumer, and no application database is reconfigured. | |
| 17 | ||
| 18 | Two databases have separate responsibilities. A writer database holds an immediate | |
| 19 | transaction across awaited work, excluding another coordinator. The protocol | |
| 20 | database commits inputs and accepted metadata independently while that lease is | |
| 21 | held. Both use a rollback journal, FULL synchronous commits, fullfsync where the | |
| 22 | host supports it, zero busy timeout and verified configuration. The file format | |
| 23 | has an application ID, version, exact schema and explicit size/count limits. | |
| 24 | Existing databases are opened without creation. Symlinks, shared writable paths, | |
| 25 | wrong ownership, extra hard links and unexpected schema are rejected. | |
| 26 | ||
| 27 | Before an SDK fetch returns metadata, commit the exact bounded input or missing | |
| 28 | response. Before its private cache write, commit each accepted metadata observation | |
| 29 | and its high-water state. The cache is disposable; the anchor and protocol history | |
| 30 | are not. Every attempt records its base generation and independently supplied fixed | |
| 31 | wall time. New attempts cannot skip an unresolved attempt. Historical recovery | |
| 32 | replays exact recorded inputs at that original time and can only retain floors. | |
| 33 | Only a new verification at a trusted current time can authorize candidate use. | |
| 34 | Unknown verification, crypto, persistence or resource failures remain maintenance | |
| 35 | conditions. Operational error types alone do not prove invalid input. | |
| 36 | ||
| 37 | Root-authorized timestamp or snapshot key rotation starts a new active metadata | |
| 38 | epoch and invalidates both working-cache files, as required by TUF 5.3.11. Retain | |
| 39 | the previous epochs and their floors. The global release-security floor never | |
| 40 | decreases across an epoch change. Root rotation alone, without either authority | |
| 41 | change, does not reset role floors. | |
| 42 | ||
| 43 | The schema and recovery implementation must be exercised with signed repositories, | |
| 44 | actual competing processes, SIGKILL cuts around input/prefix commits, corrupt and | |
| 45 | missing files, clock reversal, and real host storage failures. SQLite commit or | |
| 46 | process-crash evidence is not measured physical power-loss durability. Same-user | |
| 47 | privileged disk rollback and malicious kernel/filesystem behavior require separate | |
| 48 | hardware protection and are outside this host protocol's claims. | |
| 49 | ||
| 50 | Installed, candidate and known-good slots, authenticated image integration and | |
| 51 | health confirmation bound to an exact image and boot/session identity remain part | |
| 52 | of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 53 | bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9. | |
| @@ -1,10 +1,11 @@ | ||
| 1 | 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, 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. | |
| 4 | staging, independent candidate verification, protected initial trust enrollment | |
| 5 | and a persistent host metadata protocol. These commands prepare and verify host | |
| 6 | state. Integrating that protocol with authenticated image staging, revocation, | |
| 7 | installed images, boot selection and known-good rollback slots remains part of | |
| 8 | the full coordinator. Physical deployment is disabled. | |
| 8 | 9 | |
| 9 | 10 | ## Explicit trust and policy |
| 10 | 11 | |
| @@ -78,10 +79,109 @@ Keep this immutable anchor separate from an SDK working cache. The current SDK | ||
| 78 | 79 | writes cache files in place; a host write-limit probe truncated its cached root. |
| 79 | 80 | Never refresh against the enrollment directory. Staging/inspection still require |
| 80 | 81 | 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). | |
| 82 | The metadata-store functions below retain verified metadata/release floors. | |
| 83 | The complete key lifecycle, storage-fault campaigns, image integration and | |
| 84 | known-good coordinator remain in [issue #42](https://github.com/stacksjs/drone/issues/42). | |
| 83 | 85 | Do not discard or re-enroll over failed state to reset a security floor. |
| 84 | 86 | |
| 87 | ## Persistent host metadata protocol | |
| 88 | ||
| 89 | Initialize a separate new private directory and retain its returned `storeSha256` | |
| 90 | independently, alongside the enrollment pin: | |
| 91 | ||
| 92 | ```sh | |
| 93 | ./buddy updates:store-init --enrollment /path/to/trust \ | |
| 94 | --enrollment-sha256 "$ENROLLMENT_SHA256" --output /path/to/new-store | |
| 95 | ||
| 96 | ./buddy updates:store /path/to/new-store --enrollment /path/to/trust \ | |
| 97 | --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" | |
| 98 | ||
| 99 | ./buddy updates:refresh /path/to/new-store --enrollment /path/to/trust \ | |
| 100 | --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \ | |
| 101 | --repository /path/to/offline-repository \ | |
| 102 | --trusted-system-clock-source operator-managed-utc-clock | |
| 103 | ||
| 104 | ./buddy updates:recover /path/to/new-store --enrollment /path/to/trust \ | |
| 105 | --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" | |
| 106 | ``` | |
| 107 | ||
| 108 | Native Buddy and the copied standalone CLI use the same functions under | |
| 109 | `resources/functions/drone/updates/store.ts`. The independent store pin binds an | |
| 110 | immutable identity to the authenticated enrollment. It is not a hash of the | |
| 111 | mutable database or a candidate receipt. Wrong pins, existing initialization | |
| 112 | outputs, missing/corrupt files, foreign schemas, unexpected entries, symlinks, | |
| 113 | extra hard links and unsafe ownership/permissions fail closed. Existing files | |
| 114 | are never recreated as a way to recover a floor. See the | |
| 115 | [protocol decision](adr/0028-runtime-trust-store.md). | |
| 116 | ||
| 117 | `writer.sqlite` holds a transaction across awaited work. `state.sqlite` commits | |
| 118 | each input and accepted metadata prefix independently while that writer is | |
| 119 | excluded. The exact schema, application ID and version are checked before | |
| 120 | configuration that could rewrite a foreign database's format. Both connections | |
| 121 | require DELETE journals, FULL synchronous commits and configuration readback. | |
| 122 | SQLite and directory sync evidence concerns this host, not electrical power loss. | |
| 123 | ||
| 124 | An attempt records its base generation, clock source and fixed start time. Before | |
| 125 | the SDK receives an input, its bounded bytes or missing-file response are committed | |
| 126 | and read back. The awaited SDK observer commits accepted metadata before its | |
| 127 | private cache write. Later verification, compatibility or output failure does not | |
| 128 | discard that prefix. Working caches are separate disposable temporary directories. | |
| 129 | Progress-callback rejection aborts refresh and preserves its original cause. A | |
| 130 | callback's HTTP error cannot stand in for an absent repository file. | |
| 131 | `generation` and `retainedMetadataRecords` describe the append-only metadata history; | |
| 132 | status lists the active roles and the latest attempt. | |
| 133 | ||
| 134 | The global `releaseFloor` starts with enrolled policy and advances from the selected | |
| 135 | compatible target's verified signed declaration. Refresh verifies metadata and | |
| 136 | declared compatibility; it does not download or authenticate the actual image | |
| 137 | bytes. An attempt marked `verified` means this metadata operation completed. It | |
| 138 | does not mean an image is staged, installed, booted or confirmed healthy. Existing | |
| 139 | `updates:stage` and `updates:inspect` remain separate and still report | |
| 140 | `trustStatePersisted: false`; integrating them with this store is unfinished. | |
| 141 | ||
| 142 | Timestamp or snapshot authority rotation, authenticated through sequential root | |
| 143 | metadata, creates a new active epoch and invalidates both working-cache entries. | |
| 144 | Historical epochs remain in the database. A test changes only the snapshot key, | |
| 145 | keeps the timestamp key, accepts metadata counters dropping from 100 to 1 under | |
| 146 | the new authority, and still rejects a release below the global release floor. | |
| 147 | Changing only the root signing key does not reset active role floors. | |
| 148 | ||
| 149 | ### Clock and interrupted attempts | |
| 150 | ||
| 151 | The function interface requires a `RuntimeUpdateClock` with a source label and | |
| 152 | `read(): Date`. The adapter must obtain independently trusted current wall time; | |
| 153 | there is no repository-supplied time or default adapter. Read once, copy that | |
| 154 | instant, and reject a decrease from the persisted clock floor. The native command | |
| 155 | uses the system UTC clock only with the explicit source option above. That name | |
| 156 | is an audit label, not an attestation that the system clock is synchronized or | |
| 157 | protected. Configure and verify that trust externally before invoking it. Trusted | |
| 158 | clock acquisition and hardware clock/storage rollback protection remain separate | |
| 159 | requirements. Control-loop freshness keeps its own monotonic clock. | |
| 160 | ||
| 161 | A pending or maintenance attempt blocks another refresh. Recovery reconstructs | |
| 162 | its exact base cache, replays only durable inputs at the recorded historical time, | |
| 163 | and compares every earlier accepted observation with its committed bytes. It can | |
| 164 | finish at an owned pre-delivery end-of-recorded-input marker, or after complete | |
| 165 | verification followed by a known rejection from our explicit manifest policy. | |
| 166 | The latter retains all metadata and the release floor without crediting a rejected | |
| 167 | release. Signature, crypto, input-integrity, persistence and unknown SDK errors do | |
| 168 | not become permission to discard a journal: they remain maintenance conditions. | |
| 169 | No automatic repair for those conditions or corrupt state is supplied yet. | |
| 170 | Preserve the directory for investigation. | |
| 171 | ||
| 172 | Recovery can advance floors from a previously journaled valid input that never | |
| 173 | reached the SDK before interruption. Its `recovered` status never authorizes an | |
| 174 | image; a new trusted-current-time verification is required. Host tests perform | |
| 175 | actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a | |
| 176 | competing process, restart and retain the timestamp before a fresh refresh. These | |
| 177 | cuts do not cover every write/sync boundary or actual physical power loss. | |
| 178 | ||
| 179 | The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB | |
| 180 | state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per | |
| 181 | file. Exhaustion fails closed; history is not automatically deleted or compacted. | |
| 182 | Capacity migration, the full storage-fault campaign, authenticated repair/key | |
| 183 | revocation, image/slot integration and session-bound health remain in #42. | |
| 184 | ||
| 85 | 185 | ## Repository and signed image contract |
| 86 | 186 | |
| 87 | 187 | Use a standard TUF repository with `metadata/` and `targets/` directories, |
| @@ -138,11 +238,14 @@ phase error alone does not prove that input was invalid, that a prefix is safe | ||
| 138 | 238 | to discard, or that recovery may continue. Unknown crypto, resource or other |
| 139 | 239 | causes must fail closed pending investigation. Error objects and type names do |
| 140 | 240 | not establish authenticated trust or permit firmware use. The recovery |
| 141 | coordinator and its full failure classification remain to be implemented. | |
| 241 | coordinator's broader failure classification and authenticated maintenance repair | |
| 242 | remain to be implemented. The metadata protocol above only recognizes its own | |
| 243 | pre-delivery replay marker and completed domain-policy rejection. | |
| 142 | 244 | |
| 143 | 245 | The seam alone does not persist a protected prefix or make SDK cache writes |
| 144 | 246 | 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 | |
| 247 | store and recovery coordinator in issue #42. The bounded metadata protocol above | |
| 248 | uses that seam; neither completes the full coordinator or | |
| 146 | 249 | permit flashing, boot selection or physical deployment. |
| 147 | 250 | |
| 148 | 251 | The selected target must have SHA-256, a nonzero length of at most 16 MiB and |
| @@ -190,16 +293,17 @@ treat marker existence alone as permission to install or boot it. | ||
| 190 | 293 | |
| 191 | 294 | ## Remaining update and operations acceptance |
| 192 | 295 | |
| 193 | Each verification starts from the supplied root. `trustStatePersisted: false` | |
| 194 | means this module has no protected cross-invocation metadata or release | |
| 195 | high-water state. It is not a complete anti-rollback implementation. The caller | |
| 196 | must supply the trusted release floor every time. TUF expiry uses host wall | |
| 197 | time; trusted clock acquisition and protection against clock/storage rollback | |
| 198 | remain open. Control-loop freshness still uses its separate monotonic clock. | |
| 199 | ||
| 200 | Initial enrollment is available above. Next, implement protected trust rotation | |
| 201 | and persistent metadata/release floors, an | |
| 202 | interruption-safe coordinator, installed/known-good slot integrity, explicit | |
| 296 | The separate image-stage/inspection path still starts from its supplied root. | |
| 297 | Its `trustStatePersisted: false` result means it does not use the metadata store's | |
| 298 | cross-invocation floors. The caller must supply its trusted release floor each | |
| 299 | time. Neither path is a complete anti-rollback coordinator. Trusted clock | |
| 300 | acquisition and protection against privileged clock/storage rollback remain | |
| 301 | open. Control-loop freshness still uses its separate monotonic clock. | |
| 302 | ||
| 303 | Initial enrollment and the bounded persistent metadata protocol are available | |
| 304 | above. Complete authenticated maintenance/key revocation, the full storage-fault | |
| 305 | campaign and integration with signed image staging, then installed/known-good | |
| 306 | slot integrity, explicit | |
| 203 | 307 | health confirmation and rollback without lowering security floors. Then add |
| 204 | 308 | the board bootloader/install protocol and measured power-loss, full-medium, |
| 205 | 309 | storage-latency and recovery campaigns. Local privileged filesystem tampering |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { RuntimeFirmwarePolicy } from './staging' |
| 2 | import { printErrorAsync } from '../../cli/output' | |
| 2 | 3 | |
| 3 | 4 | export const firmwarePolicyFlags = { |
| 4 | 5 | '--target <path>': 'Explicit signed target path inside the offline repository', |
| @@ -12,3 +13,23 @@ export function firmwareCommandPolicy(options: { target?: unknown, platform?: un | ||
| 12 | 13 | if (options.platform !== 'pico2-neutral-bench-v1' && options.platform !== 'host-neutral-bench-v1') throw new Error('Explicit implemented bench platform required') |
| 13 | 14 | return { target: options.target, platform: options.platform, trustedRootSha256: options.rootSha256, minimumReleaseSequence: Number(options.minimumReleaseSequence) } |
| 14 | 15 | } |
| 16 | ||
| 17 | export const storeEnrollmentFlags = { | |
| 18 | '--enrollment <path>': 'Immutable independently authenticated initial trust enrollment', | |
| 19 | '--enrollment-sha256 <hex>': 'Independently retained enrollment record SHA-256', | |
| 20 | } as const | |
| 21 | export const storeReferenceFlags = { ...storeEnrollmentFlags, '--store-sha256 <hex>': 'Independently retained update-store identity SHA-256' } as const | |
| 22 | ||
| 23 | export function storeCommandEnrollment(options: { enrollment?: unknown, enrollmentSha256?: unknown }) { | |
| 24 | if (typeof options.enrollment !== 'string' || typeof options.enrollmentSha256 !== 'string') throw new Error('Explicit enrollment and independent enrollment pin required') | |
| 25 | return { enrollmentDirectory: options.enrollment, enrollmentSha256: options.enrollmentSha256 } | |
| 26 | } | |
| 27 | export function storeCommandReference(options: { enrollment?: unknown, enrollmentSha256?: unknown, storeSha256?: unknown }, directory: string) { | |
| 28 | if (typeof options.storeSha256 !== 'string') throw new Error('Explicit independent store pin required') | |
| 29 | return { ...storeCommandEnrollment(options), directory, storeSha256: options.storeSha256 } | |
| 30 | } | |
| 31 | export async function storeCommandFailure() { | |
| 32 | process.exitCode = 1 | |
| 33 | try { await printErrorAsync('Runtime update store unavailable: invalid trust, clock, state or storage failure') } | |
| 34 | catch { /* Keep exit 1 if the diagnostic pipe also fails. */ } | |
| 35 | } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import type { TargetFile } from 'tuf-js' | |
| 2 | import { imageLimit } from './files' | |
| 3 | import type { RuntimeFirmwarePolicy } from './policy' | |
| 4 | ||
| 5 | /** A completed metadata verification can still be refused by our explicit image policy. */ | |
| 6 | export class RuntimeManifestRejected extends Error {} | |
| 7 | ||
| 8 | export function admitSignedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { | |
| 9 | const value = info.custom.drone | |
| 10 | if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RuntimeManifestRejected('Unsupported signed runtime manifest') | |
| 11 | const data = value as Record<string, unknown> | |
| 12 | const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile'] | |
| 13 | if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new RuntimeManifestRejected('Unsupported signed runtime manifest') | |
| 14 | if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new RuntimeManifestRejected('Signed runtime platform or protocol does not match policy') | |
| 15 | if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new RuntimeManifestRejected('Signed release is below the trusted minimum release sequence') | |
| 16 | if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new RuntimeManifestRejected('Invalid signed source revision') | |
| 17 | if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new RuntimeManifestRejected('Runtime image requires a bounded length and SHA-256') | |
| 18 | return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision } | |
| 19 | } | |
| @@ -5,9 +5,9 @@ import { tmpdir } from 'node:os' | ||
| 5 | 5 | import { Readable } from 'node:stream' |
| 6 | 6 | import { isDeepStrictEqual } from 'node:util' |
| 7 | 7 | import { Updater } from 'tuf-js' |
| 8 | import type { TargetFile } from 'tuf-js' | |
| 9 | 8 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files' |
| 10 | 9 | import { object, validateMetadata } from './metadata' |
| 10 | import { admitSignedRuntime } from './manifest' | |
| 11 | 11 | import { admitPolicy } from './policy' |
| 12 | 12 | import type { RuntimeFirmwarePolicy } from './policy' |
| 13 | 13 | export type { RuntimeFirmwarePolicy } from './policy' |
| @@ -30,16 +30,6 @@ export interface RuntimeFirmwareStage { | ||
| 30 | 30 | trustStatePersisted: false |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { | |
| 34 | const data = object(info.custom.drone) | |
| 35 | const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile'] | |
| 36 | if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new Error('Unsupported signed runtime manifest') | |
| 37 | if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new Error('Signed runtime platform or protocol does not match policy') | |
| 38 | if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new Error('Signed release is below the trusted minimum release sequence') | |
| 39 | if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new Error('Invalid signed source revision') | |
| 40 | if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new Error('Runtime image requires a bounded length and SHA-256') | |
| 41 | return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision } | |
| 42 | } | |
| 43 | 33 | |
| 44 | 34 | /** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */ |
| 45 | 35 | async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) { |
| @@ -81,7 +71,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string, | ||
| 81 | 71 | await updater.refresh() |
| 82 | 72 | const info = await updater.getTargetInfo(policy.target) |
| 83 | 73 | if (!info) throw new Error('Selected firmware target is absent') |
| 84 | const runtime = signedRuntime(info, policy) | |
| 74 | const runtime = admitSignedRuntime(info, policy) | |
| 85 | 75 | if (download) await updater.downloadTarget(info, download) |
| 86 | 76 | const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8'))) |
| 87 | 77 | const rootVersion = object(latest.signed).version |
| @@ -0,0 +1,405 @@ | ||
| 1 | import { Database } from 'bun:sqlite' | |
| 2 | import { randomBytes } from 'node:crypto' | |
| 3 | import { lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, unlinkSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { dirname, join, resolve } from 'node:path' | |
| 6 | import { DownloadHTTPError, Updater } from 'tuf-js' | |
| 7 | import { inspectRuntimeTrust } from './enrollment' | |
| 8 | import type { RuntimeTrustSummary } from './enrollment' | |
| 9 | import { directory, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files' | |
| 10 | import { object, validateMetadata } from './metadata' | |
| 11 | import { admitSignedRuntime, RuntimeManifestRejected } from './manifest' | |
| 12 | ||
| 13 | const applicationId = 0x44525453 | |
| 14 | const maximumGeneration = 10000 | |
| 15 | const maximumDatabaseBytes = 128 * 1024 * 1024 | |
| 16 | const activeStores = new Set<string>() | |
| 17 | const writerSchema = 'CREATE TABLE writer (id INTEGER PRIMARY KEY CHECK(id = 1), store_id TEXT NOT NULL CHECK(length(store_id) = 32)) STRICT' | |
| 18 | const schema = [ | |
| 19 | `CREATE TABLE store_state (id INTEGER PRIMARY KEY CHECK(id = 1), store_id TEXT NOT NULL CHECK(length(store_id) = 32), enrollment_sha256 TEXT NOT NULL CHECK(length(enrollment_sha256) = 64), generation INTEGER NOT NULL CHECK(generation BETWEEN 0 AND ${maximumGeneration}), release_floor INTEGER NOT NULL CHECK(release_floor BETWEEN 1 AND 9007199254740991), clock_floor_ms INTEGER NOT NULL CHECK(clock_floor_ms BETWEEN 0 AND 253402300799000), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT`, | |
| 20 | `CREATE TABLE attempts (id TEXT PRIMARY KEY CHECK(length(id) = 32), base_generation INTEGER NOT NULL, started_ms INTEGER NOT NULL CHECK(started_ms BETWEEN 1 AND 253402300799000), clock_source TEXT NOT NULL CHECK(length(clock_source) BETWEEN 1 AND 128), phase TEXT NOT NULL CHECK(phase IN ('pending', 'verified', 'recovered', 'maintenance')), accepted_release INTEGER CHECK(accepted_release BETWEEN 1 AND 9007199254740991), CHECK(phase != 'verified' OR accepted_release IS NOT NULL), FOREIGN KEY(base_generation) REFERENCES metadata(generation)) STRICT`, | |
| 21 | `CREATE TABLE inputs (attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), path TEXT NOT NULL CHECK(length(path) BETWEEN 1 AND 256), status INTEGER NOT NULL CHECK(status IN (200, 404)), bytes BLOB, sha256 TEXT, PRIMARY KEY(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id), CHECK((status = 404 AND bytes IS NULL AND sha256 IS NULL) OR (status = 200 AND bytes IS NOT NULL AND sha256 IS NOT NULL AND length(bytes) BETWEEN 1 AND ${metadataLimit} AND length(sha256) = 64))) STRICT`, | |
| 22 | `CREATE TABLE metadata (generation INTEGER PRIMARY KEY CHECK(generation BETWEEN 0 AND ${maximumGeneration}), attempt_id TEXT, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), role TEXT NOT NULL CHECK(length(role) BETWEEN 1 AND 180), version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 9007199254740991), epoch INTEGER NOT NULL CHECK(epoch BETWEEN 0 AND generation), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND ${metadataLimit}), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), UNIQUE(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id)) STRICT`, | |
| 23 | ] | |
| 24 | ||
| 25 | interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string } | |
| 26 | interface StoreReference extends TrustReference { directory: string, storeSha256: string } | |
| 27 | interface Binding { schema: 'drone-runtime-store-v1', storeId: string, enrollmentId: string, enrollmentSha256: string } | |
| 28 | interface State { store_id: string, enrollment_sha256: string, generation: number, release_floor: number, clock_floor_ms: number } | |
| 29 | interface MetadataRow { generation: number, attempt_id: string | null, ordinal: number, role: string, version: number, epoch: number, bytes: Uint8Array, sha256: string } | |
| 30 | interface Attempt { id: string, base_generation: number, started_ms: number, clock_source: string, phase: 'pending' | 'verified' | 'recovered' | 'maintenance', accepted_release: number | null } | |
| 31 | export interface RuntimeTrustStoreSummary { | |
| 32 | schema: 'drone-runtime-store-status-v1' | |
| 33 | storeId: string | |
| 34 | storeSha256: string | |
| 35 | enrollmentSha256: string | |
| 36 | generation: number | |
| 37 | epoch: number | |
| 38 | releaseFloor: number | |
| 39 | clockFloorMs: number | |
| 40 | retainedMetadataRecords: number | |
| 41 | metadata: ReadonlyArray<Readonly<{ role: string, version: number, sha256: string }>> | |
| 42 | attempt: Readonly<{ id: string, phase: Attempt['phase'], clockSource: string, startedMs: number }> | null | |
| 43 | physicalDeploymentAllowed: false | |
| 44 | } | |
| 45 | ||
| 46 | function protectedFile(path: string, limit: number, allowEmpty = false) { | |
| 47 | const stat = lstatSync(path) | |
| 48 | if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.size > limit || (!allowEmpty && stat.size === 0)) throw new Error('Invalid protected update-store file') | |
| 49 | return stat | |
| 50 | } | |
| 51 | function protectedDirectory(path: string) { | |
| 52 | directory(dirname(path)); directory(path) | |
| 53 | if ((lstatSync(path).mode & 0o777) !== 0o700) throw new Error('Update-store directory must be private') | |
| 54 | } | |
| 55 | function configure(db: Database) { | |
| 56 | db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA fullfsync=ON; PRAGMA page_size=4096; PRAGMA max_page_count=32768;') | |
| 57 | for (const [name, expected] of [['trusted_schema', 0], ['foreign_keys', 1], ['busy_timeout', 0], ['journal_mode', 'delete'], ['synchronous', 2], ['fullfsync', 1], ['page_size', 4096], ['max_page_count', 32768]] as const) { | |
| 58 | const result = db.query(`PRAGMA ${name}`).get() as Record<string, unknown> | null | |
| 59 | if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable') | |
| 60 | } | |
| 61 | } | |
| 62 | function openDatabase(path: string, expectedSchema: string[]) { | |
| 63 | const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes) | |
| 64 | const db = new Database(path, { create: false, strict: true }) | |
| 65 | try { | |
| 66 | const after = lstatSync(path) | |
| 67 | if (before.dev !== after.dev || before.ino !== after.ino) throw new Error('Update-store file replaced while opening') | |
| 68 | db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;') | |
| 69 | // Authenticate the protocol format before any configuration that could | |
| 70 | // rewrite a foreign database's journal/header format. | |
| 71 | checkSchema(db, expectedSchema) | |
| 72 | if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format') | |
| 73 | configure(db) | |
| 74 | return db | |
| 75 | } | |
| 76 | catch (error) { db.close(); throw error } | |
| 77 | } | |
| 78 | function checkSchema(db: Database, expected: string[]) { | |
| 79 | const definitions = db.query<{ sql: string }, []>("SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").all().map(row => row.sql).sort() | |
| 80 | if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema') | |
| 81 | const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get() | |
| 82 | const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get() | |
| 83 | if (values?.application_id !== applicationId || version?.user_version !== 1 || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version') | |
| 84 | } | |
| 85 | function createDatabase(path: string, statements: string[]) { | |
| 86 | writeExclusive(path, Buffer.alloc(0)) | |
| 87 | const db = new Database(path, { create: false, strict: true }) | |
| 88 | try { | |
| 89 | configure(db) | |
| 90 | db.exec(`PRAGMA application_id=${applicationId}; PRAGMA user_version=1;`) | |
| 91 | db.transaction(() => { for (const sql of statements) db.exec(sql) })() | |
| 92 | return db | |
| 93 | } | |
| 94 | catch (error) { db.close(); throw error } | |
| 95 | } | |
| 96 | function admitBinding(bytes: Buffer, trusted: Readonly<RuntimeTrustSummary>): Binding { | |
| 97 | const item = object(JSON.parse(bytes.toString('utf8'))) | |
| 98 | const keys = ['schema', 'storeId', 'enrollmentId', 'enrollmentSha256'] | |
| 99 | if (Object.keys(item).length !== keys.length || !keys.every(key => Object.hasOwn(item, key)) || item.schema !== 'drone-runtime-store-v1' || typeof item.storeId !== 'string' || !/^[a-f0-9]{32}$/.test(item.storeId) || item.enrollmentId !== trusted.enrollmentId || item.enrollmentSha256 !== trusted.enrollmentSha256) throw new Error('Update-store binding does not match enrollment') | |
| 100 | return item as unknown as Binding | |
| 101 | } | |
| 102 | function state(db: Database, binding: Binding) { | |
| 103 | const rows = db.query<State, []>('SELECT store_id, enrollment_sha256, generation, release_floor, clock_floor_ms FROM store_state').all() | |
| 104 | if (rows.length !== 1 || rows[0]!.store_id !== binding.storeId || rows[0]!.enrollment_sha256 !== binding.enrollmentSha256) throw new Error('Invalid update-store identity') | |
| 105 | return rows[0]! | |
| 106 | } | |
| 107 | function activeMetadata(db: Database, generation: number) { | |
| 108 | const root = db.query<MetadataRow, [number]>("SELECT * FROM metadata WHERE role = 'root' AND generation <= ? ORDER BY generation DESC LIMIT 1").get(generation) | |
| 109 | if (!root) throw new Error('Missing protected root') | |
| 110 | return db.query<MetadataRow, [number, number]>("SELECT * FROM metadata WHERE generation IN (SELECT max(generation) FROM metadata WHERE generation <= ? AND (role = 'root' OR epoch = ?) GROUP BY role) ORDER BY role").all(generation, root.epoch) | |
| 111 | } | |
| 112 | function checkedMetadataBytes(row: MetadataRow) { | |
| 113 | const bytes = Buffer.from(row.bytes) | |
| 114 | validateMetadata(bytes) | |
| 115 | const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed) | |
| 116 | const kind = ['root', 'timestamp', 'snapshot'].includes(row.role) ? row.role : 'targets' | |
| 117 | if (!/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(row.role) || sha256(bytes) !== row.sha256 || signed.version !== row.version || signed._type !== kind) throw new Error('Protected metadata integrity mismatch') | |
| 118 | return bytes | |
| 119 | } | |
| 120 | function summary(db: Database, binding: Binding, storeSha256: string): Readonly<RuntimeTrustStoreSummary> { | |
| 121 | const current = state(db, binding), metadata = activeMetadata(db, current.generation) | |
| 122 | const anchor = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get() | |
| 123 | if (!anchor || anchor.role !== 'root' || anchor.attempt_id !== null) throw new Error('Missing initial update-store anchor') | |
| 124 | for (const row of [anchor, ...metadata]) checkedMetadataBytes(row) | |
| 125 | const counts = db.query<{ attempts: number, metadata: number, inputs: number }, []>('SELECT (SELECT count(*) FROM attempts) AS attempts, (SELECT count(*) FROM metadata) AS metadata, (SELECT count(*) FROM inputs) AS inputs').get()! | |
| 126 | if (counts.attempts > 256 || counts.metadata !== current.generation + 1 || counts.metadata > maximumGeneration + 1 || counts.inputs > 16384) throw new Error('Update-store history budget exceeded or metadata history is incomplete') | |
| 127 | const floors = db.query<{ clock: number, release: number, generation: number }, []>('SELECT coalesce((SELECT max(started_ms) FROM attempts), 0) AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation').get()! | |
| 128 | if (current.clock_floor_ms !== floors.clock || current.release_floor < floors.release || current.generation !== floors.generation || db.query<{ count: number }, []>("SELECT count(*) AS count FROM attempts WHERE phase IN ('pending', 'maintenance')").get()!.count > 1) throw new Error('Inconsistent update-store floors or attempts') | |
| 129 | const attempt = db.query<Attempt, []>('SELECT * FROM attempts ORDER BY rowid DESC LIMIT 1').get() | |
| 130 | return Object.freeze({ schema: 'drone-runtime-store-status-v1', storeId: binding.storeId, storeSha256, enrollmentSha256: binding.enrollmentSha256, generation: current.generation, epoch: metadata.find(row => row.role === 'root')!.epoch, releaseFloor: current.release_floor, clockFloorMs: current.clock_floor_ms, retainedMetadataRecords: counts.metadata, metadata: Object.freeze(metadata.map(row => Object.freeze({ role: row.role, version: row.version, sha256: row.sha256 }))), attempt: attempt ? Object.freeze({ id: attempt.id, phase: attempt.phase, clockSource: attempt.clock_source, startedMs: attempt.started_ms }) : null, physicalDeploymentAllowed: false }) | |
| 131 | } | |
| 132 | ||
| 133 | async function withStore<T>(options: StoreReference, operation: (db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string) => T | Promise<T>): Promise<T> { | |
| 134 | const trusted = inspectRuntimeTrust({ directory: options.enrollmentDirectory, enrollmentSha256: options.enrollmentSha256 }) | |
| 135 | const path = resolve(options.directory) | |
| 136 | protectedDirectory(path) | |
| 137 | if (typeof options.storeSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(options.storeSha256)) throw new Error('Independent update-store pin required') | |
| 138 | protectedFile(join(path, 'store.json'), 16384) | |
| 139 | const bytes = regularFile(join(path, 'store.json'), 16384) | |
| 140 | if (sha256(bytes) !== options.storeSha256) throw new Error('Update-store pin mismatch') | |
| 141 | const binding = admitBinding(bytes, trusted), key = realpathSync(path) | |
| 142 | if (activeStores.has(key)) throw new Error('Update-store writer already active') | |
| 143 | activeStores.add(key) | |
| 144 | let writer: Database | undefined, db: Database | undefined | |
| 145 | try { | |
| 146 | for (const name of readdirSync(path)) { | |
| 147 | if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry') | |
| 148 | if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true) | |
| 149 | } | |
| 150 | writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema]) | |
| 151 | writer.exec('BEGIN IMMEDIATE') | |
| 152 | const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all() | |
| 153 | if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity') | |
| 154 | db = openDatabase(join(path, 'state.sqlite'), schema) | |
| 155 | const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get() | |
| 156 | if (initial?.sha256 !== trusted.policy.trustedRootSha256 || initial.version !== trusted.rootVersion || state(db, binding).release_floor < trusted.policy.minimumReleaseSequence) throw new Error('Update-store anchor or floor mismatch') | |
| 157 | summary(db, binding, options.storeSha256) | |
| 158 | return await operation(db, binding, trusted, path) | |
| 159 | } | |
| 160 | finally { | |
| 161 | try { db?.close() } | |
| 162 | finally { | |
| 163 | try { if (writer?.inTransaction) writer.exec('ROLLBACK') } | |
| 164 | finally { try { writer?.close() } finally { activeStores.delete(key) } } | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | /** Create a new host protocol store. Keep its returned pin independently; existing state is never reset. */ | |
| 170 | export async function createRuntimeTrustStore(options: TrustReference & { output: string }): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 171 | const trusted = inspectRuntimeTrust({ directory: options.enrollmentDirectory, enrollmentSha256: options.enrollmentSha256 }) | |
| 172 | const output = resolve(options.output) | |
| 173 | directory(dirname(output)); mkdirSync(output, { mode: 0o700 }) | |
| 174 | const binding: Binding = { schema: 'drone-runtime-store-v1', storeId: randomBytes(16).toString('hex'), enrollmentId: trusted.enrollmentId, enrollmentSha256: trusted.enrollmentSha256 } | |
| 175 | const writer = createDatabase(join(output, 'writer.sqlite'), [writerSchema]) | |
| 176 | try { writer.query('INSERT INTO writer VALUES(1, ?)').run(binding.storeId) } | |
| 177 | finally { writer.close() } | |
| 178 | const db = createDatabase(join(output, 'state.sqlite'), schema) | |
| 179 | try { | |
| 180 | const root = regularFile(join(options.enrollmentDirectory, 'root.json'), metadataLimit) | |
| 181 | if (sha256(root) !== trusted.policy.trustedRootSha256) throw new Error('Enrolled root changed during store initialization') | |
| 182 | db.transaction(() => { | |
| 183 | db.query("INSERT INTO metadata VALUES(0, NULL, 0, 'root', ?, 0, ?, ?)").run(trusted.rootVersion, root, sha256(root)) | |
| 184 | db.query('INSERT INTO store_state VALUES(1, ?, ?, 0, ?, 0)').run(binding.storeId, binding.enrollmentSha256, trusted.policy.minimumReleaseSequence) | |
| 185 | })() | |
| 186 | } | |
| 187 | finally { db.close() } | |
| 188 | syncDirectory(output) | |
| 189 | const bytes = Buffer.from(`${JSON.stringify(binding)}\n`), storeSha256 = sha256(bytes) | |
| 190 | writeExclusive(join(output, 'store.json'), bytes); syncDirectory(output); syncDirectory(dirname(output)) | |
| 191 | return inspectRuntimeTrustStore({ ...options, directory: output, storeSha256 }) | |
| 192 | } | |
| 193 | ||
| 194 | /** Inspect protected host state under the same writer exclusion used by update operations. */ | |
| 195 | export async function inspectRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 196 | return withStore(options, (db, binding) => summary(db, binding, options.storeSha256)) | |
| 197 | } | |
| 198 | ||
| 199 | export interface RuntimeUpdateClock { | |
| 200 | /** Audit label for an independently trusted clock. The label itself is not attestation. */ | |
| 201 | source: string | |
| 202 | read: () => Date | |
| 203 | } | |
| 204 | ||
| 205 | export interface RuntimeUpdateProgress { | |
| 206 | phase: 'input-committed' | 'metadata-committed' | |
| 207 | ordinal: number | |
| 208 | } | |
| 209 | type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void> | |
| 210 | interface InputRow { ordinal: number, path: string, status: 200 | 404, bytes: Uint8Array | null, sha256: string | null } | |
| 211 | ||
| 212 | function clockReading(clock: RuntimeUpdateClock, floor: number) { | |
| 213 | if (!clock || typeof clock.source !== 'string' || !/^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(clock.source) || typeof clock.read !== 'function') throw new Error('An independently trusted update clock is required') | |
| 214 | const source = clock.source, time = clock.read() | |
| 215 | const milliseconds = time instanceof Date ? Date.prototype.getTime.call(time) : Number.NaN | |
| 216 | if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 || milliseconds > 253402300799000 || milliseconds < floor) throw new Error('Invalid or reversed update clock') | |
| 217 | return { source, time: new Date(milliseconds) } | |
| 218 | } | |
| 219 | function authorities(bytes: Uint8Array) { | |
| 220 | const roles = object(object(object(JSON.parse(Buffer.from(bytes).toString('utf8'))).signed).roles) | |
| 221 | return JSON.stringify(['timestamp', 'snapshot'].map(name => { | |
| 222 | const role = object(roles[name]) | |
| 223 | if (!Array.isArray(role.keyids) || !role.keyids.every(id => typeof id === 'string')) throw new Error('Invalid verified role authority') | |
| 224 | return { keyids: [...role.keyids].sort(), threshold: role.threshold } | |
| 225 | })) | |
| 226 | } | |
| 227 | function workingCache(db: Database, generation: number) { | |
| 228 | const path = mkdtempSync(join(tmpdir(), 'drone-update-refresh-')) | |
| 229 | try { | |
| 230 | for (const row of activeMetadata(db, generation)) { | |
| 231 | if (!/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(row.role)) throw new Error('Unsafe protected metadata role') | |
| 232 | writeExclusive(join(path, `${row.role}.json`), checkedMetadataBytes(row)) | |
| 233 | } | |
| 234 | return path | |
| 235 | } | |
| 236 | catch (error) { rmSync(path, { recursive: true, force: true }); throw error } | |
| 237 | } | |
| 238 | function invalidateActiveCache(cache: string) { | |
| 239 | for (const role of ['timestamp', 'snapshot']) { | |
| 240 | try { unlinkSync(join(cache, `${role}.json`)) } | |
| 241 | catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } | |
| 242 | } | |
| 243 | } | |
| 244 | function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal: number, role: string, bytes: Buffer) { | |
| 245 | validateMetadata(bytes) | |
| 246 | if (bytes.length > metadataLimit || !/^[a-z0-9][a-z0-9._-]{0,179}$/i.test(role)) throw new Error('Invalid verified metadata observation') | |
| 247 | const version = object(object(JSON.parse(bytes.toString('utf8'))).signed).version as number | |
| 248 | const current = state(db, binding), rows = activeMetadata(db, current.generation), prior = rows.find(row => row.role === role)! | |
| 249 | const root = rows.find(row => row.role === 'root')! | |
| 250 | if (current.generation >= maximumGeneration || (role === 'root' && version !== root.version + 1) || (prior && (version < prior.version || (version === prior.version && sha256(bytes) !== prior.sha256)))) throw new Error('Protected metadata floor or history limit rejected advancement') | |
| 251 | const changed = role === 'root' && authorities(root.bytes) !== authorities(bytes), epoch = root.epoch + (changed ? 1 : 0) | |
| 252 | db.transaction(() => { | |
| 253 | db.query('INSERT INTO metadata VALUES(?, ?, ?, ?, ?, ?, ?, ?)').run(current.generation + 1, attempt, ordinal, role, version, epoch, bytes, sha256(bytes)) | |
| 254 | db.query('UPDATE store_state SET generation = ? WHERE id = 1').run(current.generation + 1) | |
| 255 | })() | |
| 256 | const readback = db.query<MetadataRow, [number]>('SELECT * FROM metadata WHERE generation = ?').get(current.generation + 1) | |
| 257 | if (!readback || readback.sha256 !== sha256(bytes) || !Buffer.from(readback.bytes).equals(bytes)) throw new Error('Verified metadata commit readback failed') | |
| 258 | return changed | |
| 259 | } | |
| 260 | ||
| 261 | /** Verify bounded offline metadata and retain its accepted prefix. This does not stage, select or install an image. */ | |
| 262 | export async function refreshRuntimeTrustStore(options: StoreReference & { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 263 | options = { ...options } | |
| 264 | return withStore(options, async (db, binding, trusted) => { | |
| 265 | if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery') | |
| 266 | const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms) | |
| 267 | if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached') | |
| 268 | const source = realpathSync(options.repository); directory(source) | |
| 269 | const cache = workingCache(db, current.generation), attempt = randomBytes(16).toString('hex') | |
| 270 | let requests = 0, received = 0, observations = 0 | |
| 271 | try { | |
| 272 | db.transaction(() => { | |
| 273 | db.query("INSERT INTO attempts VALUES(?, ?, ?, ?, 'pending', NULL)").run(attempt, current.generation, now.time.getTime(), now.source) | |
| 274 | db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(now.time.getTime()) | |
| 275 | })() | |
| 276 | const fetcher = { | |
| 277 | async downloadBytes(url: string, limit: number) { | |
| 278 | const address = new URL(url), path = address.pathname.slice('/metadata/'.length) | |
| 279 | if (address.origin !== 'https://offline.invalid' || !address.pathname.startsWith('/metadata/') || address.search || address.hash || !/^(?:[0-9]+\.)?[a-z0-9][a-z0-9._-]{0,179}\.json$/i.test(path) || requests >= 64) throw new Error('Unsupported or excessive metadata request') | |
| 280 | safePath(path) | |
| 281 | let bytes: Buffer | null = null | |
| 282 | try { bytes = repositoryFile(source, `metadata/${path}`, Math.min(limit, metadataLimit)); validateMetadata(bytes) } | |
| 283 | catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } | |
| 284 | received += bytes?.length ?? 0 | |
| 285 | if (received > 32 * 1024 * 1024) throw new Error('Metadata input budget exceeded') | |
| 286 | const index = requests++ | |
| 287 | db.transaction(() => db.query('INSERT INTO inputs VALUES(?, ?, ?, ?, ?, ?)').run(attempt, index, path, bytes ? 200 : 404, bytes, bytes ? sha256(bytes) : null))() | |
| 288 | const saved = db.query<InputRow, [string, number]>('SELECT * FROM inputs WHERE attempt_id = ? AND ordinal = ?').get(attempt, index) | |
| 289 | if (!saved || saved.path !== path || saved.status !== (bytes ? 200 : 404) || (bytes ? !saved.bytes || !Buffer.from(saved.bytes).equals(bytes) || saved.sha256 !== sha256(bytes) : saved.bytes !== null || saved.sha256 !== null)) throw new Error('Metadata input commit readback failed') | |
| 290 | // Only our absent-file response may enter the SDK's HTTP classification. | |
| 291 | // Observer failures must abort even when their raw cause is an HTTP error. | |
| 292 | try { await options.onProgress?.(Object.freeze({ phase: 'input-committed', ordinal: index })) } | |
| 293 | catch (cause) { throw new Error('Update input progress observer rejected', { cause }) } | |
| 294 | if (!bytes) throw new DownloadHTTPError('Offline metadata is absent', 404) | |
| 295 | return Buffer.from(bytes) | |
| 296 | }, | |
| 297 | async downloadFile(): Promise<never> { throw new Error('Metadata refresh never downloads an image') }, | |
| 298 | } | |
| 299 | const updater = new Updater({ metadataDir: cache, metadataBaseUrl: 'https://offline.invalid/metadata/', fetcher, referenceTime: now.time, config: { maxRootRotations: 16, maxDelegations: 16, rootMaxLength: metadataLimit, timestampMaxLength: metadataLimit, snapshotMaxLength: metadataLimit, targetsMaxLength: metadataLimit }, async onVerifiedMetadata(role, bytes) { | |
| 300 | const index = observations++ | |
| 301 | if (commitMetadata(db, binding, attempt, index, role, bytes)) invalidateActiveCache(cache) | |
| 302 | await options.onProgress?.(Object.freeze({ phase: 'metadata-committed', ordinal: index })) | |
| 303 | } }) | |
| 304 | await updater.refresh() | |
| 305 | const info = await updater.getTargetInfo(trusted.policy.target) | |
| 306 | if (!info) throw new Error('Selected runtime target is absent') | |
| 307 | const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor }) | |
| 308 | db.transaction(() => { | |
| 309 | db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence) | |
| 310 | db.query("UPDATE attempts SET phase = 'verified', accepted_release = ? WHERE id = ?").run(runtime.releaseSequence, attempt) | |
| 311 | })() | |
| 312 | return summary(db, binding, options.storeSha256) | |
| 313 | } | |
| 314 | catch (error) { | |
| 315 | try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) } | |
| 316 | catch (persistence) { throw new AggregateError([error, persistence], 'Update failed and maintenance state could not be recorded') } | |
| 317 | throw error | |
| 318 | } | |
| 319 | finally { rmSync(cache, { recursive: true, force: true }) } | |
| 320 | }) | |
| 321 | } | |
| 322 | ||
| 323 | function causedBy(error: unknown, marker: Error) { | |
| 324 | const seen = new Set<unknown>() | |
| 325 | for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) { | |
| 326 | if (value === marker) return true | |
| 327 | seen.add(value) | |
| 328 | } | |
| 329 | return false | |
| 330 | } | |
| 331 | ||
| 332 | /** Replay an interrupted attempt from its exact base and durable inputs. This only retains floors, never authorizes image use. */ | |
| 333 | export async function recoverRuntimeTrustStore(options: StoreReference): Promise<Readonly<RuntimeTrustStoreSummary>> { | |
| 334 | options = { ...options } | |
| 335 | return withStore(options, async (db, binding, trusted) => { | |
| 336 | const pending = db.query<Attempt, []>("SELECT * FROM attempts WHERE phase IN ('pending', 'maintenance')").all() | |
| 337 | if (pending.length !== 1) throw new Error('Exactly one unresolved attempt is required for recovery') | |
| 338 | const attempt = pending[0]!, current = state(db, binding) | |
| 339 | const inputs = db.query<InputRow, [string]>('SELECT * FROM inputs WHERE attempt_id = ? ORDER BY ordinal').all(attempt.id) | |
| 340 | const committed = db.query<MetadataRow, [number]>('SELECT * FROM metadata WHERE generation > ? ORDER BY generation').all(attempt.base_generation) | |
| 341 | if (inputs.length > 64 || inputs.some((row, index) => row.ordinal !== index) || committed.some((row, index) => row.attempt_id !== attempt.id || row.ordinal !== index || row.generation !== attempt.base_generation + index + 1) || current.generation !== attempt.base_generation + committed.length) throw new Error('Interrupted attempt history is inconsistent') | |
| 342 | const cache = workingCache(db, attempt.base_generation), end = new Error('Recorded metadata input boundary reached') | |
| 343 | let replayRoot = activeMetadata(db, attempt.base_generation).find(row => row.role === 'root')! | |
| 344 | let delivered = 0, observed = 0, received = 0, release: number | null = null | |
| 345 | try { | |
| 346 | const fetcher = { | |
| 347 | async downloadBytes(url: string, limit: number) { | |
| 348 | const address = new URL(url), path = address.pathname.slice('/metadata/'.length) | |
| 349 | if (address.origin !== 'https://offline.invalid' || !address.pathname.startsWith('/metadata/') || address.search || address.hash) throw new Error('Unexpected recovery request') | |
| 350 | const item = inputs[delivered] | |
| 351 | if (!item) throw end | |
| 352 | if (item.path !== path) throw new Error('Recovery request differs from recorded input') | |
| 353 | delivered++ | |
| 354 | if (item.status === 404) { | |
| 355 | if (item.bytes !== null || item.sha256 !== null) throw new Error('Invalid recorded missing response') | |
| 356 | throw new DownloadHTTPError('Recorded offline metadata is absent', 404) | |
| 357 | } | |
| 358 | if (!item.bytes) throw new Error('Missing recorded metadata bytes') | |
| 359 | const bytes = Buffer.from(item.bytes) | |
| 360 | received += bytes.length | |
| 361 | if (bytes.length < 1 || bytes.length > Math.min(limit, metadataLimit) || received > 32 * 1024 * 1024 || sha256(bytes) !== item.sha256) throw new Error('Recorded metadata input integrity or budget failure') | |
| 362 | validateMetadata(bytes) | |
| 363 | return bytes | |
| 364 | }, | |
| 365 | async downloadFile(): Promise<never> { throw new Error('Recovery never downloads an image') }, | |
| 366 | } | |
| 367 | const updater = new Updater({ metadataDir: cache, metadataBaseUrl: 'https://offline.invalid/metadata/', fetcher, referenceTime: new Date(attempt.started_ms), config: { maxRootRotations: 16, maxDelegations: 16, rootMaxLength: metadataLimit, timestampMaxLength: metadataLimit, snapshotMaxLength: metadataLimit, targetsMaxLength: metadataLimit }, onVerifiedMetadata(role, bytes) { | |
| 368 | const old = committed[observed], changed = role === 'root' && authorities(replayRoot.bytes) !== authorities(bytes) | |
| 369 | const epoch = replayRoot.epoch + (changed ? 1 : 0) | |
| 370 | if (old) { | |
| 371 | if (old.role !== role || old.sha256 !== sha256(bytes) || old.epoch !== epoch || !Buffer.from(old.bytes).equals(bytes)) throw new Error('Recovered metadata differs from committed prefix') | |
| 372 | } | |
| 373 | else commitMetadata(db, binding, attempt.id, observed, role, bytes) | |
| 374 | if (role === 'root') replayRoot = { ...replayRoot, bytes, epoch } | |
| 375 | if (changed) invalidateActiveCache(cache) | |
| 376 | observed++ | |
| 377 | } }) | |
| 378 | try { | |
| 379 | await updater.refresh() | |
| 380 | const info = await updater.getTargetInfo(trusted.policy.target) | |
| 381 | if (!info) throw new Error('Selected runtime target is absent during recovery') | |
| 382 | release = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor }).releaseSequence | |
| 383 | } | |
| 384 | catch (error) { | |
| 385 | // Only this owned pre-delivery marker can end replay without completing | |
| 386 | // metadata verification. SDK validation/IO/crypto errors stay maintenance. | |
| 387 | // A policy rejection raised here after complete SDK verification can end | |
| 388 | // the attempt without crediting a release. SDK/IO failures cannot. | |
| 389 | if (!causedBy(error, end) && !(error instanceof RuntimeManifestRejected)) throw error | |
| 390 | } | |
| 391 | if (delivered !== inputs.length || observed < committed.length) throw new Error('Recovery did not reproduce the full recorded prefix') | |
| 392 | db.transaction(() => { | |
| 393 | if (release !== null) db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(release) | |
| 394 | db.query("UPDATE attempts SET phase = 'recovered', accepted_release = ? WHERE id = ?").run(release, attempt.id) | |
| 395 | })() | |
| 396 | return summary(db, binding, options.storeSha256) | |
| 397 | } | |
| 398 | catch (error) { | |
| 399 | try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt.id) } | |
| 400 | catch (persistence) { throw new AggregateError([error, persistence], 'Recovery failed and maintenance state could not be recorded') } | |
| 401 | throw error | |
| 402 | } | |
| 403 | finally { rmSync(cache, { recursive: true, force: true }) } | |
| 404 | }) | |
| 405 | } | |
| @@ -0,0 +1,37 @@ | ||
| 1 | import { mkdir, writeFile } from 'node:fs/promises' | |
| 2 | import { join } from 'node:path' | |
| 3 | import { Metadata, MetaFile, Root, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models' | |
| 4 | import { digest, platform, signer, target } from './repository' | |
| 5 | ||
| 6 | /** The same timestamp key survives a snapshot-only authority rotation. All keys are throwaway. */ | |
| 7 | export async function epochRepository(directory: string, duplicateSnapshotKey = false) { | |
| 8 | await mkdir(join(directory, 'metadata'), { recursive: true }) | |
| 9 | const keys = { root: signer(), timestamp: signer(), snapshot: signer(), nextSnapshot: signer(), targets: signer() } | |
| 10 | const fields = { specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' } | |
| 11 | function root(version: number) { | |
| 12 | const data = new Root({ ...fields, version }) | |
| 13 | for (const role of ['root', 'timestamp', 'snapshot', 'targets'] as const) data.addKey(version === 2 && role === 'snapshot' && !duplicateSnapshotKey ? keys.nextSnapshot.key : keys[role].key, role) | |
| 14 | if (version === 2 && duplicateSnapshotKey) data.roles.snapshot!.keyIDs.push(keys.snapshot.key.keyID) | |
| 15 | const metadata = new Metadata(data); metadata.sign(keys.root.sign) | |
| 16 | return Buffer.from(JSON.stringify(metadata.toJSON())) | |
| 17 | } | |
| 18 | const rootPath = join(directory, 'anchor.json'), anchor = root(1) | |
| 19 | await writeFile(rootPath, anchor) | |
| 20 | async function publish(epoch: 1 | 2, version: number, releaseSequence: number) { | |
| 21 | if (epoch === 2) await writeFile(join(directory, 'metadata/2.root.json'), root(2)) | |
| 22 | const image = Buffer.from('synthetic epoch image, never execute or flash\n') | |
| 23 | const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench' } | |
| 24 | const targets = new Metadata(new Targets({ ...fields, version, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: digest(image) }, unrecognizedFields: { custom: { drone: custom } } }) } })) | |
| 25 | targets.sign(keys.targets.sign) | |
| 26 | const targetBytes = Buffer.from(JSON.stringify(targets.toJSON())) | |
| 27 | const snapshot = new Metadata(new Snapshot({ ...fields, version, meta: { 'targets.json': new MetaFile({ version, length: targetBytes.length, hashes: { sha256: digest(targetBytes) } }) } })) | |
| 28 | snapshot.sign(epoch === 1 || duplicateSnapshotKey ? keys.snapshot.sign : keys.nextSnapshot.sign) | |
| 29 | const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON())) | |
| 30 | const timestamp = new Metadata(new Timestamp({ ...fields, version, snapshotMeta: new MetaFile({ version, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) })) | |
| 31 | timestamp.sign(keys.timestamp.sign) | |
| 32 | await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON())) | |
| 33 | await writeFile(join(directory, `metadata/${version}.snapshot.json`), snapshotBytes) | |
| 34 | await writeFile(join(directory, `metadata/${version}.targets.json`), targetBytes) | |
| 35 | } | |
| 36 | return { rootPath, rootSha256: digest(anchor), publish } | |
| 37 | } | |
| @@ -0,0 +1,59 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { constants } from 'node:fs' | |
| 3 | import { createHash } from 'node:crypto' | |
| 4 | import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 5 | import { join, resolve } from 'node:path' | |
| 6 | import { refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store' | |
| 7 | import { platform, repository, target } from './repository' | |
| 8 | ||
| 9 | async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') } | |
| 10 | ||
| 11 | /** Actual copied CLI plus a source-side interrupted input to exercise the same persisted recovery format. */ | |
| 12 | export async function qualifyPortableStore(directory: string, source: string) { | |
| 13 | await mkdir(directory, { mode: 0o700 }) | |
| 14 | const binary = join(directory, 'drone'), sourceSha256 = await hash(source) | |
| 15 | await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700) | |
| 16 | assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256) | |
| 17 | const repo = join(directory, 'repo'), fixture = await repository(repo, { custom: { releaseSequence: 9 } }) | |
| 18 | const enrollment = join(directory, 'enrollment'), store = join(directory, 'store') | |
| 19 | const results: { command: string, exit: number, stdout: string, stderr: string }[] = [] | |
| 20 | async function run(args: string[], expected = 0) { | |
| 21 | const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 22 | const timer = setTimeout(() => child.kill('SIGKILL'), 20000) | |
| 23 | try { | |
| 24 | const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]) | |
| 25 | results.push({ command: args[0]!, exit, stdout, stderr }) | |
| 26 | assert.equal(exit, expected) | |
| 27 | if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') } | |
| 28 | return stdout ? JSON.parse(stdout) : undefined | |
| 29 | } | |
| 30 | finally { clearTimeout(timer) } | |
| 31 | } | |
| 32 | try { | |
| 33 | const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']) | |
| 34 | const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256] | |
| 35 | const created = await run(['updates:store-init', ...flags, '--output', store]) | |
| 36 | assert.equal(created.generation, 0); assert.equal(created.releaseFloor, 7) | |
| 37 | const pinned = [...flags, '--store-sha256', created.storeSha256] | |
| 38 | await run(['updates:refresh', store, ...pinned, '--repository', repo], 1) | |
| 39 | const marker = new Error('Synthetic interruption after timestamp input commit') | |
| 40 | const rejected = await refreshRuntimeTrustStore({ directory: store, storeSha256: created.storeSha256, enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, repository: repo, clock: { source: 'synthetic-host-current-clock', read: () => new Date() }, onProgress(event) { | |
| 41 | if (event.phase === 'input-committed' && event.ordinal === 1) throw marker | |
| 42 | } }).then(() => undefined, error => error) | |
| 43 | assert.ok(rejected instanceof Error) | |
| 44 | assert.equal(rejected.cause, marker) | |
| 45 | const recovered = await run(['updates:recover', store, ...pinned]) | |
| 46 | assert.equal(recovered.generation, 1); assert.equal(recovered.releaseFloor, 7); assert.equal(recovered.attempt.phase, 'recovered') | |
| 47 | const refreshed = await run(['updates:refresh', store, ...pinned, '--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']) | |
| 48 | assert.equal(refreshed.generation, 3); assert.equal(refreshed.releaseFloor, 9); assert.equal(refreshed.attempt.phase, 'verified'); assert.equal(refreshed.physicalDeploymentAllowed, false) | |
| 49 | assert.deepEqual(await run(['updates:store', store, ...pinned]), refreshed) | |
| 50 | await run(['updates:store', store, ...flags, '--store-sha256', '0'.repeat(64)], 1) | |
| 51 | await run(['updates:recover', store, ...pinned], 1) | |
| 52 | } | |
| 53 | finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-store-v1', sourceSha256, sourceSideInterruption: 'callback after committed input, not a process kill', results, physicalDeploymentAllowed: false }, null, 2)) } | |
| 54 | return { evidence: 'copied-standalone-store-v1', operations: results.length, sourceSha256, physicalDeploymentAllowed: false } | |
| 55 | } | |
| 56 | if (import.meta.main) { | |
| 57 | if (process.argv.length !== 4) throw new Error('Use qualify-store.ts NEW_DIRECTORY COMPILED_BINARY') | |
| 58 | console.log(JSON.stringify(await qualifyPortableStore(resolve(process.argv[2]!), resolve(process.argv[3]!)))) | |
| 59 | } | |
| @@ -9,7 +9,7 @@ export const platform = 'pico2-neutral-bench-v1' | ||
| 9 | 9 | export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') |
| 10 | 10 | const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' } |
| 11 | 11 | |
| 12 | function signer(nonCanonical = false) { | |
| 12 | export function signer(nonCanonical = false) { | |
| 13 | 13 | const pair = generateKeyPairSync('ed25519') |
| 14 | 14 | const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex') |
| 15 | 15 | const keyObject = { keytype: 'ed25519', scheme: 'ed25519', keyval: { public: publicKey } } |
| @@ -0,0 +1,94 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { spawnSync } from 'node:child_process' | |
| 3 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' | |
| 6 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store' | |
| 7 | import { platform, repository, target } from './repository' | |
| 8 | ||
| 9 | type Reference = Parameters<typeof inspectRuntimeTrustStore>[0] | |
| 10 | type Cut = 'input-committed' | 'metadata-committed' | |
| 11 | interface WorkerInput { reference: Reference, repository: string, cut: Cut } | |
| 12 | const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 13 | ||
| 14 | async function worker(mode: string, file: string) { | |
| 15 | const input = JSON.parse(await readFile(file, 'utf8')) as WorkerInput | |
| 16 | if (mode === 'contend') { | |
| 17 | try { await inspectRuntimeTrustStore(input.reference); process.exitCode = 18 } | |
| 18 | catch (error) { | |
| 19 | const code = (error as NodeJS.ErrnoException).code | |
| 20 | console.log(JSON.stringify({ blocked: code === 'SQLITE_BUSY', code })) | |
| 21 | process.exitCode = code === 'SQLITE_BUSY' ? 17 : 19 | |
| 22 | } | |
| 23 | return | |
| 24 | } | |
| 25 | if (mode !== 'hold') throw new Error('Unknown interruption worker mode') | |
| 26 | await refreshRuntimeTrustStore({ ...input.reference, repository: input.repository, clock, async onProgress(event) { | |
| 27 | if (event.phase !== input.cut || event.ordinal !== (input.cut === 'input-committed' ? 1 : 0)) return | |
| 28 | console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal })) | |
| 29 | await Bun.stdin.text() | |
| 30 | } }) | |
| 31 | throw new Error('Interruption worker unexpectedly finished') | |
| 32 | } | |
| 33 | ||
| 34 | /** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */ | |
| 35 | export async function qualifyStoreInterruptions(directory: string) { | |
| 36 | await mkdir(directory, { mode: 0o700 }) | |
| 37 | const results = [] | |
| 38 | for (const cut of ['input-committed', 'metadata-committed'] as const) { | |
| 39 | const root = join(directory, cut); await mkdir(root, { mode: 0o700 }) | |
| 40 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) | |
| 41 | const enrollmentDirectory = join(root, 'enrollment') | |
| 42 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 43 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) | |
| 44 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 } | |
| 45 | const file = join(root, 'worker.json') | |
| 46 | await writeFile(file, JSON.stringify({ reference, repository: source, cut } satisfies WorkerInput), { mode: 0o600 }) | |
| 47 | const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) | |
| 48 | const timer = setTimeout(() => child.kill('SIGKILL'), 15000) | |
| 49 | const reader = child.stdout.getReader(), decoder = new TextDecoder() | |
| 50 | let stdout = '' | |
| 51 | try { | |
| 52 | while (!stdout.includes('\n')) { | |
| 53 | const part = await reader.read() | |
| 54 | if (part.done) throw new Error('Interruption worker exited before readiness') | |
| 55 | stdout += decoder.decode(part.value, { stream: true }) | |
| 56 | if (stdout.length > 4096) throw new Error('Interruption worker output exceeded its bound') | |
| 57 | } | |
| 58 | const ready = JSON.parse(stdout.trim()) | |
| 59 | assert.equal(ready.ready, true); assert.equal(ready.phase, cut) | |
| 60 | const contender = spawnSync(process.execPath, [import.meta.path, 'contend', file], { encoding: 'utf8', timeout: 10000 }) | |
| 61 | assert.equal(contender.status, 17) | |
| 62 | assert.equal(JSON.parse(contender.stdout).blocked, true) | |
| 63 | child.kill('SIGKILL') | |
| 64 | const exit = await child.exited, signal = child.signalCode | |
| 65 | assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0) | |
| 66 | const interrupted = await inspectRuntimeTrustStore(reference) | |
| 67 | assert.equal(interrupted.attempt?.phase, 'pending') | |
| 68 | assert.equal(interrupted.generation, cut === 'input-committed' ? 0 : 1) | |
| 69 | const recovered = await recoverRuntimeTrustStore(reference) | |
| 70 | assert.equal(recovered.generation, 1); assert.equal(recovered.attempt?.phase, 'recovered') | |
| 71 | assert.equal(recovered.releaseFloor, 7) | |
| 72 | const fresh = await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 73 | assert.equal(fresh.generation, 3); assert.equal(fresh.releaseFloor, 9) | |
| 74 | assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false) | |
| 75 | results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, interrupted, recovered, fresh }) | |
| 76 | } | |
| 77 | finally { | |
| 78 | clearTimeout(timer) | |
| 79 | if (child.exitCode === null) child.kill('SIGKILL') | |
| 80 | await child.exited | |
| 81 | reader.releaseLock() | |
| 82 | await writeFile(join(root, 'worker.stdout'), stdout) | |
| 83 | await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text()) | |
| 84 | await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', results, physicalDeploymentAllowed: false }, null, 2)) | |
| 85 | } | |
| 86 | } | |
| 87 | return { evidence: 'host-process-interruption-only', cuts: results.length, physicalDeploymentAllowed: false } | |
| 88 | } | |
| 89 | ||
| 90 | if (import.meta.main) { | |
| 91 | if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!) | |
| 92 | else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!)))) | |
| 93 | else throw new Error('Use store-interruptions.ts NEW_DIRECTORY') | |
| 94 | } | |
| @@ -0,0 +1,38 @@ | ||
| 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 { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 7 | import { repository, platform, target } from '../fixtures/updates/repository' | |
| 8 | ||
| 9 | const roots: string[] = [] | |
| 10 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 11 | ||
| 12 | test('native store commands require independent pins and an explicit trusted system-clock source', async () => { | |
| 13 | const root = await mkdtemp(join(tmpdir(), 'drone-store-cli-')); roots.push(root) | |
| 14 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) | |
| 15 | const enrollment = join(root, 'enrollment'), output = join(root, 'store') | |
| 16 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollment }) | |
| 17 | const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256] | |
| 18 | const initialized = await command(['updates:store-init', ...flags, '--output', output]).withTimeout(20000) | |
| 19 | expect(initialized.exitCode).toBe(0) | |
| 20 | const created = JSON.parse(initialized.stdout) | |
| 21 | expect(created.releaseFloor).toBe(7) | |
| 22 | const pinned = [...flags, '--store-sha256', created.storeSha256] | |
| 23 | const missingClock = await command(['updates:refresh', output, ...pinned, '--repository', source]).withTimeout(20000) | |
| 24 | expect(missingClock.exitCode).toBe(1) | |
| 25 | expect(missingClock.stdout).toBe('') | |
| 26 | const refreshed = await command(['updates:refresh', output, ...pinned, '--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000) | |
| 27 | expect(refreshed.exitCode).toBe(0) | |
| 28 | const value = JSON.parse(refreshed.stdout) | |
| 29 | expect(value.generation).toBe(3) | |
| 30 | expect(value.releaseFloor).toBe(9) | |
| 31 | expect(value.physicalDeploymentAllowed).toBe(false) | |
| 32 | const inspected = await command(['updates:store', output, ...pinned]).withTimeout(20000) | |
| 33 | expect(inspected.exitCode).toBe(0) | |
| 34 | expect(JSON.parse(inspected.stdout)).toEqual(value) | |
| 35 | const wrongPin = await command(['updates:store', output, ...flags, '--store-sha256', '0'.repeat(64)]).withTimeout(20000) | |
| 36 | expect(wrongPin.exitCode).toBe(1) | |
| 37 | expect(wrongPin.stdout).toBe('') | |
| 38 | }, 120000) | |
| @@ -0,0 +1,236 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { Database } from 'bun:sqlite' | |
| 3 | import { DownloadHTTPError, MetadataVerificationError } from 'tuf-js' | |
| 4 | import { mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | import { join } from 'node:path' | |
| 7 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 8 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 9 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 10 | import { qualifyStoreInterruptions } from '../fixtures/updates/store-interruptions' | |
| 11 | import { epochRepository } from '../fixtures/updates/epochs' | |
| 12 | ||
| 13 | const roots: string[] = [] | |
| 14 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 15 | ||
| 16 | async function setup(options: Parameters<typeof repository>[1] = {}) { | |
| 17 | const root = await mkdtemp(join(tmpdir(), 'drone-update-store-')); roots.push(root) | |
| 18 | const source = join(root, 'repository'), fixture = await repository(source, options) | |
| 19 | const enrollmentDirectory = join(root, 'enrollment') | |
| 20 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 21 | return { root, source, fixture, enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') } | |
| 22 | } | |
| 23 | ||
| 24 | test('a new protocol store binds the independently pinned enrollment and retains its initial floors', async () => { | |
| 25 | const item = await setup(), created = await createRuntimeTrustStore(item) | |
| 26 | expect(created.enrollmentSha256).toBe(item.enrollmentSha256) | |
| 27 | expect(created.releaseFloor).toBe(7) | |
| 28 | expect(created.clockFloorMs).toBe(0) | |
| 29 | expect(created.generation).toBe(0) | |
| 30 | expect(created.epoch).toBe(0) | |
| 31 | expect(created.metadata).toEqual([{ role: 'root', version: 1, sha256: item.fixture.rootSha256 }]) | |
| 32 | expect(created.attempt).toBeNull() | |
| 33 | expect(created.physicalDeploymentAllowed).toBe(false) | |
| 34 | expect(await inspectRuntimeTrustStore({ ...item, directory: item.output, storeSha256: created.storeSha256 })).toEqual(created) | |
| 35 | expect((await stat(item.output)).mode & 0o777).toBe(0o700) | |
| 36 | for (const file of ['store.json', 'writer.sqlite', 'state.sqlite']) expect((await stat(join(item.output, file))).mode & 0o777).toBe(0o600) | |
| 37 | await expect(createRuntimeTrustStore(item)).rejects.toThrow() | |
| 38 | }) | |
| 39 | ||
| 40 | test('signed refresh commits metadata and release floors that independent readback retains', async () => { | |
| 41 | const item = await setup({ custom: { releaseSequence: 9 } }), created = await createRuntimeTrustStore(item) | |
| 42 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 43 | const time = new Date('2030-01-01T00:00:00Z') | |
| 44 | const result = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-independent-clock', read: () => time } }) | |
| 45 | expect(result.generation).toBe(3) | |
| 46 | expect(result.releaseFloor).toBe(9) | |
| 47 | expect(result.clockFloorMs).toBe(time.getTime()) | |
| 48 | expect(result.metadata.map(row => [row.role, row.version])).toEqual([['root', 1], ['snapshot', 1], ['targets', 1], ['timestamp', 1]]) | |
| 49 | expect(result.attempt?.phase).toBe('verified') | |
| 50 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 51 | expect(await inspectRuntimeTrustStore(reference)).toEqual(result) | |
| 52 | }) | |
| 53 | ||
| 54 | test('a later signature failure preserves the accepted prefix and blocks a fresh attempt', async () => { | |
| 55 | const item = await setup({ badTargetsSignature: true }), created = await createRuntimeTrustStore(item) | |
| 56 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 57 | const clock = { source: 'test-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 58 | await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock })).rejects.toThrow() | |
| 59 | const result = await inspectRuntimeTrustStore(reference) | |
| 60 | expect(result.generation).toBe(2) | |
| 61 | expect(result.metadata.map(row => row.role)).toEqual(['root', 'snapshot', 'timestamp']) | |
| 62 | expect(result.releaseFloor).toBe(7) | |
| 63 | expect(result.attempt?.phase).toBe('maintenance') | |
| 64 | await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock })).rejects.toThrow('requires recovery') | |
| 65 | await expect(recoverRuntimeTrustStore(reference)).rejects.toThrow() | |
| 66 | expect(await inspectRuntimeTrustStore(reference)).toEqual(result) | |
| 67 | }) | |
| 68 | ||
| 69 | test('reversed clock and a wrong independent store pin cannot mutate an accepted state', async () => { | |
| 70 | const item = await setup(), created = await createRuntimeTrustStore(item) | |
| 71 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 72 | const result = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } }) | |
| 73 | await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2029-01-01T00:00:00Z') } })).rejects.toThrow('reversed') | |
| 74 | await expect(inspectRuntimeTrustStore({ ...reference, storeSha256: '0'.repeat(64) })).rejects.toThrow('pin mismatch') | |
| 75 | expect(await inspectRuntimeTrustStore(reference)).toEqual(result) | |
| 76 | }) | |
| 77 | ||
| 78 | test('missing or corrupt protocol state is preserved and never recreated from the marker', async () => { | |
| 79 | for (const missing of [false, true]) { | |
| 80 | const item = await setup(), created = await createRuntimeTrustStore(item) | |
| 81 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 82 | const path = join(item.output, 'state.sqlite'), original = await readFile(path) | |
| 83 | if (missing) await unlink(path) | |
| 84 | else await writeFile(path, Buffer.from('corrupt protocol state')) | |
| 85 | await expect(inspectRuntimeTrustStore(reference)).rejects.toThrow() | |
| 86 | await expect(createRuntimeTrustStore(item)).rejects.toThrow() | |
| 87 | if (missing) expect(await Bun.file(path).exists()).toBe(false) | |
| 88 | else expect(await readFile(path)).toEqual(Buffer.from('corrupt protocol state')) | |
| 89 | await writeFile(path, original, { mode: 0o600 }) | |
| 90 | expect(await inspectRuntimeTrustStore(reference)).toEqual(created) | |
| 91 | } | |
| 92 | }) | |
| 93 | ||
| 94 | test('recovery verifies a journaled input that was never delivered, without consulting the repository', async () => { | |
| 95 | const item = await setup({ custom: { releaseSequence: 9 } }), created = await createRuntimeTrustStore(item) | |
| 96 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 97 | const clock = { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 98 | await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock, onProgress(event) { | |
| 99 | if (event.phase === 'input-committed' && event.ordinal === 1) throw new Error('Interrupted before timestamp delivery') | |
| 100 | } })).rejects.toThrow('Update input progress observer rejected') | |
| 101 | expect((await inspectRuntimeTrustStore(reference)).generation).toBe(0) | |
| 102 | await rename(item.source, join(item.root, 'offline')) | |
| 103 | const recovered = await recoverRuntimeTrustStore(reference) | |
| 104 | expect(recovered.attempt?.phase).toBe('recovered') | |
| 105 | expect(recovered.generation).toBe(1) | |
| 106 | expect(recovered.metadata.map(row => row.role)).toEqual(['root', 'timestamp']) | |
| 107 | expect(recovered.clockFloorMs).toBe(clock.read().getTime()) | |
| 108 | expect(recovered.releaseFloor).toBe(7) | |
| 109 | expect(recovered.physicalDeploymentAllowed).toBe(false) | |
| 110 | await rename(join(item.root, 'offline'), item.source) | |
| 111 | const fresh = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock }) | |
| 112 | expect(fresh.generation).toBe(3) | |
| 113 | expect(fresh.releaseFloor).toBe(9) | |
| 114 | expect(fresh.attempt?.phase).toBe('verified') | |
| 115 | }) | |
| 116 | ||
| 117 | test('real killed writers retain durable inputs and prefixes while contenders stay excluded', async () => { | |
| 118 | const root = await mkdtemp(join(tmpdir(), 'drone-store-host-cuts-')); roots.push(root) | |
| 119 | const result = await qualifyStoreInterruptions(join(root, 'campaign')) | |
| 120 | expect(result.cuts).toBe(2) | |
| 121 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 122 | }, 45000) | |
| 123 | ||
| 124 | test('a signed target rejected by release policy can finish recovery without lowering any floor', async () => { | |
| 125 | const item = await setup({ custom: { releaseSequence: 6 } }), created = await createRuntimeTrustStore(item) | |
| 126 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 127 | await expect(refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } })).rejects.toThrow('below the trusted minimum') | |
| 128 | expect((await inspectRuntimeTrustStore(reference)).generation).toBe(3) | |
| 129 | const result = await recoverRuntimeTrustStore(reference) | |
| 130 | expect(result.attempt?.phase).toBe('recovered') | |
| 131 | expect(result.generation).toBe(3) | |
| 132 | expect(result.releaseFloor).toBe(7) | |
| 133 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 134 | }) | |
| 135 | ||
| 136 | test('a foreign database is rejected without changing its journal format or bytes', async () => { | |
| 137 | const item = await setup(), created = await createRuntimeTrustStore(item) | |
| 138 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 139 | const foreign = join(item.root, 'foreign.sqlite'), db = new Database(foreign) | |
| 140 | try { db.exec('PRAGMA journal_mode=WAL; CREATE TABLE foreign_data(value TEXT); INSERT INTO foreign_data VALUES(\'preserve me\')') } | |
| 141 | finally { db.close() } | |
| 142 | const before = await readFile(foreign), path = join(item.output, 'state.sqlite') | |
| 143 | await writeFile(path, before) | |
| 144 | await expect(inspectRuntimeTrustStore(reference)).rejects.toThrow() | |
| 145 | expect((await readFile(path)).equals(before)).toBe(true) | |
| 146 | }) | |
| 147 | ||
| 148 | test('authenticated snapshot-key rotation permits a new metadata epoch while retaining the global release floor', async () => { | |
| 149 | const root = await mkdtemp(join(tmpdir(), 'drone-store-epochs-')); roots.push(root) | |
| 150 | const source = join(root, 'repo'), fixture = await epochRepository(source) | |
| 151 | const enrollmentDirectory = join(root, 'enrollment') | |
| 152 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 153 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: '' } | |
| 154 | reference.storeSha256 = (await createRuntimeTrustStore({ ...reference, output: reference.directory })).storeSha256 | |
| 155 | const clock = { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 156 | await fixture.publish(1, 100, 9) | |
| 157 | const before = await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 158 | expect(before.releaseFloor).toBe(9); expect(before.epoch).toBe(0) | |
| 159 | await fixture.publish(2, 1, 8) | |
| 160 | await expect(refreshRuntimeTrustStore({ ...reference, repository: source, clock })).rejects.toThrow('below the trusted minimum') | |
| 161 | const after = await inspectRuntimeTrustStore(reference) | |
| 162 | expect(after.epoch).toBe(1) | |
| 163 | expect(after.metadata.map(row => [row.role, row.version])).toEqual([['root', 2], ['snapshot', 1], ['targets', 1], ['timestamp', 1]]) | |
| 164 | expect(after.releaseFloor).toBe(9) | |
| 165 | expect(after.generation).toBe(7) | |
| 166 | expect(after.retainedMetadataRecords).toBe(8) | |
| 167 | const recovered = await recoverRuntimeTrustStore(reference) | |
| 168 | expect(recovered.attempt?.phase).toBe('recovered'); expect(recovered.releaseFloor).toBe(9) | |
| 169 | await fixture.publish(2, 2, 10) | |
| 170 | const fresh = await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 171 | expect(fresh.epoch).toBe(1); expect(fresh.releaseFloor).toBe(10); expect(fresh.generation).toBe(10) | |
| 172 | expect(fresh.retainedMetadataRecords).toBe(11) | |
| 173 | }) | |
| 174 | ||
| 175 | test('a clock adapter can be a class instance and is read exactly once', async () => { | |
| 176 | const item = await setup(), created = await createRuntimeTrustStore(item) | |
| 177 | class Clock { | |
| 178 | source = 'test-owned-clock' | |
| 179 | calls = 0 | |
| 180 | read() { this.calls++; return new Date('2030-01-01T00:00:00Z') } | |
| 181 | } | |
| 182 | const clock = new Clock() | |
| 183 | const result = await refreshRuntimeTrustStore({ ...item, directory: item.output, storeSha256: created.storeSha256, repository: item.source, clock }) | |
| 184 | expect(clock.calls).toBe(1) | |
| 185 | expect(result.clockFloorMs).toBe(Date.parse('2030-01-01T00:00:00Z')) | |
| 186 | }) | |
| 187 | ||
| 188 | test('duplicate role key IDs are rejected before a root can reset metadata floors', async () => { | |
| 189 | const root = await mkdtemp(join(tmpdir(), 'drone-store-same-authority-')); roots.push(root) | |
| 190 | const source = join(root, 'repo'), fixture = await epochRepository(source, true) | |
| 191 | const enrollmentDirectory = join(root, 'enrollment') | |
| 192 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) | |
| 193 | const initial = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) | |
| 194 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: initial.storeSha256 } | |
| 195 | const clock = { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 196 | await fixture.publish(1, 100, 9) | |
| 197 | await refreshRuntimeTrustStore({ ...reference, repository: source, clock }) | |
| 198 | await fixture.publish(2, 1, 9) | |
| 199 | const failure = await refreshRuntimeTrustStore({ ...reference, repository: source, clock }).then(() => undefined, error => error) | |
| 200 | expect(failure).toBeInstanceOf(MetadataVerificationError) | |
| 201 | expect(failure.role).toBe('root') | |
| 202 | expect(failure.cause.message).toBe('duplicate key IDs found') | |
| 203 | const result = await inspectRuntimeTrustStore(reference) | |
| 204 | expect(result.epoch).toBe(0) | |
| 205 | expect(result.generation).toBe(3) | |
| 206 | expect(result.releaseFloor).toBe(9) | |
| 207 | expect(result.metadata.map(row => [row.role, row.version])).toEqual([['root', 1], ['snapshot', 100], ['targets', 100], ['timestamp', 100]]) | |
| 208 | }) | |
| 209 | ||
| 210 | for (const status of [404, 403]) { | |
| 211 | test(`input progress HTTP ${status} rejection cannot masquerade as an absent rotated root`, async () => { | |
| 212 | const item = await setup({ rotate: true }), created = await createRuntimeTrustStore(item) | |
| 213 | const reference = { ...item, directory: item.output, storeSha256: created.storeSha256 } | |
| 214 | const sentinel = new DownloadHTTPError('Progress observer interrupted', status), events: string[] = [] | |
| 215 | const failure = await refreshRuntimeTrustStore({ ...reference, repository: item.source, clock: { source: 'test-clock', read: () => new Date('2030-01-01T00:00:00Z') }, onProgress(event) { | |
| 216 | events.push(`${event.phase}:${event.ordinal}`) | |
| 217 | if (event.phase === 'input-committed' && event.ordinal === 0) throw sentinel | |
| 218 | } }).then(() => undefined, error => error) | |
| 219 | expect(failure).toBeInstanceOf(Error) | |
| 220 | expect(failure).not.toBeInstanceOf(DownloadHTTPError) | |
| 221 | expect(failure.cause).toBe(sentinel) | |
| 222 | expect(events).toEqual(['input-committed:0']) | |
| 223 | const stopped = await inspectRuntimeTrustStore(reference) | |
| 224 | expect(stopped.generation).toBe(0) | |
| 225 | expect(stopped.metadata).toEqual(created.metadata) | |
| 226 | expect(stopped.releaseFloor).toBe(7) | |
| 227 | expect(stopped.attempt?.phase).toBe('maintenance') | |
| 228 | await rename(item.source, join(item.root, 'offline')) | |
| 229 | const recovered = await recoverRuntimeTrustStore(reference) | |
| 230 | expect(recovered.generation).toBe(1) | |
| 231 | expect(recovered.metadata.map(row => [row.role, row.version])).toEqual([['root', 2]]) | |
| 232 | expect(recovered.attempt?.phase).toBe('recovered') | |
| 233 | expect(recovered.releaseFloor).toBe(7) | |
| 234 | expect(recovered.physicalDeploymentAllowed).toBe(false) | |
| 235 | }) | |
| 236 | } | |