Snarkey

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:

AttackTraditional authSnarkey
DB leak (password hashes)Offline cracking, credential stuffingOnly public keys — useless without private key
DB leak (account list)Emails/usernames in plaintextBlind hashes only, unlinkable without server pepper
Network sniffingPassword/hash captured in transitNever transmitted — only ZKP proofs
MITM replayReplay captured hash to authenticateTimestamp-bound proof, windowed rejection
PhishingUser tricked into entering password on fake siteChallenge tied to server identity, no secret leaves device
Server compromiseServer can impersonate userServer can't compute proofs — doesn't have x

Core guarantees:

  1. No password on the wire — secret x never leaves the client
  2. No password in the database — server stores only public_y = g^x mod p
  3. No linkable identity — database rows use HMAC-blind identifiers keyed to a server-side pepper
  4. Hardware-backed secrets — WebAuthn PRF derives x from passkey seeds (Secure Enclave / TPM)
  5. Replay-proof — every proof is bound to a timestamp window
  6. Reliable fallback — if the non-interactive proof expires, the protocol degrades to interactive challenge-response

Installation

def deps do
[
{:snarkey, "~> 0.1.0"}
]
end

Snarkey 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^sr · y^c (mod p)
store public_y

The 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^sr · 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:

ConcernYour code
StorageStore public_y and blind_identifier in your DB
TransportParse proof maps from HTTP bodies / WebSockets
Session creationIssue tokens/cookies after {:ok, :authenticated}
WebAuthn PRF ceremonyBrowser-side credentials.create() with PRF extension
Peppers and secretsSet in config :snarkey, pepper: "..."
Nonce exchangeGenerate and track registration nonces
Challenge cacheStore active challenges (ETS, Redis) with TTL

API reference

ModulePurpose
SnarkeyTop-level facade: default_params/0, blind_identity/1, register/3, authenticate/3
Snarkey.Cryptomod_pow/3, hash_to_scalar/2, default_params/0
Snarkey.Proofgenerate_keypair/1, generate_commitment/3, respond/4, verify_interactive/7, verify_non_interactive/7
Snarkey.Identityblind/2, dummy_work/0
Snarkey.Fallbackgenerate_challenge/1, verify/7

Full documentation: https://hexdocs.pm/snarkey


License

MIT — see LICENSE.