package web import ( "encoding/base64" "net/http" "net/url" "strconv" "sync" "time" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "hearthforge/internal/db" ) const challengeTTL = 5 * time.Minute // Credential encoding in the passkeys table. Existing rows were written by // @simplewebauthn/server and use exactly these formats: // - credential_id: base64url without padding of the raw credential ID. // - public_key: standard base64 with padding of the COSE public key. // - counter: the signature counter as an integer. // // decodeCredentialID and encodeCredentialID wrap that so the choice lives in // one place. func encodeCredentialID(id []byte) string { return base64.RawURLEncoding.EncodeToString(id) } func decodeCredentialID(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } // challengeStore keeps in-flight ceremonies in memory. A single process owns // them. // // ponytail: in-memory map, move to the database if the server ever runs more // than one process. type challengeStore struct { mu sync.Mutex entries map[string]challengeEntry } type challengeEntry struct { session webauthn.SessionData expires time.Time } var challenges = challengeStore{entries: map[string]challengeEntry{}} // maxChallenges caps the in-flight ceremonies. Login options are // unauthenticated, so without a cap an attacker can grow memory without bound. const maxChallenges = 10000 // put stores a session and drops every expired entry. When full it evicts // the oldest entry instead of refusing new ceremonies. func (c *challengeStore) put(key string, session webauthn.SessionData) { c.mu.Lock() defer c.mu.Unlock() now := time.Now() oldest := "" for k, e := range c.entries { if now.After(e.expires) { delete(c.entries, k) } else if oldest == "" || e.expires.Before(c.entries[oldest].expires) { oldest = k } } if _, replacing := c.entries[key]; !replacing && len(c.entries) >= maxChallenges { delete(c.entries, oldest) } c.entries[key] = challengeEntry{session: session, expires: now.Add(challengeTTL)} } // take returns a session and removes it, so a challenge is used once. func (c *challengeStore) take(key string) (webauthn.SessionData, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[key] if !ok || time.Now().After(e.expires) { delete(c.entries, key) return webauthn.SessionData{}, false } delete(c.entries, key) return e.session, true } // peek returns a session without removing it, for the steps that may still // fail and should let the user retry. func (c *challengeStore) peek(key string) (webauthn.SessionData, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[key] if !ok || time.Now().After(e.expires) { return webauthn.SessionData{}, false } return e.session, true } // webAuthnUser adapts a user row plus its passkeys to the library's interface. type webAuthnUser struct { id int64 username string credentials []webauthn.Credential } // WebAuthnID is the decimal user id as ASCII bytes. Existing credentials // carry that user handle, so the format must not change. func (u *webAuthnUser) WebAuthnID() []byte { return []byte(strconv.FormatInt(u.id, 10)) } func (u *webAuthnUser) WebAuthnName() string { return u.username } func (u *webAuthnUser) WebAuthnDisplayName() string { return u.username } func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } // webAuthn builds the relying party. The RP ID and origin come from BASE_URL, // never from the request, so the origin check cannot validate a value against // itself. func (s *Server) webAuthn() (*webauthn.WebAuthn, error) { u, err := url.Parse(s.Cfg.PublicOrigin) if err != nil { return nil, err } return webauthn.New(&webauthn.Config{ RPID: u.Hostname(), RPDisplayName: s.Cfg.OwnerDisplayName + "'s Hearthforge", RPOrigins: []string{s.Cfg.PublicOrigin}, }) } // credentialFromRow maps a stored passkey to a library credential. // Only the fields the login check reads are stored, so the rest stay zero. func credentialFromRow(p db.Passkey) (webauthn.Credential, error) { id, err := decodeCredentialID(p.CredentialID) if err != nil { return webauthn.Credential{}, err } key, err := base64.StdEncoding.DecodeString(p.PublicKey) if err != nil { return webauthn.Credential{}, err } return webauthn.Credential{ ID: id, PublicKey: key, Authenticator: webauthn.Authenticator{SignCount: uint32(p.Counter)}, }, nil } // registrationUser resolves the account the passkey ceremony belongs to. // It accepts an account that is still pending approval, because in queue mode // create-user leaves the new account pending and the ceremony runs next. func (s *Server) registrationUser(r *http.Request) *db.SessionUser { if u := User(r); u != nil { return u } c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { return nil } u, err := s.DB.SessionUserAllowPending(r.Context(), c.Value, db.NowISO()) if err != nil { return nil } return u } func (s *Server) passkeyRegisterOptions(w http.ResponseWriter, r *http.Request) { su := s.registrationUser(r) if su == nil { jsonError(w, http.StatusUnauthorized, "Not authenticated") return } if !recentLogin(su) { writeJSON(w, http.StatusForbidden, map[string]string{ "error": "Please sign in again to add a passkey.", "reauth": reauthURL("/settings"), }) return } wa, err := s.webAuthn() if err != nil { jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured") return } rows, err := s.DB.ListPasskeys(r.Context(), su.ID) if err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } exclude := make([]protocol.CredentialDescriptor, 0, len(rows)) for _, p := range rows { id, err := decodeCredentialID(p.CredentialID) if err != nil { continue } exclude = append(exclude, protocol.CredentialDescriptor{ Type: protocol.PublicKeyCredentialType, CredentialID: id, }) } user := &webAuthnUser{id: su.ID, username: su.Username} creation, session, err := wa.BeginRegistration(user, webauthn.WithExclusions(exclude), webauthn.WithConveyancePreference(protocol.PreferNoAttestation), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementPreferred, UserVerification: protocol.VerificationRequired, }), ) if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return } challenges.put("reg:"+su.Username, *session) // @simplewebauthn/browser expects the bare creation options, not the // { publicKey: ... } wrapper the library's top-level type marshals to. writeJSON(w, http.StatusOK, creation.Response) } func (s *Server) passkeyRegisterVerify(w http.ResponseWriter, r *http.Request) { su := s.registrationUser(r) if su == nil { jsonError(w, http.StatusUnauthorized, "Not authenticated") return } session, ok := challenges.peek("reg:" + su.Username) if !ok { jsonError(w, http.StatusBadRequest, "No challenge") return } wa, err := s.webAuthn() if err != nil { jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured") return } parsed, err := protocol.ParseCredentialCreationResponseBody(r.Body) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } user := &webAuthnUser{id: su.ID, username: su.Username} credential, err := wa.CreateCredential(user, session, parsed) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } err = s.DB.CreatePasskey(r.Context(), su.ID, encodeCredentialID(credential.ID), base64.StdEncoding.EncodeToString(credential.PublicKey), int64(credential.Authenticator.SignCount), db.NowISO()) if err != nil { jsonError(w, http.StatusBadRequest, "Could not store the passkey") return } // The account now has a credential, so it is no longer provisional. if err := s.DB.SetPasskeySetupStarted(r.Context(), su.ID, ""); err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } challenges.take("reg:" + su.Username) if err := s.dropOtherSessions(r, su.ID); err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) passkeyLoginOptions(w http.ResponseWriter, r *http.Request) { // Unauthenticated endpoint: it allocates a challenge per call, so it uses // the same per-IP budget as the password login. if !s.allowed(r, loginLimiter, true) { jsonError(w, http.StatusTooManyRequests, "Too many sign-in attempts. Please try again later.") return } wa, err := s.webAuthn() if err != nil { jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured") return } // No allowCredentials: the user picks a discoverable credential, so the // sign-in page needs no username. assertion, session, err := wa.BeginDiscoverableLogin( webauthn.WithUserVerification(protocol.VerificationRequired), ) if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return } challenges.put("login:"+session.Challenge, *session) writeJSON(w, http.StatusOK, assertion.Response) } func (s *Server) passkeyLoginVerify(w http.ResponseWriter, r *http.Request) { wa, err := s.webAuthn() if err != nil { jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured") return } parsed, err := protocol.ParseCredentialRequestResponseBody(r.Body) if err != nil { jsonError(w, http.StatusBadRequest, "Missing credential") return } // Look the challenge up by the value inside the signed client data, so // concurrent sign-ins each find their own ceremony. session, ok := challenges.peek("login:" + parsed.Response.CollectedClientData.Challenge) if !ok { jsonError(w, http.StatusBadRequest, "No challenge") return } row, err := s.DB.PasskeyByCredentialID(r.Context(), encodeCredentialID(parsed.RawID)) if err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } if row == nil { jsonError(w, http.StatusBadRequest, "Unknown credential") return } credential, err := credentialFromRow(row.Passkey) if err != nil { jsonError(w, http.StatusBadRequest, "Stored credential is unreadable") return } // The rows written by @simplewebauthn/server carry no backup flags, so // take them from this response. Without that the library's // "flag changed" check rejects every synced passkey. credential.Flags = webauthn.NewCredentialFlags(parsed.Response.AuthenticatorData.Flags) user := &webAuthnUser{ id: row.UserID, username: row.Username, credentials: []webauthn.Credential{credential}, } // The discoverable ceremony leaves the session user empty. Bind it to the // owner we just looked up so ValidateLogin can check the user handle. session.UserID = user.WebAuthnID() verified, err := wa.ValidateLogin(user, session, parsed) if err != nil { jsonError(w, http.StatusUnauthorized, err.Error()) return } if verified.Authenticator.CloneWarning { jsonError(w, http.StatusUnauthorized, "Credential replay detected") return } // Advance the counter with the old value as a guard. A concurrent request // that already advanced it leaves 0 rows updated, which is a replay. updated, err := s.DB.UpdatePasskeyCounter(r.Context(), row.ID, row.Counter, int64(verified.Authenticator.SignCount)) if err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } if !updated { jsonError(w, http.StatusUnauthorized, "Credential replay detected") return } // Same rule as the password login: an account waiting for approval gets // no session. if row.IsPending { jsonError(w, http.StatusForbidden, "Your account is awaiting approval.") return } challenges.take("login:" + parsed.Response.CollectedClientData.Challenge) cookie, err := s.newSession(r.Context(), row.UserID) if err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } http.SetCookie(w, cookie) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }