update
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user