mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
R2-1: biometric enrollment is a forged-enrollment oracle fixed.
- pkg/webauthn/verify.go: full WebAuthn verification — compare against the
random challenge bytes we issued (not the DB row ID), origin allowlist,
rpIDHash equality, UP+UV authenticator-flag enforcement, ceremony type.
- pkg/webauthn/liveness.go: -signed liveness attestation. Ed25519
envelope over {providerId, userId, score, timestamp, nonce}. MPC verifies
against MPC__PUBKEY_ED25519 and enforces userId match, score
floor, max age. Body-supplied livenessScore is never trusted again.
- If pubkey is unconfigured, handleBiometricEnroll returns 503.
- 14 new tests cover each rejection path.
R2-2: CAS-under-Postgres. The previous in-Go check-then-write +
unconditional UPSERT works on SQLite (writeMu) but loses updates under
Postgres READ COMMITTED. Switched to SELECT ... FOR UPDATE inside READ
COMMITTED tx via orm.RunInTransactionWith + orm.GetForUpdate[T]. Requires
hanzoai/[email protected] (tagged today).
- Session consume: operationLimit strictly respected under concurrency;
valueAccum computed under the lock.
- Operation approve: ApprovedBy append under the row lock, quorum
finalization transitions at most once even under racing approvers.
- pkg/db.New now supports postgres:// DSNs.
- New Postgres-backed regression tests in cas_postgres_test.go —
auto-skipped when no TEST_PG_DSN reachable; 10 concurrent consumers
against operationLimit=5 yields exactly 5 successes and 5 denials.
Bumps github.com/hanzoai/orm v0.3.2 → v0.4.0.
Test plan:
go test -tags sqlite_json1 -count=10 \
-run 'CASUnderRace|CASUnderRace_Postgres|RequireRoleOrAPIPermission' \
./pkg/api/ ./pkg/webauthn/
258 lines
9.0 KiB
Go
258 lines
9.0 KiB
Go
// Package webauthn verifies WebAuthn/FIDO2 attestations and assertions.
|
|
//
|
|
// R2-1: Previous biometric enroll was a forged-enrollment oracle:
|
|
//
|
|
// 1. challenge comparison used the DB row ID (predictable timestamp) rather
|
|
// than the random challenge bytes we issued;
|
|
// 2. neither origin nor the rpIDHash were checked, so a relying-party
|
|
// mismatch (phishing site, another tenant's RP) was accepted;
|
|
// 3. UP/UV authenticator flags were never enforced, so a stolen authenticator
|
|
// with locked-out biometrics could still enroll.
|
|
//
|
|
// This package is the single point of truth for "given an attestation/assertion
|
|
// bundle and the metadata we kept from the challenge issuance, is this proof
|
|
// acceptable?". Callers supply the parsed WebAuthn ceremony as an Opts struct
|
|
// and get back an *Outcome with the public key to persist (for enroll) or a
|
|
// verified boolean (for assertion).
|
|
package webauthn
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
)
|
|
|
|
// Ceremony identifies which WebAuthn type is expected in clientDataJSON.
|
|
type Ceremony string
|
|
|
|
const (
|
|
CeremonyCreate Ceremony = "webauthn.create"
|
|
CeremonyGet Ceremony = "webauthn.get"
|
|
)
|
|
|
|
// AuthenticatorFlags is the bitmask in authenticatorData byte 32 (per spec
|
|
// §6.1 "Authenticator Data"). The bits we enforce:
|
|
//
|
|
// 0x01 UP — User presence
|
|
// 0x04 UV — User verification (PIN/biometric)
|
|
// 0x40 AT — Attested credential data included (enroll only)
|
|
type AuthenticatorFlags byte
|
|
|
|
const (
|
|
FlagUP AuthenticatorFlags = 0x01
|
|
FlagUV AuthenticatorFlags = 0x04
|
|
FlagAT AuthenticatorFlags = 0x40
|
|
)
|
|
|
|
// Opts carries the raw, base64url-decoded ceremony bundle plus the server's
|
|
// side of the conversation (expected challenge bytes, origin allowlist, RP ID
|
|
// it signed the challenge under, expected ceremony type).
|
|
type Opts struct {
|
|
// Expected ceremony: CeremonyCreate for enroll, CeremonyGet for assertion.
|
|
Ceremony Ceremony
|
|
|
|
// ExpectedChallenge is the exact random byte string the server issued
|
|
// (before base64url encoding to the client). We compare base64url(this)
|
|
// against clientDataJSON.challenge.
|
|
ExpectedChallenge []byte
|
|
|
|
// AllowedOrigins is the closed set of origins we accept
|
|
// clientDataJSON.origin from. Empty = reject everything (fail-secure).
|
|
AllowedOrigins map[string]bool
|
|
|
|
// RPID is the relying party ID the challenge was issued under (typically
|
|
// the apex domain of the login surface, e.g. "lux.network"). We verify
|
|
// authenticatorData[0:32] == sha256(RPID).
|
|
RPID string
|
|
|
|
// RequireUP / RequireUV control authenticator-flag enforcement.
|
|
// RequireUV=true is the right default for biometric/passkey flows —
|
|
// setting it false lets a locked authenticator satisfy the ceremony.
|
|
RequireUP bool
|
|
RequireUV bool
|
|
|
|
// PublicKeyB64 is the caller's claimed COSE key (uncompressed P-256 point,
|
|
// base64-standard-encoded) for signature verification. Required when
|
|
// Signature is non-nil.
|
|
PublicKeyB64 string
|
|
|
|
// ClientDataJSONB64 / AuthDataB64 / SignatureB64 are the raw base64url
|
|
// ceremony fields. All three must decode or the verify fails.
|
|
ClientDataJSONB64 string
|
|
AuthDataB64 string
|
|
SignatureB64 string
|
|
}
|
|
|
|
// Outcome is the decoded form of a successfully verified ceremony — callers
|
|
// typically persist PublicKeyB64 on enroll and log Outcome.Origin for audit.
|
|
type Outcome struct {
|
|
Origin string
|
|
Type Ceremony
|
|
Flags AuthenticatorFlags
|
|
SignedOK bool // true iff Signature was provided and verified
|
|
}
|
|
|
|
// Verify runs the full WebAuthn verification pipeline in order. Each check is
|
|
// a hard fail — returning an error leaks only the first mismatch so we never
|
|
// give the caller a differential oracle on which field is wrong.
|
|
func Verify(opts *Opts) (*Outcome, error) {
|
|
if opts == nil {
|
|
return nil, errors.New("webauthn: opts required")
|
|
}
|
|
if opts.Ceremony != CeremonyCreate && opts.Ceremony != CeremonyGet {
|
|
return nil, errors.New("webauthn: unknown ceremony")
|
|
}
|
|
if len(opts.AllowedOrigins) == 0 {
|
|
return nil, errors.New("webauthn: allowed origins not configured")
|
|
}
|
|
if opts.RPID == "" {
|
|
return nil, errors.New("webauthn: rpID required")
|
|
}
|
|
if len(opts.ExpectedChallenge) == 0 {
|
|
return nil, errors.New("webauthn: expected challenge required")
|
|
}
|
|
|
|
clientData, err := base64.URLEncoding.DecodeString(opts.ClientDataJSONB64)
|
|
if err != nil {
|
|
// WebAuthn permits both padding variants; fall back to raw.
|
|
if cd2, err2 := base64.RawURLEncoding.DecodeString(opts.ClientDataJSONB64); err2 == nil {
|
|
clientData = cd2
|
|
} else {
|
|
return nil, errors.New("webauthn: invalid clientDataJSON encoding")
|
|
}
|
|
}
|
|
var cd struct {
|
|
Challenge string `json:"challenge"`
|
|
Origin string `json:"origin"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(clientData, &cd); err != nil {
|
|
return nil, errors.New("webauthn: invalid clientDataJSON structure")
|
|
}
|
|
|
|
if Ceremony(cd.Type) != opts.Ceremony {
|
|
return nil, fmt.Errorf("webauthn: wrong ceremony type: %q", cd.Type)
|
|
}
|
|
if !opts.AllowedOrigins[cd.Origin] {
|
|
return nil, fmt.Errorf("webauthn: origin not allowed: %q", cd.Origin)
|
|
}
|
|
|
|
// Compare the challenge fresh: the client sends base64url-no-padding per
|
|
// spec §5.1.3 step 5, some implementations send base64url-with-padding.
|
|
// Compare both encodings; constant-time compare to avoid timing leaks.
|
|
expRaw := base64.RawURLEncoding.EncodeToString(opts.ExpectedChallenge)
|
|
expStd := base64.URLEncoding.EncodeToString(opts.ExpectedChallenge)
|
|
if !ctEq([]byte(cd.Challenge), []byte(expRaw)) && !ctEq([]byte(cd.Challenge), []byte(expStd)) {
|
|
return nil, errors.New("webauthn: challenge mismatch")
|
|
}
|
|
|
|
authData, err := base64.URLEncoding.DecodeString(opts.AuthDataB64)
|
|
if err != nil {
|
|
if ad2, err2 := base64.RawURLEncoding.DecodeString(opts.AuthDataB64); err2 == nil {
|
|
authData = ad2
|
|
} else {
|
|
return nil, errors.New("webauthn: invalid authenticatorData encoding")
|
|
}
|
|
}
|
|
if len(authData) < 37 {
|
|
return nil, errors.New("webauthn: authenticatorData too short")
|
|
}
|
|
// Bytes 0..31 are SHA-256(RP ID). Compare.
|
|
expectedRPIDHash := sha256.Sum256([]byte(opts.RPID))
|
|
if !ctEq(authData[:32], expectedRPIDHash[:]) {
|
|
return nil, errors.New("webauthn: rpIDHash mismatch")
|
|
}
|
|
flags := AuthenticatorFlags(authData[32])
|
|
if opts.RequireUP && flags&FlagUP == 0 {
|
|
return nil, errors.New("webauthn: user presence flag not set")
|
|
}
|
|
if opts.RequireUV && flags&FlagUV == 0 {
|
|
return nil, errors.New("webauthn: user verification flag not set")
|
|
}
|
|
|
|
out := &Outcome{
|
|
Origin: cd.Origin,
|
|
Type: opts.Ceremony,
|
|
Flags: flags,
|
|
}
|
|
|
|
// Signature verification — only attempted if the caller supplied both the
|
|
// pubkey and the signature. Enroll ceremonies with attestation=none might
|
|
// skip this, but we treat that as a higher-level decision — verify()
|
|
// itself always runs it if the fields are present.
|
|
if opts.SignatureB64 != "" || opts.PublicKeyB64 != "" {
|
|
sig, err := base64.URLEncoding.DecodeString(opts.SignatureB64)
|
|
if err != nil {
|
|
if s2, err2 := base64.RawURLEncoding.DecodeString(opts.SignatureB64); err2 == nil {
|
|
sig = s2
|
|
} else {
|
|
return nil, errors.New("webauthn: invalid signature encoding")
|
|
}
|
|
}
|
|
pub, err := parseUncompressedP256(opts.PublicKeyB64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clientDataHash := sha256.Sum256(clientData)
|
|
signed := append(append([]byte{}, authData...), clientDataHash[:]...)
|
|
// WebAuthn assertion signatures sign `authData || SHA256(clientDataJSON)`
|
|
// directly — not SHA-256 of that concatenation again. Spec §7.2 step 17.
|
|
signedHash := sha256.Sum256(signed)
|
|
if !ecdsa.VerifyASN1(pub, signedHash[:], sig) {
|
|
return nil, errors.New("webauthn: signature verification failed")
|
|
}
|
|
out.SignedOK = true
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// ParseUncompressedP256 decodes a base64-standard-encoded uncompressed P-256
|
|
// public key (0x04 || X(32) || Y(32), 65 bytes) and verifies it's on the
|
|
// curve. Exported for callers that store+validate public keys independently.
|
|
func ParseUncompressedP256(b64 string) (*ecdsa.PublicKey, error) {
|
|
return parseUncompressedP256(b64)
|
|
}
|
|
|
|
func parseUncompressedP256(b64 string) (*ecdsa.PublicKey, error) {
|
|
if b64 == "" {
|
|
return nil, errors.New("webauthn: public key required")
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
// Tolerate base64url if a legacy caller used it.
|
|
if r2, err2 := base64.URLEncoding.DecodeString(b64); err2 == nil {
|
|
raw = r2
|
|
} else {
|
|
return nil, errors.New("webauthn: invalid public key encoding")
|
|
}
|
|
}
|
|
if len(raw) < 65 || raw[0] != 0x04 {
|
|
return nil, errors.New("webauthn: public key must be 65-byte uncompressed P-256 point")
|
|
}
|
|
x := new(big.Int).SetBytes(raw[1:33])
|
|
y := new(big.Int).SetBytes(raw[33:65])
|
|
if !elliptic.P256().IsOnCurve(x, y) {
|
|
return nil, errors.New("webauthn: public key not on P-256 curve")
|
|
}
|
|
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, nil
|
|
}
|
|
|
|
// ctEq is constant-time byte equality that tolerates different lengths —
|
|
// unequal lengths always return false without a data-dependent branch.
|
|
func ctEq(a, b []byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
var diff byte
|
|
for i := range a {
|
|
diff |= a[i] ^ b[i]
|
|
}
|
|
return diff == 0
|
|
}
|