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
@@ -48,6 +48,9 @@ verification. No external network fetch or arbitrary URL option is provided.
4848The host additionally verifies that root and delegation key IDs equal SHA-256
4949of their canonical key objects, using the upstream canonical JSON library.
5050This rejects arbitrary key-ID aliases before role verification.
51Metadata versions must be positive safe integers. Expiration dates must be real
52UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes
53expiry comparisons; malformed or automatically normalized dates are rejected.
5154
5255The selected target must have SHA-256, a nonzero length of at most 16 MiB and
5356exactly these fields in its signed `custom.drone` object:
resources/functions/drone/updates/staging.tsmodified+8-3
Changes to resources/functions/drone/updates/staging.ts
@@ -115,8 +115,13 @@ function object(value: unknown): Record<string, unknown> {
115115 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
116116 return value as Record<string, unknown>
117117}
118function validateMetadataKeyIDs(bytes: Buffer) {
118function validateMetadata(bytes: Buffer) {
119119 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')
120125 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
121126 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
122127 // identifier aliases by binding each declared ID to the canonical key object.
@@ -137,7 +142,7 @@ function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
137142
138143/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
139144async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
140 validateMetadataKeyIDs(root)
145 validateMetadata(root)
141146 const fetched = new Map<string, Buffer>()
142147 let requests = 0, received = 0
143148 const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/`
@@ -154,7 +159,7 @@ async function verifyRepository(repository: string, root: Buffer, work: string,
154159 let bytes = fetched.get(path)
155160 if (!bytes) {
156161 bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit)
157 if (path.startsWith('metadata/')) validateMetadataKeyIDs(bytes)
162 if (path.startsWith('metadata/')) validateMetadata(bytes)
158163 received += bytes.length
159164 if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded')
160165 fetched.set(path, bytes)
tests/fixtures/updates/repository.tsmodified+6-6
Changes to tests/fixtures/updates/repository.ts
@@ -19,18 +19,18 @@ function signer(nonCanonical = false) {
1919}
2020
2121/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
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 } = {}) {
22export 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 } = {}) {
2323 await mkdir(join(directory, 'metadata'), { recursive: true })
2424 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
2525 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 })
2727 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role)
2828 const rootMetadata = new Metadata(root)
2929 rootMetadata.sign(keys.root.sign)
3030 const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON()))
3131 await writeFile(join(directory, 'trusted-root.json'), rootBytes)
3232 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 })
3434 next.addKey(nextKey.key, 'root')
3535 for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role)
3636 const rotated = new Metadata(next)
@@ -41,17 +41,17 @@ export async function repository(directory: string, options: { custom?: Record<s
4141 const image = Buffer.from('test payload only, never execute or flash\n')
4242 const imageHash = digest(image)
4343 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 } } }) } }))
4545 if (options.nonCanonicalDelegation) {
4646 const delegated = signer(true)
4747 targets.signed.unrecognizedFields.delegations = { keys: { [delegated.key.keyID]: delegated.key.toJSON() }, roles: [] }
4848 }
4949 targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign)
5050 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) } }) } }))
5252 snapshot.sign(keys.snapshot.sign)
5353 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) } }) }))
5555 timestamp.sign(keys.timestamp.sign)
5656 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
5757 await writeFile(join(directory, 'metadata/1.snapshot.json'), snapshotBytes)
tests/unit/updates.test.tsmodified+34-0
Changes to tests/unit/updates.test.ts
@@ -168,3 +168,37 @@ test('admission owns the trust policy and inspection directory across asynchrono
168168 stable.target = 'other/image.bin'
169169 expect(await pendingStable).toEqual(accepted)
170170}, 60000)
171
172
173test('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
181test('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
189test('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
200test('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})