Zero-Knowledge Authentication

Prove your password
without revealing anything.

ZK-Auth uses a full STARK proof system to let a client authenticate to a server without ever transmitting — or even computing — a password hash the server could misuse. The server learns only that you know the correct password. Nothing more.

▲ STARK / FRI Poseidon Hash Argon2id KDF MiMC-7 Circuit 2¹²⁸ Security Target
What the server never learns

In every authentication flow, the following invariants hold unconditionally.

Item Transmitted to server? How
Raw password ✗ Never Stays in browser memory; never serialised or sent
Argon2id hash output (h₁) ✗ Never (Pattern 2) Used only as circuit witness; not included in proof
Server pepper ✗ Never Stored in server env / HSM; client never sees it
Commitment C = Poseidon(h₁, pepper) Server computes at registration Client sends mimc_output; server applies pepper itself
STARK proof ✓ Sent Proves correct execution; reveals no witness values
Argon2id salt & params ✓ Public Stored in DB; returned to client on login so it can re-derive h₁
Three modes of operation

The system supports three auth patterns with different security / complexity trade-offs. Pattern 2 is the primary focus — it is the only mode where the server pepper is completely opaque to the client.

Pattern 0
Standard Hash

A classical bcrypt or Argon2id hash is computed client-side and sent to the server. No zero-knowledge proof. Included for baseline comparison only.

Hash transmitted to server
Server could brute-force offline
Fast & simple
Pattern 1 — Legacy ZK
Client Pepper Commit

Client computes C = Poseidon(h₁, serverPepper) and includes it in the proof's public inputs. Server re-computes and compares. Requires the pepper to be available in the client execution context.

Pepper referenced client-side
Full STARK proof
Password never sent
Pattern 3 — Experimental
MiMC-STARK Direct

No external KDF. The password itself is encoded as the circuit witness via hashBytesToWitness. Useful for constrained environments but lacks Argon2id's memory-hardness against offline attacks.

No memory-hard KDF
Full STARK proof
Experimental only
Files and responsibilities
stark-math.js — Field Arithmetic & Primitives

The cryptographic foundation. Provides finite-field arithmetic over the BN254 scalar field (p ≈ 2¹⁵⁴), the Number Theoretic Transform (NTT), the friFold function, Merkle trees, the Poseidon-2 hash, and the Fiat-Shamir Transcript sponge with integrated PoW grinding.

Exported as window.StarkMath in browsers and module.exports in Node.js. Used by both prover and verifier.

prover.js — ZKProver (Client-side)

Runs entirely in the browser. Accepts a password and server challenge, runs Argon2id (WASM), derives the circuit witness, builds the MiMC-7 execution trace, performs the Low-Degree Extension, constructs the quotient polynomial, commits via Merkle trees, runs FRI, grinds PoW, and returns the complete proof object.

In Pattern 2 mode (algo: 'argon2id-p2'), pepper_commit is deliberately omitted from public_inputs.

verifier.js — ZKVerifier (Server-side)

Runs on the server (Node.js). Re-derives mimcKey from the proof nonce and server secret, replays the Fiat-Shamir transcript, checks all Merkle inclusion proofs, enforces AIR constraints at each query index, checks FRI consistency across all folds, and validates the PoW nonce.

Exposes verifyPepperCommitP2(mimc_output, storedC) separately — called by the routing layer after verify() succeeds in Pattern 2. Uses timing-safe comparison for all commitment checks.

login.html — Browser Client (Pattern 2 Demo)

Demonstrates the full Pattern 2 registration and login flow. Fetches a server challenge, runs Argon2id in-browser via WASM, generates a STARK proof, and POSTs to /api/p2/register or /api/p2/login. Displays live step-by-step progress with success/error states.

Cryptographic Primitives & Security Parameters

Complete specification of the field, hash functions, cipher, proof system parameters, and security argument for the ZK-Auth STARK construction.

BN254 Scalar Field

All arithmetic is performed modulo the BN254 scalar field prime.

p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
≈ 2¹⁵⁴ — a ~254-bit prime, the scalar field of the BN254 elliptic curve

g = 5   — primitive root / field generator
p − 1 = 2²⁸ × q (2-adic valuation 28, enabling NTT over domains up to size 2²⁸)
Why BN254? Its scalar field has a large 2-adic factor, making it NTT-friendly for trace sizes up to 2³², and Poseidon was specifically designed and analysed for this field. All field elements fit in 254 bits (≈ 32 bytes), keeping proof sizes compact.
Poseidon-2 Permutation

Used for the Fiat-Shamir transcript sponge, the KDF chain commitment, Merkle tree leaf/node hashing, the round-constant commitment, and the pepper commitment.

— Poseidon-2 permutation (width t = 3: two rate lanes s0, s1 + one capacity s2) —

poseidonPermute(s0, s1, s2) (s0', s1', s2')

Transcript sponge (Fiat-Shamir):
  absorb(v): s0 ← (s0 + v) mod p      (s0,s1,s2) poseidonPermute(s0,s1,s2)
  challenge(): s1 ← (s1 + 1) mod p (s0,s1,s2) poseidonPermute(s0,s1,s2) return s0

hashBytesToWitness(bytes) — encode a byte array as a single field element:
  chunk bytes into 31-byte (248-bit) limbs v₀, v₁, … (stays safely under p)
  h = poseidonHash([v₀, v₁, …])  ← used for Argon2id output, bcrypt output, salt & pepper
Argon2id (Memory-Hard KDF)

Argon2id is the OWASP-recommended password hashing function. It is memory-hard, defeating GPU and ASIC brute-force attacks. The following parameter floor is enforced by both the prover and verifier.

Time cost (t)
≥ 3
Minimum iterations (OWASP min)
Memory cost (m)
≥ 64 MB
65 536 KB minimum
Parallelism (p)
≥ 4
Thread lanes (advisory)
Hash length
32 B
256-bit output
result = Argon2id(password, salt, t, m, p, hashLen)
encodedStr = result.encoded   ← "$argon2id$v=19$m=65536,t=3,p=4$<b64salt>$<b64hash>"
h₁ = hashBytesToWitness(UTF8(encodedStr)) ← tamper-evident: altering encoded string changes h₁
Tamper-evidence: The witness is derived from the full encoded string (including parameters and base64 hash), not just the raw hash bytes. Any alteration to encodedStr changes h₁, which changes the transcript nonce, which invalidates every Merkle path and FRI check in the proof.
MiMC-7 Block Cipher

MiMC is a block cipher designed for ZK-friendliness — its step function is a single low-degree polynomial over the field (degree 7 with the x⁷ exponent). This makes it directly expressible as an Algebraic Intermediate Representation (AIR) constraint.

— MiMC-7 with 220 rounds —

mimcKey = HMAC-SHA256(nonce, serverSecret) mod p  ← session-specific key (Fix #2)

Round constants Cᵢ (nothing-up-my-sleeve, deterministic):
  s₀ = RC_A  ← 2π × 10¹⁸ digits (mod p)
  sᵢ₊₁ = (sᵢ × RC_B + i × RC_A + 1) mod p  ← RC_B = φ × 10¹⁸ (golden ratio)

Execution trace (256 rows, 220 active MiMC rounds):
  trace[0] = h₁  ← Argon2id witness (Fix #1)
  t = (trace[i] + mimcKey + Cᵢ) mod p
  trace[i+1] = t⁷ mod p    for i = 0..219

finalOutput = trace[220]  ← public circuit output (mimc_output in proof)

With 220 rounds, MiMC provides a ×5 safety margin over the minimum of ⌈log₇(p)⌉ ≈ 40 rounds required to resist interpolation attacks, contributing an estimated 25–45 additional bits of concrete security.

STARK + FRI Construction

The STARK proof encodes the MiMC execution trace as a polynomial, proves it satisfies the MiMC AIR constraint everywhere on the trace domain, then uses FRI to prove the resulting quotient polynomial is low-degree.

Trace size
256
Rows in execution trace
LDE blowup
32×
LDE domain = 8 192 points
FRI folds
9
log₂(32) + 1
Query count
35
Soundness ≤ 2⁻¹⁷⁵
PoW bits
2²⁴
~16M SHA-256 ops
Security target
2¹²⁸
Statistical soundness
— AIR Constraint (enforced at every trace step i = 0..219) —

P(w·z) = (P(z) + mimcKey + C(z))⁷  ← w = domain generator (order = TRACE_SIZE)

— Quotient Polynomial —
Q(x) = [P(w·x) − (P(x) + mimcKey + C(x))⁷] / Z_T(x)
Z_T(x) = x^220 − 1  ← vanishing polynomial (zeros on trace domain)

If Q is low-degree, the constraint holds on all 220 steps — soundness 2⁻¹⁷⁵

— Secondary KDF trace (Argon2id AIR binding) —
kdfTrace[0] = h₁
kdfTrace[i+1] = Poseidon(kdfTrace[i], (mimcKey + C[i mod 220]) mod p)  ← 64 rows
kdfChainCommit = Poseidon-fold(kdfTrace[0..63])

Both prover and verifier must absorb values in exactly this order. Deviations cause all challenge values to diverge, making every check fail.

Step 0: absorb(server_challenge) ← 256-bit server nonce — binds proof to this session
Step 1: absorb(nonce) ← hashWitness = h₁ field element
Step 2: absorb(mimcKey) ← HMAC-derived session key
Step 3: absorb(rc_commitment) ← Poseidon fold of all 220 round constants
Step 4: absorb(mimc_output) ← final MiMC circuit output
Step 4b: absorb(kdfChainCommit) ← Argon2id KDF chain commitment
Step 5: absorb(trace_root) ← Merkle root of LDE trace
Step 5b: absorb(kdf_trace_root) ← Merkle root of KDF trace LDE
Step 6: absorb(q_root) ← Merkle root of quotient LDE
         friAlpha ← challenge() ← linear combination challenge
Step 7+i: absorb(fri_roots[i]) → friAlphas[i] ← challenge() for i=0..8
         absorb(fri_final[0])
         [PoW grinding: find N s.t. SHA-256(state‖N)[0..23] = 0x000000]
         absorb(powNonce)
         r_k ← challenge() for k=0..34  ← query indices
Soundness & Concrete Security
ComponentContributionBits
FRI query soundness Per-query error ≤ 2⁻⁵ (ρ = 1/32); 35 queries → 2⁻¹⁷⁵ 175
Fiat-Shamir PoW grinding 2²⁴ SHA-256 work per forged proof attempt +24
MiMC-7 algebraic security 220 rounds vs. ⌈log₇(p)⌉ ≈ 40 minimum → ×5 safety margin +25–45
Argon2id memory-hardness 64 MB RAM required per hash → ASIC/GPU brute-force infeasible KDF layer
Timing-safe comparisons All commitment checks use crypto.timingSafeEqual (Phase 3) Side-channel
Session replay prevention server_challenge absorbed first; each proof bound to one login Protocol

From Password to Commitment

Step-by-step walkthrough of Pattern 2 — the primary auth mode — showing exactly how a user's password is transformed into a stored commitment the server can verify, without the password or Argon2id output ever leaving the client.

Key insight: The server stores C = Poseidon(mimc_output, serverPepper). The client proves (via STARK) that it knows a password whose Argon2id output, when run through MiMC-7, produces mimc_output. The server then applies the pepper itself — the client never computes or sees C.
1
User enters credentials
Client

The user types their username and password into the browser. The password never leaves JavaScript memory — it is never serialised, logged, or sent over the network at any point in this flow.

Example inputs
username = "alice"
password = "correct-horse-battery-staple"
↑ stays in JS memory only — never serialised
2
Fetch server challenge nonce
Server → Client

The client calls GET /api/nonce. The server returns a freshly generated 256-bit cryptographically random hex string. This nonce is absorbed first into the Fiat-Shamir transcript, binding the entire proof to this specific login session. Replaying the proof against a different session is impossible.

Server challenge (256-bit random hex)
serverChallenge = "a3f9b2...c4e1"  ← 64 hex chars = 256 bits
transcript.absorb(BigInt('0x' + serverChallenge) % p) ← step 0
3
Generate Argon2id salt & run KDF
Client

The client generates a 16-byte random salt using crypto.getRandomValues. Argon2id is then run in-browser via WebAssembly with parameters at or above the enforced floor (t ≥ 3, m ≥ 65 536 KB). The result is the full encoded string — including the parameters and base64-encoded hash — which is used as the circuit witness.

Argon2id execution
salt = crypto.getRandomValues(16 bytes)  ← unique per user
result = Argon2id(password, salt, t=3, m=65536, p=4, hashLen=32)

encodedStr = "$argon2id$v=19$m=65536,t=3,p=4$<b64salt>$<b64hash>"

h₁ = hashBytesToWitness(UTF8(encodedStr))
   ← chunk UTF-8 bytes into 31-byte limbs → Poseidon-absorb → single field element
   ← h₁ ∈ 𝔽_p (tamper-evident: any change to encodedStr → different h₁)

nonce = h₁.toString()  ← used as MiMC key seed AND transcript step 1
4
Derive session MiMC key
Client

The MiMC encryption key is not fixed — it is derived fresh for each session using HMAC-SHA256. The key material depends on both the hash witness (nonce) and the server secret, making it unpredictable to an attacker who does not know the server secret. The server independently re-derives this key during verification.

HMAC key derivation (Fix #2)
mimcKey = HMAC-SHA256(nonce, serverSecret) mod p
↑ nonce = h₁.toString() | serverSecret from HSM/env var
← 256-bit HMAC output reduced mod p → field element
← session-specific: different every login, unpredictable without serverSecret
5
Build MiMC-7 execution trace
Client

The prover builds a 256-row execution trace. The first row is the Argon2id witness h₁. Each subsequent row applies one MiMC-7 step: add the key and round constant, then raise to the 7th power. This is the computation the STARK proof will certify.

MiMC-7 trace (220 active rounds, 256 rows total)
trace[0] = h₁                             ← Argon2id witness (Fix #1)
for i = 0..219:
  t            = (trace[i] + mimcKey + C[i]) mod p
  trace[i+1] = t⁷ mod p

mimc_output = finalOutput = trace[220]  ← public proof output

Simultaneously, build the 64-row Argon2id KDF binding trace:
kdfTrace[0] = h₁
kdfTrace[i+1] = Poseidon(kdfTrace[i], (mimcKey + C[i mod 220]) mod p)
kdfCommit      = Poseidon-fold(kdfTrace[0..63])
6
Low-Degree Extension & Merkle commitments
Client

The 256-row trace is interpolated into a polynomial via inverse NTT, then evaluated at 8 192 points (blowup factor 32). This "low-degree extension" is committed to with a Merkle tree. The same is done for the quotient polynomial and the KDF trace.

LDE + Merkle commitments
polyP    = NTT⁻¹(trace)                   ← polynomial over trace domain
ldeP     = NTT(polyP.coeffs, size=8192)      ← evaluate on LDE domain (×32)
treeP    = MerkleTree(ldeP)                 ← trace commitment

ldeQ     = constraint numerator / Z_T(x), each point ← quotient LDE
treeQ    = MerkleTree(ldeQ)                 ← quotient commitment

treeKDF  = MerkleTree(NTT(kdfTrace, size=8192)) ← KDF trace commitment

All roots absorbed into Fiat-Shamir transcript (steps 5, 5b, 6)
7
FRI protocol — 9 folds
Client

The Fast Reed-Solomon IOP (FRI) protocol interactively reduces the claim that the combined polynomial is low-degree. Each fold halves the domain size (8192 → 4096 → … → 16). Random folding challenges are drawn from the transcript after each Merkle commitment, making the protocol non-interactive (Fiat-Shamir).

FRI fold formula (one round)
H = ldeP + friAlpha × ldeQ ← linear combination to check

for i = 0..8 (9 folds):
  layer[i]    = MerkleTree(currentLayer)
  alpha[i]    transcript.challenge()    ← Fiat-Shamir
  nextLayer  = friFold(currentLayer, alpha[i], domainGen)
    P'(x²) = (P(x)+P(-x))/2 + α·(P(x)-P(-x))/(2x)
  domainGen  = domainGen²              ← advance generator for next domain

finalPoly   = currentLayer              ← should be constant (degree-0)
8
Proof-of-Work grinding (2²⁴ work)
Client

After all commitments are absorbed, the prover must find a 32-bit nonce N such that the first 24 bits of SHA-256(transcriptState ‖ N) are zero. This requires ~16 million SHA-256 evaluations on average, making it computationally expensive to forge transcripts by trying different commitment sequences.

PoW grinding condition
Find N ∈ [0, 2³²) such that:
SHA-256(serializeState(s0, s1, s2) ‖ N_4bytes)[0..3] < 2^(32-24)
i.e. first 24 bits of digest = 0x000000……

Expected work: 2²⁴ ≈ 16.7M SHA-256 ops (~1–3 s Node, ~5–15 s browser)
pow_nonce = N   ← included in public_inputs; verifier checks independently
9
Query phase — 35 random openings
Client

The verifier challenges the prover with 35 random indices (drawn from the transcript). At each index, the prover opens the trace polynomial, the quotient polynomial, and all FRI layers with Merkle inclusion proofs. These openings form the bulk of the proof blob.

Per-query opening (35 times)
for k = 0..34:
  idx           transcript.challenge() mod 8192
  p_val        = ldeP[idx] + Merkle path to trace_root
  p_next_val  = ldeP[(idx + 32) mod 8192] + Merkle path
  q_val        = ldeQ[idx] + Merkle path to q_root
  kdf_val      = ldeKDF[idx] + Merkle path to kdf_trace_root
  fri_proof    = sibling + path for each of 9 FRI layers
10
Send proof to server (no pepper_commit)
Client → Server

The complete proof is POSTed to /api/p2/register. Crucially, in Pattern 2, pepper_commit is absent from public_inputs. The client never computed it. mimc_output is present as a public output of the circuit.

public_inputs (Pattern 2 — pepper_commit omitted)
{
  nonce: h₁.toString(),            ← hashWitness
  server_challenge: "a3f9b2...c4e1",
  mimc_key_hint: mimcKey.toString(),
  mimc_output: trace[220].toString(),  ← public output
  rc_commitment: ...,                   ← Poseidon fold of C[0..219]
  trace_root: ...,
  kdf_trace_root: ...,                  ← Argon2id AIR commitment
  kdf_chain_commit: ...,
  q_root: ...,
  fri_roots: [9 roots],
  fri_final: [...],
  pow_nonce: N,
  hash_encoded: "$argon2id$v=19$...",
  // pepper_commit: ABSENT — server computes this independently
}
11
Server verifies STARK proof
Server

The server's ZKVerifier.verify(proof, {skipPepperCheck: true}) performs a complete STARK verification: re-derives mimcKey, replays the transcript, checks all Merkle paths, verifies the AIR constraint at all 35 query indices, checks FRI consistency across all 9 folds, and validates the PoW nonce.

Server-side verification checks
A. Re-derive mimcKey server-side:
  expected_key = HMAC-SHA256(nonce, serverSecret) mod p
  assert expected_key === mimc_key_hint   ← client cannot lie

B. Replay Fiat-Shamir transcript → recompute all challenges

C. At each query idx (×35):
  check Merkle(trace_root, idx, p_val, p_path)
  check Merkle(q_root, idx, q_val, q_path)
  check AIR: p_next_val == (p_val + mimcKey + c_val)⁷ + q_val × Z_T(z)
  check FRI consistency across 9 layers + Merkle paths

D. hash_encoded binding: re-derive expectedNonce = hashBytesToWitness(UTF8(hash_encoded))
  assert timingSafeEqual(expectedNonce, nonce)

E. MiMC re-computation: server runs full 220-round MiMC from trace[0]=nonce with
  server-derived mimcKey; asserts result == mimc_output (closes witness substitution)

F. KDF chain re-derivation: server re-derives kdfChainCommit, asserts == kdf_chain_commit

G. Boundary path: Merkle.verify(trace_root, idx=MIMC_ROUNDS×BLOWUP, mimc_output, path)

H. Validate PoW: SHA-256(state ‖ pow_nonce)[0..23] == 0
12
Server computes & stores commitment C
Server

After proof verification succeeds, the routing layer calls verifyPepperCommitP2. During registration, the server computes C and stores it alongside the username and Argon2id salt. The pepper is read from the server environment (HSM in production). The client never sees this value.

Commitment computation & storage (server-only)
serverPepper  = process.env.ZK_SERVER_PEPPER  ← HSM in production
pepperField   = hashBytesToWitness(UTF8(serverPepper))

C              = Poseidon([mimc_output, pepperField])
                ← C is what gets stored in the database

DB.store({ username, C, salt, argon2_params })
             ← server stores C (not h₁, not mimc_output directly)
Why Poseidon(mimc_output, pepper)? Even if an attacker steals the database, they see only C. To reverse it they need both mimc_output (requires running MiMC on Argon2id output) and the pepper (server secret). Brute-forcing requires running Argon2id + MiMC-7 per password guess.
Login reuses the same STARK proof machinery. The key difference: the Argon2id salt is fetched from the server (it was stored at registration) so the client can reproduce the exact same h₁ from the same password.
1
Fetch server challenge nonce
Server → Client

Identical to registration: the client calls GET /api/nonce and receives a fresh 256-bit challenge. This is the first value absorbed into the Fiat-Shamir transcript, preventing replay of a previously valid proof.

2
Retrieve Argon2id salt from server
Server → Client

The client calls GET /api/p2/salt/:username. The server returns the Argon2id salt and parameters that were stored at registration. The salt is public — knowing it without the password yields nothing.

Server response
{ success: true, salt: "<16-byte hex>", argon2_params: { time: 3, mem: 65536, hashLen: 32 } }
3
Re-derive h₁ with the same Argon2id params
Client

Argon2id is a deterministic function: given the same password, salt, and parameters, it always produces the same output. The client re-runs Argon2id with the retrieved salt to reproduce the exact h₁ that was used during registration.

Deterministic re-derivation
h₁ = hashBytesToWitness(UTF8(Argon2id(password, same_salt, same_params).encoded))
← identical to registration h₁ iff correct password + same salt/params
4
Generate STARK proof (identical flow)
Client

Steps 4–9 of registration repeat identically: derive mimcKey, build MiMC trace, LDE, Merkle trees, FRI folds, PoW grinding, and query openings. The resulting mimc_output = trace[220] will match the registration value only if h₁ is the same — which happens only if the password is correct.

5
Server verifies STARK proof
Server

The server runs the full STARK verifier (verify(proof, {skipPepperCheck: true})), confirming that the proof is structurally valid and internally consistent. This proves the client ran Argon2id and MiMC correctly, producing the stated mimc_output.

6
Server verifies pepper commitment
Server

After the STARK is verified, verifyPepperCommitP2(mimc_output, storedC) is called. The server recomputes Poseidon(mimc_output, serverPepper) and compares it — using timing-safe equality — to the stored commitment C. If they match, authentication succeeds.

Final credential binding check
storedC     = DB.lookup(username).C
pepperField  = hashBytesToWitness(serverPepper)
computed     = Poseidon([mimc_output, pepperField])

assert timingSafeEqual(computed, storedC)  ← ✓ login granted
← mismatch means wrong password, tampered proof, or different user
Security closure: An attacker who can forge a STARK proof but does not know the correct password will produce a mimc_output that differs from the registered value. The pepper check will fail. The STARK proof and the pepper check are independent security layers — both must pass.