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
@@ -13,6 +13,7 @@ package storagepb
1313 import (
1414 protoreflect "google.golang.org/protobuf/reflect/protoreflect"
1515 protoimpl "google.golang.org/protobuf/runtime/protoimpl"
16+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
1617 reflect "reflect"
1718 sync "sync"
1819 unsafe "unsafe"
@@ -120,16 +121,208 @@ func (x *SessionEpoch) GetEpoch() uint64 {
120121 return 0
121122 }
122123
124+// PasskeyUser is stored under the key "passkey/user": the WebAuthn user
125+// handle of the owner, generated on first use. It identifies the owner to
126+// authenticators and never changes.
127+type PasskeyUser struct {
128+ state protoimpl.MessageState `protogen:"open.v1"`
129+ // 32 random bytes.
130+ Handle []byte `protobuf:"bytes,1,opt,name=handle,proto3" json:"handle,omitempty"`
131+ unknownFields protoimpl.UnknownFields
132+ sizeCache protoimpl.SizeCache
133+}
134+
135+func (x *PasskeyUser) Reset() {
136+ *x = PasskeyUser{}
137+ mi := &file_ermite_storage_session_proto_msgTypes[2]
138+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
139+ ms.StoreMessageInfo(mi)
140+}
141+
142+func (x *PasskeyUser) String() string {
143+ return protoimpl.X.MessageStringOf(x)
144+}
145+
146+func (*PasskeyUser) ProtoMessage() {}
147+
148+func (x *PasskeyUser) ProtoReflect() protoreflect.Message {
149+ mi := &file_ermite_storage_session_proto_msgTypes[2]
150+ if x != nil {
151+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
152+ if ms.LoadMessageInfo() == nil {
153+ ms.StoreMessageInfo(mi)
154+ }
155+ return ms
156+ }
157+ return mi.MessageOf(x)
158+}
159+
160+// Deprecated: Use PasskeyUser.ProtoReflect.Descriptor instead.
161+func (*PasskeyUser) Descriptor() ([]byte, []int) {
162+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{2}
163+}
164+
165+func (x *PasskeyUser) GetHandle() []byte {
166+ if x != nil {
167+ return x.Handle
168+ }
169+ return nil
170+}
171+
172+// Passkey is stored under the key "passkey/cred/<base64url credential id>":
173+// a WebAuthn credential that can sign the owner in (ADR 0026). It holds the
174+// public key only.
175+type Passkey struct {
176+ state protoimpl.MessageState `protogen:"open.v1"`
177+ // Credential id, at most 1023 bytes.
178+ Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
179+ // Name given by the owner.
180+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
181+ // COSE public key.
182+ PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
183+ AttestationType string `protobuf:"bytes,4,opt,name=attestation_type,json=attestationType,proto3" json:"attestation_type,omitempty"`
184+ Transports []string `protobuf:"bytes,5,rep,name=transports,proto3" json:"transports,omitempty"`
185+ // Last signature counter; 0 for synced passkeys.
186+ SignCount uint32 `protobuf:"varint,6,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"`
187+ BackupEligible bool `protobuf:"varint,7,opt,name=backup_eligible,json=backupEligible,proto3" json:"backup_eligible,omitempty"`
188+ BackupState bool `protobuf:"varint,8,opt,name=backup_state,json=backupState,proto3" json:"backup_state,omitempty"`
189+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
190+ // Unset until the passkey is first used.
191+ LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
192+ unknownFields protoimpl.UnknownFields
193+ sizeCache protoimpl.SizeCache
194+}
195+
196+func (x *Passkey) Reset() {
197+ *x = Passkey{}
198+ mi := &file_ermite_storage_session_proto_msgTypes[3]
199+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
200+ ms.StoreMessageInfo(mi)
201+}
202+
203+func (x *Passkey) String() string {
204+ return protoimpl.X.MessageStringOf(x)
205+}
206+
207+func (*Passkey) ProtoMessage() {}
208+
209+func (x *Passkey) ProtoReflect() protoreflect.Message {
210+ mi := &file_ermite_storage_session_proto_msgTypes[3]
211+ if x != nil {
212+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
213+ if ms.LoadMessageInfo() == nil {
214+ ms.StoreMessageInfo(mi)
215+ }
216+ return ms
217+ }
218+ return mi.MessageOf(x)
219+}
220+
221+// Deprecated: Use Passkey.ProtoReflect.Descriptor instead.
222+func (*Passkey) Descriptor() ([]byte, []int) {
223+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{3}
224+}
225+
226+func (x *Passkey) GetId() []byte {
227+ if x != nil {
228+ return x.Id
229+ }
230+ return nil
231+}
232+
233+func (x *Passkey) GetName() string {
234+ if x != nil {
235+ return x.Name
236+ }
237+ return ""
238+}
239+
240+func (x *Passkey) GetPublicKey() []byte {
241+ if x != nil {
242+ return x.PublicKey
243+ }
244+ return nil
245+}
246+
247+func (x *Passkey) GetAttestationType() string {
248+ if x != nil {
249+ return x.AttestationType
250+ }
251+ return ""
252+}
253+
254+func (x *Passkey) GetTransports() []string {
255+ if x != nil {
256+ return x.Transports
257+ }
258+ return nil
259+}
260+
261+func (x *Passkey) GetSignCount() uint32 {
262+ if x != nil {
263+ return x.SignCount
264+ }
265+ return 0
266+}
267+
268+func (x *Passkey) GetBackupEligible() bool {
269+ if x != nil {
270+ return x.BackupEligible
271+ }
272+ return false
273+}
274+
275+func (x *Passkey) GetBackupState() bool {
276+ if x != nil {
277+ return x.BackupState
278+ }
279+ return false
280+}
281+
282+func (x *Passkey) GetCreatedAt() *timestamppb.Timestamp {
283+ if x != nil {
284+ return x.CreatedAt
285+ }
286+ return nil
287+}
288+
289+func (x *Passkey) GetLastUsedAt() *timestamppb.Timestamp {
290+ if x != nil {
291+ return x.LastUsedAt
292+ }
293+ return nil
294+}
295+
123296 var File_ermite_storage_session_proto protoreflect.FileDescriptor
124297
125298 const file_ermite_storage_session_proto_rawDesc = "" +
126299 "\n" +
127- "\x1cermite/storage/session.proto\x12\x0eermite.storage\"\x1e\n" +
300+ "\x1cermite/storage/session.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x1e\n" +
128301 "\n" +
129302 "SessionKey\x12\x10\n" +
130303 "\x03key\x18\x01 \x01(\fR\x03key\"$\n" +
131304 "\fSessionEpoch\x12\x14\n" +
132- "\x05epoch\x18\x01 \x01(\x04R\x05epochB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
305+ "\x05epoch\x18\x01 \x01(\x04R\x05epoch\"%\n" +
306+ "\vPasskeyUser\x12\x16\n" +
307+ "\x06handle\x18\x01 \x01(\fR\x06handle\"\xfb\x02\n" +
308+ "\aPasskey\x12\x0e\n" +
309+ "\x02id\x18\x01 \x01(\fR\x02id\x12\x12\n" +
310+ "\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" +
311+ "\n" +
312+ "public_key\x18\x03 \x01(\fR\tpublicKey\x12)\n" +
313+ "\x10attestation_type\x18\x04 \x01(\tR\x0fattestationType\x12\x1e\n" +
314+ "\n" +
315+ "transports\x18\x05 \x03(\tR\n" +
316+ "transports\x12\x1d\n" +
317+ "\n" +
318+ "sign_count\x18\x06 \x01(\rR\tsignCount\x12'\n" +
319+ "\x0fbackup_eligible\x18\a \x01(\bR\x0ebackupEligible\x12!\n" +
320+ "\fbackup_state\x18\b \x01(\bR\vbackupState\x129\n" +
321+ "\n" +
322+ "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
323+ "\flast_used_at\x18\n" +
324+ " \x01(\v2\x1a.google.protobuf.TimestampR\n" +
325+ "lastUsedAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
133326
134327 var (
135328 file_ermite_storage_session_proto_rawDescOnce sync.Once
@@ -143,17 +336,22 @@ func file_ermite_storage_session_proto_rawDescGZIP() []byte {
143336 return file_ermite_storage_session_proto_rawDescData
144337 }
145338
146-var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
339+var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
147340 var file_ermite_storage_session_proto_goTypes = []any{
148- (*SessionKey)(nil), // 0: ermite.storage.SessionKey
149- (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch
341+ (*SessionKey)(nil), // 0: ermite.storage.SessionKey
342+ (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch
343+ (*PasskeyUser)(nil), // 2: ermite.storage.PasskeyUser
344+ (*Passkey)(nil), // 3: ermite.storage.Passkey
345+ (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
150346 }
151347 var file_ermite_storage_session_proto_depIdxs = []int32{
152- 0, // [0:0] is the sub-list for method output_type
153- 0, // [0:0] is the sub-list for method input_type
154- 0, // [0:0] is the sub-list for extension type_name
155- 0, // [0:0] is the sub-list for extension extendee
156- 0, // [0:0] is the sub-list for field type_name
348+ 4, // 0: ermite.storage.Passkey.created_at:type_name -> google.protobuf.Timestamp
349+ 4, // 1: ermite.storage.Passkey.last_used_at:type_name -> google.protobuf.Timestamp
350+ 2, // [2:2] is the sub-list for method output_type
351+ 2, // [2:2] is the sub-list for method input_type
352+ 2, // [2:2] is the sub-list for extension type_name
353+ 2, // [2:2] is the sub-list for extension extendee
354+ 0, // [0:2] is the sub-list for field type_name
157355 }
158356
159357 func init() { file_ermite_storage_session_proto_init() }
@@ -167,7 +365,7 @@ func file_ermite_storage_session_proto_init() {
167365 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
168366 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)),
169367 NumEnums: 0,
170- NumMessages: 2,
368+ NumMessages: 4,
171369 NumExtensions: 0,
172370 NumServices: 0,
173371 },
@@ -13,6 +13,7 @@ package storagepb
13 import (13 import (
14 protoreflect "google.golang.org/protobuf/reflect/protoreflect"14 protoreflect "google.golang.org/protobuf/reflect/protoreflect"
15 protoimpl "google.golang.org/protobuf/runtime/protoimpl"15 protoimpl "google.golang.org/protobuf/runtime/protoimpl"
16+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
16 reflect "reflect"17 reflect "reflect"
17 sync "sync"18 sync "sync"
18 unsafe "unsafe"19 unsafe "unsafe"
@@ -120,16 +121,208 @@ func (x *SessionEpoch) GetEpoch() uint64 {
120 return 0121 return 0
121 }122 }
122 123
124+// PasskeyUser is stored under the key "passkey/user": the WebAuthn user
125+// handle of the owner, generated on first use. It identifies the owner to
126+// authenticators and never changes.
127+type PasskeyUser struct {
128+ state protoimpl.MessageState `protogen:"open.v1"`
129+ // 32 random bytes.
130+ Handle []byte `protobuf:"bytes,1,opt,name=handle,proto3" json:"handle,omitempty"`
131+ unknownFields protoimpl.UnknownFields
132+ sizeCache protoimpl.SizeCache
133+}
134+
135+func (x *PasskeyUser) Reset() {
136+ *x = PasskeyUser{}
137+ mi := &file_ermite_storage_session_proto_msgTypes[2]
138+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
139+ ms.StoreMessageInfo(mi)
140+}
141+
142+func (x *PasskeyUser) String() string {
143+ return protoimpl.X.MessageStringOf(x)
144+}
145+
146+func (*PasskeyUser) ProtoMessage() {}
147+
148+func (x *PasskeyUser) ProtoReflect() protoreflect.Message {
149+ mi := &file_ermite_storage_session_proto_msgTypes[2]
150+ if x != nil {
151+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
152+ if ms.LoadMessageInfo() == nil {
153+ ms.StoreMessageInfo(mi)
154+ }
155+ return ms
156+ }
157+ return mi.MessageOf(x)
158+}
159+
160+// Deprecated: Use PasskeyUser.ProtoReflect.Descriptor instead.
161+func (*PasskeyUser) Descriptor() ([]byte, []int) {
162+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{2}
163+}
164+
165+func (x *PasskeyUser) GetHandle() []byte {
166+ if x != nil {
167+ return x.Handle
168+ }
169+ return nil
170+}
171+
172+// Passkey is stored under the key "passkey/cred/<base64url credential id>":
173+// a WebAuthn credential that can sign the owner in (ADR 0026). It holds the
174+// public key only.
175+type Passkey struct {
176+ state protoimpl.MessageState `protogen:"open.v1"`
177+ // Credential id, at most 1023 bytes.
178+ Id []byte `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
179+ // Name given by the owner.
180+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
181+ // COSE public key.
182+ PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"`
183+ AttestationType string `protobuf:"bytes,4,opt,name=attestation_type,json=attestationType,proto3" json:"attestation_type,omitempty"`
184+ Transports []string `protobuf:"bytes,5,rep,name=transports,proto3" json:"transports,omitempty"`
185+ // Last signature counter; 0 for synced passkeys.
186+ SignCount uint32 `protobuf:"varint,6,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"`
187+ BackupEligible bool `protobuf:"varint,7,opt,name=backup_eligible,json=backupEligible,proto3" json:"backup_eligible,omitempty"`
188+ BackupState bool `protobuf:"varint,8,opt,name=backup_state,json=backupState,proto3" json:"backup_state,omitempty"`
189+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
190+ // Unset until the passkey is first used.
191+ LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
192+ unknownFields protoimpl.UnknownFields
193+ sizeCache protoimpl.SizeCache
194+}
195+
196+func (x *Passkey) Reset() {
197+ *x = Passkey{}
198+ mi := &file_ermite_storage_session_proto_msgTypes[3]
199+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
200+ ms.StoreMessageInfo(mi)
201+}
202+
203+func (x *Passkey) String() string {
204+ return protoimpl.X.MessageStringOf(x)
205+}
206+
207+func (*Passkey) ProtoMessage() {}
208+
209+func (x *Passkey) ProtoReflect() protoreflect.Message {
210+ mi := &file_ermite_storage_session_proto_msgTypes[3]
211+ if x != nil {
212+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
213+ if ms.LoadMessageInfo() == nil {
214+ ms.StoreMessageInfo(mi)
215+ }
216+ return ms
217+ }
218+ return mi.MessageOf(x)
219+}
220+
221+// Deprecated: Use Passkey.ProtoReflect.Descriptor instead.
222+func (*Passkey) Descriptor() ([]byte, []int) {
223+ return file_ermite_storage_session_proto_rawDescGZIP(), []int{3}
224+}
225+
226+func (x *Passkey) GetId() []byte {
227+ if x != nil {
228+ return x.Id
229+ }
230+ return nil
231+}
232+
233+func (x *Passkey) GetName() string {
234+ if x != nil {
235+ return x.Name
236+ }
237+ return ""
238+}
239+
240+func (x *Passkey) GetPublicKey() []byte {
241+ if x != nil {
242+ return x.PublicKey
243+ }
244+ return nil
245+}
246+
247+func (x *Passkey) GetAttestationType() string {
248+ if x != nil {
249+ return x.AttestationType
250+ }
251+ return ""
252+}
253+
254+func (x *Passkey) GetTransports() []string {
255+ if x != nil {
256+ return x.Transports
257+ }
258+ return nil
259+}
260+
261+func (x *Passkey) GetSignCount() uint32 {
262+ if x != nil {
263+ return x.SignCount
264+ }
265+ return 0
266+}
267+
268+func (x *Passkey) GetBackupEligible() bool {
269+ if x != nil {
270+ return x.BackupEligible
271+ }
272+ return false
273+}
274+
275+func (x *Passkey) GetBackupState() bool {
276+ if x != nil {
277+ return x.BackupState
278+ }
279+ return false
280+}
281+
282+func (x *Passkey) GetCreatedAt() *timestamppb.Timestamp {
283+ if x != nil {
284+ return x.CreatedAt
285+ }
286+ return nil
287+}
288+
289+func (x *Passkey) GetLastUsedAt() *timestamppb.Timestamp {
290+ if x != nil {
291+ return x.LastUsedAt
292+ }
293+ return nil
294+}
295+
123 var File_ermite_storage_session_proto protoreflect.FileDescriptor296 var File_ermite_storage_session_proto protoreflect.FileDescriptor
124 297
125 const file_ermite_storage_session_proto_rawDesc = "" +298 const file_ermite_storage_session_proto_rawDesc = "" +
126 "\n" +299 "\n" +
127- "\x1cermite/storage/session.proto\x12\x0eermite.storage\"\x1e\n" +300+ "\x1cermite/storage/session.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x1e\n" +
128 "\n" +301 "\n" +
129 "SessionKey\x12\x10\n" +302 "SessionKey\x12\x10\n" +
130 "\x03key\x18\x01 \x01(\fR\x03key\"$\n" +303 "\x03key\x18\x01 \x01(\fR\x03key\"$\n" +
131 "\fSessionEpoch\x12\x14\n" +304 "\fSessionEpoch\x12\x14\n" +
132- "\x05epoch\x18\x01 \x01(\x04R\x05epochB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"305+ "\x05epoch\x18\x01 \x01(\x04R\x05epoch\"%\n" +
306+ "\vPasskeyUser\x12\x16\n" +
307+ "\x06handle\x18\x01 \x01(\fR\x06handle\"\xfb\x02\n" +
308+ "\aPasskey\x12\x0e\n" +
309+ "\x02id\x18\x01 \x01(\fR\x02id\x12\x12\n" +
310+ "\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" +
311+ "\n" +
312+ "public_key\x18\x03 \x01(\fR\tpublicKey\x12)\n" +
313+ "\x10attestation_type\x18\x04 \x01(\tR\x0fattestationType\x12\x1e\n" +
314+ "\n" +
315+ "transports\x18\x05 \x03(\tR\n" +
316+ "transports\x12\x1d\n" +
317+ "\n" +
318+ "sign_count\x18\x06 \x01(\rR\tsignCount\x12'\n" +
319+ "\x0fbackup_eligible\x18\a \x01(\bR\x0ebackupEligible\x12!\n" +
320+ "\fbackup_state\x18\b \x01(\bR\vbackupState\x129\n" +
321+ "\n" +
322+ "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
323+ "\flast_used_at\x18\n" +
324+ " \x01(\v2\x1a.google.protobuf.TimestampR\n" +
325+ "lastUsedAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
133 326
134 var (327 var (
135 file_ermite_storage_session_proto_rawDescOnce sync.Once328 file_ermite_storage_session_proto_rawDescOnce sync.Once
@@ -143,17 +336,22 @@ func file_ermite_storage_session_proto_rawDescGZIP() []byte {
143 return file_ermite_storage_session_proto_rawDescData336 return file_ermite_storage_session_proto_rawDescData
144 }337 }
145 338
146-var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 2)339+var file_ermite_storage_session_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
147 var file_ermite_storage_session_proto_goTypes = []any{340 var file_ermite_storage_session_proto_goTypes = []any{
148- (*SessionKey)(nil), // 0: ermite.storage.SessionKey341+ (*SessionKey)(nil), // 0: ermite.storage.SessionKey
149- (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch342+ (*SessionEpoch)(nil), // 1: ermite.storage.SessionEpoch
343+ (*PasskeyUser)(nil), // 2: ermite.storage.PasskeyUser
344+ (*Passkey)(nil), // 3: ermite.storage.Passkey
345+ (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
150 }346 }
151 var file_ermite_storage_session_proto_depIdxs = []int32{347 var file_ermite_storage_session_proto_depIdxs = []int32{
152- 0, // [0:0] is the sub-list for method output_type348+ 4, // 0: ermite.storage.Passkey.created_at:type_name -> google.protobuf.Timestamp
153- 0, // [0:0] is the sub-list for method input_type349+ 4, // 1: ermite.storage.Passkey.last_used_at:type_name -> google.protobuf.Timestamp
154- 0, // [0:0] is the sub-list for extension type_name350+ 2, // [2:2] is the sub-list for method output_type
155- 0, // [0:0] is the sub-list for extension extendee351+ 2, // [2:2] is the sub-list for method input_type
156- 0, // [0:0] is the sub-list for field type_name352+ 2, // [2:2] is the sub-list for extension type_name
353+ 2, // [2:2] is the sub-list for extension extendee
354+ 0, // [0:2] is the sub-list for field type_name
157 }355 }
158 356
159 func init() { file_ermite_storage_session_proto_init() }357 func init() { file_ermite_storage_session_proto_init() }
@@ -167,7 +365,7 @@ func file_ermite_storage_session_proto_init() {
167 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),365 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
168 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)),366 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_session_proto_rawDesc), len(file_ermite_storage_session_proto_rawDesc)),
169 NumEnums: 0,367 NumEnums: 0,
170- NumMessages: 2,368+ NumMessages: 4,
171 NumExtensions: 0,369 NumExtensions: 0,
172 NumServices: 0,370 NumServices: 0,
173 },371 },
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