diff --git a/package-lock.json b/package-lock.json index 7f901b4..017dab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@ledgerhq/hw-app-str": "^6.29.0", "@ledgerhq/hw-transport-webhid": "^6.35.2", + "@noble/curves": "^2.2.0", "@stellar/freighter-api": "^3.1.0", "@stellar/stellar-sdk": "^13.3.0", "@walletconnect/sign-client": "^2.23.9" @@ -617,15 +618,27 @@ } }, "node_modules/@noble/curves": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", - "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.7.0" + "@noble/hashes": "2.2.0" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1709,7 +1722,6 @@ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1959,6 +1971,21 @@ "uint8arrays": "^3.0.0" } }, + "node_modules/@walletconnect/relay-auth/node_modules/@noble/curves": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@walletconnect/safe-json": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@walletconnect/safe-json/-/safe-json-1.0.2.tgz", @@ -2677,7 +2704,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3029,8 +3055,7 @@ "version": "6.2.4", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz", "integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/ieee754": { "version": "1.2.1", @@ -3493,7 +3518,6 @@ "version": "4.0.4", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3581,7 +3605,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -3894,7 +3917,6 @@ "integrity": "sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "lilconfig": "^3.1.3", @@ -4248,7 +4270,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 7d2ea6b..c49a769 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dependencies": { "@ledgerhq/hw-app-str": "^6.29.0", "@ledgerhq/hw-transport-webhid": "^6.35.2", + "@noble/curves": "^2.2.0", "@stellar/freighter-api": "^3.1.0", "@stellar/stellar-sdk": "^13.3.0", "@walletconnect/sign-client": "^2.23.9" diff --git a/src/confidential.ts b/src/confidential.ts new file mode 100644 index 0000000..3faa464 --- /dev/null +++ b/src/confidential.ts @@ -0,0 +1,548 @@ +/** + * Confidential payment helpers for Pedersen commitment-based privacy. + * + * Provides functions to: + * - Generate Pedersen commitments for payment amounts + * - Store/retrieve encrypted blinding factors + * - Build reveal_payment transactions + * + * Uses secp256k1 curve with @noble/curves for cryptographic operations. + */ + +import { secp256k1, secp256k1_hasher } from "@noble/curves/secp256k1.js"; +import { + Contract, + nativeToScVal, + TransactionBuilder, + BASE_FEE, + Account, + Transaction, +} from "@stellar/stellar-sdk"; +import type { StellarSplitClientConfig } from "./client.js"; +import type { + PedersenCommitment, + BlindingFactorStorageConfig, + StoredBlindingFactor, + RevealPaymentOptions, +} from "./types.js"; +import { + CommitmentGenerationError, + BlindingFactorStorageError, + BlindingFactorNotFoundError, + BlindingFactorDecryptionError, +} from "./errors.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PEDERSEN_H_DOMAIN = "StellarSplit_Pedersen_H_v1"; +const DB_NAME = "StellarSplitConfidential"; +const STORE_NAME = "blindingFactors"; +const DEFAULT_KEY_PREFIX = "stellarsplit:bf:"; +const SESSION_KEY_NAME = "stellarsplit:encryption_key"; + +// --------------------------------------------------------------------------- +// Generator Point H (cached) +// --------------------------------------------------------------------------- + +/** Cached H point for Pedersen commitments. */ +let _cachedH: InstanceType | null = null; + +/** + * Derive the secondary generator point H using hash-to-curve. + * This is a "nothing up my sleeve" derivation ensuring H is + * provably independent from G. + */ +function getGeneratorH(): InstanceType { + if (_cachedH) return _cachedH; + + // Use hash-to-curve with domain separator to derive H + // This guarantees a valid point and is cryptographically sound + const domainBytes = new TextEncoder().encode(PEDERSEN_H_DOMAIN); + const point = secp256k1_hasher.hashToCurve(domainBytes); + + _cachedH = point; + return point; +} + +/** + * Generate a random 32-byte blinding factor using cryptographically + * secure random number generation. + */ +function generateBlindingFactor(): Buffer { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + + // Ensure the scalar is valid (less than curve order) by using mod + const n = secp256k1.Point.Fn.ORDER; + let scalar = BigInt("0x" + Buffer.from(bytes).toString("hex")); + scalar = scalar % n; + if (scalar === 0n) scalar = 1n; // Avoid zero + + const hexStr = scalar.toString(16).padStart(64, "0"); + return Buffer.from(hexStr, "hex"); +} + +// --------------------------------------------------------------------------- +// Pedersen Commitment Generation +// --------------------------------------------------------------------------- + +/** + * Generate a Pedersen commitment for a payment amount. + * + * The commitment C = aH + vG where: + * - v is the value (amount) + * - a is the random blinding factor + * - G is the secp256k1 generator + * - H is a secondary generator derived via hash-to-curve + * + * @param amount - The payment amount in stroops (must be >= 0) + * @returns Commitment and blinding factor + * @throws {CommitmentGenerationError} If generation fails + */ +export function generateCommitment(amount: bigint): PedersenCommitment { + if (amount < 0n) { + throw new CommitmentGenerationError("Amount must be non-negative"); + } + + try { + const H = getGeneratorH(); + const G = secp256k1.Point.BASE; + + // Generate random blinding factor + const blindingFactor = generateBlindingFactor(); + const a = BigInt("0x" + blindingFactor.toString("hex")); + + // Compute C = aH + vG + const aH = H.multiply(a); + // Handle zero amount specially - use identity point + const vG = amount === 0n ? secp256k1.Point.ZERO : G.multiply(amount); + const C = aH.add(vG); + + // Serialize commitment as compressed point (33 bytes) + const commitment = Buffer.from(C.toBytes()); + + return { + commitment, + blindingFactor, + }; + } catch (err) { + if (err instanceof CommitmentGenerationError) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new CommitmentGenerationError(`Failed to generate commitment: ${message}`); + } +} + +/** + * Verify that a commitment matches a given value and blinding factor. + * + * @param commitment - The commitment to verify + * @param value - The claimed value + * @param blindingFactor - The claimed blinding factor + * @returns True if the commitment is valid for the given value and blinding factor + */ +export function verifyCommitment( + commitment: Buffer, + value: bigint, + blindingFactor: Buffer +): boolean { + try { + const H = getGeneratorH(); + const G = secp256k1.Point.BASE; + + const a = BigInt("0x" + blindingFactor.toString("hex")); + const aH = H.multiply(a); + // Handle zero value specially - use identity point + const vG = value === 0n ? secp256k1.Point.ZERO : G.multiply(value); + const expectedC = aH.add(vG); + + // fromHex expects a hex string, not a Buffer + const actualC = secp256k1.Point.fromHex(commitment.toString("hex")); + return expectedC.equals(actualC); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Blinding Factor Storage (Session-based AES-256-GCM) +// --------------------------------------------------------------------------- + +/** Global storage configuration. */ +let globalStorageConfig: BlindingFactorStorageConfig = {}; + +/** + * Configure blinding factor storage globally. + * + * @param config - Storage configuration options + */ +export function configureBlindingFactorStorage(config: BlindingFactorStorageConfig): void { + globalStorageConfig = { ...globalStorageConfig, ...config }; +} + +/** + * Reset storage configuration to defaults. + */ +export function resetBlindingFactorStorageConfig(): void { + globalStorageConfig = {}; +} + +/** + * Get or create encryption key stored in sessionStorage. + */ +async function getEncryptionKey(): Promise { + // Check for stored key in sessionStorage (ephemeral) + const storedKey = sessionStorage.getItem(SESSION_KEY_NAME); + if (storedKey) { + const keyData = Uint8Array.from(atob(storedKey), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + "raw", + keyData, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + } + + // Generate and store new key + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + const exported = await crypto.subtle.exportKey("raw", key); + sessionStorage.setItem( + SESSION_KEY_NAME, + btoa(String.fromCharCode(...new Uint8Array(exported))) + ); + + return key; +} + +/** + * Open IndexedDB connection with the blinding factors store. + */ +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + + request.onerror = () => + reject(new BlindingFactorStorageError("Failed to open database")); + + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "invoiceId" }); + } + }; + }); +} + +/** + * Check if IndexedDB is available. + */ +function isIndexedDBAvailable(): boolean { + try { + return typeof indexedDB !== "undefined" && indexedDB !== null; + } catch { + return false; + } +} + +/** + * Store a blinding factor encrypted with AES-GCM. + * + * @param invoiceId - Invoice ID to associate with the blinding factor + * @param blindingFactor - The blinding factor to store + * @param config - Optional storage configuration override + * @throws {BlindingFactorStorageError} If storage fails + */ +export async function storeBlindingFactor( + invoiceId: bigint, + blindingFactor: Buffer, + config?: BlindingFactorStorageConfig +): Promise { + const cfg = { ...globalStorageConfig, ...config }; + const keyPrefix = cfg.keyPrefix ?? DEFAULT_KEY_PREFIX; + const invoiceIdStr = invoiceId.toString(); + + try { + // Generate IV + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Get encryption key + const key = await getEncryptionKey(); + + // Encrypt the blinding factor (convert Buffer to Uint8Array for Web Crypto API) + const encryptedData = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new Uint8Array(blindingFactor) + ); + + const entry: StoredBlindingFactor = { + encryptedData: new Uint8Array(encryptedData), + iv, + invoiceId: invoiceIdStr, + storedAt: Date.now(), + }; + + if (isIndexedDBAvailable()) { + // Use IndexedDB + const db = await openDatabase(); + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + + await new Promise((resolve, reject) => { + const request = store.put(entry); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + db.close(); + } else { + // Fall back to localStorage with Base64 encoding + const serialized = JSON.stringify({ + encryptedData: btoa(String.fromCharCode(...entry.encryptedData)), + iv: btoa(String.fromCharCode(...entry.iv)), + invoiceId: entry.invoiceId, + storedAt: entry.storedAt, + }); + localStorage.setItem(`${keyPrefix}${invoiceIdStr}`, serialized); + } + } catch (err) { + if (err instanceof BlindingFactorStorageError) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new BlindingFactorStorageError( + `Failed to store blinding factor: ${message}`, + invoiceIdStr + ); + } +} + +/** + * Load and decrypt a blinding factor for an invoice. + * + * @param invoiceId - Invoice ID to retrieve blinding factor for + * @param config - Optional storage configuration override + * @returns The decrypted blinding factor, or null if not found + * @throws {BlindingFactorDecryptionError} If decryption fails + */ +export async function loadBlindingFactor( + invoiceId: bigint, + config?: BlindingFactorStorageConfig +): Promise { + const cfg = { ...globalStorageConfig, ...config }; + const keyPrefix = cfg.keyPrefix ?? DEFAULT_KEY_PREFIX; + const invoiceIdStr = invoiceId.toString(); + + try { + let entry: StoredBlindingFactor | null = null; + + if (isIndexedDBAvailable()) { + const db = await openDatabase(); + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + + entry = await new Promise((resolve, reject) => { + const request = store.get(invoiceIdStr); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }); + + db.close(); + } else { + // Load from localStorage + const serialized = localStorage.getItem(`${keyPrefix}${invoiceIdStr}`); + if (serialized) { + const parsed = JSON.parse(serialized); + entry = { + encryptedData: Uint8Array.from(atob(parsed.encryptedData), (c) => + c.charCodeAt(0) + ), + iv: Uint8Array.from(atob(parsed.iv), (c) => c.charCodeAt(0)), + invoiceId: parsed.invoiceId, + storedAt: parsed.storedAt, + }; + } + } + + if (!entry) { + return null; + } + + // Get decryption key + const key = await getEncryptionKey(); + + // Decrypt the blinding factor (ensure we pass ArrayBuffer for Web Crypto API) + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: new Uint8Array(entry.iv) }, + key, + new Uint8Array(entry.encryptedData) + ); + + return Buffer.from(decrypted); + } catch (err) { + if (err instanceof BlindingFactorDecryptionError) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new BlindingFactorDecryptionError( + `Failed to decrypt blinding factor: ${message}`, + invoiceIdStr + ); + } +} + +/** + * Delete a stored blinding factor. + * + * @param invoiceId - Invoice ID to delete blinding factor for + * @param config - Optional storage configuration override + */ +export async function deleteBlindingFactor( + invoiceId: bigint, + config?: BlindingFactorStorageConfig +): Promise { + const cfg = { ...globalStorageConfig, ...config }; + const keyPrefix = cfg.keyPrefix ?? DEFAULT_KEY_PREFIX; + const invoiceIdStr = invoiceId.toString(); + + try { + if (isIndexedDBAvailable()) { + const db = await openDatabase(); + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + + await new Promise((resolve, reject) => { + const request = store.delete(invoiceIdStr); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + db.close(); + } else { + localStorage.removeItem(`${keyPrefix}${invoiceIdStr}`); + } + } catch { + // Silently ignore deletion errors + } +} + +// --------------------------------------------------------------------------- +// Transaction Building +// --------------------------------------------------------------------------- + +/** + * Build a reveal_payment transaction for the StellarSplit contract. + * + * This transaction reveals a previously committed payment by providing + * the original value and blinding factor. + * + * @param options - Reveal payment options + * @param config - Client configuration + * @returns An unsigned Transaction ready for signing + */ +export function buildRevealTransaction( + options: RevealPaymentOptions, + config: StellarSplitClientConfig +): Transaction { + const { invoiceId, value, blindingFactor, payer } = options; + + // Create contract instance + const contract = new Contract(config.contractId); + + // Build the reveal_payment operation + // Contract signature: reveal_payment(invoice_id: u64, value: i128, blinding_factor: BytesN<32>, payer: Address) + const op = contract.call( + "reveal_payment", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(value, { type: "i128" }), + nativeToScVal(blindingFactor, { type: "bytes" }), + nativeToScVal(payer, { type: "address" }) + ); + + // Create a fallback source account for offline building + const sourceAccount = { + accountId: () => payer, + sequenceNumber: () => "0", + incrementSequenceNumber: () => { + /* no-op */ + }, + } as unknown as Account; + + const builder = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }); + + builder.addOperation(op); + builder.setTimeout(30); + + return builder.build(); +} + +// --------------------------------------------------------------------------- +// Convenience Functions +// --------------------------------------------------------------------------- + +/** + * Generate a commitment and store the blinding factor atomically. + * + * This is a convenience function that combines commitment generation + * with secure storage of the blinding factor. + * + * @param invoiceId - Invoice ID to associate with the commitment + * @param amount - Payment amount in stroops + * @param storageConfig - Optional storage configuration + * @returns The generated commitment (blinding factor is stored internally) + */ +export async function generateAndStoreCommitment( + invoiceId: bigint, + amount: bigint, + storageConfig?: BlindingFactorStorageConfig +): Promise { + const { commitment, blindingFactor } = generateCommitment(amount); + + await storeBlindingFactor(invoiceId, blindingFactor, storageConfig); + + return commitment; +} + +/** + * Load blinding factor and build reveal transaction. + * + * This is a convenience function for the full reveal flow. + * + * @param invoiceId - Invoice ID to reveal + * @param value - The original committed value + * @param payer - Payer's Stellar address + * @param config - Client configuration + * @param storageConfig - Optional storage configuration + * @returns An unsigned Transaction ready for signing + * @throws {BlindingFactorNotFoundError} If no blinding factor is stored + */ +export async function buildRevealTransactionFromStorage( + invoiceId: bigint, + value: bigint, + payer: string, + config: StellarSplitClientConfig, + storageConfig?: BlindingFactorStorageConfig +): Promise { + const blindingFactor = await loadBlindingFactor(invoiceId, storageConfig); + + if (!blindingFactor) { + throw new BlindingFactorNotFoundError(invoiceId.toString()); + } + + return buildRevealTransaction( + { + invoiceId, + value, + blindingFactor, + payer, + }, + config + ); +} diff --git a/src/errors.ts b/src/errors.ts index 83f0050..c877bc8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1090,3 +1090,72 @@ export class AdminOperationError extends StellarSplitError { export function isAdminOperationError(err: unknown): err is AdminOperationError { return err instanceof AdminOperationError; } + +// --------------------------------------------------------------------------- +// Confidential Payment Errors (Pedersen Commitments) +// --------------------------------------------------------------------------- + +/** Thrown when Pedersen commitment generation fails. */ +export class CommitmentGenerationError extends StellarSplitError { + constructor(message: string) { + super(message, "COMMITMENT_GENERATION_ERROR", undefined, message); + this.name = "CommitmentGenerationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isCommitmentGenerationError(err: unknown): err is CommitmentGenerationError { + return err instanceof CommitmentGenerationError; +} + +/** Thrown when blinding factor storage operation fails. */ +export class BlindingFactorStorageError extends StellarSplitError { + readonly invoiceId?: string; + + constructor(message: string, invoiceId?: string) { + super(message, "BLINDING_FACTOR_STORAGE_ERROR", { invoiceId }, message); + this.name = "BlindingFactorStorageError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isBlindingFactorStorageError(err: unknown): err is BlindingFactorStorageError { + return err instanceof BlindingFactorStorageError; +} + +/** Thrown when blinding factor is not found for an invoice. */ +export class BlindingFactorNotFoundError extends StellarSplitError { + readonly invoiceId: string; + + constructor(invoiceId: string) { + super( + `Blinding factor not found for invoice: ${invoiceId}`, + "BLINDING_FACTOR_NOT_FOUND", + { invoiceId } + ); + this.name = "BlindingFactorNotFoundError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isBlindingFactorNotFoundError(err: unknown): err is BlindingFactorNotFoundError { + return err instanceof BlindingFactorNotFoundError; +} + +/** Thrown when blinding factor decryption fails. */ +export class BlindingFactorDecryptionError extends StellarSplitError { + readonly invoiceId?: string; + + constructor(message: string, invoiceId?: string) { + super(message, "BLINDING_FACTOR_DECRYPTION_ERROR", { invoiceId }, message); + this.name = "BlindingFactorDecryptionError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isBlindingFactorDecryptionError(err: unknown): err is BlindingFactorDecryptionError { + return err instanceof BlindingFactorDecryptionError; +} diff --git a/src/index.ts b/src/index.ts index fbf08b0..88d20df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,6 +150,14 @@ export { isRequestTimeoutError, AdminOperationError, isAdminOperationError, + CommitmentGenerationError, + isCommitmentGenerationError, + BlindingFactorStorageError, + isBlindingFactorStorageError, + BlindingFactorNotFoundError, + isBlindingFactorNotFoundError, + BlindingFactorDecryptionError, + isBlindingFactorDecryptionError, } from "./errors.js"; export { getScheduledReleaseCountdown } from "./client.js"; export { verifyCompletionProof } from "./client.js"; @@ -159,6 +167,20 @@ export type { ProfileReport } from "./profiler.js"; export { enrichInvoice, registerInvoiceFetcher } from "./enricher.js"; export type { EnrichedInvoice } from "./enricher.js"; +// Confidential payments (Pedersen commitments) +export { + generateCommitment, + verifyCommitment, + storeBlindingFactor, + loadBlindingFactor, + deleteBlindingFactor, + configureBlindingFactorStorage, + resetBlindingFactorStorageConfig, + buildRevealTransaction, + generateAndStoreCommitment, + buildRevealTransactionFromStorage, +} from "./confidential.js"; + export { Deduplicator } from "./dedup.js"; export { TxQueue } from "./queue.js"; @@ -539,4 +561,8 @@ export type { AutoResolveSimulation, InvoiceStats, PrerequisiteChainEntry, + PedersenCommitment, + BlindingFactorStorageConfig, + StoredBlindingFactor, + RevealPaymentOptions, } from "./types.js"; diff --git a/src/types.ts b/src/types.ts index d2c2e43..b6ebaf1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -819,3 +819,45 @@ export interface SetCrossChainRefParams { /** Cross-chain reference data. */ ref: CrossChainRef; } + +// --------------------------------------------------------------------------- +// Confidential Payment Types (Pedersen Commitments) +// --------------------------------------------------------------------------- + +/** Result of generating a Pedersen commitment for a payment amount. */ +export interface PedersenCommitment { + /** The commitment point C = aH + vG, serialized as 33-byte compressed point. */ + commitment: Buffer; + /** The random blinding factor 'a' used in the commitment (32 bytes). */ + blindingFactor: Buffer; +} + +/** Configuration for blinding factor storage. */ +export interface BlindingFactorStorageConfig { + /** Storage key prefix. Defaults to "stellarsplit:bf:". */ + keyPrefix?: string; +} + +/** Stored blinding factor entry with metadata. */ +export interface StoredBlindingFactor { + /** The encrypted blinding factor. */ + encryptedData: Uint8Array; + /** AES-GCM initialization vector (12 bytes). */ + iv: Uint8Array; + /** Invoice ID this blinding factor belongs to. */ + invoiceId: string; + /** Unix timestamp when stored. */ + storedAt: number; +} + +/** Options for building a reveal payment transaction. */ +export interface RevealPaymentOptions { + /** Invoice ID to reveal payment for. */ + invoiceId: bigint; + /** The actual payment value that was committed. */ + value: bigint; + /** The blinding factor used in the original commitment. */ + blindingFactor: Buffer; + /** Payer's Stellar address. */ + payer: string; +} diff --git a/test/confidential.test.ts b/test/confidential.test.ts new file mode 100644 index 0000000..b19b254 --- /dev/null +++ b/test/confidential.test.ts @@ -0,0 +1,462 @@ +/** + * Tests for confidential payment helpers. + * + * Coverage: + * - Pedersen commitment generation + * - Commitment/reveal roundtrip verification + * - Blinding factor storage and retrieval + * - Tampered value detection + * - Missing blinding factor handling + * - Transaction building for reveal_payment + * - Error scenarios + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { + generateCommitment, + verifyCommitment, + storeBlindingFactor, + loadBlindingFactor, + deleteBlindingFactor, + configureBlindingFactorStorage, + resetBlindingFactorStorageConfig, + buildRevealTransaction, + generateAndStoreCommitment, + buildRevealTransactionFromStorage, +} from "../src/confidential.js"; +import { + CommitmentGenerationError, + BlindingFactorStorageError, + BlindingFactorNotFoundError, + BlindingFactorDecryptionError, + isCommitmentGenerationError, + isBlindingFactorStorageError, + isBlindingFactorNotFoundError, + isBlindingFactorDecryptionError, +} from "../src/errors.js"; +import type { StellarSplitClientConfig } from "../src/client.js"; + +// Test configuration +const TEST_CONFIG: StellarSplitClientConfig = { + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", +}; + +// Mock sessionStorage for Node.js test environment +const mockSessionStorage: Record = {}; +vi.stubGlobal("sessionStorage", { + getItem: (key: string) => mockSessionStorage[key] ?? null, + setItem: (key: string, value: string) => { + mockSessionStorage[key] = value; + }, + removeItem: (key: string) => { + delete mockSessionStorage[key]; + }, + clear: () => { + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + }, +}); + +// Mock localStorage for fallback testing +const mockLocalStorage: Record = {}; +vi.stubGlobal("localStorage", { + getItem: (key: string) => mockLocalStorage[key] ?? null, + setItem: (key: string, value: string) => { + mockLocalStorage[key] = value; + }, + removeItem: (key: string) => { + delete mockLocalStorage[key]; + }, + clear: () => { + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]); + }, +}); + +describe("Pedersen Commitment Generation", () => { + it("generates valid commitment for positive amount", () => { + const amount = 1000000000n; // 100 XLM in stroops + const result = generateCommitment(amount); + + expect(result.commitment).toBeInstanceOf(Buffer); + expect(result.commitment.length).toBe(33); // Compressed point + expect(result.blindingFactor).toBeInstanceOf(Buffer); + expect(result.blindingFactor.length).toBe(32); + }); + + it("generates different commitments for same amount", () => { + const amount = 1000000000n; + const result1 = generateCommitment(amount); + const result2 = generateCommitment(amount); + + // Commitments should differ due to random blinding factors + expect(result1.commitment.equals(result2.commitment)).toBe(false); + expect(result1.blindingFactor.equals(result2.blindingFactor)).toBe(false); + }); + + it("generates commitment for zero amount", () => { + const result = generateCommitment(0n); + + expect(result.commitment).toBeInstanceOf(Buffer); + expect(result.commitment.length).toBe(33); + }); + + it("throws for negative amount", () => { + expect(() => generateCommitment(-100n)).toThrow(CommitmentGenerationError); + }); + + it("handles very large amounts", () => { + const largeAmount = BigInt("9223372036854775807"); // Max i64 + const result = generateCommitment(largeAmount); + + expect(result.commitment).toBeInstanceOf(Buffer); + expect(result.commitment.length).toBe(33); + }); + + it("commitment starts with valid prefix byte", () => { + const result = generateCommitment(1000n); + // Compressed point prefix is 0x02 or 0x03 + expect([0x02, 0x03]).toContain(result.commitment[0]); + }); +}); + +describe("Commitment Verification", () => { + it("verifies correct value and blinding factor", () => { + const amount = 500000000n; + const { commitment, blindingFactor } = generateCommitment(amount); + + const isValid = verifyCommitment(commitment, amount, blindingFactor); + expect(isValid).toBe(true); + }); + + it("detects tampered value", () => { + const amount = 500000000n; + const { commitment, blindingFactor } = generateCommitment(amount); + + // Try to verify with wrong amount + const wrongAmount = 600000000n; + const isValid = verifyCommitment(commitment, wrongAmount, blindingFactor); + expect(isValid).toBe(false); + }); + + it("detects wrong blinding factor", () => { + const amount = 500000000n; + const { commitment } = generateCommitment(amount); + + // Generate different blinding factor + const { blindingFactor: wrongBlindingFactor } = generateCommitment(amount); + + const isValid = verifyCommitment(commitment, amount, wrongBlindingFactor); + expect(isValid).toBe(false); + }); + + it("returns false for invalid commitment buffer", () => { + const invalidCommitment = Buffer.alloc(33, 0); + const blindingFactor = Buffer.alloc(32, 1); + + const isValid = verifyCommitment(invalidCommitment, 100n, blindingFactor); + expect(isValid).toBe(false); + }); + + it("returns false for truncated commitment", () => { + const { blindingFactor } = generateCommitment(100n); + const truncatedCommitment = Buffer.alloc(16, 0x02); + + const isValid = verifyCommitment(truncatedCommitment, 100n, blindingFactor); + expect(isValid).toBe(false); + }); + + it("verifies multiple roundtrips", () => { + for (let i = 0; i < 5; i++) { + const amount = BigInt(Math.floor(Math.random() * 1000000000)); + const { commitment, blindingFactor } = generateCommitment(amount); + expect(verifyCommitment(commitment, amount, blindingFactor)).toBe(true); + } + }); +}); + +describe("Blinding Factor Storage", () => { + beforeEach(() => { + resetBlindingFactorStorageConfig(); + // Clear IndexedDB + indexedDB.deleteDatabase("StellarSplitConfidential"); + // Clear mock storage + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stores and retrieves blinding factor", async () => { + const invoiceId = 12345n; + const { blindingFactor } = generateCommitment(1000000n); + + await storeBlindingFactor(invoiceId, blindingFactor); + const loaded = await loadBlindingFactor(invoiceId); + + expect(loaded).not.toBeNull(); + expect(loaded!.equals(blindingFactor)).toBe(true); + }); + + it("returns null for non-existent blinding factor", async () => { + const loaded = await loadBlindingFactor(99999n); + expect(loaded).toBeNull(); + }); + + it("overwrites existing blinding factor", async () => { + const invoiceId = 12345n; + const { blindingFactor: bf1 } = generateCommitment(1000000n); + const { blindingFactor: bf2 } = generateCommitment(2000000n); + + await storeBlindingFactor(invoiceId, bf1); + await storeBlindingFactor(invoiceId, bf2); + + const loaded = await loadBlindingFactor(invoiceId); + expect(loaded!.equals(bf2)).toBe(true); + }); + + it("deletes blinding factor", async () => { + const invoiceId = 12345n; + const { blindingFactor } = generateCommitment(1000000n); + + await storeBlindingFactor(invoiceId, blindingFactor); + await deleteBlindingFactor(invoiceId); + + const loaded = await loadBlindingFactor(invoiceId); + expect(loaded).toBeNull(); + }); + + it("stores multiple blinding factors independently", async () => { + const { blindingFactor: bf1 } = generateCommitment(1000000n); + const { blindingFactor: bf2 } = generateCommitment(2000000n); + + await storeBlindingFactor(1n, bf1); + await storeBlindingFactor(2n, bf2); + + const loaded1 = await loadBlindingFactor(1n); + const loaded2 = await loadBlindingFactor(2n); + + expect(loaded1!.equals(bf1)).toBe(true); + expect(loaded2!.equals(bf2)).toBe(true); + }); + + it("uses global configuration", async () => { + configureBlindingFactorStorage({ keyPrefix: "custom:" }); + + const invoiceId = 12345n; + const { blindingFactor } = generateCommitment(1000000n); + + await storeBlindingFactor(invoiceId, blindingFactor); + const loaded = await loadBlindingFactor(invoiceId); + + expect(loaded!.equals(blindingFactor)).toBe(true); + }); +}); + +describe("Transaction Building", () => { + it("builds reveal_payment transaction", () => { + const { blindingFactor } = generateCommitment(1000000n); + + const tx = buildRevealTransaction( + { + invoiceId: 42n, + value: 1000000n, + blindingFactor, + payer: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + }, + TEST_CONFIG + ); + + expect(tx).toBeDefined(); + expect(tx.operations).toHaveLength(1); + }); + + it("builds transaction with correct network passphrase", () => { + const { blindingFactor } = generateCommitment(1000000n); + + const tx = buildRevealTransaction( + { + invoiceId: 42n, + value: 1000000n, + blindingFactor, + payer: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + }, + TEST_CONFIG + ); + + expect(tx.networkPassphrase).toBe(TEST_CONFIG.networkPassphrase); + }); + + it("builds transaction for different invoice IDs", () => { + const { blindingFactor } = generateCommitment(1000000n); + + const tx1 = buildRevealTransaction( + { + invoiceId: 1n, + value: 1000000n, + blindingFactor, + payer: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + }, + TEST_CONFIG + ); + + const tx2 = buildRevealTransaction( + { + invoiceId: 2n, + value: 1000000n, + blindingFactor, + payer: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + }, + TEST_CONFIG + ); + + // Transactions should be different + expect(tx1.toXDR()).not.toBe(tx2.toXDR()); + }); +}); + +describe("Convenience Functions", () => { + beforeEach(() => { + resetBlindingFactorStorageConfig(); + indexedDB.deleteDatabase("StellarSplitConfidential"); + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]); + }); + + it("generateAndStoreCommitment stores blinding factor", async () => { + const invoiceId = 42n; + const amount = 1000000n; + + const commitment = await generateAndStoreCommitment(invoiceId, amount); + + expect(commitment).toBeInstanceOf(Buffer); + expect(commitment.length).toBe(33); + + // Verify blinding factor was stored + const loaded = await loadBlindingFactor(invoiceId); + expect(loaded).not.toBeNull(); + + // Verify roundtrip + const isValid = verifyCommitment(commitment, amount, loaded!); + expect(isValid).toBe(true); + }); + + it("buildRevealTransactionFromStorage loads and builds", async () => { + const invoiceId = 42n; + const amount = 1000000n; + const payer = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + + await generateAndStoreCommitment(invoiceId, amount); + + const tx = await buildRevealTransactionFromStorage( + invoiceId, + amount, + payer, + TEST_CONFIG + ); + + expect(tx).toBeDefined(); + expect(tx.operations).toHaveLength(1); + }); + + it("throws BlindingFactorNotFoundError when not stored", async () => { + const invoiceId = 99999n; + const payer = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + + await expect( + buildRevealTransactionFromStorage(invoiceId, 1000000n, payer, TEST_CONFIG) + ).rejects.toThrow(BlindingFactorNotFoundError); + }); +}); + +describe("Error Type Guards", () => { + it("isCommitmentGenerationError identifies correctly", () => { + expect(isCommitmentGenerationError(new CommitmentGenerationError("test"))).toBe(true); + expect(isCommitmentGenerationError(new Error("test"))).toBe(false); + }); + + it("isBlindingFactorStorageError identifies correctly", () => { + expect(isBlindingFactorStorageError(new BlindingFactorStorageError("test"))).toBe(true); + expect(isBlindingFactorStorageError(new Error("test"))).toBe(false); + }); + + it("isBlindingFactorNotFoundError identifies correctly", () => { + expect(isBlindingFactorNotFoundError(new BlindingFactorNotFoundError("123"))).toBe(true); + expect(isBlindingFactorNotFoundError(new Error("test"))).toBe(false); + }); + + it("isBlindingFactorDecryptionError identifies correctly", () => { + expect(isBlindingFactorDecryptionError(new BlindingFactorDecryptionError("test"))).toBe(true); + expect(isBlindingFactorDecryptionError(new Error("test"))).toBe(false); + }); +}); + +describe("Commitment/Reveal Roundtrip", () => { + beforeEach(() => { + resetBlindingFactorStorageConfig(); + indexedDB.deleteDatabase("StellarSplitConfidential"); + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + Object.keys(mockLocalStorage).forEach((key) => delete mockLocalStorage[key]); + }); + + it("full commitment/reveal cycle succeeds", async () => { + const invoiceId = 42n; + const amount = 1000000000n; + const payer = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + + // Step 1: Generate commitment + const commitment = await generateAndStoreCommitment(invoiceId, amount); + + // Step 2: Load blinding factor + const blindingFactor = await loadBlindingFactor(invoiceId); + expect(blindingFactor).not.toBeNull(); + + // Step 3: Verify the commitment + const isValid = verifyCommitment(commitment, amount, blindingFactor!); + expect(isValid).toBe(true); + + // Step 4: Build reveal transaction + const tx = await buildRevealTransactionFromStorage( + invoiceId, + amount, + payer, + TEST_CONFIG + ); + expect(tx).toBeDefined(); + + // Step 5: Clean up + await deleteBlindingFactor(invoiceId); + const loaded = await loadBlindingFactor(invoiceId); + expect(loaded).toBeNull(); + }); + + it("detects tampered value in reveal", async () => { + const invoiceId = 42n; + const originalAmount = 1000000000n; + const tamperedAmount = 2000000000n; + + const commitment = await generateAndStoreCommitment(invoiceId, originalAmount); + const blindingFactor = await loadBlindingFactor(invoiceId); + + // Verification with tampered amount should fail + const isValid = verifyCommitment(commitment, tamperedAmount, blindingFactor!); + expect(isValid).toBe(false); + }); + + it("detects tampered blinding factor in reveal", async () => { + const invoiceId = 42n; + const amount = 1000000000n; + + const commitment = await generateAndStoreCommitment(invoiceId, amount); + + // Generate a different blinding factor + const { blindingFactor: tamperedBf } = generateCommitment(amount); + + // Verification with tampered blinding factor should fail + const isValid = verifyCommitment(commitment, amount, tamperedBf); + expect(isValid).toBe(false); + }); +});