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