diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 58f2df6..e6d3567 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -1,6 +1,7 @@ import { ValidPayClient } from '../src/client'; import { configure } from '../src/index'; import { + buildAad, buildKeyMap, combineKeyShares, computeCommitmentHash, @@ -95,7 +96,8 @@ describe('createIntent', () => { expect(body.split_key).toBeUndefined(); // Round-trip the ciphertext to confirm the SDK encrypted what we passed. - const decrypted = await decrypt(body.encrypted_payload as string, result.key); + // M-5: the blob is AAD-bound, so pass the same AAD the create call used. + const decrypted = await decrypt(body.encrypted_payload as string, result.key, buildAad('check')); expect(JSON.parse(decrypted)).toEqual({ payee: 'Alice', amount: 1500 }); }); @@ -169,6 +171,7 @@ describe('createIntentBatch', () => { const decrypted0 = await decrypt( body.intents[0].encrypted_payload as string, results[0].key, + buildAad('check'), ); expect(JSON.parse(decrypted0)).toEqual({ i: 0 }); }); @@ -315,7 +318,7 @@ describe('split-key flow', () => { // Reconstruct the master key from Share A + fragment B, decrypt the body. const fullKey = combineKeyShares(result.key, body.key_fragment_b as string); - const decrypted = await decrypt(body.encrypted_payload as string, fullKey); + const decrypted = await decrypt(body.encrypted_payload as string, fullKey, buildAad('check')); expect(JSON.parse(decrypted)).toEqual({ secret: 1 }); }); @@ -449,7 +452,7 @@ describe('createBoundIntent', () => { threshold: 7, }); const body = calls[0].body as Record; - const decrypted = await decrypt(body.encrypted_payload as string, result.key); + const decrypted = await decrypt(body.encrypted_payload as string, result.key, buildAad('check')); const obj = JSON.parse(decrypted); expect(obj.amount).toBe(100); expect(obj._binding_hash).toMatch(/^[0-9a-f]{16}$/); diff --git a/__tests__/crypto.test.ts b/__tests__/crypto.test.ts index e880429..7fd5abe 100644 --- a/__tests__/crypto.test.ts +++ b/__tests__/crypto.test.ts @@ -1,4 +1,5 @@ import { + buildAad, buildKeyMap, combineKeyShares, computeCommitmentHash, @@ -17,6 +18,29 @@ beforeAll(() => { configure({ crypto: new NodeCryptoProvider() }); }); +describe('AAD binding (M-5)', () => { + test('round-trips with matching AAD', async () => { + const key = await generateKey(); + const aad = buildAad('check', null, '2026-08-01T00:00:00Z'); + expect(await decrypt(await encrypt('{"amount":100}', key, aad), key, aad)).toBe( + '{"amount":100}', + ); + }); + + test('fails when the AAD is altered', async () => { + const key = await generateKey(); + const blob = await encrypt('{"amount":100}', key, buildAad('check')); + // The configured provider's GCM tag check rejects the altered AAD. + await expect(decrypt(blob, key, buildAad('other'))).rejects.toThrow(); + }); + + test('produces the canonical compact, epoch-ms form (cross-SDK interop)', () => { + expect(buildAad('check', null, '2026-08-01T00:00:00Z')).toBe( + '{"document_type":"check","valid_from":null,"valid_until":1785542400000}', + ); + }); +}); + describe('generateKey', () => { test('returns a base64 string that decodes to 32 bytes', async () => { const k = await generateKey(); diff --git a/__tests__/testProvider.ts b/__tests__/testProvider.ts index d231838..f06f4bd 100644 --- a/__tests__/testProvider.ts +++ b/__tests__/testProvider.ts @@ -12,19 +12,20 @@ export class NodeCryptoProvider implements CryptoProvider { return new Uint8Array(nodeCrypto.randomBytes(length)); } - async encrypt(plaintext: Uint8Array, key: Uint8Array): Promise { + async encrypt(plaintext: Uint8Array, key: Uint8Array, aad?: Uint8Array): Promise { const iv = nodeCrypto.randomBytes(IV_BYTES); const cipher = nodeCrypto.createCipheriv( 'aes-256-gcm', Buffer.from(key), iv, ); + if (aad) cipher.setAAD(Buffer.from(aad)); const ct = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, ct]).toString('base64'); } - async decrypt(blob: string, key: Uint8Array): Promise { + async decrypt(blob: string, key: Uint8Array, aad?: Uint8Array): Promise { const buf = Buffer.from(blob, 'base64'); if (buf.length < IV_BYTES + TAG_BYTES + 1) { throw new Error('Blob too short'); @@ -38,6 +39,7 @@ export class NodeCryptoProvider implements CryptoProvider { iv, ); decipher.setAuthTag(tag); + if (aad) decipher.setAAD(Buffer.from(aad)); const pt = Buffer.concat([decipher.update(ct), decipher.final()]); return new Uint8Array(pt); } diff --git a/src/client.ts b/src/client.ts index 0ddcdd7..3a9e4d1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import { ValidPayError } from './errors'; import { + buildAad, buildKeyMap, combineKeyShares, computeCommitmentHash, @@ -56,10 +57,14 @@ interface IntentApiResponse { issuer_verified?: boolean; registered_at?: string; status?: string; + document_type?: string; commitment_hash?: string | null; /** 1 = legacy SHA-256(plaintext), skipped on verify; 2 = SHA-256(ciphertext), * enforced. Absent is treated as 1 (Prompt 097 C-1). */ commitment_version?: number; + /** 1 = no AAD (legacy); 2 = {document_type, valid_from, valid_until} bound + * as AES-GCM AAD. Absent is treated as 1 (Prompt 097 M-5). */ + encryption_version?: number; split_key?: boolean; selective_disclosure?: boolean; encrypted_key_map?: string | null; @@ -107,7 +112,9 @@ export class ValidPayClient { const key = await generateKey(); const plaintext = JSON.stringify(payload); - const encryptedPayload = await encrypt(plaintext, key); + // M-5: bind document_type + validity window as AAD. + const aad = buildAad(documentType, opts.validFrom, opts.validUntil); + const encryptedPayload = await encrypt(plaintext, key, aad); // Commitment v2: hash the ciphertext, not the plaintext (C-1). const commitmentHash = await computeCommitmentHash(encryptedPayload); @@ -115,6 +122,7 @@ export class ValidPayClient { document_type: documentType, encrypted_payload: encryptedPayload, commitment_hash: commitmentHash, + encryption_version: 2, }; if (opts.validFrom != null) body.valid_from = opts.validFrom; if (opts.validUntil != null) body.valid_until = opts.validUntil; @@ -167,12 +175,15 @@ export class ValidPayClient { const k = await generateKey(); keys.push(k); const plaintext = JSON.stringify(item.payload); - const itemEncryptedPayload = await encrypt(plaintext, k); + // M-5: bind document_type + validity window as AAD per item. + const itemAad = buildAad(item.documentType, item.validFrom, item.validUntil); + const itemEncryptedPayload = await encrypt(plaintext, k, itemAad); const reqItem: Record = { document_type: item.documentType, encrypted_payload: itemEncryptedPayload, // Commitment v2: hash the ciphertext, not the plaintext (C-1). commitment_hash: await computeCommitmentHash(itemEncryptedPayload), + encryption_version: 2, }; if (item.validFrom != null) reqItem.valid_from = item.validFrom; if (item.validUntil != null) reqItem.valid_until = item.validUntil; @@ -253,7 +264,8 @@ export class ValidPayClient { // Commitment check over the ciphertext (C-1); legacy v1 skips. const integrityVerified = await this.checkCommitmentHash(data); - const decrypted = await decrypt(data.encrypted_payload, key); + // M-5: reconstruct the AAD for v2 intents. + const decrypted = await decrypt(data.encrypted_payload, key, this.aadFor(data)); const payload = parseJsonOrThrow(decrypted); return { @@ -287,7 +299,9 @@ export class ValidPayClient { const fullKey = await generateKey(); const [shareA, shareB] = splitKey(fullKey); const plaintext = JSON.stringify(payload); - const encryptedPayload = await encrypt(plaintext, fullKey); + // M-5: bind document_type + validity window as AAD. + const aad = buildAad(documentType, opts.validFrom, opts.validUntil); + const encryptedPayload = await encrypt(plaintext, fullKey, aad); // Commitment v2: hash the ciphertext, not the plaintext (C-1). const commitmentHash = await computeCommitmentHash(encryptedPayload); @@ -295,6 +309,7 @@ export class ValidPayClient { document_type: documentType, encrypted_payload: encryptedPayload, commitment_hash: commitmentHash, + encryption_version: 2, split_key: true, key_fragment_b: shareB, }; @@ -366,7 +381,8 @@ export class ValidPayClient { // Commitment check over the ciphertext (C-1); legacy v1 skips. const integrityVerified = await this.checkCommitmentHash(data); const fullKey = combineKeyShares(shareA, shareB); - const decrypted = await decrypt(data.encrypted_payload, fullKey); + // M-5: reconstruct the AAD for v2 intents. + const decrypted = await decrypt(data.encrypted_payload, fullKey, this.aadFor(data)); const payload = parseJsonOrThrow(decrypted); return { @@ -696,6 +712,16 @@ export class ValidPayClient { // Internal helpers // --------------------------------------------------------------------------- + /** + * AAD to pass to decrypt (Prompt 097 M-5). v2 intents reconstruct it from + * the server-returned metadata so an altered document_type or validity + * window fails the GCM tag check. undefined for legacy v1. + */ + private aadFor(data: IntentApiResponse): string | undefined { + if ((data.encryption_version ?? 1) < 2) return undefined; + return buildAad(data.document_type ?? '', data.valid_from, data.valid_until); + } + /** * Version-aware commitment check (Prompt 097 C-1). v2 commitments are * SHA-256(ciphertext): recompute over the received blob and compare. v1 diff --git a/src/crypto.ts b/src/crypto.ts index bc7f551..404a1ce 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -105,20 +105,56 @@ export async function generateKey(): Promise { * * base64(iv[12] || authTag[16] || ciphertext) */ -export async function encrypt(plaintext: string, keyBase64: string): Promise { +export async function encrypt( + plaintext: string, + keyBase64: string, + aad?: string, +): Promise { const provider = getCryptoProvider(); const key = decodeKey(keyBase64); - return provider.encrypt(utf8Encode(plaintext), key); + return provider.encrypt(utf8Encode(plaintext), key, aad ? utf8Encode(aad) : undefined); } /** Decrypt a ValidPay-format base64 blob and return the plaintext (UTF-8). */ -export async function decrypt(ciphertext: string, keyBase64: string): Promise { +export async function decrypt( + ciphertext: string, + keyBase64: string, + aad?: string, +): Promise { const provider = getCryptoProvider(); const key = decodeKey(keyBase64); - const plaintextBytes = await provider.decrypt(ciphertext, key); + const plaintextBytes = await provider.decrypt( + ciphertext, + key, + aad ? utf8Encode(aad) : undefined, + ); return utf8Decode(plaintextBytes); } +/** + * Canonical AAD for AES-GCM metadata binding (Prompt 097 M-5). MUST be + * byte-identical to the other SDKs and the website verifier: fixed key order, + * compact JSON, timestamps normalized to epoch milliseconds (the server + * reformats raw ISO strings, which would break time-locked verification). + */ +export function buildAad( + documentType: string, + validFrom?: string | null, + validUntil?: string | null, +): string { + return JSON.stringify({ + document_type: documentType, + valid_from: epochMs(validFrom), + valid_until: epochMs(validUntil), + }); +} + +function epochMs(iso?: string | null): number | null { + if (!iso) return null; + const t = Date.parse(iso); + return Number.isNaN(t) ? null : t; +} + /** * SHA-256 hex over the *ciphertext* blob (commitment v2). * diff --git a/src/types.ts b/src/types.ts index e6ba90e..8c9100a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,14 +83,18 @@ export interface CryptoProvider { /** * AES-256-GCM encrypt. Generate a random 12-byte IV, encrypt `plaintext` * with `key` (32 bytes), and return base64(iv || authTag || ciphertext). + * + * `aad` (Prompt 097 M-5) is optional Associated Authenticated Data. When + * provided, pass it to the cipher (e.g. `cipher.setAAD(...)`) so the GCM + * tag covers it; the same bytes must be supplied to `decrypt`. */ - encrypt(plaintext: Uint8Array, key: Uint8Array): Promise; + encrypt(plaintext: Uint8Array, key: Uint8Array, aad?: Uint8Array): Promise; /** * AES-256-GCM decrypt. Input is the base64 string produced by `encrypt`. - * Returns the raw plaintext bytes. + * Returns the raw plaintext bytes. `aad` must match what `encrypt` bound. */ - decrypt(ciphertext: string, key: Uint8Array): Promise; + decrypt(ciphertext: string, key: Uint8Array, aad?: Uint8Array): Promise; /** SHA-256, hex-encoded (lowercase, 64 chars). */ sha256(data: Uint8Array): Promise;