detached 075280b

Merge pull request 'feat(web): sign in with owner passkeys' (#14) from feat/passkeys into main

commit
075280b0356c05ded5a782219d0c9a7b94201b67
author
bnema · · 2026-10-12 20:31 UTC
merge of
98d2616 + eca6041 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
89 files · +7974 -207
M README.md +9 -1
@@ -74,6 +74,7 @@ ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [-
7474 ermite runner list | remove <id>
7575 ermite web login # prints a one-time sign-in link (60 s)
7676 ermite web logout --all # signs every browser out
77+ermite web passkey list | remove <id> # the owner's passkeys; removing one signs every browser out
7778 ermite signing-key show # public key and allowed_signers line of squash commits
7879 ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file
7980 ermite version # client and server, warns about missing server features
@@ -118,7 +119,14 @@ Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts
118119
119120 ## Web sign-in
120121
121-`ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming sets a session cookie for 12 hours (never longer than the token that asked for the link). It needs `https` in `ERMITE_PUBLIC_URL` (except on localhost). Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. `ermite web logout --all`, or revoking a token with every scope, ends every session.
122+Two ways in, both for the owner, both giving the same session cookie for 12 hours. Neither works over plain `http` except on localhost: the cookie is `Secure`.
123+
124+- **Passkey** (a phone, a laptop, a security key): open `/-/login` and press "Sign in with a passkey". Nothing to type, no terminal. A passkey is added once from `/-/passkeys` while signed in.
125+- **Link**: `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming signs the browser in (never longer than the token that asked for the link). It is also how to sign in the first time, before any passkey exists.
126+
127+Passkeys need `ERMITE_PUBLIC_URL` to be `https` (or `http://localhost`) with a **host name, not an IP address**: WebAuthn ties a passkey to that name, and a passkey registered for one name does not work on another. On `http://127.0.0.1:…` the server starts without passkeys (the log says `passkeys disabled`, the routes and links are absent, `ermite version` does not list `passkeys`). Up to 20 passkeys can be registered; each asks for user verification (a fingerprint, a face or a PIN), and the server stores only the public key. A security key that reports a signature counter is checked for cloning; synced passkeys (iCloud, Google, a password manager) report none, so they are not.
128+
129+`ermite web passkey list` shows them, `ermite web passkey remove <id>` removes one. Removing a passkey, `ermite web logout --all`, or revoking a token with every scope ends every session, on every device. Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. If you lose every device, `ermite web login` still works from a terminal.
122130
123131 The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.
124132
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 },
M go.mod +8 -0
@@ -21,6 +21,8 @@ require (
2121 github.com/alecthomas/chroma/v2 v2.27.0
2222 github.com/bnema/kvconf v0.1.2
2323 github.com/bnema/zerowrap v1.4.1
24+ github.com/descope/virtualwebauthn v1.0.5
25+ github.com/go-webauthn/webauthn v0.18.2
2426 github.com/golang-jwt/jwt/v5 v5.3.1
2527 github.com/spf13/cobra v1.10.2
2628 github.com/stretchr/testify v1.12.1
@@ -81,14 +83,17 @@ require (
8183 github.com/fatih/structs v1.1.0 // indirect
8284 github.com/felixge/httpsnoop v1.1.0 // indirect
8385 github.com/fsnotify/fsnotify v1.9.0 // indirect
86+ github.com/fxamacker/cbor/v2 v2.9.4 // indirect
8487 github.com/go-logr/logr v1.4.4 // indirect
8588 github.com/go-logr/stdr v1.2.2 // indirect
8689 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
90+ github.com/go-webauthn/x v0.3.1 // indirect
8791 github.com/gobwas/glob v0.2.3 // indirect
8892 github.com/gofrs/flock v0.13.1 // indirect
8993 github.com/gohugoio/hashstructure v0.6.0 // indirect
9094 github.com/gohugoio/hugo v0.164.0 // indirect
9195 github.com/google/go-containerregistry v0.22.1 // indirect
96+ github.com/google/go-tpm v0.9.8 // indirect
9297 github.com/google/uuid v1.6.0 // indirect
9398 github.com/huandu/xstrings v1.5.0 // indirect
9499 github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -118,6 +123,7 @@ require (
118123 github.com/pelletier/go-toml v1.9.5 // indirect
119124 github.com/pelletier/go-toml/v2 v2.4.3 // indirect
120125 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
126+ github.com/philhofer/fwd v1.2.0 // indirect
121127 github.com/quic-go/qpack v0.6.0 // indirect
122128 github.com/quic-go/quic-go v0.62.0 // indirect
123129 github.com/rivo/uniseg v0.4.7 // indirect
@@ -134,7 +140,9 @@ require (
134140 github.com/tdewolff/parse/v2 v2.8.12 // indirect
135141 github.com/tetratelabs/wazero v1.12.0 // indirect
136142 github.com/tidwall/btree v1.8.1 // indirect
143+ github.com/tinylib/msgp v1.6.4 // indirect
137144 github.com/vektra/mockery/v3 v3.8.0 // indirect
145+ github.com/x448/float16 v0.8.4 // indirect
138146 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
139147 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
140148 github.com/xeipuuv/gojsonschema v1.2.0 // indirect
M go.sum +20 -2 generated file: collapsed
M internal/adapters/client/client_test.go +6 -1
@@ -140,10 +140,15 @@ func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners},
143- client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
143+ client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
147+ client.FeaturePasskeys,
148+ }))
149+ assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
147152 }))
148153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
149154 }
M internal/adapters/client/features.go +5 -1
@@ -22,6 +22,10 @@ const (
2222 FeatureSigningKey = "signing_key"
2323 // FeatureSession is SessionService (`ermite web login`, `web logout`).
2424 FeatureSession = "session"
25+ // FeaturePasskeys is SessionService.ListPasskeys and RemovePasskey
26+ // (`ermite web passkey`). A server lists it only when passkeys are
27+ // available (https public URL with a DNS host name).
28+ FeaturePasskeys = "passkeys"
2529 // FeatureTokenBranchPrefixes is Token.branch_prefixes and
2630 // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`).
2731 // A server without it ignores the field and would create an unlimited
@@ -37,7 +41,7 @@ const (
3741
3842 // UsedFeatures lists the features the CLI uses, in the order they are reported.
3943 func UsedFeatures() []string {
40- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners}
44+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeaturePasskeys}
4145 }
4246
4347 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/mergerequests_test.go +11 -2
@@ -360,7 +360,8 @@ func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain
360360 // TestDeleteRacingWithWrites: writes racing with Delete either land before it
361361 // and are deleted, or are refused (not found after it, or a conflict when the
362362 // retries run out under this contention); nothing is left for a repository
363-// created again under the name.
363+// created again under the name. Delete itself may report a conflict under the
364+// same contention and is then called again.
364365 func TestDeleteRacingWithWrites(t *testing.T) {
365366 ctx := context.Background()
366367 for round := range 20 {
@@ -394,7 +395,15 @@ func TestDeleteRacingWithWrites(t *testing.T) {
394395 }
395396 }()
396397 }
397- delErr := s.Delete(ctx, n)
398+ // Delete gives up with a conflict when the writers keep rewriting the
399+ // repository key for deleteTries listings in a row; it is documented as
400+ // safe to call again, as a caller does.
401+ var delErr error
402+ for range 20 {
403+ if delErr = s.Delete(ctx, n); !errors.Is(delErr, domain.ErrConflict) {
404+ break
405+ }
406+ }
398407 wg.Wait()
399408 require.NoError(t, delErr)
400409
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+}
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+}
M internal/adapters/rpc/api.go +16 -3
@@ -10,6 +10,7 @@ import (
1010 "context"
1111 "net/http"
1212 "net/netip"
13+ "slices"
1314
1415 "connectrpc.com/connect"
1516 "github.com/bnema/zerowrap"
@@ -25,9 +26,13 @@ import (
2526 const MaxRequestBytes = 1 << 20
2627
2728 // Features are the optional capabilities of the contract that this server
28-// implements, as GetVersion lists them.
29+// always implements, as GetVersion lists them. FeaturePasskeys comes on top
30+// when Config.Passkeys is set.
2931 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}
3032
33+// FeaturePasskeys is listed by GetVersion when passkeys are available.
34+const FeaturePasskeys = "passkeys"
35+
3136 // Config configures the API.
3237 type Config struct {
3338 Auth ports.AuthService
@@ -55,6 +60,10 @@ type Config struct {
5560 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
5661 // the interface is not nil, and the service would be served and called).
5762 Sessions ports.SessionService
63+ // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are
64+ // not available (the RPCs answer failed_precondition and GetVersion does
65+ // not list "passkeys"). Same rule as Sessions about typed nil pointers.
66+ Passkeys ports.PasskeyService
5867 }
5968
6069 // API is the set of Connect handlers of the contract.
@@ -81,10 +90,14 @@ func New(cfg Config) *API {
8190 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
8291 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
8392 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
84- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
93+ features := slices.Clone(Features)
94+ if cfg.Passkeys != nil {
95+ features = append(features, FeaturePasskeys)
96+ }
97+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
8598 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
8699 if cfg.Sessions != nil {
87- mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
100+ mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
88101 }
89102 if cfg.Runner != nil {
90103 mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
M internal/adapters/rpc/convert.go +17 -0
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "encoding/base64"
67 "fmt"
78 "slices"
89 "time"
@@ -411,3 +412,19 @@ func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility
411412 }
412413 return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
413414 }
415+
416+// passkeysToPB converts passkeys; the credential id is base64url without
417+// padding, the form RemovePasskey takes back. Key material never leaves.
418+func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
419+ out := make([]*pb.Passkey, 0, len(list))
420+ for _, p := range list {
421+ out = append(out, &pb.Passkey{
422+ Id: base64.RawURLEncoding.EncodeToString(p.ID),
423+ Name: p.Name,
424+ CreatedAt: timestamp(p.CreatedAt),
425+ LastUsedAt: timestamp(p.LastUsedAt),
426+ Synced: p.BackupState,
427+ })
428+ }
429+ return out
430+}
M internal/adapters/rpc/server.go +4 -3
@@ -14,12 +14,13 @@ import (
1414
1515 // serverService implements ermiteconnect.ServerServiceHandler.
1616 type serverService struct {
17- version string
18- key ports.SigningKeyService
17+ version string
18+ features []string
19+ key ports.SigningKeyService
1920 }
2021
2122 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
22- return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil
23+ return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(s.features)}), nil
2324 }
2425
2526 // GetSigningKey returns the public key of the commits ermite signs. It needs
M internal/adapters/rpc/server_test.go +27 -3
@@ -4,28 +4,33 @@ package rpc
44
55 import (
66 "context"
7+ "slices"
78 "testing"
89
10+ "net/http/httptest"
11+
912 "connectrpc.com/connect"
1013 "github.com/stretchr/testify/assert"
1114 "github.com/stretchr/testify/require"
1215
1316 pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1418 "bnema.dev/ermite/internal/domain"
1519 "bnema.dev/ermite/internal/ports/mocks"
1620 )
1721
1822 func TestServerServiceGetVersion(t *testing.T) {
1923 t.Parallel()
20- svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)}
24+ advertised := []string{"one", "two"}
25+ svc := &serverService{version: "v1.2.3", features: advertised, key: mocks.NewSigningKeyService(t)}
2126 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
2227 require.NoError(t, err)
2328 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())
24- assert.Equal(t, Features, resp.Msg.GetFeatures())
29+ assert.Equal(t, []string{"one", "two"}, resp.Msg.GetFeatures())
2530
2631 // The caller gets a copy: changing it does not change what is advertised.
2732 resp.Msg.Features[0] = "tampered"
28- assert.NotEqual(t, "tampered", Features[0])
33+ assert.Equal(t, []string{"one", "two"}, advertised)
2934 }
3035
3136 func TestServerServiceGetSigningKey(t *testing.T) {
@@ -43,3 +48,22 @@ func TestServerServiceGetSigningKey(t *testing.T) {
4348 assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail())
4449 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())
4550 }
51+
52+// GetVersion lists "passkeys" only when the API is built with passkeys.
53+func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
54+ t.Parallel()
55+ list := func(cfg Config) []string {
56+ cfg.Sessions = mocks.NewSessionService(t)
57+ cfg.SigningKey = mocks.NewSigningKeyService(t)
58+ srv := httptest.NewServer(New(cfg).Handler())
59+ t.Cleanup(srv.Close)
60+ c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
61+ resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
62+ require.NoError(t, err)
63+ return resp.Msg.GetFeatures()
64+ }
65+ assert.Equal(t, Features, list(Config{}))
66+ got := list(Config{Passkeys: mocks.NewPasskeyService(t)})
67+ assert.Equal(t, append(slices.Clone(Features), "passkeys"), got)
68+ assert.NotContains(t, Features, "passkeys", "the shared list is not modified")
69+}
M internal/adapters/rpc/session.go +32 -9
@@ -4,22 +4,28 @@ package rpc
44
55 import (
66 "context"
7+ "encoding/base64"
78 "errors"
9+ "fmt"
810
911 "connectrpc.com/connect"
1012
1113 pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
1215 "bnema.dev/ermite/internal/ports"
1316 )
1417
15-// errPasskeysUnavailable is what the passkey RPCs answer: this server has no
16-// passkeys, which the contract reports as failed_precondition. The passkey
17-// feature replaces these two methods.
18+// errPasskeysUnavailable is what the passkey RPCs answer when the server runs
19+// without passkeys (no https public URL with a DNS host name).
1820 var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
1921
2022 // sessionService implements ermiteconnect.SessionServiceHandler. The browser
21-// never calls it: sessions come from the link, and the API ignores cookies.
22-type sessionService struct{ sessions ports.SessionService }
23+// never calls it: sessions come from the link or a passkey, and the API
24+// ignores cookies. passkeys is nil when passkeys are not available.
25+type sessionService struct {
26+ sessions ports.SessionService
27+ passkeys ports.PasskeyService
28+}
2329
2430 func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {
2531 link, err := s.sessions.CreateLoginLink(ctx, caller(ctx))
@@ -36,10 +42,27 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque
3642 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
3743 }
3844
39-func (s *sessionService) ListPasskeys(context.Context, *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) {
40- return nil, errPasskeysUnavailable
45+func (s *sessionService) ListPasskeys(ctx context.Context, _ *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) {
46+ if s.passkeys == nil {
47+ return nil, errPasskeysUnavailable
48+ }
49+ list, err := s.passkeys.List(ctx, caller(ctx))
50+ if err != nil {
51+ return nil, connectError(ctx, err)
52+ }
53+ return connect.NewResponse(&pb.ListPasskeysResponse{Passkeys: passkeysToPB(list)}), nil
4154 }
4255
43-func (s *sessionService) RemovePasskey(context.Context, *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) {
44- return nil, errPasskeysUnavailable
56+func (s *sessionService) RemovePasskey(ctx context.Context, req *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) {
57+ if s.passkeys == nil {
58+ return nil, errPasskeysUnavailable
59+ }
60+ id, err := base64.RawURLEncoding.DecodeString(req.Msg.GetId())
61+ if err != nil || len(id) == 0 {
62+ return nil, connectError(ctx, fmt.Errorf("%w: id is not a base64url credential id", domain.ErrInvalid))
63+ }
64+ if err := s.passkeys.Remove(ctx, caller(ctx), id); err != nil {
65+ return nil, connectError(ctx, err)
66+ }
67+ return connect.NewResponse(&pb.RemovePasskeyResponse{}), nil
4568 }
M internal/adapters/rpc/session_test.go +97 -2
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "encoding/base64"
67 "testing"
78 "time"
89
@@ -35,7 +36,7 @@ func TestSessionServiceCreateLoginLink(t *testing.T) {
3536 sessions := mocks.NewSessionService(t)
3637 sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()).
3738 Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once()
38- resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
39+ resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
3940 if tt.wantCode != 0 {
4041 requireCode(t, tt.wantCode, err)
4142 return
@@ -62,7 +63,7 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) {
6263 t.Parallel()
6364 sessions := mocks.NewSessionService(t)
6465 sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once()
65- _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
66+ _, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
6667 if tt.wantCode != 0 {
6768 requireCode(t, tt.wantCode, err)
6869 return
@@ -71,3 +72,97 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) {
7172 })
7273 }
7374 }
75+
76+func TestSessionServiceListPasskeys(t *testing.T) {
77+ t.Parallel()
78+ created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
79+ used := created.Add(time.Hour)
80+ tests := []struct {
81+ name string
82+ list []domain.Passkey
83+ usecase error
84+ wantCode connect.Code
85+ }{
86+ {name: "none", list: nil},
87+ {name: "two", list: []domain.Passkey{
88+ {ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true},
89+ {ID: []byte("hw"), Name: "key", CreatedAt: created},
90+ }},
91+ {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
92+ {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
93+ }
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ t.Parallel()
97+ passkeys := mocks.NewPasskeyService(t)
98+ passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once()
99+ resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
100+ if tt.wantCode != 0 {
101+ requireCode(t, tt.wantCode, err)
102+ return
103+ }
104+ require.NoError(t, err)
105+ require.Len(t, resp.Msg.GetPasskeys(), len(tt.list))
106+ for i, want := range tt.list {
107+ got := resp.Msg.GetPasskeys()[i]
108+ assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId())
109+ assert.Equal(t, want.Name, got.GetName())
110+ assert.Equal(t, want.BackupState, got.GetSynced())
111+ assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime()))
112+ if want.LastUsedAt.IsZero() {
113+ assert.Nil(t, got.GetLastUsedAt(), "never used")
114+ } else {
115+ assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime()))
116+ }
117+ }
118+ })
119+ }
120+}
121+
122+func TestSessionServiceRemovePasskey(t *testing.T) {
123+ t.Parallel()
124+ id := []byte{0xfb, 0xff, 0x01, 'x'}
125+ enc := base64.RawURLEncoding.EncodeToString(id)
126+ tests := []struct {
127+ name string
128+ id string
129+ usecase error
130+ called bool
131+ wantCode connect.Code
132+ }{
133+ {name: "ok", id: enc, called: true},
134+ {name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
135+ {name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
136+ {name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
137+ {name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument},
138+ {name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument},
139+ {name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument},
140+ {name: "empty", id: "", wantCode: connect.CodeInvalidArgument},
141+ }
142+ for _, tt := range tests {
143+ t.Run(tt.name, func(t *testing.T) {
144+ t.Parallel()
145+ passkeys := mocks.NewPasskeyService(t)
146+ if tt.called {
147+ passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once()
148+ }
149+ _, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id}))
150+ if tt.wantCode != 0 {
151+ requireCode(t, tt.wantCode, err)
152+ return
153+ }
154+ require.NoError(t, err)
155+ })
156+ }
157+}
158+
159+// Without passkeys the RPCs answer failed_precondition and call nothing.
160+func TestSessionServicePasskeysUnavailable(t *testing.T) {
161+ t.Parallel()
162+ svc := &sessionService{sessions: mocks.NewSessionService(t)}
163+ _, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
164+ requireCode(t, connect.CodeFailedPrecondition, err)
165+ assert.Contains(t, err.Error(), "passkeys are not available on this server")
166+ _, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"}))
167+ requireCode(t, connect.CodeFailedPrecondition, err)
168+}
M internal/adapters/web/ASSETS.md +1 -0
@@ -11,6 +11,7 @@ content-hashed names. Nothing is fetched at runtime.
1111 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1212 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1313 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
14+| `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
1415 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1516 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1617 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +2 -1
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
2323 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2424 // the content, so they can be cached for ever.
2525 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Log, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string
2727 // Font is the UI font, preloaded by every page.
2828 Font string
2929 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
5656 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5858 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
5960 {"log.js", func(a *Assets, u string) { a.Log = u }},
6061 }
6162
M internal/adapters/web/jstest/dom.mjs +3 -1
@@ -143,10 +143,11 @@ export class El {
143143 return null;
144144 }
145145 scrollIntoView() {}
146+ focus() { dom.focused.push(this); }
146147 click() { dom.clicked.push(this); }
147148 }
148149
149-export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150+export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150151
151152 // flushFrames runs the frames held since the last flush.
152153 export function flushFrames() {
@@ -160,6 +161,7 @@ export function flushFrames() {
160161 export function installDom({ parseHTML } = {}) {
161162 dom.body = new El('body');
162163 dom.clicked = [];
164+ dom.focused = [];
163165 const doc = {
164166 body: dom.body,
165167 createElement(tag) {
A internal/adapters/web/jstest/passkey.test.mjs +470 -0
@@ -0,0 +1,470 @@
1+// Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and
2+// navigator.credentials stubbed. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test as nodeTest, beforeEach, afterEach } from 'node:test';
6+import { dom, el, installDom } from './dom.mjs';
7+
8+// Every test has two seconds: a ceremony that never settles fails the test
9+// instead of hanging the run.
10+const test = (title, fn) => nodeTest(title, { timeout: 2000 }, fn);
11+
12+const doc = installDom({ parseHTML: () => [] });
13+// The script registers its listeners when it loads: record them.
14+const listeners = [];
15+doc.addEventListener = (type, fn) => { listeners.push([type, fn]); };
16+// Node defines navigator as a getter: replace it, as a browser test would.
17+const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
18+setNavigator({ credentials: {} });
19+globalThis.PublicKeyCredential = undefined;
20+await import('../static/js/passkey.js');
21+const P = window.ermitePasskey;
22+
23+const OPTIONS = { publicKey: { challenge: 'abc' } };
24+const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
25+
26+let fetches; // {url, init}
27+let answers; // url -> {ok, status, body} (body undefined: not JSON)
28+let parsed; // [kind, publicKey]
29+let nav; // where the page was sent: 'assign:/' or 'reload'
30+let status;
31+let name;
32+
33+// page builds the DOM of the login page (a form with the sign-in button) or of
34+// the passkeys page (a form with the name field and the add button). Both
35+// buttons are submit buttons of a [data-passkey-form].
36+function page(kind) {
37+ dom.body = el('body');
38+ dom.focused = [];
39+ status = el('p', { attrs: { 'data-passkey-status': '' } });
40+ name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
41+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish', type: 'submit' } });
42+ const form = el('form', { attrs: { 'data-passkey-form': '' } }, ...(kind === 'register' ? [name, btn] : [btn]));
43+ dom.body.append(status, form);
44+ btn.form = form;
45+ return btn;
46+}
47+
48+// pending holds the resolvers of the authenticator prompts a test left open;
49+// afterEach answers them, so that no promise outlives its test.
50+let pending = [];
51+const prompt = (value) => new Promise((resolve) => { pending.push(() => resolve(value)); });
52+afterEach(() => { pending.forEach((release) => release()); pending = []; });
53+
54+beforeEach(() => {
55+ fetches = [];
56+ parsed = [];
57+ nav = '';
58+ answers = {
59+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
60+ '/finish': { ok: true, status: 204, body: undefined },
61+ };
62+ globalThis.fetch = async (url, init) => {
63+ fetches.push({ url, init });
64+ const a = answers[url];
65+ return {
66+ ok: a.ok,
67+ status: a.status,
68+ json: async () => {
69+ if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input');
70+ return a.body;
71+ },
72+ };
73+ };
74+ globalThis.PublicKeyCredential = {
75+ parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
76+ parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; },
77+ };
78+ setNavigator({
79+ credentials: {
80+ get: async (o) => { navigator.got = o; return CRED; },
81+ create: async (o) => { navigator.created = o; return CRED; },
82+ },
83+ });
84+ globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
85+});
86+
87+// submitOn sends a submit event from a form (a click on its submit button, or
88+// Enter in a field). It answers the promise of the handler and whether the
89+// browser's own navigation was kept (no preventDefault).
90+const submitOn = (target) => {
91+ let prevented = false;
92+ const p = P.submit({ target, preventDefault() { prevented = true; } });
93+ return { p, prevented: () => prevented };
94+};
95+const submitForm = (btn) => submitOn(btn.form).p;
96+const jsonBody = (i) => JSON.parse(fetches[i].init.body);
97+const beginCalls = () => fetches.filter((f) => f.url === '/begin').length;
98+const tick = () => new Promise((r) => setTimeout(r, 0));
99+
100+test('the script registers exactly one submit listener and no click listener', () => {
101+ assert.deepEqual(listeners.map(([t]) => t), ['submit']);
102+ assert.equal(listeners[0][1], P.submit);
103+ assert.deepEqual(Object.keys(P).sort(), ['init', 'nameProblem', 'submit'], 'only what the tests need is exported');
104+});
105+
106+test('login: begin, get, finish with toJSON(), then home', async () => {
107+ const btn = page('login');
108+ const { p, prevented } = submitOn(btn.form);
109+ await p;
110+
111+ assert.equal(prevented(), true, 'the form never navigates by itself');
112+ assert.equal(fetches.length, 2);
113+ assert.equal(fetches[0].url, '/begin');
114+ assert.equal(fetches[0].init.method, 'POST');
115+ assert.equal(fetches[0].init.credentials, 'same-origin');
116+ assert.equal(fetches[0].init.body, undefined, 'a sign-in sends no name');
117+ assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
118+ assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
119+
120+ assert.equal(fetches[1].url, '/finish');
121+ assert.equal(fetches[1].init.method, 'POST');
122+ assert.equal(fetches[1].init.credentials, 'same-origin');
123+ assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
124+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } });
125+ assert.equal(nav, 'assign:/');
126+ assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
127+});
128+
129+test('register: the trimmed name goes to begin only, then reload', async () => {
130+ const btn = page('register');
131+ const { p, prevented } = submitOn(btn.form);
132+ await p;
133+
134+ assert.equal(prevented(), true);
135+ assert.equal(fetches[0].url, '/begin');
136+ assert.equal(fetches[0].init.credentials, 'same-origin');
137+ assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');
138+ assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' });
139+ assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
140+ assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
141+ assert.equal(fetches[1].init.credentials, 'same-origin');
142+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } }, 'the finish body has no name');
143+ assert.equal(nav, 'reload');
144+});
145+
146+test('a submit from another form is not ours and keeps its default', async () => {
147+ page('register');
148+ const other = el('form', {}, el('input'));
149+ dom.body.append(other);
150+ const { p, prevented } = submitOn(other);
151+ await p;
152+ assert.equal(fetches.length, 0);
153+ assert.equal(prevented(), false, 'other forms submit as they always did');
154+});
155+
156+// While the ceremony waits for the authenticator the button is busy and
157+// disabled, the status says so, and a second submit starts nothing: the
158+// disabled button is the guard.
159+test('busy: aria-busy, the waiting text, and a double submit makes one begin', async () => {
160+ const btn = page('login');
161+ navigator.credentials.get = () => prompt(CRED);
162+
163+ const first = submitForm(btn);
164+ await tick();
165+ assert.equal(btn.getAttribute('aria-busy'), 'true');
166+ assert.equal(btn.disabled, true);
167+ assert.equal(status.textContent, 'waiting for your passkey…');
168+
169+ await submitForm(btn); // the double click, or Enter
170+ assert.equal(beginCalls(), 1, 'one begin only');
171+
172+ pending.forEach((release) => release());
173+ await first;
174+ assert.equal(beginCalls(), 1);
175+ assert.equal(nav, 'assign:/');
176+});
177+
178+test('a double submit of the add form makes one begin', async () => {
179+ const btn = page('register');
180+ navigator.credentials.create = () => prompt(CRED);
181+ const first = submitForm(btn);
182+ await tick();
183+ await submitForm(btn);
184+ assert.equal(beginCalls(), 1);
185+ pending.forEach((release) => release());
186+ await first;
187+ assert.equal(nav, 'reload');
188+});
189+
190+test('after a failure the page is free for another try', async () => {
191+ const btn = page('register');
192+ answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };
193+ await submitForm(btn);
194+ assert.equal(status.textContent, 'nope');
195+ assert.equal(btn.disabled, false);
196+ await submitForm(btn);
197+ assert.equal(beginCalls(), 2, 'the second try ran');
198+});
199+
200+test('on failure the button is enabled again, not busy, and has the focus', async () => {
201+ const btn = page('login');
202+ answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
203+ await submitForm(btn);
204+ assert.equal(status.textContent, 'passkey not accepted');
205+ assert.equal(btn.disabled, false);
206+ assert.equal(btn.hasAttribute('aria-busy'), false);
207+ assert.equal(nav, '');
208+ assert.deepEqual(dom.focused, [btn], 'the focus is given back');
209+});
210+
211+test('on success the button stays busy and disabled, without stealing focus', async () => {
212+ const btn = page('login');
213+ await submitForm(btn);
214+ assert.equal(btn.disabled, true);
215+ assert.equal(btn.getAttribute('aria-busy'), 'true');
216+ assert.deepEqual(dom.focused, []);
217+});
218+
219+test('begin refused: the server reason is shown, nothing else is called', async () => {
220+ const btn = page('login');
221+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
222+ await submitForm(btn);
223+ assert.equal(status.textContent, 'too many attempts');
224+ assert.equal(fetches.length, 1);
225+ assert.equal(nav, '');
226+ assert.equal(btn.disabled, false);
227+ assert.deepEqual(dom.focused, [btn]);
228+});
229+
230+// The server can refuse the name at begin: the field is what to fix, so it
231+// gets the focus. Any other refusal gives the button the focus.
232+test('a 400 from register/begin focuses the name field', async () => {
233+ const btn = page('register');
234+ answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } };
235+ await submitForm(btn);
236+ assert.match(status.textContent, /control character/);
237+ assert.equal(navigator.created, undefined, 'create() is not called');
238+ assert.equal(btn.disabled, false);
239+ assert.deepEqual(dom.focused, [name]);
240+});
241+
242+test('other refusals of register give the focus back to the button', async () => {
243+ for (const [at, code] of [['/begin', 429], ['/begin', 403], ['/begin', 500], ['/finish', 400], ['/finish', 409]]) {
244+ const btn = page('register');
245+ answers = {
246+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
247+ '/finish': { ok: true, status: 204, body: undefined },
248+ [at]: { ok: false, status: code, body: { error: 'refused' } },
249+ };
250+ await submitForm(btn);
251+ assert.deepEqual(dom.focused, [btn], at + ' ' + code);
252+ }
253+});
254+
255+test('a 400 at begin on the login page focuses the button: there is no name', async () => {
256+ const btn = page('login');
257+ answers['/begin'] = { ok: false, status: 400, body: { error: 'malformed request' } };
258+ await submitForm(btn);
259+ assert.deepEqual(dom.focused, [btn]);
260+});
261+
262+test('a refusal without a JSON body falls back to the status', async () => {
263+ const btn = page('login');
264+ answers['/finish'] = { ok: false, status: 502, body: undefined };
265+ await submitForm(btn);
266+ assert.equal(status.textContent, 'the server answered 502');
267+ assert.equal(nav, '');
268+});
269+
270+test('a 403 without a reason says the session ended', async () => {
271+ for (const body of [undefined, { error: 'forbidden' }, {}]) {
272+ fetches = [];
273+ const btn = page('register');
274+ answers['/begin'] = { ok: false, status: 403, body };
275+ await submitForm(btn);
276+ assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again');
277+ assert.equal(fetches.length, 1);
278+ }
279+ // At finish too.
280+ const btn = page('login');
281+ answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };
282+ answers['/finish'] = { ok: false, status: 403, body: undefined };
283+ await submitForm(btn);
284+ assert.match(status.textContent, /your session ended/);
285+});
286+
287+test('a 403 with a reason of its own shows that reason', async () => {
288+ const btn = page('register');
289+ answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };
290+ await submitForm(btn);
291+ assert.equal(status.textContent, 'the owner must sign in first');
292+});
293+
294+test('the owner cancels the prompt', async () => {
295+ const btn = page('login');
296+ navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
297+ await submitForm(btn);
298+ assert.match(status.textContent, /cancelled, or it took too long/);
299+ assert.equal(fetches.length, 1, 'finish is not called');
300+ assert.equal(btn.disabled, false);
301+ assert.deepEqual(dom.focused, [btn]);
302+});
303+
304+test('AbortError says cancelled', async () => {
305+ const btn = page('login');
306+ navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };
307+ await submitForm(btn);
308+ assert.equal(status.textContent, 'cancelled');
309+ assert.equal(fetches.length, 1);
310+ assert.equal(btn.disabled, false);
311+});
312+
313+test('an authenticator that already holds a passkey', async () => {
314+ const btn = page('register');
315+ navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
316+ await submitForm(btn);
317+ assert.match(status.textContent, /already holds a passkey/);
318+ assert.equal(fetches.length, 1);
319+});
320+
321+test('a network failure is reported', async () => {
322+ const btn = page('login');
323+ globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
324+ await submitForm(btn);
325+ assert.equal(status.textContent, 'Failed to fetch');
326+ assert.equal(btn.disabled, false);
327+});
328+
329+test('a null credential is not sent', async () => {
330+ const btn = page('login');
331+ navigator.credentials.get = async () => null;
332+ await submitForm(btn);
333+ assert.equal(status.textContent, 'no passkey was returned');
334+ assert.equal(fetches.length, 1);
335+});
336+
337+test('a credential without toJSON is reported, not sent', async () => {
338+ for (const kind of ['login', 'register']) {
339+ fetches = [];
340+ const btn = page(kind);
341+ const bare = { id: 'x' };
342+ navigator.credentials.get = async () => bare;
343+ navigator.credentials.create = async () => bare;
344+ await submitForm(btn);
345+ assert.match(status.textContent, /cannot send; update the browser/);
346+ assert.equal(fetches.length, 1, 'finish is not called');
347+ assert.equal(btn.disabled, false);
348+ }
349+});
350+
351+// The begin answer is checked before it reaches the browser API.
352+test('a begin answer that is not JSON', async () => {
353+ const btn = page('login');
354+ answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON
355+ await submitForm(btn);
356+ assert.equal(status.textContent, 'the server sent an unexpected answer');
357+ assert.equal(parsed.length, 0);
358+ assert.equal(btn.disabled, false);
359+});
360+
361+test('a begin answer with the wrong shape', async () => {
362+ for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } },
363+ { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) {
364+ fetches = [];
365+ parsed = [];
366+ const btn = page('login');
367+ answers['/begin'] = { ok: true, status: 200, body };
368+ await submitForm(btn);
369+ assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));
370+ assert.equal(parsed.length, 0, 'nothing reached the browser API');
371+ assert.equal(fetches.length, 1);
372+ }
373+});
374+
375+// A name the server would refuse is caught before the owner is prompted.
376+test('a name over 64 bytes (UTF-8) stops before begin', async () => {
377+ const btn = page('register');
378+ name.value = 'é'.repeat(33); // 33 characters, 66 bytes
379+ await submitForm(btn);
380+ assert.match(status.textContent, /longer than 64 bytes/);
381+ assert.equal(fetches.length, 0, 'no request');
382+ assert.equal(navigator.created, undefined, 'create() is not called');
383+ assert.notEqual(btn.disabled, true, 'the button was never disabled');
384+ assert.deepEqual(dom.focused, [name], 'the field gets the focus');
385+ // The check is not a ceremony: the next try is not blocked.
386+ name.value = 'ok';
387+ await submitForm(btn);
388+ assert.equal(fetches.length, 2);
389+});
390+
391+test('64 bytes is fine, in ASCII or in two-byte characters', async () => {
392+ for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) {
393+ fetches = [];
394+ const btn = page('register'); // a success navigates away: each try is a new page
395+ name.value = ok;
396+ await submitForm(btn);
397+ assert.equal(fetches.length, 2, ok.length + ' characters');
398+ }
399+});
400+
401+test('control and direction characters stop before begin', async () => {
402+ for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) {
403+ fetches = [];
404+ const btn = page('register');
405+ name.value = bad;
406+ await submitForm(btn);
407+ assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));
408+ assert.equal(fetches.length, 0, JSON.stringify(bad));
409+ }
410+});
411+
412+test('plain text with a zero width joiner, accents and emoji is fine', () => {
413+ for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) {
414+ assert.equal(P.nameProblem(ok), '', JSON.stringify(ok));
415+ }
416+});
417+
418+test('an empty name is allowed: the server names the passkey', async () => {
419+ const btn = page('register');
420+ name.value = ' ';
421+ await submitForm(btn);
422+ assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });
423+ assert.equal(fetches.length, 2);
424+});
425+
426+test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
427+ const login = page('login');
428+ const register = el('button', { attrs: { 'data-passkey': 'register' } });
429+ dom.body.append(register);
430+ globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON
431+ P.init();
432+ assert.equal(login.disabled, true);
433+ assert.equal(register.disabled, true);
434+ assert.equal(status.textContent, 'this browser does not support passkeys');
435+
436+ login.disabled = false;
437+ await submitForm(login);
438+ assert.equal(fetches.length, 0);
439+ assert.equal(status.textContent, 'this browser does not support passkeys');
440+});
441+
442+test('no PublicKeyCredential at all', () => {
443+ const btn = page('login');
444+ globalThis.PublicKeyCredential = undefined;
445+ P.init();
446+ assert.equal(btn.disabled, true);
447+ assert.equal(status.textContent, 'this browser does not support passkeys');
448+});
449+
450+test('a supported browser keeps the buttons enabled', () => {
451+ const btn = page('login');
452+ P.init();
453+ assert.equal(btn.disabled, undefined);
454+ assert.equal(status.textContent, '');
455+});
456+
457+test('a disabled button ignores a submit', async () => {
458+ const btn = page('login');
459+ btn.disabled = true;
460+ await submitForm(btn);
461+ assert.equal(fetches.length, 0);
462+});
463+
464+test('register needs parseCreationOptionsFromJSON', async () => {
465+ const btn = page('register');
466+ delete PublicKeyCredential.parseCreationOptionsFromJSON;
467+ await submitForm(btn);
468+ assert.equal(fetches.length, 0);
469+ assert.equal(status.textContent, 'this browser does not support passkeys');
470+});
M internal/adapters/web/pages.templ +86 -0
@@ -183,3 +183,89 @@ templ Login(l Layout, p LoginPage) {
183183 </section>
184184 }
185185 }
186+
187+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
188+// Without script the button is hidden and the page says what to do instead.
189+templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
190+ @Shell(l, nil) {
191+ <section class="state" aria-labelledby="state-title">
192+ <h1 id="state-title">{ p.Title }</h1>
193+ <form class="state-act" data-js data-passkey-form>
194+ <button type="submit" class="btn primary" data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
195+ </form>
196+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
197+ for _, line := range p.Lines {
198+ <p class="dim">{ line }</p>
199+ }
200+ <p class="state-back"><a href="/">Back to home</a></p>
201+ </section>
202+ }
203+}
204+
205+// Passkeys lists the owner's passkeys, removes them and adds one.
206+templ Passkeys(l Layout, p PasskeysPage) {
207+ @Shell(l, nil) {
208+ <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2>
209+ <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>
210+ if len(p.Rows) > 0 {
211+ <table class="list passkeys" aria-label="Passkeys">
212+ <thead>
213+ <tr>
214+ <th class="cur"></th>
215+ <th class="c-name">Name</th>
216+ <th class="c-when">Created</th>
217+ <th class="c-when">Last used</th>
218+ <th class="c-sync">Synced</th>
219+ <th class="c-act"><span class="sr">Remove</span></th>
220+ </tr>
221+ </thead>
222+ <tbody>
223+ for _, r := range p.Rows {
224+ <tr>
225+ <td class="cur" aria-hidden="true"></td>
226+ <td class="c-name">{ r.Name }</td>
227+ <td class="c-when nowrap">
228+ <time
229+ if r.CreatedAt != "" {
230+ datetime={ r.CreatedAt }
231+ }
232+ >{ r.Created }</time>
233+ </td>
234+ <td class="c-when nowrap">
235+ <time
236+ if r.LastUsedAt != "" {
237+ datetime={ r.LastUsedAt }
238+ }
239+ >{ r.LastUsed }</time>
240+ </td>
241+ <td class="c-sync">
242+ if r.Synced {
243+ yes
244+ } else {
245+ no
246+ }
247+ </td>
248+ <td class="c-act">
249+ <form method="post" action={ templ.SafeURL(r.Remove) }>
250+ <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button>
251+ </form>
252+ </td>
253+ </tr>
254+ }
255+ </tbody>
256+ </table>
257+ } else {
258+ <p class="lede dim">no passkeys yet.</p>
259+ }
260+ if p.Full {
261+ <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
262+ } else {
263+ <form class="passkey-add" data-js data-passkey-form>
264+ <label for="passkey-name" class="sr">Name of the new passkey</label>
265+ <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
266+ <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
267+ </form>
268+ }
269+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
270+ }
271+}
M internal/adapters/web/pages_templ.go +390 -0 generated file: collapsed
A internal/adapters/web/passkeys.go +329 -0
@@ -0,0 +1,329 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "mime"
12+ "net/http"
13+ "strconv"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The owner's passkeys on the web (ADR 0026).
24+//
25+// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26+// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27+// makes, each in two steps (begin, finish). Every POST passes the gate: an
28+// Origin equal to the public origin, and a session, except the two login
29+// endpoints, which are how a session starts. The finish endpoints also need
30+// "Content-Type: application/json", which a cross-site form cannot send.
31+
32+const (
33+ passkeyLoginPath = "/-/login"
34+ passkeysPath = "/-/passkeys"
35+
36+ loginBeginPath = passkeysPath + "/login/begin"
37+ loginFinishPath = passkeysPath + "/login/finish"
38+
39+ // maxCeremonyBytes bounds the body of a finish: a response of an
40+ // authenticator is well under 10 KiB.
41+ maxCeremonyBytes = 64 << 10
42+)
43+
44+// passkeysOn tells that the passkey routes exist.
45+func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46+
47+func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48+ mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49+ mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50+ mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51+ mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52+ mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53+ mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54+ mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55+}
56+
57+// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58+// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59+// which is what the router matches.
60+func isPasskeyLoginPath(path string) bool {
61+ return path == loginBeginPath || path == loginFinishPath
62+}
63+
64+// jsonReply writes a JSON answer that no cache keeps.
65+func jsonReply(w http.ResponseWriter, status int, v any) {
66+ h := w.Header()
67+ h.Set("Content-Type", "application/json")
68+ h.Set("Cache-Control", noStore)
69+ w.WriteHeader(status)
70+ _ = json.NewEncoder(w).Encode(v)
71+}
72+
73+type jsonMessage struct {
74+ Error string `json:"error"`
75+}
76+
77+func jsonError(w http.ResponseWriter, status int, msg string) {
78+ jsonReply(w, status, jsonMessage{Error: msg})
79+}
80+
81+// jsonInternal answers a failure that is not the caller's to fix: an id for the
82+// log, and no detail.
83+func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84+ id := requestID()
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87+ jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88+}
89+
90+// jsonBody checks the content type of a finish and reads its body, bounded. It
91+// answers 415 or 413 and returns false when it cannot.
92+func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93+ mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94+ if err != nil || mt != "application/json" {
95+ jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96+ return nil, false
97+ }
98+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99+ if err != nil {
100+ var tooBig *http.MaxBytesError
101+ if errors.As(err, &tooBig) {
102+ jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103+ } else {
104+ jsonError(w, http.StatusBadRequest, "request could not be read")
105+ }
106+ return nil, false
107+ }
108+ return body, true
109+}
110+
111+// ceremonyReply is the answer of a begin.
112+type ceremonyReply struct {
113+ Ceremony string `json:"ceremony"`
114+ Options json.RawMessage `json:"options"`
115+}
116+
117+// blocked answers 429 when the client has failed too many sign-ins.
118+func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119+ addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120+ if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122+ jsonError(w, http.StatusTooManyRequests, "too many attempts")
123+ return addr, true
124+ }
125+ return addr, false
126+}
127+
128+// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129+// home.
130+func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131+ if SignedIn(r.Context()) {
132+ seeOther(w, r, "/")
133+ return
134+ }
135+ l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136+ s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137+ Title: "sign in",
138+ Button: "Sign in with a passkey",
139+ Lines: []string{"or run `ermite web login` in a terminal."},
140+ Begin: loginBeginPath,
141+ Finish: loginFinishPath,
142+ }))
143+}
144+
145+// passkeyLoginBegin is POST /-/passkeys/login/begin.
146+func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147+ addr, blocked := s.blocked(w, r)
148+ if blocked {
149+ return
150+ }
151+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
152+ defer cancel()
153+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, clientlimit.Key(addr))
154+ if err != nil {
155+ s.jsonInternal(ctx, w, r, err)
156+ return
157+ }
158+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
159+}
160+
161+// finishRequest is the body of a finish. The name of a new passkey is given at
162+// the begin of its registration, not here.
163+type finishRequest struct {
164+ Ceremony string `json:"ceremony"`
165+ Credential json.RawMessage `json:"credential"`
166+}
167+
168+// readFinish reads the body of a finish: JSON, bounded, with a ceremony and a
169+// credential. It answers 415, 413 or 400 and returns false when it cannot. A
170+// body that fails here is a bug of the client, not a guess at a passkey: the
171+// callers do not count it as a failed sign-in.
172+func readFinish(w http.ResponseWriter, r *http.Request) (finishRequest, bool) {
173+ body, ok := jsonBody(w, r)
174+ if !ok {
175+ return finishRequest{}, false
176+ }
177+ var req finishRequest
178+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
179+ jsonError(w, http.StatusBadRequest, "malformed request")
180+ return finishRequest{}, false
181+ }
182+ return req, true
183+}
184+
185+// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
186+// of the authenticator and sets the session cookie, the same as a login link.
187+// A refusal counts as a failed authentication of the client, whatever the
188+// reason.
189+func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
190+ // The limiter first: a client that is blocked gets 429 whatever it sends.
191+ addr, blocked := s.blocked(w, r)
192+ if blocked {
193+ return
194+ }
195+ req, ok := readFinish(w, r)
196+ if !ok {
197+ return
198+ }
199+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
200+ defer cancel()
201+ token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
202+ switch {
203+ case errors.Is(err, ports.ErrLinkRejected):
204+ s.cfg.Limiter.Fail(ctx, addr)
205+ jsonError(w, http.StatusUnauthorized, "passkey not accepted")
206+ return
207+ case err != nil:
208+ s.jsonInternal(ctx, w, r, err)
209+ return
210+ }
211+ h := w.Header()
212+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
213+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
214+ h.Set("Cache-Control", noStore)
215+ w.WriteHeader(http.StatusNoContent)
216+}
217+
218+// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
219+// page, as for any page that does not exist for them.
220+func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
221+ if !SignedIn(ctx) {
222+ return nil, 0, errNotFound
223+ }
224+ list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
225+ if err != nil {
226+ return nil, 0, err
227+ }
228+ now := s.cfg.Now()
229+ rows := make([]PasskeyRow, 0, len(list))
230+ for _, p := range list {
231+ rows = append(rows, PasskeyRow{
232+ Name: p.Name,
233+ Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
234+ LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
235+ Synced: p.BackupState,
236+ Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
237+ })
238+ }
239+ l := s.base()
240+ l.Title = "passkeys · " + s.instance
241+ l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
242+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
243+ return Passkeys(l, PasskeysPage{
244+ Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
245+ Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
246+ }), http.StatusOK, nil
247+}
248+
249+// registerError answers a refused registration step.
250+func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
251+ switch {
252+ case errors.Is(err, context.Canceled):
253+ // The browser is gone.
254+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
255+ jsonError(w, http.StatusForbidden, "forbidden")
256+ case errors.Is(err, domain.ErrAlreadyExists):
257+ jsonError(w, http.StatusConflict, "this passkey is already registered")
258+ case errors.Is(err, domain.ErrResourceExhausted):
259+ jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
260+ case domain.IsInvalid(err):
261+ jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
262+ case errors.Is(err, domain.ErrUnauthenticated):
263+ jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
264+ default:
265+ s.jsonInternal(ctx, w, r, err)
266+ }
267+}
268+
269+// passkeyRegisterBegin is POST /-/passkeys/register/begin. Like the finish
270+// endpoints it needs "Content-Type: application/json".
271+func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
272+ body, ok := jsonBody(w, r)
273+ if !ok {
274+ return
275+ }
276+ // {"name": "..."}; an empty body is no name. The name is checked now, before
277+ // the browser prompts the owner for a passkey that would then be refused.
278+ var req struct {
279+ Name string `json:"name"`
280+ }
281+ if len(body) > 0 {
282+ if err := json.Unmarshal(body, &req); err != nil {
283+ jsonError(w, http.StatusBadRequest, "malformed request")
284+ return
285+ }
286+ }
287+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
288+ defer cancel()
289+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx), req.Name)
290+ if err != nil {
291+ s.registerError(ctx, w, r, err)
292+ return
293+ }
294+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
295+}
296+
297+// passkeyRegisterFinish is POST /-/passkeys/register/finish.
298+func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
299+ req, ok := readFinish(w, r)
300+ if !ok {
301+ return
302+ }
303+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
304+ defer cancel()
305+ if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Credential); err != nil {
306+ s.registerError(ctx, w, r, err)
307+ return
308+ }
309+ w.Header().Set("Cache-Control", noStore)
310+ w.WriteHeader(http.StatusNoContent)
311+}
312+
313+// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
314+// signs every browser out, this one included: the answer sends it to the
315+// sign-in page.
316+func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
317+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
318+ defer cancel()
319+ id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
320+ if err != nil || len(id) == 0 {
321+ s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
322+ return
323+ }
324+ if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
325+ s.actionFailed(ctx, w, r, err, passkeysPath)
326+ return
327+ }
328+ seeOther(w, r, passkeyLoginPath)
329+}
A internal/adapters/web/passkeys_actions_test.go +89 -0
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+// Passkeys add an anonymous way to start a session, and the Actions pages add
19+// POSTs that cancel and re-run runs. The two must not meet: the only POSTs an
20+// anonymous browser may make are the two passkey login endpoints (and a login
21+// link), so the run actions still need the Origin of the public URL and a
22+// session, whether that session came from a link or from a passkey.
23+func TestPasskeysDoNotOpenTheActionsPosts(t *testing.T) {
24+ t.Parallel()
25+ repo := repoName(t, "proj")
26+ runs := mocks.NewRunService(t) // a call the gate should have stopped fails the test
27+ sessions := newSessions(t, "tok")
28+ srv := newTestServer(t, Config{
29+ Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs,
30+ PublicURL: forgeOrigin, Sessions: sessions, Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{},
31+ Now: func() time.Time { return fixedNow },
32+ })
33+ f := &passkeyFixture{srv: srv}
34+ actions := []string{
35+ "/proj/actions/runs/1/cancel", "/proj/actions/runs/1/rerun", "/proj/actions/runs/1/rerun-failed",
36+ }
37+ do := func(target, origin, cookie string) *httptest.ResponseRecorder {
38+ return f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/x-www-form-urlencoded"})
39+ }
40+
41+ for _, target := range actions {
42+ // Anonymous, with or without the right Origin, and with a cookie the
43+ // sessions do not know: forbidden, before any use case runs.
44+ for _, tc := range []struct{ origin, cookie string }{
45+ {forgeOrigin, ""}, {"", ""}, {forgeOrigin, "stale"},
46+ } {
47+ rec := do(target, tc.origin, tc.cookie)
48+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, tc.origin, tc.cookie)
49+ }
50+ // A signed-in browser from another origin: forbidden as well.
51+ for _, origin := range []string{"", "null", "https://evil.example"} {
52+ assert.Equal(t, http.StatusForbidden, do(target, origin, "tok").Code, "%s origin=%q", target, origin)
53+ }
54+ }
55+
56+ // The same paths are not passkey endpoints: lookalikes of the anonymous
57+ // ones do not pass either.
58+ for _, target := range []string{"/proj/actions/runs/1/cancel/-/passkeys/login/begin", "/-/passkeys/login/begin/../../proj/actions/runs/1/cancel"} {
59+ assert.NotEqual(t, http.StatusSeeOther, do(target, forgeOrigin, "").Code, target)
60+ }
61+
62+ // A browser signed in (by a link or by a passkey, the cookie is the same)
63+ // reaches the use case as the owner, once per action.
64+ owner := mock.MatchedBy(func(p domain.Principal) bool { return p.IsOwner() })
65+ runs.EXPECT().CancelRun(mock.Anything, owner, repo, int64(1)).Return(domain.CIRun{}, nil).Once()
66+ runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), false).Return(domain.CIRun{}, nil).Once()
67+ runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), true).Return(domain.CIRun{}, nil).Once()
68+ for _, target := range actions {
69+ rec := do(target, forgeOrigin, "tok")
70+ assert.Equal(t, http.StatusSeeOther, rec.Code, target)
71+ assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"), target)
72+ }
73+}
74+
75+// The refusal of the use case still shows as the page of a forbidden action
76+// for a signed-in browser.
77+func TestPasskeySessionRefusedByTheRunUseCase(t *testing.T) {
78+ t.Parallel()
79+ repo := repoName(t, "proj")
80+ runs := mocks.NewRunService(t)
81+ runs.EXPECT().CancelRun(mock.Anything, mock.Anything, repo, int64(1)).Return(domain.CIRun{}, domain.ErrForbidden).Once()
82+ srv := newTestServer(t, Config{
83+ Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs,
84+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{},
85+ })
86+ f := &passkeyFixture{srv: srv}
87+ rec := f.do(t, req{method: "POST", target: "/proj/actions/runs/1/cancel", origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
88+ assert.Equal(t, http.StatusForbidden, rec.Code)
89+}
A internal/adapters/web/passkeys_test.go +648 -0
@@ -0,0 +1,648 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+const forgeOrigin = "https://forge.example"
25+
26+// blockingLimiter is a FailureLimiter that can be told to block.
27+type blockingLimiter struct {
28+ blocked bool
29+ fails int
30+}
31+
32+func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) {
33+ if l.blocked {
34+ return true, 90 * time.Second
35+ }
36+ return false, 0
37+}
38+func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ }
39+
40+type passkeyFixture struct {
41+ srv *Server
42+ sessions *mocks.WebSessionService
43+ passkeys *mocks.WebPasskeyService
44+ lim *blockingLimiter
45+}
46+
47+func newPasskeyServer(t *testing.T) *passkeyFixture {
48+ t.Helper()
49+ f := &passkeyFixture{
50+ sessions: newSessions(t, "tok"),
51+ passkeys: mocks.NewWebPasskeyService(t),
52+ lim: &blockingLimiter{},
53+ }
54+ f.srv = newTestServer(t, Config{
55+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
56+ PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim,
57+ Now: func() time.Time { return fixedNow },
58+ })
59+ return f
60+}
61+
62+// fixedNow is the clock of the passkey pages: "3d ago" does not depend on when
63+// the test runs.
64+var fixedNow = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
65+
66+type req struct {
67+ method, target string
68+ origin string // "" sends none
69+ cookie string
70+ contentType string
71+ body string
72+}
73+
74+func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder {
75+ t.Helper()
76+ hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body))
77+ hr.Host = "forge.example"
78+ if r.origin != "" {
79+ hr.Header.Set("Origin", r.origin)
80+ }
81+ if r.cookie != "" {
82+ hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie})
83+ }
84+ if r.contentType != "" {
85+ hr.Header.Set("Content-Type", r.contentType)
86+ }
87+ rec := httptest.NewRecorder()
88+ f.srv.ServeHTTP(rec, hr)
89+ return rec
90+}
91+
92+func postJSON(target, cookie, body string) req {
93+ return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body}
94+}
95+
96+const finishBody = `{"ceremony":"c1","credential":{"id":"x","type":"public-key"}}`
97+
98+var (
99+ beginLogin = "/-/passkeys/login/begin"
100+ finishLogin = "/-/passkeys/login/finish"
101+ beginRegister = "/-/passkeys/register/begin"
102+ finishRegister = "/-/passkeys/register/finish"
103+)
104+
105+// Every new POST needs the Origin of the public URL.
106+func TestPasskeyPostsNeedTheOrigin(t *testing.T) {
107+ t.Parallel()
108+ f := newPasskeyServer(t) // the mocks fail the test on any call
109+ removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove"
110+ for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} {
111+ for _, cookie := range []string{"", "tok"} {
112+ for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} {
113+ rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody})
114+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie)
115+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
116+ }
117+ }
118+ }
119+}
120+
121+// An anonymous browser may post to the two login endpoints only.
122+func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
123+ t.Parallel()
124+ f := newPasskeyServer(t)
125+ id := base64.RawURLEncoding.EncodeToString([]byte("a"))
126+ for _, target := range []string{
127+ beginRegister, finishRegister, "/-/passkeys/" + id + "/remove",
128+ "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x",
129+ "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin",
130+ "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin",
131+ "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f",
132+ } {
133+ rec := f.do(t, postJSON(target, "", finishBody))
134+ assert.Equal(t, http.StatusForbidden, rec.Code, target)
135+ }
136+ // A GET is never a login.
137+ rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin})
138+ assert.NotEqual(t, http.StatusOK, rec.Code)
139+}
140+
141+func TestPasskeyLoginBegin(t *testing.T) {
142+ t.Parallel()
143+ f := newPasskeyServer(t)
144+ f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
145+
146+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
147+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
148+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
149+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
150+ assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String())
151+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
152+}
153+
154+func TestPasskeyLoginBeginFailure(t *testing.T) {
155+ t.Parallel()
156+ f := newPasskeyServer(t)
157+ f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("", nil, errors.New("secret detail")).Once()
158+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
159+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
160+ assert.NotContains(t, rec.Body.String(), "secret detail")
161+ assert.Contains(t, rec.Body.String(), "request id")
162+}
163+
164+func TestPasskeyLoginBlocked(t *testing.T) {
165+ t.Parallel()
166+ f := newPasskeyServer(t) // no use case call is expected
167+ f.lim.blocked = true
168+ // Blocked answers 429 whatever the request holds: the limiter comes before
169+ // the content type and the body are looked at.
170+ for _, r := range []req{
171+ {method: "POST", target: beginLogin, origin: forgeOrigin},
172+ postJSON(finishLogin, "", finishBody),
173+ {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
174+ postJSON(finishLogin, "", `not json`),
175+ postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
176+ } {
177+ rec := f.do(t, r)
178+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, "%s %q", r.target, r.contentType)
179+ assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String())
180+ assert.Equal(t, "91", rec.Header().Get("Retry-After"))
181+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
182+ }
183+ assert.Zero(t, f.lim.fails, "a blocked client is not counted again")
184+}
185+
186+func TestPasskeyLoginFinishSetsTheSession(t *testing.T) {
187+ t.Parallel()
188+ f := newPasskeyServer(t)
189+ f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool {
190+ return string(b) == `{"id":"x","type":"public-key"}`
191+ })).Return("tok", 12*time.Hour, nil).Once()
192+
193+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
194+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
195+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
196+ assert.Zero(t, f.lim.fails)
197+
198+ cookies := rec.Result().Cookies()
199+ require.Len(t, cookies, 1)
200+ c := cookies[0]
201+ assert.Equal(t, CookieName, c.Name)
202+ assert.Equal(t, "tok", c.Value)
203+ assert.True(t, c.Secure)
204+ assert.True(t, c.HttpOnly)
205+ assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
206+ assert.Equal(t, "/", c.Path)
207+ assert.Equal(t, 12*3600, c.MaxAge)
208+ assert.Empty(t, c.Domain)
209+ set := rec.Header().Get("Set-Cookie")
210+ for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} {
211+ assert.Contains(t, set, attr)
212+ }
213+}
214+
215+// A stale cookie that withSession cleared is replaced, not doubled.
216+func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) {
217+ t.Parallel()
218+ f := newPasskeyServer(t)
219+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
220+ rec := f.do(t, postJSON(finishLogin, "stale", finishBody))
221+ // A stale cookie is not a session: the login endpoint still lets it through.
222+ require.Equal(t, http.StatusNoContent, rec.Code)
223+ require.Len(t, rec.Result().Cookies(), 1)
224+ assert.Equal(t, "tok", rec.Result().Cookies()[0].Value)
225+}
226+
227+func TestPasskeyLoginFinishRefusal(t *testing.T) {
228+ t.Parallel()
229+ f := newPasskeyServer(t)
230+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice()
231+
232+ for range 2 {
233+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
234+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
235+ assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String())
236+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
237+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
238+ }
239+ assert.Equal(t, 2, f.lim.fails, "every refusal counts")
240+}
241+
242+func TestPasskeyLoginFinishInternalError(t *testing.T) {
243+ t.Parallel()
244+ f := newPasskeyServer(t)
245+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once()
246+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
247+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
248+ assert.NotContains(t, rec.Body.String(), "kv down")
249+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
250+ assert.Zero(t, f.lim.fails, "a server failure is not the client's")
251+}
252+
253+func TestPasskeyFinishNeedsJSON(t *testing.T) {
254+ t.Parallel()
255+ f := newPasskeyServer(t) // no use case call is expected
256+ for _, target := range []string{finishLogin, finishRegister} {
257+ for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} {
258+ cookie := ""
259+ if target == finishRegister {
260+ cookie = "tok"
261+ }
262+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody})
263+ assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct)
264+ }
265+ }
266+ assert.Zero(t, f.lim.fails)
267+}
268+
269+func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) {
270+ t.Parallel()
271+ f := newPasskeyServer(t)
272+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
273+ rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody})
274+ assert.Equal(t, http.StatusNoContent, rec.Code)
275+}
276+
277+func TestPasskeyFinishBodyLimit(t *testing.T) {
278+ t.Parallel()
279+ f := newPasskeyServer(t)
280+ big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}`
281+ for _, target := range []string{finishLogin, finishRegister} {
282+ rec := f.do(t, postJSON(target, "tok", big))
283+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target)
284+ }
285+ // Right at the limit is read (and then refused as malformed, not as too large).
286+ exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}`
287+ require.Len(t, exact, 64<<10)
288+ rec := f.do(t, postJSON(finishLogin, "", exact))
289+ assert.Equal(t, http.StatusBadRequest, rec.Code)
290+}
291+
292+func TestPasskeyFinishMalformed(t *testing.T) {
293+ t.Parallel()
294+ f := newPasskeyServer(t)
295+ for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} {
296+ for _, target := range []string{finishLogin, finishRegister} {
297+ rec := f.do(t, postJSON(target, "tok", body))
298+ assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body)
299+ }
300+ }
301+}
302+
303+// The registration endpoints are the owner's.
304+func TestPasskeyRegistration(t *testing.T) {
305+ t.Parallel()
306+ f := newPasskeyServer(t)
307+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
308+ rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
309+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
310+ assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
311+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
312+
313+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", mock.MatchedBy(func(b []byte) bool {
314+ return string(b) == `{"id":"x","type":"public-key"}`
315+ })).Return(nil).Once()
316+ rec = f.do(t, postJSON(finishRegister, "tok", finishBody))
317+ assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
318+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
319+ assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session")
320+}
321+
322+func TestPasskeyRegistrationErrors(t *testing.T) {
323+ t.Parallel()
324+ for name, tc := range map[string]struct {
325+ err error
326+ status int
327+ body string
328+ }{
329+ "duplicate": {domain.ErrAlreadyExists, 409, "already registered"},
330+ "limit": {domain.ErrResourceExhausted, 429, ""},
331+ "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"},
332+ "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"},
333+ "not owner": {domain.ErrForbidden, 403, ""},
334+ "server error": {errors.New("kv down"), 500, ""},
335+ } {
336+ t.Run(name, func(t *testing.T) {
337+ t.Parallel()
338+ f := newPasskeyServer(t)
339+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once()
340+ rec := f.do(t, postJSON(finishRegister, "tok", finishBody))
341+ assert.Equal(t, tc.status, rec.Code)
342+ assert.Contains(t, rec.Body.String(), `"error"`)
343+ assert.Contains(t, rec.Body.String(), tc.body)
344+ assert.NotContains(t, rec.Body.String(), "kv down")
345+ assert.Zero(t, f.lim.fails, "the owner's errors are not login failures")
346+ })
347+ }
348+
349+ f := newPasskeyServer(t)
350+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
351+ rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
352+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
353+}
354+
355+func TestPasskeysPage(t *testing.T) {
356+ t.Parallel()
357+ f := newPasskeyServer(t)
358+ now := fixedNow
359+ list := []domain.Passkey{
360+ {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true},
361+ {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)},
362+ }
363+ f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once()
364+
365+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
366+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
367+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
368+ body := rec.Body.String()
369+ assert.Contains(t, body, "Pixel &lt;9&gt;", "names are escaped")
370+ assert.NotContains(t, body, "Pixel <9>")
371+ assert.Contains(t, body, "yubikey")
372+ assert.Contains(t, body, "3d ago")
373+ assert.Contains(t, body, "never")
374+ assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
375+ assert.Contains(t, body, `data-passkey="register"`)
376+ // The name field and the add button are one form, so Enter submits.
377+ start := strings.Index(body, "<form class=\"passkey-add\"")
378+ require.GreaterOrEqual(t, start, 0, "the add form is on the page")
379+ form := body[start:]
380+ end := strings.Index(form, "</form>")
381+ require.GreaterOrEqual(t, end, 0)
382+ form = form[:end]
383+ assert.Contains(t, form, "data-passkey-form")
384+ assert.Contains(t, form, `data-passkey-name`)
385+ assert.Contains(t, form, `type="submit"`)
386+ assert.Contains(t, form, `data-passkey="register"`)
387+ assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
388+ assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
389+ assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
390+ assert.Contains(t, body, "data-passkey-status")
391+ assert.Contains(t, body, `aria-live="polite"`)
392+}
393+
394+func TestPasskeysPageFull(t *testing.T) {
395+ t.Parallel()
396+ f := newPasskeyServer(t)
397+ list := make([]domain.Passkey, domain.MaxPasskeys)
398+ for i := range list {
399+ list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: fixedNow}
400+ }
401+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once()
402+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
403+ require.Equal(t, http.StatusOK, rec.Code)
404+ assert.NotContains(t, rec.Body.String(), `data-passkey="register"`)
405+ assert.Contains(t, rec.Body.String(), "Remove one to add another")
406+}
407+
408+// Anybody but the owner gets the 404 page, and the list is not read.
409+func TestPasskeysPageIsTheOwners(t *testing.T) {
410+ t.Parallel()
411+ f := newPasskeyServer(t)
412+ for _, cookie := range []string{"", "stale"} {
413+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie})
414+ assert.Equal(t, http.StatusNotFound, rec.Code, cookie)
415+ assert.NotContains(t, rec.Body.String(), "Passkeys")
416+ assert.NotContains(t, rec.Body.String(), "data-passkey")
417+ }
418+}
419+
420+func TestPasskeyRemove(t *testing.T) {
421+ t.Parallel()
422+ f := newPasskeyServer(t)
423+ id := []byte{0xfb, 0xff, 'a'}
424+ target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove"
425+ f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once()
426+
427+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
428+ assert.Equal(t, http.StatusSeeOther, rec.Code)
429+ assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too")
430+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
431+}
432+
433+func TestPasskeyRemoveErrors(t *testing.T) {
434+ t.Parallel()
435+ f := newPasskeyServer(t)
436+ // Ids that are not unpadded base64url never reach the use case (the mock
437+ // fails the test on a call).
438+ for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} {
439+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"})
440+ assert.Equal(t, http.StatusNotFound, rec.Code, id)
441+ }
442+
443+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once()
444+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"})
445+ assert.Equal(t, http.StatusNotFound, rec.Code)
446+
447+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once()
448+ rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"})
449+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
450+ assert.NotContains(t, rec.Body.String(), "kv down")
451+}
452+
453+func TestPasskeyLoginPage(t *testing.T) {
454+ t.Parallel()
455+ f := newPasskeyServer(t)
456+ rec := f.do(t, req{method: "GET", target: "/-/login"})
457+ require.Equal(t, http.StatusOK, rec.Code)
458+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
459+ assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy"))
460+ body := rec.Body.String()
461+ assert.Contains(t, body, "<title>sign in")
462+ assert.Contains(t, body, "Sign in with a passkey")
463+ assert.Contains(t, body, `data-passkey="login"`)
464+ // One entry point for the script: the button is the submit button of a form.
465+ start := strings.Index(body, "<form class=\"state-act\"")
466+ require.GreaterOrEqual(t, start, 0, "the sign-in form is on the page")
467+ form := body[start:]
468+ end := strings.Index(form, "</form>")
469+ require.GreaterOrEqual(t, end, 0)
470+ form = form[:end]
471+ assert.Contains(t, form, "data-passkey-form")
472+ assert.Contains(t, form, `type="submit"`)
473+ assert.Contains(t, form, `data-passkey="login"`)
474+ assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
475+ assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
476+ assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
477+ assert.Contains(t, body, "ermite web login")
478+ assert.NotContains(t, body, "<script>", "no inline script")
479+
480+ // A signed-in visitor goes home.
481+ rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"})
482+ assert.Equal(t, http.StatusSeeOther, rec.Code)
483+ assert.Equal(t, "/", rec.Header().Get("Location"))
484+}
485+
486+// The header links: "Sign in" for a visitor, "Passkeys" for the owner.
487+func TestPasskeyHeaderLinks(t *testing.T) {
488+ t.Parallel()
489+ f := newPasskeyServer(t)
490+ rec := f.do(t, req{method: "GET", target: "/-/login"})
491+ assert.Contains(t, rec.Body.String(), `href="/-/login"`)
492+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
493+ assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
494+
495+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
496+ rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
497+ assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`)
498+ assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
499+ assert.NotContains(t, rec.Body.String(), `>Sign in</a>`)
500+}
501+
502+// Without the passkey service there is no route, no link and no script
503+// behavior to reach: the routes answer like any unknown path.
504+func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
505+ t.Parallel()
506+ lim := &blockingLimiter{}
507+ srv := newTestServer(t, Config{
508+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
509+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
510+ })
511+ do := func(method, target, cookie string) *httptest.ResponseRecorder {
512+ r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
513+ r.Header.Set("Origin", forgeOrigin)
514+ r.Header.Set("Content-Type", "application/json")
515+ if cookie != "" {
516+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
517+ }
518+ rec := httptest.NewRecorder()
519+ srv.ServeHTTP(rec, r)
520+ return rec
521+ }
522+ for _, target := range []string{"/-/login", "/-/passkeys"} {
523+ assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
524+ assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
525+ }
526+ // A POST to a path without a route gets what any unknown path gets for a
527+ // POST (the catch-all page route is GET only).
528+ unknown := do("POST", "/-/nothing-here", "tok").Code
529+ assert.Equal(t, http.StatusMethodNotAllowed, unknown)
530+ for _, target := range []string{beginLogin, finishLogin} {
531+ assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
532+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
533+ }
534+ for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
535+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
536+ }
537+ // And no link in the header.
538+ rec := do("GET", "/", "")
539+ assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
540+ rec = do("GET", "/", "tok")
541+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
542+}
543+
544+// Passkeys need sessions: with the service alone nothing is served.
545+func TestPasskeyRoutesNeedSessions(t *testing.T) {
546+ t.Parallel()
547+ srv := newTestServer(t, Config{
548+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
549+ PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
550+ })
551+ rec := httptest.NewRecorder()
552+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
553+ assert.Equal(t, http.StatusNotFound, rec.Code)
554+ r := httptest.NewRequest("POST", beginLogin, nil)
555+ r.Header.Set("Origin", forgeOrigin)
556+ rec = httptest.NewRecorder()
557+ srv.ServeHTTP(rec, r)
558+ assert.Equal(t, http.StatusForbidden, rec.Code)
559+}
560+
561+// The existing login links keep working next to the passkey routes.
562+func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
563+ t.Parallel()
564+ f := newPasskeyServer(t)
565+ rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
566+ assert.Equal(t, http.StatusOK, rec.Code)
567+ assert.Contains(t, rec.Body.String(), "sign in as the owner")
568+ rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
569+ assert.Equal(t, http.StatusSeeOther, rec.Code)
570+}
571+
572+func TestIsPasskeyLoginPath(t *testing.T) {
573+ t.Parallel()
574+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
575+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
576+ for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
577+ assert.False(t, isPasskeyLoginPath(p), p)
578+ }
579+}
580+
581+// A finish that cannot be read is a 4xx, and is not counted as a failed
582+// sign-in: the client is buggy, it did not guess at a passkey.
583+func TestMalformedFinishIsNotAFailedSignIn(t *testing.T) {
584+ t.Parallel()
585+ f := newPasskeyServer(t)
586+ for _, r := range []req{
587+ {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
588+ postJSON(finishLogin, "", `not json`),
589+ postJSON(finishLogin, "", `{}`),
590+ postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
591+ } {
592+ rec := f.do(t, r)
593+ assert.GreaterOrEqual(t, rec.Code, 400)
594+ assert.Less(t, rec.Code, 500)
595+ }
596+ assert.Zero(t, f.lim.fails)
597+}
598+
599+// The client key the web hands to the use case is the one the failure limiter
600+// counts under: the address with trusted proxies applied, an IPv4 address as it
601+// is (mapped or not), an IPv6 one cut to its /64.
602+func TestPasskeyLoginBeginPassesTheClientKey(t *testing.T) {
603+ t.Parallel()
604+ for name, tc := range map[string]struct{ remote, xff, want string }{
605+ "ipv4": {"192.0.2.77:51000", "", "192.0.2.77"},
606+ "ipv4 mapped": {"[::ffff:192.0.2.77]:51000", "", "192.0.2.77"},
607+ "ipv6 host": {"[2001:db8:1:2::1]:51000", "", "2001:db8:1:2::/64"},
608+ "ipv6 same /64": {"[2001:db8:1:2:ffff:ffff:ffff:ffff]:51000", "", "2001:db8:1:2::/64"},
609+ "ipv6 another /64": {"[2001:db8:1:3::1]:51000", "", "2001:db8:1:3::/64"},
610+ "forwarded for ignored": {"192.0.2.77:51000", "203.0.113.5", "192.0.2.77"},
611+ } {
612+ t.Run(name, func(t *testing.T) {
613+ t.Parallel()
614+ f := newPasskeyServer(t)
615+ f.passkeys.EXPECT().BeginLogin(mock.Anything, tc.want).Return("c", []byte(`{}`), nil).Once()
616+ r := httptest.NewRequest("POST", beginLogin, nil)
617+ r.RemoteAddr = tc.remote
618+ r.Header.Set("Origin", forgeOrigin)
619+ if tc.xff != "" {
620+ r.Header.Set("X-Forwarded-For", tc.xff) // no trusted proxy: ignored
621+ }
622+ rec := httptest.NewRecorder()
623+ f.srv.ServeHTTP(rec, r)
624+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
625+ })
626+ }
627+}
628+
629+// The name travels with register/begin and is checked there.
630+func TestPasskeyRegisterBeginChecksTheName(t *testing.T) {
631+ t.Parallel()
632+ f := newPasskeyServer(t)
633+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "Pixel 9").Return("cer", []byte(`{"publicKey":{}}`), nil).Once()
634+ rec := f.do(t, postJSON(beginRegister, "tok", `{"name":"Pixel 9"}`))
635+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
636+
637+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "bad\u0000name").
638+ Return("", nil, &domain.TextError{Reason: "contains the control character U+0000"}).Once()
639+ rec = f.do(t, postJSON(beginRegister, "tok", `{"name":"bad\u0000name"}`))
640+ assert.Equal(t, http.StatusBadRequest, rec.Code)
641+ assert.Contains(t, rec.Body.String(), "control character")
642+
643+ // No body at all is no name; a body that is not JSON, or not the right type, is refused.
644+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer", []byte(`{}`), nil).Once()
645+ assert.Equal(t, http.StatusOK, f.do(t, postJSON(beginRegister, "tok", ``)).Code)
646+ assert.Equal(t, http.StatusBadRequest, f.do(t, postJSON(beginRegister, "tok", `nope`)).Code)
647+ assert.Equal(t, http.StatusUnsupportedMediaType, f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}).Code)
648+}
M internal/adapters/web/session.go +12 -1
@@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
180180 denyForbidden(w)
181181 return false
182182 }
183- if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
183+ if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184184 denyForbidden(w)
185185 return false
186186 }
187187 return true
188188 }
189189
190+// anonymousPost tells whether a POST without a session may pass the gate: the
191+// requests that start a session. They are the confirmation of a login link and,
192+// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193+// exactly.
194+func (s *Server) anonymousPost(r *http.Request) bool {
195+ if r.Method != http.MethodPost {
196+ return false
197+ }
198+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+}
200+
190201 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
191202 // the right shape and nothing after it. It looks at the escaped path, which is
192203 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
M internal/adapters/web/shell.templ +6 -0
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
2222 <script src={ l.Assets.SSE } defer></script>
2323 <script src={ l.Assets.App } defer></script>
2424 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Passkey } defer></script>
2526 <script src={ l.Assets.Log } defer></script>
2627 </head>
2728 <body
@@ -124,8 +125,13 @@ templ Header(l Layout) {
124125 if SignedIn(ctx) {
125126 <form class="owner" method="post" action="/-/logout">
126127 <span class="owner-badge" title="signed in as the owner">owner</span>
128+ if l.Passkeys {
129+ <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a>
130+ }
127131 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>
128132 </form>
133+ } else if l.Passkeys {
134+ <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a>
129135 }
130136 </div>
131137 </div>
M internal/adapters/web/shell_templ.go +172 -144 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +15 -0
@@ -47,6 +47,21 @@
4747 & .c-sub { width: auto; }
4848 & .c-who { width: 130px; }
4949 }
50+ .list.passkeys {
51+ & .c-name { width: auto; font-weight: 600; }
52+ & .c-when { width: 120px; font-size: var(--fs-s); }
53+ & .c-sync { width: 80px; font-size: var(--fs-s); }
54+ & .c-act { width: 110px; text-align: right; }
55+ }
56+ .passkey-add {
57+ display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4);
58+ & input {
59+ flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3);
60+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s);
61+ }
62+ & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
63+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
64+ }
5065 .list.commits, .list.refs {
5166 & .c-sha { width: 96px; }
5267 & .c-who { width: 140px; }
A internal/adapters/web/static/js/passkey.js +169 -0
@@ -0,0 +1,169 @@
1+// passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2+//
3+// One delegated submit handler for both pages: the sign-in button and the "add
4+// a passkey" button are the submit buttons of a [data-passkey-form], so that a
5+// click, Enter in the name field and the keyboard all end in the same place.
6+// The button carries the two endpoints (data-begin, data-finish). While a
7+// ceremony runs the button is disabled, and a disabled submit button makes the
8+// form submit nothing: that is the only guard against a second prompt. The flow
9+// is the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the
10+// browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST
11+// the result (toJSON()) to finish. Nothing is inline: the CSP only allows
12+// scripts from this origin. The core is tested in Node by
13+// jstest/passkey.test.mjs.
14+window.ermitePasskey = (() => {
15+ const UNSUPPORTED = 'this browser does not support passkeys';
16+ const UNEXPECTED = 'the server sent an unexpected answer';
17+ const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again';
18+ const WAITING = 'waiting for your passkey…';
19+ const MAX_NAME_BYTES = 64;
20+
21+ // The parse function each ceremony needs; absent in a browser that predates
22+ // the WebAuthn JSON API.
23+ const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' };
24+ const supported = (kind) => {
25+ const pkc = window.PublicKeyCredential;
26+ return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials;
27+ };
28+
29+ const statusEl = () => document.querySelector('[data-passkey-status]');
30+ const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
31+
32+ // nameProblem says why a passkey name would be refused, or '' when it is fine.
33+ // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8,
34+ // one line, no control character and no bidirectional mark, override or isolate.
35+ // The server stays the judge; this only saves the owner a prompt that would end
36+ // in a refusal.
37+ function nameProblem(name) {
38+ if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) {
39+ return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes';
40+ }
41+ // C0 and C1 controls (this includes tab and line breaks), the line and
42+ // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C,
43+ // U+202A to U+202E, U+2066 to U+2069).
44+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) {
45+ return 'the name has a control or direction character';
46+ }
47+ return '';
48+ }
49+
50+ // messageOf turns what a step threw into one line for the owner.
51+ function messageOf(e) {
52+ if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
53+ if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site';
54+ if (e && e.name === 'AbortError') return 'cancelled';
55+ return (e && e.message) || 'something went wrong; try again';
56+ }
57+
58+ // failure reads the error of a refused request: the server's one-line
59+ // reason when it sent one, the status otherwise. A 403 without a reason of
60+ // its own is a session that ended, or a page older than the server's state.
61+ async function failure(res) {
62+ let reason = '';
63+ try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
64+ const e = new Error(reason || 'the server answered ' + res.status);
65+ if (res.status === 403 && (!reason || reason === 'forbidden')) e.message = SESSION_ENDED;
66+ e.status = res.status;
67+ return e;
68+ }
69+
70+ // begin posts to the begin endpoint and checks the shape of the answer.
71+ async function begin(btn, kind, name) {
72+ const init = { method: 'POST', credentials: 'same-origin' };
73+ if (kind === 'register') {
74+ init.headers = { 'Content-Type': 'application/json' };
75+ init.body = JSON.stringify({ name });
76+ }
77+ const res = await fetch(btn.dataset.begin, init);
78+ if (!res.ok) {
79+ const e = await failure(res);
80+ e.atBegin = true; // a 400 here is the server refusing the name
81+ throw e;
82+ }
83+ let answer;
84+ try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }
85+ const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&
86+ answer.options && typeof answer.options === 'object' &&
87+ answer.options.publicKey && typeof answer.options.publicKey === 'object';
88+ if (!ok) throw new Error(UNEXPECTED);
89+ return answer;
90+ }
91+
92+ async function run(btn) {
93+ const kind = btn.dataset.passkey;
94+ if (!parser[kind]) return;
95+ if (!supported(kind)) { say(UNSUPPORTED); return; }
96+
97+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
98+ const name = nameEl ? nameEl.value.trim() : '';
99+ if (nameEl) {
100+ const problem = nameProblem(name);
101+ if (problem) { say(problem); nameEl.focus(); return; }
102+ }
103+
104+ btn.disabled = true;
105+ btn.setAttribute('aria-busy', 'true');
106+ say(WAITING);
107+ let done = false;
108+ let failed = null;
109+ try {
110+ const { ceremony, options } = await begin(btn, kind, name);
111+ const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
112+ const cred = kind === 'login'
113+ ? await navigator.credentials.get({ publicKey })
114+ : await navigator.credentials.create({ publicKey });
115+ if (!cred) throw new Error('no passkey was returned');
116+ if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser');
117+ const finished = await fetch(btn.dataset.finish, {
118+ method: 'POST',
119+ credentials: 'same-origin',
120+ headers: { 'Content-Type': 'application/json' },
121+ body: JSON.stringify({ ceremony, credential: cred.toJSON() }),
122+ });
123+ if (!finished.ok) throw await failure(finished);
124+ done = true;
125+ if (kind === 'login') location.assign('/');
126+ else location.reload();
127+ } catch (e) {
128+ say(messageOf(e));
129+ failed = e;
130+ } finally {
131+ if (!done) {
132+ btn.removeAttribute('aria-busy');
133+ btn.disabled = false;
134+ // A disabled button cannot hold the focus. When the server refused the
135+ // name the field is what to fix; otherwise the button, to try again.
136+ if (nameEl && failed && failed.atBegin && failed.status === 400) nameEl.focus();
137+ else btn.focus();
138+ }
139+ }
140+ }
141+
142+ // submit handles both forms. The event comes from the form itself.
143+ function submit(ev) {
144+ const form = ev.target;
145+ if (!form || typeof form.matches !== 'function' || !form.matches('[data-passkey-form]')) return;
146+ if (ev.preventDefault) ev.preventDefault(); // never a real navigation
147+ const btn = form.querySelector('[data-passkey]');
148+ if (!btn || btn.disabled) return;
149+ return run(btn);
150+ }
151+
152+ // init disables the buttons of a browser that cannot do passkeys, and says why.
153+ function init() {
154+ let missing = false;
155+ document.querySelectorAll('[data-passkey]').forEach((btn) => {
156+ if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) {
157+ btn.disabled = true;
158+ missing = true;
159+ }
160+ });
161+ if (missing) say(UNSUPPORTED);
162+ }
163+
164+ if (typeof document.addEventListener === 'function') {
165+ document.addEventListener('submit', submit);
166+ init();
167+ }
168+ return { submit, init, nameProblem };
169+})();
M internal/adapters/web/view.go +33 -0
@@ -60,6 +60,9 @@ type Layout struct {
6060 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
6161 // both are "" when the page has no commit to search.
6262 PathsURL, BlobBase string
63+ // Passkeys tells that the passkey routes exist: the header then links to
64+ // them.
65+ Passkeys bool
6366 }
6467
6568 // Link is a plain label + href pair.
@@ -195,3 +198,33 @@ type LoginPage struct {
195198 Action string // where the form posts
196199 Button string
197200 }
201+
202+// PasskeyLoginPage is the sign-in page: one button the script drives.
203+type PasskeyLoginPage struct {
204+ Title string
205+ Lines []string
206+ Button string
207+ // Begin and Finish are the endpoints the script posts to.
208+ Begin, Finish string
209+}
210+
211+// PasskeyRow is one passkey on the passkeys page.
212+type PasskeyRow struct {
213+ Name string
214+ // Created and LastUsed are relative ("3d ago"); the At fields are the
215+ // RFC 3339 forms, "" when never.
216+ Created, CreatedAt string
217+ LastUsed, LastUsedAt string
218+ Synced bool
219+ // Remove is where the remove form posts.
220+ Remove string
221+}
222+
223+// PasskeysPage lists the passkeys and adds one.
224+type PasskeysPage struct {
225+ Rows []PasskeyRow
226+ Begin, Finish string
227+ // Full tells that no more passkeys can be added.
228+ Full bool
229+ Max int
230+}
M internal/adapters/web/web.go +9 -0
@@ -77,6 +77,11 @@ type Config struct {
7777 // Sessions signs the owner in (ADR 0018). Without it there are no login
7878 // routes and every visitor is anonymous.
7979 Sessions ports.WebSessionService
80+ // Passkeys signs the owner in with a passkey and manages the passkeys
81+ // (ADR 0026). Without it, or without Sessions, there are no passkey
82+ // routes and no link to them. Leave it unset rather than assigning a typed
83+ // nil pointer.
84+ Passkeys ports.WebPasskeyService
8085 // Limiter counts the failed logins; required with Sessions. Share it with
8186 // the API and Git, so that a client has one budget of failures.
8287 Limiter ports.FailureLimiter
@@ -139,6 +144,9 @@ func (s *Server) routes() *http.ServeMux {
139144 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
140145 mux.HandleFunc("POST /-/logout", s.logout)
141146 }
147+ if s.passkeysOn() {
148+ s.passkeyRoutes(mux)
149+ }
142150 mux.HandleFunc("GET /{$}", s.page(s.home))
143151 mux.HandleFunc("GET /{repo}", s.page(s.summary))
144152 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -306,6 +314,7 @@ func (s *Server) base() Layout {
306314 Version: s.cfg.Version,
307315 SourceURL: s.cfg.SourceURL,
308316 Assets: s.assets.urls,
317+ Passkeys: s.passkeysOn(),
309318 }
310319 }
311320
A internal/adapters/webauthn/webauthn.go +207 -0
@@ -0,0 +1,207 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package webauthn implements ports.PasskeyVerifier over go-webauthn (ADR
4+// 0026). It is the only importer of the library: the use case sees plain
5+// domain values and opaque ceremony bytes.
6+package webauthn
7+
8+import (
9+ "encoding/json"
10+ "errors"
11+ "fmt"
12+ "net/url"
13+ "strings"
14+
15+ "github.com/go-webauthn/webauthn/protocol"
16+ gowebauthn "github.com/go-webauthn/webauthn/webauthn"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// ownerName names the one user in the options sent to the browser.
23+const ownerName = "owner"
24+
25+// Verifier runs the WebAuthn ceremonies for the owner of one public URL.
26+type Verifier struct {
27+ web *gowebauthn.WebAuthn
28+}
29+
30+var _ ports.PasskeyVerifier = (*Verifier)(nil)
31+
32+// New builds a verifier for publicURL: the relying party ID is its host name,
33+// the only accepted origin is its origin. It fails when the library refuses the
34+// configuration, for example for a host that is an IP address.
35+func New(publicURL string) (*Verifier, error) {
36+ u, err := url.Parse(publicURL)
37+ if err != nil {
38+ return nil, fmt.Errorf("passkeys: parse public URL: %w", err)
39+ }
40+ host := strings.ToLower(u.Hostname())
41+ if (u.Scheme != "http" && u.Scheme != "https") || host == "" {
42+ return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL)
43+ }
44+ timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: domain.PasskeyCeremonyTTL, TimeoutUVD: domain.PasskeyCeremonyTTL}
45+ web, err := gowebauthn.New(&gowebauthn.Config{
46+ RPID: host,
47+ RPDisplayName: host,
48+ RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)},
49+ AuthenticatorSelection: protocol.AuthenticatorSelection{
50+ ResidentKey: protocol.ResidentKeyRequirementRequired,
51+ RequireResidentKey: protocol.ResidentKeyRequired(),
52+ UserVerification: protocol.VerificationRequired,
53+ },
54+ AttestationPreference: protocol.PreferNoAttestation,
55+ Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout},
56+ })
57+ if err != nil {
58+ return nil, fmt.Errorf("passkeys: %w", err)
59+ }
60+ return &Verifier{web: web}, nil
61+}
62+
63+// owner is the single WebAuthn user.
64+type owner struct {
65+ handle []byte
66+ credentials []gowebauthn.Credential
67+}
68+
69+func (o owner) WebAuthnID() []byte { return o.handle }
70+func (o owner) WebAuthnName() string { return ownerName }
71+func (o owner) WebAuthnDisplayName() string { return ownerName }
72+func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials }
73+
74+// refuse builds the error of a failed verification: an ErrUnauthenticated whose
75+// text carries the reason, for logs. The library error is flattened to text so
76+// that its types never leave this package.
77+func refuse(what string, err error) error {
78+ return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err)
79+}
80+
81+// BeginRegistration implements ports.PasskeyVerifier.
82+func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
83+ exclude := make([]protocol.CredentialDescriptor, 0, len(existing))
84+ for _, p := range existing {
85+ exclude = append(exclude, descriptor(p))
86+ }
87+ creation, session, err := v.web.BeginRegistration(owner{handle: userHandle},
88+ gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
89+ gowebauthn.WithExclusions(exclude),
90+ gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation),
91+ )
92+ if err != nil {
93+ return nil, nil, fmt.Errorf("begin passkey registration: %w", err)
94+ }
95+ return encode(creation, session)
96+}
97+
98+// FinishRegistration implements ports.PasskeyVerifier.
99+func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) {
100+ var session gowebauthn.SessionData
101+ if err := json.Unmarshal(ceremony, &session); err != nil {
102+ return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err))
103+ }
104+ parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
105+ if err != nil {
106+ return domain.Passkey{}, refuse("registration", err)
107+ }
108+ cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed)
109+ if err != nil {
110+ return domain.Passkey{}, refuse("registration", err)
111+ }
112+ transports := make([]string, 0, len(cred.Transport))
113+ for _, t := range cred.Transport {
114+ transports = append(transports, string(t))
115+ }
116+ return domain.Passkey{
117+ ID: cred.ID,
118+ PublicKey: cred.PublicKey,
119+ AttestationType: cred.AttestationType,
120+ Transports: transports,
121+ SignCount: cred.Authenticator.SignCount,
122+ BackupEligible: cred.Flags.BackupEligible,
123+ BackupState: cred.Flags.BackupState,
124+ }, nil
125+}
126+
127+// BeginLogin implements ports.PasskeyVerifier.
128+func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
129+ assertion, session, err := v.web.BeginDiscoverableLogin()
130+ if err != nil {
131+ return nil, nil, fmt.Errorf("begin passkey login: %w", err)
132+ }
133+ return encode(assertion, session)
134+}
135+
136+// FinishLogin implements ports.PasskeyVerifier. The library compares the user
137+// handle of the response with userHandle.
138+//
139+//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance
140+func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
141+ var session gowebauthn.SessionData
142+ if err := json.Unmarshal(ceremony, &session); err != nil {
143+ return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err))
144+ }
145+ parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
146+ if err != nil {
147+ return domain.Passkey{}, refuse("login", err)
148+ }
149+
150+ var stored domain.Passkey
151+ var lookupErr error // a failure of the store, as opposed to an unknown credential
152+ handler := func(rawID, _ []byte) (gowebauthn.User, error) {
153+ p, err := lookup(rawID)
154+ if err != nil {
155+ if !errors.Is(err, domain.ErrNotFound) {
156+ lookupErr = err
157+ }
158+ return nil, err
159+ }
160+ stored = p
161+ return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil
162+ }
163+ _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed)
164+ if lookupErr != nil {
165+ return domain.Passkey{}, lookupErr // fail closed, as it is
166+ }
167+ if err != nil {
168+ return domain.Passkey{}, refuse("login", err)
169+ }
170+ // The library keeps the stored counter when the received one does not
171+ // advance (its clone warning); the use case refuses that, under its lock.
172+ stored.SignCount = cred.Authenticator.SignCount
173+ stored.BackupState = cred.Flags.BackupState
174+ return stored, nil
175+}
176+
177+// encode returns the JSON of the options for the browser and of the session.
178+func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) {
179+ if opts, err = json.Marshal(options); err != nil {
180+ return nil, nil, fmt.Errorf("encode passkey options: %w", err)
181+ }
182+ if ceremony, err = json.Marshal(session); err != nil {
183+ return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err)
184+ }
185+ return opts, ceremony, nil
186+}
187+
188+// credential converts a stored passkey into the library's form.
189+func credential(p domain.Passkey) gowebauthn.Credential {
190+ c := gowebauthn.Credential{
191+ ID: p.ID,
192+ PublicKey: p.PublicKey,
193+ AttestationType: p.AttestationType,
194+ Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState},
195+ Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount},
196+ }
197+ for _, t := range p.Transports {
198+ c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t))
199+ }
200+ return c
201+}
202+
203+// descriptor names a stored passkey for the exclusion list.
204+func descriptor(p domain.Passkey) protocol.CredentialDescriptor {
205+ c := credential(p)
206+ return c.Descriptor()
207+}
A internal/adapters/webauthn/webauthn_test.go +446 -0
@@ -0,0 +1,446 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package webauthn_test
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "maps"
9+ "testing"
10+ "time"
11+
12+ "github.com/descope/virtualwebauthn"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/webauthn"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+var userHandle = []byte("0123456789abcdef0123456789abcdef")
21+
22+// site is one public URL and the relying party a browser would present for it.
23+type site struct {
24+ url string
25+ rp virtualwebauthn.RelyingParty
26+}
27+
28+var sites = map[string]site{
29+ "localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}},
30+ "https": {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}},
31+}
32+
33+// device is a virtual authenticator with one credential and the passkey the
34+// server stored for it.
35+type device struct {
36+ auth virtualwebauthn.Authenticator
37+ cred virtualwebauthn.Credential
38+ store map[string]domain.Passkey // by credential id
39+}
40+
41+func (d *device) lookup(id []byte) (domain.Passkey, error) {
42+ p, ok := d.store[string(id)]
43+ if !ok {
44+ return domain.Passkey{}, domain.ErrNotFound
45+ }
46+ return p, nil
47+}
48+
49+// register runs a registration ceremony against v with a new credential.
50+func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device {
51+ t.Helper()
52+ opts.UserHandle = userHandle
53+ d := &device{
54+ auth: virtualwebauthn.NewAuthenticatorWithOptions(opts),
55+ cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
56+ store: map[string]domain.Passkey{},
57+ }
58+ options, ceremony, err := v.BeginRegistration(userHandle, nil)
59+ require.NoError(t, err)
60+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
61+ require.NoError(t, err)
62+ response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed)
63+
64+ p, err := v.FinishRegistration(userHandle, ceremony, []byte(response))
65+ require.NoError(t, err)
66+ assert.Equal(t, d.cred.ID, p.ID)
67+ assert.NotEmpty(t, p.PublicKey)
68+ p.Name = "test"
69+ d.store[string(p.ID)] = p
70+ d.auth.AddCredential(d.cred)
71+ return d
72+}
73+
74+// assertion starts a login and returns the ceremony and the signed response.
75+func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) {
76+ t.Helper()
77+ raw, ceremony, err := v.BeginLogin()
78+ require.NoError(t, err)
79+ options, err = virtualwebauthn.ParseAssertionOptions(string(raw))
80+ require.NoError(t, err)
81+ return ceremony, options
82+}
83+
84+func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte {
85+ return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options))
86+}
87+
88+func TestRegisterThenLogin(t *testing.T) {
89+ t.Parallel()
90+ for name, s := range sites {
91+ t.Run(name, func(t *testing.T) {
92+ t.Parallel()
93+ v, err := webauthn.New(s.url)
94+ require.NoError(t, err)
95+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true})
96+ d.cred.Counter = 5
97+
98+ ceremony, options := d.assertion(t, v, s)
99+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
100+ require.NoError(t, err)
101+ assert.Equal(t, d.cred.ID, got.ID)
102+ assert.Equal(t, "test", got.Name, "the stored fields are kept")
103+ assert.Equal(t, uint32(5), got.SignCount, "the new sign count")
104+ assert.True(t, got.BackupEligible)
105+ assert.False(t, got.BackupState)
106+ assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey)
107+
108+ // The next login advances the counter again.
109+ d.store[string(got.ID)] = got
110+ d.cred.Counter = 6
111+ ceremony, options = d.assertion(t, v, s)
112+ got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
113+ require.NoError(t, err)
114+ assert.Equal(t, uint32(6), got.SignCount)
115+ })
116+ }
117+}
118+
119+func TestRegistrationReportsBackupFlags(t *testing.T) {
120+ t.Parallel()
121+ s := sites["https"]
122+ v, err := webauthn.New(s.url)
123+ require.NoError(t, err)
124+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true,
125+ Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}})
126+ p := d.store[string(d.cred.ID)]
127+ assert.True(t, p.BackupEligible)
128+ assert.True(t, p.BackupState)
129+ assert.Equal(t, []string{"internal", "hybrid"}, p.Transports)
130+
131+ // A login reports the backup state the authenticator has now.
132+ d.auth.Options.BackupState = false
133+ ceremony, options := d.assertion(t, v, s)
134+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
135+ require.NoError(t, err)
136+ assert.False(t, got.BackupState)
137+}
138+
139+func TestOptions(t *testing.T) {
140+ t.Parallel()
141+ s := sites["https"]
142+ v, err := webauthn.New(s.url)
143+ require.NoError(t, err)
144+
145+ existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}}
146+ raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing})
147+ require.NoError(t, err)
148+ var creation struct {
149+ PublicKey struct {
150+ RP struct{ ID, Name string }
151+ User struct{ ID, Name string }
152+ Challenge string
153+ AuthenticatorSelection struct {
154+ ResidentKey string
155+ RequireResidentKey bool
156+ UserVerification string
157+ }
158+ Attestation string
159+ Timeout int
160+ ExcludeCredentials []struct{ ID string }
161+ }
162+ }
163+ require.NoError(t, json.Unmarshal(raw, &creation))
164+ pk := creation.PublicKey
165+ assert.Equal(t, "git.example.test", pk.RP.ID)
166+ assert.Equal(t, "owner", pk.User.Name)
167+ assert.NotEmpty(t, pk.Challenge)
168+ assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey)
169+ assert.True(t, pk.AuthenticatorSelection.RequireResidentKey)
170+ assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification)
171+ assert.Equal(t, "none", pk.Attestation)
172+ assert.Equal(t, 120000, pk.Timeout)
173+ require.Len(t, pk.ExcludeCredentials, 1)
174+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw))
175+ require.NoError(t, err)
176+ require.Len(t, parsed.ExcludeCredentials, 1)
177+
178+ raw, _, err = v.BeginLogin()
179+ require.NoError(t, err)
180+ var assertion struct {
181+ PublicKey struct {
182+ RPID string
183+ Challenge string
184+ UserVerification string
185+ Timeout int
186+ AllowCredentials []struct{ ID string }
187+ }
188+ }
189+ require.NoError(t, json.Unmarshal(raw, &assertion))
190+ assert.Equal(t, "git.example.test", assertion.PublicKey.RPID)
191+ assert.NotEmpty(t, assertion.PublicKey.Challenge)
192+ assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification")
193+ assert.Equal(t, 120000, assertion.PublicKey.Timeout)
194+ assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential")
195+}
196+
197+func TestLoginRefused(t *testing.T) {
198+ t.Parallel()
199+ s := sites["localhost"]
200+ v, err := webauthn.New(s.url)
201+ require.NoError(t, err)
202+
203+ tests := []struct {
204+ name string
205+ // run returns the error of FinishLogin.
206+ run func(t *testing.T, d *device) error
207+ }{
208+ {"wrong origin", func(t *testing.T, d *device) error {
209+ ceremony, options := d.assertion(t, v, s)
210+ other := s
211+ other.rp.Origin = "http://localhost:9999"
212+ return finish(v, ceremony, d.respond(other, options), d)
213+ }},
214+ {"wrong RP ID", func(t *testing.T, d *device) error {
215+ ceremony, options := d.assertion(t, v, s)
216+ other := s
217+ other.rp.ID = "evil.example.test"
218+ return finish(v, ceremony, d.respond(other, options), d)
219+ }},
220+ {"tampered challenge", func(t *testing.T, d *device) error {
221+ ceremony, options := d.assertion(t, v, s)
222+ options.Challenge[0] ^= 0xff
223+ return finish(v, ceremony, d.respond(s, options), d)
224+ }},
225+ {"challenge of another ceremony", func(t *testing.T, d *device) error {
226+ ceremony, _ := d.assertion(t, v, s)
227+ _, other := d.assertion(t, v, s)
228+ return finish(v, ceremony, d.respond(s, other), d)
229+ }},
230+ {"expired ceremony", func(t *testing.T, d *device) error {
231+ ceremony, options := d.assertion(t, v, s)
232+ return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d)
233+ }},
234+ {"garbage ceremony", func(t *testing.T, d *device) error {
235+ _, options := d.assertion(t, v, s)
236+ return finish(v, []byte("not json"), d.respond(s, options), d)
237+ }},
238+ {"garbage response", func(t *testing.T, d *device) error {
239+ ceremony, _ := d.assertion(t, v, s)
240+ return finish(v, ceremony, []byte("{}"), d)
241+ }},
242+ {"user not verified", func(t *testing.T, d *device) error {
243+ d.auth.Options.UserNotVerified = true
244+ ceremony, options := d.assertion(t, v, s)
245+ return finish(v, ceremony, d.respond(s, options), d)
246+ }},
247+ {"user not present", func(t *testing.T, d *device) error {
248+ d.auth.Options.UserNotPresent = true
249+ ceremony, options := d.assertion(t, v, s)
250+ return finish(v, ceremony, d.respond(s, options), d)
251+ }},
252+ {"unknown credential", func(t *testing.T, d *device) error {
253+ ceremony, options := d.assertion(t, v, s)
254+ delete(d.store, string(d.cred.ID))
255+ return finish(v, ceremony, d.respond(s, options), d)
256+ }},
257+ {"other user handle", func(t *testing.T, d *device) error {
258+ d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------")
259+ ceremony, options := d.assertion(t, v, s)
260+ return finish(v, ceremony, d.respond(s, options), d)
261+ }},
262+ {"no user handle", func(t *testing.T, d *device) error {
263+ d.auth.Options.UserHandle = nil
264+ ceremony, options := d.assertion(t, v, s)
265+ return finish(v, ceremony, d.respond(s, options), d)
266+ }},
267+ {"registration ceremony used for a login", func(t *testing.T, d *device) error {
268+ _, ceremony, err := v.BeginRegistration(userHandle, nil)
269+ require.NoError(t, err)
270+ _, options := d.assertion(t, v, s)
271+ return finish(v, ceremony, d.respond(s, options), d)
272+ }},
273+ }
274+ for _, tc := range tests {
275+ t.Run(tc.name, func(t *testing.T) {
276+ t.Parallel()
277+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
278+ opts := d.auth.Options
279+ stored := maps.Clone(d.store)
280+ err := tc.run(t, d)
281+ require.Error(t, err)
282+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
283+
284+ // Control: the refusal was for the reason of the case. With the device
285+ // and the store as they were, a fresh assertion still succeeds, so the
286+ // case did not fail by something that breaks every login.
287+ d.auth.Options = opts
288+ d.store = stored
289+ ceremony, options := d.assertion(t, v, s)
290+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
291+ require.NoError(t, err, "a fresh, untouched assertion must succeed")
292+ assert.Equal(t, d.cred.ID, got.ID)
293+ })
294+ }
295+}
296+
297+func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error {
298+ _, err := v.FinishLogin(userHandle, ceremony, response, d.lookup)
299+ return err
300+}
301+
302+// setExpires rewrites the expiry of a ceremony the way a late finish would see
303+// it, since the library checks it against the wall clock.
304+func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte {
305+ t.Helper()
306+ var m map[string]any
307+ require.NoError(t, json.Unmarshal(ceremony, &m))
308+ require.Contains(t, m, "expires")
309+ m["expires"] = at
310+ out, err := json.Marshal(m)
311+ require.NoError(t, err)
312+ return out
313+}
314+
315+func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) {
316+ t.Parallel()
317+ s := sites["localhost"]
318+ v, err := webauthn.New(s.url)
319+ require.NoError(t, err)
320+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
321+ ceremony, options := d.assertion(t, v, s)
322+ boom := errors.New("disk on fire")
323+ _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) {
324+ return domain.Passkey{}, boom
325+ })
326+ require.ErrorIs(t, err, boom)
327+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
328+}
329+
330+// A counter that does not advance is not refused here: the verifier reports the
331+// stored counter (the library keeps it on a clone warning) and the use case,
332+// which orders the logins under its lock, refuses it. TestPasskeyClonedAuthenticator
333+// in package app checks the refusal end to end.
334+func TestCounterThatDoesNotAdvanceKeepsTheStoredOne(t *testing.T) {
335+ t.Parallel()
336+ s := sites["localhost"]
337+ v, err := webauthn.New(s.url)
338+ require.NoError(t, err)
339+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
340+
341+ d.cred.Counter = 1
342+ ceremony, options := d.assertion(t, v, s)
343+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
344+ require.NoError(t, err)
345+ assert.Equal(t, uint32(1), got.SignCount)
346+ d.store[string(got.ID)] = got
347+
348+ // The counter did not advance: a copy of the key may be in use. The answer
349+ // is valid, and carries the stored counter, not a lower or equal new one.
350+ ceremony, options = d.assertion(t, v, s)
351+ again, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
352+ require.NoError(t, err)
353+ assert.Equal(t, uint32(1), again.SignCount, "the stored counter, unchanged")
354+
355+ // A response with a lower counter keeps the stored one too.
356+ d.cred.Counter = 0
357+ ceremony, options = d.assertion(t, v, s)
358+ lower, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
359+ require.NoError(t, err)
360+ assert.Equal(t, uint32(1), lower.SignCount)
361+}
362+
363+// Synced passkeys always report 0: that is not a clone.
364+func TestZeroCounterIsNotAClone(t *testing.T) {
365+ t.Parallel()
366+ s := sites["localhost"]
367+ v, err := webauthn.New(s.url)
368+ require.NoError(t, err)
369+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true})
370+ for range 2 {
371+ ceremony, options := d.assertion(t, v, s)
372+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
373+ require.NoError(t, err)
374+ assert.Zero(t, got.SignCount)
375+ }
376+}
377+
378+func TestRegistrationRefused(t *testing.T) {
379+ t.Parallel()
380+ s := sites["https"]
381+ v, err := webauthn.New(s.url)
382+ require.NoError(t, err)
383+
384+ tests := []struct {
385+ name string
386+ auth virtualwebauthn.AuthenticatorOptions
387+ rp func(rp *virtualwebauthn.RelyingParty)
388+ // ceremony replaces the ceremony data when set.
389+ ceremony func(t *testing.T, ceremony []byte) []byte
390+ handle []byte
391+ }{
392+ {name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }},
393+ {name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }},
394+ {name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}},
395+ {name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }},
396+ {name: "other user", handle: []byte("somebody-else-------------------")},
397+ }
398+ for _, tc := range tests {
399+ t.Run(tc.name, func(t *testing.T) {
400+ t.Parallel()
401+ rp := s.rp
402+ if tc.rp != nil {
403+ tc.rp(&rp)
404+ }
405+ auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth)
406+ cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
407+ options, ceremony, err := v.BeginRegistration(userHandle, nil)
408+ require.NoError(t, err)
409+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
410+ require.NoError(t, err)
411+ response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed)
412+ if tc.ceremony != nil {
413+ ceremony = tc.ceremony(t, ceremony)
414+ }
415+ handle := userHandle
416+ if tc.handle != nil {
417+ handle = tc.handle
418+ }
419+ _, err = v.FinishRegistration(handle, ceremony, []byte(response))
420+ require.Error(t, err)
421+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
422+ })
423+ }
424+
425+ t.Run("garbage", func(t *testing.T) {
426+ t.Parallel()
427+ _, ceremony, err := v.BeginRegistration(userHandle, nil)
428+ require.NoError(t, err)
429+ _, err = v.FinishRegistration(userHandle, ceremony, []byte("{"))
430+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
431+ _, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}"))
432+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
433+ })
434+}
435+
436+func TestNew(t *testing.T) {
437+ t.Parallel()
438+ for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} {
439+ _, err := webauthn.New(ok)
440+ assert.NoError(t, err, ok)
441+ }
442+ for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} {
443+ _, err := webauthn.New(bad)
444+ assert.Error(t, err, bad)
445+ }
446+}
M internal/app/cli_e2e_test.go +7 -4
@@ -309,9 +309,11 @@ func TestAC6CLIEndToEnd(t *testing.T) {
309309 res = c.cli(t, "", "repo", "list", "--json").ok(t)
310310 assert.JSONEq(t, `[]`, res.stdout)
311311
312- // version: no token needed, nothing missing.
312+ // version: no token needed. This server runs on http://127.0.0.1, where
313+ // passkeys are not available: they are the only feature missing.
313314 res = c.cli(t, "", "version", "--json").ok(t)
314- assert.Empty(t, res.stderr, "no missing-feature warning")
315+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
316+ assert.Contains(t, res.stderr, "the server does not support passkeys")
315317 type versionOut struct {
316318 Client struct{ Version string }
317319 Server struct {
@@ -325,11 +327,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
325327 assert.Equal(t, c.url, ver.Server.URL)
326328 assert.NotEmpty(t, ver.Server.Version)
327329 assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
328- assert.Equal(t, []string{}, ver.Server.MissingFeatures)
330+ assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
329331 stdout, stderr, code = c.bin(t, secret, "version")
330332 require.Equal(t, 0, code, stderr)
331333 assert.Contains(t, stdout, "server")
332- assert.Empty(t, stderr)
334+ assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
335+ assert.Contains(t, stderr, "the server does not support passkeys")
333336
334337 // Tokens: create a second one, list, revoke it, see it stop working.
335338 // (through the binary: the in-process runs use a fixed clock)
M internal/app/cli_fake_test.go +12 -0
@@ -31,6 +31,7 @@ type fakeAPI struct {
3131 ermiteconnect.UnimplementedServerServiceHandler
3232 ermiteconnect.UnimplementedEventServiceHandler
3333 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedSessionServiceHandler
3435 ermiteconnect.UnimplementedRunServiceHandler
3536
3637 mu sync.Mutex
@@ -58,6 +59,9 @@ type fakeAPI struct {
5859 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
5960 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
6061 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
62+
63+ listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
64+ removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error)
6165 }
6266
6367 func (f *fakeAPI) record(name string, h http.Header, req any) {
@@ -140,6 +144,12 @@ func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkRead
140144 func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
141145 return respond(f, "GetSigningKey", r, f.getSigningKey)
142146 }
147+func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
148+ return respond(f, "ListPasskeys", r, f.listPasskeys)
149+}
150+func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
151+ return respond(f, "RemovePasskey", r, f.removePasskey)
152+}
143153 func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
144154 f.record("Watch", r.Header(), r.Msg)
145155 if f.watch == nil {
@@ -163,6 +173,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
163173 mount(p, h)
164174 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
165175 mount(p, h)
176+ p, h = ermiteconnect.NewSessionServiceHandler(f)
177+ mount(p, h)
166178 p, h = ermiteconnect.NewRunServiceHandler(f)
167179 mount(p, h)
168180 srv := httptest.NewServer(mux)
M internal/app/cli_version.go +1 -0
@@ -116,4 +116,5 @@ var featureUse = map[string]string{
116116 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117117 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118118 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
119+ client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
119120 }
M internal/app/cli_version_test.go +20 -5
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "errors"
7+ "strings"
78 "testing"
89
910 "connectrpc.com/connect"
@@ -14,7 +15,7 @@ import (
1415 )
1516
1617 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners"}
18+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys"}
1819
1920 func versionFake(features ...string) *fakeAPI {
2021 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +36,15 @@ func TestVersionWithServer(t *testing.T) {
3536 f := versionFake(allFeatures...)
3637 url := f.serve(t)
3738 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners\n", res.stdout)
39+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, passkeys\n", res.stdout)
3940 assert.Empty(t, res.stderr, "no warning when every feature is there")
4041
4142 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
43+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":[]}}`, res.stdout)
4344 }
4445
4546 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners")
47+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys")
4748 url := f.serve(t)
4849 res := runCLI(t, envFor(url), "", "version").ok(t)
4950 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,13 +52,27 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5152 assert.NotContains(t, res.stderr, "repo.update")
5253
5354 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
55+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
5556 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5657
5758 f = versionFake()
5859 res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t)
5960 assert.Contains(t, res.stderr, "repo.update")
6061 assert.Contains(t, res.stderr, "events.resume")
62+ assert.Contains(t, res.stderr, "passkeys")
63+}
64+
65+// A server without passkeys (http public URL, or an IP address host) works for
66+// everything else: only `ermite web passkey` is warned about.
67+func TestVersionWarnsAboutMissingPasskeys(t *testing.T) {
68+ f := versionFake(allFeatures[:len(allFeatures)-1]...)
69+ res := runCLI(t, envFor(f.serve(t)), "", "version", "--json").ok(t)
70+ assert.Contains(t, res.stdout, `"missing_features": [
71+ "passkeys"
72+ ]`)
73+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
74+ assert.Contains(t, res.stderr, "the server does not support passkeys, which `ermite web passkey` needs")
75+ assert.Contains(t, res.stderr, "https public URL")
6176 }
6277
6378 func TestVersionServerFailureStillShowsClient(t *testing.T) {
M internal/app/cli_web.go +117 -3
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "context"
67 "errors"
78 "fmt"
89
@@ -10,6 +11,7 @@ import (
1011 "github.com/spf13/cobra"
1112
1213 "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/adapters/client"
1315 )
1416
1517 // jsonLoginLink is the stable JSON shape of `ermite web login --json`.
@@ -22,10 +24,10 @@ func newWebCmd(env *cliEnv) *cobra.Command {
2224 web := &cobra.Command{
2325 Use: "web",
2426 Short: "Sign the owner in to the web interface of a remote server",
25- Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +
26- "token that holds every scope and is limited neither to repositories nor to branches.",
27+ Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" +
28+ "token, or a token that holds every scope and is limited neither to repositories nor to branches.",
2729 }
28- web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))
30+ web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env))
2931 return web
3032 }
3133
@@ -87,3 +89,115 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
8789 }
8890 return cmd
8991 }
92+
93+// jsonPasskey is the stable JSON shape of a passkey. It never holds key
94+// material.
95+type jsonPasskey struct {
96+ ID string `json:"id"`
97+ Name string `json:"name"`
98+ CreatedAt *string `json:"created_at"`
99+ LastUsedAt *string `json:"last_used_at"`
100+ Synced bool `json:"synced"`
101+}
102+
103+const jsonPasskeyHelp = "\n\nJSON fields (--json): id (credential id, base64url), name, created_at (RFC 3339 UTC), last_used_at\n" +
104+ "(null if never used), synced (the authenticator reports the passkey as backed up)."
105+
106+func newWebPasskeyCmd(env *cliEnv) *cobra.Command {
107+ cmd := &cobra.Command{
108+ Use: "passkey",
109+ Short: "List or remove the passkeys that sign the owner in",
110+ Long: "List or remove the passkeys that sign the owner in to the web interface. Adding one needs a browser:\n" +
111+ "sign in (`ermite web login`), then open /-/passkeys. Passkeys are only available when the server's\n" +
112+ "public URL is https (or localhost) with a host name, not an IP address.",
113+ }
114+ cmd.AddCommand(newWebPasskeyListCmd(env), newWebPasskeyRemoveCmd(env))
115+ return cmd
116+}
117+
118+func listPasskeys(ctx context.Context, c *client.Client) ([]*ermite.Passkey, error) {
119+ res, err := c.Sessions().ListPasskeys(ctx, connect.NewRequest(&ermite.ListPasskeysRequest{}))
120+ if err != nil {
121+ return nil, c.Describe(err)
122+ }
123+ return res.Msg.GetPasskeys(), nil
124+}
125+
126+func newWebPasskeyListCmd(env *cliEnv) *cobra.Command {
127+ cmd := &cobra.Command{
128+ Use: "list",
129+ Short: "List the registered passkeys",
130+ Long: "List the passkeys, oldest first, with the id to give to `ermite web passkey remove`." + jsonPasskeyHelp +
131+ "\n--json prints an array of passkeys.",
132+ Args: cobra.NoArgs,
133+ }
134+ asJSON := addJSONFlag(cmd)
135+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
136+ c, err := env.client(cmd)
137+ if err != nil {
138+ return err
139+ }
140+ passkeys, err := listPasskeys(env.ctx(cmd), c)
141+ if err != nil {
142+ return err
143+ }
144+ if *asJSON {
145+ out := make([]jsonPasskey, 0, len(passkeys))
146+ for _, p := range passkeys {
147+ out = append(out, jsonPasskey{
148+ ID: p.GetId(), Name: p.GetName(), CreatedAt: jsonTime(p.GetCreatedAt()),
149+ LastUsedAt: jsonTime(p.GetLastUsedAt()), Synced: p.GetSynced(),
150+ })
151+ }
152+ return writeJSON(cmd.OutOrStdout(), out)
153+ }
154+ if len(passkeys) == 0 {
155+ noticef(cmd, "no passkeys")
156+ return nil
157+ }
158+ rows := make([][]string, 0, len(passkeys))
159+ for _, p := range passkeys {
160+ synced := "no"
161+ if p.GetSynced() {
162+ synced = "yes"
163+ }
164+ rows = append(rows, []string{
165+ p.GetId(), p.GetName(), shortTime(p.GetCreatedAt(), "-"), shortTime(p.GetLastUsedAt(), "never"), synced,
166+ })
167+ }
168+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "CREATED", "LAST USED", "SYNCED"}, rows)
169+ }
170+ return cmd
171+}
172+
173+func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command {
174+ return &cobra.Command{
175+ Use: "remove <id>",
176+ Short: "Remove a passkey and sign every browser out",
177+ Long: "Remove a passkey by id (see `ermite web passkey list`). It can no longer sign in. Removing one also\n" +
178+ "signs every browser out, like `ermite web logout --all`: web sessions are not tied to the passkey that\n" +
179+ "opened them. Sign in again with another passkey or `ermite web login`.",
180+ Args: cobra.ExactArgs(1),
181+ ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
182+ if len(args) > 0 {
183+ return nil, cobra.ShellCompDirectiveNoFileComp
184+ }
185+ ids := complete(env, cmd, toComplete, listPasskeys, func(p *ermite.Passkey) (string, string) {
186+ return p.GetId(), clean(p.GetName())
187+ })
188+ return ids, cobra.ShellCompDirectiveNoFileComp
189+ },
190+ RunE: func(cmd *cobra.Command, args []string) error {
191+ c, err := env.client(cmd)
192+ if err != nil {
193+ return err
194+ }
195+ if _, err := c.Sessions().RemovePasskey(env.ctx(cmd), connect.NewRequest(&ermite.RemovePasskeyRequest{Id: args[0]})); err != nil {
196+ return c.Describe(err)
197+ }
198+ noticef(cmd, "passkey removed; every browser is signed out")
199+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", args[0])
200+ return err
201+ },
202+ }
203+}
A internal/app/cli_web_test.go +134 -0
@@ -0,0 +1,134 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+func twoPasskeys() *ermite.ListPasskeysResponse {
17+ return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{
18+ {Id: "AQID", Name: "Pixel 9", CreatedAt: ts("2026-10-01T08:00:00Z"), LastUsedAt: ts("2026-10-09T18:30:00Z"), Synced: true},
19+ {Id: "BAUG", Name: "yubikey", CreatedAt: ts("2026-10-02T09:15:00Z")},
20+ }}
21+}
22+
23+func TestWebPasskeyList(t *testing.T) {
24+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
25+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
26+ assert.Empty(t, res.stderr)
27+ assert.Regexp(t, `(?m)^ID\s+NAME\s+CREATED\s+LAST USED\s+SYNCED$`, res.stdout)
28+ assert.Regexp(t, `(?m)^AQID\s+Pixel 9\s+2026-10-01 08:00\s+2026-10-09 18:30\s+yes$`, res.stdout)
29+ assert.Regexp(t, `(?m)^BAUG\s+yubikey\s+2026-10-02 09:15\s+never\s+no$`, res.stdout)
30+}
31+
32+func TestWebPasskeyListEmpty(t *testing.T) {
33+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
34+ return &ermite.ListPasskeysResponse{}, nil
35+ }}
36+ url := f.serve(t)
37+ res := runCLI(t, envFor(url), "", "web", "passkey", "list").ok(t)
38+ assert.Empty(t, res.stdout)
39+ assert.Contains(t, res.stderr, "no passkeys")
40+
41+ res = runCLI(t, envFor(url), "", "web", "passkey", "list", "--json").ok(t)
42+ assert.JSONEq(t, `[]`, res.stdout)
43+}
44+
45+func TestWebPasskeyListJSON(t *testing.T) {
46+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
47+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list", "--json").ok(t)
48+ assert.Empty(t, res.stderr)
49+ assert.JSONEq(t, `[
50+ {"id":"AQID","name":"Pixel 9","created_at":"2026-10-01T08:00:00Z","last_used_at":"2026-10-09T18:30:00Z","synced":true},
51+ {"id":"BAUG","name":"yubikey","created_at":"2026-10-02T09:15:00Z","last_used_at":null,"synced":false}
52+ ]`, res.stdout)
53+}
54+
55+func TestWebPasskeyListCleansServerText(t *testing.T) {
56+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
57+ return &ermite.ListPasskeysResponse{Passkeys: []*ermite.Passkey{{Id: "AQ", Name: "evil\x1b[2J name", CreatedAt: ts("2026-10-01T08:00:00Z")}}}, nil
58+ }}
59+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list").ok(t)
60+ assert.NotContains(t, res.stdout, "\x1b")
61+}
62+
63+func TestWebPasskeyRemove(t *testing.T) {
64+ f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
65+ return &ermite.RemovePasskeyResponse{}, nil
66+ }}
67+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID").ok(t)
68+ assert.Equal(t, "removed AQID\n", res.stdout)
69+ assert.Contains(t, res.stderr, "every browser is signed out")
70+
71+ calls, reqs := f.snapshot()
72+ assert.Equal(t, []string{"RemovePasskey"}, calls)
73+ assert.Equal(t, "AQID", reqs[0].(*ermite.RemovePasskeyRequest).GetId())
74+}
75+
76+func TestWebPasskeyRemoveErrors(t *testing.T) {
77+ for name, tc := range map[string]struct {
78+ code connect.Code
79+ msg string
80+ want string
81+ }{
82+ "unknown id": {connect.CodeNotFound, "not found", "not found"},
83+ "not the owner": {connect.CodePermissionDenied, "", "permission denied"},
84+ "unavailable": {connect.CodeFailedPrecondition, "passkeys are not available on this server", "passkeys are not available on this server"},
85+ "bad id": {connect.CodeInvalidArgument, "id is not a base64url credential id", "invalid request"},
86+ } {
87+ t.Run(name, func(t *testing.T) {
88+ f := &fakeAPI{removePasskey: func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error) {
89+ return nil, connect.NewError(tc.code, errors.New(tc.msg))
90+ }}
91+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "remove", "AQID")
92+ require.Error(t, res.err)
93+ assert.Contains(t, res.err.Error(), tc.want)
94+ assert.Empty(t, res.stdout, "nothing is reported as removed")
95+ assert.NotContains(t, res.stderr, "signed out")
96+ })
97+ }
98+}
99+
100+func TestWebPasskeyListUnavailable(t *testing.T) {
101+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) {
102+ return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
103+ }}
104+ res := runCLI(t, envFor(f.serve(t)), "", "web", "passkey", "list")
105+ require.Error(t, res.err)
106+ assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
107+}
108+
109+func TestWebPasskeyArgs(t *testing.T) {
110+ f := &fakeAPI{}
111+ url := f.serve(t)
112+ for _, args := range [][]string{
113+ {"web", "passkey", "remove"},
114+ {"web", "passkey", "remove", "a", "b"},
115+ {"web", "passkey", "list", "extra"},
116+ } {
117+ res := runCLI(t, envFor(url), "", args...)
118+ require.Error(t, res.err, args)
119+ }
120+ calls, _ := f.snapshot()
121+ assert.Empty(t, calls, "nothing reached the server")
122+}
123+
124+// The ids complete from the server's list.
125+func TestWebPasskeyRemoveCompletesIDs(t *testing.T) {
126+ f := &fakeAPI{listPasskeys: func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) { return twoPasskeys(), nil }}
127+ res := runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "").ok(t)
128+ assert.Contains(t, res.stdout, "AQID\tPixel 9")
129+ assert.Contains(t, res.stdout, "BAUG\tyubikey")
130+
131+ res = runCLI(t, envFor(f.serve(t)), "", "__complete", "web", "passkey", "remove", "B").ok(t)
132+ assert.NotContains(t, res.stdout, "AQID")
133+ assert.Contains(t, res.stdout, "BAUG\tyubikey")
134+}
M internal/app/e2e_helpers_test.go +5 -0
@@ -48,6 +48,8 @@ type harnessOpts struct {
4848 eventBuffer int
4949 // revalidateEvery overrides how often a watcher's token is re-checked.
5050 revalidateEvery time.Duration
51+ // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
52+ publicURL string
5153 }
5254
5355 // newHarness starts a forge. The data directory is short: the hook socket
@@ -72,6 +74,9 @@ func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness
7274 config.EnvGit: gitBin,
7375 config.EnvAddr: "127.0.0.1:1",
7476 }
77+ if o.publicURL != "" {
78+ env[config.EnvPublicURL] = o.publicURL
79+ }
7580 if o.trustedProxies != "" {
7681 env[config.EnvTrustedProxies] = o.trustedProxies
7782 }
M internal/app/forge.go +24 -1
@@ -25,6 +25,7 @@ import (
2525 "bnema.dev/ermite/internal/adapters/rpc"
2626 "bnema.dev/ermite/internal/adapters/sshsig"
2727 "bnema.dev/ermite/internal/adapters/web"
28+ "bnema.dev/ermite/internal/adapters/webauthn"
2829 "bnema.dev/ermite/internal/clientlimit"
2930 "bnema.dev/ermite/internal/config"
3031 "bnema.dev/ermite/internal/domain"
@@ -77,7 +78,9 @@ type forge struct {
7778 MergeRequests *usecase.MergeRequests
7879 SigningKey *usecase.SigningKey
7980 Sessions *usecase.Sessions
80- EventLog *usecase.Events
81+ // Passkeys is nil when passkeys are not available (passkeysAvailable).
82+ Passkeys *usecase.Passkeys
83+ EventLog *usecase.Events
8184 // CI is the CI engine: workflow trigger, planner, runner protocol and the
8285 // owner side. It starts with the server and stops with it.
8386 CI *usecase.CI
@@ -231,9 +234,28 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
231234 return nil, err
232235 }
233236
237+ // Owner passkeys (ADR 0026): built only when the public URL allows them.
238+ // When it does, a verifier that cannot be built is fatal.
239+ if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
240+ verifier, err := webauthn.New(cfg.PublicURL)
241+ if err != nil {
242+ return nil, fmt.Errorf("passkeys: %w", err)
243+ }
244+ f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
245+ log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
246+ } else {
247+ log.Info().Str("reason", reason).Msg("passkeys disabled")
248+ }
249+
234250 // One budget of failed authentications per client, for Git, the API and
235251 // the web login.
236252 limiter := clientlimit.NewLimiter(nil)
253+ // A nil *usecase.Passkeys must not become a non-nil interface: the API
254+ // tests for nil to answer failed_precondition and to leave the feature out.
255+ var apiPasskeys ports.PasskeyService
256+ if f.Passkeys != nil {
257+ apiPasskeys = f.Passkeys
258+ }
237259 f.API = rpc.New(rpc.Config{
238260 Auth: f.Auth,
239261 Repos: f.Repos,
@@ -243,6 +265,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
243265 MergeRequests: f.MergeRequests,
244266 SigningKey: f.SigningKey,
245267 Sessions: f.Sessions,
268+ Passkeys: apiPasskeys,
246269 Runner: f.CI,
247270 Runs: f.CI,
248271 Version: serverVersion(),
M internal/app/testdata/web/blob-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-code.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-image.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-markdown.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-svg.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-text.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-too-large.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/blob-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-big.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commits-page2.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commits-path.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commits-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/commits-small.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/home.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/not-found.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/refs.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/summary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/tree-nested.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/tree-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/tree-slash-ref.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/testdata/web/tree-subdir.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 <script src="/-/static/js/log.HASH.js" defer>
2325 </script>
2426 </head>
M internal/app/web.go +65 -9
@@ -29,6 +29,12 @@ import (
2929 // checks it with sessions); every other request is anonymous. The web passes
3030 // the principal of the request to each use case call itself.
3131 func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32+ // A nil *usecase.Passkeys must not become a non-nil interface (the web
33+ // tests for nil to leave the passkey routes out).
34+ var passkeys ports.WebPasskeyService
35+ if f.Passkeys != nil {
36+ passkeys = webPasskeys{f.Passkeys}
37+ }
3238 return web.New(web.Config{
3339 Repos: f.Repos,
3440 Browse: f.Browse,
@@ -40,6 +46,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
4046 Version: serverVersion(),
4147
4248 Sessions: webSessions{f.Sessions},
49+ Passkeys: passkeys,
4350 Limiter: limiter,
4451 TrustedProxies: cfg.TrustedProxies,
4552 })
@@ -62,6 +69,25 @@ func sessionsNeedHTTPS(publicURL string) bool {
6269 return err != nil || !ip.Unmap().IsLoopback()
6370 }
6471
72+// passkeysAvailable reports whether passkeys can work on this public URL, and
73+// if not, why. WebAuthn needs a secure context (https, or localhost) and a
74+// relying party ID that is a DNS name, not an IP address, so a server on
75+// http://127.0.0.1 starts without passkeys. A sign-in needs the session
76+// cookie, hence the same https rule as sessionsNeedHTTPS.
77+func passkeysAvailable(publicURL string) (ok bool, reason string) {
78+ if sessionsNeedHTTPS(publicURL) {
79+ return false, "needs https"
80+ }
81+ u, err := url.Parse(publicURL)
82+ if err != nil || u.Hostname() == "" {
83+ return false, "public URL has no host"
84+ }
85+ if _, err := netip.ParseAddr(strings.ToLower(u.Hostname())); err == nil {
86+ return false, "public URL host is an IP address"
87+ }
88+ return true, ""
89+}
90+
6591 // warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
6692 // not work on an http public URL. It does not stop the server: the pages,
6793 // the API and Git do not need the cookie.
@@ -107,20 +133,27 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura
107133 if err != nil {
108134 return "", 0, err
109135 }
110- // The cookie lasts as long as the session, which the expiry of the token
111- // that made the link may cut short. The session token carries its own
112- // expiry, so a cookie that outlives it by under a second is harmless.
113- // The expiry is truncated to the second, hence the slack on the full TTL.
114- lifetime := usecase.SessionTTL
115- if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
116- lifetime = d.Round(time.Second)
117- }
118- if lifetime < time.Second {
136+ lifetime, ok := cookieLifetime(expiresAt)
137+ if !ok {
119138 return "", 0, ports.ErrLinkRejected
120139 }
121140 return token, lifetime, nil
122141 }
123142
143+// cookieLifetime is how long the session cookie lasts for a session that
144+// expires at expiresAt: as long as the session, which the expiry of the token
145+// that made a login link may cut short. The session token carries its own
146+// expiry, so a cookie that outlives it by under a second is harmless. The
147+// expiry is truncated to the second, hence the slack on the full TTL. ok is
148+// false when under a second is left: there is no session to give.
149+func cookieLifetime(expiresAt time.Time) (lifetime time.Duration, ok bool) {
150+ lifetime = usecase.SessionTTL
151+ if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
152+ lifetime = d.Round(time.Second)
153+ }
154+ return lifetime, lifetime >= time.Second
155+}
156+
124157 func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) {
125158 p, err := s.Sessions.Verify(ctx, token)
126159 if errors.Is(err, domain.ErrUnauthenticated) {
@@ -132,6 +165,29 @@ func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal
132165 return p, true, nil
133166 }
134167
168+// webPasskeys adapts the passkey use cases to ports.WebPasskeyService. Only
169+// FinishLogin differs from *usecase.Passkeys: the session a passkey sign-in
170+// opens is given to the web as a token and a lifetime, like webSessions.Login
171+// does for a link, and a refusal is ports.ErrLinkRejected.
172+type webPasskeys struct{ *usecase.Passkeys }
173+
174+var _ ports.WebPasskeyService = webPasskeys{}
175+
176+func (p webPasskeys) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
177+ token, expiresAt, err := p.Passkeys.FinishLogin(ctx, ceremony, response)
178+ if errors.Is(err, domain.ErrUnauthenticated) {
179+ return "", 0, ports.ErrLinkRejected
180+ }
181+ if err != nil {
182+ return "", 0, err
183+ }
184+ lifetime, ok := cookieLifetime(expiresAt)
185+ if !ok {
186+ return "", 0, ports.ErrLinkRejected
187+ }
188+ return token, lifetime, nil
189+}
190+
135191 // webRoutes mounts the web on the patterns it serves.
136192 func webRoutes(h http.Handler) []httpserver.Route {
137193 routes := make([]httpserver.Route, 0, len(web.Patterns))
A internal/app/web_adapters_test.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+// cookieLifetime: the session lifetime, cut short by an expiry that comes
22+// first, and no session at all when under a second is left.
23+func TestCookieLifetime(t *testing.T) {
24+ t.Parallel()
25+ now := time.Now()
26+ tests := []struct {
27+ name string
28+ expires time.Time
29+ want time.Duration // approximate, within a second
30+ ok bool
31+ }{
32+ {"a full session", now.Add(usecase.SessionTTL), usecase.SessionTTL, true},
33+ {"the expiry is truncated to the second: the slack counts as full", now.Add(usecase.SessionTTL - 500*time.Millisecond), usecase.SessionTTL, true},
34+ {"cut short by the token that made the link", now.Add(30 * time.Minute), 30 * time.Minute, true},
35+ {"two seconds left", now.Add(2 * time.Second), 2 * time.Second, true},
36+ {"under a second left", now.Add(400 * time.Millisecond), 0, false},
37+ {"already over", now.Add(-time.Hour), 0, false},
38+ }
39+ for _, tc := range tests {
40+ t.Run(tc.name, func(t *testing.T) {
41+ t.Parallel()
42+ got, ok := cookieLifetime(tc.expires)
43+ assert.Equal(t, tc.ok, ok)
44+ if tc.ok {
45+ assert.InDelta(t, tc.want.Seconds(), got.Seconds(), 1.5)
46+ }
47+ })
48+ }
49+}
50+
51+// webPasskeys.FinishLogin turns the use case's answer into the web's: a refusal
52+// is ports.ErrLinkRejected, any other failure is passed on, and a success has
53+// the cookie lifetime of its session.
54+func TestWebPasskeysFinishLogin(t *testing.T) {
55+ t.Parallel()
56+ ctx := context.Background()
57+ boom := errors.New("disk on fire")
58+
59+ // newPasskeys builds the use cases over mocks; the verifier answers verify.
60+ newPasskeys := func(t *testing.T, verify func() (domain.Passkey, error), stored domain.Passkey) (webPasskeys, string) {
61+ t.Helper()
62+ clock := mocks.NewClock(t)
63+ clock.EXPECT().Now().RunAndReturn(time.Now).Maybe()
64+ sessionStore := mocks.NewSessionStore(t)
65+ sessionStore.EXPECT().Key(mock.Anything).Return([]byte("0123456789abcdef0123456789abcdef"), nil)
66+ sessionStore.EXPECT().Epoch(mock.Anything).Return(uint64(0), nil).Maybe()
67+ sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example")
68+ require.NoError(t, err)
69+
70+ store := mocks.NewPasskeyStore(t)
71+ store.EXPECT().UserHandle(mock.Anything).Return([]byte("handle"), nil).Maybe()
72+ store.EXPECT().Get(mock.Anything, mock.Anything).Return(stored, nil).Maybe()
73+ store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil).Maybe()
74+ verifier := mocks.NewPasskeyVerifier(t)
75+ verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("c"), nil)
76+ verifier.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
77+ RunAndReturn(func([]byte, []byte, []byte, func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
78+ return verify()
79+ }).Maybe() // not reached for an unknown ceremony
80+ pk := webPasskeys{usecase.NewPasskeys(store, verifier, sessions, clock)}
81+ id, _, err := pk.BeginLogin(ctx, "192.0.2.1")
82+ require.NoError(t, err)
83+ return pk, id
84+ }
85+ stored := domain.Passkey{ID: []byte("cred-1")}
86+
87+ t.Run("a refusal is ErrLinkRejected", func(t *testing.T) {
88+ t.Parallel()
89+ pk, id := newPasskeys(t, func() (domain.Passkey, error) {
90+ return domain.Passkey{}, domain.ErrUnauthenticated
91+ }, stored)
92+ token, lifetime, err := pk.FinishLogin(ctx, id, []byte(`{}`))
93+ assert.ErrorIs(t, err, ports.ErrLinkRejected)
94+ assert.Empty(t, token)
95+ assert.Zero(t, lifetime)
96+ })
97+
98+ t.Run("an unknown ceremony is a refusal too", func(t *testing.T) {
99+ t.Parallel()
100+ pk, _ := newPasskeys(t, func() (domain.Passkey, error) { return stored, nil }, stored)
101+ _, _, err := pk.FinishLogin(ctx, "unknown", []byte(`{}`))
102+ assert.ErrorIs(t, err, ports.ErrLinkRejected)
103+ })
104+
105+ t.Run("another error is passed on as it is", func(t *testing.T) {
106+ t.Parallel()
107+ pk, id := newPasskeys(t, func() (domain.Passkey, error) { return domain.Passkey{}, boom }, stored)
108+ token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
109+ require.ErrorIs(t, err, boom)
110+ assert.NotErrorIs(t, err, ports.ErrLinkRejected)
111+ assert.Empty(t, token)
112+ })
113+
114+ t.Run("a success gives the token and the lifetime of the session", func(t *testing.T) {
115+ t.Parallel()
116+ pk, id := newPasskeys(t, func() (domain.Passkey, error) { return stored, nil }, stored)
117+ token, lifetime, err := pk.FinishLogin(ctx, id, []byte(`{}`))
118+ require.NoError(t, err)
119+ assert.NotEmpty(t, token)
120+ assert.InDelta(t, usecase.SessionTTL.Seconds(), lifetime.Seconds(), 1.5)
121+ })
122+}
A internal/app/web_passkey_test.go +309 -0
@@ -0,0 +1,309 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "encoding/json"
11+ "net/http"
12+ "net/url"
13+ "strings"
14+ "testing"
15+
16+ "github.com/descope/virtualwebauthn"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+)
22+
23+const passkeyOrigin = "https://git.example.test"
24+
25+var passkeyRP = virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: passkeyOrigin}
26+
27+// passkeyHarness is a forge whose public URL allows passkeys, with a virtual
28+// authenticator in front of its real web handler.
29+type passkeyHarness struct {
30+ *cliHarness
31+ b browser
32+ auth virtualwebauthn.Authenticator
33+ cred virtualwebauthn.Credential
34+}
35+
36+func newPasskeyHarness(t *testing.T) *passkeyHarness {
37+ t.Helper()
38+ h := newHarness(t, harnessOpts{publicURL: passkeyOrigin})
39+ c := &cliHarness{harness: h, url: h.srv.URL}
40+ return &passkeyHarness{
41+ cliHarness: c, b: browser{h},
42+ auth: virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{BackupEligible: true}),
43+ cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
44+ }
45+}
46+
47+// postJSON posts a JSON body as the browser's script does.
48+func (p *passkeyHarness) postJSON(path, token string, body any) page {
49+ p.t.Helper()
50+ var buf bytes.Buffer
51+ if body != nil {
52+ require.NoError(p.t, json.NewEncoder(&buf).Encode(body))
53+ }
54+ req, err := http.NewRequest("POST", p.url+path, &buf)
55+ require.NoError(p.t, err)
56+ req.Header.Set("Origin", passkeyOrigin)
57+ req.Header.Set("Content-Type", "application/json")
58+ if token != "" {
59+ req.Header.Set("Cookie", sessionCookie+"="+token)
60+ }
61+ rec, err := http.DefaultClient.Do(req)
62+ require.NoError(p.t, err)
63+ defer func() { _ = rec.Body.Close() }()
64+ var out bytes.Buffer
65+ _, _ = out.ReadFrom(rec.Body)
66+ return page{status: rec.StatusCode, hdr: rec.Header, body: out.String()}
67+}
68+
69+// passkeyLoginPath turns the link the CLI printed into a path on the test
70+// server (the harness here has an https public URL).
71+func passkeyLoginPath(t *testing.T, link string) string {
72+ t.Helper()
73+ u, err := url.Parse(strings.TrimSpace(link))
74+ require.NoError(t, err)
75+ assert.Equal(t, passkeyOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
76+ return u.Path
77+}
78+
79+type beginReply struct {
80+ Ceremony string `json:"ceremony"`
81+ Options json.RawMessage `json:"options"`
82+}
83+
84+// register adds the authenticator's credential as the owner holding token.
85+func (p *passkeyHarness) register(token, name string) page {
86+ p.t.Helper()
87+ begin := p.postJSON("/-/passkeys/register/begin", token, map[string]any{"name": name})
88+ require.Equal(p.t, http.StatusOK, begin.status, begin.body)
89+ var br beginReply
90+ require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
91+ opts, err := virtualwebauthn.ParseAttestationOptions(string(br.Options))
92+ require.NoError(p.t, err)
93+ resp := virtualwebauthn.CreateAttestationResponse(passkeyRP, p.auth, p.cred, *opts)
94+ fin := p.postJSON("/-/passkeys/register/finish", token, map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
95+ if fin.status == http.StatusNoContent {
96+ p.auth.AddCredential(p.cred)
97+ p.auth.Options.UserHandle = p.userHandle()
98+ }
99+ return fin
100+}
101+
102+// userHandle reads the owner's WebAuthn user handle, which a real
103+// authenticator got from the creation options.
104+func (p *passkeyHarness) userHandle() []byte {
105+ h, err := p.forge.store.Passkeys().UserHandle(p.ctx)
106+ require.NoError(p.t, err)
107+ return h
108+}
109+
110+// login signs in with the authenticator; it returns the answer of finish.
111+func (p *passkeyHarness) login() page {
112+ p.t.Helper()
113+ begin := p.postJSON("/-/passkeys/login/begin", "", nil)
114+ require.Equal(p.t, http.StatusOK, begin.status, begin.body)
115+ var br beginReply
116+ require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
117+ opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
118+ require.NoError(p.t, err)
119+ resp := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
120+ return p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp)})
121+}
122+
123+// AC1, AC2, AC3: the owner signs in with the CLI link, adds a passkey, signs
124+// out, signs in with the passkey, sees it in the CLI, and removing it ends
125+// every session.
126+func TestPasskeyEndToEnd(t *testing.T) {
127+ p := newPasskeyHarness(t)
128+ p.seed("secret", domain.VisibilityPrivate, "notes.txt")
129+
130+ // The server announces passkeys, and the pages link to them.
131+ ver := p.cli(t, "", "version", "--json").ok(t)
132+ assert.Contains(t, ver.stdout, `"passkeys"`)
133+ assert.Empty(t, ver.stderr, "no feature is missing")
134+ login := p.b.get("/-/login", nil)
135+ require.Equal(t, http.StatusOK, login.status)
136+ assert.Contains(t, login.body, `data-passkey="login"`)
137+
138+ // Anonymous: the list is a 404, registration is refused.
139+ assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
140+ assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", map[string]any{}).status)
141+
142+ // Sign in with the CLI link (the way in before any passkey), add a passkey.
143+ link := p.cli(t, adminToken, "web", "login").ok(t)
144+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
145+ require.Equal(t, http.StatusSeeOther, pg.status, pg.body)
146+ token := pg.cookie(sessionCookie).Value
147+
148+ fin := p.register(token, "Test phone")
149+ require.Equal(t, http.StatusNoContent, fin.status, fin.body)
150+
151+ list := p.b.get("/-/passkeys", as(token))
152+ require.Equal(t, http.StatusOK, list.status)
153+ assert.Contains(t, list.body, "Test phone")
154+ assert.Equal(t, "private, no-store", list.hdr.Get("Cache-Control"))
155+
156+ // The same credential cannot be added twice.
157+ dup := p.register(token, "again")
158+ assert.Equal(t, http.StatusConflict, dup.status, dup.body)
159+
160+ // AC2: an anonymous browser signs in with the passkey: same cookie.
161+ res := p.login()
162+ require.Equal(t, http.StatusNoContent, res.status, res.body)
163+ var ck *http.Cookie
164+ for _, c := range (&http.Response{Header: res.hdr}).Cookies() {
165+ if c.Name == sessionCookie {
166+ ck = c
167+ }
168+ }
169+ require.NotNil(t, ck)
170+ assert.True(t, ck.Secure)
171+ assert.True(t, ck.HttpOnly)
172+ assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
173+ assert.Equal(t, 12*3600, ck.MaxAge)
174+ home := p.b.get("/", as(ck.Value))
175+ assert.Contains(t, home.body, "secret", "the passkey session is the owner's")
176+
177+ // AC3: the CLI lists it, with its use; the id removes it.
178+ out := p.cli(t, adminToken, "web", "passkey", "list", "--json").ok(t)
179+ var listed []struct {
180+ ID string `json:"id"`
181+ Name string `json:"name"`
182+ LastUsedAt *string `json:"last_used_at"`
183+ Synced bool `json:"synced"`
184+ }
185+ require.NoError(t, json.Unmarshal([]byte(out.stdout), &listed))
186+ require.Len(t, listed, 1)
187+ assert.Equal(t, "Test phone", listed[0].Name)
188+ assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
189+ assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
190+
191+ rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t)
192+ assert.Contains(t, rm.stderr, "every browser is signed out")
193+ for _, tok := range []string{token, ck.Value} {
194+ assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")
195+ }
196+ // The removed passkey no longer signs in, and is not there to remove again.
197+ res = p.login()
198+ assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
199+ assert.Empty(t, res.hdr.Values("Set-Cookie"))
200+ again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID)
201+ require.Error(t, again.err)
202+ assert.Contains(t, again.err.Error(), "not found")
203+}
204+
205+// A passkey that signs in is refused when the origin is wrong, and nothing is set.
206+func TestPasskeyLoginRefusedFromAnotherOrigin(t *testing.T) {
207+ p := newPasskeyHarness(t)
208+ link := p.cli(t, adminToken, "web", "login").ok(t)
209+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
210+ token := pg.cookie(sessionCookie).Value
211+ require.Equal(t, http.StatusNoContent, p.register(token, "k").status)
212+
213+ // The Origin header is wrong: the gate refuses before anything else.
214+ req, err := http.NewRequest("POST", p.url+"/-/passkeys/login/begin", strings.NewReader(""))
215+ require.NoError(t, err)
216+ req.Header.Set("Origin", "https://evil.example")
217+ resp, err := http.DefaultClient.Do(req)
218+ require.NoError(t, err)
219+ _ = resp.Body.Close()
220+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
221+
222+ // The authenticator answers for another relying party.
223+ begin := p.postJSON("/-/passkeys/login/begin", "", nil)
224+ var br beginReply
225+ require.NoError(t, json.Unmarshal([]byte(begin.body), &br))
226+ opts, err := virtualwebauthn.ParseAssertionOptions(string(br.Options))
227+ require.NoError(t, err)
228+ evil := virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "x", Origin: "https://evil.example"}
229+ resp2 := virtualwebauthn.CreateAssertionResponse(evil, p.auth, p.cred, *opts)
230+ fin := p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(resp2)})
231+ assert.Equal(t, http.StatusUnauthorized, fin.status, fin.body)
232+ assert.Empty(t, fin.hdr.Values("Set-Cookie"))
233+
234+ // The ceremony is burnt: even the right answer is refused afterwards.
235+ good := virtualwebauthn.CreateAssertionResponse(passkeyRP, p.auth, p.cred, *opts)
236+ fin = p.postJSON("/-/passkeys/login/finish", "", map[string]any{"ceremony": br.Ceremony, "credential": json.RawMessage(good)})
237+ assert.Equal(t, http.StatusUnauthorized, fin.status)
238+}
239+
240+// AC4: on the harness's http public URL (not https), passkeys are off: no
241+// route, no link, no feature, and the RPCs say so.
242+func TestPasskeysDisabledOnHTTPPublicURL(t *testing.T) {
243+ c := newCLIHarness(t) // http://example.test
244+ b := browser{c.harness}
245+ assert.Equal(t, http.StatusNotFound, b.get("/-/login", nil).status)
246+ assert.Equal(t, http.StatusForbidden, b.do("POST", "/-/passkeys/login/begin", map[string]string{"Origin": publicOrigin}, "").status, "no session, no route")
247+ token := c.signIn(t)
248+ assert.Equal(t, http.StatusNotFound, b.get("/-/passkeys", as(token)).status)
249+ home := b.get("/", as(token))
250+ assert.NotContains(t, home.body, "/-/passkeys")
251+ assert.NotContains(t, b.get("/", nil).body, `href="/-/login"`)
252+
253+ res := c.cli(t, adminToken, "web", "passkey", "list")
254+ require.Error(t, res.err)
255+ assert.Contains(t, res.err.Error(), "passkeys are not available on this server")
256+ ver := c.cli(t, "", "version", "--json").ok(t)
257+ assert.NotContains(t, ver.stdout, `"features": [`+"\n"+` "passkeys"`)
258+}
259+
260+// A passkey whose counter does not advance (a hardware key that was cloned) is
261+// refused end to end, with nothing set and the stored counter kept, while a
262+// counter that advances signs in. Without the sign-count check of the use case
263+// the clone would sign in: the WebAuthn adapter accepts it and only reports the
264+// stored counter back.
265+func TestPasskeyClonedAuthenticator(t *testing.T) {
266+ p := newPasskeyHarness(t)
267+ link := p.cli(t, adminToken, "web", "login").ok(t)
268+ pg := p.b.do("POST", passkeyLoginPath(t, link.stdout), map[string]string{"Origin": passkeyOrigin}, "")
269+ token := pg.cookie(sessionCookie).Value
270+
271+ // A hardware key: its counter counts up from 1.
272+ p.cred.Counter = 1
273+ require.Equal(t, http.StatusNoContent, p.register(token, "key").status)
274+
275+ p.cred.Counter = 5
276+ res := p.login()
277+ require.Equal(t, http.StatusNoContent, res.status, res.body)
278+
279+ // The clone presents the counter it last saw: not higher than the stored 5.
280+ for _, counter := range []uint32{5, 3} {
281+ p.cred.Counter = counter
282+ res = p.login()
283+ assert.Equal(t, http.StatusUnauthorized, res.status, "counter %d: %s", counter, res.body)
284+ assert.Empty(t, res.hdr.Values("Set-Cookie"), "no session for a clone")
285+ }
286+
287+ // The refusals recorded nothing: the next higher counter still works.
288+ p.cred.Counter = 6
289+ res = p.login()
290+ assert.Equal(t, http.StatusNoContent, res.status, res.body)
291+
292+ stored, err := p.forge.store.Passkeys().List(p.ctx)
293+ require.NoError(t, err)
294+ require.Len(t, stored, 1)
295+ assert.Equal(t, uint32(6), stored[0].SignCount)
296+
297+ // The warning names the passkey by its short reference, never by its id.
298+ sum := sha256.Sum256(p.cred.ID)
299+ ref := hex.EncodeToString(sum[:])[:8]
300+ var warns []string
301+ for _, line := range strings.Split(p.logs.String(), "\n") {
302+ var m map[string]any
303+ if json.Unmarshal([]byte(line), &m) == nil && m["level"] == "warn" && m["passkey"] == ref {
304+ warns = append(warns, line)
305+ }
306+ }
307+ assert.Len(t, warns, 2, "one warning per refused clone")
308+ assert.NotContains(t, p.logs.String(), base64.RawURLEncoding.EncodeToString(p.cred.ID))
309+}
M internal/app/web_session_test.go +27 -0
@@ -596,3 +596,30 @@ func TestWarnSharedClientKey(t *testing.T) {
596596 })
597597 }
598598 }
599+
600+// AC4: passkeys exist only where WebAuthn can work: an https public URL (or
601+// localhost) with a DNS name as host.
602+func TestPasskeysAvailable(t *testing.T) {
603+ t.Parallel()
604+ for in, want := range map[string]struct {
605+ ok bool
606+ reason string
607+ }{
608+ "https://git.example.com": {true, ""},
609+ "https://git.example.com:8443": {true, ""},
610+ "https://GIT.example.com/": {true, ""},
611+ "http://localhost:7341": {true, ""},
612+ "http://127.0.0.1:7341": {false, "public URL host is an IP address"},
613+ "http://[::1]:7341": {false, "public URL host is an IP address"},
614+ "https://203.0.113.7": {false, "public URL host is an IP address"},
615+ "https://[2001:db8::1]": {false, "public URL host is an IP address"},
616+ "http://git.example.com": {false, "needs https"},
617+ "http://192.168.1.2:7341": {false, "needs https"},
618+ "": {false, "public URL has no host"},
619+ "https://": {false, "public URL has no host"},
620+ } {
621+ ok, reason := passkeysAvailable(in)
622+ assert.Equal(t, want.ok, ok, in)
623+ assert.Equal(t, want.reason, reason, in)
624+ }
625+}
A internal/domain/passkey.go +68 -0
@@ -0,0 +1,68 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "time"
8+)
9+
10+const (
11+ // MaxPasskeys is the most passkeys the owner may register.
12+ MaxPasskeys = 20
13+ // MaxPasskeyIDLen is the longest credential id, in bytes (the WebAuthn
14+ // limit).
15+ MaxPasskeyIDLen = 1023
16+ // MaxPasskeyNameLen is the longest passkey name, in bytes.
17+ MaxPasskeyNameLen = 64
18+ // DefaultPasskeyName names a passkey registered without a name.
19+ DefaultPasskeyName = "passkey"
20+ // PasskeyCeremonyTTL is how long a registration or a sign-in may take, from
21+ // its begin to its finish. The WebAuthn adapter enforces it on the
22+ // challenge and the use case on the ceremony it keeps.
23+ PasskeyCeremonyTTL = 2 * time.Minute
24+)
25+
26+// Passkey is a WebAuthn credential that can sign the owner in. It holds the
27+// public key only.
28+type Passkey struct {
29+ // ID is the credential id: at most MaxPasskeyIDLen bytes, never empty.
30+ ID []byte
31+ Name string
32+ // PublicKey is the COSE public key.
33+ PublicKey []byte
34+ AttestationType string
35+ Transports []string
36+ // SignCount is the last counter the authenticator reported; 0 for
37+ // authenticators that have none (synced passkeys).
38+ SignCount uint32
39+ // BackupEligible and BackupState are the WebAuthn flags: the credential
40+ // can be backed up, and currently is (a synced passkey).
41+ BackupEligible bool
42+ BackupState bool
43+ CreatedAt time.Time
44+ LastUsedAt time.Time // zero until first used
45+}
46+
47+// PasskeyName is a validated passkey name: one line of plain text, 1 to
48+// MaxPasskeyNameLen bytes, not blank. Only ParsePasskeyName builds a non-zero
49+// value.
50+type PasskeyName struct{ s string }
51+
52+// ParsePasskeyName validates s with the rules of the other one-line texts: valid
53+// UTF-8, no control or bidirectional override character, and not blank.
54+func ParsePasskeyName(s string) (PasskeyName, error) {
55+ if len(s) > MaxPasskeyNameLen {
56+ return PasskeyName{}, &TextError{Reason: fmt.Sprintf("is longer than %d bytes (%d)", MaxPasskeyNameLen, len(s))}
57+ }
58+ if reason := textProblem(s, false, MaxPasskeyNameLen); reason != "" {
59+ return PasskeyName{}, &TextError{Reason: reason}
60+ }
61+ if isBlank(s) {
62+ return PasskeyName{}, &TextError{Reason: "is empty"}
63+ }
64+ return PasskeyName{s: s}, nil
65+}
66+
67+// String returns the name.
68+func (n PasskeyName) String() string { return n.s }
A internal/domain/passkey_test.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestParsePasskeyName(t *testing.T) {
16+ t.Parallel()
17+ tests := []struct {
18+ name string
19+ in string
20+ reason string // "" when valid
21+ }{
22+ {"plain", "Pixel 9", ""},
23+ {"unicode", "clé de ma mère 🔑", ""},
24+ {"one byte", "a", ""},
25+ {"max bytes", strings.Repeat("a", domain.MaxPasskeyNameLen), ""},
26+ {"too long", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "longer than 64"},
27+ {"too long in bytes, short in characters", strings.Repeat("é", 33), "longer than 64 bytes"},
28+ {"empty", "", "is empty"},
29+ {"blank", " \u00a0 ", "is empty"},
30+ {"line break", "two\nlines", "one line"},
31+ {"tab", "a\tb", "control character U+0009"},
32+ {"nul", "a\x00b", "control character U+0000"},
33+ {"escape", "\x1b[31mred", "control character U+001B"},
34+ {"bidi override", "evil \u202e txt.exe", "U+202E"},
35+ {"bidi isolate", "a\u2066b", "U+2066"},
36+ {"line separator", "a\u2028b", "U+2028"},
37+ {"invalid utf-8", "a\xffb", "not valid UTF-8"},
38+ }
39+ for _, tc := range tests {
40+ t.Run(tc.name, func(t *testing.T) {
41+ t.Parallel()
42+ got, err := domain.ParsePasskeyName(tc.in)
43+ if tc.reason == "" {
44+ require.NoError(t, err)
45+ assert.Equal(t, tc.in, got.String())
46+ return
47+ }
48+ require.Error(t, err)
49+ assert.ErrorIs(t, err, domain.ErrInvalidText)
50+ assert.True(t, domain.IsInvalid(err))
51+ assert.Contains(t, err.Error(), tc.reason)
52+ })
53+ }
54+}
55+
56+func TestDefaultPasskeyNameIsValid(t *testing.T) {
57+ t.Parallel()
58+ _, err := domain.ParsePasskeyName(domain.DefaultPasskeyName)
59+ require.NoError(t, err)
60+}
A internal/ports/mocks/PasskeyService.go +179 -0
@@ -0,0 +1,179 @@
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+// NewPasskeyService creates a new instance of PasskeyService. 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 NewPasskeyService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *PasskeyService {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &PasskeyService{}
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+// PasskeyService is an autogenerated mock type for the PasskeyService type
38+type PasskeyService struct {
39+ mock.Mock
40+}
41+
42+type PasskeyService_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *PasskeyService) EXPECT() *PasskeyService_Expecter {
47+ return &PasskeyService_Expecter{mock: &_m.Mock}
48+}
49+
50+// List provides a mock function for the type PasskeyService
51+func (_mock *PasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
52+ ret := _mock.Called(ctx, p)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for List")
56+ }
57+
58+ var r0 []domain.Passkey
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok {
61+ return returnFunc(ctx, p)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok {
64+ r0 = returnFunc(ctx, p)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.Passkey)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
71+ r1 = returnFunc(ctx, p)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// PasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
79+type PasskeyService_List_Call struct {
80+ *mock.Call
81+}
82+
83+// List is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+func (_e *PasskeyService_Expecter) List(ctx any, p any) *PasskeyService_List_Call {
87+ return &PasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)}
88+}
89+
90+func (_c *PasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *PasskeyService_List_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ )
104+ })
105+ return _c
106+}
107+
108+func (_c *PasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *PasskeyService_List_Call {
109+ _c.Call.Return(passkeys, err)
110+ return _c
111+}
112+
113+func (_c *PasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *PasskeyService_List_Call {
114+ _c.Call.Return(run)
115+ return _c
116+}
117+
118+// Remove provides a mock function for the type PasskeyService
119+func (_mock *PasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error {
120+ ret := _mock.Called(ctx, p, id)
121+
122+ if len(ret) == 0 {
123+ panic("no return value specified for Remove")
124+ }
125+
126+ var r0 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok {
128+ r0 = returnFunc(ctx, p, id)
129+ } else {
130+ r0 = ret.Error(0)
131+ }
132+ return r0
133+}
134+
135+// PasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
136+type PasskeyService_Remove_Call struct {
137+ *mock.Call
138+}
139+
140+// Remove is a helper method to define mock.On call
141+// - ctx context.Context
142+// - p domain.Principal
143+// - id []byte
144+func (_e *PasskeyService_Expecter) Remove(ctx any, p any, id any) *PasskeyService_Remove_Call {
145+ return &PasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)}
146+}
147+
148+func (_c *PasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *PasskeyService_Remove_Call {
149+ _c.Call.Run(func(args mock.Arguments) {
150+ var arg0 context.Context
151+ if args[0] != nil {
152+ arg0 = args[0].(context.Context)
153+ }
154+ var arg1 domain.Principal
155+ if args[1] != nil {
156+ arg1 = args[1].(domain.Principal)
157+ }
158+ var arg2 []byte
159+ if args[2] != nil {
160+ arg2 = args[2].([]byte)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ )
167+ })
168+ return _c
169+}
170+
171+func (_c *PasskeyService_Remove_Call) Return(err error) *PasskeyService_Remove_Call {
172+ _c.Call.Return(err)
173+ return _c
174+}
175+
176+func (_c *PasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *PasskeyService_Remove_Call {
177+ _c.Call.Return(run)
178+ return _c
179+}
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+}
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+}
A internal/ports/mocks/WebPasskeyService.go +481 -0
@@ -0,0 +1,481 @@
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+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewWebPasskeyService creates a new instance of WebPasskeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewWebPasskeyService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *WebPasskeyService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &WebPasskeyService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// WebPasskeyService is an autogenerated mock type for the WebPasskeyService type
39+type WebPasskeyService struct {
40+ mock.Mock
41+}
42+
43+type WebPasskeyService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter {
48+ return &WebPasskeyService_Expecter{mock: &_m.Mock}
49+}
50+
51+// BeginLogin provides a mock function for the type WebPasskeyService
52+func (_mock *WebPasskeyService) BeginLogin(ctx context.Context, client string) (string, []byte, error) {
53+ ret := _mock.Called(ctx, client)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for BeginLogin")
57+ }
58+
59+ var r0 string
60+ var r1 []byte
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, []byte, error)); ok {
63+ return returnFunc(ctx, client)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
66+ r0 = returnFunc(ctx, client)
67+ } else {
68+ r0 = ret.Get(0).(string)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) []byte); ok {
71+ r1 = returnFunc(ctx, client)
72+ } else {
73+ if ret.Get(1) != nil {
74+ r1 = ret.Get(1).([]byte)
75+ }
76+ }
77+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
78+ r2 = returnFunc(ctx, client)
79+ } else {
80+ r2 = ret.Error(2)
81+ }
82+ return r0, r1, r2
83+}
84+
85+// WebPasskeyService_BeginLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginLogin'
86+type WebPasskeyService_BeginLogin_Call struct {
87+ *mock.Call
88+}
89+
90+// BeginLogin is a helper method to define mock.On call
91+// - ctx context.Context
92+// - client string
93+func (_e *WebPasskeyService_Expecter) BeginLogin(ctx any, client any) *WebPasskeyService_BeginLogin_Call {
94+ return &WebPasskeyService_BeginLogin_Call{Call: _e.mock.On("BeginLogin", ctx, client)}
95+}
96+
97+func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context, client string)) *WebPasskeyService_BeginLogin_Call {
98+ _c.Call.Run(func(args mock.Arguments) {
99+ var arg0 context.Context
100+ if args[0] != nil {
101+ arg0 = args[0].(context.Context)
102+ }
103+ var arg1 string
104+ if args[1] != nil {
105+ arg1 = args[1].(string)
106+ }
107+ run(
108+ arg0,
109+ arg1,
110+ )
111+ })
112+ return _c
113+}
114+
115+func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginLogin_Call {
116+ _c.Call.Return(ceremony, options, err)
117+ return _c
118+}
119+
120+func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context, client string) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {
121+ _c.Call.Return(run)
122+ return _c
123+}
124+
125+// BeginRegistration provides a mock function for the type WebPasskeyService
126+func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
127+ ret := _mock.Called(ctx, p, name)
128+
129+ if len(ret) == 0 {
130+ panic("no return value specified for BeginRegistration")
131+ }
132+
133+ var r0 string
134+ var r1 []byte
135+ var r2 error
136+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) (string, []byte, error)); ok {
137+ return returnFunc(ctx, p, name)
138+ }
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) string); ok {
140+ r0 = returnFunc(ctx, p, name)
141+ } else {
142+ r0 = ret.Get(0).(string)
143+ }
144+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string) []byte); ok {
145+ r1 = returnFunc(ctx, p, name)
146+ } else {
147+ if ret.Get(1) != nil {
148+ r1 = ret.Get(1).([]byte)
149+ }
150+ }
151+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string) error); ok {
152+ r2 = returnFunc(ctx, p, name)
153+ } else {
154+ r2 = ret.Error(2)
155+ }
156+ return r0, r1, r2
157+}
158+
159+// WebPasskeyService_BeginRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginRegistration'
160+type WebPasskeyService_BeginRegistration_Call struct {
161+ *mock.Call
162+}
163+
164+// BeginRegistration is a helper method to define mock.On call
165+// - ctx context.Context
166+// - p domain.Principal
167+// - name string
168+func (_e *WebPasskeyService_Expecter) BeginRegistration(ctx any, p any, name any) *WebPasskeyService_BeginRegistration_Call {
169+ return &WebPasskeyService_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, p, name)}
170+}
171+
172+func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, name string)) *WebPasskeyService_BeginRegistration_Call {
173+ _c.Call.Run(func(args mock.Arguments) {
174+ var arg0 context.Context
175+ if args[0] != nil {
176+ arg0 = args[0].(context.Context)
177+ }
178+ var arg1 domain.Principal
179+ if args[1] != nil {
180+ arg1 = args[1].(domain.Principal)
181+ }
182+ var arg2 string
183+ if args[2] != nil {
184+ arg2 = args[2].(string)
185+ }
186+ run(
187+ arg0,
188+ arg1,
189+ arg2,
190+ )
191+ })
192+ return _c
193+}
194+
195+func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, options []byte, err error) *WebPasskeyService_BeginRegistration_Call {
196+ _c.Call.Return(ceremony, options, err)
197+ return _c
198+}
199+
200+func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name string) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {
201+ _c.Call.Return(run)
202+ return _c
203+}
204+
205+// FinishLogin provides a mock function for the type WebPasskeyService
206+func (_mock *WebPasskeyService) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
207+ ret := _mock.Called(ctx, ceremony, response)
208+
209+ if len(ret) == 0 {
210+ panic("no return value specified for FinishLogin")
211+ }
212+
213+ var r0 string
214+ var r1 time.Duration
215+ var r2 error
216+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) (string, time.Duration, error)); ok {
217+ return returnFunc(ctx, ceremony, response)
218+ }
219+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []byte) string); ok {
220+ r0 = returnFunc(ctx, ceremony, response)
221+ } else {
222+ r0 = ret.Get(0).(string)
223+ }
224+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []byte) time.Duration); ok {
225+ r1 = returnFunc(ctx, ceremony, response)
226+ } else {
227+ r1 = ret.Get(1).(time.Duration)
228+ }
229+ if returnFunc, ok := ret.Get(2).(func(context.Context, string, []byte) error); ok {
230+ r2 = returnFunc(ctx, ceremony, response)
231+ } else {
232+ r2 = ret.Error(2)
233+ }
234+ return r0, r1, r2
235+}
236+
237+// WebPasskeyService_FinishLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishLogin'
238+type WebPasskeyService_FinishLogin_Call struct {
239+ *mock.Call
240+}
241+
242+// FinishLogin is a helper method to define mock.On call
243+// - ctx context.Context
244+// - ceremony string
245+// - response []byte
246+func (_e *WebPasskeyService_Expecter) FinishLogin(ctx any, ceremony any, response any) *WebPasskeyService_FinishLogin_Call {
247+ return &WebPasskeyService_FinishLogin_Call{Call: _e.mock.On("FinishLogin", ctx, ceremony, response)}
248+}
249+
250+func (_c *WebPasskeyService_FinishLogin_Call) Run(run func(ctx context.Context, ceremony string, response []byte)) *WebPasskeyService_FinishLogin_Call {
251+ _c.Call.Run(func(args mock.Arguments) {
252+ var arg0 context.Context
253+ if args[0] != nil {
254+ arg0 = args[0].(context.Context)
255+ }
256+ var arg1 string
257+ if args[1] != nil {
258+ arg1 = args[1].(string)
259+ }
260+ var arg2 []byte
261+ if args[2] != nil {
262+ arg2 = args[2].([]byte)
263+ }
264+ run(
265+ arg0,
266+ arg1,
267+ arg2,
268+ )
269+ })
270+ return _c
271+}
272+
273+func (_c *WebPasskeyService_FinishLogin_Call) Return(token string, lifetime time.Duration, err error) *WebPasskeyService_FinishLogin_Call {
274+ _c.Call.Return(token, lifetime, err)
275+ return _c
276+}
277+
278+func (_c *WebPasskeyService_FinishLogin_Call) RunAndReturn(run func(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error)) *WebPasskeyService_FinishLogin_Call {
279+ _c.Call.Return(run)
280+ return _c
281+}
282+
283+// FinishRegistration provides a mock function for the type WebPasskeyService
284+func (_mock *WebPasskeyService) FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, response []byte) error {
285+ ret := _mock.Called(ctx, p, ceremony, response)
286+
287+ if len(ret) == 0 {
288+ panic("no return value specified for FinishRegistration")
289+ }
290+
291+ var r0 error
292+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, []byte) error); ok {
293+ r0 = returnFunc(ctx, p, ceremony, response)
294+ } else {
295+ r0 = ret.Error(0)
296+ }
297+ return r0
298+}
299+
300+// WebPasskeyService_FinishRegistration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishRegistration'
301+type WebPasskeyService_FinishRegistration_Call struct {
302+ *mock.Call
303+}
304+
305+// FinishRegistration is a helper method to define mock.On call
306+// - ctx context.Context
307+// - p domain.Principal
308+// - ceremony string
309+// - response []byte
310+func (_e *WebPasskeyService_Expecter) FinishRegistration(ctx any, p any, ceremony any, response any) *WebPasskeyService_FinishRegistration_Call {
311+ return &WebPasskeyService_FinishRegistration_Call{Call: _e.mock.On("FinishRegistration", ctx, p, ceremony, response)}
312+}
313+
314+func (_c *WebPasskeyService_FinishRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, ceremony string, response []byte)) *WebPasskeyService_FinishRegistration_Call {
315+ _c.Call.Run(func(args mock.Arguments) {
316+ var arg0 context.Context
317+ if args[0] != nil {
318+ arg0 = args[0].(context.Context)
319+ }
320+ var arg1 domain.Principal
321+ if args[1] != nil {
322+ arg1 = args[1].(domain.Principal)
323+ }
324+ var arg2 string
325+ if args[2] != nil {
326+ arg2 = args[2].(string)
327+ }
328+ var arg3 []byte
329+ if args[3] != nil {
330+ arg3 = args[3].([]byte)
331+ }
332+ run(
333+ arg0,
334+ arg1,
335+ arg2,
336+ arg3,
337+ )
338+ })
339+ return _c
340+}
341+
342+func (_c *WebPasskeyService_FinishRegistration_Call) Return(err error) *WebPasskeyService_FinishRegistration_Call {
343+ _c.Call.Return(err)
344+ return _c
345+}
346+
347+func (_c *WebPasskeyService_FinishRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, ceremony string, response []byte) error) *WebPasskeyService_FinishRegistration_Call {
348+ _c.Call.Return(run)
349+ return _c
350+}
351+
352+// List provides a mock function for the type WebPasskeyService
353+func (_mock *WebPasskeyService) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
354+ ret := _mock.Called(ctx, p)
355+
356+ if len(ret) == 0 {
357+ panic("no return value specified for List")
358+ }
359+
360+ var r0 []domain.Passkey
361+ var r1 error
362+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.Passkey, error)); ok {
363+ return returnFunc(ctx, p)
364+ }
365+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.Passkey); ok {
366+ r0 = returnFunc(ctx, p)
367+ } else {
368+ if ret.Get(0) != nil {
369+ r0 = ret.Get(0).([]domain.Passkey)
370+ }
371+ }
372+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
373+ r1 = returnFunc(ctx, p)
374+ } else {
375+ r1 = ret.Error(1)
376+ }
377+ return r0, r1
378+}
379+
380+// WebPasskeyService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
381+type WebPasskeyService_List_Call struct {
382+ *mock.Call
383+}
384+
385+// List is a helper method to define mock.On call
386+// - ctx context.Context
387+// - p domain.Principal
388+func (_e *WebPasskeyService_Expecter) List(ctx any, p any) *WebPasskeyService_List_Call {
389+ return &WebPasskeyService_List_Call{Call: _e.mock.On("List", ctx, p)}
390+}
391+
392+func (_c *WebPasskeyService_List_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_List_Call {
393+ _c.Call.Run(func(args mock.Arguments) {
394+ var arg0 context.Context
395+ if args[0] != nil {
396+ arg0 = args[0].(context.Context)
397+ }
398+ var arg1 domain.Principal
399+ if args[1] != nil {
400+ arg1 = args[1].(domain.Principal)
401+ }
402+ run(
403+ arg0,
404+ arg1,
405+ )
406+ })
407+ return _c
408+}
409+
410+func (_c *WebPasskeyService_List_Call) Return(passkeys []domain.Passkey, err error) *WebPasskeyService_List_Call {
411+ _c.Call.Return(passkeys, err)
412+ return _c
413+}
414+
415+func (_c *WebPasskeyService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)) *WebPasskeyService_List_Call {
416+ _c.Call.Return(run)
417+ return _c
418+}
419+
420+// Remove provides a mock function for the type WebPasskeyService
421+func (_mock *WebPasskeyService) Remove(ctx context.Context, p domain.Principal, id []byte) error {
422+ ret := _mock.Called(ctx, p, id)
423+
424+ if len(ret) == 0 {
425+ panic("no return value specified for Remove")
426+ }
427+
428+ var r0 error
429+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, []byte) error); ok {
430+ r0 = returnFunc(ctx, p, id)
431+ } else {
432+ r0 = ret.Error(0)
433+ }
434+ return r0
435+}
436+
437+// WebPasskeyService_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
438+type WebPasskeyService_Remove_Call struct {
439+ *mock.Call
440+}
441+
442+// Remove is a helper method to define mock.On call
443+// - ctx context.Context
444+// - p domain.Principal
445+// - id []byte
446+func (_e *WebPasskeyService_Expecter) Remove(ctx any, p any, id any) *WebPasskeyService_Remove_Call {
447+ return &WebPasskeyService_Remove_Call{Call: _e.mock.On("Remove", ctx, p, id)}
448+}
449+
450+func (_c *WebPasskeyService_Remove_Call) Run(run func(ctx context.Context, p domain.Principal, id []byte)) *WebPasskeyService_Remove_Call {
451+ _c.Call.Run(func(args mock.Arguments) {
452+ var arg0 context.Context
453+ if args[0] != nil {
454+ arg0 = args[0].(context.Context)
455+ }
456+ var arg1 domain.Principal
457+ if args[1] != nil {
458+ arg1 = args[1].(domain.Principal)
459+ }
460+ var arg2 []byte
461+ if args[2] != nil {
462+ arg2 = args[2].([]byte)
463+ }
464+ run(
465+ arg0,
466+ arg1,
467+ arg2,
468+ )
469+ })
470+ return _c
471+}
472+
473+func (_c *WebPasskeyService_Remove_Call) Return(err error) *WebPasskeyService_Remove_Call {
474+ _c.Call.Return(err)
475+ return _c
476+}
477+
478+func (_c *WebPasskeyService_Remove_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id []byte) error) *WebPasskeyService_Remove_Call {
479+ _c.Call.Return(run)
480+ return _c
481+}
M internal/ports/ports.go +47 -0
@@ -320,6 +320,53 @@ type SessionStore interface {
320320 BumpEpoch(ctx context.Context) (uint64, error)
321321 }
322322
323+// PasskeyStore keeps the owner's passkeys (ADR 0026) and the WebAuthn user
324+// handle they belong to.
325+type PasskeyStore interface {
326+ // UserHandle returns the 32-byte WebAuthn user handle of the owner. The
327+ // first call on an empty store generates and stores it; it never changes.
328+ UserHandle(ctx context.Context) ([]byte, error)
329+ // List returns every passkey, oldest first.
330+ List(ctx context.Context) ([]domain.Passkey, error)
331+ // Get returns the passkey with that credential id, or domain.ErrNotFound.
332+ Get(ctx context.Context, id []byte) (domain.Passkey, error)
333+ // Create stores the passkey. It returns domain.ErrAlreadyExists if the
334+ // credential id is taken. It does not bound the number of passkeys: the
335+ // use case checks domain.MaxPasskeys.
336+ Create(ctx context.Context, p domain.Passkey) error
337+ // Update replaces the stored passkey (sign count, backup state, last
338+ // use), or returns domain.ErrNotFound.
339+ Update(ctx context.Context, p domain.Passkey) error
340+ // Delete removes the passkey, or returns domain.ErrNotFound.
341+ Delete(ctx context.Context, id []byte) error
342+}
343+
344+// PasskeyVerifier runs the WebAuthn ceremonies (ADR 0026). It hides the
345+// WebAuthn library: the ceremony data it hands out is opaque to the caller,
346+// which keeps it between the begin and the finish of a ceremony and passes it
347+// back untouched. Every verification failure is domain.ErrUnauthenticated,
348+// wrapped with a reason meant for logs, not for callers.
349+type PasskeyVerifier interface {
350+ // BeginRegistration starts the registration of a passkey for the owner.
351+ // options is the JSON of the credential creation options for the browser
352+ // ({"publicKey": {...}}); existing passkeys are excluded.
353+ BeginRegistration(userHandle []byte, existing []domain.Passkey) (options []byte, ceremony []byte, err error)
354+ // FinishRegistration checks the browser's response (the JSON of
355+ // PublicKeyCredential.toJSON()) and returns the new passkey, without a name
356+ // or creation time.
357+ FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error)
358+ // BeginLogin starts a discoverable login. options is the JSON of the
359+ // credential request options for the browser ({"publicKey": {...}}).
360+ BeginLogin() (options []byte, ceremony []byte, err error)
361+ // FinishLogin checks the browser's response and returns the stored passkey
362+ // updated with the sign count and backup state the response reports. lookup
363+ // returns domain.ErrNotFound for an unknown credential id. A counter that
364+ // does not advance is not an error here: the returned SignCount is then the
365+ // stored one, and the caller, which holds the lock that orders the logins,
366+ // refuses it.
367+ FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)
368+}
369+
323370 // EventBus fans events out to in-process subscribers.
324371 type EventBus interface {
325372 // Publish hands the event to every subscriber without blocking. A
M internal/ports/services.go +35 -0
@@ -117,6 +117,41 @@ type WebSessionService interface {
117117 Verify(ctx context.Context, token string) (owner domain.Principal, ok bool, err error)
118118 }
119119
120+// PasskeyService is the owner's passkey use cases the API serves.
121+// *usecase.Passkeys implements it. Registering a passkey and signing in with
122+// one are browser ceremonies and are not part of it.
123+type PasskeyService interface {
124+ // List returns the passkeys, oldest first.
125+ List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
126+ // Remove deletes the passkey with that credential id, or returns
127+ // domain.ErrNotFound, and signs every browser out.
128+ Remove(ctx context.Context, p domain.Principal, id []byte) error
129+}
130+
131+// WebPasskeyService is the passkey side of the use cases, as the web needs
132+// it: the API's List and Remove, and the browser ceremonies. The composition
133+// root implements it over *usecase.Passkeys. A ceremony is the opaque
134+// identifier BeginLogin and BeginRegistration return; options is the JSON the
135+// browser passes to the WebAuthn API.
136+type WebPasskeyService interface {
137+ PasskeyService
138+ // BeginLogin starts a sign-in. Anonymous callers may. client is the client
139+ // key of the request (clientlimit.Key of the address the failure limiter
140+ // uses); it bounds how many sign-ins a client keeps waiting.
141+ BeginLogin(ctx context.Context, client string) (ceremony string, options []byte, err error)
142+ // FinishLogin checks the browser's response and returns the session token
143+ // and how long it lasts, or ErrLinkRejected for any refusal.
144+ FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)
145+ // BeginRegistration starts adding a passkey. Only the owner may. name is
146+ // the name the passkey will get (empty means the default): a name that
147+ // breaks the rules is refused here, before any ceremony starts, and is the
148+ // one FinishRegistration stores.
149+ BeginRegistration(ctx context.Context, p domain.Principal, name string) (ceremony string, options []byte, err error)
150+ // FinishRegistration checks the browser's response and stores the passkey
151+ // under the name given to BeginRegistration.
152+ FinishRegistration(ctx context.Context, p domain.Principal, ceremony string, response []byte) error
153+}
154+
120155 // PushService opens a repository for a push: it is domain.ErrUnauthenticated
121156 // for an anonymous caller, domain.ErrNotFound for a repository the caller may
122157 // not write and cannot see (or that is missing and may not be created),
A internal/usecase/passkeys.go +411 -0
@@ -0,0 +1,411 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "crypto/sha256"
9+ "encoding/base64"
10+ "encoding/hex"
11+ "errors"
12+ "fmt"
13+ "sync"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Owner passkeys (ADR 0026). A ceremony is a registration or a login that the
23+// browser carries out between two requests; its data lives in memory only.
24+const (
25+ // MaxPendingPasskeyRegistrations bounds the registrations that wait to be
26+ // finished. Only the owner starts one.
27+ MaxPendingPasskeyRegistrations = 16
28+ // MaxPendingPasskeyLogins bounds the logins that wait to be finished.
29+ // Anyone may start one, so a login is never refused: on overflow one is
30+ // dropped instead, and anonymous callers cannot lock the owner out.
31+ MaxPendingPasskeyLogins = 256
32+ // MaxPendingPasskeyLoginsPerClient bounds the logins one client (one client
33+ // key) may have waiting. A client over it loses its own oldest.
34+ MaxPendingPasskeyLoginsPerClient = 8
35+
36+ // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.
37+ ceremonyIDBytes = 32
38+)
39+
40+type ceremonyKind uint8
41+
42+const (
43+ ceremonyRegister ceremonyKind = iota + 1
44+ ceremonyLogin
45+)
46+
47+// ceremony is a begun registration or login.
48+type ceremony struct {
49+ kind ceremonyKind
50+ data []byte // opaque, from the verifier
51+ expires time.Time
52+ seq uint64 // creation order
53+ client string // who began a login (its client key); "" for a registration
54+ name domain.PasskeyName // what a registration will call the passkey
55+}
56+
57+// Passkeys are the use cases of the owner's passkeys: registering, signing in,
58+// listing and removing them.
59+type Passkeys struct {
60+ store ports.PasskeyStore
61+ verifier ports.PasskeyVerifier
62+ sessions *Sessions
63+ clock ports.Clock
64+
65+ // writeMu serializes the writes to the store (Create, Update, Delete) and
66+ // the session they make or void, so that a removed passkey cannot sign in.
67+ writeMu sync.Mutex
68+
69+ mu sync.Mutex
70+ ceremonies map[[sha256.Size]byte]ceremony // by hash of the identifier
71+ seq uint64
72+}
73+
74+var _ ports.PasskeyService = (*Passkeys)(nil)
75+
76+// NewPasskeys builds the passkey use cases. Sessions are issued and voided
77+// through sessions.
78+func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessions *Sessions, clock ports.Clock) *Passkeys {
79+ return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}
80+}
81+
82+// pendingLogins is what begin knows of the logins that wait, per client.
83+type pendingLogins struct {
84+ count int
85+ oldest [sha256.Size]byte
86+ oldestSeq uint64 // 0 until a login is added: sequence numbers start at 1
87+}
88+
89+func (p *pendingLogins) add(h [sha256.Size]byte, seq uint64) {
90+ p.count++
91+ if p.oldestSeq == 0 || seq < p.oldestSeq {
92+ p.oldest, p.oldestSeq = h, seq
93+ }
94+}
95+
96+// begin keeps a ceremony and returns its identifier. It sweeps the expired
97+// ones first. A registration over the limit gives domain.ErrResourceExhausted.
98+// A login is never refused, but it can cost another login its place: over the
99+// per-client cap it drops that client's own oldest; over the global cap it drops
100+// the oldest of the client with the most logins waiting. A client that floods
101+// therefore only ever loses its own ceremonies, never another client's.
102+func (s *Passkeys) begin(c ceremony) (string, error) {
103+ var raw [ceremonyIDBytes]byte
104+ if _, err := rand.Read(raw[:]); err != nil {
105+ return "", fmt.Errorf("generate passkey ceremony: %w", err)
106+ }
107+ id := base64.RawURLEncoding.EncodeToString(raw[:])
108+ now := s.clock.Now()
109+
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ registrations := 0
113+ logins := 0
114+ byClient := map[string]*pendingLogins{}
115+ for h, c := range s.ceremonies {
116+ if !now.Before(c.expires) {
117+ delete(s.ceremonies, h)
118+ continue
119+ }
120+ switch c.kind {
121+ case ceremonyRegister:
122+ registrations++
123+ case ceremonyLogin:
124+ logins++
125+ pl := byClient[c.client]
126+ if pl == nil {
127+ pl = &pendingLogins{}
128+ byClient[c.client] = pl
129+ }
130+ pl.add(h, c.seq)
131+ }
132+ }
133+ switch c.kind {
134+ case ceremonyRegister:
135+ if registrations >= MaxPendingPasskeyRegistrations {
136+ return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)
137+ }
138+ case ceremonyLogin:
139+ if pl := byClient[c.client]; pl != nil && pl.count >= MaxPendingPasskeyLoginsPerClient {
140+ delete(s.ceremonies, pl.oldest)
141+ logins--
142+ // The client is back under its cap; the global step below needs
143+ // its new count and its new oldest.
144+ pl.count--
145+ s.refreshOldest(pl, c.client)
146+ }
147+ if logins >= MaxPendingPasskeyLogins {
148+ // The client with the most waiting loses its oldest; among equals,
149+ // the one whose oldest waited longest.
150+ var victim *pendingLogins
151+ for _, pl := range byClient {
152+ if pl.count == 0 {
153+ continue
154+ }
155+ if victim == nil || pl.count > victim.count || (pl.count == victim.count && pl.oldestSeq < victim.oldestSeq) {
156+ victim = pl
157+ }
158+ }
159+ if victim != nil {
160+ delete(s.ceremonies, victim.oldest)
161+ }
162+ }
163+ }
164+ s.seq++
165+ c.expires, c.seq = now.Add(domain.PasskeyCeremonyTTL), s.seq
166+ s.ceremonies[sha256.Sum256([]byte(id))] = c
167+ return id, nil
168+}
169+
170+// refreshOldest recomputes the oldest login of a client after one was dropped.
171+// The caller holds s.mu.
172+func (s *Passkeys) refreshOldest(pl *pendingLogins, client string) {
173+ pl.oldestSeq = 0
174+ for h, c := range s.ceremonies {
175+ if c.kind != ceremonyLogin || c.client != client {
176+ continue
177+ }
178+ if pl.oldestSeq == 0 || c.seq < pl.oldestSeq {
179+ pl.oldest, pl.oldestSeq = h, c.seq
180+ }
181+ }
182+}
183+
184+// take removes the ceremony and returns it. It is single use, also when the
185+// ceremony expired or is of another kind. ok is false for an unknown, expired or
186+// mismatched ceremony.
187+func (s *Passkeys) take(id string, kind ceremonyKind) (c ceremony, ok bool) {
188+ h := sha256.Sum256([]byte(id))
189+ s.mu.Lock()
190+ c, found := s.ceremonies[h]
191+ delete(s.ceremonies, h)
192+ s.mu.Unlock()
193+ if !found || c.kind != kind || !s.clock.Now().Before(c.expires) {
194+ return ceremony{}, false
195+ }
196+ return c, true
197+}
198+
199+// BeginRegistration starts adding a passkey. Only the owner may. name is
200+// checked now (an empty name means the default), so that a name the owner would
201+// be prompted for and then see refused costs no ceremony; the ceremony keeps the
202+// checked name and FinishRegistration stores it. It gives domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too
203+// many registrations wait. It returns the ceremony identifier and the options
204+// for the browser.
205+func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
206+ if err := requireOwner(p); err != nil {
207+ return "", nil, err
208+ }
209+ parsed, err := parsePasskeyName(name)
210+ if err != nil {
211+ return "", nil, err
212+ }
213+ existing, err := s.store.List(ctx)
214+ if err != nil {
215+ return "", nil, fmt.Errorf("list passkeys: %w", err)
216+ }
217+ if len(existing) >= domain.MaxPasskeys {
218+ return "", nil, fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
219+ }
220+ handle, err := s.store.UserHandle(ctx)
221+ if err != nil {
222+ return "", nil, fmt.Errorf("load passkey user handle: %w", err)
223+ }
224+ options, data, err := s.verifier.BeginRegistration(handle, existing)
225+ if err != nil {
226+ return "", nil, err
227+ }
228+ id, err := s.begin(ceremony{kind: ceremonyRegister, data: data, name: parsed})
229+ if err != nil {
230+ return "", nil, err
231+ }
232+ return id, options, nil
233+}
234+
235+// FinishRegistration checks the browser's response and stores the passkey under
236+// the name BeginRegistration checked. A ceremony that is unknown, expired, used
237+// or of the wrong kind, and a response the verifier refuses, give
238+// domain.ErrUnauthenticated; a credential already registered gives
239+// domain.ErrAlreadyExists; too many passkeys, domain.ErrResourceExhausted.
240+func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, ceremonyID string, response []byte) error {
241+ if err := requireOwner(p); err != nil {
242+ return err
243+ }
244+ log := zerowrap.FromCtx(ctx)
245+ c, ok := s.take(ceremonyID, ceremonyRegister)
246+ if !ok {
247+ err := fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
248+ log.Info().Err(err).Msg("passkey registration refused")
249+ return err
250+ }
251+ handle, err := s.store.UserHandle(ctx)
252+ if err != nil {
253+ return fmt.Errorf("load passkey user handle: %w", err)
254+ }
255+ pk, err := s.verifier.FinishRegistration(handle, c.data, response)
256+ if err != nil {
257+ if errors.Is(err, domain.ErrUnauthenticated) {
258+ // The reason is for the log (it holds no key); the caller gets a 400.
259+ log.Info().Err(err).Msg("passkey registration refused")
260+ }
261+ return err
262+ }
263+ if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {
264+ return fmt.Errorf("%w: credential id has %d bytes", domain.ErrUnauthenticated, n)
265+ }
266+ pk.Name = c.name.String()
267+ pk.CreatedAt = s.clock.Now().UTC()
268+ pk.LastUsedAt = time.Time{}
269+
270+ s.writeMu.Lock()
271+ defer s.writeMu.Unlock()
272+ existing, err := s.store.List(ctx)
273+ if err != nil {
274+ return fmt.Errorf("list passkeys: %w", err)
275+ }
276+ if len(existing) >= domain.MaxPasskeys {
277+ return fmt.Errorf("%w: %d passkeys are registered, remove one first", domain.ErrResourceExhausted, domain.MaxPasskeys)
278+ }
279+ if err := s.store.Create(ctx, pk); err != nil {
280+ return err
281+ }
282+ log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")
283+ return nil
284+}
285+
286+// BeginLogin starts a passkey sign-in. Anonymous callers may. client is the
287+// client key of the caller (clientlimit.Key of the address the failure limiter
288+// uses); it bounds how many sign-ins one client keeps waiting. It returns the
289+// ceremony identifier and the options for the browser.
290+func (s *Passkeys) BeginLogin(_ context.Context, client string) (string, []byte, error) {
291+ options, data, err := s.verifier.BeginLogin()
292+ if err != nil {
293+ return "", nil, err
294+ }
295+ id, err := s.begin(ceremony{kind: ceremonyLogin, data: data, client: client})
296+ if err != nil {
297+ return "", nil, err
298+ }
299+ return id, options, nil
300+}
301+
302+// FinishLogin checks the browser's response and opens the owner's session,
303+// like a login link does. Whatever the reason of a refusal (unknown or
304+// expired ceremony, unknown passkey, bad signature, missing user verification,
305+// cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the
306+// store is returned as it is, so that the caller fails closed.
307+func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {
308+ log := zerowrap.FromCtx(ctx)
309+ c, ok := s.take(ceremonyID, ceremonyLogin)
310+ if !ok {
311+ log.Info().Msg("passkey sign-in refused: the ceremony expired or is unknown")
312+ return "", time.Time{}, domain.ErrUnauthenticated
313+ }
314+ handle, err := s.store.UserHandle(ctx)
315+ if err != nil {
316+ return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)
317+ }
318+ pk, err := s.verifier.FinishLogin(handle, c.data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) })
319+ if err != nil {
320+ if errors.Is(err, domain.ErrUnauthenticated) {
321+ // The reason is for the log (it holds no key); the caller learns nothing.
322+ log.Info().Err(err).Msg("passkey sign-in refused")
323+ return "", time.Time{}, domain.ErrUnauthenticated
324+ }
325+ return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)
326+ }
327+
328+ // Recording the use and opening the session are one step with respect to
329+ // Remove and to a concurrent login, so that a passkey removed meanwhile
330+ // cannot sign in, nor two logins with the same counter.
331+ s.writeMu.Lock()
332+ defer s.writeMu.Unlock()
333+ cur, err := s.store.Get(ctx, pk.ID)
334+ if err != nil {
335+ if errors.Is(err, domain.ErrNotFound) {
336+ log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the passkey was removed meanwhile")
337+ return "", time.Time{}, domain.ErrUnauthenticated
338+ }
339+ return "", time.Time{}, fmt.Errorf("read passkey: %w", err)
340+ }
341+ // The one sign-count check. The verifier returns the counter the response
342+ // reports (the stored one when it does not advance), and another login may
343+ // have moved the stored one since it looked. A counter that does not advance
344+ // is the clone signal; synced passkeys report 0 and are exempt.
345+ if cur.SignCount != 0 && pk.SignCount <= cur.SignCount {
346+ log.Warn().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
347+ return "", time.Time{}, domain.ErrUnauthenticated
348+ }
349+ pk.LastUsedAt = s.clock.Now().UTC()
350+ if err := s.store.Update(ctx, pk); err != nil {
351+ if errors.Is(err, domain.ErrNotFound) {
352+ return "", time.Time{}, domain.ErrUnauthenticated
353+ }
354+ return "", time.Time{}, fmt.Errorf("record passkey use: %w", err)
355+ }
356+ token, expiresAt, err = s.sessions.issueOwner(ctx)
357+ if err != nil {
358+ return "", time.Time{}, err
359+ }
360+ log.Info().Str("passkey", passkeyRef(pk.ID)).Time("expires_at", expiresAt).Msg("owner signed in with a passkey")
361+ return token, expiresAt, nil
362+}
363+
364+// List returns the passkeys, oldest first. Only the owner may.
365+func (s *Passkeys) List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) {
366+ if err := requireOwner(p); err != nil {
367+ return nil, err
368+ }
369+ return s.store.List(ctx)
370+}
371+
372+// Remove deletes a passkey and signs every browser out, like revoking a token
373+// with owner rights does. Only the owner may. An unknown id gives
374+// domain.ErrNotFound.
375+func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) error {
376+ if err := requireOwner(p); err != nil {
377+ return err
378+ }
379+ s.writeMu.Lock()
380+ defer s.writeMu.Unlock()
381+ if err := s.store.Delete(ctx, id); err != nil {
382+ return err
383+ }
384+ log := zerowrap.FromCtx(ctx)
385+ log.Info().Str("passkey", passkeyRef(id)).Str("removed_by", p.Label()).Str("removed_by_token", p.TokenID()).Msg("passkey removed")
386+ epoch, err := s.sessions.bumpEpoch(ctx)
387+ if err != nil {
388+ return fmt.Errorf("passkey %s removed, but signing out the web sessions failed: %w", passkeyRef(id), err)
389+ }
390+ log.Info().Str("passkey", passkeyRef(id)).Uint64("epoch", epoch).Msg("every web session signed out: a passkey was removed")
391+ return nil
392+}
393+
394+// parsePasskeyName validates a name; an empty one is domain.DefaultPasskeyName.
395+func parsePasskeyName(name string) (domain.PasskeyName, error) {
396+ if name == "" {
397+ name = domain.DefaultPasskeyName
398+ }
399+ parsed, err := domain.ParsePasskeyName(name)
400+ if err != nil {
401+ return domain.PasskeyName{}, fmt.Errorf("passkey name: %w", err)
402+ }
403+ return parsed, nil
404+}
405+
406+// passkeyRef is how logs name a passkey: the first 8 hex characters of the
407+// SHA-256 of its credential id.
408+func passkeyRef(id []byte) string {
409+ sum := sha256.Sum256(id)
410+ return hex.EncodeToString(sum[:])[:8]
411+}
A internal/usecase/passkeys_test.go +1064 -0
@@ -0,0 +1,1064 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/sha256"
9+ "encoding/hex"
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "slices"
14+ "strings"
15+ "sync"
16+ "sync/atomic"
17+ "testing"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+ "github.com/stretchr/testify/assert"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports/mocks"
27+ "bnema.dev/ermite/internal/usecase"
28+)
29+
30+var testHandle = []byte("0123456789abcdef0123456789abcdef")
31+
32+type passkeyFixture struct {
33+ *sessionFixture
34+ store *mocks.PasskeyStore
35+ verifier *mocks.PasskeyVerifier
36+ passkeys *usecase.Passkeys
37+
38+ mu sync.Mutex
39+ stored map[string]domain.Passkey // by credential id
40+ ceremon atomic.Int64 // makes the ceremony data of each begin unique
41+}
42+
43+func newPasskeyFixture(t *testing.T) *passkeyFixture {
44+ t.Helper()
45+ f := &passkeyFixture{
46+ sessionFixture: newSessionFixture(t),
47+ store: mocks.NewPasskeyStore(t),
48+ verifier: mocks.NewPasskeyVerifier(t),
49+ stored: map[string]domain.Passkey{},
50+ }
51+ f.store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil).Maybe()
52+ f.store.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Passkey, error) {
53+ f.mu.Lock()
54+ defer f.mu.Unlock()
55+ var out []domain.Passkey
56+ for _, p := range f.stored {
57+ out = append(out, p)
58+ }
59+ slices.SortFunc(out, func(a, b domain.Passkey) int { return bytes.Compare(a.ID, b.ID) })
60+ return out, nil
61+ }).Maybe()
62+ f.store.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) (domain.Passkey, error) {
63+ f.mu.Lock()
64+ defer f.mu.Unlock()
65+ if p, ok := f.stored[string(id)]; ok {
66+ return p, nil
67+ }
68+ return domain.Passkey{}, domain.ErrNotFound
69+ }).Maybe()
70+ f.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
71+ f.mu.Lock()
72+ defer f.mu.Unlock()
73+ if _, ok := f.stored[string(p.ID)]; ok {
74+ return domain.ErrAlreadyExists
75+ }
76+ f.stored[string(p.ID)] = p
77+ return nil
78+ }).Maybe()
79+ f.store.EXPECT().Update(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Passkey) error {
80+ f.mu.Lock()
81+ defer f.mu.Unlock()
82+ if _, ok := f.stored[string(p.ID)]; !ok {
83+ return domain.ErrNotFound
84+ }
85+ f.stored[string(p.ID)] = p
86+ return nil
87+ }).Maybe()
88+ f.store.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, id []byte) error {
89+ f.mu.Lock()
90+ defer f.mu.Unlock()
91+ if _, ok := f.stored[string(id)]; !ok {
92+ return domain.ErrNotFound
93+ }
94+ delete(f.stored, string(id))
95+ return nil
96+ }).Maybe()
97+
98+ f.verifier.EXPECT().BeginRegistration(mock.Anything, mock.Anything).RunAndReturn(func([]byte, []domain.Passkey) ([]byte, []byte, error) {
99+ return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("register-%d", f.ceremon.Add(1))), nil
100+ }).Maybe()
101+ f.verifier.EXPECT().BeginLogin().RunAndReturn(func() ([]byte, []byte, error) {
102+ return []byte(`{"publicKey":{}}`), []byte(fmt.Sprintf("login-%d", f.ceremon.Add(1))), nil
103+ }).Maybe()
104+
105+ f.passkeys = usecase.NewPasskeys(f.store, f.verifier, f.sessions, f.clock)
106+ return f
107+}
108+
109+// add puts a passkey in the store directly.
110+func (f *passkeyFixture) add(id string) domain.Passkey {
111+ p := domain.Passkey{ID: []byte(id), Name: id, PublicKey: []byte("k"), CreatedAt: *f.at.Load()}
112+ f.mu.Lock()
113+ f.stored[id] = p
114+ f.mu.Unlock()
115+ return p
116+}
117+
118+func (f *passkeyFixture) count() int {
119+ f.mu.Lock()
120+ defer f.mu.Unlock()
121+ return len(f.stored)
122+}
123+
124+// acceptRegistration makes the verifier accept any registration as the passkey.
125+func (f *passkeyFixture) acceptRegistration(id string) {
126+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(func(_, ceremony, _ []byte) (domain.Passkey, error) {
127+ if !bytes.HasPrefix(ceremony, []byte("register-")) {
128+ return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
129+ }
130+ return domain.Passkey{ID: []byte(id), PublicKey: []byte("pub"), AttestationType: "none", Transports: []string{"internal"}, BackupEligible: true}, nil
131+ }).Maybe()
132+}
133+
134+// acceptLogin makes the verifier accept a login with the stored passkey `id`.
135+// first is the counter of the first sign-in; like a real authenticator, each
136+// later one reports a higher counter. 0 means a synced passkey: always 0.
137+func (f *passkeyFixture) acceptLogin(id string, first uint32) {
138+ var next atomic.Uint32
139+ next.Store(first)
140+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
141+ func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
142+ if !bytes.HasPrefix(ceremony, []byte("login-")) {
143+ return domain.Passkey{}, fmt.Errorf("%w: wrong ceremony %q", domain.ErrUnauthenticated, ceremony)
144+ }
145+ p, err := lookup([]byte(id))
146+ if err != nil {
147+ return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)
148+ }
149+ if first != 0 {
150+ p.SignCount = next.Add(1) - 1
151+ }
152+ p.BackupState = true
153+ return p, nil
154+ }).Maybe()
155+}
156+
157+func ownerAndOthers() map[string]struct {
158+ p domain.Principal
159+ err error
160+} {
161+ partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
162+ return map[string]struct {
163+ p domain.Principal
164+ err error
165+ }{
166+ "root": {domain.Root(), nil},
167+ "owner session": {domain.Owner(), nil},
168+ "every scope": {allScopeToken(), nil},
169+ "scoped token": {partial, domain.ErrForbidden},
170+ "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated},
171+ }
172+}
173+
174+func TestPasskeysAreOwnerOnly(t *testing.T) {
175+ t.Parallel()
176+ ctx := context.Background()
177+ for name, tc := range ownerAndOthers() {
178+ t.Run(name, func(t *testing.T) {
179+ t.Parallel()
180+ f := newPasskeyFixture(t)
181+ f.add("a")
182+ f.acceptRegistration("new")
183+ check := func(what string, err error) {
184+ t.Helper()
185+ if tc.err == nil {
186+ assert.NoError(t, err, what)
187+ } else {
188+ assert.ErrorIs(t, err, tc.err, what)
189+ }
190+ }
191+ _, _, err := f.passkeys.BeginRegistration(ctx, tc.p, "")
192+ check("begin registration", err)
193+ _, err = f.passkeys.List(ctx, tc.p)
194+ check("list", err)
195+
196+ // A ceremony begun by the owner cannot be finished by anyone else.
197+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
198+ require.NoError(t, err)
199+ err = f.passkeys.FinishRegistration(ctx, tc.p, id, []byte("{}"))
200+ check("finish registration", err)
201+ if tc.err != nil {
202+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte("{}"))
203+ assert.NoError(t, err, "a refused caller does not burn the ceremony")
204+ }
205+
206+ err = f.passkeys.Remove(ctx, tc.p, []byte("a"))
207+ check("remove", err)
208+ if tc.err != nil {
209+ assert.Equal(t, 2, f.count(), "nothing removed")
210+ }
211+ })
212+ }
213+}
214+
215+func TestBeginLoginIsAnonymous(t *testing.T) {
216+ t.Parallel()
217+ f := newPasskeyFixture(t)
218+ id, options, err := f.passkeys.BeginLogin(context.Background(), "192.0.2.1")
219+ require.NoError(t, err)
220+ assert.Len(t, id, 43, "256 bits")
221+ assert.JSONEq(t, `{"publicKey":{}}`, string(options))
222+}
223+
224+func TestRegistration(t *testing.T) {
225+ t.Parallel()
226+ ctx := context.Background()
227+ f := newPasskeyFixture(t)
228+ f.acceptRegistration("cred-1")
229+
230+ id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "Pixel 9")
231+ require.NoError(t, err)
232+ assert.JSONEq(t, `{"publicKey":{}}`, string(options))
233+
234+ require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`)))
235+ list, err := f.passkeys.List(ctx, domain.Owner())
236+ require.NoError(t, err)
237+ require.Len(t, list, 1)
238+ got := list[0]
239+ assert.Equal(t, "Pixel 9", got.Name, "the name given at begin")
240+ assert.Equal(t, *f.at.Load(), got.CreatedAt)
241+ assert.True(t, got.LastUsedAt.IsZero())
242+ assert.Equal(t, []byte("cred-1"), got.ID)
243+
244+ // The ceremony is single use.
245+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))
246+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
247+}
248+
249+// An empty name is the default one, and each ceremony keeps the name it began
250+// with, whatever else is pending.
251+func TestRegistrationNames(t *testing.T) {
252+ t.Parallel()
253+ ctx := context.Background()
254+ f := newPasskeyFixture(t)
255+ // The credential id is the ceremony data, so that two ceremonies finish as
256+ // two different passkeys.
257+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).RunAndReturn(
258+ func(_, ceremony, _ []byte) (domain.Passkey, error) {
259+ return domain.Passkey{ID: bytes.Clone(ceremony), PublicKey: []byte("pub")}, nil
260+ })
261+ unnamed, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
262+ require.NoError(t, err)
263+ named, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "Second")
264+ require.NoError(t, err)
265+ // Finished in the other order.
266+ require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), named, []byte(`{}`)))
267+ require.NoError(t, f.passkeys.FinishRegistration(ctx, domain.Owner(), unnamed, []byte(`{}`)))
268+
269+ names := map[string]string{}
270+ list, err := f.passkeys.List(ctx, domain.Owner())
271+ require.NoError(t, err)
272+ for _, p := range list {
273+ names[string(p.ID)] = p.Name
274+ }
275+ assert.ElementsMatch(t, []string{"Second", domain.DefaultPasskeyName}, []string{names["register-2"], names["register-1"]})
276+ assert.Equal(t, "Second", names["register-2"])
277+ assert.Equal(t, domain.DefaultPasskeyName, names["register-1"])
278+}
279+
280+// What FinishRegistration refuses, and what is left in the store each time.
281+func TestRegistrationRefusals(t *testing.T) {
282+ t.Parallel()
283+ tests := []struct {
284+ name string
285+ existing []string // passkeys already stored
286+ verifier func(f *passkeyFixture)
287+ wantErr error
288+ wantKept int // passkeys in the store afterwards
289+ }{
290+ {
291+ name: "duplicate credential",
292+ existing: []string{"cred-1"},
293+ verifier: func(f *passkeyFixture) { f.acceptRegistration("cred-1") },
294+ wantErr: domain.ErrAlreadyExists, wantKept: 1,
295+ },
296+ {
297+ name: "refused by the verifier",
298+ verifier: func(f *passkeyFixture) {
299+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
300+ Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))
301+ },
302+ wantErr: domain.ErrUnauthenticated, wantKept: 0,
303+ },
304+ {
305+ name: "credential id too long",
306+ verifier: func(f *passkeyFixture) {
307+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
308+ Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)
309+ },
310+ wantErr: domain.ErrUnauthenticated, wantKept: 0,
311+ },
312+ {
313+ name: "empty credential id",
314+ verifier: func(f *passkeyFixture) {
315+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
316+ Return(domain.Passkey{PublicKey: []byte("k")}, nil)
317+ },
318+ wantErr: domain.ErrUnauthenticated, wantKept: 0,
319+ },
320+ }
321+ for _, tc := range tests {
322+ t.Run(tc.name, func(t *testing.T) {
323+ t.Parallel()
324+ ctx := context.Background()
325+ f := newPasskeyFixture(t)
326+ for _, id := range tc.existing {
327+ f.add(id)
328+ }
329+ tc.verifier(f)
330+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "x")
331+ require.NoError(t, err)
332+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))
333+ assert.ErrorIs(t, err, tc.wantErr)
334+ assert.Equal(t, tc.wantKept, f.count())
335+ })
336+ }
337+}
338+
339+// The verifier is told about the passkeys to exclude.
340+func TestBeginRegistrationExcludesExisting(t *testing.T) {
341+ t.Parallel()
342+ f := newPasskeyFixture(t)
343+ f.add("a")
344+ f.add("b")
345+ f.verifier.ExpectedCalls = nil // replace the permissive default
346+ f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {
347+ return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"
348+ })).Return([]byte(`{}`), []byte("register-x"), nil).Once()
349+ _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner(), "")
350+ require.NoError(t, err)
351+}
352+
353+func TestMaxPasskeys(t *testing.T) {
354+ t.Parallel()
355+ ctx := context.Background()
356+ f := newPasskeyFixture(t)
357+ f.acceptRegistration("one-more")
358+ for i := range domain.MaxPasskeys - 1 {
359+ f.add(fmt.Sprintf("p%02d", i))
360+ }
361+ // A ceremony begun with room for one more.
362+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
363+ require.NoError(t, err)
364+ f.add("last")
365+ assert.Equal(t, domain.MaxPasskeys, f.count())
366+
367+ // Full: no new ceremony, and the one in flight is refused at the end.
368+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
369+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
370+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{}`))
371+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
372+ assert.Equal(t, domain.MaxPasskeys, f.count())
373+
374+ // Removing one makes room again.
375+ require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))
376+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
377+ assert.NoError(t, err)
378+}
379+
380+func TestCeremonyExpiry(t *testing.T) {
381+ t.Parallel()
382+ ctx := context.Background()
383+ f := newPasskeyFixture(t)
384+ f.acceptRegistration("cred-1")
385+ f.add("login-cred")
386+ f.acceptLogin("login-cred", 1)
387+
388+ // One second short of the TTL still works.
389+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
390+ require.NoError(t, err)
391+ login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
392+ require.NoError(t, err)
393+ f.advance(domain.PasskeyCeremonyTTL - time.Second)
394+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, []byte(`{}`))
395+ assert.NoError(t, err)
396+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
397+ assert.NoError(t, err)
398+
399+ // At the TTL it is gone, and it stays gone.
400+ reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
401+ require.NoError(t, err)
402+ login, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
403+ require.NoError(t, err)
404+ f.advance(domain.PasskeyCeremonyTTL)
405+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, []byte(`{}`))
406+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
407+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
408+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
409+}
410+
411+// Expired ceremonies are swept on begin, so they stop counting.
412+func TestExpiredRegistrationsDoNotCount(t *testing.T) {
413+ t.Parallel()
414+ ctx := context.Background()
415+ f := newPasskeyFixture(t)
416+ for range usecase.MaxPendingPasskeyRegistrations {
417+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
418+ require.NoError(t, err)
419+ }
420+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
421+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
422+ f.advance(domain.PasskeyCeremonyTTL)
423+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
424+ assert.NoError(t, err)
425+}
426+
427+func TestPendingRegistrationsAreBounded(t *testing.T) {
428+ t.Parallel()
429+ ctx := context.Background()
430+ f := newPasskeyFixture(t)
431+ f.acceptRegistration("cred-1")
432+ var ids []string
433+ for range usecase.MaxPendingPasskeyRegistrations {
434+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
435+ require.NoError(t, err)
436+ ids = append(ids, id)
437+ }
438+ assert.Len(t, ids, 16)
439+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
440+ require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")
441+
442+ // Pending logins do not use the registrations' room, and a finished
443+ // registration frees one.
444+ _, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
445+ require.NoError(t, err)
446+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], []byte(`{}`))
447+ require.NoError(t, err)
448+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
449+ assert.NoError(t, err)
450+}
451+
452+// loginIDs begins n logins for the client, a millisecond apart so that the order
453+// of creation is the order of time too.
454+func (f *passkeyFixture) loginIDs(t *testing.T, client string, n int) []string {
455+ t.Helper()
456+ var ids []string
457+ for range n {
458+ id, _, err := f.passkeys.BeginLogin(context.Background(), client)
459+ require.NoError(t, err, "a login is never refused")
460+ ids = append(ids, id)
461+ f.advance(time.Millisecond)
462+ }
463+ return ids
464+}
465+
466+// finishes tells whether the ceremony still opens a session.
467+func (f *passkeyFixture) finishes(id string) bool {
468+ _, _, err := f.passkeys.FinishLogin(context.Background(), id, []byte(`{}`))
469+ return err == nil
470+}
471+
472+// A client keeps at most 8 logins waiting: the 9th costs it its own oldest.
473+func TestPendingLoginsPerClientCap(t *testing.T) {
474+ t.Parallel()
475+ f := newPasskeyFixture(t)
476+ f.add("cred-1")
477+ f.acceptLogin("cred-1", 1)
478+
479+ other := f.loginIDs(t, "198.51.100.7", 1)[0]
480+ ids := f.loginIDs(t, "192.0.2.1", usecase.MaxPendingPasskeyLoginsPerClient)
481+ assert.Equal(t, 8, usecase.MaxPendingPasskeyLoginsPerClient)
482+ newest := f.loginIDs(t, "192.0.2.1", 1)[0]
483+
484+ assert.False(t, f.finishes(ids[0]), "the 9th evicted this client's oldest")
485+ assert.True(t, f.finishes(ids[1]), "the next oldest is still there")
486+ assert.True(t, f.finishes(newest), "the newest works")
487+ assert.True(t, f.finishes(other), "another client's ceremony is untouched")
488+}
489+
490+// One address flooding can never evict another address's ceremony.
491+func TestLoginFloodDoesNotEvictAnotherClient(t *testing.T) {
492+ t.Parallel()
493+ f := newPasskeyFixture(t)
494+ f.add("cred-1")
495+ f.acceptLogin("cred-1", 1)
496+
497+ owner := f.loginIDs(t, "198.51.100.7", 1)[0]
498+ f.loginIDs(t, "192.0.2.1", 2*usecase.MaxPendingPasskeyLogins)
499+ assert.True(t, f.finishes(owner), "the owner's ceremony from another address still works")
500+}
501+
502+// Over the global cap, the client with the most waiting loses its oldest; a
503+// client with one ceremony is never the victim.
504+func TestPendingLoginsGlobalOverflow(t *testing.T) {
505+ t.Parallel()
506+ f := newPasskeyFixture(t)
507+ f.add("cred-1")
508+ f.acceptLogin("cred-1", 1)
509+
510+ owner := f.loginIDs(t, "198.51.100.7", 1)[0]
511+ perClient := usecase.MaxPendingPasskeyLoginsPerClient
512+ var first, second []string
513+ n := 0
514+ for total := 1; total+perClient <= usecase.MaxPendingPasskeyLogins; total += perClient {
515+ ids := f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)
516+ if n == 0 {
517+ first = ids
518+ }
519+ if n == 1 {
520+ second = ids
521+ }
522+ n++
523+ }
524+ // 1 + 31*8 = 249 waiting; top up to the cap with a client below the cap.
525+ small := f.loginIDs(t, "10.1.0.1", usecase.MaxPendingPasskeyLogins-1-n*perClient)
526+ require.Len(t, small, 7)
527+
528+ // The 257th comes from a client that has none: nobody is refused, the
529+ // oldest of a client with 8 waiting goes (the one that waited longest).
530+ fresh := f.loginIDs(t, "203.0.113.9", 1)[0]
531+ assert.False(t, f.finishes(first[0]), "the oldest of the fullest, oldest client was evicted")
532+ assert.True(t, f.finishes(first[1]))
533+ assert.True(t, f.finishes(second[0]), "other full clients keep theirs")
534+ assert.True(t, f.finishes(owner), "a client with one ceremony is never the victim")
535+ assert.True(t, f.finishes(small[0]), "nor one below the others")
536+ assert.True(t, f.finishes(fresh), "the new login works")
537+}
538+
539+// Logins never push out a registration, and registrations never push out a login.
540+func TestCeremonyKindsAreSeparate(t *testing.T) {
541+ t.Parallel()
542+ ctx := context.Background()
543+ f := newPasskeyFixture(t)
544+ f.add("cred-1")
545+ f.acceptLogin("cred-1", 1)
546+ f.acceptRegistration("cred-2")
547+
548+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
549+ require.NoError(t, err)
550+ login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
551+ require.NoError(t, err)
552+
553+ // A registration ceremony does not finish a login, nor the reverse; the
554+ // attempt burns it.
555+ _, _, err = f.passkeys.FinishLogin(ctx, reg, []byte(`{}`))
556+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
557+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), login, []byte(`{}`))
558+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
559+ _, _, err = f.passkeys.FinishLogin(ctx, login, []byte(`{}`))
560+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "burned by the mismatch")
561+ assert.Equal(t, 1, f.count(), "no passkey was added")
562+}
563+
564+func TestUnknownCeremony(t *testing.T) {
565+ t.Parallel()
566+ ctx := context.Background()
567+ f := newPasskeyFixture(t)
568+ _, _, err := f.passkeys.FinishLogin(ctx, "", []byte(`{}`))
569+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
570+ _, _, err = f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`))
571+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
572+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "nope", []byte(`{}`))
573+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
574+}
575+
576+// A passkey login gives a session that Verify accepts, and records the use.
577+func TestLoginIssuesASession(t *testing.T) {
578+ t.Parallel()
579+ ctx := context.Background()
580+ f := newPasskeyFixture(t)
581+ f.add("cred-1")
582+ f.acceptLogin("cred-1", 9)
583+
584+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
585+ require.NoError(t, err)
586+ f.advance(10 * time.Second)
587+ token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
588+ require.NoError(t, err)
589+ assert.Equal(t, f.at.Load().Add(usecase.SessionTTL).Truncate(time.Second), exp)
590+
591+ p, err := f.sessions.Verify(ctx, token)
592+ require.NoError(t, err)
593+ assert.Equal(t, "owner", p.Label())
594+ assert.True(t, p.HasOwnerRights())
595+
596+ list, err := f.passkeys.List(ctx, domain.Owner())
597+ require.NoError(t, err)
598+ require.Len(t, list, 1)
599+ assert.Equal(t, uint32(9), list[0].SignCount)
600+ assert.True(t, list[0].BackupState)
601+ assert.Equal(t, *f.at.Load(), list[0].LastUsedAt)
602+
603+ // The ceremony is single use.
604+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
605+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
606+}
607+
608+// logCtx is a context whose logger writes JSON lines into the buffer.
609+func logCtx(t *testing.T) (context.Context, *syncLog) {
610+ t.Helper()
611+ buf := &syncLog{}
612+ log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: buf})
613+ return zerowrap.WithCtx(context.Background(), log), buf
614+}
615+
616+type syncLog struct {
617+ mu sync.Mutex
618+ b bytes.Buffer
619+}
620+
621+func (l *syncLog) Write(p []byte) (int, error) {
622+ l.mu.Lock()
623+ defer l.mu.Unlock()
624+ return l.b.Write(p)
625+}
626+
627+func (l *syncLog) String() string {
628+ l.mu.Lock()
629+ defer l.mu.Unlock()
630+ return l.b.String()
631+}
632+
633+// logEntries parses the JSON lines of the buffer.
634+func logEntries(t *testing.T, logs *syncLog) []map[string]any {
635+ t.Helper()
636+ var out []map[string]any
637+ for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
638+ if line == "" {
639+ continue
640+ }
641+ var m map[string]any
642+ require.NoError(t, json.Unmarshal([]byte(line), &m), line)
643+ out = append(out, m)
644+ }
645+ return out
646+}
647+
648+// The reason of a refusal is logged at info, never answered, and holds no
649+// secret: neither the response bytes nor a key.
650+func TestRefusalsAreLogged(t *testing.T) {
651+ t.Parallel()
652+
653+ t.Run("sign-in", func(t *testing.T) {
654+ t.Parallel()
655+ ctx, logs := logCtx(t)
656+ f := newPasskeyFixture(t)
657+ f.add("cred-1")
658+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
659+ Return(domain.Passkey{}, fmt.Errorf("%w: passkey login: origin mismatch", domain.ErrUnauthenticated))
660+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
661+ require.NoError(t, err)
662+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{"secret":"response-bytes"}`))
663+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
664+ assert.NotContains(t, err.Error(), "origin mismatch", "the caller learns nothing")
665+ entries := logEntries(t, logs)
666+ require.Len(t, entries, 1)
667+ assert.Equal(t, "info", entries[0]["level"])
668+ assert.Contains(t, entries[0]["error"], "origin mismatch")
669+ assert.NotContains(t, logs.String(), "response-bytes")
670+ })
671+
672+ t.Run("sign-in with an unknown ceremony", func(t *testing.T) {
673+ t.Parallel()
674+ ctx, logs := logCtx(t)
675+ f := newPasskeyFixture(t)
676+ _, _, err := f.passkeys.FinishLogin(ctx, "nope", []byte(`{"secret":"response-bytes"}`))
677+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
678+ entries := logEntries(t, logs)
679+ require.Len(t, entries, 1)
680+ assert.Equal(t, "info", entries[0]["level"])
681+ assert.NotContains(t, logs.String(), "response-bytes")
682+ })
683+
684+ t.Run("registration", func(t *testing.T) {
685+ t.Parallel()
686+ ctx, logs := logCtx(t)
687+ f := newPasskeyFixture(t)
688+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
689+ Return(domain.Passkey{}, fmt.Errorf("%w: passkey registration: user verification missing", domain.ErrUnauthenticated))
690+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "x")
691+ require.NoError(t, err)
692+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, []byte(`{"secret":"response-bytes"}`))
693+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
694+ entries := logEntries(t, logs)
695+ require.Len(t, entries, 1)
696+ assert.Equal(t, "info", entries[0]["level"])
697+ assert.Contains(t, entries[0]["error"], "user verification missing")
698+ assert.NotContains(t, logs.String(), "response-bytes")
699+
700+ // An unknown ceremony is logged too.
701+ err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "unknown", []byte(`{}`))
702+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
703+ entries = logEntries(t, logs)
704+ require.Len(t, entries, 2)
705+ assert.Equal(t, "info", entries[1]["level"])
706+ })
707+}
708+
709+// A counter that does not advance on a passkey that has one is refused, and the
710+// warning names the passkey by its short reference. The library hands back the
711+// stored counter when the received one is not higher, which is what the verifier
712+// returns here.
713+func TestCloneIsRefusedAndNamed(t *testing.T) {
714+ t.Parallel()
715+ ctx, logs := logCtx(t)
716+ f := newPasskeyFixture(t)
717+ p := f.add("cred-1")
718+ p.SignCount = 5
719+ f.mu.Lock()
720+ f.stored["cred-1"] = p
721+ f.mu.Unlock()
722+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
723+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
724+ return lookup([]byte("cred-1")) // SignCount stays 5
725+ })
726+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
727+ require.NoError(t, err)
728+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
729+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
730+ assert.Empty(t, token, "no session")
731+ assert.Equal(t, 1, f.count())
732+ f.mu.Lock()
733+ assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded")
734+ f.mu.Unlock()
735+
736+ sum := sha256.Sum256([]byte("cred-1"))
737+ ref := hex.EncodeToString(sum[:])[:8]
738+ entries := logEntries(t, logs)
739+ require.Len(t, entries, 1, "one log line for the refusal")
740+ assert.Equal(t, "warn", entries[0]["level"])
741+ assert.Equal(t, ref, entries[0]["passkey"])
742+ assert.NotContains(t, logs.String(), "cred-1", "the credential id itself is never logged")
743+}
744+
745+// Two logins verified against the same stored counter cannot both
746+// succeed. The verifier saw counter 5; by the time the second one writes, the
747+// first has stored 6.
748+func TestLoginRefusesACounterThatDidNotAdvance(t *testing.T) {
749+ t.Parallel()
750+ ctx := context.Background()
751+
752+ t.Run("another login moved the counter meanwhile", func(t *testing.T) {
753+ t.Parallel()
754+ f := newPasskeyFixture(t)
755+ stored := f.add("cred-1")
756+ stored.SignCount = 5
757+ f.mu.Lock()
758+ f.stored["cred-1"] = stored
759+ f.mu.Unlock()
760+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
761+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
762+ p, err := lookup([]byte("cred-1"))
763+ require.NoError(t, err)
764+ p.SignCount = 6 // what the response reports, against the 5 it was shown
765+ // A concurrent login stores 6 before this one gets the lock.
766+ f.mu.Lock()
767+ f.stored["cred-1"] = p
768+ f.mu.Unlock()
769+ return p, nil
770+ })
771+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
772+ require.NoError(t, err)
773+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
774+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
775+ assert.Empty(t, token, "no session")
776+ f.mu.Lock()
777+ assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded")
778+ f.mu.Unlock()
779+ })
780+
781+ t.Run("a counter that advances is accepted", func(t *testing.T) {
782+ t.Parallel()
783+ f := newPasskeyFixture(t)
784+ p := f.add("cred-1")
785+ p.SignCount = 5
786+ f.mu.Lock()
787+ f.stored["cred-1"] = p
788+ f.mu.Unlock()
789+ f.acceptLogin("cred-1", 6)
790+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
791+ require.NoError(t, err)
792+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
793+ assert.NoError(t, err)
794+ })
795+
796+ t.Run("a synced passkey reports 0 and is exempt", func(t *testing.T) {
797+ t.Parallel()
798+ f := newPasskeyFixture(t)
799+ f.add("cred-1") // stored counter 0
800+ f.acceptLogin("cred-1", 0)
801+ for range 2 {
802+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
803+ require.NoError(t, err)
804+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
805+ assert.NoError(t, err)
806+ }
807+ })
808+
809+}
810+
811+func TestLoginRefusals(t *testing.T) {
812+ t.Parallel()
813+ ctx := context.Background()
814+
815+ t.Run("unknown passkey", func(t *testing.T) {
816+ t.Parallel()
817+ f := newPasskeyFixture(t)
818+ f.acceptLogin("ghost", 1)
819+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
820+ require.NoError(t, err)
821+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
822+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
823+ })
824+
825+ t.Run("verifier refuses", func(t *testing.T) {
826+ t.Parallel()
827+ f := newPasskeyFixture(t)
828+ f.add("cred-1")
829+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
830+ Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))
831+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
832+ require.NoError(t, err)
833+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
834+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
835+ assert.NotContains(t, err.Error(), "bad signature", "the reason is for logs, not for the caller")
836+ })
837+
838+ t.Run("store failure fails closed and is not an authentication error", func(t *testing.T) {
839+ t.Parallel()
840+ f := newPasskeyFixture(t)
841+ boom := errors.New("disk on fire")
842+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
843+ Return(domain.Passkey{}, boom)
844+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
845+ require.NoError(t, err)
846+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
847+ require.ErrorIs(t, err, boom)
848+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
849+ assert.Empty(t, token)
850+ })
851+
852+ t.Run("update failure gives no session", func(t *testing.T) {
853+ t.Parallel()
854+ f := newPasskeyFixture(t)
855+ store := mocks.NewPasskeyStore(t)
856+ boom := errors.New("disk on fire")
857+ store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)
858+ store.EXPECT().Get(mock.Anything, []byte("cred-1")).Return(domain.Passkey{ID: []byte("cred-1")}, nil)
859+ store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)
860+ verifier := mocks.NewPasskeyVerifier(t)
861+ verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)
862+ verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
863+ Return(domain.Passkey{ID: []byte("cred-1")}, nil)
864+ pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)
865+ id, _, err := pk.BeginLogin(ctx, "192.0.2.1")
866+ require.NoError(t, err)
867+ token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
868+ require.ErrorIs(t, err, boom)
869+ assert.Empty(t, token)
870+ })
871+
872+ t.Run("removed while verifying", func(t *testing.T) {
873+ t.Parallel()
874+ f := newPasskeyFixture(t)
875+ f.add("cred-1")
876+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
877+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
878+ p, err := lookup([]byte("cred-1"))
879+ require.NoError(t, err)
880+ f.mu.Lock()
881+ delete(f.stored, "cred-1") // an owner removes it meanwhile
882+ f.mu.Unlock()
883+ return p, nil
884+ })
885+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
886+ require.NoError(t, err)
887+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
888+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
889+ assert.Empty(t, token)
890+ })
891+}
892+
893+// Removing a passkey signs every browser out, the passkey's own sessions
894+// included.
895+func TestRemoveBumpsTheEpoch(t *testing.T) {
896+ t.Parallel()
897+ ctx := context.Background()
898+ f := newPasskeyFixture(t)
899+ f.add("cred-1")
900+ f.add("cred-2")
901+ f.acceptLogin("cred-1", 1)
902+
903+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
904+ require.NoError(t, err)
905+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
906+ require.NoError(t, err)
907+ _, err = f.sessions.Verify(ctx, token)
908+ require.NoError(t, err)
909+ linkToken := func() string {
910+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
911+ require.NoError(t, err)
912+ return tok
913+ }()
914+
915+ require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("cred-2")))
916+ assert.Equal(t, 1, f.count())
917+ for name, tok := range map[string]string{"passkey session": token, "link session": linkToken} {
918+ _, err = f.sessions.Verify(ctx, tok)
919+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
920+ }
921+
922+ // A new login works with the epoch it finds now.
923+ id, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
924+ require.NoError(t, err)
925+ token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
926+ require.NoError(t, err)
927+ _, err = f.sessions.Verify(ctx, token)
928+ assert.NoError(t, err)
929+}
930+
931+func TestRemoveUnknownKeepsTheSessions(t *testing.T) {
932+ t.Parallel()
933+ ctx := context.Background()
934+ f := newPasskeyFixture(t)
935+ f.add("cred-1")
936+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
937+ require.NoError(t, err)
938+
939+ err = f.passkeys.Remove(ctx, domain.Owner(), []byte("ghost"))
940+ require.ErrorIs(t, err, domain.ErrNotFound)
941+ _, err = f.sessions.Verify(ctx, tok)
942+ assert.NoError(t, err, "nothing was removed, nobody is signed out")
943+}
944+
945+func TestRemoveReportsASignOutFailure(t *testing.T) {
946+ t.Parallel()
947+ ctx := context.Background()
948+ boom := errors.New("disk on fire")
949+ sessionStore := mocks.NewSessionStore(t)
950+ sessionStore.EXPECT().Key(mock.Anything).Return(testHandle, nil)
951+ sessionStore.EXPECT().BumpEpoch(mock.Anything).Return(0, boom)
952+ clock := mocks.NewClock(t)
953+ clock.EXPECT().Now().Return(time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)).Maybe()
954+ sessions, err := usecase.NewSessions(ctx, sessionStore, clock, "https://forge.example")
955+ require.NoError(t, err)
956+
957+ store := mocks.NewPasskeyStore(t)
958+ store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(nil)
959+ pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), sessions, clock)
960+ err = pk.Remove(ctx, domain.Owner(), []byte("cred-1"))
961+ require.ErrorIs(t, err, boom)
962+ assert.Contains(t, err.Error(), "signing out the web sessions failed")
963+}
964+
965+// The use cases are safe for concurrent callers (run with -race).
966+func TestPasskeysConcurrent(t *testing.T) {
967+ t.Parallel()
968+ ctx := context.Background()
969+ f := newPasskeyFixture(t)
970+ f.add("cred-1")
971+ f.acceptLogin("cred-1", 0) // synced: counters racing in order would be refused, rightly
972+ var wg sync.WaitGroup
973+ for range 32 {
974+ wg.Add(1)
975+ go func() {
976+ defer wg.Done()
977+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
978+ if assert.NoError(t, err) {
979+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
980+ assert.NoError(t, err)
981+ }
982+ _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
983+ }()
984+ }
985+ wg.Wait()
986+}
987+
988+// TS-001: a name that breaks the rules is refused before any ceremony starts,
989+// and does not use up one of the pending registrations.
990+func TestBeginRegistrationChecksTheName(t *testing.T) {
991+ t.Parallel()
992+ ctx := context.Background()
993+ f := newPasskeyFixture(t)
994+ for _, name := range []string{"evil \u202e name", "a\x00b", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "two\nlines", "\xff"} {
995+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), name)
996+ assert.ErrorIs(t, err, domain.ErrInvalidText, "%q", name)
997+ }
998+ // None of those counted as a pending registration.
999+ for range usecase.MaxPendingPasskeyRegistrations {
1000+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "ok")
1001+ require.NoError(t, err)
1002+ }
1003+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
1004+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1005+}
1006+
1007+// A client at its cap begins a login while the store is full. Its own eviction
1008+// makes the room the new login needs, so exactly one ceremony is dropped, the
1009+// client's own oldest: the global rule must not take a second one, from anyone.
1010+func TestFullClientOnAFullStoreLosesOnlyItsOwnOldest(t *testing.T) {
1011+ t.Parallel()
1012+ f := newPasskeyFixture(t)
1013+ f.add("cred-1")
1014+ f.acceptLogin("cred-1", 1)
1015+ perClient := usecase.MaxPendingPasskeyLoginsPerClient
1016+
1017+ // Seven clients with one login each, the flooder with a full cap, then full
1018+ // clients and a last partial one until the store holds exactly its cap.
1019+ small := make([]string, 0, 7)
1020+ for i := range 7 {
1021+ small = append(small, f.loginIDs(t, fmt.Sprintf("10.9.0.%d", i), 1)[0])
1022+ }
1023+ flooder := f.loginIDs(t, "192.0.2.1", perClient)
1024+ total := len(small) + len(flooder)
1025+ var others []string
1026+ for n := 0; total+perClient <= usecase.MaxPendingPasskeyLogins; n++ {
1027+ others = append(others, f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)...)
1028+ total += perClient
1029+ }
1030+ if rest := usecase.MaxPendingPasskeyLogins - total; rest > 0 {
1031+ others = append(others, f.loginIDs(t, "10.8.0.1", rest)...)
1032+ total += rest
1033+ }
1034+ require.Equal(t, usecase.MaxPendingPasskeyLogins, total, "the store is full")
1035+
1036+ newest := f.loginIDs(t, "192.0.2.1", 1)[0]
1037+
1038+ assert.False(t, f.finishes(flooder[0]), "the flooder lost its own oldest")
1039+ for _, id := range append(append(flooder[1:], small...), others...) {
1040+ assert.True(t, f.finishes(id), "nobody else lost a ceremony")
1041+ }
1042+ assert.True(t, f.finishes(newest), "the new login works")
1043+}
1044+
1045+// A failure of the store while removing a passkey is returned as it is, and
1046+// nobody is signed out for it.
1047+func TestRemoveReturnsAStoreError(t *testing.T) {
1048+ t.Parallel()
1049+ ctx := context.Background()
1050+ f := newPasskeyFixture(t)
1051+ boom := errors.New("disk on fire")
1052+ store := mocks.NewPasskeyStore(t)
1053+ store.EXPECT().Delete(mock.Anything, []byte("cred-1")).Return(boom)
1054+ // The session store is a strict mock: a BumpEpoch would fail the test.
1055+ pk := usecase.NewPasskeys(store, mocks.NewPasskeyVerifier(t), f.sessions, f.clock)
1056+ tok, _, err := f.sessions.Login(ctx, f.nonce(t))
1057+ require.NoError(t, err)
1058+
1059+ err = pk.Remove(ctx, domain.Owner(), []byte("cred-1"))
1060+ require.ErrorIs(t, err, boom)
1061+ assert.NotErrorIs(t, err, domain.ErrNotFound)
1062+ _, err = f.sessions.Verify(ctx, tok)
1063+ assert.NoError(t, err, "the epoch did not move: the sessions are still valid")
1064+}
M internal/usecase/sessions.go +11 -0
@@ -189,6 +189,17 @@ func (s *Sessions) issue(ctx context.Context, capAt time.Time) (string, time.Tim
189189 return tok, exp.Truncate(time.Second), nil
190190 }
191191
192+// issueOwner opens an uncapped session for the owner (a passkey login): the
193+// same token a login link gives, for SessionTTL.
194+func (s *Sessions) issueOwner(ctx context.Context) (string, time.Time, error) {
195+ return s.issue(ctx, time.Time{})
196+}
197+
198+// bumpEpoch ends every session and returns the new epoch.
199+func (s *Sessions) bumpEpoch(ctx context.Context) (uint64, error) {
200+ return s.store.BumpEpoch(ctx)
201+}
202+
192203 // Verify checks a session token and returns the owner. The signature must be
193204 // HS256 with the session key (the algorithm is pinned, so "none" and any other
194205 // are refused), the token must not be expired or issued in the future, and its
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+}
M skills/ermite/CLI.md +4 -0
@@ -84,3 +84,7 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
8484 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
8585 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
8686 | `web logout --all` | signs every browser out |
87+| `web passkey list [--json]` | the owner's passkeys (`id`, `name`, `created_at`, `last_used_at`, `synced`); same rights as `web login`; fails with "passkeys are not available on this server" when the public URL is not https with a host name |
88+| `web passkey remove <id>` | removes a passkey and signs every browser out; the id comes from `list` |
89+
90+Adding a passkey is a browser action (`/-/passkeys` once signed in); agents do not use passkeys.

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 44

↑↓ moveenter openesc close