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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }[]`

Expand Down Expand Up @@ -113,19 +116,28 @@ interface VerifyIntentResult<T> {
}
```

### 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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
125 changes: 70 additions & 55 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,22 +74,43 @@ 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<CreateIntentResult> {
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<string, unknown> = {
document_type: params.documentType,
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;

Expand All @@ -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<CreateIntentResult[]> {
Expand Down Expand Up @@ -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);

Expand All @@ -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<CreateIntentResult> {
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<string, unknown> = {
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<RawCreateIntentResponse>("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<string> {
const frag = await this.request<RawFragmentResponse>(
"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<T = unknown>(
Expand Down Expand Up @@ -299,23 +330,7 @@ export class ValidPayClient {
);
}

const frag = await this.request<RawFragmentResponse>(
"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);
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading