defmodule GitGud.Accounts.Webauthn do
@moduledoc """
WebAuthn (FIDO2) credentials — hardware security keys, platform
authenticators (Touch ID / Windows Hello), browser passkeys.
Sits alongside `GitGud.Accounts.TwoFactor` (TOTP); a user can have
one TOTP secret + many security keys, and the login challenge accepts
any valid factor.
## Flows
* **Registration** — `registration_challenge/1` builds a
`Wax.Challenge` keyed on the user's id. The caller serializes it
for the browser, the browser calls `navigator.credentials.create`,
and `register_credential/3` verifies the attestation + persists.
* **Authentication** — `authentication_challenge/1` builds a
challenge listing the user's credential ids. The browser calls
`navigator.credentials.get`, and `verify_assertion/4` verifies
the signature, updates the stored sign counter, and reports
success.
Wax handles the cryptographic legwork (CBOR + COSE + attestation
format dispatch + clock-skew bounds); we only persist what survives
verification.
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Accounts.WebauthnCredential
alias GitGud.Repo
# ── relying party config ────────────────────────────────────────────
defp rp_id, do: Application.get_env(:git_gud, __MODULE__, [])[:rp_id] || endpoint_host()
defp origin do
Application.get_env(:git_gud, __MODULE__, [])[:origin] ||
GitGudWeb.Endpoint.url()
end
defp endpoint_host do
GitGudWeb.Endpoint.url() |> URI.parse() |> Map.get(:host) || "localhost"
end
# ── registration ────────────────────────────────────────────────────
@doc """
Build a challenge for the WebAuthn `navigator.credentials.create`
call. `user.id` becomes the WebAuthn user handle.
"""
def registration_challenge(%User{} = _user) do
Wax.new_registration_challenge(
origin: origin(),
rp_id: rp_id(),
user_verification: "preferred",
attestation: "none",
trusted_attestation_types: [:none, :basic, :uncertain, :self, :attca]
)
end
@doc """
Serialize a registration challenge for the browser. Returns the
PublicKeyCredentialCreationOptions JSON shape the JS WebAuthn API
expects (with base64url-encoded binary fields).
"""
def serialize_registration_options(%User{} = user, %Wax.Challenge{} = challenge) do
excluded =
user
|> list_credentials()
|> Enum.map(fn c -> %{"type" => "public-key", "id" => b64u(c.credential_id)} end)
%{
"challenge" => b64u(challenge.bytes),
"rp" => %{"id" => rp_id(), "name" => "GitGud"},
"user" => %{
"id" => b64u(<<user.id::64>>),
"name" => user.email,
"displayName" => user.email
},
"pubKeyCredParams" => [
%{"type" => "public-key", "alg" => -7},
%{"type" => "public-key", "alg" => -257}
],
"timeout" => 60_000,
"attestation" => "none",
"authenticatorSelection" => %{
"userVerification" => "preferred",
"residentKey" => "preferred"
},
"excludeCredentials" => excluded
}
end
@doc """
Verify an attestation returned by `navigator.credentials.create` and
persist the credential. Returns `{:ok, credential}` or `{:error, _}`.
`attestation_b64` and `client_data_json_b64` are the base64url
strings the browser sends back; we decode them here. `name` is the
user-supplied label.
"""
def register_credential(%User{} = user, %{} = body, %Wax.Challenge{} = challenge) do
with {:ok, attestation_object} <- b64u_decode(body["attestationObject"]),
{:ok, client_data_json} <- b64u_decode(body["clientDataJSON"]),
{:ok, {auth_data, _attestation}} <-
Wax.register(attestation_object, client_data_json, challenge) do
acd = auth_data.attested_credential_data
cred_attrs = %{
user_id: user.id,
credential_id: acd.credential_id,
public_key: :erlang.term_to_binary(acd.credential_public_key),
sign_count: auth_data.sign_count,
aaguid: format_aaguid(acd.aaguid),
name: Map.get(body, "name", "Security key")
}
case %WebauthnCredential{}
|> WebauthnCredential.create_changeset(cred_attrs)
|> Repo.insert() do
{:ok, cred} ->
# First successful security-key enrollment (or first MFA setup
# entirely if the user skipped TOTP) — issue recovery codes
# now if they don't already exist.
recovery_codes = GitGud.Accounts.TwoFactor.ensure_recovery_codes(user)
{:ok, cred, recovery_codes}
err ->
err
end
else
{:error, _} = err -> err
err -> {:error, err}
end
end
defp format_aaguid(<<a::32, b::16, c::16, d::16, e::48>>) do
:io_lib.format("~8.16.0b-~4.16.0b-~4.16.0b-~4.16.0b-~12.16.0b", [a, b, c, d, e])
|> IO.iodata_to_binary()
end
defp format_aaguid(_), do: nil
# ── authentication ──────────────────────────────────────────────────
@doc """
Build a challenge for the WebAuthn `navigator.credentials.get` call
scoped to `user`'s registered credentials.
"""
def authentication_challenge(%User{} = user) do
allowed =
user
|> list_credentials()
|> Enum.map(&{&1.credential_id, :erlang.binary_to_term(&1.public_key)})
Wax.new_authentication_challenge(
origin: origin(),
rp_id: rp_id(),
user_verification: "preferred",
allow_credentials: allowed
)
end
@doc """
Serialize an authentication challenge for the browser.
"""
def serialize_authentication_options(%Wax.Challenge{} = challenge) do
%{
"challenge" => b64u(challenge.bytes),
"rpId" => rp_id(),
"timeout" => 60_000,
"userVerification" => "preferred",
"allowCredentials" =>
Enum.map(challenge.allow_credentials, fn {id, _} ->
%{"type" => "public-key", "id" => b64u(id)}
end)
}
end
@doc """
Verify an assertion from `navigator.credentials.get`. On success,
updates the matching credential's `sign_count` + `last_used_at` and
returns `{:ok, credential}`.
"""
def verify_assertion(%User{} = user, %{} = body, %Wax.Challenge{} = challenge) do
with {:ok, credential_id} <- b64u_decode(body["id"]),
{:ok, auth_data} <- b64u_decode(body["authenticatorData"]),
{:ok, signature} <- b64u_decode(body["signature"]),
{:ok, client_data_json} <- b64u_decode(body["clientDataJSON"]),
%WebauthnCredential{} = cred <- find_credential(user, credential_id),
{:ok, %Wax.AuthenticatorData{} = decoded} <-
Wax.authenticate(
credential_id,
auth_data,
signature,
client_data_json,
challenge
) do
cred
|> WebauthnCredential.usage_changeset(decoded.sign_count)
|> Repo.update()
else
nil -> {:error, :unknown_credential}
{:error, _} = err -> err
err -> {:error, err}
end
end
defp find_credential(%User{id: uid}, credential_id) do
Repo.get_by(WebauthnCredential, user_id: uid, credential_id: credential_id)
end
# ── CRUD helpers ────────────────────────────────────────────────────
def list_credentials(%User{id: uid}) do
Repo.all(
from c in WebauthnCredential,
where: c.user_id == ^uid,
order_by: [desc: c.inserted_at]
)
end
def get_credential!(%User{id: uid}, id) do
Repo.one!(from c in WebauthnCredential, where: c.id == ^id and c.user_id == ^uid)
end
def delete_credential(%WebauthnCredential{} = cred), do: Repo.delete(cred)
def rename_credential(%WebauthnCredential{} = cred, name) do
cred
|> WebauthnCredential.rename_changeset(name)
|> Repo.update()
end
def has_credentials?(%User{id: uid}) do
Repo.exists?(from c in WebauthnCredential, where: c.user_id == ^uid)
end
def count(%User{id: uid}) do
Repo.aggregate(from(c in WebauthnCredential, where: c.user_id == ^uid), :count)
end
# ── base64url helpers ───────────────────────────────────────────────
defp b64u(bin), do: Base.url_encode64(bin, padding: false)
defp b64u_decode(nil), do: {:error, :missing}
defp b64u_decode(s) when is_binary(s) do
case Base.url_decode64(s, padding: false) do
{:ok, bin} -> {:ok, bin}
:error -> {:error, :bad_base64}
end
end
end
8.7 KiB · text
5af55d9