From 226a3510489c13e496e396a1ac199ddf6e4a9dbe Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 12 Jun 2026 04:36:56 -0500 Subject: [PATCH] =?UTF-8?q?feat(094):=200.4.0=20=E2=80=94=20split-key=20by?= =?UTF-8?q?=20default=20in=20createIntent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createIntent() gains splitKey (default true): returns Share A, sends Share B (key_fragment_b) to the server; splitKey:false = legacy flow. - verifyIntent() transparently verifies split-key intents (fetches the fragment and XOR-combines) so create -> verify round-trips keep working. - createSplitKeyIntent() deprecated -> alias of createIntent(). - Version 0.4.0; CHANGELOG + README updated. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 21 ++++++++ README.md | 28 +++++++--- package.json | 2 +- src/client.ts | 125 ++++++++++++++++++++++++------------------- src/types.ts | 7 +++ tests/client.test.ts | 71 +++++++++++++++++++----- 6 files changed, 178 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de2f9a..9cbfae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to `@validpay/node-sdk` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.4.0] — 2026-06-12 + +### Changed + +- **Split-key protection (Patent C) is now the default** (Prompt 094). + `createIntent()` splits the AES key into two XOR shares: Share A is + returned as `key`, Share B is stored on the ValidPay server. The full + decryption key never exists on any single system after the call + returns. Pass `splitKey: false` for the legacy single-key flow. +- `verifyIntent()` now verifies split-key intents transparently: when + the API marks an intent `split_key`, it fetches Share B from the + fragment endpoint and XOR-combines it with the key you pass (Share A), + instead of throwing `split_key_required`. Legacy intents verify + exactly as before. + +### Deprecated + +- `createSplitKeyIntent()` — now an alias for `createIntent()` (which + does split-key by default). Emits a `DeprecationWarning` once per + process; will be removed in 1.0. + ## [0.3.1] — 2026-06-08 ### Changed diff --git a/README.md b/README.md index b682ed4..420ab21 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,17 @@ import { ValidPayClient } from "@validpay/node-sdk"; const client = new ValidPayClient({ apiKey: process.env.VALIDPAY_API_KEY! }); -// 1. Issuer side — register an intent with sensitive payload +// 1. Issuer side — register an intent with sensitive payload. +// Split-key protection (Patent C) is the default since 0.4.0: `key` is +// Share A of the AES key; Share B is stored on the ValidPay server. The +// full decryption key never exists on any single system. const { retrievalId, key } = await client.createIntent({ documentType: "ssn_card", payload: { ssn: "123-45-6789", name: "Jane Doe" }, }); // retrievalId is public (e.g. "vp_abc123def456") — embed in a QR code. -// key is secret — deliver it ONLY to the intended verifier, out-of-band. +// key (Share A) is secret — deliver it ONLY to the intended verifier, out-of-band. // 2. Verifier side — fetch and decrypt (no API key needed) const result = await client.verifyIntent<{ ssn: string; name: string }>(retrievalId, key); @@ -81,9 +84,9 @@ The key is generated client-side, used client-side, and transmitted client-side. ### Core -#### `client.createIntent({ documentType, payload, validFrom?, validUntil? }) → { retrievalId, key }` +#### `client.createIntent({ documentType, payload, validFrom?, validUntil?, splitKey? }) → { retrievalId, key }` -Generates a key, encrypts `JSON.stringify(payload)`, posts ciphertext + commitment hash to `/v1/intent`. **The key is never sent to the API.** +Generates a key, encrypts `JSON.stringify(payload)`, posts ciphertext + commitment hash to `/v1/intent`. Defaults to split-key (Patent C): the returned `key` is Share A and Share B goes to the server — neither alone decrypts. Pass `splitKey: false` for the legacy flow where `key` is the full AES key. **The full key is never sent to the API.** #### `client.createIntentBatch(items[]) → { retrievalId, key }[]` @@ -113,19 +116,28 @@ interface VerifyIntentResult { } ``` -### Split-key (Patent C) +### Split-key (Patent C) — the default + +All documents created with SDK v0.4+ use split-key by default — `createIntent` +returns Share A and stores Share B at the API; `verifyIntent` detects a +split-key intent, fetches Share B from `/v1/intent/:id/fragment`, +XOR-combines, and decrypts: ```ts -const { retrievalId, key: shareA } = await client.createSplitKeyIntent({ +const { retrievalId, key: shareA } = await client.createIntent({ documentType: "ssn_card", payload: { ssn: "123-45-6789" }, }); // shareA goes in the QR; shareB stays at the API. -const result = await client.verifySplitKeyIntent(retrievalId, shareA); -// SDK fetches shareB from /v1/intent/:id/fragment, XOR-combines, decrypts. +const result = await client.verifyIntent(retrievalId, shareA); ``` +Backward compatibility: `createIntent({ ..., splitKey: false })` gives the +legacy single-key flow; `createSplitKeyIntent()` is a deprecated alias of +`createIntent()` (emits a `DeprecationWarning`); `verifySplitKeyIntent()` +still works. + ### Selective disclosure (Patent E) ```ts diff --git a/package.json b/package.json index fe844f3..3375344 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@validpay/node-sdk", - "version": "0.3.1", + "version": "0.4.0", "description": "Official ValidPay Node.js SDK — client-side AES-256-GCM encryption + commitment hashing + split-key + selective disclosure + revocation. Zero production dependencies.", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 88fa7d1..b9eee74 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,6 +42,20 @@ interface RequestOpts { auth: boolean; } +let splitKeyDeprecationEmitted = false; +function emitSplitKeyDeprecation(): void { + if (splitKeyDeprecationEmitted) return; + splitKeyDeprecationEmitted = true; + const message = + "createSplitKeyIntent() is deprecated since @validpay/node-sdk 0.4.0: " + + "createIntent() uses split-key protection by default. Call createIntent() instead."; + if (typeof process !== "undefined" && typeof process.emitWarning === "function") { + process.emitWarning(message, "DeprecationWarning"); + } else { + console.warn(message); + } +} + export class ValidPayClient { private readonly apiKey: string; private readonly baseUrl: string; @@ -60,15 +74,32 @@ export class ValidPayClient { // === Core === + /** + * Encrypt `payload` locally and register it with the ValidPay API. + * + * Since 0.4.0 this uses **split-key protection (Patent C) by default**: + * the AES-256 key is split into two XOR shares — Share A is returned as + * `key` (embed it in the QR code exactly as before), Share B is stored + * on the ValidPay server. The full decryption key never exists on any + * single system after this call returns. Pass `splitKey: false` for the + * legacy single-key flow. + */ async createIntent(params: CreateIntentParams): Promise { if (!params.documentType) { throw new ValidPayError("invalid_argument", "documentType is required"); } validateTimeLock(params.validFrom, params.validUntil); - const key = generateKey(); + const splitKey = params.splitKey !== false; + const fullKey = generateKey(); + let resultKey = fullKey; + let shareB: string | undefined; + if (splitKey) { + [resultKey, shareB] = splitKeyFn(fullKey); + } + const plaintext = JSON.stringify(params.payload); - const encrypted_payload = encrypt(plaintext, key); + const encrypted_payload = encrypt(plaintext, fullKey); const commitment_hash = commitmentHash(plaintext); const body: Record = { @@ -76,6 +107,10 @@ export class ValidPayClient { encrypted_payload, commitment_hash, }; + if (splitKey) { + body["split_key"] = true; + body["key_fragment_b"] = shareB; + } if (params.validFrom !== undefined) body["valid_from"] = params.validFrom; if (params.validUntil !== undefined) body["valid_until"] = params.validUntil; @@ -89,7 +124,7 @@ export class ValidPayClient { details: data, }); } - return { retrievalId: data.retrieval_id, key }; + return { retrievalId: data.retrieval_id, key: resultKey }; } async createIntentBatch(items: BatchIntentItem[]): Promise { @@ -205,14 +240,16 @@ export class ValidPayClient { "This intent uses selective field disclosure. Use verifySelectiveIntent(retrievalId, key, role) instead.", ); } + // Split-Key Verification (Patent C). Since 0.4.0 split-key is the + // default issue path, so the key the caller holds is Share A — fetch + // Share B from the fragment endpoint and XOR-combine, so the natural + // createIntent -> verifyIntent round trip keeps working. + let decryptionKey = key; if (data.split_key) { - throw new ValidPayError( - "split_key_required", - `Intent ${retrievalId} uses split-key protection. Use verifySplitKeyIntent(retrievalId, shareA) instead.`, - ); + decryptionKey = combineKeyShares(key, await this.fetchFragmentB(retrievalId)); } - const decrypted = decrypt(data.encrypted_payload, key); + const decrypted = decrypt(data.encrypted_payload, decryptionKey); const integrityVerified = checkCommitment(data.commitment_hash, decrypted); @@ -230,40 +267,34 @@ export class ValidPayClient { // === Split-key (Patent C) === + /** + * @deprecated Since 0.4.0 `createIntent()` uses split-key protection by + * default, so this alias adds nothing. Call `createIntent()` instead. + * Kept so 0.3.x code keeps working; will be removed in 1.0. + */ async createSplitKeyIntent(params: CreateIntentParams): Promise { - if (!params.documentType) { - throw new ValidPayError("invalid_argument", "documentType is required"); - } - validateTimeLock(params.validFrom, params.validUntil); - - const fullKey = generateKey(); - const [shareA, shareB] = splitKeyFn(fullKey); - - const plaintext = JSON.stringify(params.payload); - const encrypted_payload = encrypt(plaintext, fullKey); - const commitment_hash = commitmentHash(plaintext); - - const body: Record = { - document_type: params.documentType, - encrypted_payload, - commitment_hash, - split_key: true, - key_fragment_b: shareB, - }; - if (params.validFrom !== undefined) body["valid_from"] = params.validFrom; - if (params.validUntil !== undefined) body["valid_until"] = params.validUntil; - - const data = await this.request("POST", "/v1/intent", { - body, - auth: true, - }); + emitSplitKeyDeprecation(); + return this.createIntent({ ...params, splitKey: true }); + } - if (!data?.retrieval_id) { - throw new ValidPayError("invalid_response", "API response missing retrieval_id", { - details: data, + /** Fetch Share B from the public fragment endpoint (Patent C). */ + private async fetchFragmentB(retrievalId: string): Promise { + const frag = await this.request( + "GET", + `/v1/intent/${encodeURIComponent(retrievalId)}/fragment`, + { auth: false }, + ); + if (frag?.error) { + throw new ValidPayError(frag.error, `Fragment retrieval failed: ${frag.error}`, { + details: frag, }); } - return { retrievalId: data.retrieval_id, key: shareA }; + if (!frag?.fragment_b) { + throw new ValidPayError("missing_fragment", "Server did not return key fragment", { + details: frag, + }); + } + return frag.fragment_b; } async verifySplitKeyIntent( @@ -299,23 +330,7 @@ export class ValidPayClient { ); } - const frag = await this.request( - "GET", - `/v1/intent/${encodeURIComponent(retrievalId)}/fragment`, - { auth: false }, - ); - if (frag?.error) { - throw new ValidPayError(frag.error, `Fragment retrieval failed: ${frag.error}`, { - details: frag, - }); - } - if (!frag?.fragment_b) { - throw new ValidPayError("missing_fragment", "Server did not return key fragment", { - details: frag, - }); - } - - const fullKey = combineKeyShares(shareA, frag.fragment_b); + const fullKey = combineKeyShares(shareA, await this.fetchFragmentB(retrievalId)); const decrypted = decrypt(data.encrypted_payload, fullKey); const integrityVerified = checkCommitment(data.commitment_hash, decrypted); diff --git a/src/types.ts b/src/types.ts index ddab2b2..946124e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,13 @@ export interface CreateIntentParams { payload: unknown; validFrom?: string; validUntil?: string; + /** + * Split-key protection (Patent C). Default `true` since 0.4.0: the + * returned `key` is Share A of the AES key and Share B is stored on + * the ValidPay server — neither alone decrypts. Set `false` for the + * legacy single-key flow where `key` is the full AES key. + */ + splitKey?: boolean; } export interface BatchIntentItem { diff --git a/tests/client.test.ts b/tests/client.test.ts index f3d88ff..826b821 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -6,6 +6,7 @@ import { generateKey, commitmentHash, splitKey, + combineKeyShares, encryptFields, buildKeyMap, } from "../src/crypto.js"; @@ -24,6 +25,9 @@ describe("ValidPayClient", () => { }); it("createIntent encrypts client-side, posts to /v1/intent, sends commitment hash, never sends key", async () => { + // Since 0.4.0 createIntent defaults to split-key: result.key is Share A, + // Share B travels to the server, and neither the full key nor the + // plaintext ever appears on the wire. const fetchMock = vi.fn(async () => jsonResponse(201, { retrieval_id: "vp_abc123def456", status: "active" }), ); @@ -54,14 +58,44 @@ describe("ValidPayClient", () => { expect(typeof sentBody.encrypted_payload).toBe("string"); expect(typeof sentBody.commitment_hash).toBe("string"); expect(sentBody.commitment_hash).toBe(commitmentHash(JSON.stringify(payload))); + expect(sentBody.split_key).toBe(true); + expect(typeof sentBody.key_fragment_b).toBe("string"); - // CRITICAL: key must never appear in the request body, URL, or headers + // CRITICAL: Share A (the returned key) must never appear in the + // request body, URL, or headers const fullCall = JSON.stringify({ url: calledUrl, init }); expect(fullCall).not.toContain(result.key); expect(fullCall).not.toContain("123-45-6789"); expect(fullCall).not.toContain("Jane Doe"); - // And the encrypted_payload should actually decrypt back to the original + // Share A (returned) XOR Share B (sent) reconstructs the full key — + // which itself never appears on the wire — and decrypts the payload. + const fullKey = combineKeyShares(result.key, sentBody.key_fragment_b); + expect(fullCall).not.toContain(fullKey); + const decrypted = JSON.parse(decrypt(sentBody.encrypted_payload, fullKey)); + expect(decrypted).toEqual(payload); + }); + + it("createIntent with splitKey:false is the legacy single-key flow", async () => { + const fetchMock = vi.fn(async () => + jsonResponse(201, { retrieval_id: "vp_legacy1", status: "active" }), + ); + const client = new ValidPayClient({ + apiKey: "test_key", + baseUrl: "https://api.example.test", + fetch: fetchMock as unknown as typeof fetch, + }); + + const payload = { amount: "100.00" }; + const result = await client.createIntent({ + documentType: "check", + payload, + splitKey: false, + }); + + const sentBody = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); + expect(sentBody.split_key).toBeUndefined(); + expect(sentBody.key_fragment_b).toBeUndefined(); const decrypted = JSON.parse(decrypt(sentBody.encrypted_payload, result.key)); expect(decrypted).toEqual(payload); }); @@ -287,11 +321,19 @@ describe("ValidPayClient", () => { }); }); - it("verifyIntent redirects to verifySplitKeyIntent when split_key flag is set", async () => { - const key = generateKey(); - const blob = encrypt(JSON.stringify({ a: 1 }), key); - const fetchMock = vi.fn(async () => - jsonResponse(200, { + it("verifyIntent transparently verifies a split-key intent (key = Share A)", async () => { + // Since 0.4.0 the natural createIntent -> verifyIntent round trip + // works on split-key intents: verifyIntent fetches Share B from the + // fragment endpoint and XOR-combines it with the Share A it was given. + const fullKey = generateKey(); + const [shareA, shareB] = splitKey(fullKey); + const payload = { a: 1 }; + const blob = encrypt(JSON.stringify(payload), fullKey); + const fetchMock = vi.fn(async (url: string) => { + if (String(url).endsWith("/fragment")) { + return jsonResponse(200, { intent_id: "vp_sk", fragment_b: shareB }); + } + return jsonResponse(200, { intent_id: "vp_sk", encrypted_payload: blob, issuer: "x", @@ -299,16 +341,21 @@ describe("ValidPayClient", () => { registered_at: "2026-01-01T00:00:00Z", status: "active", split_key: true, - }), - ); + commitment_hash: commitmentHash(JSON.stringify(payload)), + }); + }); const client = new ValidPayClient({ apiKey: "k", baseUrl: "https://api.example.test", fetch: fetchMock as unknown as typeof fetch, }); - await expect(client.verifyIntent("vp_sk", key)).rejects.toMatchObject({ - code: "split_key_required", - }); + const result = await client.verifyIntent("vp_sk", shareA); + expect(result.payload).toEqual(payload); + expect(result.integrityVerified).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1]![0])).toBe( + "https://api.example.test/v1/intent/vp_sk/fragment", + ); }); it("verifyIntent and createIntent require their arguments", async () => {