Schnorr-based zero-knowledge proof authentication for Elixir.
Prove who you are without revealing what you know. Snarkey implements the Schnorr identification protocol with Fiat-Shamir non-interactive proofs, interactive fallback, blind-hashed identities, and WebAuthn PRF passkey binding support.
Why Snarkey?
Every decision in Snarkey follows from one goal: the server never possesses material that could authenticate as the user. This changes the threat model fundamentally:
| Attack | Traditional auth | Snarkey |
|---|---|---|
| DB leak (password hashes) | Offline cracking, credential stuffing | Only public keys — useless without private key |
| DB leak (account list) | Emails/usernames in plaintext | Blind hashes only, unlinkable without server pepper |
| Network sniffing | Password/hash captured in transit | Never transmitted — only ZKP proofs |
| MITM replay | Replay captured hash to authenticate | Timestamp-bound proof, windowed rejection |
| Phishing | User tricked into entering password on fake site | Challenge tied to server identity, no secret leaves device |
| Server compromise | Server can impersonate user | Server can't compute proofs — doesn't have x |
Core guarantees:
- No password on the wire — secret
xnever leaves the client - No password in the database — server stores only
public_y = g^x mod p - No linkable identity — database rows use HMAC-blind identifiers keyed to a server-side pepper
- Hardware-backed secrets — WebAuthn PRF derives
xfrom passkey seeds (Secure Enclave / TPM) - Replay-proof — every proof is bound to a timestamp window
- Reliable fallback — if the non-interactive proof expires, the protocol degrades to interactive challenge-response
Installation
def deps do
[
{:snarkey, "~> 0.1.0"}
]
endSnarkey has zero runtime dependencies. (ex_doc, credo, and dialyxir
are used only in dev and test.)
Configuration
Set a server-side pepper for blind identity hashing. Keep this secret — without it, blind identifiers cannot be linked to raw user identities.
# config/config.exs
config :snarkey, pepper: "your-256-bit-server-secret"How it works (protocol overview)
Registration
Client Server
x, y = generate_keypair()
k, r = generate_commitment()
c = SHA256(g || y || r || nonce) mod q
s = k + c·x mod q
send {y, r, s, nonce} ──────► verify g^s ≡ r · y^c (mod p)
store public_yThe client proves knowledge of x without ever sending it. The server stores
only y.
Non-interactive authentication (primary path)
Client Server
k, r = generate_commitment()
c = SHA256(g || y || r || user_id || timestamp) mod q
s = k + c·x mod q
send {r, s, timestamp} ─────► reject if timestamp outside drift window
c = SHA256(g || y || r || user_id || ts) mod q
accept iff g^s ≡ r · y^c (mod p)Interactive fallback
When the non-interactive proof's timestamp is stale, the server issues a
random challenge c and the client responds with s = k + c·x mod q.
Same verification equation.
Usage
Blind identifier (user lookup key)
Before storing or looking up a user, hash their identifier with the pepper. This produces an unlinkable value — knowing the DB row reveals nothing about the user's email or username.
blind_id = Snarkey.blind_identity("alice@example.com")
# => <<72, 139, 94, 77, 237, ...>> (32 bytes)Registration
# ── Server: generate a nonce ──
nonce = :crypto.strong_rand_bytes(32)
# ── Client (browser, mobile, or device): create keypair and proof ──
params = Snarkey.default_params()
{secret_x, public_y} = Snarkey.Proof.generate_keypair(params)
proof = Snarkey.Proof.compute_proof(params, secret_x, public_y, nonce)
# Note: compute_proof returns integer values. Convert to binaries for
# transport and storage.
y_bin = :binary.encode_unsigned(public_y)
r_bin = :binary.encode_unsigned(proof.r)
s_bin = :binary.encode_unsigned(proof.s)
# Send {y_bin, r_bin, s_bin, nonce} to server
# ── Server: verify proof of ownership ──
Snarkey.register(y_bin, %{r: r_bin, s: s_bin}, nonce: nonce)
# => {:ok, :registered}The server stores y_bin (the public key) and the blind identifier for
future authentication.
Authentication (non-interactive)
# ── Client: generate a proof bound to the current timestamp ──
params = Snarkey.default_params()
{_x, y} = stored_keypair # previously generated during registration
{r, k} = Snarkey.Proof.generate_commitment(params.p, params.g, params.q)
user_id = blind_id
timestamp = System.system_time(:second)
# Compute the Fiat-Shamir challenge
data = :binary.encode_unsigned(params.g) <>
:binary.encode_unsigned(y) <>
:binary.encode_unsigned(r) <>
user_id <>
<<timestamp::64-unsigned-big>>
c = Snarkey.Crypto.hash_to_scalar(data, params.q)
s = Snarkey.Proof.respond(k, c, x, params.q)
# Send {r_bin, s_bin, timestamp} to server
# ── Server: verify ──
Snarkey.authenticate(y_bin, %{r: r_bin, s: s_bin, timestamp: ts},
user_id: blind_id, max_drift: 5)
# => {:ok, :authenticated}
# => {:fallback, challenge_binary} — if timestamp expired
# => {:error, :unauthorized}Authentication (interactive fallback)
When the non-interactive path returns {:fallback, challenge}, decode the
challenge and use it for the interactive protocol:
# ── Server: issue challenge ──
challenge_int = :binary.decode_unsigned(challenge_binary)
# ── Client: respond ──
s = Snarkey.Proof.respond(k, challenge_int, x, params.q)
# Send {r, s} back to server
# ── Server: verify ──
Snarkey.authenticate(y_bin, %{r: r_bin, c: challenge_binary, s: s_bin})
# => {:ok, :authenticated}Integration responsibilities
Snarkey is a pure cryptographic library. The caller provides:
| Concern | Your code |
|---|---|
| Storage | Store public_y and blind_identifier in your DB |
| Transport | Parse proof maps from HTTP bodies / WebSockets |
| Session creation | Issue tokens/cookies after {:ok, :authenticated} |
| WebAuthn PRF ceremony | Browser-side credentials.create() with PRF extension |
| Peppers and secrets | Set in config :snarkey, pepper: "..." |
| Nonce exchange | Generate and track registration nonces |
| Challenge cache | Store active challenges (ETS, Redis) with TTL |
API reference
| Module | Purpose |
|---|---|
Snarkey | Top-level facade: default_params/0, blind_identity/1, register/3, authenticate/3 |
Snarkey.Crypto | mod_pow/3, hash_to_scalar/2, default_params/0 |
Snarkey.Proof | generate_keypair/1, generate_commitment/3, respond/4, verify_interactive/7, verify_non_interactive/7 |
Snarkey.Identity | blind/2, dummy_work/0 |
Snarkey.Fallback | generate_challenge/1, verify/7 |
Full documentation: https://hexdocs.pm/snarkey
License
MIT — see LICENSE.