webauthn.go
⎇
Raw
1package web
2
3import (
4 "encoding/base64"
5 "net/http"
6 "net/url"
7 "strconv"
8 "sync"
9 "time"
10
11 "github.com/go-webauthn/webauthn/protocol"
12 "github.com/go-webauthn/webauthn/webauthn"
13
14 "hearthforge/internal/db"
15)
16
17const challengeTTL = 5 * time.Minute
18
19// Credential encoding in the passkeys table. Existing rows were written by
20// @simplewebauthn/server and use exactly these formats:
21// - credential_id: base64url without padding of the raw credential ID.
22// - public_key: standard base64 with padding of the COSE public key.
23// - counter: the signature counter as an integer.
24//
25// decodeCredentialID and encodeCredentialID wrap that so the choice lives in
26// one place.
27func encodeCredentialID(id []byte) string { return base64.RawURLEncoding.EncodeToString(id) }
28
29func decodeCredentialID(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
30
31// challengeStore keeps in-flight ceremonies in memory. A single process owns
32// them.
33//
34// ponytail: in-memory map, move to the database if the server ever runs more
35// than one process.
36type challengeStore struct {
37 mu sync.Mutex
38 entries map[string]challengeEntry
39}
40
41type challengeEntry struct {
42 session webauthn.SessionData
43 expires time.Time
44}
45
46var challenges = challengeStore{entries: map[string]challengeEntry{}}
47
48// maxChallenges caps the in-flight ceremonies. Login options are
49// unauthenticated, so without a cap an attacker can grow memory without bound.
50const maxChallenges = 10000
51
52// put stores a session and drops every expired entry. When full it evicts
53// the oldest entry instead of refusing new ceremonies.
54func (c *challengeStore) put(key string, session webauthn.SessionData) {
55 c.mu.Lock()
56 defer c.mu.Unlock()
57 now := time.Now()
58 oldest := ""
59 for k, e := range c.entries {
60 if now.After(e.expires) {
61 delete(c.entries, k)
62 } else if oldest == "" || e.expires.Before(c.entries[oldest].expires) {
63 oldest = k
64 }
65 }
66 if _, replacing := c.entries[key]; !replacing && len(c.entries) >= maxChallenges {
67 delete(c.entries, oldest)
68 }
69 c.entries[key] = challengeEntry{session: session, expires: now.Add(challengeTTL)}
70}
71
72// take returns a session and removes it, so a challenge is used once.
73func (c *challengeStore) take(key string) (webauthn.SessionData, bool) {
74 c.mu.Lock()
75 defer c.mu.Unlock()
76 e, ok := c.entries[key]
77 if !ok || time.Now().After(e.expires) {
78 delete(c.entries, key)
79 return webauthn.SessionData{}, false
80 }
81 delete(c.entries, key)
82 return e.session, true
83}
84
85// peek returns a session without removing it, for the steps that may still
86// fail and should let the user retry.
87func (c *challengeStore) peek(key string) (webauthn.SessionData, bool) {
88 c.mu.Lock()
89 defer c.mu.Unlock()
90 e, ok := c.entries[key]
91 if !ok || time.Now().After(e.expires) {
92 return webauthn.SessionData{}, false
93 }
94 return e.session, true
95}
96
97// webAuthnUser adapts a user row plus its passkeys to the library's interface.
98type webAuthnUser struct {
99 id int64
100 username string
101 credentials []webauthn.Credential
102}
103
104// WebAuthnID is the decimal user id as ASCII bytes. Existing credentials
105// carry that user handle, so the format must not change.
106func (u *webAuthnUser) WebAuthnID() []byte { return []byte(strconv.FormatInt(u.id, 10)) }
107
108func (u *webAuthnUser) WebAuthnName() string { return u.username }
109func (u *webAuthnUser) WebAuthnDisplayName() string { return u.username }
110
111func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
112
113// webAuthn builds the relying party. The RP ID and origin come from BASE_URL,
114// never from the request, so the origin check cannot validate a value against
115// itself.
116func (s *Server) webAuthn() (*webauthn.WebAuthn, error) {
117 u, err := url.Parse(s.Cfg.PublicOrigin)
118 if err != nil {
119 return nil, err
120 }
121 return webauthn.New(&webauthn.Config{
122 RPID: u.Hostname(),
123 RPDisplayName: s.Cfg.OwnerDisplayName + "'s Hearthforge",
124 RPOrigins: []string{s.Cfg.PublicOrigin},
125 })
126}
127
128// credentialFromRow maps a stored passkey to a library credential.
129// Only the fields the login check reads are stored, so the rest stay zero.
130func credentialFromRow(p db.Passkey) (webauthn.Credential, error) {
131 id, err := decodeCredentialID(p.CredentialID)
132 if err != nil {
133 return webauthn.Credential{}, err
134 }
135 key, err := base64.StdEncoding.DecodeString(p.PublicKey)
136 if err != nil {
137 return webauthn.Credential{}, err
138 }
139 return webauthn.Credential{
140 ID: id,
141 PublicKey: key,
142 Authenticator: webauthn.Authenticator{SignCount: uint32(p.Counter)},
143 }, nil
144}
145
146// registrationUser resolves the account the passkey ceremony belongs to.
147// It accepts an account that is still pending approval, because in queue mode
148// create-user leaves the new account pending and the ceremony runs next.
149func (s *Server) registrationUser(r *http.Request) *db.SessionUser {
150 if u := User(r); u != nil {
151 return u
152 }
153 c, err := r.Cookie(sessionCookie)
154 if err != nil || c.Value == "" {
155 return nil
156 }
157 u, err := s.DB.SessionUserAllowPending(r.Context(), c.Value, db.NowISO())
158 if err != nil {
159 return nil
160 }
161 return u
162}
163
164func (s *Server) passkeyRegisterOptions(w http.ResponseWriter, r *http.Request) {
165 su := s.registrationUser(r)
166 if su == nil {
167 jsonError(w, http.StatusUnauthorized, "Not authenticated")
168 return
169 }
170 if !recentLogin(su) {
171 writeJSON(w, http.StatusForbidden, map[string]string{
172 "error": "Please sign in again to add a passkey.",
173 "reauth": reauthURL("/settings"),
174 })
175 return
176 }
177 wa, err := s.webAuthn()
178 if err != nil {
179 jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured")
180 return
181 }
182 rows, err := s.DB.ListPasskeys(r.Context(), su.ID)
183 if err != nil {
184 jsonError(w, http.StatusInternalServerError, "Database error")
185 return
186 }
187 exclude := make([]protocol.CredentialDescriptor, 0, len(rows))
188 for _, p := range rows {
189 id, err := decodeCredentialID(p.CredentialID)
190 if err != nil {
191 continue
192 }
193 exclude = append(exclude, protocol.CredentialDescriptor{
194 Type: protocol.PublicKeyCredentialType,
195 CredentialID: id,
196 })
197 }
198
199 user := &webAuthnUser{id: su.ID, username: su.Username}
200 creation, session, err := wa.BeginRegistration(user,
201 webauthn.WithExclusions(exclude),
202 webauthn.WithConveyancePreference(protocol.PreferNoAttestation),
203 webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
204 ResidentKey: protocol.ResidentKeyRequirementPreferred,
205 UserVerification: protocol.VerificationRequired,
206 }),
207 )
208 if err != nil {
209 jsonError(w, http.StatusInternalServerError, err.Error())
210 return
211 }
212
213 challenges.put("reg:"+su.Username, *session)
214 // @simplewebauthn/browser expects the bare creation options, not the
215 // { publicKey: ... } wrapper the library's top-level type marshals to.
216 writeJSON(w, http.StatusOK, creation.Response)
217}
218
219func (s *Server) passkeyRegisterVerify(w http.ResponseWriter, r *http.Request) {
220 su := s.registrationUser(r)
221 if su == nil {
222 jsonError(w, http.StatusUnauthorized, "Not authenticated")
223 return
224 }
225 session, ok := challenges.peek("reg:" + su.Username)
226 if !ok {
227 jsonError(w, http.StatusBadRequest, "No challenge")
228 return
229 }
230 wa, err := s.webAuthn()
231 if err != nil {
232 jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured")
233 return
234 }
235 parsed, err := protocol.ParseCredentialCreationResponseBody(r.Body)
236 if err != nil {
237 jsonError(w, http.StatusBadRequest, err.Error())
238 return
239 }
240 user := &webAuthnUser{id: su.ID, username: su.Username}
241 credential, err := wa.CreateCredential(user, session, parsed)
242 if err != nil {
243 jsonError(w, http.StatusBadRequest, err.Error())
244 return
245 }
246
247 err = s.DB.CreatePasskey(r.Context(), su.ID,
248 encodeCredentialID(credential.ID),
249 base64.StdEncoding.EncodeToString(credential.PublicKey),
250 int64(credential.Authenticator.SignCount), db.NowISO())
251 if err != nil {
252 jsonError(w, http.StatusBadRequest, "Could not store the passkey")
253 return
254 }
255 // The account now has a credential, so it is no longer provisional.
256 if err := s.DB.SetPasskeySetupStarted(r.Context(), su.ID, ""); err != nil {
257 jsonError(w, http.StatusInternalServerError, "Database error")
258 return
259 }
260 challenges.take("reg:" + su.Username)
261 if err := s.dropOtherSessions(r, su.ID); err != nil {
262 jsonError(w, http.StatusInternalServerError, "Database error")
263 return
264 }
265 writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
266}
267
268func (s *Server) passkeyLoginOptions(w http.ResponseWriter, r *http.Request) {
269 // Unauthenticated endpoint: it allocates a challenge per call, so it uses
270 // the same per-IP budget as the password login.
271 if !s.allowed(r, loginLimiter, true) {
272 jsonError(w, http.StatusTooManyRequests, "Too many sign-in attempts. Please try again later.")
273 return
274 }
275 wa, err := s.webAuthn()
276 if err != nil {
277 jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured")
278 return
279 }
280 // No allowCredentials: the user picks a discoverable credential, so the
281 // sign-in page needs no username.
282 assertion, session, err := wa.BeginDiscoverableLogin(
283 webauthn.WithUserVerification(protocol.VerificationRequired))
284 if err != nil {
285 jsonError(w, http.StatusInternalServerError, err.Error())
286 return
287 }
288 challenges.put("login:"+session.Challenge, *session)
289 writeJSON(w, http.StatusOK, assertion.Response)
290}
291
292func (s *Server) passkeyLoginVerify(w http.ResponseWriter, r *http.Request) {
293 wa, err := s.webAuthn()
294 if err != nil {
295 jsonError(w, http.StatusInternalServerError, "WebAuthn is misconfigured")
296 return
297 }
298 parsed, err := protocol.ParseCredentialRequestResponseBody(r.Body)
299 if err != nil {
300 jsonError(w, http.StatusBadRequest, "Missing credential")
301 return
302 }
303 // Look the challenge up by the value inside the signed client data, so
304 // concurrent sign-ins each find their own ceremony.
305 session, ok := challenges.peek("login:" + parsed.Response.CollectedClientData.Challenge)
306 if !ok {
307 jsonError(w, http.StatusBadRequest, "No challenge")
308 return
309 }
310
311 row, err := s.DB.PasskeyByCredentialID(r.Context(), encodeCredentialID(parsed.RawID))
312 if err != nil {
313 jsonError(w, http.StatusInternalServerError, "Database error")
314 return
315 }
316 if row == nil {
317 jsonError(w, http.StatusBadRequest, "Unknown credential")
318 return
319 }
320 credential, err := credentialFromRow(row.Passkey)
321 if err != nil {
322 jsonError(w, http.StatusBadRequest, "Stored credential is unreadable")
323 return
324 }
325 // The rows written by @simplewebauthn/server carry no backup flags, so
326 // take them from this response. Without that the library's
327 // "flag changed" check rejects every synced passkey.
328 credential.Flags = webauthn.NewCredentialFlags(parsed.Response.AuthenticatorData.Flags)
329
330 user := &webAuthnUser{
331 id: row.UserID,
332 username: row.Username,
333 credentials: []webauthn.Credential{credential},
334 }
335 // The discoverable ceremony leaves the session user empty. Bind it to the
336 // owner we just looked up so ValidateLogin can check the user handle.
337 session.UserID = user.WebAuthnID()
338
339 verified, err := wa.ValidateLogin(user, session, parsed)
340 if err != nil {
341 jsonError(w, http.StatusUnauthorized, err.Error())
342 return
343 }
344 if verified.Authenticator.CloneWarning {
345 jsonError(w, http.StatusUnauthorized, "Credential replay detected")
346 return
347 }
348
349 // Advance the counter with the old value as a guard. A concurrent request
350 // that already advanced it leaves 0 rows updated, which is a replay.
351 updated, err := s.DB.UpdatePasskeyCounter(r.Context(), row.ID,
352 row.Counter, int64(verified.Authenticator.SignCount))
353 if err != nil {
354 jsonError(w, http.StatusInternalServerError, "Database error")
355 return
356 }
357 if !updated {
358 jsonError(w, http.StatusUnauthorized, "Credential replay detected")
359 return
360 }
361
362 // Same rule as the password login: an account waiting for approval gets
363 // no session.
364 if row.IsPending {
365 jsonError(w, http.StatusForbidden, "Your account is awaiting approval.")
366 return
367 }
368
369 challenges.take("login:" + parsed.Response.CollectedClientData.Challenge)
370 cookie, err := s.newSession(r.Context(), row.UserID)
371 if err != nil {
372 jsonError(w, http.StatusInternalServerError, "Database error")
373 return
374 }
375 http.SetCookie(w, cookie)
376 writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
377}
378