v0.22.0 4c072b1

passkeys.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6	"bytes"
7	"context"
8	"crypto/rand"
9	"encoding/base64"
10	"errors"
11	"fmt"
12	"slices"
13	"unicode/utf8"
14
15	kv "bnema.dev/ermite-kv"
16	"github.com/bnema/zerowrap"
17	"google.golang.org/protobuf/proto"
18
19	storagepb "bnema.dev/ermite/gen/ermite/storage"
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22)
23
24const (
25	passkeyUserKey    = "passkey/user"
26	passkeyCredPrefix = "passkey/cred/"
27
28	// passkeyHandleLen is the size of the WebAuthn user handle, in bytes.
29	passkeyHandleLen = 32
30)
31
32// Passkeys is the ermite-kv backed ports.PasskeyStore. It shares the database
33// of the Store it comes from.
34type Passkeys struct {
35	db *kv.DB
36}
37
38var _ ports.PasskeyStore = (*Passkeys)(nil)
39
40// Passkeys returns the passkey store over the same database. It is valid until
41// the Store is closed.
42func (s *Store) Passkeys() *Passkeys { return &Passkeys{db: s.db} }
43
44func passkeyKey(id []byte) []byte {
45	return []byte(passkeyCredPrefix + base64.RawURLEncoding.EncodeToString(id))
46}
47
48// validPasskeyID reports whether id can be a credential id.
49func validPasskeyID(id []byte) bool { return len(id) > 0 && len(id) <= domain.MaxPasskeyIDLen }
50
51// UserHandle reads passkey/user and generates it when absent. The handle is
52// stable for the life of the store.
53func (s *Passkeys) UserHandle(ctx context.Context) ([]byte, error) {
54	for range updateAttempts {
55		if item, ok := s.db.Get(ctx, []byte(passkeyUserKey)); ok {
56			var m storagepb.PasskeyUser
57			if err := proto.Unmarshal(item.Value, &m); err != nil {
58				return nil, fmt.Errorf("decode passkey user: %w", err)
59			}
60			if len(m.GetHandle()) != passkeyHandleLen {
61				return nil, fmt.Errorf("decode passkey user: %d bytes, want %d", len(m.GetHandle()), passkeyHandleLen)
62			}
63			return bytes.Clone(m.GetHandle()), nil
64		}
65		handle := make([]byte, passkeyHandleLen)
66		if _, err := rand.Read(handle); err != nil {
67			return nil, fmt.Errorf("generate passkey user handle: %w", err)
68		}
69		var b kv.Batch
70		b.RequireAbsent([]byte(passkeyUserKey))
71		b.Put([]byte(passkeyUserKey), mustMarshal(&storagepb.PasskeyUser{Handle: handle}))
72		_, err := s.db.Apply(ctx, &b)
73		if errors.Is(err, kv.ErrConflict) {
74			continue // written meanwhile: read it
75		}
76		if err != nil {
77			return nil, fmt.Errorf("store passkey user handle: %w", err)
78		}
79		log := zerowrap.FromCtx(ctx)
80		log.Info().Msg("passkey user handle generated")
81		return handle, nil
82	}
83	return nil, fmt.Errorf("store passkey user handle: %w", domain.ErrConflict)
84}
85
86// List returns the passkeys ordered by creation time, then id.
87func (s *Passkeys) List(ctx context.Context) ([]domain.Passkey, error) {
88	var out []domain.Passkey
89	for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(passkeyCredPrefix)) {
90		p, err := decodePasskey(item.Value)
91		if err != nil {
92			return nil, fmt.Errorf("decode %s: %w", key, err)
93		}
94		out = append(out, p)
95	}
96	slices.SortStableFunc(out, func(a, b domain.Passkey) int {
97		if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
98			return c
99		}
100		return bytes.Compare(a.ID, b.ID)
101	})
102	return out, nil
103}
104
105// Get reads passkey/cred/<id>.
106func (s *Passkeys) Get(ctx context.Context, id []byte) (domain.Passkey, error) {
107	if !validPasskeyID(id) {
108		return domain.Passkey{}, domain.ErrNotFound
109	}
110	item, ok := s.db.Get(ctx, passkeyKey(id))
111	if !ok {
112		return domain.Passkey{}, domain.ErrNotFound
113	}
114	return decodePasskey(item.Value)
115}
116
117// Create stores passkey/cred/<id> with an "absent" precondition: a taken id
118// yields domain.ErrAlreadyExists. The caller bounds the number of passkeys.
119func (s *Passkeys) Create(ctx context.Context, p domain.Passkey) error {
120	val, err := encodePasskey(p)
121	if err != nil {
122		return err
123	}
124	var b kv.Batch
125	b.RequireAbsent(passkeyKey(p.ID))
126	b.Put(passkeyKey(p.ID), val)
127	if _, err := s.db.Apply(ctx, &b); err != nil {
128		if errors.Is(err, kv.ErrConflict) {
129			return domain.ErrAlreadyExists
130		}
131		return fmt.Errorf("create passkey: %w", err)
132	}
133	return nil
134}
135
136// Update replaces a stored passkey, or returns domain.ErrNotFound.
137//
138//hexcheck:ignore suspicious-business-logic-in-adapter version-checked replace, not a policy
139func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error {
140	val, err := encodePasskey(p)
141	if err != nil {
142		return err
143	}
144	for range updateAttempts {
145		item, ok := s.db.Get(ctx, passkeyKey(p.ID))
146		if !ok {
147			return domain.ErrNotFound
148		}
149		var b kv.Batch
150		b.RequireVersion(passkeyKey(p.ID), item.Version)
151		b.Put(passkeyKey(p.ID), val)
152		_, err := s.db.Apply(ctx, &b)
153		if errors.Is(err, kv.ErrConflict) {
154			continue // changed or removed meanwhile: read again
155		}
156		if err != nil {
157			return fmt.Errorf("update passkey: %w", err)
158		}
159		return nil
160	}
161	return fmt.Errorf("update passkey: %w", domain.ErrConflict)
162}
163
164// Delete removes passkey/cred/<id>, or returns domain.ErrNotFound.
165func (s *Passkeys) Delete(ctx context.Context, id []byte) error {
166	if !validPasskeyID(id) {
167		return domain.ErrNotFound
168	}
169	for range updateAttempts {
170		item, ok := s.db.Get(ctx, passkeyKey(id))
171		if !ok {
172			return domain.ErrNotFound
173		}
174		var b kv.Batch
175		b.RequireVersion(passkeyKey(id), item.Version)
176		b.Delete(passkeyKey(id))
177		_, err := s.db.Apply(ctx, &b)
178		if errors.Is(err, kv.ErrConflict) {
179			continue
180		}
181		if err != nil {
182			return fmt.Errorf("delete passkey: %w", err)
183		}
184		return nil
185	}
186	return fmt.Errorf("delete passkey: %w", domain.ErrConflict)
187}
188
189func encodePasskey(p domain.Passkey) ([]byte, error) {
190	if !validPasskeyID(p.ID) {
191		return nil, fmt.Errorf("%w: passkey id has %d bytes", domain.ErrInvalid, len(p.ID))
192	}
193	if !utf8.ValidString(p.Name) || !utf8.ValidString(p.AttestationType) {
194		return nil, fmt.Errorf("%w: passkey text is not valid UTF-8", domain.ErrInvalid)
195	}
196	for _, t := range p.Transports {
197		if !utf8.ValidString(t) {
198			return nil, fmt.Errorf("%w: passkey transport is not valid UTF-8", domain.ErrInvalid)
199		}
200	}
201	return mustMarshal(&storagepb.Passkey{
202		Id:              p.ID,
203		Name:            p.Name,
204		PublicKey:       p.PublicKey,
205		AttestationType: p.AttestationType,
206		Transports:      p.Transports,
207		SignCount:       p.SignCount,
208		BackupEligible:  p.BackupEligible,
209		BackupState:     p.BackupState,
210		CreatedAt:       encodeTime(p.CreatedAt),
211		LastUsedAt:      encodeTime(p.LastUsedAt),
212	}), nil
213}
214
215func decodePasskey(val []byte) (domain.Passkey, error) {
216	var pb storagepb.Passkey
217	if err := proto.Unmarshal(val, &pb); err != nil {
218		return domain.Passkey{}, fmt.Errorf("decode passkey: %w", err)
219	}
220	if !validPasskeyID(pb.GetId()) {
221		return domain.Passkey{}, fmt.Errorf("decode passkey: id has %d bytes", len(pb.GetId()))
222	}
223	return domain.Passkey{
224		ID:              bytes.Clone(pb.GetId()),
225		Name:            pb.GetName(),
226		PublicKey:       bytes.Clone(pb.GetPublicKey()),
227		AttestationType: pb.GetAttestationType(),
228		Transports:      slices.Clone(pb.GetTransports()),
229		SignCount:       pb.GetSignCount(),
230		BackupEligible:  pb.GetBackupEligible(),
231		BackupState:     pb.GetBackupState(),
232		CreatedAt:       decodeTime(pb.GetCreatedAt()),
233		LastUsedAt:      decodeTime(pb.GetLastUsedAt()),
234	}, nil
235}

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 45

↑↓ moveenter openesc close