From e035142abc32ec0b90d156ccc4ec183ce067a3c6 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Wed, 1 Jul 2026 18:45:26 +0900 Subject: [PATCH] feat(passkey): add WebAuthn login/assertion crypto adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the assertion (login) half of the WebAuthn ceremony crypto in the internal/passkey adapter, Oracle-verified against a virtual authenticator. - BeginLogin/FinishLogin over go-webauthn BeginLogin/ValidateLogin, username-first (allowCredentials scoped to the known user's bound passkeys). Discoverable/usernameless login stays out of scope: the enrolled credentials are non-resident and the challenge store is user-keyed (migration 0007), so it would need a future migration. - WebAuthnCredentials() now populates the stored COSE public key and signature counter (assertion validation needs both to verify the signature and detect clones); enrollment ignores them, so the change is backward-compatible and the enrollment tests guard it. - VerifiedAssertion seam output: which credential signed plus the raw signature counter. Clone/regression policy is deliberately NOT here — the counter is a ceremony fact and the future handler, which holds the previously stored counter, decides reject/warn. Scope: crypto adapter only. The login HTTP handlers, session minting, and the panel.* relying-party boundary/tier decision remain a deferred slice (no unauthenticated login route is added). BeginLogin/FinishLogin live on the concrete adapter, not the api.PasskeyVerifier interface, which grows only when a handler consumes them. Tests (virtualwebauthn): a real enrollment chained into a real assertion exercises the COSE public-key decode path and surfaces the advanced signature counter, plus origin-mismatch and unbound-credential rejection. --- internal/api/handlers_passkey.go | 32 +++++-- internal/passkey/verifier.go | 87 +++++++++++++++++-- internal/passkey/verifier_test.go | 138 ++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 20 deletions(-) diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index 6fc75c4..6a94ec9 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -16,12 +16,16 @@ import ( // ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go) // stays the fallback factor, so a player with no passkey is never locked out. // -// Scope: ENROLLMENT only. The login/assertion path (proving a passkey to mint or -// elevate a session from an unauthenticated state) is a deferred slice — the panel.* -// passkey relying-party boundary is a later decision (see migration 0007). So every -// ceremony here rides on a known principal: the challenge is bound to the caller's -// user_id and the finish verifies against the server-stashed SessionData, never a -// client-echoed challenge. +// Scope of the HANDLERS in this file: ENROLLMENT only. Every ceremony here rides on a +// known principal — the challenge is bound to the caller's user_id and the finish +// verifies against the server-stashed SessionData, never a client-echoed challenge. The +// login/assertion path (proving a passkey to mint or elevate a session from an +// UNauthenticated state) has its cryptographic half built and Oracle-verified in the +// adapter (internal/passkey BeginLogin/FinishLogin, against a virtual authenticator), +// and its persist-ready output shape is VerifiedAssertion below — but the login HTTP +// handlers, the session minting, and the panel.* passkey relying-party boundary/tier +// decision (see migration 0007) are a deferred slice: this file adds no unauthenticated +// login route. // // The cryptographic half is a seam (PasskeyVerifier) so this package never imports // go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation @@ -85,6 +89,22 @@ type VerifiedCredential struct { AAGUID string } +// VerifiedAssertion is the output of a finished LOGIN (assertion) ceremony: which of the +// user's bound credentials proved itself and the signature counter the authenticator +// reported. Like VerifiedCredential it carries no secret. SignCount is the raw ceremony +// fact, NOT a policy verdict: the handler that eventually consumes this holds the +// previously-stored counter and decides whether a non-increase is a cloned-authenticator +// signal — the verifier deliberately does not, so clone policy lives in one place with +// the stored state. SignCount is legitimately 0 for authenticators that keep no counter. +// +// The login handlers do not exist yet (see the file header): this is the stable seam +// output the production adapter (internal/passkey) already produces and its Oracle test +// already asserts on, so wiring the handlers later needs no reshaping here. +type VerifiedAssertion struct { + CredentialID string // base64url(raw credential id) — which bound credential signed + SignCount uint32 +} + // errPasskeyUnavailable is returned when the WebAuthn verifier is not configured on // this api instance, so the begin/finish ceremony routes answer 503 rather than panic. var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable", diff --git a/internal/passkey/verifier.go b/internal/passkey/verifier.go index 1b30375..e35ad38 100644 --- a/internal/passkey/verifier.go +++ b/internal/passkey/verifier.go @@ -6,9 +6,15 @@ // 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: ENROLLMENT only, matching handlers_passkey.go. This wraps BeginRegistration and -// CreateCredential (the credential-creation ceremony). The login/assertion path -// (BeginLogin/ValidateLogin) is a deferred slice and is intentionally not adapted here. +// 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. package passkey import ( @@ -118,6 +124,64 @@ func (v *Verifier) FinishRegistration(user api.PasskeyUser, sessionData []byte, }, nil } +// BeginLogin starts an assertion (login) ceremony for a KNOWN user. It is username-first +// by construction, not by preference: go-webauthn scopes allowCredentials to the user's +// bound passkeys (from WebAuthnCredentials), which is the only fit here because the +// enrolled credentials are not resident/discoverable and the challenge store is user-keyed +// (migration 0007) — discoverable ("usernameless") login would need resident-key +// enrollment plus a non-user-keyed challenge store, a future migration, so it is out of +// scope. It returns the {"publicKey": {...}} request options for navigator.credentials.get() +// and the opaque, marshaled SessionData the handler stashes and replays at finish. A user +// with no bound credential yields an error from go-webauthn (nothing to assert); the caller +// treats that as "offer the email-OTP fallback instead", never as a server fault. +func (v *Verifier) BeginLogin(user api.PasskeyUser) (json.RawMessage, []byte, error) { + assertion, session, err := v.wa.BeginLogin(webauthnUser{u: user}) + if err != nil { + return nil, nil, err + } + // CredentialAssertion marshals to {"publicKey": {...}} (its Response field carries the + // `publicKey` json tag), exactly the document the browser hands to navigator.credentials.get(). + options, err := json.Marshal(assertion) + if err != nil { + return nil, nil, err + } + // As with registration, 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 +} + +// FinishLogin verifies the browser's assertion against the stashed SessionData and reports +// which of the user's credentials signed and the signature counter the authenticator +// reported. go-webauthn checks the challenge, RP id, and origin against server-held values, +// that the asserted credential id is one the user actually holds (it returns +// protocol.ErrorUnknownCredential otherwise), and the signature against the stored COSE +// public key. It does NOT decide clone/regression policy here: the returned SignCount is +// the raw ceremony fact, and the handler — which holds the previously-stored counter — +// decides whether a non-increase is a cloned-authenticator signal. The verified credential +// id is returned base64url so the handler can look up the exact row to update. +func (v *Verifier) FinishLogin(user api.PasskeyUser, 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 + } + cred, err := v.wa.ValidateLogin(webauthnUser{u: user}, 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 @@ -171,8 +235,14 @@ func (w webauthnUser) WebAuthnName() string { return w.u.Name } func (w webauthnUser) WebAuthnDisplayName() string { return w.u.DisplayName } // WebAuthnCredentials returns the principal's bound passkeys as webauthn.Credentials. -// Enrollment only needs the credential ids (for identity/exclusion bookkeeping), so only -// the id is populated; a row whose id does not decode is skipped. +// Enrollment needs only the credential ids (for identity/exclusion bookkeeping); login +// (assertion) validation additionally needs the stored COSE public key (to verify the +// signature) and the last-seen signature counter (for clone detection), so both are +// populated when present. Filling them is backward-compatible with enrollment, which +// simply ignores the extra fields. A row whose id does not decode is skipped entirely; a +// row whose public key does not decode is still surfaced (so it counts for exclusion) but +// with a nil key, so an assertion against it cannot verify — it fails closed rather than +// silently accepting. func (w webauthnUser) WebAuthnCredentials() []webauthn.Credential { out := make([]webauthn.Credential, 0, len(w.u.Credentials)) for _, c := range w.u.Credentials { @@ -180,7 +250,12 @@ func (w webauthnUser) WebAuthnCredentials() []webauthn.Credential { if err != nil { continue } - out = append(out, webauthn.Credential{ID: id}) + cred := webauthn.Credential{ID: id} + if key, err := base64.StdEncoding.DecodeString(c.PublicKey); err == nil { + cred.PublicKey = key + } + cred.Authenticator.SignCount = c.SignCount + out = append(out, cred) } return out } diff --git a/internal/passkey/verifier_test.go b/internal/passkey/verifier_test.go index f8a2545..a88d3c1 100644 --- a/internal/passkey/verifier_test.go +++ b/internal/passkey/verifier_test.go @@ -2,6 +2,7 @@ package passkey import ( "encoding/base64" + "slices" "strings" "testing" @@ -157,14 +158,7 @@ func TestBeginExcludesBoundCredentials(t *testing.T) { if err != nil { t.Fatalf("ParseAttestationOptions: %v", err) } - found := false - for _, ex := range attestationOpts.ExcludeCredentials { - if ex == existingID { - found = true - break - } - } - if !found { + if !slices.Contains(attestationOpts.ExcludeCredentials, existingID) { t.Errorf("excludeCredentials = %v, want it to contain %q", attestationOpts.ExcludeCredentials, existingID) } } @@ -176,3 +170,131 @@ func TestNewRejectsEmptyRPID(t *testing.T) { t.Fatal("New accepted an empty RP id; want an error") } } + +// enrollCredential runs a real credential-creation ceremony and returns the verified +// credential as the persist-ready stored view a later login validates against. Starting +// the login tests from a GENUINE COSE public key (not a hand-built one) is what makes them +// exercise WebAuthnCredentials()' base64 decode path — the exact spot an adapter silently +// breaks. +func enrollCredential(t *testing.T, v *Verifier, rp virtualwebauthn.RelyingParty, auth virtualwebauthn.Authenticator, cred virtualwebauthn.Credential) api.PasskeyCredential { + t.Helper() + options, sessionData, err := v.BeginRegistration(testUser()) + if err != nil { + t.Fatalf("BeginRegistration: %v", err) + } + attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options)) + if err != nil { + t.Fatalf("ParseAttestationOptions: %v", err) + } + attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *attestationOpts) + vc, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse)) + if err != nil { + t.Fatalf("FinishRegistration: %v", err) + } + return api.PasskeyCredential{CredentialID: vc.CredentialID, PublicKey: vc.PublicKey, SignCount: vc.SignCount} +} + +// TestLoginRoundTrip is the PARITY check for the assertion (login) half, deliberately +// chained onto a REAL enrollment so the login validates against a genuine COSE public key. +// A real go-webauthn RP (through our adapter) enrolls a virtual authenticator's credential; +// the verified public key + credential id are fed back as the user's STORED credential into +// BeginLogin → the virtual authenticator signs an assertion → FinishLogin verifies it. This +// exercises exactly the path a hand-built credential would skip: WebAuthnCredentials() +// decoding the base64 COSE key so ValidateLogin can check the signature against it. The +// authenticator's counter is advanced before the assertion so the test also proves +// FinishLogin surfaces the real signature counter rather than a hardcoded 0. +func TestLoginRoundTrip(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 reports an advanced counter; a fresh enrollment stored 0, so this + // is a strict increase (no clone warning) and must survive through to VerifiedAssertion. + cred.Counter = 7 + + options, sessionData, err := v.BeginLogin(testUser(stored)) + if err != nil { + t.Fatalf("BeginLogin: %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 bound credential must be offered as an allowCredentials entry — username-first, + // the ceremony names which credentials the known user may assert. + wantID := base64.RawURLEncoding.EncodeToString(cred.ID) + if !slices.Contains(assertionOpts.AllowCredentials, wantID) { + t.Fatalf("allowCredentials = %v, want it to contain %q", assertionOpts.AllowCredentials, wantID) + } + + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts) + va, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)) + if err != nil { + t.Fatalf("FinishLogin: %v", err) + } + if va.CredentialID != stored.CredentialID { + t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID) + } + if va.SignCount != 7 { + t.Errorf("SignCount = %d, want 7 (the authenticator's advanced counter)", va.SignCount) + } +} + +// TestLoginOriginMismatchRejected proves FinishLogin actually checks the origin: an +// assertion signed for an origin the RP does not permit must fail. Without this guard the +// round-trip test would be hollow — it would accept a signature from anywhere. +func TestLoginOriginMismatchRejected(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + stored := enrollCredential(t, v, rp, authenticator, cred) + + options, sessionData, err := v.BeginLogin(testUser(stored)) + if err != nil { + t.Fatalf("BeginLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v", err) + } + // Sign the assertion for a foreign origin the verifier does not permit. + evil := virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: "https://evil.example.net"} + assertionResponse := virtualwebauthn.CreateAssertionResponse(evil, authenticator, cred, *assertionOpts) + if _, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)); err == nil { + t.Fatal("FinishLogin accepted an assertion signed for a foreign origin; want rejection") + } +} + +// TestLoginUnknownCredentialRejected proves the credential-ownership binding: a valid +// signature over the right challenge is NOT enough — it must come from one of the user's +// own bound credentials. The user's stored credential is A, but the assertion is signed by +// a different, never-bound credential B; go-webauthn must reject it (B is not in the +// challenge's allowCredentials, nor among the user's credentials). +func TestLoginUnknownCredentialRejected(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + stored := enrollCredential(t, v, rp, authenticator, credA) + + options, sessionData, err := v.BeginLogin(testUser(stored)) + if err != nil { + t.Fatalf("BeginLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v", err) + } + credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts) + if _, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)); err == nil { + t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection") + } +}