feat(passkey): add WebAuthn login/assertion crypto adapter

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.
This commit is contained in:
flyemoji committed 2026-07-01 18:45:26 +09:00
1 parent 7464fa700b
commit e035142abc
3 files changed
+237 -20

No files matched your search

+26 -6
View File
@@ -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",