Files
gomail/internal/webauthn/webauthn.go
T
2026-08-10 21:15:19 +01:00

298 lines
12 KiB
Go

// 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
}