also looking at this
feat: enroll protected runtime trust anchors
#43
33 files
+1297
-18
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -45,6 +45,9 @@ rotation, signature thresholds, expiration, inter-role versions/hashes and | ||
| 45 | 45 | target length/hashes. A private, randomly addressed loopback server supplies |
| 46 | 46 | bounded local files to the client's public HTTP interface. It is stopped after |
| 47 | 47 | verification. No external network fetch or arbitrary URL option is provided. |
| 48 | The host additionally verifies that root and delegation key IDs equal SHA-256 | |
| 49 | of their canonical key objects, using the upstream canonical JSON library. | |
| 50 | This rejects arbitrary key-ID aliases before role verification. | |
| 48 | 51 | |
| 49 | 52 | The selected target must have SHA-256, a nonzero length of at most 16 MiB and |
| 50 | 53 | exactly these fields in its signed `custom.drone` object: |
| @@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path' | ||
| 4 | 4 | import { tmpdir } from 'node:os' |
| 5 | 5 | import { Readable } from 'node:stream' |
| 6 | 6 | import { isDeepStrictEqual } from 'node:util' |
| 7 | import { canonicalize } from '@tufjs/canonical-json' | |
| 7 | 8 | import { Updater } from 'tuf-js' |
| 8 | 9 | import type { TargetFile } from 'tuf-js' |
| 9 | 10 | |
| @@ -114,6 +115,15 @@ function object(value: unknown): Record<string, unknown> { | ||
| 114 | 115 | if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata') |
| 115 | 116 | return value as Record<string, unknown> |
| 116 | 117 | } |
| 118 | function 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 | } | |
| 117 | 127 | function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { |
| 118 | 128 | const data = object(info.custom.drone) |
| 119 | 129 | const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile'] |
| @@ -127,6 +137,7 @@ function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { | ||
| 127 | 137 | |
| 128 | 138 | /** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */ |
| 129 | 139 | async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) { |
| 140 | validateMetadataKeyIDs(root) | |
| 130 | 141 | const fetched = new Map<string, Buffer>() |
| 131 | 142 | let requests = 0, received = 0 |
| 132 | 143 | const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/` |
| @@ -143,6 +154,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string, | ||
| 143 | 154 | let bytes = fetched.get(path) |
| 144 | 155 | if (!bytes) { |
| 145 | 156 | bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit) |
| 157 | if (path.startsWith('metadata/')) validateMetadataKeyIDs(bytes) | |
| 146 | 158 | received += bytes.length |
| 147 | 159 | if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded') |
| 148 | 160 | fetched.set(path, bytes) |
| @@ -2,24 +2,27 @@ import { generateKeyPairSync, sign, createHash } from 'node:crypto' | ||
| 2 | 2 | import { mkdir, writeFile } from 'node:fs/promises' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { Key, Metadata, MetaFile, Root, Signature, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models' |
| 5 | import { canonicalize } from '@tufjs/canonical-json' | |
| 5 | 6 | |
| 6 | 7 | export const target = 'runtime/pico2-bench.bin' |
| 7 | 8 | export const platform = 'pico2-neutral-bench-v1' |
| 8 | 9 | export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') |
| 9 | 10 | const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' } |
| 10 | 11 | |
| 11 | function signer() { | |
| 12 | function signer(nonCanonical = false) { | |
| 12 | 13 | const pair = generateKeyPairSync('ed25519') |
| 13 | 14 | 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 } }) | |
| 15 | 18 | return { key, sign: (data: Buffer) => new Signature({ keyID: key.keyID, sig: sign(null, data, pair.privateKey).toString('hex') }) } |
| 16 | 19 | } |
| 17 | 20 | |
| 18 | 21 | /** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */ |
| 19 | export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean } = {}) { | |
| 22 | export 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 } = {}) { | |
| 20 | 23 | await mkdir(join(directory, 'metadata'), { recursive: true }) |
| 21 | 24 | 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() } | |
| 23 | 26 | const root = new Root(fields) |
| 24 | 27 | for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role) |
| 25 | 28 | const rootMetadata = new Metadata(root) |
| @@ -27,7 +30,7 @@ export async function repository(directory: string, options: { custom?: Record<s | ||
| 27 | 30 | const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON())) |
| 28 | 31 | await writeFile(join(directory, 'trusted-root.json'), rootBytes) |
| 29 | 32 | 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 }) | |
| 31 | 34 | next.addKey(nextKey.key, 'root') |
| 32 | 35 | for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role) |
| 33 | 36 | const rotated = new Metadata(next) |
| @@ -39,6 +42,10 @@ export async function repository(directory: string, options: { custom?: Record<s | ||
| 39 | 42 | const imageHash = digest(image) |
| 40 | 43 | const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom } |
| 41 | 44 | 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 | } | |
| 42 | 49 | targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign) |
| 43 | 50 | const targetsBytes = Buffer.from(JSON.stringify(targets.toJSON())) |
| 44 | 51 | const snapshot = new Metadata(new Snapshot({ ...fields, meta: { 'targets.json': new MetaFile({ version: 1, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } })) |
| @@ -142,6 +142,14 @@ test('repository directories writable by other principals cannot enter verificat | ||
| 142 | 142 | await incomplete(item.output) |
| 143 | 143 | }) |
| 144 | 144 | |
| 145 | test('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 | ||
| 145 | 153 | test('admission owns the trust policy and inspection directory across asynchronous verification', async () => { |
| 146 | 154 | const stage = await setup(), input = { ...stage.input, minimumReleaseSequence: 8 } |
| 147 | 155 | const pendingStage = stageRuntimeFirmware(input) |