Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions src/canonicalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,31 @@ const SIGNED_FIELDS = [

/**
* Recursively sort object keys for deterministic JSON.
* Uses Object.create(null) for the result object to avoid
* prototype-pollution style assignments being silently
* discarded (or hijacked) by Object.prototype setters.
*/
function sortKeys(obj: unknown): unknown {
if (obj === null || typeof obj !== 'object') {
return obj;
}

if (Array.isArray(obj)) {
return obj.map(sortKeys);
}
const sorted: Record<string, unknown> = {};

const sorted = Object.create(null) as Record<string, unknown>;
const keys = Object.keys(obj as Record<string, unknown>).sort();

for (const key of keys) {
sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
Object.defineProperty(sorted, key, {
value: sortKeys((obj as Record<string, unknown>)[key]),
writable: true,
enumerable: true,
configurable: true,
});
}

return sorted;
}

Expand All @@ -77,15 +85,24 @@ function serializeValue(value: unknown): unknown {
* Extract and canonicalize receipt fields for signing.
*/
export function canonicalizeReceipt(receipt: Record<string, unknown>): string {
const canonical: Record<string, unknown> = {};

// Use a null-prototype object so that a malicious receipt cannot
// route `__proto__` (or any other inherited key) through
// Object.prototype setters and silently drop its signed value from
// the canonical bytes.
const canonical = Object.create(null) as Record<string, unknown>;

for (const field of SIGNED_FIELDS) {
const value = receipt[field];
if (value !== undefined && value !== null) {
canonical[field] = serializeValue(value);
Object.defineProperty(canonical, field, {
value: serializeValue(value),
writable: true,
enumerable: true,
configurable: true,
});
}
}

// Sort keys and stringify deterministically
const sorted = sortKeys(canonical);
return JSON.stringify(sorted);
Expand Down
30 changes: 30 additions & 0 deletions tests/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { verifyReceipt } from '../src/verify.js';
import { canonicalizeReceipt, canonicalizeReceiptBytes } from '../src/canonicalize.js';

async function loadJson(name: string): Promise<unknown> {
const path = resolve(process.cwd(), 'tests/fixtures', name);
Expand Down Expand Up @@ -40,6 +41,35 @@ describe('verifyReceipt', () => {
}
});

it('canonicalize preserves nested __proto__ keys inside signed fields', () => {
// Forge a requestJson whose value contains a nested __proto__ own
// key. After the receipt is signed, mutate that value and confirm
// the canonical signing bytes change. If the canonicalizer routed
// __proto__ through Object.prototype.__proto__ the value change
// would be silently dropped and the bytes would not change.
const inner: Record<string, unknown> = {};
Object.defineProperty(inner, '__proto__', {
value: { evil: 'first' },
writable: true,
enumerable: true,
configurable: true,
});
const base = {
id: 'rcpt_proto_001',
requestJson: inner,
} as unknown as Record<string, unknown>;
const originalBytes = canonicalizeReceiptBytes(base);

// Mutate the nested __proto__ value. With the old sortKeys
// implementation, this would either silently no-op or rewrite the
// prototype chain instead of changing the own key. With the fix
// the canonical bytes must change.
(base.requestJson as unknown as { __proto__: { evil: string } }).__proto__.evil = 'second';
const mutatedBytes = canonicalizeReceiptBytes(base);

expect(mutatedBytes.equals(originalBytes)).toBe(false);
});

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 });
Expand Down