Zero-Knowledge Proofs
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
schnorr-identification.ts
import { EuclideanAlgorithm } from "../number-theory/euclidean-algorithm";import { ModularArithmetic } from "../number-theory/modular-arithmetic";import { Mulberry32 } from "../random";import type { Tracer } from "../trace";/** The group the proof runs in: a small prime with a generator, so every number stays readable. */export const ZK_GROUP = { p: 127, g: 3 } as const;export const MAX_ROUNDS = 10;export type Prover = "honest" | "cheater";export interface Transcript { round: number; /** The commitment, g to a fresh r. */ t: number; /** The verifier's challenge bit. */ c: number; /** The response, r + c x mod p - 1. */ s: number; ok: boolean;}export interface ZkRun { p: number; g: number; /** The secret the honest prover holds; a cheater does not know it, but it exists. */ x: number; secret: number; /** The public key, g to the x. */ y: number; publicKey: number; prover: Prover; rounds: number; transcripts: Transcript[]; /** The round a cheater was caught in, or null. */ caughtAt: number | null; accepted: boolean; /** The chance a cheater passes every round: one half per round. */ cheatProbability: number; /** Transcripts made with no secret at all, which verify just the same. */ simulated: Transcript[];}/** * Schnorr's identification protocol with one-bit challenges: a prover * who knows x with y = g to the x convinces a verifier of that without * revealing x. Each round the prover commits to t = g to a fresh r, * the verifier flips a coin c, and the prover answers s = r + c x; the * verifier checks g to the s = t times y to the c. An honest prover * passes every round. A cheater who does not know x can prepare an * answer for one value of c in advance, by choosing s first and * setting t = g to the s over y to the c, but not for both, so each * round catches the cheat with probability one half and ten rounds * leave a chance below one in a thousand. The protocol is zero * knowledge because a transcript made that way, with no secret, is * distributed exactly like a real one: the verifier learns nothing it * could not have made up. * * Time: O(rounds) modular powers. Space: O(rounds) transcripts. */export class SchnorrIdentification { constructor( private readonly tracer?: Tracer, private readonly modular: ModularArithmetic = new ModularArithmetic(), private readonly euclid: EuclideanAlgorithm = new EuclideanAlgorithm(), ) {} /** The public key from the secret: y = g to the x mod p. */ setup(p: number, g: number, x: number): number { setup(p: number, g: number, secret: number): number { if (!ModularArithmetic.isGenerator(g, p)) throw new RangeError(`${g} does not generate the group mod ${p}`); if (!Number.isInteger(x) || x < 1 || x > p - 2) throw new RangeError(`the secret is a whole number from 1 to p - 2, got ${x}`); if (!Number.isInteger(secret) || secret < 1 || secret > p - 2) throw new RangeError(`the secret is a whole number from 1 to p - 2, got ${x}`); const y = this.modular.pow(g, x, p); const publicKey = this.modular.pow(g, secret, p); // ▸ publish y = g to the x; x stays with the prover this.tracer?.emit("setup", { p, g, x, y }); this.tracer?.emit("setup", { p, g, secret, publicKey }); return y; return publicKey; } /** y to the minus c: the inverse of y, raised to c. */ private yPower(p: number, y: number, c: number, negative: boolean): number { private yPower(p: number, publicKey: number, c: number, negative: boolean): number { const base = negative ? (this.euclid.inverse(y, p).inverse ?? 1) : y; const base = negative ? (this.euclid.inverse(publicKey, p).inverse ?? 1) : publicKey; return this.modular.pow(base, c, p); } /** The commitment: g to r for an honest prover; for a cheater guessing c, g to s over y to the guess, so that one answer will fit. */ commit(p: number, g: number, y: number, round: number, r: number, guess: number | null, s: number): number { commit(p: number, g: number, publicKey: number, round: number, nonce: number, guess: number | null, s: number): number { const t = guess === null ? this.modular.pow(g, r, p) : this.modular.mul(this.modular.pow(g, s, p), this.yPower(p, y, guess, true), p); const t = guess === null ? this.modular.pow(g, nonce, p) : this.modular.mul(this.modular.pow(g, s, p), this.yPower(p, publicKey, guess, true), p); // ▸ the prover commits to t before seeing the coin this.tracer?.emit("commit", { round, t, guess }); return t; } /** The verifier's coin. */ challenge(round: number, c: number): number { // ▸ the verifier flips a coin this.tracer?.emit("challenge", { round, c }); return c; } /** The response: r + c x mod p - 1 for an honest prover, or the answer prepared in advance. */ respond(p: number, round: number, r: number, c: number, x: number | null, prepared: number): number { respond(p: number, round: number, nonce: number, c: number, secret: number | null, prepared: number): number { const s = x === null ? prepared : (r + c * x) % (p - 1); const s = secret === null ? prepared : (nonce + c * secret) % (p - 1); // ▸ the prover answers; only someone who knows x can answer both coins this.tracer?.emit("respond", { round, s, hasSecret: x !== null }); this.tracer?.emit("respond", { round, s, hasSecret: secret !== null }); return s; } /** The check: g to the s against t times y to the c. */ verify(p: number, g: number, y: number, round: number, t: number, c: number, s: number): boolean { verify(p: number, g: number, publicKey: number, round: number, t: number, c: number, s: number): boolean { const lhs = this.modular.pow(g, s, p); const rhs = this.modular.mul(t, this.yPower(p, y, c, false), p); const rhs = this.modular.mul(t, this.yPower(p, publicKey, c, false), p); const ok = lhs === rhs; // ▸ the check: g to the s must equal t times y to the c this.tracer?.emit("verify", { round, lhs, rhs, ok }); return ok; } /** A transcript with no secret: pick c and s, then set t so that the check holds. It is distributed like a real one. */ simulate(p: number, g: number, y: number, round: number, c: number, s: number): Transcript { simulate(p: number, g: number, publicKey: number, round: number, c: number, s: number): Transcript { const t = this.modular.mul(this.modular.pow(g, s, p), this.yPower(p, y, c, true), p); const t = this.modular.mul(this.modular.pow(g, s, p), this.yPower(p, publicKey, c, true), p); const ok = this.modular.pow(g, s, p) === this.modular.mul(t, this.yPower(p, y, c, false), p); const ok = this.modular.pow(g, s, p) === this.modular.mul(t, this.yPower(p, publicKey, c, false), p); // ▸ pick c and s first, then solve for t: a valid transcript with no secret this.tracer?.emit("simulate", { round, c, s, t, ok }); return { round, t, c, s, ok }; } /** The secret from the seed, the rounds with an honest or a cheating prover, the verdict, then as many simulated transcripts. */ run(prover: Prover, rounds: number, seed: number): ZkRun { if (!Number.isInteger(rounds) || rounds < 1 || rounds > MAX_ROUNDS) throw new RangeError(`rounds must be a whole number from 1 to ${MAX_ROUNDS}, got ${rounds}`); const { p, g } = ZK_GROUP; const random = new Mulberry32(seed); const x = random.intBetween(1, p - 2); const secret = random.intBetween(1, p - 2); const y = this.setup(p, g, x); const publicKey = this.setup(p, g, secret); const transcripts: Transcript[] = []; let caughtAt: number | null = null; for (let round = 0; round < rounds; round++) { const r = random.intBetween(1, p - 2); const nonce = random.intBetween(1, p - 2); const guess = prover === "cheater" ? random.index(2) : null; const prepared = random.intBetween(0, p - 2); const t = this.commit(p, g, y, round, r, guess, prepared); const t = this.commit(p, g, publicKey, round, nonce, guess, prepared); const c = this.challenge(round, random.index(2)); const s = this.respond(p, round, r, c, prover === "honest" ? x : null, prepared); const s = this.respond(p, round, nonce, c, prover === "honest" ? secret : null, prepared); const ok = this.verify(p, g, y, round, t, c, s); const ok = this.verify(p, g, publicKey, round, t, c, s); if (!ok && caughtAt === null) { caughtAt = round; // ▸ a wrong answer exposes a prover who does not know x this.tracer?.emit("caught", { round, guess, c }); } transcripts.push({ round, t, c, s, ok }); } const failed = transcripts.filter((tr) => !tr.ok).length; const accepted = failed === 0; const cheatProbability = 2 ** -rounds; // ▸ accept only if every round held this.tracer?.emit("verdict", { rounds, failed, accepted, cheatProbability }); const simulated: Transcript[] = []; for (let round = 0; round < rounds; round++) simulated.push(this.simulate(p, g, y, round, random.index(2), random.intBetween(0, p - 2))); for (let round = 0; round < rounds; round++) simulated.push(this.simulate(p, g, publicKey, round, random.index(2), random.intBetween(0, p - 2))); return { p, g, x, y, prover, rounds, transcripts, caughtAt, accepted, cheatProbability, simulated }; return { p, g, secret, publicKey, prover, rounds, transcripts, caughtAt, accepted, cheatProbability, simulated }; }}5 of 5 real rounds held and 5 of 5 fakes verify: accepted, and the transcript taught the verifier nothing it could not have made up.