feat(passkey): add go-webauthn enrollment verifier adapter
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.
This commit is contained in:
4 files changed
+417
-28
No files matched your search
@@ -0,0 +1,186 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package passkey
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
virtualwebauthn "github.com/descope/virtualwebauthn"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
)
|
||||
|
||||
// Test relying party. RPID is a bare host; the origin is that host as an https URL. Both
|
||||
// the go-webauthn config (via New) and the virtual authenticator (via RelyingParty) must
|
||||
// agree on these, exactly as a real deployment's config and a real browser must agree.
|
||||
const (
|
||||
testRPID = "mc.example.net"
|
||||
testRPName = "Felis"
|
||||
testOrigin = "https://mc.example.net"
|
||||
testUserID = "u1"
|
||||
testUserEml = "[email protected]"
|
||||
)
|
||||
|
||||
func newTestVerifier(t *testing.T) *Verifier {
|
||||
t.Helper()
|
||||
v, err := New(testRPID, testRPName, []string{testOrigin})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func testUser(creds ...api.PasskeyCredential) api.PasskeyUser {
|
||||
return api.PasskeyUser{ID: testUserID, Name: testUserEml, DisplayName: testUserEml, Credentials: creds}
|
||||
}
|
||||
|
||||
// virtualRP mirrors the verifier's RP so the authenticator signs clientDataJSON with the
|
||||
// origin go-webauthn will check against.
|
||||
func virtualRP() virtualwebauthn.RelyingParty {
|
||||
return virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: testOrigin}
|
||||
}
|
||||
|
||||
// TestRegisterRoundTrip is the PARITY check: a real go-webauthn relying party (through our
|
||||
// adapter) issues a creation challenge, a virtual authenticator produces a real attestation
|
||||
// response, and the adapter verifies it end to end. This exercises the pieces a CODE-ONLY
|
||||
// adapter can silently get wrong: the {"publicKey":{...}} options marshal, the SessionData
|
||||
// []byte round-trip through the seam, and the base64url/base64 encoding of the verified
|
||||
// credential id and public key. A green run means the adapter's ceremony logic and the
|
||||
// underlying crypto agree — it does NOT prove production works: the RP id/origin here are
|
||||
// the test's, so whether cfg.Auth.PanelHostname matches the real browser origin stays an
|
||||
// integration concern, and the pgrepo persistence remains unverified until a real DB run.
|
||||
func TestRegisterRoundTrip(t *testing.T) {
|
||||
v := newTestVerifier(t)
|
||||
rp := virtualRP()
|
||||
authenticator := virtualwebauthn.NewAuthenticator()
|
||||
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||
|
||||
options, sessionData, err := v.BeginRegistration(testUser())
|
||||
if err != nil {
|
||||
t.Fatalf("BeginRegistration: %v", err)
|
||||
}
|
||||
// The options the browser receives must be a WebAuthn creation document. The virtual
|
||||
// authenticator's parser (like a browser) reads the publicKey member.
|
||||
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAttestationOptions: %v (options=%s)", err, options)
|
||||
}
|
||||
if attestationOpts.RelyingPartyID != testRPID {
|
||||
t.Fatalf("options RP id = %q, want %q", attestationOpts.RelyingPartyID, testRPID)
|
||||
}
|
||||
|
||||
attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, cred, *attestationOpts)
|
||||
|
||||
vc, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse))
|
||||
if err != nil {
|
||||
t.Fatalf("FinishRegistration: %v", err)
|
||||
}
|
||||
|
||||
// The verified credential id must be the authenticator's credential id, base64url.
|
||||
wantID := base64.RawURLEncoding.EncodeToString(cred.ID)
|
||||
if vc.CredentialID != wantID {
|
||||
t.Errorf("CredentialID = %q, want %q", vc.CredentialID, wantID)
|
||||
}
|
||||
if vc.PublicKey == "" {
|
||||
t.Error("PublicKey is empty; expected the COSE public key")
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(vc.PublicKey); err != nil {
|
||||
t.Errorf("PublicKey is not valid base64: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterOriginMismatchRejected proves the adapter is really checking the origin: an
|
||||
// authenticator that signs a DIFFERENT origin than the RP is configured for must fail
|
||||
// verification. If this passed, the round-trip test above would be meaningless (it would
|
||||
// accept anything).
|
||||
func TestRegisterOriginMismatchRejected(t *testing.T) {
|
||||
v := newTestVerifier(t)
|
||||
// Authenticator signs an origin the verifier does not permit.
|
||||
rp := virtualwebauthn.RelyingParty{ID: testRPID, Name: testRPName, Origin: "https://evil.example.net"}
|
||||
authenticator := virtualwebauthn.NewAuthenticator()
|
||||
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||
|
||||
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, authenticator, cred, *attestationOpts)
|
||||
|
||||
if _, err := v.FinishRegistration(testUser(), sessionData, strings.NewReader(attestationResponse)); err == nil {
|
||||
t.Fatal("FinishRegistration accepted an attestation signed for a foreign origin; want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterUserMismatchRejected proves the user handle is bound across the ceremony:
|
||||
// finishing as a different principal than began must fail (go-webauthn checks
|
||||
// bytes.Equal(user.WebAuthnID(), session.UserID)). This is the guard that a stashed
|
||||
// challenge cannot be redeemed for a different account.
|
||||
func TestRegisterUserMismatchRejected(t *testing.T) {
|
||||
v := newTestVerifier(t)
|
||||
rp := virtualRP()
|
||||
authenticator := virtualwebauthn.NewAuthenticator()
|
||||
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||
|
||||
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, authenticator, cred, *attestationOpts)
|
||||
|
||||
other := api.PasskeyUser{ID: "u2", Name: "[email protected]", DisplayName: "[email protected]"}
|
||||
if _, err := v.FinishRegistration(other, sessionData, strings.NewReader(attestationResponse)); err == nil {
|
||||
t.Fatal("FinishRegistration accepted a finish by a different principal; want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeginExcludesBoundCredentials proves an already-bound passkey is surfaced to the
|
||||
// authenticator as an excludeCredentials entry, so a device cannot double-bind. The
|
||||
// exclusion id must be the stored credential id, base64url.
|
||||
func TestBeginExcludesBoundCredentials(t *testing.T) {
|
||||
v := newTestVerifier(t)
|
||||
existingRaw := []byte("existing-credential-id-bytes")
|
||||
existingID := base64.RawURLEncoding.EncodeToString(existingRaw)
|
||||
|
||||
options, _, err := v.BeginRegistration(testUser(api.PasskeyCredential{CredentialID: existingID}))
|
||||
if err != nil {
|
||||
t.Fatalf("BeginRegistration: %v", err)
|
||||
}
|
||||
attestationOpts, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAttestationOptions: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, ex := range attestationOpts.ExcludeCredentials {
|
||||
if ex == existingID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("excludeCredentials = %v, want it to contain %q", attestationOpts.ExcludeCredentials, existingID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRejectsEmptyRPID proves a misconfigured deployment fails loudly at construction
|
||||
// rather than minting challenges no browser can honor.
|
||||
func TestNewRejectsEmptyRPID(t *testing.T) {
|
||||
if _, err := New("", testRPName, []string{testOrigin}); err == nil {
|
||||
t.Fatal("New accepted an empty RP id; want an error")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user