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
11 changes: 8 additions & 3 deletions __tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ describe('verifyIntent', () => {
const key = await generateKey();
const plaintext = JSON.stringify(payload);
const encrypted_payload = await encrypt(plaintext, key);
const commitment_hash = await computeCommitmentHash(plaintext);
// C-1: commitment v2 is over the ciphertext blob.
const commitment_hash = await computeCommitmentHash(encrypted_payload);
return { key, encrypted_payload, commitment_hash };
}

Expand All @@ -204,6 +205,7 @@ describe('verifyIntent', () => {
intent_id: 'vp_1',
encrypted_payload,
commitment_hash,
commitment_version: 2,
issuer: 'Acme',
issuer_verified: true,
registered_at: '2026-05-01T00:00:00Z',
Expand Down Expand Up @@ -240,6 +242,7 @@ describe('verifyIntent', () => {
intent_id: 'vp_1',
encrypted_payload,
commitment_hash: 'a'.repeat(64),
commitment_version: 2,
status: 'active',
});
const c = newClient();
Expand Down Expand Up @@ -321,12 +324,13 @@ describe('split-key flow', () => {
const [shareA, shareB] = splitKey(fullKey);
const plaintext = JSON.stringify({ ssn: '123' });
const encrypted_payload = await encrypt(plaintext, fullKey);
const commitment_hash = await computeCommitmentHash(plaintext);
const commitment_hash = await computeCommitmentHash(encrypted_payload); // C-1: over ciphertext

pushResponse({
intent_id: 'vp_sp',
encrypted_payload,
commitment_hash,
commitment_version: 2,
status: 'active',
split_key: true,
});
Expand Down Expand Up @@ -410,7 +414,8 @@ describe('selective disclosure flow', () => {
expect(result.payload.amount).toBe(1500);
expect(result.payload.name).toBe('[REDACTED]');
expect(result.payload.memo).toBe('[REDACTED]');
// Integrity check only runs for full role.
// No commitment_version in the response β†’ treated as legacy v1, so the
// commitment check is skipped and integrity stays false (C-1).
expect(result.integrityVerified).toBe(false);
});

Expand Down
68 changes: 35 additions & 33 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ interface IntentApiResponse {
registered_at?: string;
status?: 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;
split_key?: boolean;
selective_disclosure?: boolean;
encrypted_key_map?: string | null;
Expand Down Expand Up @@ -104,8 +107,9 @@ export class ValidPayClient {

const key = await generateKey();
const plaintext = JSON.stringify(payload);
const commitmentHash = await computeCommitmentHash(plaintext);
const encryptedPayload = await encrypt(plaintext, key);
// Commitment v2: hash the ciphertext, not the plaintext (C-1).
const commitmentHash = await computeCommitmentHash(encryptedPayload);

const body: Record<string, unknown> = {
document_type: documentType,
Expand Down Expand Up @@ -163,10 +167,12 @@ export class ValidPayClient {
const k = await generateKey();
keys.push(k);
const plaintext = JSON.stringify(item.payload);
const itemEncryptedPayload = await encrypt(plaintext, k);
const reqItem: Record<string, unknown> = {
document_type: item.documentType,
encrypted_payload: await encrypt(plaintext, k),
commitment_hash: await computeCommitmentHash(plaintext),
encrypted_payload: itemEncryptedPayload,
// Commitment v2: hash the ciphertext, not the plaintext (C-1).
commitment_hash: await computeCommitmentHash(itemEncryptedPayload),
};
if (item.validFrom != null) reqItem.valid_from = item.validFrom;
if (item.validUntil != null) reqItem.valid_until = item.validUntil;
Expand Down Expand Up @@ -245,8 +251,9 @@ 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);
const integrityVerified = await this.checkCommitmentHash(decrypted, data.commitment_hash);
const payload = parseJsonOrThrow(decrypted);

return {
Expand Down Expand Up @@ -280,8 +287,9 @@ export class ValidPayClient {
const fullKey = await generateKey();
const [shareA, shareB] = splitKey(fullKey);
const plaintext = JSON.stringify(payload);
const commitmentHash = await computeCommitmentHash(plaintext);
const encryptedPayload = await encrypt(plaintext, fullKey);
// Commitment v2: hash the ciphertext, not the plaintext (C-1).
const commitmentHash = await computeCommitmentHash(encryptedPayload);

const body: Record<string, unknown> = {
document_type: documentType,
Expand Down Expand Up @@ -355,9 +363,10 @@ 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);
const integrityVerified = await this.checkCommitmentHash(decrypted, data.commitment_hash);
const payload = parseJsonOrThrow(decrypted);

return {
Expand Down Expand Up @@ -420,9 +429,10 @@ export class ValidPayClient {
const { encryptedFields, fieldKeys } = await encryptFields(payload);
const keyMap = buildKeyMap(fieldKeys, disclosurePolicy);
const encryptedKeyMap = await encrypt(JSON.stringify(keyMap), masterKey);
const fullPlaintext = JSON.stringify(payload);
const commitmentHash = await computeCommitmentHash(fullPlaintext);
const envelope = JSON.stringify(encryptedFields);
// Commitment v2: hash the transported ciphertext envelope, not the
// plaintext (C-1). Role-independent at verify time.
const commitmentHash = await computeCommitmentHash(envelope);

let qrKey = masterKey;
let keyFragmentB: string | null = null;
Expand Down Expand Up @@ -538,21 +548,9 @@ export class ValidPayClient {
const encryptedFields = parseJsonOrThrow(data.encrypted_payload) as Record<string, string>;
const payload = await decryptFields(encryptedFields, fieldKeys);

let integrityVerified = false;
if (data.commitment_hash && role === 'full') {
const allKeys = keyMap['full'] ?? {};
const fullPayload = await decryptFields(encryptedFields, allKeys);
const fullPlaintext = JSON.stringify(fullPayload);
const actual = await computeCommitmentHash(fullPlaintext);
if (actual !== data.commitment_hash) {
throw new ValidPayError(
'integrity_failure',
'INTEGRITY VERIFICATION FAILED β€” the decrypted payload does not match ' +
'the commitment hash stored at issuance.',
);
}
integrityVerified = true;
}
// Commitment over the ciphertext envelope (C-1) β€” role-independent now,
// so any role gets integrity verification. Legacy v1 intents skip it.
const integrityVerified = await this.checkCommitmentHash(data);

return {
intentId: data.intent_id ?? '',
Expand Down Expand Up @@ -698,18 +696,22 @@ export class ValidPayClient {
// Internal helpers
// ---------------------------------------------------------------------------

private async checkCommitmentHash(
decrypted: string,
expectedHash: string | null | undefined,
): Promise<boolean> {
if (!expectedHash) return false;
const actual = await computeCommitmentHash(decrypted);
if (actual !== expectedHash) {
/**
* Version-aware commitment check (Prompt 097 C-1). v2 commitments are
* SHA-256(ciphertext): recompute over the received blob and compare. v1
* (legacy SHA-256(plaintext)) is a confirmation-oracle risk and is
* skipped β€” those documents expire naturally. Throws on a v2 mismatch.
*/
private async checkCommitmentHash(data: IntentApiResponse): Promise<boolean> {
if (!data.commitment_hash || !data.encrypted_payload) return false;
if ((data.commitment_version ?? 1) < 2) return false;
const actual = await computeCommitmentHash(data.encrypted_payload);
if (actual !== data.commitment_hash) {
throw new ValidPayError(
'integrity_failure',
'INTEGRITY VERIFICATION FAILED β€” the decrypted payload does not match ' +
'the commitment hash stored at issuance. This may indicate server-side ' +
'tampering or payload corruption.',
'INTEGRITY VERIFICATION FAILED β€” the ciphertext does not match the ' +
'commitment hash recorded at issuance. This document may have been ' +
'tampered with.',
);
}
return true;
Expand Down
15 changes: 9 additions & 6 deletions src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,18 @@ export async function decrypt(ciphertext: string, keyBase64: string): Promise<st
}

/**
* SHA-256 hex of the UTF-8 plaintext (Hybrid Commitment Scheme, Patent A).
* SHA-256 hex over the *ciphertext* blob (commitment v2).
*
* The same hash recomputed at verification time proves the server hasn't
* swapped the ciphertext β€” SHA-256 is one-way, so the blind server can't
* forge a matching hash without the decryption key.
* Pass the base64 ValidPay wire blob from {@link encrypt} β€” NOT the
* plaintext. Hashing the ciphertext keeps the commitment safe to publish on
* the public verify endpoint: SHA-256(plaintext) over a low-entropy
* structured document can be brute-forced offline to recover contents
* without the key (Prompt 097 C-1). It still proves the server hasn't
* swapped the blob β€” the verifier recomputes SHA-256(ciphertext) and compares.
*/
export async function computeCommitmentHash(plaintext: string): Promise<string> {
export async function computeCommitmentHash(ciphertextB64: string): Promise<string> {
const provider = getCryptoProvider();
return provider.sha256(utf8Encode(plaintext));
return provider.sha256(utf8Encode(ciphertextB64));
}

// ---------------------------------------------------------------------------
Expand Down
Loading