diff --git a/src/verify.ts b/src/verify.ts index 9a3280c..24bd762 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -23,6 +23,27 @@ const REQUIRED_SIGNED_FIELDS = [ 'createdAt', ] as const; +const RECEIPT_VERSION = 'v1'; + +const REQUIRED_STRING_FIELDS = [ + 'id', + 'companyId', + 'idemKey', + 'agentId', + 'runId', + 'inputHash', + 'status', + 'riskTier', + 'policyVersion', + 'summary', + 'receiptVersion', + 'canonicalization', + 'signatureAlg', + 'signatureKeyId', + 'expiresAt', + 'createdAt', +] as const; + export type VerifyResult = | { verified: true; @@ -63,6 +84,16 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function malformed(receipt: Record, errorMessage: string): VerifyResult { + return { + verified: false, + exitCode: 3, + errorCode: 'MALFORMED_RECEIPT', + errorMessage, + receiptId: typeof receipt.id === 'string' ? receipt.id : undefined, + }; +} + export async function verifyReceipt(receipt: unknown, options: VerifyOptions): Promise { if (!isObject(receipt)) { return { @@ -74,17 +105,29 @@ export async function verifyReceipt(receipt: unknown, options: VerifyOptions): P } for (const field of REQUIRED_SIGNED_FIELDS) { - if (!(field in receipt)) { - return { - verified: false, - exitCode: 3, - errorCode: 'MALFORMED_RECEIPT', - errorMessage: `missing required signed field: ${field}`, - receiptId: typeof receipt.id === 'string' ? receipt.id : undefined, - }; + if (!Object.hasOwn(receipt, field) || receipt[field] === undefined || receipt[field] === null) { + return malformed(receipt, `missing required signed field: ${field}`); + } + } + + for (const field of REQUIRED_STRING_FIELDS) { + if (typeof receipt[field] !== 'string') { + return malformed(receipt, `required signed field must be a string: ${field}`); } } + if (!isObject(receipt.requestJson) || Array.isArray(receipt.requestJson)) { + return malformed(receipt, 'requestJson must be a JSON object'); + } + + if (!Array.isArray(receipt.reasonCodes)) { + return malformed(receipt, 'reasonCodes must be an array'); + } + + if (receipt.receiptVersion !== RECEIPT_VERSION) { + return malformed(receipt, 'unsupported receipt version'); + } + if (receipt.canonicalization !== CANONICALIZATION_VERSION) { return { verified: false, diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 0fd26db..721a56d 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -1,6 +1,8 @@ import { readFile } from 'node:fs/promises'; +import { createPrivateKey, sign } from 'node:crypto'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { canonicalizeReceiptBytes } from '../src/canonicalize.js'; import { verifyReceipt } from '../src/verify.js'; async function loadJson(name: string): Promise { @@ -9,6 +11,15 @@ async function loadJson(name: string): Promise { } const keyFile = resolve(process.cwd(), 'tests/fixtures/public-key.pem'); +const privateKeyFile = resolve(process.cwd(), 'tests/fixtures/private-key.pem'); + +async function signFixtureReceipt(receipt: Record): Promise { + receipt.signatureValue = sign( + null, + canonicalizeReceiptBytes(receipt), + createPrivateKey(await readFile(privateKeyFile)), + ).toString('base64'); +} describe('verifyReceipt', () => { it('verifies a valid receipt', async () => { @@ -40,6 +51,35 @@ describe('verifyReceipt', () => { } }); + it('returns malformed for an unsupported receipt version', async () => { + const receipt = (await loadJson('valid.json')) as Record; + receipt.receiptVersion = 'v2'; + await signFixtureReceipt(receipt); + + const result = await verifyReceipt(receipt, { keyFile, noNetwork: true }); + expect(result.verified).toBe(false); + if (!result.verified) { + expect(result.exitCode).toBe(3); + expect(result.errorCode).toBe('MALFORMED_RECEIPT'); + expect(result.errorMessage).toBe('unsupported receipt version'); + } + }); + + it('returns malformed when a required signed field is null', async () => { + const receipt = (await loadJson('valid.json')) as Record; + delete receipt.requestJson; + await signFixtureReceipt(receipt); + receipt.requestJson = null; + + const result = await verifyReceipt(receipt, { keyFile, noNetwork: true }); + expect(result.verified).toBe(false); + if (!result.verified) { + expect(result.exitCode).toBe(3); + expect(result.errorCode).toBe('MALFORMED_RECEIPT'); + expect(result.errorMessage).toBe('missing required signed field: requestJson'); + } + }); + it('returns signature invalid for wrong key id pin', async () => { const receipt = await loadJson('wrong-key.json'); const result = await verifyReceipt(receipt, { keyFile, keyId: 'pp-test-2026-q2', noNetwork: true });