From 83f08c1307c2eb4eaa46bcd91e93bad379b96e4f Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 14 Jun 2026 18:53:11 -0500 Subject: [PATCH] fix(097-C1): commitment hash over ciphertext, version-gated verify computeCommitmentHash now hashes the ciphertext blob, not the plaintext (C-1). create paths hash encrypted_payload after encryption. checkCommitmentHash recomputes SHA-256(ciphertext) and enforces it ONLY when commitment_version >= 2; legacy v1 intents skip the check and still verify. Selective integrity is now role-independent. IntentApiResponse gains commitment_version. Tests updated. Co-authored-by: Mike Co-authored-by: Claude Fable 5 --- __tests__/client.test.ts | 11 +++++-- src/client.ts | 68 +++++++++++++++++++++------------------- src/crypto.ts | 15 +++++---- 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index aa3c20a..58f2df6 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -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 }; } @@ -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', @@ -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(); @@ -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, }); @@ -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); }); diff --git a/src/client.ts b/src/client.ts index 770c556..0ddcdd7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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; @@ -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 = { document_type: documentType, @@ -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 = { 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; @@ -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 { @@ -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 = { document_type: documentType, @@ -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 { @@ -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; @@ -538,21 +548,9 @@ export class ValidPayClient { const encryptedFields = parseJsonOrThrow(data.encrypted_payload) as Record; 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 ?? '', @@ -698,18 +696,22 @@ export class ValidPayClient { // Internal helpers // --------------------------------------------------------------------------- - private async checkCommitmentHash( - decrypted: string, - expectedHash: string | null | undefined, - ): Promise { - 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 { + 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; diff --git a/src/crypto.ts b/src/crypto.ts index cf7a18a..bc7f551 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -120,15 +120,18 @@ export async function decrypt(ciphertext: string, keyBase64: string): Promise { +export async function computeCommitmentHash(ciphertextB64: string): Promise { const provider = getCryptoProvider(); - return provider.sha256(utf8Encode(plaintext)); + return provider.sha256(utf8Encode(ciphertextB64)); } // ---------------------------------------------------------------------------