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
86 changes: 86 additions & 0 deletions __tests__/rail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ed25519 } from '@noble/curves/ed25519';
import { fetchRailPiece } from '../src/rail';
import { splitKeyPieces, combineKeyPieces } from '../src/crypto';
import './setup';

const INTENT = 'vp_railtest';
const PIECE = Buffer.from(new Uint8Array(32).fill(7)).toString('base64');
const KEY = Buffer.from(new Uint8Array(32).fill(3)).toString('base64');

// Build an Ed25519 SubjectPublicKeyInfo DER (12-byte prefix + 32-byte raw key).
function spkiFromRaw(rawPub: Uint8Array): string {
const prefix = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
]);
const der = new Uint8Array(prefix.length + 32);
der.set(prefix);
der.set(rawPub, prefix.length);
return Buffer.from(der).toString('base64');
}
function asciiBytes(s: string): Uint8Array {
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff;
return out;
}
function makeKey() {
const priv = ed25519.utils.randomPrivateKey();
return { priv, spki: spkiFromRaw(ed25519.getPublicKey(priv)) };
}
function sign(priv: Uint8Array, intentId = INTENT, piece = PIECE): string {
const msg = asciiBytes(`keyhalve-rail.v1\n${intentId}\nkeyhalve\n${piece}`);
return Buffer.from(ed25519.sign(msg, priv)).toString('base64');
}
function resp(body: unknown, status = 200): Response {
return { status, ok: status >= 200 && status < 300, json: async () => body } as unknown as Response;
}
const fetchOf = (r: Response | Error) =>
(jest.fn(async () => {
if (r instanceof Error) throw r;
return r;
}) as unknown) as typeof fetch;

describe('splitKeyPieces / combineKeyPieces', () => {
it('round-trips for 1..3 holders', () => {
for (const n of [1, 2, 3]) {
const parts = splitKeyPieces(KEY, n);
expect(parts).toHaveLength(n + 1);
expect(combineKeyPieces(parts[0], parts.slice(1))).toBe(KEY);
}
});
});

describe('fetchRailPiece', () => {
it('returns the share when the signature verifies against the pinned key', async () => {
const { priv, spki } = makeKey();
const f = fetchOf(resp({ holder: 'keyhalve', piece: PIECE, sig: sign(priv), alg: 'ed25519' }));
await expect(fetchRailPiece(f, 'https://rail.test', spki, INTENT)).resolves.toBe(PIECE);
});

it('fails closed on a tampered signature', async () => {
const { priv, spki } = makeKey();
const bad = Buffer.from(sign(priv), 'base64');
bad[0] ^= 0xff;
const f = fetchOf(resp({ holder: 'keyhalve', piece: PIECE, sig: bad.toString('base64') }));
await expect(fetchRailPiece(f, 'https://rail.test', spki, INTENT)).rejects.toThrow(/signature/i);
});

it('fails closed when signed by a non-pinned key', async () => {
const attacker = makeKey();
const pinned = makeKey();
const f = fetchOf(resp({ holder: 'keyhalve', piece: PIECE, sig: sign(attacker.priv) }));
await expect(fetchRailPiece(f, 'https://rail.test', pinned.spki, INTENT)).rejects.toThrow(/signature/i);
});

it('rejects a wrong holder', async () => {
const { priv, spki } = makeKey();
const f = fetchOf(resp({ holder: 'platform', piece: PIECE, sig: sign(priv) }));
await expect(fetchRailPiece(f, 'https://rail.test', spki, INTENT)).rejects.toThrow(/malformed/i);
});

it('fails closed on 404 / 409 / unreachable', async () => {
const { spki } = makeKey();
await expect(fetchRailPiece(fetchOf(resp({}, 404)), 'https://rail.test', spki, INTENT)).rejects.toThrow(/not found/i);
await expect(fetchRailPiece(fetchOf(resp({}, 409)), 'https://rail.test', spki, INTENT)).rejects.toThrow(/revoked/i);
await expect(fetchRailPiece(fetchOf(new Error('net')), 'https://rail.test', spki, INTENT)).rejects.toThrow(/reach/i);
});
});
34 changes: 32 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"optional": true
}
},
"dependencies": {
"@noble/curves": "^1.6.0"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
Expand Down
106 changes: 104 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import {
encryptFields,
generateKey,
splitKey,
splitKeyPieces,
combineKeyPieces,
} from './crypto';
import {
fetchRailPiece,
KEYHALVE_RAIL_BASE_URL,
KEYHALVE_RAIL_PUBLIC_KEY_SPKI_B64,
} from './rail';
import { computeTimeLockStatus, validateTimeLock } from './timelock';
import { compareBindingHashes, computeBindingHash } from './binding';
import type {
Expand All @@ -26,6 +33,10 @@ export interface ValidPayClientOptions {
apiKey: string;
baseUrl?: string;
timeoutMs?: number;
/** KeyHalve rail base URL (End-Cell verify). Defaults to https://rail.keyhalve.com. */
railBaseUrl?: string;
/** Pinned rail Ed25519 public key (SPKI DER, base64). Defaults to the live rail key. */
railPublicKeySpki?: string;
}

/**
Expand Down Expand Up @@ -81,6 +92,7 @@ interface IntentApiResponse {
* as AES-GCM AAD. Absent is treated as 1 (Prompt 097 M-5). */
encryption_version?: number;
split_key?: boolean;
end_cell?: boolean;
selective_disclosure?: boolean;
encrypted_key_map?: string | null;
disclosure_policy?: string | null;
Expand All @@ -103,6 +115,8 @@ export class ValidPayClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly railBaseUrl: string;
private readonly railPublicKeySpki: string;

constructor(options: ValidPayClientOptions) {
if (!options || !options.apiKey) {
Expand All @@ -111,6 +125,8 @@ export class ValidPayClient {
this.apiKey = options.apiKey;
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.railBaseUrl = options.railBaseUrl ?? KEYHALVE_RAIL_BASE_URL;
this.railPublicKeySpki = options.railPublicKeySpki ?? KEYHALVE_RAIL_PUBLIC_KEY_SPKI_B64;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -161,6 +177,82 @@ export class ValidPayClient {
return { retrievalId: data.retrieval_id, key };
}

/**
* Seal with End-Cell (CVCP Layer 6B): an n-of-n XOR split across ShareA (returned as
* `key`, embed in the QR) + one mandatory piece per holder (default: the KeyHalve rail
* + the platform). No single party can read or assemble the key. Requires API End-Cell
* issuance to be enabled.
*/
async createEndCellIntent(
documentType: string,
payload: unknown,
opts: TimeLockOptions & { holders?: string[] } = {},
): Promise<CreateIntentResult> {
if (!documentType) {
throw new ValidPayError('invalid_argument', 'documentType is required');
}
validateTimeLock(opts.validFrom, opts.validUntil);

const holders = opts.holders ?? ['keyhalve', 'platform'];
if (new Set(holders).size !== holders.length) {
throw new ValidPayError('invalid_argument', 'holders must be unique');
}
if (holders.filter((h) => h === 'keyhalve').length !== 1 || !holders.some((h) => h !== 'keyhalve')) {
throw new ValidPayError(
'invalid_argument',
'end_cell requires exactly one "keyhalve" rail share and >= 1 platform share',
);
}

const fullKey = await generateKey();
const parts = splitKeyPieces(fullKey, holders.length); // [shareA, piece_1, …]
const shareA = parts[0];
const pieces = holders.map((holder, i) => ({ holder, piece: parts[i + 1] }));

const aad = buildAad(documentType, opts.validFrom, opts.validUntil);
const encryptedPayload = await encrypt(JSON.stringify(payload), fullKey, aad);
const body: Record<string, unknown> = {
document_type: documentType,
encrypted_payload: encryptedPayload,
commitment_hash: await computeCommitmentHash(encryptedPayload),
encryption_version: 2,
end_cell: true,
pieces,
};
if (opts.validFrom != null) body.valid_from = opts.validFrom;
if (opts.validUntil != null) body.valid_until = opts.validUntil;
if (opts.onBehalfOf != null) body.on_behalf_of = opts.onBehalfOf;

const data = await this.request<{ retrieval_id?: string }>('POST', '/v1/intent', body, true);
if (!data?.retrieval_id) {
throw new ValidPayError('invalid_response', 'API response missing retrieval_id', { details: data });
}
return { retrievalId: data.retrieval_id, key: shareA };
}

/** Fetch the End-Cell PLATFORM share(s) from the API /fragment (rail share is separate). */
private async fetchPieces(retrievalId: string): Promise<string[]> {
const data = await this.request<{ pieces?: Record<string, string>; holders?: string[]; error?: string }>(
'GET',
`/v1/intent/${encodeURIComponent(retrievalId)}/fragment`,
null,
false,
);
if (data?.error) {
throw new ValidPayError(data.error, `Fragment retrieval failed: ${data.error}`, { details: data });
}
const pieces = data?.pieces;
if (!pieces || Object.keys(pieces).length === 0) {
throw new ValidPayError('missing_fragment', 'Server did not return End-Cell pieces', { details: data });
}
const order = data?.holders?.length ? data.holders : Object.keys(pieces);
const result = order.map((h) => pieces[h]);
if (result.some((p) => !p)) {
throw new ValidPayError('missing_fragment', 'An End-Cell piece was missing', { details: data });
}
return result;
}

async createIntentBatch(intents: BatchIntentInput[]): Promise<CreateIntentResult[]> {
if (!Array.isArray(intents) || intents.length === 0) {
throw new ValidPayError('invalid_argument', 'intents must contain at least 1 item');
Expand Down Expand Up @@ -273,7 +365,17 @@ export class ValidPayClient {
'Use verifySelectiveIntent(retrievalId, key, role) instead of verifyIntent().',
);
}
if (data.split_key) {
// End-Cell (custody separation): platform share(s) from the API + the rail share
// from the independent KeyHalve rail (signature-verified vs the pinned key),
// XOR-combined with ShareA (`key`). Fails closed if either is missing.
let decryptionKey = key;
if (data.end_cell) {
const [platformPieces, railPiece] = await Promise.all([
this.fetchPieces(retrievalId),
fetchRailPiece(fetch, this.railBaseUrl, this.railPublicKeySpki, retrievalId),
]);
decryptionKey = combineKeyPieces(key, [...platformPieces, railPiece]);
} else if (data.split_key) {
throw new ValidPayError(
'split_key_required',
`Intent ${retrievalId} uses split-key protection. ` +
Expand All @@ -284,7 +386,7 @@ export class ValidPayClient {
// Commitment check over the ciphertext (C-1); legacy v1 skips.
const integrityVerified = await this.checkCommitmentHash(data);
// M-5: reconstruct the AAD for v2 intents.
const decrypted = await decrypt(data.encrypted_payload, key, this.aadFor(data));
const decrypted = await decrypt(data.encrypted_payload, decryptionKey, this.aadFor(data));
const payload = parseJsonOrThrow(decrypted);

return {
Expand Down
43 changes: 43 additions & 0 deletions src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,49 @@ export function combineKeyShares(shareA: string, shareB: string): string {
return bytesToBase64(out);
}

/**
* Split an AES-256 key for End-Cell (n-of-n XOR): `[shareA, piece_1, …]`. ShareA is
* the QR key; the pieces go to the server-side holders. The key is recovered as
* `shareA XOR piece_1 XOR … XOR piece_n`. Byte-identical to the other ValidPay SDKs
* and the verifier.
*/
export function splitKeyPieces(keyBase64: string, pieceCount: number): string[] {
if (!Number.isInteger(pieceCount) || pieceCount < 1) {
throw new ValidPayError('invalid_argument', 'pieceCount must be >= 1');
}
const keyBytes = decodeKey(keyBase64);
const shareA = getRandomValuesSync(new Uint8Array(KEY_BYTES));
const remainder = new Uint8Array(KEY_BYTES);
for (let i = 0; i < KEY_BYTES; i++) remainder[i] = keyBytes[i] ^ shareA[i];
const pieces: Uint8Array[] = [];
for (let p = 0; p < pieceCount - 1; p++) {
const piece = getRandomValuesSync(new Uint8Array(KEY_BYTES));
for (let i = 0; i < KEY_BYTES; i++) remainder[i] ^= piece[i];
pieces.push(piece);
}
pieces.push(remainder); // last piece absorbs the remainder
return [bytesToBase64(shareA), ...pieces.map((b) => bytesToBase64(b))];
}

/** Reconstruct an AES-256 key from ShareA + every server-side piece (XOR). */
export function combineKeyPieces(shareA: string, pieces: string[]): string {
if (pieces.length < 1) {
throw new ValidPayError('invalid_argument', 'need at least one piece');
}
const out = base64ToBytes(shareA);
if (out.length !== KEY_BYTES) {
throw new ValidPayError('invalid_key', `ShareA must be ${KEY_BYTES} bytes`);
}
for (const pieceB64 of pieces) {
const piece = base64ToBytes(pieceB64);
if (piece.length !== KEY_BYTES) {
throw new ValidPayError('invalid_key', `Each piece must be ${KEY_BYTES} bytes`);
}
for (let i = 0; i < KEY_BYTES; i++) out[i] ^= piece[i];
}
return bytesToBase64(out);
}

// ---------------------------------------------------------------------------
// Selective field disclosure (Patent E)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading