Wrap github.com/go-webauthn/webauthn behind the api.PasskeyVerifier seam so the api package stays free of go-webauthn types. The adapter covers the credential-creation ceremony only (BeginRegistration / CreateCredential); the login/assertion path is a deferred slice. Ceremony state crosses the seam as opaque marshaled SessionData, the attestation as an io.Reader, and the verified result as a plain VerifiedCredential. SessionData carries no expiry so the challenge row's TTL stays the single liveness authority. New rejects an empty RP id or origin list so a misconfigured deployment fails at construction rather than minting unverifiable challenges. Tests drive a real relying party against a virtual authenticator (descope/virtualwebauthn): a full creation round-trip plus adversarial guards proving origin-mismatch and user-mismatch are rejected and already-bound credentials are excluded.
187 lines
7.8 KiB
Go
187 lines
7.8 KiB
Go
// Package passkey wraps github.com/go-webauthn/webauthn behind the api.PasskeyVerifier
|
|
// seam. The api package deliberately never imports go-webauthn — ceremony state crosses
|
|
// the boundary as opaque bytes, the attestation as an io.Reader, and the verified result
|
|
// as a plain api.VerifiedCredential — so the real relying-party crypto lives here and is
|
|
// wired in only at the composition root (cmd/felis). The dependency arrow points one way:
|
|
// 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.
|
|
package passkey
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
)
|
|
|
|
// Verifier is the production api.PasskeyVerifier: a thin adapter over a configured
|
|
// *webauthn.WebAuthn relying party. It holds no per-ceremony state — the SessionData the
|
|
// server stashes between begin and finish is the only ceremony state, and it travels
|
|
// through the seam as opaque bytes (marshaled webauthn.SessionData).
|
|
type Verifier struct {
|
|
wa *webauthn.WebAuthn
|
|
}
|
|
|
|
// compile-time proof the adapter satisfies the seam the handlers depend on.
|
|
var _ api.PasskeyVerifier = (*Verifier)(nil)
|
|
|
|
// New builds a Verifier for a relying party identified by rpID (the WebAuthn RP ID — the
|
|
// registrable-domain-suffix host, e.g. the panel hostname, without scheme or port) whose
|
|
// permitted browser origins are origins (each a full "https://host" string). displayName
|
|
// is the human-facing RP name an authenticator may show. It returns an error if the
|
|
// go-webauthn config is invalid (e.g. an empty rpID), so a misconfigured deployment fails
|
|
// loudly at construction rather than silently minting unverifiable challenges.
|
|
func New(rpID, displayName string, origins []string) (*Verifier, error) {
|
|
// go-webauthn defers RP-id validation to the first ceremony; guard here so a
|
|
// misconfigured deployment fails at construction (loudly, once) rather than minting
|
|
// challenges that only fail later when a user tries to enroll.
|
|
if rpID == "" {
|
|
return nil, fmt.Errorf("passkey: relying-party id must not be empty")
|
|
}
|
|
if len(origins) == 0 {
|
|
return nil, fmt.Errorf("passkey: at least one relying-party origin is required")
|
|
}
|
|
wa, err := webauthn.New(&webauthn.Config{
|
|
RPID: rpID,
|
|
RPDisplayName: displayName,
|
|
RPOrigins: origins,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Verifier{wa: wa}, nil
|
|
}
|
|
|
|
// BeginRegistration starts a credential-creation ceremony. It returns the WebAuthn
|
|
// {"publicKey": {...}} creation options (marshaled verbatim for navigator.credentials.create())
|
|
// and the opaque, marshaled SessionData the handler stashes for finish. The passkeys the
|
|
// principal has already bound are passed as excludeCredentials so an authenticator that
|
|
// already holds a credential for this account refuses to create a second one.
|
|
func (v *Verifier) BeginRegistration(user api.PasskeyUser) (json.RawMessage, []byte, error) {
|
|
var opts []webauthn.RegistrationOption
|
|
if excl := excludeDescriptors(user.Credentials); len(excl) > 0 {
|
|
opts = append(opts, webauthn.WithExclusions(excl))
|
|
}
|
|
creation, session, err := v.wa.BeginRegistration(webauthnUser{u: user}, opts...)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// CredentialCreation marshals to {"publicKey": {...}} (its Response field carries the
|
|
// `publicKey` json tag), which is exactly the document the browser hands to
|
|
// navigator.credentials.create().
|
|
options, err := json.Marshal(creation)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// SessionData is JSON-marshalable; the handler treats the result as opaque and replays
|
|
// the exact bytes at finish. We do NOT set an expiry inside SessionData — the challenge
|
|
// row's TTL (passkeyChallengeTTL) is the single authority on liveness.
|
|
sessionData, err := json.Marshal(session)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return options, sessionData, nil
|
|
}
|
|
|
|
// FinishRegistration verifies the browser's attestation against the stashed SessionData
|
|
// and returns the persist-ready credential. The user handle must match the one bound at
|
|
// begin (go-webauthn enforces bytes.Equal(user.WebAuthnID(), session.UserID)); the
|
|
// challenge, RP ID, and origin are all checked against server-held values, never against
|
|
// anything the client echoes.
|
|
func (v *Verifier) FinishRegistration(user api.PasskeyUser, sessionData []byte, attestation io.Reader) (api.VerifiedCredential, error) {
|
|
var session webauthn.SessionData
|
|
if err := json.Unmarshal(sessionData, &session); err != nil {
|
|
return api.VerifiedCredential{}, err
|
|
}
|
|
parsed, err := protocol.ParseCredentialCreationResponseBody(attestation)
|
|
if err != nil {
|
|
return api.VerifiedCredential{}, err
|
|
}
|
|
cred, err := v.wa.CreateCredential(webauthnUser{u: user}, session, parsed)
|
|
if err != nil {
|
|
return api.VerifiedCredential{}, err
|
|
}
|
|
return api.VerifiedCredential{
|
|
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
|
PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
|
|
SignCount: cred.Authenticator.SignCount,
|
|
AAGUID: aaguidString(cred.Authenticator.AAGUID),
|
|
}, 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
|
|
// malformed row must not lock a user out of enrolling a new key.
|
|
func excludeDescriptors(creds []api.PasskeyCredential) []protocol.CredentialDescriptor {
|
|
out := make([]protocol.CredentialDescriptor, 0, len(creds))
|
|
for _, c := range creds {
|
|
id, err := base64.RawURLEncoding.DecodeString(c.CredentialID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, protocol.CredentialDescriptor{
|
|
Type: protocol.PublicKeyCredentialType,
|
|
CredentialID: protocol.URLEncodedBase64(id),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// aaguidString renders the 16-byte authenticator AAGUID as a canonical UUID string. An
|
|
// absent (wrong-length) or all-zero AAGUID — common for privacy-preserving platform
|
|
// authenticators — renders as "" so the display view omits it rather than showing a
|
|
// meaningless all-zero UUID.
|
|
func aaguidString(b []byte) string {
|
|
if len(b) != 16 {
|
|
return ""
|
|
}
|
|
allZero := true
|
|
for _, x := range b {
|
|
if x != 0 {
|
|
allZero = false
|
|
break
|
|
}
|
|
}
|
|
if allZero {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
// webauthnUser adapts the seam's api.PasskeyUser to the webauthn.User interface. The user
|
|
// handle is the account's stable user id bytes; the same principal at begin and finish
|
|
// therefore yields the same handle, which is what lets go-webauthn's user/session identity
|
|
// check pass across the two calls.
|
|
type webauthnUser struct {
|
|
u api.PasskeyUser
|
|
}
|
|
|
|
func (w webauthnUser) WebAuthnID() []byte { return []byte(w.u.ID) }
|
|
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.
|
|
func (w webauthnUser) WebAuthnCredentials() []webauthn.Credential {
|
|
out := make([]webauthn.Credential, 0, len(w.u.Credentials))
|
|
for _, c := range w.u.Credentials {
|
|
id, err := base64.RawURLEncoding.DecodeString(c.CredentialID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, webauthn.Credential{ID: id})
|
|
}
|
|
return out
|
|
}
|