From dbbdb3f0eb4a15668c6ee5c732f02a0d3e654170 Mon Sep 17 00:00:00 2001 From: manuelsampedro1 <202281585+manuelsampedro1@users.noreply.github.com> Date: Tue, 19 May 2026 10:24:15 +0200 Subject: [PATCH 1/2] Reject signed non-authorizing receipt statuses --- src/verify.ts | 10 ++++++++++ tests/verify.test.ts | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/verify.ts b/src/verify.ts index 9a3280c..83580b5 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -160,6 +160,16 @@ export async function verifyReceipt(receipt: unknown, options: VerifyOptions): P } const status = String(receipt.status); + if (status !== 'valid' && status !== 'revoked') { + return { + verified: false, + exitCode: 3, + errorCode: 'MALFORMED_RECEIPT', + errorMessage: `unsupported receipt status: ${status}`, + receiptId: receipt.id as string, + }; + } + if (expiresAt.getTime() <= now.getTime() || status === 'revoked') { return { verified: false, diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 0fd26db..863e9c6 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -1,7 +1,9 @@ import { readFile } from 'node:fs/promises'; +import { sign as signSignature } from 'node:crypto'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { verifyReceipt } from '../src/verify.js'; +import { canonicalizeReceiptBytes } from '../src/canonicalize.js'; async function loadJson(name: string): Promise { const path = resolve(process.cwd(), 'tests/fixtures', name); @@ -9,6 +11,7 @@ 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'); describe('verifyReceipt', () => { it('verifies a valid receipt', async () => { @@ -30,6 +33,23 @@ describe('verifyReceipt', () => { } }); + it('rejects a signed receipt with a non-authorizing status', async () => { + const receipt = (await loadJson('valid.json')) as Record; + receipt.status = 'DENIED'; + receipt.signatureValue = signSignature( + null, + canonicalizeReceiptBytes(receipt), + await readFile(privateKeyFile, 'utf8'), + ).toString('base64'); + + 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'); + } + }); + it('returns malformed for malformed receipt', async () => { const receipt = await loadJson('malformed.json'); const result = await verifyReceipt(receipt, { keyFile, noNetwork: true }); From 0268a80aa5e1426ba4886e44e2a40e90453dbef5 Mon Sep 17 00:00:00 2001 From: Manuel Sampedro <202281585+manuelsampedro1@users.noreply.github.com> Date: Tue, 19 May 2026 19:57:08 +0200 Subject: [PATCH 2/2] Reject noncanonical receipt statuses before verification --- src/verify.ts | 73 ++++++++++++++++++++++++++++++-------------- tests/verify.test.ts | 26 ++++++++++++++++ 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/verify.ts b/src/verify.ts index 83580b5..7571cd3 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -59,10 +59,42 @@ export type VerifyOptions = { now?: Date; }; +type ReceiptStatus = 'valid' | 'revoked'; + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function parseReceiptStatus(receipt: Record): { ok: true; status: ReceiptStatus } | { ok: false; result: VerifyResult } { + if (typeof receipt.status !== 'string') { + return { + ok: false, + result: { + verified: false, + exitCode: 3, + errorCode: 'MALFORMED_RECEIPT', + errorMessage: 'receipt status must be a canonical string', + receiptId: receipt.id as string, + }, + }; + } + + if (receipt.status !== 'valid' && receipt.status !== 'revoked') { + return { + ok: false, + result: { + verified: false, + exitCode: 3, + errorCode: 'MALFORMED_RECEIPT', + errorMessage: `unsupported receipt status: ${receipt.status}`, + receiptId: receipt.id as string, + }, + }; + } + + return { ok: true, status: receipt.status }; +} + export async function verifyReceipt(receipt: unknown, options: VerifyOptions): Promise { if (!isObject(receipt)) { return { @@ -115,6 +147,24 @@ export async function verifyReceipt(receipt: unknown, options: VerifyOptions): P }; } + const statusResult = parseReceiptStatus(receipt); + if (!statusResult.ok) { + return statusResult.result; + } + const { status } = statusResult; + + const now = options.now ?? new Date(); + const expiresAt = new Date(receipt.expiresAt as string); + if (Number.isNaN(expiresAt.getTime())) { + return { + verified: false, + exitCode: 3, + errorCode: 'MALFORMED_RECEIPT', + errorMessage: 'expiresAt is not a valid ISO timestamp', + receiptId: receipt.id as string, + }; + } + const keyResult = await resolvePublicKey({ keyFile: options.keyFile, keyUrl: options.keyUrl, @@ -147,29 +197,6 @@ export async function verifyReceipt(receipt: unknown, options: VerifyOptions): P }; } - const now = options.now ?? new Date(); - const expiresAt = new Date(receipt.expiresAt as string); - if (Number.isNaN(expiresAt.getTime())) { - return { - verified: false, - exitCode: 3, - errorCode: 'MALFORMED_RECEIPT', - errorMessage: 'expiresAt is not a valid ISO timestamp', - receiptId: receipt.id as string, - }; - } - - const status = String(receipt.status); - if (status !== 'valid' && status !== 'revoked') { - return { - verified: false, - exitCode: 3, - errorCode: 'MALFORMED_RECEIPT', - errorMessage: `unsupported receipt status: ${status}`, - receiptId: receipt.id as string, - }; - } - if (expiresAt.getTime() <= now.getTime() || status === 'revoked') { return { verified: false, diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 863e9c6..0f8b702 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -50,6 +50,32 @@ describe('verifyReceipt', () => { } }); + it('rejects non-canonical status values at the schema boundary', async () => { + const receipt = (await loadJson('valid.json')) as Record; + receipt.status = 'valid '; + + 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 status: valid '); + } + }); + + it('rejects non-string status values at the schema boundary', async () => { + const receipt = (await loadJson('valid.json')) as Record; + receipt.status = true; + + 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('receipt status must be a canonical string'); + } + }); + it('returns malformed for malformed receipt', async () => { const receipt = await loadJson('malformed.json'); const result = await verifyReceipt(receipt, { keyFile, noNetwork: true });