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.
| @@ -48,6 +48,9 @@ verification. No external network fetch or arbitrary URL option is provided. | ||
| 48 | 48 | The host additionally verifies that root and delegation key IDs equal SHA-256 |
| 49 | 49 | of their canonical key objects, using the upstream canonical JSON library. |
| 50 | 50 | This rejects arbitrary key-ID aliases before role verification. |
| 51 | Metadata versions must be positive safe integers. Expiration dates must be real | |
| 52 | UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes | |
| 53 | expiry comparisons; malformed or automatically normalized dates are rejected. | |
| 51 | 54 | |
| 52 | 55 | The selected target must have SHA-256, a nonzero length of at most 16 MiB and |
| 53 | 56 | exactly these fields in its signed `custom.drone` object: |
| @@ -115,8 +115,13 @@ function object(value: unknown): Record<string, unknown> { | ||
| 115 | 115 | if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata') |
| 116 | 116 | return value as Record<string, unknown> |
| 117 | 117 | } |
| 118 | function validateMetadataKeyIDs(bytes: Buffer) { | |
| 118 | function validateMetadata(bytes: Buffer) { | |
| 119 | 119 | const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed) |
| 120 | if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version') | |
| 121 | // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the | |
| 122 | // specified UTC calendar representation before the SDK performs its expiry comparison. | |
| 123 | const expires = signed.expires | |
| 124 | if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date') | |
| 120 | 125 | const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined |
| 121 | 126 | // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary |
| 122 | 127 | // identifier aliases by binding each declared ID to the canonical key object. |
| @@ -137,7 +142,7 @@ function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) { | ||
| 137 | 142 | |
| 138 | 143 | /** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */ |
| 139 | 144 | async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) { |
| 140 | validateMetadataKeyIDs(root) | |
| 145 | validateMetadata(root) | |
| 141 | 146 | const fetched = new Map<string, Buffer>() |
| 142 | 147 | let requests = 0, received = 0 |
| 143 | 148 | const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/` |
| @@ -154,7 +159,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string, | ||
| 154 | 159 | let bytes = fetched.get(path) |
| 155 | 160 | if (!bytes) { |
| 156 | 161 | bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit) |
| 157 | if (path.startsWith('metadata/')) validateMetadataKeyIDs(bytes) | |
| 162 | if (path.startsWith('metadata/')) validateMetadata(bytes) | |
| 158 | 163 | received += bytes.length |
| 159 | 164 | if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded') |
| 160 | 165 | fetched.set(path, bytes) |
| @@ -19,18 +19,18 @@ function signer(nonCanonical = false) { | ||
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | /** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */ |
| 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 } = {}) { | |
| 22 | export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) { | |
| 23 | 23 | await mkdir(join(directory, 'metadata'), { recursive: true }) |
| 24 | 24 | await mkdir(join(directory, 'targets/runtime'), { recursive: true }) |
| 25 | 25 | const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() } |
| 26 | const root = new Root(fields) | |
| 26 | const root = new Root({ ...fields, ...options.metadataFields?.root }) | |
| 27 | 27 | for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role) |
| 28 | 28 | const rootMetadata = new Metadata(root) |
| 29 | 29 | rootMetadata.sign(keys.root.sign) |
| 30 | 30 | const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON())) |
| 31 | 31 | await writeFile(join(directory, 'trusted-root.json'), rootBytes) |
| 32 | 32 | if (options.rotate) { |
| 33 | const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2 }) | |
| 33 | const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2, ...options.metadataFields?.rotatedRoot }) | |
| 34 | 34 | next.addKey(nextKey.key, 'root') |
| 35 | 35 | for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role) |
| 36 | 36 | const rotated = new Metadata(next) |
| @@ -41,17 +41,17 @@ export async function repository(directory: string, options: { custom?: Record<s | ||
| 41 | 41 | const image = Buffer.from('test payload only, never execute or flash\n') |
| 42 | 42 | const imageHash = digest(image) |
| 43 | 43 | const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom } |
| 44 | const targets = new Metadata(new Targets({ ...fields, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } })) | |
| 44 | const targets = new Metadata(new Targets({ ...fields, ...options.metadataFields?.targets, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } })) | |
| 45 | 45 | if (options.nonCanonicalDelegation) { |
| 46 | 46 | const delegated = signer(true) |
| 47 | 47 | targets.signed.unrecognizedFields.delegations = { keys: { [delegated.key.keyID]: delegated.key.toJSON() }, roles: [] } |
| 48 | 48 | } |
| 49 | 49 | targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign) |
| 50 | 50 | const targetsBytes = Buffer.from(JSON.stringify(targets.toJSON())) |
| 51 | const snapshot = new Metadata(new Snapshot({ ...fields, meta: { 'targets.json': new MetaFile({ version: 1, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } })) | |
| 51 | const snapshot = new Metadata(new Snapshot({ ...fields, ...options.metadataFields?.snapshot, meta: { 'targets.json': new MetaFile({ version: 1, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } })) | |
| 52 | 52 | snapshot.sign(keys.snapshot.sign) |
| 53 | 53 | const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON())) |
| 54 | const timestamp = new Metadata(new Timestamp({ ...fields, expires: options.expires ?? fields.expires, snapshotMeta: new MetaFile({ version: 1, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) })) | |
| 54 | const timestamp = new Metadata(new Timestamp({ ...fields, expires: options.expires ?? fields.expires, ...options.metadataFields?.timestamp, snapshotMeta: new MetaFile({ version: 1, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) })) | |
| 55 | 55 | timestamp.sign(keys.timestamp.sign) |
| 56 | 56 | await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON())) |
| 57 | 57 | await writeFile(join(directory, 'metadata/1.snapshot.json'), snapshotBytes) |
| @@ -168,3 +168,37 @@ test('admission owns the trust policy and inspection directory across asynchrono | ||
| 168 | 168 | stable.target = 'other/image.bin' |
| 169 | 169 | expect(await pendingStable).toEqual(accepted) |
| 170 | 170 | }, 60000) |
| 171 | ||
| 172 | ||
| 173 | test('reject malformed or normalized expiration dates instead of treating them as unexpired', async () => { | |
| 174 | for (const expires of ['not-a-date', '2035-02-30T00:00:00Z', '2035-01-01', '2035-01-01T00:00:00+01:00']) { | |
| 175 | const item = await setup({ expires }) | |
| 176 | await expect(stageRuntimeFirmware(item.input)).rejects.toThrow() | |
| 177 | await incomplete(item.output) | |
| 178 | } | |
| 179 | }) | |
| 180 | ||
| 181 | test('the bootstrap root and every fetched metadata role require valid expiration dates', async () => { | |
| 182 | for (const role of ['root', 'rotatedRoot', 'snapshot', 'targets'] as const) { | |
| 183 | const item = await setup({ rotate: role === 'rotatedRoot', metadataFields: { [role]: { expires: 'not-a-date' } } }) | |
| 184 | await expect(stageRuntimeFirmware(item.input)).rejects.toThrow() | |
| 185 | await incomplete(item.output) | |
| 186 | } | |
| 187 | }) | |
| 188 | ||
| 189 | test('metadata versions must be positive safe integers', async () => { | |
| 190 | for (const role of ['root', 'timestamp'] as const) { | |
| 191 | for (const version of [0, -1, 9007199254740992]) { | |
| 192 | const item = await setup({ metadataFields: { [role]: { version } } }) | |
| 193 | await expect(stageRuntimeFirmware(item.input)).rejects.toThrow() | |
| 194 | await incomplete(item.output) | |
| 195 | } | |
| 196 | } | |
| 197 | }) | |
| 198 | ||
| 199 | ||
| 200 | test('a valid UTC leap date remains accepted and independently verifiable', async () => { | |
| 201 | const item = await setup({ expires: '2036-02-29T12:34:56Z' }) | |
| 202 | const staged = await stageRuntimeFirmware(item.input) | |
| 203 | expect(await inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).toEqual(staged) | |
| 204 | }) | |