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:
12 files changed
+1193
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user