// Package webauthn implements enough of the W3C WebAuthn spec to use // passkeys as a second authentication factor alongside TOTP/backup codes // (internal/totp) — not a general-purpose WebAuthn library. Hand-rolled on // stdlib crypto (crypto/ecdsa, crypto/elliptic) plus this package's own // minimal CBOR decoder (cbor.go), no third-party WebAuthn/CBOR library — // same dependency-minimal principle as every other protocol in this // codebase. // // Two deliberate scope decisions, stated plainly: // // 1. Attestation statements are read but never cryptographically // verified. Proving *which physical authenticator model* registered a // credential requires vendor root CA bundles and per-format parsing // (packed/fido-u2f/tpm/android-safetynet/apple — five-plus separate // formats), and doesn't add login security: every subsequent // authentication is still fully verified by VerifyAssertion's own // signature check regardless of how registration was attested. This // matches attestation:"none" handling, the default most real-world // passkey deployments (GitHub, Google) actually use. // 2. Only the ES256 (ECDSA P-256) COSE algorithm is supported — what // virtually every modern authenticator (Windows Hello, Touch/Face ID, // YubiKeys, Android) defaults to. RS256/EdDSA are rejected with a // clear error at registration, not silently mismatched later. package webauthn import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "log/slog" "math/big" "time" ) const ( coseKtyEC2 = 2 coseAlgES256 = -7 coseCrvP256 = 1 flagUserPresent = 0x01 flagUserVerified = 0x04 flagAttestedCredentialData = 0x40 ) // AuthData is the parsed contents of WebAuthn's authenticatorData // structure (spec §6.1) — a fixed binary layout, not CBOR, embedded as a // byte string inside the CBOR-encoded attestationObject. type AuthData struct { RPIDHash []byte Flags byte SignCount uint32 AAGUID []byte // zero-length for an assertion's authData (only present at registration) CredentialID []byte PublicKey *ecdsa.PublicKey // nil for an assertion's authData (only present at registration) } func (a *AuthData) UserPresent() bool { return a.Flags&flagUserPresent != 0 } func (a *AuthData) UserVerified() bool { return a.Flags&flagUserVerified != 0 } // ParseAuthData parses a raw authenticatorData byte string — used both for // registration (where it includes attestedCredentialData) and for // authentication assertions (where it doesn't). func ParseAuthData(data []byte) (*AuthData, error) { const fixedLen = 32 + 1 + 4 // rpIdHash + flags + signCount if len(data) < fixedLen { return nil, fmt.Errorf("webauthn: authData too short (%d bytes, need at least %d)", len(data), fixedLen) } a := &AuthData{ RPIDHash: append([]byte{}, data[0:32]...), Flags: data[32], SignCount: binary.BigEndian.Uint32(data[33:37]), } offset := 37 if a.Flags&flagAttestedCredentialData != 0 { if len(data) < offset+16+2 { return nil, fmt.Errorf("webauthn: authData truncated in attested credential data") } a.AAGUID = append([]byte{}, data[offset:offset+16]...) offset += 16 credIDLen := int(binary.BigEndian.Uint16(data[offset : offset+2])) offset += 2 if len(data) < offset+credIDLen { return nil, fmt.Errorf("webauthn: authData truncated in credential ID") } a.CredentialID = append([]byte{}, data[offset:offset+credIDLen]...) offset += credIDLen pubKey, consumed, err := parseCOSEKey(data[offset:]) if err != nil { return nil, fmt.Errorf("webauthn: parsing credential public key: %w", err) } a.PublicKey = pubKey offset += consumed } return a, nil } // parseCOSEKey decodes a COSE_Key CBOR map (RFC 9053 §7.1) starting at the // beginning of data, returning the P-256 public key and how many bytes of // data the CBOR item occupied (so the caller — mid-way through parsing a // larger authData buffer — knows where it ends). Only EC2/ES256/P-256 is // supported; see the package doc comment. func parseCOSEKey(data []byte) (*ecdsa.PublicKey, int, error) { v, consumed, err := cborDecodeWithLength(data) if err != nil { return nil, 0, err } m, ok := v.(map[any]any) if !ok { return nil, 0, fmt.Errorf("COSE key is not a CBOR map") } kty, _ := m[int64(1)].(int64) if kty != coseKtyEC2 { return nil, 0, fmt.Errorf("unsupported COSE key type %d (only EC2/%d is supported)", kty, coseKtyEC2) } alg, _ := m[int64(3)].(int64) if alg != coseAlgES256 { return nil, 0, fmt.Errorf("unsupported COSE algorithm %d (only ES256/%d is supported)", alg, coseAlgES256) } crv, _ := m[int64(-1)].(int64) if crv != coseCrvP256 { return nil, 0, fmt.Errorf("unsupported COSE curve %d (only P-256/%d is supported)", crv, coseCrvP256) } xBytes, _ := m[int64(-2)].([]byte) yBytes, _ := m[int64(-3)].([]byte) if len(xBytes) == 0 || len(yBytes) == 0 { return nil, 0, fmt.Errorf("COSE EC2 key missing x/y coordinate") } pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(xBytes), Y: new(big.Int).SetBytes(yBytes)} return pub, consumed, nil } // ParseAttestationObject CBOR-decodes a registration ceremony's // attestationObject and extracts authData. The attestation statement // ("attStmt"/"fmt") is intentionally not verified — see the package doc // comment. func ParseAttestationObject(raw []byte) (*AuthData, error) { v, err := cborDecode(raw) if err != nil { return nil, fmt.Errorf("webauthn: decoding attestation object: %w", err) } m, ok := v.(map[any]any) if !ok { return nil, fmt.Errorf("webauthn: attestation object is not a CBOR map") } authDataBytes, ok := m["authData"].([]byte) if !ok { return nil, fmt.Errorf("webauthn: attestation object missing authData") } return ParseAuthData(authDataBytes) } // EncodePublicKey/DecodePublicKey store a verified P-256 public key as // fixed-width big-endian X||Y coordinates (base64-encoded for storage in // the credentials JSON) — simpler than re-deriving the COSE encoding on // every load, since nothing after registration needs the original CBOR form. func EncodePublicKey(pub *ecdsa.PublicKey) string { buf := make([]byte, 64) pub.X.FillBytes(buf[0:32]) pub.Y.FillBytes(buf[32:64]) return base64.StdEncoding.EncodeToString(buf) } func DecodePublicKey(encoded string) (*ecdsa.PublicKey, error) { buf, err := base64.StdEncoding.DecodeString(encoded) if err != nil || len(buf) != 64 { return nil, fmt.Errorf("webauthn: invalid stored public key") } return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(buf[0:32]), Y: new(big.Int).SetBytes(buf[32:64])}, nil } // clientData is the parsed JSON body of WebAuthn's clientDataJSON (spec // §5.8.1) — plain JSON, not CBOR. type clientData struct { Type string `json:"type"` Challenge string `json:"challenge"` Origin string `json:"origin"` } // verifyClientData checks clientDataJSON's type/challenge/origin against // expectations, returning the parsed struct and its SHA-256 hash (needed // by both registration and assertion verification). func verifyClientData(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) ([32]byte, error) { var cd clientData if err := json.Unmarshal(clientDataJSON, &cd); err != nil { return [32]byte{}, fmt.Errorf("webauthn: parsing clientDataJSON: %w", err) } if cd.Type != expectedType { return [32]byte{}, fmt.Errorf("webauthn: clientData type %q, want %q", cd.Type, expectedType) } if cd.Challenge != expectedChallenge { return [32]byte{}, fmt.Errorf("webauthn: challenge mismatch") } if cd.Origin != expectedOrigin { return [32]byte{}, fmt.Errorf("webauthn: origin %q, want %q", cd.Origin, expectedOrigin) } return sha256.Sum256(clientDataJSON), nil } // NewChallenge returns a fresh random challenge, base64url-encoded (no // padding) per WebAuthn's own convention for challenge/credential-ID // encoding in JSON. func NewChallenge() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("webauthn: generating challenge: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil } // StoredCredential is what gets persisted (as one element of the JSON // array in db.User.PasskeyCredentialsJSON) per registered passkey. type StoredCredential struct { ID string `json:"id"` // base64url credential ID PublicKey string `json:"public_key"` // see EncodePublicKey SignCount uint32 `json:"sign_count"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } // VerifyRegistration validates a registration ceremony's response and // returns the parsed AuthData (CredentialID/PublicKey) to store on // success. expectedChallenge/expectedRPID/expectedOrigin must come from // the server's own state (the challenge it issued, its own configured // hostname/origin) — never trust these as inputs from the client. func VerifyRegistration(clientDataJSON, attestationObject []byte, expectedChallenge, expectedRPID, expectedOrigin string) (*AuthData, error) { if _, err := verifyClientData(clientDataJSON, "webauthn.create", expectedChallenge, expectedOrigin); err != nil { return nil, err } authData, err := ParseAttestationObject(attestationObject) if err != nil { return nil, err } rpIDHash := sha256.Sum256([]byte(expectedRPID)) if !bytes.Equal(authData.RPIDHash, rpIDHash[:]) { return nil, fmt.Errorf("webauthn: rpIdHash mismatch") } if !authData.UserPresent() { return nil, fmt.Errorf("webauthn: user presence flag not set") } if authData.PublicKey == nil || len(authData.CredentialID) == 0 { return nil, fmt.Errorf("webauthn: attestation object missing attested credential data") } return authData, nil } // VerifyAssertion validates an authentication ceremony's response against // a previously stored credential, returning the sign count to persist // (callers should reject/warn if it didn't increase — see below — and // always persist whatever value is returned). func VerifyAssertion(cred StoredCredential, clientDataJSON, authenticatorData, signature []byte, expectedChallenge, expectedRPID, expectedOrigin string) (newSignCount uint32, err error) { clientDataHash, err := verifyClientData(clientDataJSON, "webauthn.get", expectedChallenge, expectedOrigin) if err != nil { return 0, err } authData, err := ParseAuthData(authenticatorData) if err != nil { return 0, err } rpIDHash := sha256.Sum256([]byte(expectedRPID)) if !bytes.Equal(authData.RPIDHash, rpIDHash[:]) { return 0, fmt.Errorf("webauthn: rpIdHash mismatch") } if !authData.UserPresent() { return 0, fmt.Errorf("webauthn: user presence flag not set") } pubKey, err := DecodePublicKey(cred.PublicKey) if err != nil { return 0, err } // Per WebAuthn §7.2: the signature covers SHA-256(authenticatorData || // clientDataHash), signed with ECDSA — browsers produce ASN.1 DER // signatures for this, which ecdsa.VerifyASN1 (stdlib, Go 1.15+) // verifies directly. signedData := append(append([]byte{}, authenticatorData...), clientDataHash[:]...) digest := sha256.Sum256(signedData) if !ecdsa.VerifyASN1(pubKey, digest[:], signature) { return 0, fmt.Errorf("webauthn: signature verification failed") } // A non-increasing counter can mean a cloned authenticator — but many // real platform authenticators (Touch ID, Windows Hello) legitimately // report 0 on every assertion, which is spec-compliant, not a clone. // Only warn when at least one side has ever reported a nonzero count. if (cred.SignCount != 0 || authData.SignCount != 0) && authData.SignCount <= cred.SignCount { slog.Warn("webauthn: assertion sign count did not increase — possible cloned authenticator", "credential_id", cred.ID) } return authData.SignCount, nil }