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}