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