Research Prototype Authentication

Browser-side password hashing
with a STARK-style consistency layer.

ZK-Auth explores a promising direction for high-assurance authentication: combine memory-hard client-side KDFs, server-side peppered commitments, and transparent proof-system techniques. The live demo is intentionally labeled as a research prototype: it verifies a MiMC/FRI consistency wrapper over a client-side KDF output, but it does not yet prove full Argon2id or bcrypt execution inside the STARK.

▲ STARK-style / FRI Poseidon Hash Argon2id KDF MiMC-7 Circuit Prototype Soundness Model
What this demo actually guarantees

The project is useful and technically interesting, but its current claims need to be precise. Treat the submitted proof body and encoded KDF output as request-scoped credential material. The server must verify them, compute the peppered commitment, and then discard them.

Item Status in this prototype Accurate interpretation
Raw password Not intentionally transmitted Stays in browser memory; never serialised or sent
Argon2id encoded output Request-scoped credential material Used to bind the proof transcript; server must not log or store it
Server pepper Server-only Stored in server env / HSM; client never sees it
Commitment C = Poseidon(mimc_output, pepper) Server computes at registration Client sends mimc_output; server applies pepper itself
STARK-style proof Sent and verified Checks MiMC algebraic consistency, Merkle openings, sampled FRI folds, transcript binding, and PoW
Argon2id salt & params Public Stored in DB; returned to client on login so it can re-run Argon2id
Three modes of operation

The system supports several demo paths with different security / complexity trade-offs. Pattern 2 is the primary focus: it keeps the pepper server-side and stores only a peppered commitment, while acknowledging that full Argon2id-in-STARK proof support is future work.

Pattern 0
Standard Hash

A classical bcrypt or Argon2id hash is computed client-side and sent to the server. This live API path is disabled because it is not a STARK-backed authentication proof. It remains useful only as a conceptual baseline.

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

Legacy mode where client-side code computes a pepper commitment. It is useful for experimentation, but it requires pepper material to be available to the client execution context and should not be used for serious deployments.

Pepper referenced client-side
Consistency proof wrapper
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
Small MiMC consistency 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 — Client-side prototype prover

Runs entirely in the browser. Accepts a password and server challenge, runs Argon2id (WASM), derives the MiMC witness from the encoded KDF output, 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 — Server-side consistency verifier

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 prover and verifier. In this prototype, Argon2id still runs outside the AIR; the proof binds to the resulting encoded KDF string and checks the downstream algebraic wrapper.

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>"
h1 = hashBytesToWitness(UTF8(encodedStr)) ← tamper-evident binding, not an in-STARK Argon2id proof
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 h1, which changes the transcript nonce, which invalidates every Merkle path and FRI check in the proof. This is a useful integrity check, but it means the encoded string is credential material during the request and must be discarded after verification.
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] = h1  ← field witness derived from encoded KDF output
  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)

MiMC is attractive here because the transition is a low-degree polynomial, which is exactly the kind of computation STARKs like to prove. The live demo should be read as a prototype construction, not as an independently audited MiMC security claim.

STARK + FRI Construction

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

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
Claim status
Research
Not a formal 128-bit proof claim
— 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)

Sampling intuition: 35 query openings with blowup 32 gives a strong heuristic check for this toy construction.

— Secondary Poseidon binding chain (not Argon2id AIR) —
kdfTrace[0] = h1
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) ← field witness derived from encoded KDF output
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 Model & Current Limits
ComponentWhat it gives todayStatus
FRI query sampling 35 sampled openings over a 32x LDE give a strong consistency check for the MiMC wrapper Prototype heuristic
Fiat-Shamir PoW grinding Requires about 2^24 SHA-256 trials to find an accepted transcript grind nonce Concrete work factor
MiMC-7 algebraic security Low-degree field transition suitable for AIR experiments; constants and parameters are not third-party audited Research primitive use
Argon2id memory-hardness Applies to client-side password derivation and offline guessing cost; not proven inside the STARK yet Real KDF, external to AIR
Timing-safe comparisons All commitment checks use crypto.timingSafeEqual (Phase 3) Implementation hardening
Session replay prevention server_challenge absorbed first; each proof bound to one login Protocol hardening
Production-grade ZK claim A legitimate 128-bit soundness claim requires an audited proof backend and an AIR/zkVM that includes Argon2id/bcrypt itself Future work

From Password to Commitment

Step-by-step walkthrough of Pattern 2 — the primary auth mode — showing how a browser-side Argon2id result is transformed into a stored peppered commitment. The password is not sent; the proof body and encoded KDF output are request-scoped credential material and must be discarded after verification.

Key insight: The server stores C = Poseidon(mimc_output, serverPepper). The client runs Argon2id locally, then the prototype proof verifies the downstream MiMC/FRI consistency wrapper over that KDF-derived witness. The server 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 is not sent over the network by this flow. As with any browser-side password system, the page must avoid logging it and must be served over trusted HTTPS.

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 to derive the MiMC witness. That encoded string is not stored, but it is request-scoped credential material while verification is happening.

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>"

h1 = hashBytesToWitness(UTF8(encodedStr))
   ← chunk UTF-8 bytes into 31-byte limbs → Poseidon-absorb → single field element
   ← h1 is a field element; any change to encodedStr gives a different h1

nonce = h1.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 = h1.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 field witness derived from the encoded Argon2id output. 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 consistency proof will check.

MiMC-7 trace (220 active rounds, 256 rows total)
trace[0] = h1                             ← witness derived from encoded KDF output
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] = h1
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: h1.toString(),            ← field witness
  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: ...,                  ← Poseidon binding-chain trace root
  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 the prototype consistency 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
Important limitation: this verifies the MiMC/FRI wrapper and the binding to the submitted encoded Argon2id string. It does not prove the full Argon2id memory-hard computation inside the STARK.
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. The proof body, encoded Argon2id output, and intermediate witness are discarded after the request.

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 encoded hash, 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 consistency-proof machinery. The key difference: the Argon2id salt is fetched from the server (it was stored at registration) so the client can reproduce the same KDF-derived witness 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 the KDF witness 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 same field witness that was used during registration.

Deterministic re-derivation
h1 = hashBytesToWitness(UTF8(Argon2id(password, same_salt, same_params).encoded))
← identical to registration h1 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 the KDF-derived witness is the same — which happens only if the password is correct.

5
Server verifies STARK proof
Server

The server runs the consistency verifier (verify(proof, {skipPepperCheck: true})), confirming that the submitted proof is structurally valid and internally consistent. It checks the MiMC/FRI wrapper and binding to the submitted encoded KDF output; it does not yet prove Argon2id itself inside the STARK.

6
Server verifies pepper commitment
Server

After the consistency proof 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 submits a proof for a different password will produce a different mimc_output. The pepper check will fail. A real production-grade ZK password proof would strengthen this by proving Argon2id/bcrypt itself inside the proof system.