diff --git a/__tests__/rail.test.ts b/__tests__/rail.test.ts new file mode 100644 index 0000000..413ecad --- /dev/null +++ b/__tests__/rail.test.ts @@ -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); + }); +}); diff --git a/package-lock.json b/package-lock.json index 9af2fa5..2cd089a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@validpay/react-native-sdk", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@validpay/react-native-sdk", - "version": "1.1.0", + "version": "1.3.0", "license": "MIT", + "dependencies": { + "@noble/curves": "^1.6.0" + }, "devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^20.0.0", @@ -896,6 +899,33 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", diff --git a/package.json b/package.json index 30281ce..378985d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "optional": true } }, + "dependencies": { + "@noble/curves": "^1.6.0" + }, "devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^20.0.0", diff --git a/src/client.ts b/src/client.ts index bbfbeac..1fdaddd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { @@ -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; } /** @@ -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; @@ -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) { @@ -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; } // --------------------------------------------------------------------------- @@ -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 { + 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 = { + 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 { + const data = await this.request<{ pieces?: Record; 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 { if (!Array.isArray(intents) || intents.length === 0) { throw new ValidPayError('invalid_argument', 'intents must contain at least 1 item'); @@ -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. ` + @@ -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 { diff --git a/src/crypto.ts b/src/crypto.ts index 404a1ce..c2dda08 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -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) // --------------------------------------------------------------------------- diff --git a/src/rail.ts b/src/rail.ts new file mode 100644 index 0000000..c81debb --- /dev/null +++ b/src/rail.ts @@ -0,0 +1,85 @@ +/** + * KeyHalve rail client (verify side) for React Native. + * + * Fetches the blind rail share `B_keyhalve` from the independent KeyHalve rail and + * verifies the rail's Ed25519 signature against a PINNED public key (via @noble/curves, + * pure JS — works in RN). Fails closed on any doubt. The caller XOR-combines the + * verified rail share with the platform share(s) (from the ValidPay API) and ShareA. + * + * The pinned key ships with the SDK, not fetched at runtime. + */ + +import { ed25519 } from '@noble/curves/ed25519'; +import { base64ToBytes } from './crypto'; +import { ValidPayError } from './errors'; + +export const KEYHALVE_RAIL_BASE_URL = 'https://rail.keyhalve.com'; +export const KEYHALVE_RAIL_PUBLIC_KEY_SPKI_B64 = + 'MCowBQYDK2VwAyEAngOcqC4hL467C9RyWUh4bAQD3Fohi9zqhY+l65bul6w='; + +const HOLDER = 'keyhalve'; + +function canonicalMessage(intentId: string, piece: string): string { + return `keyhalve-rail.v1\n${intentId}\n${HOLDER}\n${piece}`; +} + +// The canonical message is ASCII (literal + base64 + [A-Za-z0-9_-] id + newlines), +// so a charCode copy is a correct UTF-8 encoding without needing TextEncoder. +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; +} + +interface RailPieceResponse { + holder?: string; + piece?: string; + sig?: string; + alg?: string; + error?: string; +} + +export async function fetchRailPiece( + fetchImpl: typeof fetch, + railBaseUrl: string, + pinnedSpkiB64: string, + intentId: string, +): Promise { + const base = railBaseUrl.replace(/\/+$/, ''); + let res: Response; + try { + res = await fetchImpl(`${base}/v1/piece/${encodeURIComponent(intentId)}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + } catch { + throw new ValidPayError('rail_unreachable', 'Could not reach the KeyHalve rail'); + } + + if (res.status === 404) throw new ValidPayError('rail_not_found', 'Rail share not found'); + if (res.status === 409) throw new ValidPayError('rail_revoked', 'Rail share revoked'); + if (!res.ok) throw new ValidPayError('rail_error', `Rail returned ${res.status}`); + + const json = (await res.json()) as RailPieceResponse; + if (json?.error) throw new ValidPayError('rail_error', `Rail error: ${json.error}`); + if (!json?.piece || !json?.sig || json?.holder !== HOLDER) { + throw new ValidPayError('rail_malformed', 'Malformed rail response'); + } + + // The Ed25519 SPKI DER ends with the 32-byte raw public key, which is what @noble wants. + const rawPub = base64ToBytes(pinnedSpkiB64).slice(-32); + let ok = false; + try { + ok = ed25519.verify( + base64ToBytes(json.sig), + asciiBytes(canonicalMessage(intentId, json.piece)), + rawPub, + ); + } catch { + ok = false; + } + if (!ok) { + throw new ValidPayError('rail_bad_signature', 'Rail response failed signature verification'); + } + return json.piece; +}