update
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// cborDecode is a minimal CBOR (RFC 8949) decoder covering only the subset
|
||||
// WebAuthn actually uses: unsigned/negative integers, byte strings, text
|
||||
// strings, arrays, maps, and the true/false/null simple values —
|
||||
// definite-length items only (WebAuthn's attestationObject/COSE keys never
|
||||
// use CBOR's indefinite-length encoding). This is not a general-purpose
|
||||
// CBOR library, the same way internal/dnsutil is not a general DNS library.
|
||||
//
|
||||
// All integers (major types 0 and 1) decode to Go int64 — COSE key labels
|
||||
// mix small positive (kty=1, alg=3) and negative (crv=-1, x=-2, y=-3)
|
||||
// values, and normalizing to one Go type avoids uint64-vs-int64 mismatches
|
||||
// when looking values up in a decoded map.
|
||||
func cborDecode(data []byte) (any, error) {
|
||||
d := &cborDecoder{data: data}
|
||||
return d.decodeValue()
|
||||
}
|
||||
|
||||
// cborDecodeWithLength is cborDecode plus how many bytes were consumed —
|
||||
// needed when a CBOR item (a COSE key) is embedded inside a larger binary
|
||||
// structure (authData) followed by more data, not the whole buffer.
|
||||
func cborDecodeWithLength(data []byte) (any, int, error) {
|
||||
d := &cborDecoder{data: data}
|
||||
v, err := d.decodeValue()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return v, d.pos, nil
|
||||
}
|
||||
|
||||
type cborDecoder struct {
|
||||
data []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (d *cborDecoder) readByte() (byte, error) {
|
||||
if d.pos >= len(d.data) {
|
||||
return 0, fmt.Errorf("cbor: unexpected end of data")
|
||||
}
|
||||
b := d.data[d.pos]
|
||||
d.pos++
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (d *cborDecoder) readBytes(n int) ([]byte, error) {
|
||||
if n < 0 || d.pos+n > len(d.data) {
|
||||
return nil, fmt.Errorf("cbor: unexpected end of data (need %d bytes, have %d)", n, len(d.data)-d.pos)
|
||||
}
|
||||
b := d.data[d.pos : d.pos+n]
|
||||
d.pos += n
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// readLength reads the additional-info length/value encoding shared by
|
||||
// every major type: 0-23 is a literal value, 24/25/26/27 mean 1/2/4/8
|
||||
// following bytes hold it. Indefinite length (additional info 31) is
|
||||
// rejected — not used by anything this package parses.
|
||||
func (d *cborDecoder) readLength(additionalInfo byte) (uint64, error) {
|
||||
switch {
|
||||
case additionalInfo < 24:
|
||||
return uint64(additionalInfo), nil
|
||||
case additionalInfo == 24:
|
||||
b, err := d.readByte()
|
||||
return uint64(b), err
|
||||
case additionalInfo == 25:
|
||||
b, err := d.readBytes(2)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(binary.BigEndian.Uint16(b)), nil
|
||||
case additionalInfo == 26:
|
||||
b, err := d.readBytes(4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(binary.BigEndian.Uint32(b)), nil
|
||||
case additionalInfo == 27:
|
||||
b, err := d.readBytes(8)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.BigEndian.Uint64(b), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cbor: indefinite-length items are not supported (additional info %d)", additionalInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cborDecoder) decodeValue() (any, error) {
|
||||
head, err := d.readByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
majorType := head >> 5
|
||||
additionalInfo := head & 0x1F
|
||||
|
||||
switch majorType {
|
||||
case 0: // unsigned integer
|
||||
n, err := d.readLength(additionalInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return int64(n), nil
|
||||
case 1: // negative integer: value = -1 - n
|
||||
n, err := d.readLength(additionalInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return -1 - int64(n), nil
|
||||
case 2: // byte string
|
||||
n, err := d.readLength(additionalInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.readBytes(int(n))
|
||||
case 3: // text string
|
||||
n, err := d.readLength(additionalInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := d.readBytes(int(n))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
case 4: // array
|
||||
n, err := d.readLength(additionalInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr := make([]any, n)
|
||||
for i := range arr {
|
||||
v, err := d.decodeValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr[i] = v
|
||||
}
|
||||
return arr, nil
|
||||
case 5: // map
|
||||
n, err := d.readLength(additionalInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[any]any, n)
|
||||
for i := uint64(0); i < n; i++ {
|
||||
k, err := d.decodeValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := d.decodeValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
case 7: // simple values: only false/true/null are meaningful here
|
||||
switch additionalInfo {
|
||||
case 20:
|
||||
return false, nil
|
||||
case 21:
|
||||
return true, nil
|
||||
case 22, 23:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("cbor: unsupported simple/float value (additional info %d)", additionalInfo)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("cbor: unsupported major type %d", majorType)
|
||||
}
|
||||
}
|
||||
@@ -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