ReviewOS

also looking at this

stacks/drone

feat: enroll protected runtime trust anchors

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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/runtime-updates.mdmodified+3-0
Changes to docs/runtime-updates.md
@@ -45,6 +45,9 @@ rotation, signature thresholds, expiration, inter-role versions/hashes and
4545target length/hashes. A private, randomly addressed loopback server supplies
4646bounded local files to the client's public HTTP interface. It is stopped after
4747verification. No external network fetch or arbitrary URL option is provided.
48The host additionally verifies that root and delegation key IDs equal SHA-256
49of their canonical key objects, using the upstream canonical JSON library.
50This rejects arbitrary key-ID aliases before role verification.
4851
4952The selected target must have SHA-256, a nonzero length of at most 16 MiB and
5053exactly these fields in its signed `custom.drone` object:
resources/functions/drone/updates/staging.tsmodified+12-0
Changes to resources/functions/drone/updates/staging.ts
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path'
44import { tmpdir } from 'node:os'
55import { Readable } from 'node:stream'
66import { isDeepStrictEqual } from 'node:util'
7import { canonicalize } from '@tufjs/canonical-json'
78import { Updater } from 'tuf-js'
89import type { TargetFile } from 'tuf-js'
910
@@ -114,6 +115,15 @@ function object(value: unknown): Record<string, unknown> {
114115 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
115116 return value as Record<string, unknown>
116117}
118function validateMetadataKeyIDs(bytes: Buffer) {
119 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
120 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
121 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
122 // identifier aliases by binding each declared ID to the canonical key object.
123 for (const [id, key] of Object.entries(keys ?? {})) {
124 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
125 }
126}
117127function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
118128 const data = object(info.custom.drone)
119129 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
@@ -127,6 +137,7 @@ function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
127137
128138/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
129139async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
140 validateMetadataKeyIDs(root)
130141 const fetched = new Map<string, Buffer>()
131142 let requests = 0, received = 0
132143 const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/`
@@ -143,6 +154,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string,
143154 let bytes = fetched.get(path)
144155 if (!bytes) {
145156 bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit)
157 if (path.startsWith('metadata/')) validateMetadataKeyIDs(bytes)
146158 received += bytes.length
147159 if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded')
148160 fetched.set(path, bytes)
tests/fixtures/updates/repository.tsmodified+12-5
Changes to tests/fixtures/updates/repository.ts
@@ -2,24 +2,27 @@ import { generateKeyPairSync, sign, createHash } from 'node:crypto'
22import { mkdir, writeFile } from 'node:fs/promises'
33import { join } from 'node:path'
44import { Key, Metadata, MetaFile, Root, Signature, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models'
5import { canonicalize } from '@tufjs/canonical-json'
56
67export const target = 'runtime/pico2-bench.bin'
78export const platform = 'pico2-neutral-bench-v1'
89export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
910const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' }
1011
11function signer() {
12function signer(nonCanonical = false) {
1213 const pair = generateKeyPairSync('ed25519')
1314 const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex')
14 const key = new Key({ keyID: digest(Buffer.from(publicKey, 'hex')), keyType: 'ed25519', scheme: 'ed25519', keyVal: { public: publicKey } })
15 const keyObject = { keytype: 'ed25519', scheme: 'ed25519', keyval: { public: publicKey } }
16 const keyID = digest(nonCanonical ? Buffer.from(publicKey, 'hex') : Buffer.from(canonicalize(keyObject)))
17 const key = new Key({ keyID, keyType: 'ed25519', scheme: 'ed25519', keyVal: { public: publicKey } })
1518 return { key, sign: (data: Buffer) => new Signature({ keyID: key.keyID, sig: sign(null, data, pair.privateKey).toString('hex') }) }
1619}
1720
1821/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
19export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean } = {}) {
22export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
2023 await mkdir(join(directory, 'metadata'), { recursive: true })
2124 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
22 const keys = { root: signer(), targets: signer(), snapshot: signer(), timestamp: signer() }
25 const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() }
2326 const root = new Root(fields)
2427 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role)
2528 const rootMetadata = new Metadata(root)
@@ -27,7 +30,7 @@ export async function repository(directory: string, options: { custom?: Record<s
2730 const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON()))
2831 await writeFile(join(directory, 'trusted-root.json'), rootBytes)
2932 if (options.rotate) {
30 const nextKey = signer(), next = new Root({ ...fields, version: 2 })
33 const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2 })
3134 next.addKey(nextKey.key, 'root')
3235 for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role)
3336 const rotated = new Metadata(next)
@@ -39,6 +42,10 @@ export async function repository(directory: string, options: { custom?: Record<s
3942 const imageHash = digest(image)
4043 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom }
4144 const targets = new Metadata(new Targets({ ...fields, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } }))
45 if (options.nonCanonicalDelegation) {
46 const delegated = signer(true)
47 targets.signed.unrecognizedFields.delegations = { keys: { [delegated.key.keyID]: delegated.key.toJSON() }, roles: [] }
48 }
4249 targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign)
4350 const targetsBytes = Buffer.from(JSON.stringify(targets.toJSON()))
4451 const snapshot = new Metadata(new Snapshot({ ...fields, meta: { 'targets.json': new MetaFile({ version: 1, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } }))
tests/unit/updates.test.tsmodified+8-0
Changes to tests/unit/updates.test.ts
@@ -142,6 +142,14 @@ test('repository directories writable by other principals cannot enter verificat
142142 await incomplete(item.output)
143143})
144144
145test('root, rotated root and delegation key identifiers must bind canonical key data', async () => {
146 for (const options of [{ nonCanonicalRoot: true }, { rotate: true, nonCanonicalRotation: true }, { nonCanonicalDelegation: true }]) {
147 const item = await setup(options)
148 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
149 await incomplete(item.output)
150 }
151})
152
145153test('admission owns the trust policy and inspection directory across asynchronous verification', async () => {
146154 const stage = await setup(), input = { ...stage.input, minimumReleaseSequence: 8 }
147155 const pendingStage = stageRuntimeFirmware(input)