Current section
Files
Jump to
Current section
Files
README.md
# 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:
| 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:**
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
```elixir
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.
```elixir
# 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_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^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.
```elixir
blind_id = Snarkey.blind_identity("alice@example.com")
# => <<72, 139, 94, 77, 237, ...>> (32 bytes)
```
### Registration
```elixir
# ── 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)
```elixir
# ── 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:
```elixir
# ── 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](https://hexdocs.pm/snarkey)
---
## License
MIT — see [LICENSE](LICENSE).