Current section
Files
Jump to
Current section
Files
lib/snarkey.ex
defmodule Snarkey do
@moduledoc """
Schnorr-based zero-knowledge proof authentication library.
Snarkey implements the Schnorr identification protocol with Fiat-Shamir
non-interactive proofs, interactive fallback, blind-hashed identities,
and WebAuthn PRF passkey binding support.
## 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
4. **Replay-proof** — every proof is bound to a timestamp window
5. **Hardware-backed secrets** — WebAuthn PRF derives `x` from passkey seeds
## Integration
Snarkey is a pure functional library with zero runtime dependencies.
The caller provides storage, transport, and session handling.
See the individual module docs for detailed API reference:
- `Snarkey.Crypto` — modular exponentiation, hash-to-scalar, default params
- `Snarkey.Proof` — key generation, commitment, response, verification
- `Snarkey.Identity` — blind identity hashing
- `Snarkey.Fallback` — interactive challenge-response
"""
alias Snarkey.{Crypto, Fallback, Identity, Proof}
@type params :: %{p: integer(), g: integer(), q: integer()}
@doc """
Returns the default 2048-bit Schnorr group parameters.
Delegates to `Snarkey.Crypto.default_params/0`.
## Examples
iex> params = Snarkey.default_params()
iex> params.p |> is_integer()
true
iex> params.g
2
"""
def default_params do
Crypto.default_params()
end
@doc """
Produces a blind identifier by HMAC-SHA256(pepper, raw_id).
Reads the pepper from `Application.get_env(:snarkey, :pepper)`.
## Examples
iex> blind = Snarkey.blind_identity("user@example.com")
iex> is_binary(blind)
true
"""
def blind_identity(raw_id) do
Identity.blind(raw_id, pepper: Application.get_env(:snarkey, :pepper))
end
@doc """
Verifies a registration proof-of-ownership for a new public key.
The client sends `{public_y, r, s}` and the server uses a pre-generated
nonce to compute `c = hash_to_scalar(g || y || r || nonce)` and verify
the proof. This ensures the client knows the secret key corresponding to
`public_y` without revealing it.
## Options
* `:params` — crypto params (defaults to `default_params/0`)
* `:nonce` — **required.** Server-generated registration nonce (binary)
* `:user_id` — optional user identifier for logging
## Returns
* `{:ok, :registered}` — proof is valid, caller should store `public_y`
* `{:error, :invalid_proof}` — proof failed verification
"""
def register(public_y, %{r: r, s: s}, opts) do
params = Keyword.get(opts, :params, default_params())
nonce = Keyword.get(opts, :nonce) || raise ArgumentError, "nonce is required for registration"
%{p: p, g: g, q: q} = params
with {:ok, y_int} <- decode_int(public_y),
{:ok, r_int} <- decode_int(r),
{:ok, s_int} <- decode_int(s) do
data = encode_proof_data(g, y_int, r_int, nonce)
c = Crypto.hash_to_scalar(data, q)
verify_registration(Proof.verify_interactive(p, g, q, y_int, r_int, c, s_int))
else
{:error, _reason} -> {:error, :invalid_proof}
end
end
@doc """
Verifies a Schnorr proof against a public key.
## Non-interactive (Fiat-Shamir) path
The proof map must contain `:r`, `:s`, and `:timestamp` keys.
### Options
* `:params` — crypto params (defaults to `default_params/0`)
* `:user_id` — user identifier used in the Fiat-Shamir hash
* `:max_drift` — max timestamp skew in seconds (default 5)
### Returns
* `{:ok, :authenticated}` — proof valid
* `{:fallback, challenge}` — proof expired, challenge binary for interactive re-auth
* `{:error, :unauthorized}` — proof invalid
* `{:error, :expired}` — timestamp outside drift window
## Interactive path
The proof map must contain `:r`, `:c`, and `:s` keys. Used after the
caller has obtained a challenge from `Snarkey.Fallback.generate_challenge/1`.
### Options
* `:params` — crypto params (defaults to `default_params/0`)
### Returns
* `{:ok, :authenticated}` — proof valid
* `{:error, :unauthorized}` — proof invalid
"""
@spec authenticate(binary(), map(), keyword()) ::
{:ok, :authenticated} | {:fallback, binary()} | {:error, atom()}
def authenticate(public_y, %{r: r, s: s, timestamp: timestamp}, opts) do
opts
|> Keyword.put_new(:params, default_params())
|> do_authenticate_nip(public_y, r, s, timestamp)
end
def authenticate(public_y, %{r: r, c: c, s: s}, opts) do
params = Keyword.get(opts, :params, default_params())
%{p: p, g: g, q: q} = params
with {:ok, y_int} <- decode_int(public_y),
{:ok, r_int} <- decode_int(r),
{:ok, c_int} <- decode_int(c),
{:ok, s_int} <- decode_int(s) do
verify_interactive_result(Proof.verify_interactive(p, g, q, y_int, r_int, c_int, s_int))
else
{:error, _reason} -> {:error, :unauthorized}
end
end
defp do_authenticate_nip(opts, public_y, r, s, timestamp) do
params = Keyword.get(opts, :params)
%{q: q} = params
user_id = Keyword.get(opts, :user_id) || ""
max_drift = Keyword.get(opts, :max_drift, 5)
with {:ok, y_int} <- decode_int(public_y),
{:ok, r_int} <- decode_int(r),
{:ok, s_int} <- decode_int(s),
{:ok, ts} <- decode_timestamp(timestamp) do
verify_nip_result(
Proof.verify_non_interactive(params, y_int, r_int, s_int, user_id, ts,
max_drift: max_drift
),
q
)
else
{:error, _reason} -> {:error, :unauthorized}
end
end
# --------------------------------------------------------------------------
# Private helpers
# --------------------------------------------------------------------------
defp verify_registration(:ok), do: {:ok, :registered}
defp verify_registration(error), do: error
defp verify_interactive_result(:ok), do: {:ok, :authenticated}
defp verify_interactive_result({:error, :invalid_proof}), do: {:error, :unauthorized}
defp verify_nip_result(:ok, _q), do: {:ok, :authenticated}
defp verify_nip_result({:error, :expired}, q),
do: {:fallback, :binary.encode_unsigned(Fallback.generate_challenge(q))}
defp verify_nip_result({:error, :invalid_proof}, _q), do: {:error, :unauthorized}
defp decode_int(bin) when is_binary(bin), do: {:ok, :binary.decode_unsigned(bin)}
defp decode_int(_), do: {:error, :invalid_format}
defp decode_timestamp(ts) when is_integer(ts), do: {:ok, ts}
defp decode_timestamp(ts) when is_binary(ts), do: {:ok, :binary.decode_unsigned(ts)}
defp decode_timestamp(_), do: {:error, :invalid_timestamp}
defp encode_proof_data(g, y, r, nonce) do
:binary.encode_unsigned(g) <>
:binary.encode_unsigned(y) <>
:binary.encode_unsigned(r) <>
nonce
end
end