Files
Maven/internal/webauthn/webauthn.go
claude 94c273780a webauthn: lock the challenge maps and take a challenge once (V-581)
The RP kept its two in-flight challenge maps bare, and mavweb serves the four
passkey endpoints from HTTP handlers. Two browsers beginning a challenge at once
were a concurrent map write, which is a fatal runtime error rather than a
recovered panic, so it takes the daemon down. The endpoint that reaches it
answers before any credential is proven.

Every read and write of regs and asserts is now under a mutex. Lookup and delete
moved into takeReg and takeAssert so they happen under one hold, which is what
makes a challenge single-use: separately, two replays of the same response both
found it before either deleted it.

The challenge in clientDataJSON is compared in constant time. It is the one
secret in that blob, 32 bytes of crypto/rand the browser has to echo back, and a
byte-at-a-time compare is the shape that leaks a guessed prefix.

Also corrected the comment over ipc.codeOf, which claimed an unmatched error
keeps its text server-side. rpcErr ships that text deliberately, and on a tcp
seam it leaves the box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:24:00 +04:00

478 lines
15 KiB
Go

// Package webauthn implements the server side of the WebAuthn (FIDO2) protocol
// for passkey-based user verification. It handles both registration (creating
// a new credential) and assertion (verifying the user), using standard library
// crypto and a minimal CBOR decoder.
//
// Only ECDSA P-256 (ES256, COSE algorithm -7) credentials are supported.
// Attestation is read but not verified — we trust the authenticator attestation
// is honest for this deployment (single-user, self-hosted).
package webauthn
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"math/big"
"sync"
"time"
)
// Config — the WebAuthn Relying Party parameters. Must match what the browser
// sees (origin = the page's origin, rpID = the effective domain).
type Config struct {
Origin string // e.g. "https://maven.kvmx.ru"
RPID string // e.g. "maven.kvmx.ru"
RPName string // e.g. "maven"
}
// CredentialLookup is the function signature the RP needs to load a stored
// credential for assertion verification. Returns the COSE public key bytes
// and the current sign count.
type CredentialLookup func(id string) (publicKey []byte, signCount int64, err error)
// CredentialSaver stores a newly registered credential.
type CredentialSaver func(id string, publicKey []byte, userID []byte, userDisplayName string) error
// SignCountUpdater persists an updated sign counter after a successful assertion.
type SignCountUpdater func(id string, count int64) error
// credentialRegistration is the in-memory state for an in-flight registration.
type credentialRegistration struct {
Challenge string
UserID []byte
CreatedAt time.Time
}
// credentialAssertion is the in-memory state for an in-flight assertion.
type credentialAssertion struct {
Challenge string
CreatedAt time.Time
}
// RP — the relying party instance. Holds config and transient challenge state.
// A single-user daemon has one RP.
//
// mavweb serves the four passkey endpoints from its HTTP handlers, so the two
// challenge maps are reached concurrently even on a single-user box: a browser
// retrying an assertion while another tab begins one is enough. A concurrent
// map write is a fatal runtime error, not a recovered panic, so it would take
// the whole daemon down from an endpoint that answers before any credential is
// proven. Every read and write of regs and asserts is under mu.
type RP struct {
cfg Config
mu sync.Mutex
regs map[string]*credentialRegistration
asserts map[string]*credentialAssertion
challengeTTL time.Duration
}
// NewRP creates a relying party with the given WebAuthn configuration.
func NewRP(cfg Config) *RP {
return &RP{
cfg: cfg,
regs: make(map[string]*credentialRegistration),
asserts: make(map[string]*credentialAssertion),
challengeTTL: 5 * time.Minute,
}
}
// CleanExpired removes challenges older than the TTL.
func (rp *RP) CleanExpired() {
rp.mu.Lock()
defer rp.mu.Unlock()
rp.cleanExpired()
}
// cleanExpired is CleanExpired for a caller that already holds mu.
func (rp *RP) cleanExpired() {
now := time.Now()
for k, r := range rp.regs {
if now.Sub(r.CreatedAt) > rp.challengeTTL {
delete(rp.regs, k)
}
}
for k, a := range rp.asserts {
if now.Sub(a.CreatedAt) > rp.challengeTTL {
delete(rp.asserts, k)
}
}
}
// CreationOptions returns the PublicKeyCredentialCreationOptions as a
// JSON-serializable map for the browser to create a credential.
func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, string, error) {
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return nil, "", fmt.Errorf("webauthn: challenge: %w", err)
}
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
rp.mu.Lock()
rp.cleanExpired()
rp.regs[challengeB64] = &credentialRegistration{
Challenge: challengeB64,
UserID: userID,
CreatedAt: time.Now(),
}
rp.mu.Unlock()
return map[string]any{
"rp": map[string]string{
"name": rp.cfg.RPName,
"id": rp.cfg.RPID,
},
"user": map[string]any{
"id": base64.RawURLEncoding.EncodeToString(userID),
"name": userName,
"displayName": userName,
},
"challenge": challengeB64,
// ES256 only — parseCOSEKey verifies P-256/ES256 exclusively. Offering
// RS256 here would let an authenticator register a key we can never
// verify at assertion time (register-ok, assert-fail forever).
"pubKeyCredParams": []map[string]any{
{"type": "public-key", "alg": -7}, // ES256
},
"timeout": 60000,
"attestation": "none",
"excludeCredentials": []any{},
// PRF: ask the authenticator at enrollment time whether it can
// produce a per-credential secret. Nothing is wrapped here — the
// browser reports support back and mavweb decides whether cold-start
// unlock is available for this credential. See internal/webauthn/prf.go.
"extensions": map[string]any{
"prf": map[string]any{},
},
}, challengeB64, nil
}
// FinishRegistration parses the browser's response and stores the credential.
func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) {
reg, ok := rp.takeReg(challengeB64)
if !ok {
return "", fmt.Errorf("webauthn: unknown or expired challenge")
}
credID := rawString(resp, "id")
if credID == "" {
return "", fmt.Errorf("webauthn: missing credential id")
}
rawResponse, ok := resp["response"].(map[string]any)
if !ok {
return "", fmt.Errorf("webauthn: missing response")
}
cdjB64 := rawString(rawResponse, "clientDataJSON")
cdjRaw, err := base64.RawURLEncoding.DecodeString(cdjB64)
if err != nil {
return "", fmt.Errorf("webauthn: clientDataJSON: %w", err)
}
if err := verifyClientDataBytes(cdjRaw, "webauthn.create", challengeB64, rp.cfg.Origin); err != nil {
return "", err
}
attObjB64 := rawString(rawResponse, "attestationObject")
attObj, err := base64.RawURLEncoding.DecodeString(attObjB64)
if err != nil {
return "", fmt.Errorf("webauthn: attestationObject: %w", err)
}
publicKey, err := extractPublicKey(attObj)
if err != nil {
return "", fmt.Errorf("webauthn: extract key: %w", err)
}
if err := save(credID, publicKey, reg.UserID, "maven user"); err != nil {
return "", fmt.Errorf("webauthn: store credential: %w", err)
}
return credID, nil
}
// AssertionOptions returns a JSON-serializable map for browser authentication.
func (rp *RP) AssertionOptions() (map[string]any, string, error) {
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return nil, "", fmt.Errorf("webauthn: challenge: %w", err)
}
challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)
rp.mu.Lock()
rp.cleanExpired()
rp.asserts[challengeB64] = &credentialAssertion{
Challenge: challengeB64,
CreatedAt: time.Now(),
}
rp.mu.Unlock()
return map[string]any{
"challenge": challengeB64,
"timeout": 60000,
"rpId": rp.cfg.RPID,
"allowCredentials": []any{},
"userVerification": "required",
// PRF evaluation over the fixed cold-start salt. The 32 bytes that
// come back are the ONLY thing that can unwrap the database key.
"extensions": map[string]any{
"prf": map[string]any{
"eval": map[string]any{
"first": base64.RawURLEncoding.EncodeToString(PRFSalt()),
},
},
},
}, challengeB64, nil
}
// FinishAssertion verifies the browser's assertion response and returns the
// verified credential ID.
func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) {
if !rp.takeAssert(challengeB64) {
return "", fmt.Errorf("webauthn: unknown or expired challenge")
}
credID := rawString(resp, "id")
if credID == "" {
return "", fmt.Errorf("webauthn: missing credential id")
}
rawResponse, ok := resp["response"].(map[string]any)
if !ok {
return "", fmt.Errorf("webauthn: missing response")
}
cdjRaw, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "clientDataJSON"))
if err != nil {
return "", fmt.Errorf("webauthn: clientDataJSON: %w", err)
}
if err := verifyClientDataBytes(cdjRaw, "webauthn.get", challengeB64, rp.cfg.Origin); err != nil {
return "", err
}
authenticatorData, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "authenticatorData"))
if err != nil {
return "", fmt.Errorf("webauthn: authenticatorData: %w", err)
}
// Bind the assertion to this RP and require a verified user gesture. Origin
// is checked via clientDataJSON above; rpIdHash + flags bind the
// authenticator half. We requested userVerification:required, so UV must be
// set — that IS the step-up gesture (biometric/PIN).
if len(authenticatorData) < 37 {
return "", fmt.Errorf("webauthn: authenticatorData too short (%d)", len(authenticatorData))
}
rpIDHash := sha256.Sum256([]byte(rp.cfg.RPID))
if !bytes.Equal(authenticatorData[:32], rpIDHash[:]) {
return "", fmt.Errorf("webauthn: rpIdHash mismatch")
}
const flagUP, flagUV = 1 << 0, 1 << 2
if authenticatorData[32]&flagUP == 0 {
return "", fmt.Errorf("webauthn: user-present flag not set")
}
if authenticatorData[32]&flagUV == 0 {
return "", fmt.Errorf("webauthn: user-verification flag not set")
}
sig, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "signature"))
if err != nil {
return "", fmt.Errorf("webauthn: signature: %w", err)
}
pubKeyBytes, signCount, err := lookup(credID)
if err != nil {
return "", fmt.Errorf("webauthn: credential not found: %w", err)
}
clientDataHash := sha256.Sum256(cdjRaw)
sigData := append(authenticatorData, clientDataHash[:]...)
pubKey, err := parseCOSEKey(pubKeyBytes)
if err != nil {
return "", fmt.Errorf("webauthn: parse key: %w", err)
}
if !ecdsa.VerifyASN1(pubKey, sigData, sig) {
return "", fmt.Errorf("webauthn: signature verification failed")
}
if len(authenticatorData) >= 37 {
counter := int64(binary.BigEndian.Uint32(authenticatorData[33:37]))
if counter > 0 && counter <= signCount {
return "", fmt.Errorf("webauthn: sign count not greater (old=%d, new=%d)", signCount, counter)
}
if counter > 0 {
if err := updateSignCount(credID, counter); err != nil {
return "", fmt.Errorf("webauthn: update sign count: %w", err)
}
}
}
return credID, nil
}
// takeReg removes and returns the in-flight registration for challengeB64.
// Taking under one lock is what makes a challenge single-use: looking it up
// and deleting it separately lets two replays of the same response both find
// it before either deletes.
func (rp *RP) takeReg(challengeB64 string) (*credentialRegistration, bool) {
rp.mu.Lock()
defer rp.mu.Unlock()
rp.cleanExpired()
reg, ok := rp.regs[challengeB64]
if ok {
delete(rp.regs, challengeB64)
}
return reg, ok
}
// takeAssert removes the in-flight assertion for challengeB64 and reports
// whether it was there. Single-use for the same reason takeReg is.
func (rp *RP) takeAssert(challengeB64 string) bool {
rp.mu.Lock()
defer rp.mu.Unlock()
rp.cleanExpired()
if _, ok := rp.asserts[challengeB64]; !ok {
return false
}
delete(rp.asserts, challengeB64)
return true
}
func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error {
var cdj struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Origin string `json:"origin"`
}
if err := json.Unmarshal(clientDataJSON, &cdj); err != nil {
return fmt.Errorf("webauthn: parse clientDataJSON: %w", err)
}
if cdj.Type != expectedType {
return fmt.Errorf("webauthn: unexpected type %q", cdj.Type)
}
// Constant time, because the challenge is the one secret in clientDataJSON:
// it is 32 bytes of crypto/rand the browser has to echo back, and a
// byte-at-a-time compare is the shape that leaks a guessed prefix.
if subtle.ConstantTimeCompare([]byte(cdj.Challenge), []byte(expectedChallenge)) != 1 {
return fmt.Errorf("webauthn: challenge mismatch")
}
if cdj.Origin != expectedOrigin {
return fmt.Errorf("webauthn: origin mismatch: %q != %q", cdj.Origin, expectedOrigin)
}
return nil
}
func extractPublicKey(attObj []byte) ([]byte, error) {
v, err := decodeCBOR(attObj)
if err != nil {
return nil, fmt.Errorf("webauthn: decode attestation: %w", err)
}
m, err := v.MapText()
if err != nil {
return nil, fmt.Errorf("webauthn: attestation is not a map: %w", err)
}
authDataV, ok := m["authData"]
if !ok {
return nil, fmt.Errorf("webauthn: attestation missing authData")
}
authData, err := authDataV.Bytes()
if err != nil {
return nil, fmt.Errorf("webauthn: authData not bytes: %w", err)
}
return extractCOSEKeyFromAuthData(authData)
}
func extractCOSEKeyFromAuthData(authData []byte) ([]byte, error) {
if len(authData) < 37 {
return nil, fmt.Errorf("webauthn: authData too short (%d)", len(authData))
}
flags := authData[32]
if flags&(1<<6) == 0 {
return nil, fmt.Errorf("webauthn: AT flag not set in authData")
}
acd := authData[37:]
if len(acd) < 18 {
return nil, fmt.Errorf("webauthn: attested credential data too short (%d)", len(acd))
}
credIDLen := int(binary.BigEndian.Uint16(acd[16:18]))
coseKeyOff := 18 + credIDLen
if coseKeyOff > len(acd) {
return nil, fmt.Errorf("webauthn: credential ID length %d exceeds data (%d)", credIDLen, len(acd))
}
return acd[coseKeyOff:], nil
}
func parseCOSEKey(raw []byte) (*ecdsa.PublicKey, error) {
v, err := decodeCBOR(raw)
if err != nil {
return nil, fmt.Errorf("cose: decode: %w", err)
}
m, err := v.Map()
if err != nil {
return nil, fmt.Errorf("cose: not a map: %w", err)
}
kty, ok := m[1]
if !ok {
return nil, fmt.Errorf("cose: missing kty")
}
ktyV, err := kty.Int()
if err != nil {
return nil, fmt.Errorf("cose: kty: %w", err)
}
if ktyV != 2 {
return nil, fmt.Errorf("cose: unsupported kty %d (expected 2=EC2)", ktyV)
}
crv, ok := m[-1]
if !ok {
return nil, fmt.Errorf("cose: missing crv")
}
crvV, err := crv.Int()
if err != nil {
return nil, fmt.Errorf("cose: crv: %w", err)
}
if crvV != 1 {
return nil, fmt.Errorf("cose: unsupported crv %d (expected 1=P-256)", crvV)
}
xV, ok := m[-2]
if !ok {
return nil, fmt.Errorf("cose: missing x coordinate")
}
x, err := xV.Bytes()
if err != nil {
return nil, fmt.Errorf("cose: x: %w", err)
}
yV, ok := m[-3]
if !ok {
return nil, fmt.Errorf("cose: missing y coordinate")
}
y, err := yV.Bytes()
if err != nil {
return nil, fmt.Errorf("cose: y: %w", err)
}
return &ecdsa.PublicKey{
Curve: elliptic.P256(),
X: new(big.Int).SetBytes(x),
Y: new(big.Int).SetBytes(y),
}, nil
}
func rawString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
s, _ := v.(string)
return s
}