detached dbbae13

feat(kvstore): store passkeys and declare the passkey ports

commit
dbbae13064a15f4a2fcea5ec8212a6a0230def72
author
brice · · 2026-10-12 14:49 UTC
committer
brice · · 2026-10-12 14:49 UTC
parent
845634b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1415 -11
M gen/ermite/storage/session.pb.go +209 -11 generated file: collapsed
A internal/adapters/kvstore/passkeys.go +235 -0
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
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+
24+const (
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.
34+type Passkeys struct {
35+ db *kv.DB
36+}
37+
38+var _ ports.PasskeyStore = (*Passkeys)(nil)
39+
40+// Passkeys returns the passkey store over the same database. It is valid until
41+// the Store is closed.
42+func (s *Store) Passkeys() *Passkeys { return &Passkeys{db: s.db} }
43+
44+func passkeyKey(id []byte) []byte {
45+ return []byte(passkeyCredPrefix + base64.RawURLEncoding.EncodeToString(id))
46+}
47+
48+// validPasskeyID reports whether id can be a credential id.
49+func 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.
53+func (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.
87+func (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>.
106+func (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.
119+func (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
139+func (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.
165+func (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+
189+func 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+
215+func 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+}
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
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+
24+const (
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.
34+type Passkeys struct {
35+ db *kv.DB
36+}
37+
38+var _ ports.PasskeyStore = (*Passkeys)(nil)
39+
40+// Passkeys returns the passkey store over the same database. It is valid until
41+// the Store is closed.
42+func (s *Store) Passkeys() *Passkeys { return &Passkeys{db: s.db} }
43+
44+func passkeyKey(id []byte) []byte {
45+ return []byte(passkeyCredPrefix + base64.RawURLEncoding.EncodeToString(id))
46+}
47+
48+// validPasskeyID reports whether id can be a credential id.
49+func 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.
53+func (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.
87+func (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>.
106+func (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.
119+func (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
139+func (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.
165+func (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+
189+func 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+
215+func 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+}
A internal/adapters/kvstore/passkeys_test.go +151 -0
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+func newPasskey(id string, at time.Time) domain.Passkey {
19+ return domain.Passkey{
20+ ID: []byte(id),
21+ Name: "key " + id,
22+ PublicKey: []byte("cose-" + id),
23+ AttestationType: "none",
24+ Transports: []string{"internal", "hybrid"},
25+ SignCount: 7,
26+ BackupEligible: true,
27+ BackupState: true,
28+ CreatedAt: at,
29+ LastUsedAt: at.Add(time.Hour),
30+ }
31+}
32+
33+func TestPasskeysRoundTrip(t *testing.T) {
34+ ctx := context.Background()
35+ ps := openStore(t, t.TempDir()).Passkeys()
36+
37+ p := newPasskey("cred-1", created)
38+ require.NoError(t, ps.Create(ctx, p))
39+ got, err := ps.Get(ctx, p.ID)
40+ require.NoError(t, err)
41+ assert.Equal(t, p, got)
42+
43+ // Minimal passkey: no transports, never used. A binary id works too.
44+ min := domain.Passkey{ID: []byte{0, 1, 0xff, '/'}, Name: "min", PublicKey: []byte("k"), CreatedAt: created}
45+ require.NoError(t, ps.Create(ctx, min))
46+ got, err = ps.Get(ctx, min.ID)
47+ require.NoError(t, err)
48+ assert.Empty(t, got.Transports)
49+ assert.True(t, got.LastUsedAt.IsZero())
50+ assert.Equal(t, min.ID, got.ID)
51+
52+ long := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen), Name: "long", PublicKey: []byte("k"), CreatedAt: created}
53+ require.NoError(t, ps.Create(ctx, long))
54+ tooLong := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen+1), Name: "x", PublicKey: []byte("k")}
55+ assert.ErrorIs(t, ps.Create(ctx, tooLong), domain.ErrInvalid)
56+ assert.ErrorIs(t, ps.Create(ctx, domain.Passkey{Name: "no id"}), domain.ErrInvalid)
57+}
58+
59+func TestPasskeysDuplicateAndUnknown(t *testing.T) {
60+ ctx := context.Background()
61+ ps := openStore(t, t.TempDir()).Passkeys()
62+ p := newPasskey("cred-1", created)
63+ require.NoError(t, ps.Create(ctx, p))
64+
65+ assert.ErrorIs(t, ps.Create(ctx, p), domain.ErrAlreadyExists)
66+
67+ unknown := newPasskey("nope", created)
68+ _, err := ps.Get(ctx, unknown.ID)
69+ assert.ErrorIs(t, err, domain.ErrNotFound)
70+ assert.ErrorIs(t, ps.Update(ctx, unknown), domain.ErrNotFound)
71+ assert.ErrorIs(t, ps.Delete(ctx, unknown.ID), domain.ErrNotFound)
72+ _, err = ps.Get(ctx, nil)
73+ assert.ErrorIs(t, err, domain.ErrNotFound)
74+ assert.ErrorIs(t, ps.Delete(ctx, nil), domain.ErrNotFound)
75+}
76+
77+func TestPasskeysUpdateDelete(t *testing.T) {
78+ ctx := context.Background()
79+ ps := openStore(t, t.TempDir()).Passkeys()
80+ p := newPasskey("cred-1", created)
81+ require.NoError(t, ps.Create(ctx, p))
82+
83+ p.SignCount = 42
84+ p.BackupState = false
85+ p.LastUsedAt = created.Add(48 * time.Hour)
86+ require.NoError(t, ps.Update(ctx, p))
87+ got, err := ps.Get(ctx, p.ID)
88+ require.NoError(t, err)
89+ assert.Equal(t, p, got)
90+
91+ require.NoError(t, ps.Delete(ctx, p.ID))
92+ _, err = ps.Get(ctx, p.ID)
93+ assert.ErrorIs(t, err, domain.ErrNotFound)
94+ assert.ErrorIs(t, ps.Delete(ctx, p.ID), domain.ErrNotFound)
95+ list, err := ps.List(ctx)
96+ require.NoError(t, err)
97+ assert.Empty(t, list)
98+}
99+
100+// List orders by creation time, then by id.
101+func TestPasskeysListOrder(t *testing.T) {
102+ ctx := context.Background()
103+ ps := openStore(t, t.TempDir()).Passkeys()
104+ for _, p := range []domain.Passkey{
105+ newPasskey("c", created.Add(2*time.Hour)),
106+ newPasskey("b", created),
107+ newPasskey("a", created),
108+ newPasskey("d", created.Add(time.Hour)),
109+ } {
110+ require.NoError(t, ps.Create(ctx, p))
111+ }
112+ list, err := ps.List(ctx)
113+ require.NoError(t, err)
114+ var ids []string
115+ for _, p := range list {
116+ ids = append(ids, string(p.ID))
117+ }
118+ assert.Equal(t, []string{"a", "b", "d", "c"}, ids)
119+}
120+
121+// The handle is generated once, stays stable across a restart, and concurrent
122+// first calls agree on it.
123+func TestPasskeysUserHandle(t *testing.T) {
124+ ctx := context.Background()
125+ dir := t.TempDir()
126+ s := openStore(t, dir)
127+
128+ var wg sync.WaitGroup
129+ handles := make([][]byte, 8)
130+ for i := range handles {
131+ wg.Add(1)
132+ go func() {
133+ defer wg.Done()
134+ h, err := s.Passkeys().UserHandle(ctx)
135+ assert.NoError(t, err)
136+ handles[i] = h
137+ }()
138+ }
139+ wg.Wait()
140+ handle := handles[0]
141+ assert.Len(t, handle, 32)
142+ for _, h := range handles[1:] {
143+ assert.Equal(t, handle, h)
144+ }
145+ require.NoError(t, s.Close(ctx))
146+
147+ s = openStore(t, dir)
148+ again, err := s.Passkeys().UserHandle(ctx)
149+ require.NoError(t, err)
150+ assert.Equal(t, handle, again, "a restart keeps the handle")
151+}
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+func newPasskey(id string, at time.Time) domain.Passkey {
19+ return domain.Passkey{
20+ ID: []byte(id),
21+ Name: "key " + id,
22+ PublicKey: []byte("cose-" + id),
23+ AttestationType: "none",
24+ Transports: []string{"internal", "hybrid"},
25+ SignCount: 7,
26+ BackupEligible: true,
27+ BackupState: true,
28+ CreatedAt: at,
29+ LastUsedAt: at.Add(time.Hour),
30+ }
31+}
32+
33+func TestPasskeysRoundTrip(t *testing.T) {
34+ ctx := context.Background()
35+ ps := openStore(t, t.TempDir()).Passkeys()
36+
37+ p := newPasskey("cred-1", created)
38+ require.NoError(t, ps.Create(ctx, p))
39+ got, err := ps.Get(ctx, p.ID)
40+ require.NoError(t, err)
41+ assert.Equal(t, p, got)
42+
43+ // Minimal passkey: no transports, never used. A binary id works too.
44+ min := domain.Passkey{ID: []byte{0, 1, 0xff, '/'}, Name: "min", PublicKey: []byte("k"), CreatedAt: created}
45+ require.NoError(t, ps.Create(ctx, min))
46+ got, err = ps.Get(ctx, min.ID)
47+ require.NoError(t, err)
48+ assert.Empty(t, got.Transports)
49+ assert.True(t, got.LastUsedAt.IsZero())
50+ assert.Equal(t, min.ID, got.ID)
51+
52+ long := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen), Name: "long", PublicKey: []byte("k"), CreatedAt: created}
53+ require.NoError(t, ps.Create(ctx, long))
54+ tooLong := domain.Passkey{ID: bytes.Repeat([]byte{7}, domain.MaxPasskeyIDLen+1), Name: "x", PublicKey: []byte("k")}
55+ assert.ErrorIs(t, ps.Create(ctx, tooLong), domain.ErrInvalid)
56+ assert.ErrorIs(t, ps.Create(ctx, domain.Passkey{Name: "no id"}), domain.ErrInvalid)
57+}
58+
59+func TestPasskeysDuplicateAndUnknown(t *testing.T) {
60+ ctx := context.Background()
61+ ps := openStore(t, t.TempDir()).Passkeys()
62+ p := newPasskey("cred-1", created)
63+ require.NoError(t, ps.Create(ctx, p))
64+
65+ assert.ErrorIs(t, ps.Create(ctx, p), domain.ErrAlreadyExists)
66+
67+ unknown := newPasskey("nope", created)
68+ _, err := ps.Get(ctx, unknown.ID)
69+ assert.ErrorIs(t, err, domain.ErrNotFound)
70+ assert.ErrorIs(t, ps.Update(ctx, unknown), domain.ErrNotFound)
71+ assert.ErrorIs(t, ps.Delete(ctx, unknown.ID), domain.ErrNotFound)
72+ _, err = ps.Get(ctx, nil)
73+ assert.ErrorIs(t, err, domain.ErrNotFound)
74+ assert.ErrorIs(t, ps.Delete(ctx, nil), domain.ErrNotFound)
75+}
76+
77+func TestPasskeysUpdateDelete(t *testing.T) {
78+ ctx := context.Background()
79+ ps := openStore(t, t.TempDir()).Passkeys()
80+ p := newPasskey("cred-1", created)
81+ require.NoError(t, ps.Create(ctx, p))
82+
83+ p.SignCount = 42
84+ p.BackupState = false
85+ p.LastUsedAt = created.Add(48 * time.Hour)
86+ require.NoError(t, ps.Update(ctx, p))
87+ got, err := ps.Get(ctx, p.ID)
88+ require.NoError(t, err)
89+ assert.Equal(t, p, got)
90+
91+ require.NoError(t, ps.Delete(ctx, p.ID))
92+ _, err = ps.Get(ctx, p.ID)
93+ assert.ErrorIs(t, err, domain.ErrNotFound)
94+ assert.ErrorIs(t, ps.Delete(ctx, p.ID), domain.ErrNotFound)
95+ list, err := ps.List(ctx)
96+ require.NoError(t, err)
97+ assert.Empty(t, list)
98+}
99+
100+// List orders by creation time, then by id.
101+func TestPasskeysListOrder(t *testing.T) {
102+ ctx := context.Background()
103+ ps := openStore(t, t.TempDir()).Passkeys()
104+ for _, p := range []domain.Passkey{
105+ newPasskey("c", created.Add(2*time.Hour)),
106+ newPasskey("b", created),
107+ newPasskey("a", created),
108+ newPasskey("d", created.Add(time.Hour)),
109+ } {
110+ require.NoError(t, ps.Create(ctx, p))
111+ }
112+ list, err := ps.List(ctx)
113+ require.NoError(t, err)
114+ var ids []string
115+ for _, p := range list {
116+ ids = append(ids, string(p.ID))
117+ }
118+ assert.Equal(t, []string{"a", "b", "d", "c"}, ids)
119+}
120+
121+// The handle is generated once, stays stable across a restart, and concurrent
122+// first calls agree on it.
123+func TestPasskeysUserHandle(t *testing.T) {
124+ ctx := context.Background()
125+ dir := t.TempDir()
126+ s := openStore(t, dir)
127+
128+ var wg sync.WaitGroup
129+ handles := make([][]byte, 8)
130+ for i := range handles {
131+ wg.Add(1)
132+ go func() {
133+ defer wg.Done()
134+ h, err := s.Passkeys().UserHandle(ctx)
135+ assert.NoError(t, err)
136+ handles[i] = h
137+ }()
138+ }
139+ wg.Wait()
140+ handle := handles[0]
141+ assert.Len(t, handle, 32)
142+ for _, h := range handles[1:] {
143+ assert.Equal(t, handle, h)
144+ }
145+ require.NoError(t, s.Close(ctx))
146+
147+ s = openStore(t, dir)
148+ again, err := s.Passkeys().UserHandle(ctx)
149+ require.NoError(t, err)
150+ assert.Equal(t, handle, again, "a restart keeps the handle")
151+}
A internal/ports/mocks/PasskeyStore.go +409 -0
@@ -0,0 +1,409 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPasskeyStore creates a new instance of PasskeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPasskeyStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PasskeyStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PasskeyStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// PasskeyStore is an autogenerated mock type for the PasskeyStore type
38+type PasskeyStore struct {
39+ mock.Mock
40+}
41+
42+type PasskeyStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PasskeyStore) EXPECT() *PasskeyStore_Expecter {
47+ return &PasskeyStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// Create provides a mock function for the type PasskeyStore
51+func (_mock *PasskeyStore) Create(ctx context.Context, p domain.Passkey) error {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Create")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Passkey) error); ok {
60+ r0 = returnFunc(ctx, p)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// PasskeyStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
68+type PasskeyStore_Create_Call struct {
69+ *mock.Call
70+}
71+
72+// Create is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Passkey
75+func (_e *PasskeyStore_Expecter) Create(ctx any, p any) *PasskeyStore_Create_Call {
76+ return &PasskeyStore_Create_Call{Call: _e.mock.On("Create", ctx, p)}
77+}
78+
79+func (_c *PasskeyStore_Create_Call) Run(run func(ctx context.Context, p domain.Passkey)) *PasskeyStore_Create_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 domain.Passkey
86+ if args[1] != nil {
87+ arg1 = args[1].(domain.Passkey)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *PasskeyStore_Create_Call) Return(err error) *PasskeyStore_Create_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *PasskeyStore_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Passkey) error) *PasskeyStore_Create_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
106+
107+// Delete provides a mock function for the type PasskeyStore
108+func (_mock *PasskeyStore) Delete(ctx context.Context, id []byte) error {
109+ ret := _mock.Called(ctx, id)
110+
111+ if len(ret) == 0 {
112+ panic("no return value specified for Delete")
113+ }
114+
115+ var r0 error
116+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) error); ok {
117+ r0 = returnFunc(ctx, id)
118+ } else {
119+ r0 = ret.Error(0)
120+ }
121+ return r0
122+}
123+
124+// PasskeyStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
125+type PasskeyStore_Delete_Call struct {
126+ *mock.Call
127+}
128+
129+// Delete is a helper method to define mock.On call
130+// - ctx context.Context
131+// - id []byte
132+func (_e *PasskeyStore_Expecter) Delete(ctx any, id any) *PasskeyStore_Delete_Call {
133+ return &PasskeyStore_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
134+}
135+
136+func (_c *PasskeyStore_Delete_Call) Run(run func(ctx context.Context, id []byte)) *PasskeyStore_Delete_Call {
137+ _c.Call.Run(func(args mock.Arguments) {
138+ var arg0 context.Context
139+ if args[0] != nil {
140+ arg0 = args[0].(context.Context)
141+ }
142+ var arg1 []byte
143+ if args[1] != nil {
144+ arg1 = args[1].([]byte)
145+ }
146+ run(
147+ arg0,
148+ arg1,
149+ )
150+ })
151+ return _c
152+}
153+
154+func (_c *PasskeyStore_Delete_Call) Return(err error) *PasskeyStore_Delete_Call {
155+ _c.Call.Return(err)
156+ return _c
157+}
158+
159+func (_c *PasskeyStore_Delete_Call) RunAndReturn(run func(ctx context.Context, id []byte) error) *PasskeyStore_Delete_Call {
160+ _c.Call.Return(run)
161+ return _c
162+}
163+
164+// Get provides a mock function for the type PasskeyStore
165+func (_mock *PasskeyStore) Get(ctx context.Context, id []byte) (domain.Passkey, error) {
166+ ret := _mock.Called(ctx, id)
167+
168+ if len(ret) == 0 {
169+ panic("no return value specified for Get")
170+ }
171+
172+ var r0 domain.Passkey
173+ var r1 error
174+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) (domain.Passkey, error)); ok {
175+ return returnFunc(ctx, id)
176+ }
177+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) domain.Passkey); ok {
178+ r0 = returnFunc(ctx, id)
179+ } else {
180+ r0 = ret.Get(0).(domain.Passkey)
181+ }
182+ if returnFunc, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
183+ r1 = returnFunc(ctx, id)
184+ } else {
185+ r1 = ret.Error(1)
186+ }
187+ return r0, r1
188+}
189+
190+// PasskeyStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
191+type PasskeyStore_Get_Call struct {
192+ *mock.Call
193+}
194+
195+// Get is a helper method to define mock.On call
196+// - ctx context.Context
197+// - id []byte
198+func (_e *PasskeyStore_Expecter) Get(ctx any, id any) *PasskeyStore_Get_Call {
199+ return &PasskeyStore_Get_Call{Call: _e.mock.On("Get", ctx, id)}
200+}
201+
202+func (_c *PasskeyStore_Get_Call) Run(run func(ctx context.Context, id []byte)) *PasskeyStore_Get_Call {
203+ _c.Call.Run(func(args mock.Arguments) {
204+ var arg0 context.Context
205+ if args[0] != nil {
206+ arg0 = args[0].(context.Context)
207+ }
208+ var arg1 []byte
209+ if args[1] != nil {
210+ arg1 = args[1].([]byte)
211+ }
212+ run(
213+ arg0,
214+ arg1,
215+ )
216+ })
217+ return _c
218+}
219+
220+func (_c *PasskeyStore_Get_Call) Return(passkey domain.Passkey, err error) *PasskeyStore_Get_Call {
221+ _c.Call.Return(passkey, err)
222+ return _c
223+}
224+
225+func (_c *PasskeyStore_Get_Call) RunAndReturn(run func(ctx context.Context, id []byte) (domain.Passkey, error)) *PasskeyStore_Get_Call {
226+ _c.Call.Return(run)
227+ return _c
228+}
229+
230+// List provides a mock function for the type PasskeyStore
231+func (_mock *PasskeyStore) List(ctx context.Context) ([]domain.Passkey, error) {
232+ ret := _mock.Called(ctx)
233+
234+ if len(ret) == 0 {
235+ panic("no return value specified for List")
236+ }
237+
238+ var r0 []domain.Passkey
239+ var r1 error
240+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Passkey, error)); ok {
241+ return returnFunc(ctx)
242+ }
243+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Passkey); ok {
244+ r0 = returnFunc(ctx)
245+ } else {
246+ if ret.Get(0) != nil {
247+ r0 = ret.Get(0).([]domain.Passkey)
248+ }
249+ }
250+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
251+ r1 = returnFunc(ctx)
252+ } else {
253+ r1 = ret.Error(1)
254+ }
255+ return r0, r1
256+}
257+
258+// PasskeyStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
259+type PasskeyStore_List_Call struct {
260+ *mock.Call
261+}
262+
263+// List is a helper method to define mock.On call
264+// - ctx context.Context
265+func (_e *PasskeyStore_Expecter) List(ctx any) *PasskeyStore_List_Call {
266+ return &PasskeyStore_List_Call{Call: _e.mock.On("List", ctx)}
267+}
268+
269+func (_c *PasskeyStore_List_Call) Run(run func(ctx context.Context)) *PasskeyStore_List_Call {
270+ _c.Call.Run(func(args mock.Arguments) {
271+ var arg0 context.Context
272+ if args[0] != nil {
273+ arg0 = args[0].(context.Context)
274+ }
275+ run(
276+ arg0,
277+ )
278+ })
279+ return _c
280+}
281+
282+func (_c *PasskeyStore_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyStore_List_Call {
283+ _c.Call.Return(passkeys, err)
284+ return _c
285+}
286+
287+func (_c *PasskeyStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Passkey, error)) *PasskeyStore_List_Call {
288+ _c.Call.Return(run)
289+ return _c
290+}
291+
292+// Update provides a mock function for the type PasskeyStore
293+func (_mock *PasskeyStore) Update(ctx context.Context, p domain.Passkey) error {
294+ ret := _mock.Called(ctx, p)
295+
296+ if len(ret) == 0 {
297+ panic("no return value specified for Update")
298+ }
299+
300+ var r0 error
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Passkey) error); ok {
302+ r0 = returnFunc(ctx, p)
303+ } else {
304+ r0 = ret.Error(0)
305+ }
306+ return r0
307+}
308+
309+// PasskeyStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
310+type PasskeyStore_Update_Call struct {
311+ *mock.Call
312+}
313+
314+// Update is a helper method to define mock.On call
315+// - ctx context.Context
316+// - p domain.Passkey
317+func (_e *PasskeyStore_Expecter) Update(ctx any, p any) *PasskeyStore_Update_Call {
318+ return &PasskeyStore_Update_Call{Call: _e.mock.On("Update", ctx, p)}
319+}
320+
321+func (_c *PasskeyStore_Update_Call) Run(run func(ctx context.Context, p domain.Passkey)) *PasskeyStore_Update_Call {
322+ _c.Call.Run(func(args mock.Arguments) {
323+ var arg0 context.Context
324+ if args[0] != nil {
325+ arg0 = args[0].(context.Context)
326+ }
327+ var arg1 domain.Passkey
328+ if args[1] != nil {
329+ arg1 = args[1].(domain.Passkey)
330+ }
331+ run(
332+ arg0,
333+ arg1,
334+ )
335+ })
336+ return _c
337+}
338+
339+func (_c *PasskeyStore_Update_Call) Return(err error) *PasskeyStore_Update_Call {
340+ _c.Call.Return(err)
341+ return _c
342+}
343+
344+func (_c *PasskeyStore_Update_Call) RunAndReturn(run func(ctx context.Context, p domain.Passkey) error) *PasskeyStore_Update_Call {
345+ _c.Call.Return(run)
346+ return _c
347+}
348+
349+// UserHandle provides a mock function for the type PasskeyStore
350+func (_mock *PasskeyStore) UserHandle(ctx context.Context) ([]byte, error) {
351+ ret := _mock.Called(ctx)
352+
353+ if len(ret) == 0 {
354+ panic("no return value specified for UserHandle")
355+ }
356+
357+ var r0 []byte
358+ var r1 error
359+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok {
360+ return returnFunc(ctx)
361+ }
362+ if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok {
363+ r0 = returnFunc(ctx)
364+ } else {
365+ if ret.Get(0) != nil {
366+ r0 = ret.Get(0).([]byte)
367+ }
368+ }
369+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
370+ r1 = returnFunc(ctx)
371+ } else {
372+ r1 = ret.Error(1)
373+ }
374+ return r0, r1
375+}
376+
377+// PasskeyStore_UserHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserHandle'
378+type PasskeyStore_UserHandle_Call struct {
379+ *mock.Call
380+}
381+
382+// UserHandle is a helper method to define mock.On call
383+// - ctx context.Context
384+func (_e *PasskeyStore_Expecter) UserHandle(ctx any) *PasskeyStore_UserHandle_Call {
385+ return &PasskeyStore_UserHandle_Call{Call: _e.mock.On("UserHandle", ctx)}
386+}
387+
388+func (_c *PasskeyStore_UserHandle_Call) Run(run func(ctx context.Context)) *PasskeyStore_UserHandle_Call {
389+ _c.Call.Run(func(args mock.Arguments) {
390+ var arg0 context.Context
391+ if args[0] != nil {
392+ arg0 = args[0].(context.Context)
393+ }
394+ run(
395+ arg0,
396+ )
397+ })
398+ return _c
399+}
400+
401+func (_c *PasskeyStore_UserHandle_Call) Return(bytes []byte, err error) *PasskeyStore_UserHandle_Call {
402+ _c.Call.Return(bytes, err)
403+ return _c
404+}
405+
406+func (_c *PasskeyStore_UserHandle_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *PasskeyStore_UserHandle_Call {
407+ _c.Call.Return(run)
408+ return _c
409+}
@@ -0,0 +1,409 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewPasskeyStore creates a new instance of PasskeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewPasskeyStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PasskeyStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PasskeyStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// PasskeyStore is an autogenerated mock type for the PasskeyStore type
38+type PasskeyStore struct {
39+ mock.Mock
40+}
41+
42+type PasskeyStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PasskeyStore) EXPECT() *PasskeyStore_Expecter {
47+ return &PasskeyStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// Create provides a mock function for the type PasskeyStore
51+func (_mock *PasskeyStore) Create(ctx context.Context, p domain.Passkey) error {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Create")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Passkey) error); ok {
60+ r0 = returnFunc(ctx, p)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// PasskeyStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
68+type PasskeyStore_Create_Call struct {
69+ *mock.Call
70+}
71+
72+// Create is a helper method to define mock.On call
73+// - ctx context.Context
74+// - p domain.Passkey
75+func (_e *PasskeyStore_Expecter) Create(ctx any, p any) *PasskeyStore_Create_Call {
76+ return &PasskeyStore_Create_Call{Call: _e.mock.On("Create", ctx, p)}
77+}
78+
79+func (_c *PasskeyStore_Create_Call) Run(run func(ctx context.Context, p domain.Passkey)) *PasskeyStore_Create_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 domain.Passkey
86+ if args[1] != nil {
87+ arg1 = args[1].(domain.Passkey)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *PasskeyStore_Create_Call) Return(err error) *PasskeyStore_Create_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *PasskeyStore_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Passkey) error) *PasskeyStore_Create_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
106+
107+// Delete provides a mock function for the type PasskeyStore
108+func (_mock *PasskeyStore) Delete(ctx context.Context, id []byte) error {
109+ ret := _mock.Called(ctx, id)
110+
111+ if len(ret) == 0 {
112+ panic("no return value specified for Delete")
113+ }
114+
115+ var r0 error
116+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) error); ok {
117+ r0 = returnFunc(ctx, id)
118+ } else {
119+ r0 = ret.Error(0)
120+ }
121+ return r0
122+}
123+
124+// PasskeyStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
125+type PasskeyStore_Delete_Call struct {
126+ *mock.Call
127+}
128+
129+// Delete is a helper method to define mock.On call
130+// - ctx context.Context
131+// - id []byte
132+func (_e *PasskeyStore_Expecter) Delete(ctx any, id any) *PasskeyStore_Delete_Call {
133+ return &PasskeyStore_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
134+}
135+
136+func (_c *PasskeyStore_Delete_Call) Run(run func(ctx context.Context, id []byte)) *PasskeyStore_Delete_Call {
137+ _c.Call.Run(func(args mock.Arguments) {
138+ var arg0 context.Context
139+ if args[0] != nil {
140+ arg0 = args[0].(context.Context)
141+ }
142+ var arg1 []byte
143+ if args[1] != nil {
144+ arg1 = args[1].([]byte)
145+ }
146+ run(
147+ arg0,
148+ arg1,
149+ )
150+ })
151+ return _c
152+}
153+
154+func (_c *PasskeyStore_Delete_Call) Return(err error) *PasskeyStore_Delete_Call {
155+ _c.Call.Return(err)
156+ return _c
157+}
158+
159+func (_c *PasskeyStore_Delete_Call) RunAndReturn(run func(ctx context.Context, id []byte) error) *PasskeyStore_Delete_Call {
160+ _c.Call.Return(run)
161+ return _c
162+}
163+
164+// Get provides a mock function for the type PasskeyStore
165+func (_mock *PasskeyStore) Get(ctx context.Context, id []byte) (domain.Passkey, error) {
166+ ret := _mock.Called(ctx, id)
167+
168+ if len(ret) == 0 {
169+ panic("no return value specified for Get")
170+ }
171+
172+ var r0 domain.Passkey
173+ var r1 error
174+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) (domain.Passkey, error)); ok {
175+ return returnFunc(ctx, id)
176+ }
177+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) domain.Passkey); ok {
178+ r0 = returnFunc(ctx, id)
179+ } else {
180+ r0 = ret.Get(0).(domain.Passkey)
181+ }
182+ if returnFunc, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
183+ r1 = returnFunc(ctx, id)
184+ } else {
185+ r1 = ret.Error(1)
186+ }
187+ return r0, r1
188+}
189+
190+// PasskeyStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
191+type PasskeyStore_Get_Call struct {
192+ *mock.Call
193+}
194+
195+// Get is a helper method to define mock.On call
196+// - ctx context.Context
197+// - id []byte
198+func (_e *PasskeyStore_Expecter) Get(ctx any, id any) *PasskeyStore_Get_Call {
199+ return &PasskeyStore_Get_Call{Call: _e.mock.On("Get", ctx, id)}
200+}
201+
202+func (_c *PasskeyStore_Get_Call) Run(run func(ctx context.Context, id []byte)) *PasskeyStore_Get_Call {
203+ _c.Call.Run(func(args mock.Arguments) {
204+ var arg0 context.Context
205+ if args[0] != nil {
206+ arg0 = args[0].(context.Context)
207+ }
208+ var arg1 []byte
209+ if args[1] != nil {
210+ arg1 = args[1].([]byte)
211+ }
212+ run(
213+ arg0,
214+ arg1,
215+ )
216+ })
217+ return _c
218+}
219+
220+func (_c *PasskeyStore_Get_Call) Return(passkey domain.Passkey, err error) *PasskeyStore_Get_Call {
221+ _c.Call.Return(passkey, err)
222+ return _c
223+}
224+
225+func (_c *PasskeyStore_Get_Call) RunAndReturn(run func(ctx context.Context, id []byte) (domain.Passkey, error)) *PasskeyStore_Get_Call {
226+ _c.Call.Return(run)
227+ return _c
228+}
229+
230+// List provides a mock function for the type PasskeyStore
231+func (_mock *PasskeyStore) List(ctx context.Context) ([]domain.Passkey, error) {
232+ ret := _mock.Called(ctx)
233+
234+ if len(ret) == 0 {
235+ panic("no return value specified for List")
236+ }
237+
238+ var r0 []domain.Passkey
239+ var r1 error
240+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.Passkey, error)); ok {
241+ return returnFunc(ctx)
242+ }
243+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Passkey); ok {
244+ r0 = returnFunc(ctx)
245+ } else {
246+ if ret.Get(0) != nil {
247+ r0 = ret.Get(0).([]domain.Passkey)
248+ }
249+ }
250+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
251+ r1 = returnFunc(ctx)
252+ } else {
253+ r1 = ret.Error(1)
254+ }
255+ return r0, r1
256+}
257+
258+// PasskeyStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
259+type PasskeyStore_List_Call struct {
260+ *mock.Call
261+}
262+
263+// List is a helper method to define mock.On call
264+// - ctx context.Context
265+func (_e *PasskeyStore_Expecter) List(ctx any) *PasskeyStore_List_Call {
266+ return &PasskeyStore_List_Call{Call: _e.mock.On("List", ctx)}
267+}
268+
269+func (_c *PasskeyStore_List_Call) Run(run func(ctx context.Context)) *PasskeyStore_List_Call {
270+ _c.Call.Run(func(args mock.Arguments) {
271+ var arg0 context.Context
272+ if args[0] != nil {
273+ arg0 = args[0].(context.Context)
274+ }
275+ run(
276+ arg0,
277+ )
278+ })
279+ return _c
280+}
281+
282+func (_c *PasskeyStore_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyStore_List_Call {
283+ _c.Call.Return(passkeys, err)
284+ return _c
285+}
286+
287+func (_c *PasskeyStore_List_Call) RunAndReturn(run func(ctx context.Context) ([]domain.Passkey, error)) *PasskeyStore_List_Call {
288+ _c.Call.Return(run)
289+ return _c
290+}
291+
292+// Update provides a mock function for the type PasskeyStore
293+func (_mock *PasskeyStore) Update(ctx context.Context, p domain.Passkey) error {
294+ ret := _mock.Called(ctx, p)
295+
296+ if len(ret) == 0 {
297+ panic("no return value specified for Update")
298+ }
299+
300+ var r0 error
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Passkey) error); ok {
302+ r0 = returnFunc(ctx, p)
303+ } else {
304+ r0 = ret.Error(0)
305+ }
306+ return r0
307+}
308+
309+// PasskeyStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
310+type PasskeyStore_Update_Call struct {
311+ *mock.Call
312+}
313+
314+// Update is a helper method to define mock.On call
315+// - ctx context.Context
316+// - p domain.Passkey
317+func (_e *PasskeyStore_Expecter) Update(ctx any, p any) *PasskeyStore_Update_Call {
318+ return &PasskeyStore_Update_Call{Call: _e.mock.On("Update", ctx, p)}
319+}
320+
321+func (_c *PasskeyStore_Update_Call) Run(run func(ctx context.Context, p domain.Passkey)) *PasskeyStore_Update_Call {
322+ _c.Call.Run(func(args mock.Arguments) {
323+ var arg0 context.Context
324+ if args[0] != nil {
325+ arg0 = args[0].(context.Context)
326+ }
327+ var arg1 domain.Passkey
328+ if args[1] != nil {
329+ arg1 = args[1].(domain.Passkey)
330+ }
331+ run(
332+ arg0,
333+ arg1,
334+ )
335+ })
336+ return _c
337+}
338+
339+func (_c *PasskeyStore_Update_Call) Return(err error) *PasskeyStore_Update_Call {
340+ _c.Call.Return(err)
341+ return _c
342+}
343+
344+func (_c *PasskeyStore_Update_Call) RunAndReturn(run func(ctx context.Context, p domain.Passkey) error) *PasskeyStore_Update_Call {
345+ _c.Call.Return(run)
346+ return _c
347+}
348+
349+// UserHandle provides a mock function for the type PasskeyStore
350+func (_mock *PasskeyStore) UserHandle(ctx context.Context) ([]byte, error) {
351+ ret := _mock.Called(ctx)
352+
353+ if len(ret) == 0 {
354+ panic("no return value specified for UserHandle")
355+ }
356+
357+ var r0 []byte
358+ var r1 error
359+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, error)); ok {
360+ return returnFunc(ctx)
361+ }
362+ if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok {
363+ r0 = returnFunc(ctx)
364+ } else {
365+ if ret.Get(0) != nil {
366+ r0 = ret.Get(0).([]byte)
367+ }
368+ }
369+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
370+ r1 = returnFunc(ctx)
371+ } else {
372+ r1 = ret.Error(1)
373+ }
374+ return r0, r1
375+}
376+
377+// PasskeyStore_UserHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserHandle'
378+type PasskeyStore_UserHandle_Call struct {
379+ *mock.Call
380+}
381+
382+// UserHandle is a helper method to define mock.On call
383+// - ctx context.Context
384+func (_e *PasskeyStore_Expecter) UserHandle(ctx any) *PasskeyStore_UserHandle_Call {
385+ return &PasskeyStore_UserHandle_Call{Call: _e.mock.On("UserHandle", ctx)}
386+}
387+
388+func (_c *PasskeyStore_UserHandle_Call) Run(run func(ctx context.Context)) *PasskeyStore_UserHandle_Call {
389+ _c.Call.Run(func(args mock.Arguments) {
390+ var arg0 context.Context
391+ if args[0] != nil {
392+ arg0 = args[0].(context.Context)
393+ }
394+ run(
395+ arg0,
396+ )
397+ })
398+ return _c
399+}
400+
401+func (_c *PasskeyStore_UserHandle_Call) Return(bytes []byte, err error) *PasskeyStore_UserHandle_Call {
402+ _c.Call.Return(bytes, err)
403+ return _c
404+}
405+
406+func (_c *PasskeyStore_UserHandle_Call) RunAndReturn(run func(ctx context.Context) ([]byte, error)) *PasskeyStore_UserHandle_Call {
407+ _c.Call.Return(run)
408+ return _c
409+}
A internal/ports/mocks/PasskeyVerifier.go +335 -0
@@ -0,0 +1,335 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "bnema.dev/ermite/internal/domain"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewPasskeyVerifier creates a new instance of PasskeyVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewPasskeyVerifier(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *PasskeyVerifier {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &PasskeyVerifier{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// PasskeyVerifier is an autogenerated mock type for the PasskeyVerifier type
36+type PasskeyVerifier struct {
37+ mock.Mock
38+}
39+
40+type PasskeyVerifier_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *PasskeyVerifier) EXPECT() *PasskeyVerifier_Expecter {
45+ return &PasskeyVerifier_Expecter{mock: &_m.Mock}
46+}
47+
48+// BeginLogin provides a mock function for the type PasskeyVerifier
49+func (_mock *PasskeyVerifier) BeginLogin() ([]byte, []byte, error) {
50+ ret := _mock.Called()
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for BeginLogin")
54+ }
55+
56+ var r0 []byte
57+ var r1 []byte
58+ var r2 error
59+ if returnFunc, ok := ret.Get(0).(func() ([]byte, []byte, error)); ok {
60+ return returnFunc()
61+ }
62+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
63+ r0 = returnFunc()
64+ } else {
65+ if ret.Get(0) != nil {
66+ r0 = ret.Get(0).([]byte)
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(1).(func() []byte); ok {
70+ r1 = returnFunc()
71+ } else {
72+ if ret.Get(1) != nil {
73+ r1 = ret.Get(1).([]byte)
74+ }
75+ }
76+ if returnFunc, ok := ret.Get(2).(func() error); ok {
77+ r2 = returnFunc()
78+ } else {
79+ r2 = ret.Error(2)
80+ }
81+ return r0, r1, r2
82+}
83+
84+// PasskeyVerifier_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin'
85+type PasskeyVerifier_BeginLogin_Call struct {
86+ *mock.Call
87+}
88+
89+// BeginLogin is a helper method to define mock.On call
90+func (_e *PasskeyVerifier_Expecter) BeginLogin() *PasskeyVerifier_BeginLogin_Call {
91+ return &PasskeyVerifier_BeginLogin_Call{Call: _e.mock.On("BeginLogin")}
92+}
93+
94+func (_c *PasskeyVerifier_BeginLogin_Call) Run(run func()) *PasskeyVerifier_BeginLogin_Call {
95+ _c.Call.Run(func(args mock.Arguments) {
96+ run()
97+ })
98+ return _c
99+}
100+
101+func (_c *PasskeyVerifier_BeginLogin_Call) Return(options []byte, ceremony []byte, err error) *PasskeyVerifier_BeginLogin_Call {
102+ _c.Call.Return(options, ceremony, err)
103+ return _c
104+}
105+
106+func (_c *PasskeyVerifier_BeginLogin_Call) RunAndReturn(run func() ([]byte, []byte, error)) *PasskeyVerifier_BeginLogin_Call {
107+ _c.Call.Return(run)
108+ return _c
109+}
110+
111+// BeginRegistration provides a mock function for the type PasskeyVerifier
112+func (_mock *PasskeyVerifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
113+ ret := _mock.Called(userHandle, existing)
114+
115+ if len(ret) == 0 {
116+ panic("no return value specified for BeginRegistration")
117+ }
118+
119+ var r0 []byte
120+ var r1 []byte
121+ var r2 error
122+ if returnFunc, ok := ret.Get(0).(func([]byte, []domain.Passkey) ([]byte, []byte, error)); ok {
123+ return returnFunc(userHandle, existing)
124+ }
125+ if returnFunc, ok := ret.Get(0).(func([]byte, []domain.Passkey) []byte); ok {
126+ r0 = returnFunc(userHandle, existing)
127+ } else {
128+ if ret.Get(0) != nil {
129+ r0 = ret.Get(0).([]byte)
130+ }
131+ }
132+ if returnFunc, ok := ret.Get(1).(func([]byte, []domain.Passkey) []byte); ok {
133+ r1 = returnFunc(userHandle, existing)
134+ } else {
135+ if ret.Get(1) != nil {
136+ r1 = ret.Get(1).([]byte)
137+ }
138+ }
139+ if returnFunc, ok := ret.Get(2).(func([]byte, []domain.Passkey) error); ok {
140+ r2 = returnFunc(userHandle, existing)
141+ } else {
142+ r2 = ret.Error(2)
143+ }
144+ return r0, r1, r2
145+}
146+
147+// PasskeyVerifier_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration'
148+type PasskeyVerifier_BeginRegistration_Call struct {
149+ *mock.Call
150+}
151+
152+// BeginRegistration is a helper method to define mock.On call
153+// - userHandle []byte
154+// - existing []domain.Passkey
155+func (_e *PasskeyVerifier_Expecter) BeginRegistration(userHandle any, existing any) *PasskeyVerifier_BeginRegistration_Call {
156+ return &PasskeyVerifier_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", userHandle, existing)}
157+}
158+
159+func (_c *PasskeyVerifier_BeginRegistration_Call) Run(run func(userHandle []byte, existing []domain.Passkey)) *PasskeyVerifier_BeginRegistration_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ var arg0 []byte
162+ if args[0] != nil {
163+ arg0 = args[0].([]byte)
164+ }
165+ var arg1 []domain.Passkey
166+ if args[1] != nil {
167+ arg1 = args[1].([]domain.Passkey)
168+ }
169+ run(
170+ arg0,
171+ arg1,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *PasskeyVerifier_BeginRegistration_Call) Return(options []byte, ceremony []byte, err error) *PasskeyVerifier_BeginRegistration_Call {
178+ _c.Call.Return(options, ceremony, err)
179+ return _c
180+}
181+
182+func (_c *PasskeyVerifier_BeginRegistration_Call) RunAndReturn(run func(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error)) *PasskeyVerifier_BeginRegistration_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// FinishLogin provides a mock function for the type PasskeyVerifier
188+func (_mock *PasskeyVerifier) FinishLogin(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
189+ ret := _mock.Called(userHandle, ceremony, response, lookup)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for FinishLogin")
193+ }
194+
195+ var r0 domain.Passkey
196+ var r1 error
197+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)); ok {
198+ return returnFunc(userHandle, ceremony, response, lookup)
199+ }
200+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) domain.Passkey); ok {
201+ r0 = returnFunc(userHandle, ceremony, response, lookup)
202+ } else {
203+ r0 = ret.Get(0).(domain.Passkey)
204+ }
205+ if returnFunc, ok := ret.Get(1).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) error); ok {
206+ r1 = returnFunc(userHandle, ceremony, response, lookup)
207+ } else {
208+ r1 = ret.Error(1)
209+ }
210+ return r0, r1
211+}
212+
213+// PasskeyVerifier_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin'
214+type PasskeyVerifier_FinishLogin_Call struct {
215+ *mock.Call
216+}
217+
218+// FinishLogin is a helper method to define mock.On call
219+// - userHandle []byte
220+// - ceremony []byte
221+// - response []byte
222+// - lookup func(id []byte) (domain.Passkey, error)
223+func (_e *PasskeyVerifier_Expecter) FinishLogin(userHandle any, ceremony any, response any, lookup any) *PasskeyVerifier_FinishLogin_Call {
224+ return &PasskeyVerifier_FinishLogin_Call{Call: _e.mock.On("FinishLogin", userHandle, ceremony, response, lookup)}
225+}
226+
227+func (_c *PasskeyVerifier_FinishLogin_Call) Run(run func(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error))) *PasskeyVerifier_FinishLogin_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 []byte
230+ if args[0] != nil {
231+ arg0 = args[0].([]byte)
232+ }
233+ var arg1 []byte
234+ if args[1] != nil {
235+ arg1 = args[1].([]byte)
236+ }
237+ var arg2 []byte
238+ if args[2] != nil {
239+ arg2 = args[2].([]byte)
240+ }
241+ var arg3 func(id []byte) (domain.Passkey, error)
242+ if args[3] != nil {
243+ arg3 = args[3].(func(id []byte) (domain.Passkey, error))
244+ }
245+ run(
246+ arg0,
247+ arg1,
248+ arg2,
249+ arg3,
250+ )
251+ })
252+ return _c
253+}
254+
255+func (_c *PasskeyVerifier_FinishLogin_Call) Return(passkey domain.Passkey, err error) *PasskeyVerifier_FinishLogin_Call {
256+ _c.Call.Return(passkey, err)
257+ return _c
258+}
259+
260+func (_c *PasskeyVerifier_FinishLogin_Call) RunAndReturn(run func(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)) *PasskeyVerifier_FinishLogin_Call {
261+ _c.Call.Return(run)
262+ return _c
263+}
264+
265+// FinishRegistration provides a mock function for the type PasskeyVerifier
266+func (_mock *PasskeyVerifier) FinishRegistration(userHandle []byte, ceremony []byte, response []byte) (domain.Passkey, error) {
267+ ret := _mock.Called(userHandle, ceremony, response)
268+
269+ if len(ret) == 0 {
270+ panic("no return value specified for FinishRegistration")
271+ }
272+
273+ var r0 domain.Passkey
274+ var r1 error
275+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte) (domain.Passkey, error)); ok {
276+ return returnFunc(userHandle, ceremony, response)
277+ }
278+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte) domain.Passkey); ok {
279+ r0 = returnFunc(userHandle, ceremony, response)
280+ } else {
281+ r0 = ret.Get(0).(domain.Passkey)
282+ }
283+ if returnFunc, ok := ret.Get(1).(func([]byte, []byte, []byte) error); ok {
284+ r1 = returnFunc(userHandle, ceremony, response)
285+ } else {
286+ r1 = ret.Error(1)
287+ }
288+ return r0, r1
289+}
290+
291+// PasskeyVerifier_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration'
292+type PasskeyVerifier_FinishRegistration_Call struct {
293+ *mock.Call
294+}
295+
296+// FinishRegistration is a helper method to define mock.On call
297+// - userHandle []byte
298+// - ceremony []byte
299+// - response []byte
300+func (_e *PasskeyVerifier_Expecter) FinishRegistration(userHandle any, ceremony any, response any) *PasskeyVerifier_FinishRegistration_Call {
301+ return &PasskeyVerifier_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", userHandle, ceremony, response)}
302+}
303+
304+func (_c *PasskeyVerifier_FinishRegistration_Call) Run(run func(userHandle []byte, ceremony []byte, response []byte)) *PasskeyVerifier_FinishRegistration_Call {
305+ _c.Call.Run(func(args mock.Arguments) {
306+ var arg0 []byte
307+ if args[0] != nil {
308+ arg0 = args[0].([]byte)
309+ }
310+ var arg1 []byte
311+ if args[1] != nil {
312+ arg1 = args[1].([]byte)
313+ }
314+ var arg2 []byte
315+ if args[2] != nil {
316+ arg2 = args[2].([]byte)
317+ }
318+ run(
319+ arg0,
320+ arg1,
321+ arg2,
322+ )
323+ })
324+ return _c
325+}
326+
327+func (_c *PasskeyVerifier_FinishRegistration_Call) Return(passkey domain.Passkey, err error) *PasskeyVerifier_FinishRegistration_Call {
328+ _c.Call.Return(passkey, err)
329+ return _c
330+}
331+
332+func (_c *PasskeyVerifier_FinishRegistration_Call) RunAndReturn(run func(userHandle []byte, ceremony []byte, response []byte) (domain.Passkey, error)) *PasskeyVerifier_FinishRegistration_Call {
333+ _c.Call.Return(run)
334+ return _c
335+}
@@ -0,0 +1,335 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "bnema.dev/ermite/internal/domain"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewPasskeyVerifier creates a new instance of PasskeyVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewPasskeyVerifier(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *PasskeyVerifier {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &PasskeyVerifier{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// PasskeyVerifier is an autogenerated mock type for the PasskeyVerifier type
36+type PasskeyVerifier struct {
37+ mock.Mock
38+}
39+
40+type PasskeyVerifier_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *PasskeyVerifier) EXPECT() *PasskeyVerifier_Expecter {
45+ return &PasskeyVerifier_Expecter{mock: &_m.Mock}
46+}
47+
48+// BeginLogin provides a mock function for the type PasskeyVerifier
49+func (_mock *PasskeyVerifier) BeginLogin() ([]byte, []byte, error) {
50+ ret := _mock.Called()
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for BeginLogin")
54+ }
55+
56+ var r0 []byte
57+ var r1 []byte
58+ var r2 error
59+ if returnFunc, ok := ret.Get(0).(func() ([]byte, []byte, error)); ok {
60+ return returnFunc()
61+ }
62+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
63+ r0 = returnFunc()
64+ } else {
65+ if ret.Get(0) != nil {
66+ r0 = ret.Get(0).([]byte)
67+ }
68+ }
69+ if returnFunc, ok := ret.Get(1).(func() []byte); ok {
70+ r1 = returnFunc()
71+ } else {
72+ if ret.Get(1) != nil {
73+ r1 = ret.Get(1).([]byte)
74+ }
75+ }
76+ if returnFunc, ok := ret.Get(2).(func() error); ok {
77+ r2 = returnFunc()
78+ } else {
79+ r2 = ret.Error(2)
80+ }
81+ return r0, r1, r2
82+}
83+
84+// PasskeyVerifier_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin'
85+type PasskeyVerifier_BeginLogin_Call struct {
86+ *mock.Call
87+}
88+
89+// BeginLogin is a helper method to define mock.On call
90+func (_e *PasskeyVerifier_Expecter) BeginLogin() *PasskeyVerifier_BeginLogin_Call {
91+ return &PasskeyVerifier_BeginLogin_Call{Call: _e.mock.On("BeginLogin")}
92+}
93+
94+func (_c *PasskeyVerifier_BeginLogin_Call) Run(run func()) *PasskeyVerifier_BeginLogin_Call {
95+ _c.Call.Run(func(args mock.Arguments) {
96+ run()
97+ })
98+ return _c
99+}
100+
101+func (_c *PasskeyVerifier_BeginLogin_Call) Return(options []byte, ceremony []byte, err error) *PasskeyVerifier_BeginLogin_Call {
102+ _c.Call.Return(options, ceremony, err)
103+ return _c
104+}
105+
106+func (_c *PasskeyVerifier_BeginLogin_Call) RunAndReturn(run func() ([]byte, []byte, error)) *PasskeyVerifier_BeginLogin_Call {
107+ _c.Call.Return(run)
108+ return _c
109+}
110+
111+// BeginRegistration provides a mock function for the type PasskeyVerifier
112+func (_mock *PasskeyVerifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
113+ ret := _mock.Called(userHandle, existing)
114+
115+ if len(ret) == 0 {
116+ panic("no return value specified for BeginRegistration")
117+ }
118+
119+ var r0 []byte
120+ var r1 []byte
121+ var r2 error
122+ if returnFunc, ok := ret.Get(0).(func([]byte, []domain.Passkey) ([]byte, []byte, error)); ok {
123+ return returnFunc(userHandle, existing)
124+ }
125+ if returnFunc, ok := ret.Get(0).(func([]byte, []domain.Passkey) []byte); ok {
126+ r0 = returnFunc(userHandle, existing)
127+ } else {
128+ if ret.Get(0) != nil {
129+ r0 = ret.Get(0).([]byte)
130+ }
131+ }
132+ if returnFunc, ok := ret.Get(1).(func([]byte, []domain.Passkey) []byte); ok {
133+ r1 = returnFunc(userHandle, existing)
134+ } else {
135+ if ret.Get(1) != nil {
136+ r1 = ret.Get(1).([]byte)
137+ }
138+ }
139+ if returnFunc, ok := ret.Get(2).(func([]byte, []domain.Passkey) error); ok {
140+ r2 = returnFunc(userHandle, existing)
141+ } else {
142+ r2 = ret.Error(2)
143+ }
144+ return r0, r1, r2
145+}
146+
147+// PasskeyVerifier_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration'
148+type PasskeyVerifier_BeginRegistration_Call struct {
149+ *mock.Call
150+}
151+
152+// BeginRegistration is a helper method to define mock.On call
153+// - userHandle []byte
154+// - existing []domain.Passkey
155+func (_e *PasskeyVerifier_Expecter) BeginRegistration(userHandle any, existing any) *PasskeyVerifier_BeginRegistration_Call {
156+ return &PasskeyVerifier_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", userHandle, existing)}
157+}
158+
159+func (_c *PasskeyVerifier_BeginRegistration_Call) Run(run func(userHandle []byte, existing []domain.Passkey)) *PasskeyVerifier_BeginRegistration_Call {
160+ _c.Call.Run(func(args mock.Arguments) {
161+ var arg0 []byte
162+ if args[0] != nil {
163+ arg0 = args[0].([]byte)
164+ }
165+ var arg1 []domain.Passkey
166+ if args[1] != nil {
167+ arg1 = args[1].([]domain.Passkey)
168+ }
169+ run(
170+ arg0,
171+ arg1,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *PasskeyVerifier_BeginRegistration_Call) Return(options []byte, ceremony []byte, err error) *PasskeyVerifier_BeginRegistration_Call {
178+ _c.Call.Return(options, ceremony, err)
179+ return _c
180+}
181+
182+func (_c *PasskeyVerifier_BeginRegistration_Call) RunAndReturn(run func(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error)) *PasskeyVerifier_BeginRegistration_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// FinishLogin provides a mock function for the type PasskeyVerifier
188+func (_mock *PasskeyVerifier) FinishLogin(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
189+ ret := _mock.Called(userHandle, ceremony, response, lookup)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for FinishLogin")
193+ }
194+
195+ var r0 domain.Passkey
196+ var r1 error
197+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)); ok {
198+ return returnFunc(userHandle, ceremony, response, lookup)
199+ }
200+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) domain.Passkey); ok {
201+ r0 = returnFunc(userHandle, ceremony, response, lookup)
202+ } else {
203+ r0 = ret.Get(0).(domain.Passkey)
204+ }
205+ if returnFunc, ok := ret.Get(1).(func([]byte, []byte, []byte, func(id []byte) (domain.Passkey, error)) error); ok {
206+ r1 = returnFunc(userHandle, ceremony, response, lookup)
207+ } else {
208+ r1 = ret.Error(1)
209+ }
210+ return r0, r1
211+}
212+
213+// PasskeyVerifier_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin'
214+type PasskeyVerifier_FinishLogin_Call struct {
215+ *mock.Call
216+}
217+
218+// FinishLogin is a helper method to define mock.On call
219+// - userHandle []byte
220+// - ceremony []byte
221+// - response []byte
222+// - lookup func(id []byte) (domain.Passkey, error)
223+func (_e *PasskeyVerifier_Expecter) FinishLogin(userHandle any, ceremony any, response any, lookup any) *PasskeyVerifier_FinishLogin_Call {
224+ return &PasskeyVerifier_FinishLogin_Call{Call: _e.mock.On("FinishLogin", userHandle, ceremony, response, lookup)}
225+}
226+
227+func (_c *PasskeyVerifier_FinishLogin_Call) Run(run func(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error))) *PasskeyVerifier_FinishLogin_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 []byte
230+ if args[0] != nil {
231+ arg0 = args[0].([]byte)
232+ }
233+ var arg1 []byte
234+ if args[1] != nil {
235+ arg1 = args[1].([]byte)
236+ }
237+ var arg2 []byte
238+ if args[2] != nil {
239+ arg2 = args[2].([]byte)
240+ }
241+ var arg3 func(id []byte) (domain.Passkey, error)
242+ if args[3] != nil {
243+ arg3 = args[3].(func(id []byte) (domain.Passkey, error))
244+ }
245+ run(
246+ arg0,
247+ arg1,
248+ arg2,
249+ arg3,
250+ )
251+ })
252+ return _c
253+}
254+
255+func (_c *PasskeyVerifier_FinishLogin_Call) Return(passkey domain.Passkey, err error) *PasskeyVerifier_FinishLogin_Call {
256+ _c.Call.Return(passkey, err)
257+ return _c
258+}
259+
260+func (_c *PasskeyVerifier_FinishLogin_Call) RunAndReturn(run func(userHandle []byte, ceremony []byte, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)) *PasskeyVerifier_FinishLogin_Call {
261+ _c.Call.Return(run)
262+ return _c
263+}
264+
265+// FinishRegistration provides a mock function for the type PasskeyVerifier
266+func (_mock *PasskeyVerifier) FinishRegistration(userHandle []byte, ceremony []byte, response []byte) (domain.Passkey, error) {
267+ ret := _mock.Called(userHandle, ceremony, response)
268+
269+ if len(ret) == 0 {
270+ panic("no return value specified for FinishRegistration")
271+ }
272+
273+ var r0 domain.Passkey
274+ var r1 error
275+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte) (domain.Passkey, error)); ok {
276+ return returnFunc(userHandle, ceremony, response)
277+ }
278+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, []byte) domain.Passkey); ok {
279+ r0 = returnFunc(userHandle, ceremony, response)
280+ } else {
281+ r0 = ret.Get(0).(domain.Passkey)
282+ }
283+ if returnFunc, ok := ret.Get(1).(func([]byte, []byte, []byte) error); ok {
284+ r1 = returnFunc(userHandle, ceremony, response)
285+ } else {
286+ r1 = ret.Error(1)
287+ }
288+ return r0, r1
289+}
290+
291+// PasskeyVerifier_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration'
292+type PasskeyVerifier_FinishRegistration_Call struct {
293+ *mock.Call
294+}
295+
296+// FinishRegistration is a helper method to define mock.On call
297+// - userHandle []byte
298+// - ceremony []byte
299+// - response []byte
300+func (_e *PasskeyVerifier_Expecter) FinishRegistration(userHandle any, ceremony any, response any) *PasskeyVerifier_FinishRegistration_Call {
301+ return &PasskeyVerifier_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", userHandle, ceremony, response)}
302+}
303+
304+func (_c *PasskeyVerifier_FinishRegistration_Call) Run(run func(userHandle []byte, ceremony []byte, response []byte)) *PasskeyVerifier_FinishRegistration_Call {
305+ _c.Call.Run(func(args mock.Arguments) {
306+ var arg0 []byte
307+ if args[0] != nil {
308+ arg0 = args[0].([]byte)
309+ }
310+ var arg1 []byte
311+ if args[1] != nil {
312+ arg1 = args[1].([]byte)
313+ }
314+ var arg2 []byte
315+ if args[2] != nil {
316+ arg2 = args[2].([]byte)
317+ }
318+ run(
319+ arg0,
320+ arg1,
321+ arg2,
322+ )
323+ })
324+ return _c
325+}
326+
327+func (_c *PasskeyVerifier_FinishRegistration_Call) Return(passkey domain.Passkey, err error) *PasskeyVerifier_FinishRegistration_Call {
328+ _c.Call.Return(passkey, err)
329+ return _c
330+}
331+
332+func (_c *PasskeyVerifier_FinishRegistration_Call) RunAndReturn(run func(userHandle []byte, ceremony []byte, response []byte) (domain.Passkey, error)) *PasskeyVerifier_FinishRegistration_Call {
333+ _c.Call.Return(run)
334+ return _c
335+}
M internal/ports/ports.go +45 -0
@@ -318,6 +318,51 @@ type SessionStore interface {
318318 BumpEpoch(ctx context.Context) (uint64, error)
319319 }
320320
321+// PasskeyStore keeps the owner's passkeys (ADR 0026) and the WebAuthn user
322+// handle they belong to.
323+type PasskeyStore interface {
324+ // UserHandle returns the 32-byte WebAuthn user handle of the owner. The
325+ // first call on an empty store generates and stores it; it never changes.
326+ UserHandle(ctx context.Context) ([]byte, error)
327+ // List returns every passkey, oldest first.
328+ List(ctx context.Context) ([]domain.Passkey, error)
329+ // Get returns the passkey with that credential id, or domain.ErrNotFound.
330+ Get(ctx context.Context, id []byte) (domain.Passkey, error)
331+ // Create stores the passkey. It returns domain.ErrAlreadyExists if the
332+ // credential id is taken. It does not bound the number of passkeys: the
333+ // use case checks domain.MaxPasskeys.
334+ Create(ctx context.Context, p domain.Passkey) error
335+ // Update replaces the stored passkey (sign count, backup state, last
336+ // use), or returns domain.ErrNotFound.
337+ Update(ctx context.Context, p domain.Passkey) error
338+ // Delete removes the passkey, or returns domain.ErrNotFound.
339+ Delete(ctx context.Context, id []byte) error
340+}
341+
342+// PasskeyVerifier runs the WebAuthn ceremonies (ADR 0026). It hides the
343+// WebAuthn library: the ceremony data it hands out is opaque to the caller,
344+// which keeps it between the begin and the finish of a ceremony and passes it
345+// back untouched. Every verification failure is domain.ErrUnauthenticated,
346+// wrapped with a reason meant for logs, not for callers.
347+type PasskeyVerifier interface {
348+ // BeginRegistration starts the registration of a passkey for the owner.
349+ // options is the JSON of the credential creation options for the browser
350+ // ({"publicKey": {...}}); existing passkeys are excluded.
351+ BeginRegistration(userHandle []byte, existing []domain.Passkey) (options []byte, ceremony []byte, err error)
352+ // FinishRegistration checks the browser's response (the JSON of
353+ // PublicKeyCredential.toJSON()) and returns the new passkey, without a name
354+ // or creation time.
355+ FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error)
356+ // BeginLogin starts a discoverable login. options is the JSON of the
357+ // credential request options for the browser ({"publicKey": {...}}).
358+ BeginLogin() (options []byte, ceremony []byte, err error)
359+ // FinishLogin checks the browser's response and returns the stored passkey
360+ // updated with the new sign count and backup state. lookup returns
361+ // domain.ErrNotFound for an unknown credential id. A counter that did not
362+ // advance gives domain.ErrPasskeyCloned.
363+ FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)
364+}
365+
321366 // EventBus fans events out to in-process subscribers.
322367 type EventBus interface {
323368 // Publish hands the event to every subscriber without blocking. A
@@ -318,6 +318,51 @@ type SessionStore interface {
318 BumpEpoch(ctx context.Context) (uint64, error)318 BumpEpoch(ctx context.Context) (uint64, error)
319 }319 }
320 320
321+// PasskeyStore keeps the owner's passkeys (ADR 0026) and the WebAuthn user
322+// handle they belong to.
323+type PasskeyStore interface {
324+ // UserHandle returns the 32-byte WebAuthn user handle of the owner. The
325+ // first call on an empty store generates and stores it; it never changes.
326+ UserHandle(ctx context.Context) ([]byte, error)
327+ // List returns every passkey, oldest first.
328+ List(ctx context.Context) ([]domain.Passkey, error)
329+ // Get returns the passkey with that credential id, or domain.ErrNotFound.
330+ Get(ctx context.Context, id []byte) (domain.Passkey, error)
331+ // Create stores the passkey. It returns domain.ErrAlreadyExists if the
332+ // credential id is taken. It does not bound the number of passkeys: the
333+ // use case checks domain.MaxPasskeys.
334+ Create(ctx context.Context, p domain.Passkey) error
335+ // Update replaces the stored passkey (sign count, backup state, last
336+ // use), or returns domain.ErrNotFound.
337+ Update(ctx context.Context, p domain.Passkey) error
338+ // Delete removes the passkey, or returns domain.ErrNotFound.
339+ Delete(ctx context.Context, id []byte) error
340+}
341+
342+// PasskeyVerifier runs the WebAuthn ceremonies (ADR 0026). It hides the
343+// WebAuthn library: the ceremony data it hands out is opaque to the caller,
344+// which keeps it between the begin and the finish of a ceremony and passes it
345+// back untouched. Every verification failure is domain.ErrUnauthenticated,
346+// wrapped with a reason meant for logs, not for callers.
347+type PasskeyVerifier interface {
348+ // BeginRegistration starts the registration of a passkey for the owner.
349+ // options is the JSON of the credential creation options for the browser
350+ // ({"publicKey": {...}}); existing passkeys are excluded.
351+ BeginRegistration(userHandle []byte, existing []domain.Passkey) (options []byte, ceremony []byte, err error)
352+ // FinishRegistration checks the browser's response (the JSON of
353+ // PublicKeyCredential.toJSON()) and returns the new passkey, without a name
354+ // or creation time.
355+ FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error)
356+ // BeginLogin starts a discoverable login. options is the JSON of the
357+ // credential request options for the browser ({"publicKey": {...}}).
358+ BeginLogin() (options []byte, ceremony []byte, err error)
359+ // FinishLogin checks the browser's response and returns the stored passkey
360+ // updated with the new sign count and backup state. lookup returns
361+ // domain.ErrNotFound for an unknown credential id. A counter that did not
362+ // advance gives domain.ErrPasskeyCloned.
363+ FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)
364+}
365+
321 // EventBus fans events out to in-process subscribers.366 // EventBus fans events out to in-process subscribers.
322 type EventBus interface {367 type EventBus interface {
323 // Publish hands the event to every subscriber without blocking. A368 // Publish hands the event to every subscriber without blocking. A
M proto/ermite/storage/session.proto +31 -0
@@ -6,6 +6,8 @@ syntax = "proto3";
66
77 package ermite.storage;
88
9+import "google/protobuf/timestamp.proto";
10+
911 option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
1012
1113 // SessionKey is stored under the key "session/key": the HS256 secret that
@@ -22,3 +24,32 @@ message SessionKey {
2224 message SessionEpoch {
2325 uint64 epoch = 1;
2426 }
27+
28+// PasskeyUser is stored under the key "passkey/user": the WebAuthn user
29+// handle of the owner, generated on first use. It identifies the owner to
30+// authenticators and never changes.
31+message PasskeyUser {
32+ // 32 random bytes.
33+ bytes handle = 1;
34+}
35+
36+// Passkey is stored under the key "passkey/cred/<base64url credential id>":
37+// a WebAuthn credential that can sign the owner in (ADR 0026). It holds the
38+// public key only.
39+message Passkey {
40+ // Credential id, at most 1023 bytes.
41+ bytes id = 1;
42+ // Name given by the owner.
43+ string name = 2;
44+ // COSE public key.
45+ bytes public_key = 3;
46+ string attestation_type = 4;
47+ repeated string transports = 5;
48+ // Last signature counter; 0 for synced passkeys.
49+ uint32 sign_count = 6;
50+ bool backup_eligible = 7;
51+ bool backup_state = 8;
52+ google.protobuf.Timestamp created_at = 9;
53+ // Unset until the passkey is first used.
54+ google.protobuf.Timestamp last_used_at = 10;
55+}
@@ -6,6 +6,8 @@ syntax = "proto3";
6 6
7 package ermite.storage;7 package ermite.storage;
8 8
9+import "google/protobuf/timestamp.proto";
10+
9 option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";11 option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
10 12
11 // SessionKey is stored under the key "session/key": the HS256 secret that13 // SessionKey is stored under the key "session/key": the HS256 secret that
@@ -22,3 +24,32 @@ message SessionKey {
22 message SessionEpoch {24 message SessionEpoch {
23 uint64 epoch = 1;25 uint64 epoch = 1;
24 }26 }
27+
28+// PasskeyUser is stored under the key "passkey/user": the WebAuthn user
29+// handle of the owner, generated on first use. It identifies the owner to
30+// authenticators and never changes.
31+message PasskeyUser {
32+ // 32 random bytes.
33+ bytes handle = 1;
34+}
35+
36+// Passkey is stored under the key "passkey/cred/<base64url credential id>":
37+// a WebAuthn credential that can sign the owner in (ADR 0026). It holds the
38+// public key only.
39+message Passkey {
40+ // Credential id, at most 1023 bytes.
41+ bytes id = 1;
42+ // Name given by the owner.
43+ string name = 2;
44+ // COSE public key.
45+ bytes public_key = 3;
46+ string attestation_type = 4;
47+ repeated string transports = 5;
48+ // Last signature counter; 0 for synced passkeys.
49+ uint32 sign_count = 6;
50+ bool backup_eligible = 7;
51+ bool backup_state = 8;
52+ google.protobuf.Timestamp created_at = 9;
53+ // Unset until the passkey is first used.
54+ google.protobuf.Timestamp last_used_at = 10;
55+}

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