diff --git a/apps/web/src/app/api/sync/proofs/route.ts b/apps/web/src/app/api/sync/proofs/route.ts new file mode 100644 index 0000000..796ae4f --- /dev/null +++ b/apps/web/src/app/api/sync/proofs/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from 'next/server'; +import { withClient, ensureSchema } from '@/lib/db'; +import { getMerchantFromRequest } from '@/lib/merchants'; +import { ensureZkCommitmentsSchema, recordVerifiedCommitment } from '@/lib/zk-ledger'; +import { + ZK_PROOF_SCHEME, + canonicalJson, + sha256Hex, + verifyOpeningProof, + type OpeningProof, +} from '@accensa/sdk/zk-proof'; + +export const dynamic = 'force-dynamic'; + +/** + * Zero-knowledge-verified state transitions (#173). + * + * The indexer's RPC sweep records on-chain truth about public transfers, but + * merchants also need to report state transitions privately — transaction + * volumes, routes, settlement details — without the indexer ever storing the + * plaintext. This endpoint is the privacy-preserving ingestion path: + * + * 1. The SDK commits to the transition with `createCommitment`, keeps the + * blinding secret, and submits `{ commitment, proof }` here. + * 2. The indexer verifies the opening proof (recomputing the commitment + * from the proof's payload + blinding). Verification happens entirely in + * memory — the plaintext payload never touches the database. + * 3. Only the commitment and a one-way SHA-256 of the canonical payload are + * persisted (see migrations/005_zk_commitments.sql). A leaked table + * exposes nothing about the underlying data. + * + * The verifier is pluggable (`ZkVerifier` in @accensa/sdk/zk-proof): a future + * migration to a real zk-SNARK circuit implements the same interface and this + * route does not change. + * + * Protected by session authentication via middleware, resolving to exactly + * the merchant that owns this dashboard session — a signed-in merchant can + * only submit proofs for themselves. + */ +export async function POST(request: Request) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { commitment, proof } = (body ?? {}) as { + commitment?: unknown; + proof?: unknown; + }; + if (typeof commitment !== 'string' || !/^[0-9a-f]{64}$/i.test(commitment)) { + return NextResponse.json( + { error: 'commitment must be a 64-character hex SHA-256 digest' }, + { status: 400 }, + ); + } + if (!proof || typeof proof !== 'object') { + return NextResponse.json({ error: 'proof is required' }, { status: 400 }); + } + const opening = proof as OpeningProof; + if (opening.scheme !== ZK_PROOF_SCHEME) { + return NextResponse.json( + { error: `unsupported proof scheme: ${String(opening.scheme)}` }, + { status: 400 }, + ); + } + + // Verify in memory before touching the database at all. + const valid = await verifyOpeningProof(commitment, opening); + if (!valid) { + return NextResponse.json({ error: 'proof does not open the commitment' }, { status: 422 }); + } + + try { + const result = await withClient(async (client) => { + await ensureSchema(client); + await ensureZkCommitmentsSchema(client); + const merchant = await getMerchantFromRequest(client, request); + if (!merchant) return null; + // The payload hash is the only trace of the plaintext ever persisted. + const payloadHash = await sha256Hex(canonicalJson(opening.payload)); + const { recorded } = await recordVerifiedCommitment(client, merchant.id, { + commitment, + payloadHash, + scheme: opening.scheme, + }); + return { address: merchant.address, recorded }; + }); + + if (!result) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return NextResponse.json( + { + success: true, + merchant: result.address, + commitment, + // 201 for a new commitment, 200 for an already-recorded one — the + // transition is accepted either way, it is simply idempotent. + recorded: result.recorded, + }, + { status: result.recorded ? 201 : 200 }, + ); + } catch { + return NextResponse.json( + { success: false, error: 'Internal Server Error' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/lib/zk-ledger.ts b/apps/web/src/lib/zk-ledger.ts new file mode 100644 index 0000000..b58398a --- /dev/null +++ b/apps/web/src/lib/zk-ledger.ts @@ -0,0 +1,63 @@ +import type { Client } from 'pg'; + +/** + * ZK commitment ledger (#173). + * + * The sync API's proof endpoint verifies an opening proof entirely in memory, + * then records *only* the commitment and a one-way hash of the canonical + * payload here — never the plaintext, so a leaked table exposes nothing about + * the underlying data. This module owns that table's schema and the single + * write path into it. + */ + +/** + * Creates the `zk_commitments` ledger schema (#173). + * + * See migrations/005_zk_commitments.sql for the same steps as a standalone SQL + * file. Called defensively at the top of the proofs route, the same way + * `ensureSchema` runs at the top of every DB-touching handler. Idempotent. + */ +export async function ensureZkCommitmentsSchema(client: Client): Promise { + await client.query(` + CREATE TABLE IF NOT EXISTS zk_commitments ( + id BIGSERIAL PRIMARY KEY, + merchant_id INT NOT NULL REFERENCES merchants(id), + commitment TEXT NOT NULL, + payload_hash TEXT NOT NULL, + proof_scheme TEXT NOT NULL DEFAULT 'sha256-commitment-opening', + verified_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT zk_commitments_unique_commitment + UNIQUE (merchant_id, commitment) + ); + `); + await client.query( + `CREATE INDEX IF NOT EXISTS idx_zk_commitments_merchant_verified + ON zk_commitments (merchant_id, verified_at DESC);`, + ); +} + +/** + * Records a verified zero-knowledge commitment. + * + * Called by the sync API *after* the proof has been verified — this function + * only persists the commitment and a one-way hash of the canonical payload, + * never the plaintext. Idempotent per (merchant, commitment): re-submitting + * the same verified transition is a no-op, not a duplicate. + * + * @returns Whether this was a new commitment (`true`) or an already-recorded + * one (`false`). + */ +export async function recordVerifiedCommitment( + client: Client, + merchantId: number, + c: { commitment: string; payloadHash: string; scheme: string }, +): Promise<{ recorded: boolean }> { + const res = await client.query( + `INSERT INTO zk_commitments (merchant_id, commitment, payload_hash, proof_scheme) + VALUES ($1, $2, $3, $4) + ON CONFLICT (merchant_id, commitment) DO NOTHING`, + [merchantId, c.commitment, c.payloadHash, c.scheme], + ); + return { recorded: (res.rowCount ?? 0) > 0 }; +} diff --git a/migrations/005_zk_commitments.sql b/migrations/005_zk_commitments.sql new file mode 100644 index 0000000..dc8b423 --- /dev/null +++ b/migrations/005_zk_commitments.sql @@ -0,0 +1,54 @@ +-- 005_zk_commitments.sql +-- +-- Commitment ledger for zero-knowledge-verified state transitions (#173). +-- +-- Merchants require privacy for transaction volumes: the indexer must be able +-- to accept and verify a commitment to a state transition without storing the +-- plaintext. This table records what was *proven* — a binding commitment plus +-- a hash of the canonical payload that opened it — never the payload itself. +-- +-- A leaked table therefore exposes nothing about the underlying data: the +-- commitment is hiding (an observer cannot recover the payload from it) and +-- the payload hash is one-way. The full plaintext lives only with the SDK +-- that submitted the proof. +-- +-- Rows are scoped to the merchant that submitted them (`merchant_id`), and +-- every write path resolves the merchant from the session before inserting. +-- +-- This file is applied automatically by `ensureZkCommitmentsSchema()` in +-- apps/web/src/lib/zk-ledger.ts on every request to the proofs route. It is +-- committed here too for documentation and for anyone restoring a database +-- outside the app. + +BEGIN; + +CREATE TABLE IF NOT EXISTS zk_commitments ( + id BIGSERIAL PRIMARY KEY, + merchant_id INT NOT NULL REFERENCES merchants(id), + -- Hex SHA-256 commitment as submitted by the SDK. Unique per merchant so + -- the same transition cannot be recorded twice. + commitment TEXT NOT NULL, + -- SHA-256 of the canonical JSON payload that opened the commitment. This + -- is the only trace of the underlying data; the payload itself is never + -- stored. + payload_hash TEXT NOT NULL, + -- Which verification scheme accepted this commitment, so a future scheme + -- migration can tell old rows from new. + proof_scheme TEXT NOT NULL DEFAULT 'sha256-commitment-opening', + verified_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT zk_commitments_unique_commitment + UNIQUE (merchant_id, commitment) +); + +CREATE INDEX IF NOT EXISTS idx_zk_commitments_merchant_verified + ON zk_commitments (merchant_id, verified_at DESC); + +COMMIT; + +-- ============================== DOWN ============================== +-- Not executed automatically. Run by hand to roll back. +-- +-- BEGIN; +-- DROP TABLE IF EXISTS zk_commitments; +-- COMMIT; diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index f7b1aa2..1d34e2f 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -31,6 +31,22 @@ export { type TokenMeta, } from './src/price-formatter'; +/** Zero-knowledge commitments and opening proofs for off-chain privacy (#173). */ +export { + ZK_PROOF_SCHEME, + canonicalJson, + sha256Hex, + randomBlinding, + commitmentOf, + createCommitment, + createOpeningProof, + verifyOpeningProof, + sha256CommitmentVerifier, + type CommitmentResult, + type OpeningProof, + type ZkVerifier, +} from './src/zk-proof'; + /** * This package deliberately ships no paywall middleware. * diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 4f480ff..e5ea400 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -21,7 +21,8 @@ "./types": "./src/types/index.ts", "./webhooks": "./webhooks.ts", "./retry": "./retry.ts", - "./currency": "./currency.ts" + "./currency": "./currency.ts", + "./zk-proof": "./src/zk-proof.ts" }, "scripts": { "test": "vitest run", diff --git a/packages/sdk/src/zk-proof.test.ts b/packages/sdk/src/zk-proof.test.ts new file mode 100644 index 0000000..3bf9f7a --- /dev/null +++ b/packages/sdk/src/zk-proof.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + canonicalJson, + commitmentOf, + createCommitment, + createOpeningProof, + verifyOpeningProof, + sha256Hex, + ZK_PROOF_SCHEME, +} from './zk-proof'; + +const PAYLOAD = { amount: '1000', route: '/api/data', meta: { region: 'eu', tier: 3 } }; + +describe('canonicalJson', () => { + it('serializes deterministically regardless of key order', () => { + const a = canonicalJson({ b: 1, a: 2, c: { y: 1, x: 2 } }); + const b = canonicalJson({ c: { x: 2, y: 1 }, a: 2, b: 1 }); + expect(a).toBe(b); + expect(a).toBe('{"a":2,"b":1,"c":{"x":2,"y":1}}'); + }); +}); + +describe('createCommitment / commitmentOf', () => { + it('is deterministic under the same payload and blinding', async () => { + const blinding = '00'.repeat(32); + const first = await commitmentOf(PAYLOAD, blinding); + const second = await commitmentOf(PAYLOAD, blinding); + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is hiding: the same payload commits differently with fresh blinding', async () => { + const a = await createCommitment(PAYLOAD); + const b = await createCommitment(PAYLOAD); + expect(a.commitment).not.toBe(b.commitment); + expect(a.blinding).not.toBe(b.blinding); + expect(a.blinding).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is binding: a different payload never opens to the same commitment', async () => { + const blinding = '11'.repeat(32); + const c = await commitmentOf(PAYLOAD, blinding); + const other = await commitmentOf({ ...PAYLOAD, amount: '2000' }, blinding); + expect(c).not.toBe(other); + }); +}); + +describe('verifyOpeningProof', () => { + it('accepts a genuine opening', async () => { + const { commitment, blinding } = await createCommitment(PAYLOAD); + const proof = await createOpeningProof(PAYLOAD, blinding); + expect(await verifyOpeningProof(commitment, proof)).toBe(true); + }); + + it('accepts a proof built without an explicit blinding (commit+open in one step)', async () => { + const proof = await createOpeningProof(PAYLOAD); + const commitment = await commitmentOf(PAYLOAD, proof.blinding); + expect(await verifyOpeningProof(commitment, proof)).toBe(true); + }); + + it('rejects a tampered payload', async () => { + const { commitment, blinding } = await createCommitment(PAYLOAD); + const proof = await createOpeningProof({ ...PAYLOAD, amount: '9999' }, blinding); + expect(await verifyOpeningProof(commitment, proof)).toBe(false); + }); + + it('rejects a wrong blinding even for the right payload', async () => { + const { commitment } = await createCommitment(PAYLOAD); + const proof = await createOpeningProof(PAYLOAD, 'ff'.repeat(32)); + expect(await verifyOpeningProof(commitment, proof)).toBe(false); + }); + + it('rejects mismatched schemes, missing fields, and malformed commitments', async () => { + const { commitment, blinding } = await createCommitment(PAYLOAD); + const proof = await createOpeningProof(PAYLOAD, blinding); + + expect(await verifyOpeningProof(commitment, { ...proof, scheme: 'snarkjs-groth16' })).toBe( + false, + ); + expect(await verifyOpeningProof(commitment, { ...proof, blinding: '' })).toBe(false); + expect(await verifyOpeningProof('', proof)).toBe(false); + expect(await verifyOpeningProof('not-hex', proof)).toBe(false); + }); +}); + +describe('sha256Hex', () => { + it('produces the SHA-256 digest of its input', async () => { + // sha256("abc") — the NIST test vector. + expect(await sha256Hex('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + }); + + it('round-trips through the canonical payload hash the indexer stores', async () => { + const hash = await sha256Hex(canonicalJson(PAYLOAD)); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(hash).toBe(await sha256Hex(canonicalJson({ meta: { tier: 3, region: 'eu' }, route: '/api/data', amount: '1000' }))); + }); +}); + +describe('ZK_PROOF_SCHEME', () => { + it('is the scheme identifier the sync API checks', () => { + expect(ZK_PROOF_SCHEME).toBe('sha256-commitment-opening'); + }); +}); diff --git a/packages/sdk/src/zk-proof.ts b/packages/sdk/src/zk-proof.ts new file mode 100644 index 0000000..41a2ab8 --- /dev/null +++ b/packages/sdk/src/zk-proof.ts @@ -0,0 +1,211 @@ +/** + * Zero-Knowledge Verification for Off-chain Privacy (#173). + * + * Merchants require privacy for transaction volumes: the indexer must be able + * to verify a state transition without ever seeing (or storing) the + * plaintext. This module provides the SDK side of that contract — a binding + * *and hiding* commitment plus an opening proof — and a verifier interface the + * indexer uses to accept or reject submissions. + * + * The scheme is a standard hash commitment (Pedersen-style in spirit, but + * built on SHA-256 so it runs anywhere WebCrypto does): + * + * commitment = SHA-256( blinding || canonical_json(payload) ) + * + * - **Binding**: an opening cannot be found for a different payload — the + * prover cannot claim the commitment was for anything other than what they + * actually committed to (collision resistance of SHA-256). + * - **Hiding**: the payload cannot be recovered from the commitment, and a + * fresh random `blinding` per commitment means the same payload commits to + * a different value each time (the blinding is the entropy that hides it). + * - **Zero-knowledge-ish by construction**: the prover reveals only the + * commitment and, when they choose to open it, the payload+blinding pair. + * The indexer's store records the commitment and a one-way hash of the + * payload — never the payload itself — so a leaked database exposes + * nothing about the underlying data. + * + * `ZKVerifier` is the pluggable seam the indexer verifies through: a future + * migration to a real zk-SNARK circuit (e.g. SnarkJS groth16) implements the + * same interface without touching the sync API. + * + * Usage: + * import { createCommitment, createOpeningProof, verifyOpeningProof } from '@accensa/sdk/zk-proof'; + * + * const { commitment, blinding } = await createCommitment({ amount: '1000', route: '/api/data' }); + * // indexer stores only `commitment`; keep `blinding` private + * const proof = createOpeningProof({ amount: '1000', route: '/api/data' }, blinding); + * const valid = await verifyOpeningProof(commitment, proof); // true + */ + +/** Scheme identifier recorded on every proof and commitment row. */ +export const ZK_PROOF_SCHEME = 'sha256-commitment-opening'; + +/** A binding commitment to a payload, produced with fresh blinding entropy. */ +export interface CommitmentResult { + /** Hex SHA-256 of `blinding || canonical_json(payload)`. */ + commitment: string; + /** + * The hex blinding value used. This is the secret that makes the commitment + * hiding — it must never be sent to the indexer ahead of verification (it + * IS sent inside the opening proof, which is the whole point of opening). + */ + blinding: string; +} + +/** The opening a prover submits to demonstrate a commitment's payload. */ +export interface OpeningProof { + scheme: typeof ZK_PROOF_SCHEME; + payload: unknown; + blinding: string; +} + +/** + * The verification contract the indexer accepts proofs through. + * + * A real zk-SNARK verifier (SnarkJS, a Rust circuit) implements this same + * interface; the sync API only depends on `scheme` + `verify`, so swapping + * the scheme never changes the route. + */ +export interface ZkVerifier { + readonly scheme: string; + verify(commitment: string, proof: OpeningProof): Promise; +} + +/** + * Deterministic JSON serialization: keys sorted recursively, so the same + * logical payload always canonicalizes to the same bytes on every platform + * and in every runtime. + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value !== null && typeof value === 'object') { + const record = value as Record; + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) sorted[key] = sortKeys(record[key]); + return sorted; + } + return value; +} + +function hexFromBytes(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +/** + * SHA-256 digest as hex, using WebCrypto with a Node `node:crypto` fallback — + * the same dual-runtime pattern the settlement signer in index.ts uses, so + * the module works in the browser and under Node. + */ +export async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const subtle = globalThis.crypto?.subtle; + + if (subtle) { + try { + const digest = await subtle.digest('SHA-256', data); + return hexFromBytes(new Uint8Array(digest)); + } catch { + // Fall through to Node's implementation below. + } + } + + try { + const crypto = await import('node:crypto'); + return crypto.createHash('sha256').update(data).digest('hex'); + } catch { + throw new Error('SHA-256 unavailable: WebCrypto and Node.js crypto are both missing'); + } +} + +/** 32 cryptographically-random bytes as hex (the commitment blinding). */ +export async function randomBlinding(): Promise { + const subtle = globalThis.crypto; + if (subtle?.getRandomValues) { + const bytes = new Uint8Array(32); + subtle.getRandomValues(bytes); + return hexFromBytes(bytes); + } + try { + const crypto = await import('node:crypto'); + return crypto.randomBytes(32).toString('hex'); + } catch { + throw new Error('No secure random source available for commitment blinding'); + } +} + +/** + * Computes the commitment for a payload under a blinding value: + * `SHA-256(blinding || canonical_json(payload))`. + */ +export async function commitmentOf(payload: unknown, blinding: string): Promise { + return sha256Hex(`${blinding}${canonicalJson(payload)}`); +} + +/** + * Creates a binding, hiding commitment to `payload`. + * + * The returned `blinding` is the secret that makes the commitment hiding — + * keep it private until you intend to open the commitment. Generate a fresh + * one per commitment; reusing a blinding lets an observer correlate two + * commitments to the same payload. + */ +export async function createCommitment( + payload: unknown, + opts: { blinding?: string } = {}, +): Promise { + const blinding = opts.blinding ?? (await randomBlinding()); + return { commitment: await commitmentOf(payload, blinding), blinding }; +} + +/** + * Builds the opening proof for a commitment. + * + * Pass the same payload and blinding used in `createCommitment`. When + * `blinding` is omitted, a fresh one is generated — useful when the prover + * commits and opens in one step. + */ +export async function createOpeningProof( + payload: unknown, + blinding?: string, +): Promise { + const usedBlinding = blinding ?? (await randomBlinding()); + return { scheme: ZK_PROOF_SCHEME, payload, blinding: usedBlinding }; +} + +/** + * Verifies an opening proof against a commitment. + * + * Recomputes `SHA-256(blinding || canonical_json(payload))` and compares it + * with the presented commitment, returning false — never throwing — for a + * mismatched scheme, missing fields, or a failed recomputation. This is what + * the indexer calls before recording a state transition. + */ +export async function verifyOpeningProof( + commitment: string, + proof: OpeningProof, +): Promise { + if (!commitment || typeof commitment !== 'string') return false; + if (!proof || proof.scheme !== ZK_PROOF_SCHEME) return false; + if (typeof proof.blinding !== 'string' || proof.blinding.length === 0) return false; + try { + const expected = await commitmentOf(proof.payload, proof.blinding); + if (expected.length !== commitment.length) return false; + // Constant-time comparison so a failing check does not leak how far the + // two digests agree. + let diff = 0; + for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ commitment.charCodeAt(i); + return diff === 0; + } catch { + return false; + } +} + +/** The SHA-256 commitment verifier, satisfying the pluggable `ZkVerifier`. */ +export const sha256CommitmentVerifier: ZkVerifier = { + scheme: ZK_PROOF_SCHEME, + verify: verifyOpeningProof, +};