Skip to content
Merged
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
9 changes: 6 additions & 3 deletions __tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ValidPayClient } from '../src/client';
import { configure } from '../src/index';
import {
buildAad,
buildKeyMap,
combineKeyShares,
computeCommitmentHash,
Expand Down Expand Up @@ -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 });
});

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

Expand Down Expand Up @@ -449,7 +452,7 @@ describe('createBoundIntent', () => {
threshold: 7,
});
const body = calls[0].body as Record<string, unknown>;
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}$/);
Expand Down
24 changes: 24 additions & 0 deletions __tests__/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
buildAad,
buildKeyMap,
combineKeyShares,
computeCommitmentHash,
Expand All @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions __tests__/testProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,20 @@ export class NodeCryptoProvider implements CryptoProvider {
return new Uint8Array(nodeCrypto.randomBytes(length));
}

async encrypt(plaintext: Uint8Array, key: Uint8Array): Promise<string> {
async encrypt(plaintext: Uint8Array, key: Uint8Array, aad?: Uint8Array): Promise<string> {
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<Uint8Array> {
async decrypt(blob: string, key: Uint8Array, aad?: Uint8Array): Promise<Uint8Array> {
const buf = Buffer.from(blob, 'base64');
if (buf.length < IV_BYTES + TAG_BYTES + 1) {
throw new Error('Blob too short');
Expand All @@ -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);
}
Expand Down
36 changes: 31 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ValidPayError } from './errors';
import {
buildAad,
buildKeyMap,
combineKeyShares,
computeCommitmentHash,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -107,14 +112,17 @@ 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);

const body: Record<string, unknown> = {
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;
Expand Down Expand Up @@ -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<string, unknown> = {
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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -287,14 +299,17 @@ 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);

const body: Record<string, unknown> = {
document_type: documentType,
encrypted_payload: encryptedPayload,
commitment_hash: commitmentHash,
encryption_version: 2,
split_key: true,
key_fragment_b: shareB,
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
44 changes: 40 additions & 4 deletions src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,56 @@ export async function generateKey(): Promise<string> {
*
* base64(iv[12] || authTag[16] || ciphertext)
*/
export async function encrypt(plaintext: string, keyBase64: string): Promise<string> {
export async function encrypt(
plaintext: string,
keyBase64: string,
aad?: string,
): Promise<string> {
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<string> {
export async function decrypt(
ciphertext: string,
keyBase64: string,
aad?: string,
): Promise<string> {
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).
*
Expand Down
10 changes: 7 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
encrypt(plaintext: Uint8Array, key: Uint8Array, aad?: Uint8Array): Promise<string>;

/**
* 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<Uint8Array>;
decrypt(ciphertext: string, key: Uint8Array, aad?: Uint8Array): Promise<Uint8Array>;

/** SHA-256, hex-encoded (lowercase, 64 chars). */
sha256(data: Uint8Array): Promise<string>;
Expand Down
Loading