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