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.
137func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error {
138 val, err := encodePasskey(p)
139 if err != nil {
140 return err
141 }
142 for range updateAttempts {
143 item, ok := s.db.Get(ctx, passkeyKey(p.ID))
144 if !ok {
145 return domain.ErrNotFound
146 }
147 var b kv.Batch
148 b.RequireVersion(passkeyKey(p.ID), item.Version)
149 b.Put(passkeyKey(p.ID), val)
150 _, err := s.db.Apply(ctx, &b)
151 if errors.Is(err, kv.ErrConflict) {
152 continue // changed or removed meanwhile: read again
153 }
154 if err != nil {
155 return fmt.Errorf("update passkey: %w", err)
156 }
157 return nil
158 }
159 return fmt.Errorf("update passkey: %w", domain.ErrConflict)
160}
161
162// Delete removes passkey/cred/<id>, or returns domain.ErrNotFound.
163func (s *Passkeys) Delete(ctx context.Context, id []byte) error {
164 if !validPasskeyID(id) {
165 return domain.ErrNotFound
166 }
167 for range updateAttempts {
168 item, ok := s.db.Get(ctx, passkeyKey(id))
169 if !ok {
170 return domain.ErrNotFound
171 }
172 var b kv.Batch
173 b.RequireVersion(passkeyKey(id), item.Version)
174 b.Delete(passkeyKey(id))
175 _, err := s.db.Apply(ctx, &b)
176 if errors.Is(err, kv.ErrConflict) {
177 continue
178 }
179 if err != nil {
180 return fmt.Errorf("delete passkey: %w", err)
181 }
182 return nil
183 }
184 return fmt.Errorf("delete passkey: %w", domain.ErrConflict)
185}
186
187func encodePasskey(p domain.Passkey) ([]byte, error) {
188 if !validPasskeyID(p.ID) {
189 return nil, fmt.Errorf("%w: passkey id has %d bytes", domain.ErrInvalid, len(p.ID))
190 }
191 if !utf8.ValidString(p.Name) || !utf8.ValidString(p.AttestationType) {
192 return nil, fmt.Errorf("%w: passkey text is not valid UTF-8", domain.ErrInvalid)
193 }
194 for _, t := range p.Transports {
195 if !utf8.ValidString(t) {
196 return nil, fmt.Errorf("%w: passkey transport is not valid UTF-8", domain.ErrInvalid)
197 }
198 }
199 return mustMarshal(&storagepb.Passkey{
200 Id: p.ID,
201 Name: p.Name,
202 PublicKey: p.PublicKey,
203 AttestationType: p.AttestationType,
204 Transports: p.Transports,
205 SignCount: p.SignCount,
206 BackupEligible: p.BackupEligible,
207 BackupState: p.BackupState,
208 CreatedAt: encodeTime(p.CreatedAt),
209 LastUsedAt: encodeTime(p.LastUsedAt),
210 }), nil
211}
212
213func decodePasskey(val []byte) (domain.Passkey, error) {
214 var pb storagepb.Passkey
215 if err := proto.Unmarshal(val, &pb); err != nil {
216 return domain.Passkey{}, fmt.Errorf("decode passkey: %w", err)
217 }
218 if !validPasskeyID(pb.GetId()) {
219 return domain.Passkey{}, fmt.Errorf("decode passkey: id has %d bytes", len(pb.GetId()))
220 }
221 return domain.Passkey{
222 ID: bytes.Clone(pb.GetId()),
223 Name: pb.GetName(),
224 PublicKey: bytes.Clone(pb.GetPublicKey()),
225 AttestationType: pb.GetAttestationType(),
226 Transports: slices.Clone(pb.GetTransports()),
227 SignCount: pb.GetSignCount(),
228 BackupEligible: pb.GetBackupEligible(),
229 BackupState: pb.GetBackupState(),
230 CreatedAt: decodeTime(pb.GetCreatedAt()),
231 LastUsedAt: decodeTime(pb.GetLastUsedAt()),
232 }, nil
233}