v0.33.0 cf26365

passkeys.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close