1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/hex"
11 "errors"
12 "fmt"
13 "sync"
14 "time"
15
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Owner passkeys (ADR 0026). A ceremony is a registration or a login that the
23// browser carries out between two requests; its data lives in memory only.
24const (
25 // MaxPendingPasskeyRegistrations bounds the registrations that wait to be
26 // finished. Only the owner starts one.
27 MaxPendingPasskeyRegistrations = 16
28 // MaxPendingPasskeyLogins bounds the logins that wait to be finished.
29 // Anyone may start one, so a login is never refused: on overflow one is
30 // dropped instead, and anonymous callers cannot lock the owner out.
31 MaxPendingPasskeyLogins = 256
32 // MaxPendingPasskeyLoginsPerClient bounds the logins one client (one client
33 // key) may have waiting. A client over it loses its own oldest.
34 MaxPendingPasskeyLoginsPerClient = 8
35
36 // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.
37 ceremonyIDBytes = 32
38)
39
40type ceremonyKind uint8
41
42const (
43 ceremonyRegister ceremonyKind = iota + 1
44 ceremonyLogin
45)
46
47// ceremony is a begun registration or login.
48type ceremony struct {
49 kind ceremonyKind
50 data []byte // opaque, from the verifier
51 expires time.Time
52 seq uint64 // creation order
53 client string // who began a login (its client key); "" for a registration
54 name domain.PasskeyName // what a registration will call the passkey
55}
56
57// Passkeys are the use cases of the owner's passkeys: registering, signing in,
58// listing and removing them.
59type Passkeys struct {
60 store ports.PasskeyStore
61 verifier ports.PasskeyVerifier
62 sessions *Sessions
63 clock ports.Clock
64
65 // writeMu serializes the writes to the store (Create, Update, Delete) and
66 // the session they make or void, so that a removed passkey cannot sign in.
67 writeMu sync.Mutex
68
69 mu sync.Mutex
70 ceremonies map[[sha256.Size]byte]ceremony // by hash of the identifier
71 seq uint64
72}
73
74var _ ports.PasskeyService = (*Passkeys)(nil)
75
76// NewPasskeys builds the passkey use cases. Sessions are issued and voided
77// through sessions.
78func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessions *Sessions, clock ports.Clock) *Passkeys {
79 return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}
80}
81
82// pendingLogins is what begin knows of the logins that wait, per client.
83type pendingLogins struct {
84 count int
85 oldest [sha256.Size]byte
86 oldestSeq uint64 // 0 until a login is added: sequence numbers start at 1
87}
88
89func (p *pendingLogins) add(h [sha256.Size]byte, seq uint64) {
90 p.count++
91 if p.oldestSeq == 0 || seq < p.oldestSeq {
92 p.oldest, p.oldestSeq = h, seq
93 }
94}
95
96// begin keeps a ceremony and returns its identifier. It sweeps the expired
97// ones first. A registration over the limit gives domain.ErrResourceExhausted.
98// A login is never refused, but it can cost another login its place: over the
99// per-client cap it drops that client's own oldest; over the global cap it drops
100// the oldest of the client with the most logins waiting. A client that floods
101// therefore only ever loses its own ceremonies, never another client's.
102func (s *Passkeys) begin(c ceremony) (string, error) {
103 var raw [ceremonyIDBytes]byte
104 if _, err := rand.Read(raw[:]); err != nil {
105 return "", fmt.Errorf("generate passkey ceremony: %w", err)
106 }
107 id := base64.RawURLEncoding.EncodeToString(raw[:])
108 now := s.clock.Now()
109
110 s.mu.Lock()
111 defer s.mu.Unlock()
112 registrations := 0
113 logins := 0
114 byClient := map[string]*pendingLogins{}
115 for h, c := range s.ceremonies {
116 if !now.Before(c.expires) {
117 delete(s.ceremonies, h)
118 continue
119 }
120 switch c.kind {
121 case ceremonyRegister:
122 registrations++
123 case ceremonyLogin:
124 logins++
125 pl := byClient[c.client]
126 if pl == nil {
127 pl = &pendingLogins{}
128 byClient[c.client] = pl
129 }
130 pl.add(h, c.seq)
131 }
132 }
133 switch c.kind {
134 case ceremonyRegister:
135 if registrations >= MaxPendingPasskeyRegistrations {
136 return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)
137 }
138 case ceremonyLogin:
139 if pl := byClient[c.client]; pl != nil && pl.count >= MaxPendingPasskeyLoginsPerClient {
140 delete(s.ceremonies, pl.oldest)
141 logins--
142 // The client is back under its cap; the global step below needs
143 // its new count and its new oldest.
144 pl.count--
145 s.refreshOldest(pl, c.client)
146 }
147 if logins >= MaxPendingPasskeyLogins {
148 // The client with the most waiting loses its oldest; among equals,
149 // the one whose oldest waited longest.
150 var victim *pendingLogins
151 for _, pl := range byClient {
152 if pl.count == 0 {
153 continue
154 }
155 if victim == nil || pl.count > victim.count || (pl.count == victim.count && pl.oldestSeq < victim.oldestSeq) {
156 victim = pl
157 }
158 }
159 if victim != nil {
160 delete(s.ceremonies, victim.oldest)
161 }
162 }
163 }
164 s.seq++
165 c.expires, c.seq = now.Add(domain.PasskeyCeremonyTTL), s.seq
166 s.ceremonies[sha256.Sum256([]byte(id))] = c
167 return id, nil
168}
169
170// refreshOldest recomputes the oldest login of a client after one was dropped.
171// The caller holds s.mu.
172func (s *Passkeys) refreshOldest(pl *pendingLogins, client string) {
173 pl.oldestSeq = 0
174 for h, c := range s.ceremonies {
175 if c.kind != ceremonyLogin || c.client != client {
176 continue
177 }
178 if pl.oldestSeq == 0 || c.seq < pl.oldestSeq {
179 pl.oldest, pl.oldestSeq = h, c.seq
180 }
181 }
182}
183
184// take removes the ceremony and returns it. It is single use, also when the
185// ceremony expired or is of another kind. ok is false for an unknown, expired or
186// mismatched ceremony.
187func (s *Passkeys) take(id string, kind ceremonyKind) (c ceremony, ok bool) {
188 h := sha256.Sum256([]byte(id))
189 s.mu.Lock()
190 c, found := s.ceremonies[h]
191 delete(s.ceremonies, h)
192 s.mu.Unlock()
193 if !found || c.kind != kind || !s.clock.Now().Before(c.expires) {
194 return ceremony{}, false
195 }
196 return c, true
197}
198
199// BeginRegistration starts adding a passkey. Only the owner may. name is
200// checked now (an empty name means the default), so that a name the owner would
201// be prompted for and then see refused costs no ceremony; the ceremony keeps the
202// checked name and FinishRegistration stores it. It gives domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too
203// many registrations wait. It returns the ceremony identifier and the options
204// for the browser.
205func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
206 if err := requireOwner(p); err != nil {
207 return "", nil, err
208 }
209 parsed, err := parsePasskeyName(name)
210 if err != nil {
211 return "", nil, err
212 }
213 existing, err := s.store.List(ctx)
214 if err != nil {
215 return "", nil, fmt.Errorf("list passkeys: %w", err)
216 }
217 if len(existing) >= domain.MaxPasskeys {
218 return "", nil, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
219 }
220 handle, err := s.store.UserHandle(ctx)
221 if err != nil {
222 return "", nil, fmt.Errorf("load passkey user handle: %w", err)
223 }
224 options, data, err := s.verifier.BeginRegistration(handle, existing)
225 if err != nil {
226 return "", nil, err
227 }
228 id, err := s.begin(ceremony{kind: ceremonyRegister, data: data, name: parsed})
229 if err != nil {
230 return "", nil, err
231 }
232 return id, options, nil
233}
234
235// FinishRegistration checks the browser's response and stores the passkey under
236// the name BeginRegistration checked. A ceremony that is unknown, expired, used
237// or of the wrong kind, and a response the verifier refuses, give
238// domain.ErrUnauthenticated; a credential already registered gives
239// domain.ErrAlreadyExists; too many passkeys, domain.ErrResourceExhausted.
240func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, ceremonyID string, response []byte) error {
241 if err := requireOwner(p); err != nil {
242 return err
243 }
244 log := zerowrap.FromCtx(ctx)
245 c, ok := s.take(ceremonyID, ceremonyRegister)
246 if !ok {
247 err := fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
248 log.Info().Err(err).Msg("passkey registration refused")
249 return err
250 }
251 handle, err := s.store.UserHandle(ctx)
252 if err != nil {
253 return fmt.Errorf("load passkey user handle: %w", err)
254 }
255 pk, err := s.verifier.FinishRegistration(handle, c.data, response)
256 if err != nil {
257 if errors.Is(err, domain.ErrUnauthenticated) {
258 // The reason is for the log (it holds no key); the caller gets a 400.
259 log.Info().Err(err).Msg("passkey registration refused")
260 }
261 return err
262 }
263 if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {
264 return fmt.Errorf("%w: credential id has %d bytes", domain.ErrUnauthenticated, n)
265 }
266 pk.Name = c.name.String()
267 pk.CreatedAt = s.clock.Now().UTC()
268 pk.LastUsedAt = time.Time{}
269
270 s.writeMu.Lock()
271 defer s.writeMu.Unlock()
272 existing, err := s.store.List(ctx)
273 if err != nil {
274 return fmt.Errorf("list passkeys: %w", err)
275 }
276 if len(existing) >= domain.MaxPasskeys {
277 return fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
278 }
279 if err := s.store.Create(ctx, pk); err != nil {
280 return err
281 }
282 log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")
283 return nil
284}
285
286// BeginLogin starts a passkey sign-in. Anonymous callers may. client is the
287// client key of the caller (clientlimit.Key of the address the failure limiter
288// uses); it bounds how many sign-ins one client keeps waiting. It returns the
289// ceremony identifier and the options for the browser.
290func (s *Passkeys) BeginLogin(_ context.Context, client string) (string, []byte, error) {
291 options, data, err := s.verifier.BeginLogin()
292 if err != nil {
293 return "", nil, err
294 }
295 id, err := s.begin(ceremony{kind: ceremonyLogin, data: data, client: client})
296 if err != nil {
297 return "", nil, err
298 }
299 return id, options, nil
300}
301
302// FinishLogin checks the browser's response and opens the owner's session,
303// like a login link does. Whatever the reason of a refusal (unknown or
304// expired ceremony, unknown passkey, bad signature, missing user verification,
305// cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the
306// store is returned as it is, so that the caller fails closed.
307func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {
308 log := zerowrap.FromCtx(ctx)
309 c, ok := s.take(ceremonyID, ceremonyLogin)
310 if !ok {
311 log.Info().Msg("passkey sign-in refused: the ceremony expired or is unknown")
312 return "", time.Time{}, domain.ErrUnauthenticated
313 }
314 handle, err := s.store.UserHandle(ctx)
315 if err != nil {
316 return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)
317 }
318 pk, err := s.verifier.FinishLogin(handle, c.data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) })
319 if err != nil {
320 if errors.Is(err, domain.ErrUnauthenticated) {
321 // The reason is for the log (it holds no key); the caller learns nothing.
322 log.Info().Err(err).Msg("passkey sign-in refused")
323 return "", time.Time{}, domain.ErrUnauthenticated
324 }
325 return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)
326 }
327
328 // Recording the use and opening the session are one step with respect to
329 // Remove and to a concurrent login, so that a passkey removed meanwhile
330 // cannot sign in, nor two logins with the same counter.
331 s.writeMu.Lock()
332 defer s.writeMu.Unlock()
333 cur, err := s.store.Get(ctx, pk.ID)
334 if err != nil {
335 if errors.Is(err, domain.ErrNotFound) {
336 log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the passkey was removed meanwhile")
337 return "", time.Time{}, domain.ErrUnauthenticated
338 }
339 return "", time.Time{}, fmt.Errorf("read passkey: %w", err)
340 }
341 // The one sign-count check. The verifier returns the counter the response
342 // reports (the stored one when it does not advance), and another login may
343 // have moved the stored one since it looked. A counter that does not advance
344 // is the clone signal; synced passkeys report 0 and are exempt.
345 if cur.SignCount != 0 && pk.SignCount <= cur.SignCount {
346 log.Warn().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
347 return "", time.Time{}, domain.ErrUnauthenticated
348 }
349 pk.LastUsedAt = s.clock.Now().UTC()
350 if err := s.store.Update(ctx, pk); err != nil {
351 if errors.Is(err, domain.ErrNotFound) {
352 return "", time.Time{}, domain.ErrUnauthenticated
353 }
354 return "", time.Time{}, fmt.Errorf("record passkey use: %w", err)
355 }
356 token, expiresAt, err = s.sessions.issueOwner(ctx)
357 if err != nil {
358 return "", time.Time{}, err
359 }
360 log.Info().Str("passkey", passkeyRef(pk.ID)).Time("expires_at", expiresAt).Msg("owner signed in with a passkey")
361 return token, expiresAt, nil
362}
363
364// List returns the passkeys, oldest first. Only the owner may.
365func (s *Passkeys) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
366 if err := requireOwner(p); err != nil {
367 return nil, err
368 }
369 return s.store.List(ctx)
370}
371
372// Remove deletes a passkey and signs every browser out, like revoking a token
373// with owner rights does. Only the owner may. An unknown id gives
374// domain.ErrNotFound.
375func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) error {
376 if err := requireOwner(p); err != nil {
377 return err
378 }
379 s.writeMu.Lock()
380 defer s.writeMu.Unlock()
381 if err := s.store.Delete(ctx, id); err != nil {
382 return err
383 }
384 log := zerowrap.FromCtx(ctx)
385 log.Info().Str("passkey", passkeyRef(id)).Str("removed_by", p.Label()).Str("removed_by_token", p.TokenID()).Msg("passkey removed")
386 epoch, err := s.sessions.bumpEpoch(ctx)
387 if err != nil {
388 return fmt.Errorf("passkey %s removed, but signing out the web sessions failed: %w", passkeyRef(id), err)
389 }
390 log.Info().Str("passkey", passkeyRef(id)).Uint64("epoch", epoch).Msg("every web session signed out: a passkey was removed")
391 return nil
392}
393
394// parsePasskeyName validates a name; an empty one is domain.DefaultPasskeyName.
395func parsePasskeyName(name string) (domain.PasskeyName, error) {
396 if name == "" {
397 name = domain.DefaultPasskeyName
398 }
399 parsed, err := domain.ParsePasskeyName(name)
400 if err != nil {
401 return domain.PasskeyName{}, fmt.Errorf("passkey name: %w", err)
402 }
403 return parsed, nil
404}
405
406// passkeyRef is how logs name a passkey: the first 8 hex characters of the
407// SHA-256 of its credential id.
408func passkeyRef(id []byte) string {
409 sum := sha256.Sum256(id)
410 return hex.EncodeToString(sum[:])[:8]
411}