feat(auth): add discoverable (usernameless) passkey login

A from-zero login door: the browser calls navigator.credentials.get() with an
empty allowCredentials, the authenticator returns an assertion carrying the
resident credential's userHandle, and the server resolves the account from that
handle alone — nothing is typed or client-named.

Routes (both Public):
  POST /api/v1/auth/passkey/login/discoverable/begin
  POST /api/v1/auth/passkey/login/discoverable/finish

Begin stashes the ceremony SessionData server-side keyed by an opaque login_id
under a global cap; finish consumes it single-use, hands the
authenticator-revealed userHandle to a UserByID resolver, and mints a session
only for the account the assertion actually verified to. Every finish rejection
— no live challenge, expired, bad assertion, unresolvable handle — collapses to
one passkey_login_invalid envelope, so finish is never an existence/state
oracle. SignCount is surfaced but not yet consumed, exactly as the
username-first door, so the from-zero path offers no clone-detection bypass.

The discoverable VERIFY path is Oracle-verified end to end against a virtual
authenticator (internal/passkey): it resolves the account from the signed
userHandle, fails closed when the handle names no account, and rejects an
assertion signed by a credential not bound to the resolved user — the
impersonation guard unique to usernameless login. Enrollment now requests a
resident key (authenticatorSelection.residentKey=preferred), the only
server-side half a unit test can pin.

Whether an authenticator actually stores a resident key is a device property no
test can reach, so this door is INERT for a credential until its owner enrolls a
NEW passkey against these options; "preferred" (not "required") preserves the
no-lockout fallback to username-first + email-OTP.
This commit is contained in:
flyemoji committed 2026-07-05 04:05:56 +09:00
1 parent 7db57b9fff
commit ec468baef9
12 files changed
+1193 -21

No files matched your search

+5
View File
@@ -282,6 +282,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
// email-first pair above — no identifier typed, the account is resolved from the
// userHandle inside the signed assertion (handlers_passkey_discoverable.go).
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, h: a.handlePasskeyLoginDiscoverableBegin},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, h: a.handlePasskeyLoginDiscoverableFinish},
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
+74 -1
View File
@@ -81,6 +81,12 @@ type fakeRepo struct {
// just as the PG query does.
passkeyCreds map[string]PasskeyCredential
passkeyChallenges map[string]*fakePasskeyChallenge
// discoverable ("usernameless") login challenge store (task #40), keyed by opaque handle id
// with no user key, mirroring migration 0013. discoverableFull forces the capped-out path
// (ErrTooManyDiscoverableChallenges) so the begin 429 branch is reachable without inserting
// thousands of rows.
discoverableChallenges map[string]*fakeDiscoverableChallenge
discoverableFull bool
// user admin fakes
seededUsers []seededUser
fakeQuotas map[string]*QuotaView
@@ -99,6 +105,15 @@ type fakePasskeyChallenge struct {
createdAt time.Time
}
// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user
// or purpose (a from-zero begin has neither), just the opaque stashed SessionData, its expiry,
// and single-use via consumed. Keyed by the opaque handle in the map, like the real table's id.
type fakeDiscoverableChallenge struct {
sessionData []byte
expiresAt time.Time
consumed bool
}
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
@@ -191,7 +206,9 @@ func newFakeRepo() *fakeRepo {
holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
fakeQuotas: map[string]*QuotaView{},
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
fakeQuotas: map[string]*QuotaView{},
}
}
@@ -366,6 +383,31 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp
return live.sessionData, nil
}
// CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed
// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle,
// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped
// path so the begin 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
if f.discoverableFull {
return ErrTooManyDiscoverableChallenges
}
for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL)
if c.consumed || !c.expiresAt.After(now) {
delete(f.discoverableChallenges, k)
}
}
f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt}
return nil
}
func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) {
c, ok := f.discoverableChallenges[id]
if !ok || c.consumed || !c.expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
c.consumed = true
return c.sessionData, nil
}
// CreatePasskeyCredential mirrors PGRepo: a credential_id already bound to ANY account
// → ErrConflict (the UNIQUE guard), never a silent rebind.
func (f *fakeRepo) CreatePasskeyCredential(_ context.Context, c PasskeyCredential) error {
@@ -436,6 +478,10 @@ type fakePasskeyVerifier struct {
// stashed SessionData round-trips and the existing credentials reach the verifier.
lastUser PasskeyUser
lastSession []byte
// discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's
// resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a
// chosen account (or an unknown handle, to exercise the resolve-fails branch).
discoverableUserHandle []byte
}
func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) {
@@ -477,6 +523,33 @@ func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte,
return v.assertion, nil
}
func (v *fakePasskeyVerifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
if v.beginLoginErr != nil {
return nil, nil, v.beginLoginErr
}
opts := v.options
if opts == nil {
opts = json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`)
}
return opts, []byte("disc-session"), nil
}
func (v *fakePasskeyVerifier) FinishDiscoverableLogin(resolveUser func([]byte) (PasskeyUser, error), sessionData []byte, _ io.Reader) (VerifiedAssertion, error) {
v.lastSession = sessionData
if v.failErr != nil {
return VerifiedAssertion{}, v.failErr
}
// Drive the resolver with the configured user handle so the handler's userHandle → UserByID
// → session-mint wiring runs end to end; a resolve error (unknown handle) fails the ceremony
// exactly as the real ValidateDiscoverableLogin would when the handler cannot be resolved.
u, err := resolveUser(v.discoverableUserHandle)
if err != nil {
return VerifiedAssertion{}, err
}
v.lastUser = u
return v.assertion, nil
}
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
return f.allowlist[n][u], nil
}
+8
View File
@@ -58,6 +58,14 @@ var (
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
// ErrConflict so the message can name the cause (the email is spoken for).
ErrEmailTaken = errors.New("email already verified on another account")
// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
// login challenge store is at its hard cap of live rows (task #40, migration 0013).
// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
// table is capped globally and a begin over the cap is refused. Distinct from the other
// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
// as challenges expire), never a 400 that invites an immediate retry.
ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
)
// apiError is a handler-level error carrying an HTTP status and a stable,
+29 -9
View File
@@ -40,15 +40,20 @@ import (
// (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle
// it keys on: begin resolves email → account → its bound passkeys.
//
// This is an EMAIL-first assertion, not a usernameless one. The system's returning-player
// root of trust is still re-link (control of the in-game identity — handlers_onboard.go
// re-mints a session through the bind-code flow even after passkey/OTP are bound); the
// email and passkey login doors are convenience layered on top, never the root. The real
// enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable
// ("usernameless") credentials, which sidestep even the email handle but reshape enrollment
// (residentKey) and need a non-user-keyed challenge store — a future migration and its own
// checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The
// adapter crypto is verified now so that slice inherits correct crypto.
// That EMAIL-first assertion is one of TWO login doors this subsystem now offers. The other,
// the TRULY from-zero door, is discoverable ("usernameless") login (handlers_passkey_discoverable.go,
// task #40): the browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
// authenticator offers a resident credential it holds, and the account is resolved from the
// userHandle inside the signed assertion — no identifier typed at all. It reshaped enrollment
// (ResidentKey=Preferred in the verifier) and added a non-user-keyed challenge store (migration
// 0013). Two honest limits frame it: (1) the from-zero door partly bypasses the returning-player
// root of trust — control of the in-game identity, which handlers_onboard.go re-mints a session
// through even after passkey/OTP are bound — but it stands on the same footing as the email door
// (#72): a passkey is a possession+UV two-factor authenticator strong enough to stand alone; and
// (2) whether an authenticator actually STORES a resident key is a device property no server
// request compels, so a credential enrolled before this slice, or on hardware that declines
// residency, stays username-first (BeginLogin) — the from-zero door is inert for it until its
// owner enrolls a new passkey. The assertion crypto for both doors is Oracle-verified.
//
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
@@ -102,6 +107,21 @@ type PasskeyVerifier interface {
// the browser posts back; sessionData is the blob BeginLogin returned. A failed
// verification returns a non-nil error; the handler maps it to 400.
FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): there is no
// user yet, so no allowCredentials — the authenticator offers a resident (discoverable)
// credential it holds for this RP and reveals the account only in the signed response. It
// returns the {"publicKey": {...}} request options for navigator.credentials.get() and the
// opaque SessionData the handler stashes under an opaque handle (not a user id) and replays
// at finish.
BeginDiscoverableLogin() (options json.RawMessage, sessionData []byte, err error)
// FinishDiscoverableLogin verifies a usernameless assertion. resolveUser is called with the
// authenticator-revealed user handle so the caller loads the account and its bound
// credentials WITHOUT any client-supplied identifier; the verifier then checks the asserted
// credential id is one that user holds and verifies the signature. A resolveUser error
// (unknown handle) fails the ceremony closed; the handle is the account's stable user id, so
// resolveUser is a direct id lookup. A failed verification returns a non-nil error the
// handler maps to 400.
FinishDiscoverableLogin(resolveUser func(userHandle []byte) (PasskeyUser, error), sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
}
// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
@@ -0,0 +1,209 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"strings"
)
// Discoverable ("usernameless") passkey login (spec §14, task #40) — the TRULY from-zero
// console.<root_domain> door. Its email-first sibling (handlers_passkey.go) still needs a typed
// email to resolve the account before offering its passkeys; this door needs nothing typed at
// all. The browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
// authenticator offers a resident credential it holds for this RP, and the account is revealed
// only by the userHandle inside the signed assertion. Because there is no identifier at begin,
// the challenge cannot be user-keyed: it is stashed under an opaque server-minted handle
// (login_id) in the non-user-keyed store (migration 0013) and echoed back at finish. Email-OTP
// and username-first passkey remain the fallbacks, so an authenticator that stored no resident
// key is never locked out — only its from-zero convenience is unavailable.
//
// Anti-abuse divergence from the email-first door: that door reserves a per-recipient cooldown
// (a.otpLimiter) keyed on the typed email. A usernameless begin has no recipient OR principal to
// key a fair per-caller limit on, so — matching the stance in handlers_auth_email.go (behind
// Cloudflare RemoteAddr is the proxy; CGNAT false-positives) — volumetric per-source limiting is
// left to the edge, and the server-side bound is a hard global cap on live challenges enforced
// atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges → 429).
// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public,
// pre-session). It has no request body — the whole point is that the caller supplies no
// identifier — but requires the JSON Content-Type as the same cross-origin CSRF guard the other
// pre-session doors use. It asks the verifier for assertion options with an empty
// allowCredentials + opaque SessionData, stashes the SessionData under a fresh opaque handle in
// the capped non-user-keyed store, and returns the options with that handle merged in as
// login_id for the browser to echo at finish.
func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
options, sessionData, err := a.Passkey.BeginDiscoverableLogin()
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
"could not start passkey login"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
now := a.now()
if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
if errors.Is(err, ErrTooManyDiscoverableChallenges) {
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_challenges",
"too many passkey logins in progress; try again shortly"))
return
}
writeError(w, r, err)
return
}
// Merge the opaque login handle into the options envelope so the response is a single
// {"publicKey": {...}, "login_id": "..."} document. The browser passes publicKey to
// navigator.credentials.get() and echoes login_id back at finish (the challenge is never
// user-keyed, so this handle is the only link between begin and finish).
envelope, err := mergeLoginID(options, id)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, envelope)
}
// passkeyDiscoverableFinishRequest is the finish body: the opaque login_id that begin returned
// (the only link to the stashed challenge, since it is not user-keyed) and the raw
// navigator.credentials.get() assertion. Assertion is RawMessage so the exact bytes the browser
// produced reach the verifier without a re-encode that could perturb the signed payload.
type passkeyDiscoverableFinishRequest struct {
LoginID string `json:"login_id"`
Assertion json.RawMessage `json:"assertion"`
}
// handlePasskeyLoginDiscoverableFinish verifies a usernameless assertion and mints a session
// (Public, pre-session). It consumes the stashed challenge under login_id (a missing/expired/
// consumed handle → 400), then verifies the assertion — the verifier resolves the account from
// the authenticator-revealed userHandle via the resolve callback below, WITHOUT any
// client-supplied identifier. On success the session is minted for the account the assertion
// actually resolved AND verified to (the resolved user is hoisted out of the callback), never
// anything the client named. All rejection branches collapse to one passkey_login_invalid
// envelope so finish is never an existence/state oracle.
func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req passkeyDiscoverableFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if strings.TrimSpace(req.LoginID) == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "login_id is required"))
return
}
if len(req.Assertion) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
return
}
sessionData, err := a.Repo.ConsumeDiscoverableChallenge(r.Context(), req.LoginID, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
// The verifier hands the authenticator-revealed userHandle to this resolver; it loads the
// account and its bound credentials so ValidateDiscoverableLogin can check the asserted
// credential belongs to that user and verify the signature. The userHandle IS the account's
// stable id (WebAuthnID), so this is a direct id lookup. The resolved user is hoisted here so
// the session below is minted for the account the assertion actually resolved AND verified to
// — not anything the client supplied (the body carries only a challenge handle).
var resolved *StaffUser
resolve := func(userHandle []byte) (PasskeyUser, error) {
u, err := a.Repo.UserByID(r.Context(), string(userHandle))
if err != nil {
return PasskeyUser{}, err
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
return PasskeyUser{}, err
}
resolved = u
// Name/DisplayName are cosmetic at assertion time (nothing is shown to the user); use the
// stable username so a nil email never matters.
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
}
if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
// A verified assertion guarantees resolve ran and set resolved: go-webauthn calls the handler
// to obtain the user BEFORE checking the signature, and a resolve error would have failed
// FinishDiscoverableLogin above. Guard anyway so a future verifier that could return success
// without invoking the resolver fails closed rather than nil-dereferencing.
if resolved == nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, resolved.Username, "auth.passkey_login_discoverable", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": resolved.ID,
"role": resolved.Role,
})
}
// mergeLoginID returns options with an added top-level "login_id" member, so a discoverable
// begin can hand the browser one {"publicKey": {...}, "login_id": "..."} document. It parses the
// options into a generic envelope (they are already a JSON object with a publicKey member) and
// re-marshals with the handle added; a malformed options blob surfaces as an error rather than a
// silently unmergeable response.
func mergeLoginID(options json.RawMessage, id string) (json.RawMessage, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(options, &envelope); err != nil {
return nil, err
}
idJSON, err := json.Marshal(id)
if err != nil {
return nil, err
}
envelope["login_id"] = idJSON
return json.Marshal(envelope)
}
@@ -0,0 +1,319 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Pre-session DISCOVERABLE ("usernameless") passkey login tests (spec §14, task #40 — the
// truly from-zero console.<root_domain> door). These drive the two Public routes against the
// fakeRepo's non-user-keyed challenge store and a fake PasskeyVerifier, so what they PROVE is
// the handler + login state machine (opaque-handle stash → consume → userHandle-resolve →
// session mint), NOT the pgrepo SQL nor the cryptographic assertion verification (the latter is
// the parity subject of internal/passkey/verifier_test.go's TestDiscoverableLoginRoundTrip).
// The load-bearing properties, in flow order:
//
// - No identifier crosses the wire: begin has no request body and finish carries only the
// opaque login_id + the assertion. The account is revealed solely by the userHandle the
// verifier surfaces, resolved server-side via UserByID — never anything the client names.
// - Session-data round-trip: the stashed SessionData reaches FinishDiscoverableLogin only via
// store-stash → consume (the finish body has no session data), so it is never client-echoed.
// - Fail-closed anti-enumeration on finish: a bogus/expired/consumed handle, a failed
// assertion, AND a userHandle that resolves to no account all collapse to ONE
// passkey_login_invalid envelope — finish is never an existence/state oracle.
// - Volumetric bound: begin has no per-caller identity to rate-limit (that is delegated to the
// edge), so the server-side guard is the hard global cap → 429 too_many_challenges.
// seedDiscoverableLoginAPI wires the public from-zero door: local sessions enabled, a single
// verified player "player" (id u1) with one bound passkey, and a verifier primed with fixed
// options, a verified assertion, and a discoverableUserHandle of "u1" — so the default resolve
// path surfaces that account exactly as a real resident credential's userHandle would. Both
// routes are Public (no External principal), proving they are truly pre-session.
func seedDiscoverableLoginAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
discoverableUserHandle: []byte("u1"),
}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = v
return api, repo, v
}
// TestPasskeyDiscoverableLoginVertical walks the whole from-zero slice across the external face:
// begin stashes one challenge under an opaque login_id and returns the assertion options with
// that handle merged in; finish consumes the handle, verifies the assertion against the
// SERVER-STASHED session data, resolves the account from the authenticator-revealed userHandle
// (NOT from anything typed), and mints the same host-only felis_session as the other doors. The
// decisive assertion is the session-data round-trip: the finish body carries only login_id +
// assertion, so the only path for the stashed blob into FinishDiscoverableLogin is store-stash →
// consume — the challenge is never client-echoed.
func TestPasskeyDiscoverableLoginVertical(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
eh := api.ExternalHandler()
// 1) begin: usernameless — no request body by design (the caller supplies no identifier),
// only the JSON Content-Type CSRF guard. The response is one {"publicKey":{...},"login_id":
// "..."} document, and exactly one challenge is stashed, keyed by the returned handle.
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
body := acctBody(t, w)
if body["publicKey"] == nil {
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
}
loginID, _ := body["login_id"].(string)
if loginID == "" {
t.Fatalf("begin must return a non-empty login_id, got %s", w.Body.String())
}
if len(repo.discoverableChallenges) != 1 {
t.Fatalf("begin must stash exactly one discoverable challenge, got %d", len(repo.discoverableChallenges))
}
if _, ok := repo.discoverableChallenges[loginID]; !ok {
t.Errorf("the stashed challenge must be keyed by the returned login_id %q", loginID)
}
// 2) finish: the body carries ONLY the login_id and the assertion — no identifier and no
// session data. The account is revealed by the userHandle the verifier surfaces (u1).
w = do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// THE security assertion: the session data the verifier saw is exactly what begin stashed —
// it travelled store-stash → consume, never the client (the finish body has no session data).
if !bytes.Equal(v.lastSession, []byte("disc-session")) {
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
v.lastSession, "disc-session")
}
vb := acctBody(t, w)
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("finish body = %v, want user_id:u1 role:user", vb)
}
// The host-only HttpOnly cookie is the whole point — same contract as the other doors.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
if !ok {
t.Fatal("no session row for the issued cookie (must be stored hashed)")
}
if s.userID != "u1" {
t.Errorf("session userID = %q, want u1", s.userID)
}
if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) {
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
}
// Audited once, by the RESOLVED account's username (there is no principal yet); begin is silent.
if n := len(repo.audits); n != 1 {
t.Fatalf("want exactly 1 audit (passkey_login_discoverable), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "auth.passkey_login_discoverable" || repo.audits[0].Actor != "player" {
t.Errorf("audit = %+v, want auth.passkey_login_discoverable by player", repo.audits[0])
}
// 3) single-use: the consumed login_id buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replay of consumed login_id: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
// TestPasskeyDiscoverableLoginBeginCapped pins the server-side volumetric bound: with the store
// at its hard cap, begin answers 429 too_many_challenges and stashes nothing. This is the only
// per-server brake on the usernameless begin (there is no recipient/principal to key a per-caller
// cooldown on, so volumetric per-source limiting is delegated to the edge) — a reap alone cannot
// bound a burst, since freshly-inserted rows are not yet expired.
func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) {
api, repo, _ := seedDiscoverableLoginAPI(t)
repo.discoverableFull = true
eh := api.ExternalHandler()
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" {
t.Fatalf("capped begin: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String())
}
if len(repo.discoverableChallenges) != 0 {
t.Errorf("a capped begin must stash nothing, got %d", len(repo.discoverableChallenges))
}
}
// TestPasskeyDiscoverableLoginBeginVerifierError pins the verifier-fault path: a
// BeginDiscoverableLogin failure is a server-side fault, answered passkey_login_failed, and
// stashes no challenge (there is nothing to stash — the ceremony never started).
func TestPasskeyDiscoverableLoginBeginVerifierError(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
v.beginLoginErr = errors.New("cannot begin discoverable")
eh := api.ExternalHandler()
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
}
if len(repo.discoverableChallenges) != 0 {
t.Errorf("a failed begin must stash nothing, got %d", len(repo.discoverableChallenges))
}
}
// TestPasskeyDiscoverableLoginGates covers the shared front doors of both halves: the
// fail-closed local-auth toggle, graceful degradation when no verifier is wired, the CSRF
// Content-Type guard (these are Public, credential-minting routes), and the finish input gates
// that must reject before any consume or resolve.
func TestPasskeyDiscoverableLoginGates(t *testing.T) {
const beginPath = "/api/v1/auth/passkey/login/discoverable/begin"
const finishPath = "/api/v1/auth/passkey/login/discoverable/finish"
const goodFinish = `{"login_id":"x","assertion":{"id":"cred-1"}}`
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) {
api, _, _ := seedDiscoverableLoginAPI(t)
api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
api, _, _ := seedDiscoverableLoginAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", beginPath, `{}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("finish missing inputs -> 400 bad_request, nothing minted", func(t *testing.T) {
cases := map[string]string{
"missing login_id": `{"assertion":{"id":"cred-1"}}`,
"empty login_id": `{"login_id":"","assertion":{"id":"cred-1"}}`,
"missing assertion": `{"login_id":"x"}`,
}
for name, body := range cases {
api, repo, _ := seedDiscoverableLoginAPI(t)
w := do(api.ExternalHandler(), "POST", finishPath, body, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Errorf("%s: code = %d body %s, want 400 bad_request", name, w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Errorf("%s: a rejected finish must mint no session (%d)", name, len(repo.sessions))
}
}
})
}
// TestPasskeyDiscoverableLoginFinishRejections is the redeem-side failure matrix and the anchor
// for from-zero anti-enumeration: a bogus handle, an expired challenge, an assertion that fails
// verification, AND a userHandle that resolves to no account must ALL answer the byte-identical
// passkey_login_invalid envelope (code AND message) and mint no session. The last case is the
// one unique to this door — the account is chosen by the authenticator, so an unresolvable
// handle must fail exactly like a bad signature, never leaking that the handle was well-formed.
func TestPasskeyDiscoverableLoginFinishRejections(t *testing.T) {
const finishPath = "/api/v1/auth/passkey/login/discoverable/finish"
finish := func(eh http.Handler, loginID string) *httptest.ResponseRecorder {
return do(eh, "POST", finishPath,
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
}
cases := []struct {
name string
loginID string
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
}{
{"no live challenge", "ghost", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "ex", func(repo *fakeRepo, v *fakePasskeyVerifier) {
repo.discoverableChallenges["ex"] = &fakeDiscoverableChallenge{
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(-time.Second),
}
}},
{"assertion fails verification", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) {
repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL),
}
v.failErr = errors.New("bad assertion")
}},
{"userHandle resolves to no account", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) {
repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL),
}
v.discoverableUserHandle = []byte("nonexistent")
}},
}
var envelopes [][2]string
for _, c := range cases {
api, repo, v := seedDiscoverableLoginAPI(t)
c.setup(repo, v)
w := finish(api.ExternalHandler(), c.loginID)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
code, msg := errEnvelope(t, w)
if code != "passkey_login_invalid" {
t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code)
}
if len(repo.sessions) != 0 {
t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions))
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("%s: a rejected finish must set no cookie", c.name)
}
envelopes = append(envelopes, [2]string{code, msg})
}
// The anchor: every envelope is identical (code AND message), so no branch — including the
// unresolvable-handle branch — is distinguishable from another.
for i := 1; i < len(envelopes); i++ {
if envelopes[i] != envelopes[0] {
t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical",
cases[i].name, envelopes[i], cases[0].name, envelopes[0])
}
}
}
// TestPasskeyDiscoverableLoginFaceSeparation enforces that both halves are web-only: the
// internal (service-token) face must 404 them, never serve them.
func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) {
api, _, _ := seedDiscoverableLoginAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("begin on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/finish", `{"login_id":"x","assertion":{"id":"y"}}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
}
+85
View File
@@ -1004,6 +1004,91 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp
return sessionData, nil
}
// ---- discoverable ("usernameless") passkey login (task #40, migration 0013) ----
// maxLiveDiscoverableChallenges hard-bounds the non-user-keyed discoverable-login challenge
// store. webauthn_challenges self-bounds via a per-(user,purpose) supersede; a from-zero begin
// has no such key, so the table is capped: once this many LIVE (unexpired, unconsumed) rows
// exist, a new begin is refused (ErrTooManyDiscoverableChallenges → 429). The cap is generous —
// a login challenge lives only passkeyChallengeTTL (5 min) and each row is ~1 KB — so real
// concurrency never approaches it, while an abusive begin-flood is bounded to a few MB instead
// of growing without limit. Volumetric per-IP limiting is the edge's job (handlers_auth_email.go):
// behind Cloudflare RemoteAddr is the proxy, and a usernameless door has no recipient to key a
// fair per-caller limit on.
const maxLiveDiscoverableChallenges = 4096
// CreateDiscoverableChallenge stashes a discoverable-login ceremony under an opaque handle,
// bounding the table in one transaction (see the Repo interface for the full contract). It
// reaps expired/consumed rows first — the non-user-keyed analog of CreatePasskeyChallenge's
// supersede — then refuses over the cap rather than inserting. Because the reap ran first, the
// COUNT is exactly the live-row count, so the cap bounds an adversarial begin-flood (which a
// reap alone cannot: a burst inside the TTL leaves every fresh row live).
func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
if _, err := tx.ExecContext(ctx,
`DELETE FROM webauthn_discoverable_challenges WHERE expires_at <= $1 OR consumed_at IS NOT NULL`,
now); err != nil {
return fmt.Errorf("reap discoverable challenges: %w", err)
}
var live int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM webauthn_discoverable_challenges`).Scan(&live); err != nil {
return fmt.Errorf("count discoverable challenges: %w", err)
}
if live >= maxLiveDiscoverableChallenges {
return ErrTooManyDiscoverableChallenges
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at)
VALUES ($1, $2, $3)`,
id, sessionData, expiresAt); err != nil {
return fmt.Errorf("insert discoverable challenge: %w", err)
}
return tx.Commit()
}
// ConsumeDiscoverableChallenge redeems the challenge under handle id, single-use (see the Repo
// interface for the contract). The row is taken FOR UPDATE so a concurrent finish cannot
// double-spend it; expiry is checked before consuming. No live row → ErrPasskeyChallengeInvalid.
func (p *PGRepo) ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) ([]byte, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var (
sessionData []byte
expiresAt time.Time
)
switch err := tx.QueryRowContext(ctx,
`SELECT session_data, expires_at FROM webauthn_discoverable_challenges
WHERE id = $1 AND consumed_at IS NULL FOR UPDATE`,
id).Scan(&sessionData, &expiresAt); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrPasskeyChallengeInvalid
case err != nil:
return nil, err
}
if !expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
if _, err := tx.ExecContext(ctx,
`UPDATE webauthn_discoverable_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
return nil, fmt.Errorf("consume discoverable challenge: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return sessionData, nil
}
// CreatePasskeyCredential stores a freshly verified passkey (enrollment). Only public
// attestation material is written; a credential_id already bound to ANY account is left
// untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero RowsAffected,
+18 -2
View File
@@ -350,9 +350,25 @@ type Repo interface {
// returns the stashed SessionData so finish can validate the attestation against
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
// for the same ceremony finds nothing live and fails. now is the API clock so
// expiry is testable. Bound to user_id — enrollment always has a principal, so
// there is no usernameless consume-by-hash variant (login is a deferred slice).
// expiry is testable. Bound to user_id — enrollment and username-first login both
// know the principal at begin; the usernameless from-zero door instead uses the
// non-user-keyed pair below.
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
// CreateDiscoverableChallenge persists a DISCOVERABLE ("usernameless") login ceremony
// (task #40, migration 0013), keyed by an opaque server-minted handle id — NOT a user,
// since a from-zero begin has no principal. In one transaction it reaps expired/consumed
// rows (the non-user-keyed analog of CreatePasskeyChallenge's supersede) and then, if the
// live count is at the hard cap, refuses with ErrTooManyDiscoverableChallenges rather than
// inserting — the cap, not the reap, bounds an adversarial begin-flood, since a burst
// inside the TTL leaves every fresh row live. now and expiresAt are both the API clock
// (now drives the reap; expiresAt = now + TTL drives liveness).
CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error
// ConsumeDiscoverableChallenge redeems the discoverable challenge under handle id,
// atomically and single-use (mirrors ConsumePasskeyChallengeByUser without the user key):
// it takes the row FOR UPDATE, checks expiry against now, stamps consumed_at, and returns
// the stashed SessionData. An unknown, expired, or already-consumed handle →
// ErrPasskeyChallengeInvalid, so the finish door never doubles as a state oracle.
ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) (sessionData []byte, err error)
// CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6
// enrollment). It writes only public attestation material (credential_id,
// public_key, sign_count, aaguid) plus the caller's nickname. A credential_id
+86 -9
View File
@@ -6,15 +6,19 @@
// this package imports api for the seam types; api never imports this package, which is
// what keeps the seam (and the api test suite's fake verifier) honest.
//
// Scope: the full WebAuthn ceremony crypto — both the credential-creation (enrollment:
// BeginRegistration/FinishRegistration over go-webauthn's BeginRegistration/CreateCredential)
// and the assertion (login: BeginLogin/FinishLogin over BeginLogin/ValidateLogin) halves.
// Both are Oracle-verified in verifier_test.go against a virtual authenticator. Only the
// enrollment half is wired to HTTP handlers today (handlers_passkey.go); the login
// handlers, session minting, and the panel.* relying-party boundary are a deferred slice,
// so BeginLogin/FinishLogin here have no api-package caller yet. They are added to the
// concrete adapter (not the api.PasskeyVerifier interface) precisely so the crypto is
// built and verified now while the interface grows only when a handler consumes it.
// Scope: the full WebAuthn ceremony crypto across three halves, each Oracle-verified in
// verifier_test.go against a virtual authenticator:
//
// - enrollment (BeginRegistration/FinishRegistration over go-webauthn's
// BeginRegistration/CreateCredential),
// - username-first login (BeginLogin/FinishLogin over BeginLogin/ValidateLogin), where the
// account is known and its bound credentials scope allowCredentials, and
// - discoverable, "usernameless" login (BeginDiscoverableLogin/FinishDiscoverableLogin over
// BeginDiscoverableLogin/ValidateDiscoverableLogin), where the account is unknown at begin
// and revealed only by the userHandle inside the signed assertion (task #40).
//
// All three are in the api.PasskeyVerifier interface and consumed by handlers today (enrollment
// + login in handlers_passkey.go, from-zero login in handlers_passkey_discoverable.go).
package passkey
import (
@@ -68,6 +72,17 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) {
// email-OTP factor (migration 0004); no one is locked out.
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
// Prefer a discoverable (resident) credential so a passkey can later be asserted
// usernamelessly (task #40 from-zero login): the authenticator stores the credential
// and can present it with no identifier typed. PREFERRED, not Required, keeps the
// no-lockout ethos — an authenticator that cannot make a resident key still binds a
// working username-first passkey (BeginLogin) and falls back to email-OTP; only the
// from-zero convenience is unavailable. This shapes only the creation options a browser
// receives (a server-side request, asserted in TestEnrollmentRequestsResidentKey);
// whether a real authenticator honors it — actually storing a resident key — is a device
// property no unit test can prove, so already-bound non-resident credentials stay
// username-first until their owner enrolls a new passkey.
ResidentKey: protocol.ResidentKeyRequirementPreferred,
},
})
if err != nil {
@@ -200,6 +215,68 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti
}, nil
}
// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): the caller is
// not yet identified, so — unlike BeginLogin — there is no user and no allowCredentials. The
// authenticator picks a resident (discoverable) credential it holds for this RP and reveals
// the account only inside the signed response at finish. It returns the {"publicKey": {...}}
// request options for navigator.credentials.get() and the opaque, marshaled SessionData the
// handler stashes under an opaque handle (migration 0013's non-user-keyed store) and replays
// at finish. User verification is required, matching enrollment, so a from-zero login still
// proves possession AND user.
func (v *Verifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
assertion, session, err := v.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return nil, nil, err
}
// CredentialAssertion marshals to {"publicKey": {...}} with an EMPTY allowCredentials —
// exactly the usernameless document the browser hands to navigator.credentials.get().
options, err := json.Marshal(assertion)
if err != nil {
return nil, nil, err
}
// As with the other ceremonies, we stash the marshaled SessionData verbatim and let the
// challenge row's TTL be the sole authority on liveness (no expiry inside SessionData).
sessionData, err := json.Marshal(session)
if err != nil {
return nil, nil, err
}
return options, sessionData, nil
}
// FinishDiscoverableLogin verifies a usernameless assertion (task #40). go-webauthn hands the
// authenticator-revealed user handle to resolveUser, which the caller uses to load the account
// and its bound credentials WITHOUT any client-supplied identifier; go-webauthn then checks
// the asserted credential id is one that user holds and verifies the signature against its
// stored COSE public key. The user handle is the account's stable id (webauthnUser.WebAuthnID),
// so resolveUser is a direct id lookup. A resolveUser error (unknown handle) fails the ceremony
// closed. resolveUser is a plain api-typed callback so the api package still never imports
// go-webauthn: the adapter wraps it into go-webauthn's DiscoverableUserHandler here.
func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) (api.PasskeyUser, error), sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) {
var session webauthn.SessionData
if err := json.Unmarshal(sessionData, &session); err != nil {
return api.VerifiedAssertion{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBody(assertion)
if err != nil {
return api.VerifiedAssertion{}, err
}
handler := func(_, userHandle []byte) (webauthn.User, error) {
u, err := resolveUser(userHandle)
if err != nil {
return nil, err
}
return webauthnUser{u: u}, nil
}
cred, err := v.wa.ValidateDiscoverableLogin(handler, session, parsed)
if err != nil {
return api.VerifiedAssertion{}, err
}
return api.VerifiedAssertion{
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
SignCount: cred.Authenticator.SignCount,
}, nil
}
// excludeDescriptors turns the principal's already-bound passkeys into the
// excludeCredentials list for a creation ceremony. A stored credential id that does not
// decode as base64url is skipped rather than aborting the whole ceremony — a single
+177
View File
@@ -2,6 +2,7 @@ package passkey
import (
"encoding/base64"
"encoding/json"
"slices"
"strings"
"testing"
@@ -333,3 +334,179 @@ func TestLoginUnknownCredentialRejected(t *testing.T) {
t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection")
}
}
// TestDiscoverableLoginRoundTrip is the PARITY check for the usernameless (from-zero) half
// (task #40), and the proof its VERIFY path is real crypto rather than a stub. It differs from
// TestLoginRoundTrip in the two ways that define discoverable login: the begin names no user
// (so the request's allowCredentials must be EMPTY), and the account is revealed only by the
// userHandle the authenticator embeds in the signed assertion — the verifier hands that handle
// to a resolve callback that stands in for the handler's userHandle → UserByID lookup. Chained
// onto a REAL enrollment so the assertion validates against a genuine COSE key, and the
// authenticator's counter is advanced first so the surfaced SignCount is proven real, not a
// hardcoded 0. What this does NOT prove: that a real authenticator actually STORED a resident
// key — that residency is a device property (see TestEnrollmentRequestsResidentKey for the only
// thing a unit test can pin, the request the browser receives).
func TestDiscoverableLoginRoundTrip(t *testing.T) {
v := newTestVerifier(t)
rp := virtualRP()
authenticator := virtualwebauthn.NewAuthenticator()
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
stored := enrollCredential(t, v, rp, authenticator, cred)
// The authenticator returns the user handle in the assertion — this is what a resident
// credential does and what lets the account be resolved from nothing typed. It is the
// account's stable user id (WebAuthnID), so the resolver must receive exactly these bytes.
authenticator.Options.UserHandle = []byte(testUserID)
// Advance the counter so a real (non-zero, strictly increasing) SignCount must survive.
cred.Counter = 9
options, sessionData, err := v.BeginDiscoverableLogin()
if err != nil {
t.Fatalf("BeginDiscoverableLogin: %v", err)
}
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
}
if assertionOpts.RelyingPartyID != testRPID {
t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID)
}
// The defining property of a usernameless request: no credential is named. If this were
// non-empty the ceremony would be username-first and the test would prove nothing about #40.
if len(assertionOpts.AllowCredentials) != 0 {
t.Fatalf("allowCredentials = %v, want empty (usernameless request names no credential)", assertionOpts.AllowCredentials)
}
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
// resolve stands in for the handler's userHandle → UserByID lookup: it records the handle
// it was handed (to prove the account is revealed by the authenticator, not the client) and
// returns the stored credential so ValidateDiscoverableLogin can verify the signature.
var gotHandle []byte
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
gotHandle = userHandle
return testUser(stored), nil
}
va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse))
if err != nil {
t.Fatalf("FinishDiscoverableLogin: %v", err)
}
if string(gotHandle) != testUserID {
t.Errorf("resolver received userHandle %q, want %q (the account is revealed by the assertion)", gotHandle, testUserID)
}
if va.CredentialID != stored.CredentialID {
t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID)
}
if va.SignCount != 9 {
t.Errorf("SignCount = %d, want 9 (the authenticator's advanced counter)", va.SignCount)
}
}
// TestDiscoverableLoginResolveFailsClosed proves the from-zero door fails CLOSED when the
// authenticator-revealed account cannot be resolved: a resolve callback that returns an error
// (the handler's UserByID found nothing — a handle for a deleted/unknown account) must abort
// the ceremony, never mint an assertion. Without this the usernameless path could be coaxed
// into treating an unresolvable handle as success. Pairs with the round-trip above so the
// resolver neither over- nor under-blocks.
func TestDiscoverableLoginResolveFailsClosed(t *testing.T) {
v := newTestVerifier(t)
rp := virtualRP()
authenticator := virtualwebauthn.NewAuthenticator()
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
enrollCredential(t, v, rp, authenticator, cred)
authenticator.Options.UserHandle = []byte("nonexistent-account")
options, sessionData, err := v.BeginDiscoverableLogin()
if err != nil {
t.Fatalf("BeginDiscoverableLogin: %v", err)
}
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
t.Fatalf("ParseAssertionOptions: %v", err)
}
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
return api.PasskeyUser{}, api.ErrNotFound
}
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
t.Fatal("FinishDiscoverableLogin accepted an assertion whose account could not be resolved; want rejection")
}
}
// TestDiscoverableLoginUnboundCredentialRejected proves the credential-ownership binding for
// the usernameless door — the defense unique to it. In username-first login the server names
// allowCredentials, so an assertion must match a credential the server itself offered. The
// from-zero door names NOTHING: the authenticator reveals BOTH the userHandle and the signing
// credential, so the ONLY barrier stopping an attacker from signing with their own resident key
// while embedding a victim's userHandle is go-webauthn's check that the asserted credential id
// belongs to the resolved user. Here the resolve callback succeeds (the handle names a REAL
// account, u1, holding credential A) — unlike TestDiscoverableLoginResolveFailsClosed where it
// resolves to nothing — but the assertion is signed by credential B, never bound to u1.
// FinishDiscoverableLogin must reject: a good signature over the right challenge under a valid
// userHandle is still not enough without membership. This is the exact guard the "account is
// revealed by the assertion, never named by the client" claim leans on.
func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) {
v := newTestVerifier(t)
rp := virtualRP()
authenticator := virtualwebauthn.NewAuthenticator()
credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
stored := enrollCredential(t, v, rp, authenticator, credA)
// A valid handle: it resolves to the real account u1, which holds credential A.
authenticator.Options.UserHandle = []byte(testUserID)
options, sessionData, err := v.BeginDiscoverableLogin()
if err != nil {
t.Fatalf("BeginDiscoverableLogin: %v", err)
}
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
t.Fatalf("ParseAssertionOptions: %v", err)
}
// Sign with a fresh credential never bound to u1. The resolver still returns u1's real
// credential set (credential A) — so the ONLY thing that can reject this is the check that
// the asserted credential (B) is among the resolved user's credentials.
credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts)
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
return testUser(stored), nil
}
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
t.Fatal("FinishDiscoverableLogin accepted an assertion signed by a credential not bound to the resolved user; want rejection")
}
}
// TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a
// unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e.
// the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real
// authenticator honors the request — actually persisting a resident key so it can later be
// asserted usernamelessly — is a device property no unit test can reach, which is exactly why
// the from-zero door is inert for a credential until its owner enrolls a NEW passkey against
// these options. "preferred" (not "required") is deliberate: an authenticator that cannot store
// a resident key still binds a working username-first passkey and falls back to email-OTP, so
// no one is locked out — asserting the exact string guards against a silent drop to "" (ask for
// nothing) or a tightening to "required" (which would break the no-lockout ethos).
func TestEnrollmentRequestsResidentKey(t *testing.T) {
v := newTestVerifier(t)
options, _, err := v.BeginRegistration(testUser())
if err != nil {
t.Fatalf("BeginRegistration: %v", err)
}
var doc struct {
PublicKey struct {
AuthenticatorSelection struct {
ResidentKey string `json:"residentKey"`
} `json:"authenticatorSelection"`
} `json:"publicKey"`
}
if err := json.Unmarshal(options, &doc); err != nil {
t.Fatalf("unmarshal creation options: %v (options=%s)", err, options)
}
if got := doc.PublicKey.AuthenticatorSelection.ResidentKey; got != "preferred" {
t.Errorf("authenticatorSelection.residentKey = %q, want %q", got, "preferred")
}
}
@@ -0,0 +1,36 @@
-- Phase 6 passkey — the challenge store for DISCOVERABLE ("usernameless") login (spec §14,
-- task #40). webauthn_challenges (0007) is keyed by (user_id, purpose) because both
-- enrollment and email-first login already know WHO is authenticating before the ceremony
-- starts. A from-zero passkey login does not: the browser calls navigator.credentials.get()
-- with an EMPTY allowCredentials list, the authenticator offers a resident credential it
-- holds for this RP, and the account is revealed only inside the signed assertion at finish.
-- So this challenge cannot be keyed by user — it is keyed by an opaque, server-minted handle
-- (login_id) the browser echoes back at finish, and the marshaled WebAuthn SessionData is the
-- only server-held ceremony state. This is exactly the "non-user-keyed challenge store, a
-- future migration" that 0007's own comment anticipated.
--
-- Bounding. webauthn_challenges self-bounds via a per-(user,purpose) supersede-DELETE on each
-- begin — one live row per user+purpose. That key does not exist here (there is no user at
-- begin), so this table is bounded two ways instead, both inside CreateDiscoverableChallenge's
-- one transaction: (1) every begin first reaps rows that already expired or were consumed by a
-- prior finish, and (2) a hard cap (maxLiveDiscoverableChallenges) refuses a new begin once the
-- live count is reached, so an abusive begin-flood is bounded to trivial storage rather than
-- growing without limit. A reap alone does NOT bound a burst — freshly inserted rows have a
-- future expiry, so N begins inside the TTL leave N live rows — which is why the cap, not the
-- reap, is the real ceiling. Volumetric per-source (client-IP) limiting is deliberately left to
-- the edge, the same stance handlers_auth_email.go documents (behind Cloudflare RemoteAddr is
-- the proxy, and CGNAT would false-positive) and unavoidable here since a usernameless door has
-- neither a principal NOR a typed recipient to key a fair per-caller limit on.
--
-- The expires_at index serves the reap's WHERE clause; consumed_at (nullable) makes the row
-- single-use, stamped at finish and swept by a later begin's reap.
CREATE TABLE webauthn_discoverable_challenges (
id text PRIMARY KEY, -- opaque login handle (login_id): 128-bit hex
session_data bytea NOT NULL, -- marshaled webauthn.SessionData (the challenge lives here)
expires_at timestamptz NOT NULL,
consumed_at timestamptz, -- single-use: NULL until finish stamps it
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX webauthn_discoverable_challenges_expires_idx
ON webauthn_discoverable_challenges (expires_at);