detached 6125ca9

fix(passkeys): log refusals, recheck the sign count, cap pending logins per client

- log the reason of a refused sign-in or registration at info, and name the
  passkey in the clone warning
- re-read the passkey under the write lock and refuse a counter that did not
  advance, so two logins cannot share one counter
- bound pending logins per client (8, IPv6 by /64) and evict from the fullest
  client on global overflow, so one address cannot push out another's ceremony
- answer 429 on login/finish before the content-type and body checks
- check the passkey name at register/begin, before any ceremony starts
commit
6125ca9f4b1e5e2b1570039cb123c07623ddaa60
author
brice · · 2026-10-12 15:46 UTC
parent
633b248
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +682 -145
M internal/adapters/web/passkeys.go +28 -8
@@ -144,12 +144,13 @@ func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
144144
145145 // passkeyLoginBegin is POST /-/passkeys/login/begin.
146146 func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147- if _, blocked := s.blocked(w, r); blocked {
147+ addr, blocked := s.blocked(w, r)
148+ if blocked {
148149 return
149150 }
150151 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
151152 defer cancel()
152- ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx)
153+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, addr)
153154 if err != nil {
154155 s.jsonInternal(ctx, w, r, err)
155156 return
@@ -169,14 +170,17 @@ type finishRequest struct {
169170 // A refusal counts as a failed authentication of the client, whatever the
170171 // reason.
171172 func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
172- body, ok := jsonBody(w, r)
173- if !ok {
174- return
175- }
173+ // The limiter first: a client that is blocked gets 429 whatever it sends.
176174 addr, blocked := s.blocked(w, r)
177175 if blocked {
178176 return
179177 }
178+ // A wrong content type or a malformed body is a bug of the client, not a
179+ // guess at the owner's passkey: it answers 4xx and is not counted as a failure.
180+ body, ok := jsonBody(w, r)
181+ if !ok {
182+ return
183+ }
180184 var req finishRequest
181185 if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
182186 jsonError(w, http.StatusBadRequest, "malformed request")
@@ -252,11 +256,27 @@ func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *ht
252256 }
253257 }
254258
255-// passkeyRegisterBegin is POST /-/passkeys/register/begin.
259+// passkeyRegisterBegin is POST /-/passkeys/register/begin. Like the finish
260+// endpoints it needs "Content-Type: application/json".
256261 func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
262+ body, ok := jsonBody(w, r)
263+ if !ok {
264+ return
265+ }
266+ // {"name": "..."}; an empty body is no name. The name is checked now, before
267+ // the browser prompts the owner for a passkey that would then be refused.
268+ var req struct {
269+ Name string `json:"name"`
270+ }
271+ if len(body) > 0 {
272+ if err := json.Unmarshal(body, &req); err != nil {
273+ jsonError(w, http.StatusBadRequest, "malformed request")
274+ return
275+ }
276+ }
257277 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
258278 defer cancel()
259- ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx))
279+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx), req.Name)
260280 if err != nil {
261281 s.registerError(ctx, w, r, err)
262282 return
@@ -144,12 +144,13 @@ func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
144 144
145 // passkeyLoginBegin is POST /-/passkeys/login/begin.145 // passkeyLoginBegin is POST /-/passkeys/login/begin.
146 func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {146 func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147- if _, blocked := s.blocked(w, r); blocked {147+ addr, blocked := s.blocked(w, r)
148+ if blocked {
148 return149 return
149 }150 }
150 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)151 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
151 defer cancel()152 defer cancel()
152- ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx)153+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, addr)
153 if err != nil {154 if err != nil {
154 s.jsonInternal(ctx, w, r, err)155 s.jsonInternal(ctx, w, r, err)
155 return156 return
@@ -169,14 +170,17 @@ type finishRequest struct {
169 // A refusal counts as a failed authentication of the client, whatever the170 // A refusal counts as a failed authentication of the client, whatever the
170 // reason.171 // reason.
171 func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {172 func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
172- body, ok := jsonBody(w, r)173+ // The limiter first: a client that is blocked gets 429 whatever it sends.
173- if !ok {
174- return
175- }
176 addr, blocked := s.blocked(w, r)174 addr, blocked := s.blocked(w, r)
177 if blocked {175 if blocked {
178 return176 return
179 }177 }
178+ // A wrong content type or a malformed body is a bug of the client, not a
179+ // guess at the owner's passkey: it answers 4xx and is not counted as a failure.
180+ body, ok := jsonBody(w, r)
181+ if !ok {
182+ return
183+ }
180 var req finishRequest184 var req finishRequest
181 if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {185 if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
182 jsonError(w, http.StatusBadRequest, "malformed request")186 jsonError(w, http.StatusBadRequest, "malformed request")
@@ -252,11 +256,27 @@ func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *ht
252 }256 }
253 }257 }
254 258
255-// passkeyRegisterBegin is POST /-/passkeys/register/begin.259+// passkeyRegisterBegin is POST /-/passkeys/register/begin. Like the finish
260+// endpoints it needs "Content-Type: application/json".
256 func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {261 func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
262+ body, ok := jsonBody(w, r)
263+ if !ok {
264+ return
265+ }
266+ // {"name": "..."}; an empty body is no name. The name is checked now, before
267+ // the browser prompts the owner for a passkey that would then be refused.
268+ var req struct {
269+ Name string `json:"name"`
270+ }
271+ if len(body) > 0 {
272+ if err := json.Unmarshal(body, &req); err != nil {
273+ jsonError(w, http.StatusBadRequest, "malformed request")
274+ return
275+ }
276+ }
257 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)277 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
258 defer cancel()278 defer cancel()
259- ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx))279+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx), req.Name)
260 if err != nil {280 if err != nil {
261 s.registerError(ctx, w, r, err)281 s.registerError(ctx, w, r, err)
262 return282 return
M internal/adapters/web/passkeys_test.go +70 -6
@@ -136,7 +136,7 @@ func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
136136 func TestPasskeyLoginBegin(t *testing.T) {
137137 t.Parallel()
138138 f := newPasskeyServer(t)
139- f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
139+ f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
140140
141141 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
142142 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
@@ -149,7 +149,7 @@ func TestPasskeyLoginBegin(t *testing.T) {
149149 func TestPasskeyLoginBeginFailure(t *testing.T) {
150150 t.Parallel()
151151 f := newPasskeyServer(t)
152- f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once()
152+ f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("", nil, errors.New("secret detail")).Once()
153153 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
154154 assert.Equal(t, http.StatusInternalServerError, rec.Code)
155155 assert.NotContains(t, rec.Body.String(), "secret detail")
@@ -295,8 +295,8 @@ func TestPasskeyFinishMalformed(t *testing.T) {
295295 func TestPasskeyRegistration(t *testing.T) {
296296 t.Parallel()
297297 f := newPasskeyServer(t)
298- f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299- rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
298+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299+ rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
300300 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
301301 assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
302302 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
@@ -338,8 +338,8 @@ func TestPasskeyRegistrationErrors(t *testing.T) {
338338 }
339339
340340 f := newPasskeyServer(t)
341- f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342- rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
341+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342+ rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
343343 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
344344 }
345345
@@ -545,3 +545,67 @@ func TestIsPasskeyLoginPath(t *testing.T) {
545545 assert.False(t, isPasskeyLoginPath(p), p)
546546 }
547547 }
548+
549+// GO-005: a blocked client gets 429 whatever it sends, and a malformed finish
550+// is a 4xx that does not count as a failed authentication.
551+func TestPasskeyLoginFinishBlockedBeforeBodyChecks(t *testing.T) {
552+ t.Parallel()
553+ f := newPasskeyServer(t)
554+ f.lim.blocked = true
555+ for _, r := range []req{
556+ {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
557+ postJSON(finishLogin, "", `not json`),
558+ postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
559+ } {
560+ rec := f.do(t, r)
561+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, "%q", r.contentType)
562+ }
563+ assert.Zero(t, f.lim.fails)
564+
565+ f.lim.blocked = false
566+ for _, r := range []req{
567+ {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
568+ postJSON(finishLogin, "", `not json`),
569+ postJSON(finishLogin, "", `{}`),
570+ } {
571+ rec := f.do(t, r)
572+ assert.GreaterOrEqual(t, rec.Code, 400)
573+ assert.Less(t, rec.Code, 500)
574+ }
575+ assert.Zero(t, f.lim.fails, "a malformed request is not a failed sign-in")
576+}
577+
578+// The client key the web hands to the use case is the one the limiter uses.
579+func TestPasskeyLoginBeginPassesTheClientAddress(t *testing.T) {
580+ t.Parallel()
581+ f := newPasskeyServer(t)
582+ f.passkeys.EXPECT().BeginLogin(mock.Anything, "192.0.2.77").Return("c", []byte(`{}`), nil).Once()
583+ r := httptest.NewRequest("POST", beginLogin, nil)
584+ r.RemoteAddr = "192.0.2.77:51000"
585+ r.Header.Set("Origin", forgeOrigin)
586+ r.Header.Set("X-Forwarded-For", "203.0.113.5") // no trusted proxy: ignored
587+ rec := httptest.NewRecorder()
588+ f.srv.ServeHTTP(rec, r)
589+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
590+}
591+
592+// TS-001: the name travels with register/begin and is checked there.
593+func TestPasskeyRegisterBeginChecksTheName(t *testing.T) {
594+ t.Parallel()
595+ f := newPasskeyServer(t)
596+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "Pixel 9").Return("cer", []byte(`{"publicKey":{}}`), nil).Once()
597+ rec := f.do(t, postJSON(beginRegister, "tok", `{"name":"Pixel 9"}`))
598+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
599+
600+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "bad\u0000name").
601+ Return("", nil, &domain.TextError{Reason: "contains the control character U+0000"}).Once()
602+ rec = f.do(t, postJSON(beginRegister, "tok", `{"name":"bad\u0000name"}`))
603+ assert.Equal(t, http.StatusBadRequest, rec.Code)
604+ assert.Contains(t, rec.Body.String(), "control character")
605+
606+ // No body at all is no name; a body that is not JSON, or not the right type, is refused.
607+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer", []byte(`{}`), nil).Once()
608+ assert.Equal(t, http.StatusOK, f.do(t, postJSON(beginRegister, "tok", ``)).Code)
609+ assert.Equal(t, http.StatusBadRequest, f.do(t, postJSON(beginRegister, "tok", `nope`)).Code)
610+ assert.Equal(t, http.StatusUnsupportedMediaType, f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}).Code)
611+}
@@ -136,7 +136,7 @@ func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
136 func TestPasskeyLoginBegin(t *testing.T) {136 func TestPasskeyLoginBegin(t *testing.T) {
137 t.Parallel()137 t.Parallel()
138 f := newPasskeyServer(t)138 f := newPasskeyServer(t)
139- f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()139+ f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
140 140
141 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})141 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
142 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())142 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
@@ -149,7 +149,7 @@ func TestPasskeyLoginBegin(t *testing.T) {
149 func TestPasskeyLoginBeginFailure(t *testing.T) {149 func TestPasskeyLoginBeginFailure(t *testing.T) {
150 t.Parallel()150 t.Parallel()
151 f := newPasskeyServer(t)151 f := newPasskeyServer(t)
152- f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once()152+ f.passkeys.EXPECT().BeginLogin(mock.Anything, mock.Anything).Return("", nil, errors.New("secret detail")).Once()
153 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})153 rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
154 assert.Equal(t, http.StatusInternalServerError, rec.Code)154 assert.Equal(t, http.StatusInternalServerError, rec.Code)
155 assert.NotContains(t, rec.Body.String(), "secret detail")155 assert.NotContains(t, rec.Body.String(), "secret detail")
@@ -295,8 +295,8 @@ func TestPasskeyFinishMalformed(t *testing.T) {
295 func TestPasskeyRegistration(t *testing.T) {295 func TestPasskeyRegistration(t *testing.T) {
296 t.Parallel()296 t.Parallel()
297 f := newPasskeyServer(t)297 f := newPasskeyServer(t)
298- f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()298+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299- rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})299+ rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
300 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())300 require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
301 assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())301 assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
302 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))302 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
@@ -338,8 +338,8 @@ func TestPasskeyRegistrationErrors(t *testing.T) {
338 }338 }
339 339
340 f := newPasskeyServer(t)340 f := newPasskeyServer(t)
341- f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()341+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342- rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})342+ rec := f.do(t, postJSON(beginRegister, "tok", `{}`))
343 assert.Equal(t, http.StatusTooManyRequests, rec.Code)343 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
344 }344 }
345 345
@@ -545,3 +545,67 @@ func TestIsPasskeyLoginPath(t *testing.T) {
545 assert.False(t, isPasskeyLoginPath(p), p)545 assert.False(t, isPasskeyLoginPath(p), p)
546 }546 }
547 }547 }
548+
549+// GO-005: a blocked client gets 429 whatever it sends, and a malformed finish
550+// is a 4xx that does not count as a failed authentication.
551+func TestPasskeyLoginFinishBlockedBeforeBodyChecks(t *testing.T) {
552+ t.Parallel()
553+ f := newPasskeyServer(t)
554+ f.lim.blocked = true
555+ for _, r := range []req{
556+ {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
557+ postJSON(finishLogin, "", `not json`),
558+ postJSON(finishLogin, "", strings.Repeat("a", 64<<10+1)),
559+ } {
560+ rec := f.do(t, r)
561+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, "%q", r.contentType)
562+ }
563+ assert.Zero(t, f.lim.fails)
564+
565+ f.lim.blocked = false
566+ for _, r := range []req{
567+ {method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "text/plain", body: "x"},
568+ postJSON(finishLogin, "", `not json`),
569+ postJSON(finishLogin, "", `{}`),
570+ } {
571+ rec := f.do(t, r)
572+ assert.GreaterOrEqual(t, rec.Code, 400)
573+ assert.Less(t, rec.Code, 500)
574+ }
575+ assert.Zero(t, f.lim.fails, "a malformed request is not a failed sign-in")
576+}
577+
578+// The client key the web hands to the use case is the one the limiter uses.
579+func TestPasskeyLoginBeginPassesTheClientAddress(t *testing.T) {
580+ t.Parallel()
581+ f := newPasskeyServer(t)
582+ f.passkeys.EXPECT().BeginLogin(mock.Anything, "192.0.2.77").Return("c", []byte(`{}`), nil).Once()
583+ r := httptest.NewRequest("POST", beginLogin, nil)
584+ r.RemoteAddr = "192.0.2.77:51000"
585+ r.Header.Set("Origin", forgeOrigin)
586+ r.Header.Set("X-Forwarded-For", "203.0.113.5") // no trusted proxy: ignored
587+ rec := httptest.NewRecorder()
588+ f.srv.ServeHTTP(rec, r)
589+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
590+}
591+
592+// TS-001: the name travels with register/begin and is checked there.
593+func TestPasskeyRegisterBeginChecksTheName(t *testing.T) {
594+ t.Parallel()
595+ f := newPasskeyServer(t)
596+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "Pixel 9").Return("cer", []byte(`{"publicKey":{}}`), nil).Once()
597+ rec := f.do(t, postJSON(beginRegister, "tok", `{"name":"Pixel 9"}`))
598+ assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
599+
600+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "bad\u0000name").
601+ Return("", nil, &domain.TextError{Reason: "contains the control character U+0000"}).Once()
602+ rec = f.do(t, postJSON(beginRegister, "tok", `{"name":"bad\u0000name"}`))
603+ assert.Equal(t, http.StatusBadRequest, rec.Code)
604+ assert.Contains(t, rec.Body.String(), "control character")
605+
606+ // No body at all is no name; a body that is not JSON, or not the right type, is refused.
607+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner(), "").Return("cer", []byte(`{}`), nil).Once()
608+ assert.Equal(t, http.StatusOK, f.do(t, postJSON(beginRegister, "tok", ``)).Code)
609+ assert.Equal(t, http.StatusBadRequest, f.do(t, postJSON(beginRegister, "tok", `nope`)).Code)
610+ assert.Equal(t, http.StatusUnsupportedMediaType, f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}).Code)
611+}
M internal/app/web_passkey_test.go +2 -2
@@ -82,7 +82,7 @@ type beginReply struct {
8282 // register adds the authenticator's credential as the owner holding token.
8383 func (p *passkeyHarness) register(token, name string) page {
8484 p.t.Helper()
85- begin := p.postJSON("/-/passkeys/register/begin", token, nil)
85+ begin := p.postJSON("/-/passkeys/register/begin", token, map[string]any{"name": name})
8686 require.Equal(p.t, http.StatusOK, begin.status, begin.body)
8787 var br beginReply
8888 require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
@@ -135,7 +135,7 @@ func TestPasskeyEndToEnd(t *testing.T) {
135135
136136 // Anonymous: the list is a 404, registration is refused.
137137 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
138- assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", nil).status)
138+ assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", map[string]any{}).status)
139139
140140 // Sign in with the CLI link (the way in before any passkey), add a passkey.
141141 link := p.cli(t, adminToken, "web", "login").ok(t)
@@ -82,7 +82,7 @@ type beginReply struct {
82 // register adds the authenticator's credential as the owner holding token.82 // register adds the authenticator's credential as the owner holding token.
83 func (p *passkeyHarness) register(token, name string) page {83 func (p *passkeyHarness) register(token, name string) page {
84 p.t.Helper()84 p.t.Helper()
85- begin := p.postJSON("/-/passkeys/register/begin", token, nil)85+ begin := p.postJSON("/-/passkeys/register/begin", token, map[string]any{"name": name})
86 require.Equal(p.t, http.StatusOK, begin.status, begin.body)86 require.Equal(p.t, http.StatusOK, begin.status, begin.body)
87 var br beginReply87 var br beginReply
88 require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))88 require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br))
@@ -135,7 +135,7 @@ func TestPasskeyEndToEnd(t *testing.T) {
135 135
136 // Anonymous: the list is a 404, registration is refused.136 // Anonymous: the list is a 404, registration is refused.
137 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)137 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", nil).status)
138- assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", nil).status)138+ assert.Equal(t, http.StatusForbidden, p.postJSON("/-/passkeys/register/begin", "", map[string]any{}).status)
139 139
140 // Sign in with the CLI link (the way in before any passkey), add a passkey.140 // Sign in with the CLI link (the way in before any passkey), add a passkey.
141 link := p.cli(t, adminToken, "web", "login").ok(t)141 link := p.cli(t, adminToken, "web", "login").ok(t)
M internal/ports/mocks/WebPasskeyService.go +40 -28
@@ -49,8 +49,8 @@ func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter {
4949 }
5050
5151 // BeginLogin provides a mock function for the type WebPasskeyService
52-func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte, error) {
53- ret := _mock.Called(ctx)
52+func (_mock *WebPasskeyService) BeginLogin(ctx context.Context, client string) (string, []byte, error) {
53+ ret := _mock.Called(ctx, client)
5454
5555 if len(ret) == 0 {
5656 panic("no return value specified for BeginLogin")
@@ -59,23 +59,23 @@ func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte,
5959 var r0 string
6060 var r1 []byte
6161 var r2 error
62- if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
63- return returnFunc(ctx)
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, []byte, error)); ok {
63+ return returnFunc(ctx, client)
6464 }
65- if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
66- r0 = returnFunc(ctx)
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
66+ r0 = returnFunc(ctx, client)
6767 } else {
6868 r0 = ret.Get(0).(string)
6969 }
70- if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
71- r1 = returnFunc(ctx)
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) []byte); ok {
71+ r1 = returnFunc(ctx, client)
7272 } else {
7373 if ret.Get(1) != nil {
7474 r1 = ret.Get(1).([]byte)
7575 }
7676 }
77- if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
78- r2 = returnFunc(ctx)
77+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
78+ r2 = returnFunc(ctx, client)
7979 } else {
8080 r2 = ret.Error(2)
8181 }
@@ -89,18 +89,24 @@ type WebPasskeyService_BeginLogin_Call struct {
8989
9090 // BeginLogin is a helper method to define mock.On call
9191 // - ctx context.Context
92-func (_e *WebPasskeyService_Expecter) BeginLogin(ctx any) *WebPasskeyService_BeginLogin_Call {
93- return &WebPasskeyService_BeginLogin_Call{Call: _e.mock.On("BeginLogin", ctx)}
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)}
9495 }
9596
96-func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context)) *WebPasskeyService_BeginLogin_Call {
97+func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context, client string)) *WebPasskeyService_BeginLogin_Call {
9798 _c.Call.Run(func(args mock.Arguments) {
9899 var arg0 context.Context
99100 if args[0] != nil {
100101 arg0 = args[0].(context.Context)
101102 }
103+ var arg1 string
104+ if args[1] != nil {
105+ arg1 = args[1].(string)
106+ }
102107 run(
103108 arg0,
109+ arg1,
104110 )
105111 })
106112 return _c
@@ -111,14 +117,14 @@ func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []b
111117 return _c
112118 }
113119
114-func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {
120+func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context, client string) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {
115121 _c.Call.Return(run)
116122 return _c
117123 }
118124
119125 // BeginRegistration provides a mock function for the type WebPasskeyService
120-func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {
121- ret := _mock.Called(ctx, p)
126+func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
127+ ret := _mock.Called(ctx, p, name)
122128
123129 if len(ret) == 0 {
124130 panic("no return value specified for BeginRegistration")
@@ -127,23 +133,23 @@ func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.
127133 var r0 string
128134 var r1 []byte
129135 var r2 error
130- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (string, []byte, error)); ok {
131- return returnFunc(ctx, p)
136+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) (string, []byte, error)); ok {
137+ return returnFunc(ctx, p, name)
132138 }
133- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) string); ok {
134- r0 = returnFunc(ctx, p)
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) string); ok {
140+ r0 = returnFunc(ctx, p, name)
135141 } else {
136142 r0 = ret.Get(0).(string)
137143 }
138- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) []byte); ok {
139- r1 = returnFunc(ctx, p)
144+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string) []byte); ok {
145+ r1 = returnFunc(ctx, p, name)
140146 } else {
141147 if ret.Get(1) != nil {
142148 r1 = ret.Get(1).([]byte)
143149 }
144150 }
145- if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal) error); ok {
146- r2 = returnFunc(ctx, p)
151+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string) error); ok {
152+ r2 = returnFunc(ctx, p, name)
147153 } else {
148154 r2 = ret.Error(2)
149155 }
@@ -158,11 +164,12 @@ type WebPasskeyService_BeginRegistration_Call struct {
158164 // BeginRegistration is a helper method to define mock.On call
159165 // - ctx context.Context
160166 // - p domain.Principal
161-func (_e *WebPasskeyService_Expecter) BeginRegistration(ctx any, p any) *WebPasskeyService_BeginRegistration_Call {
162- return &WebPasskeyService_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, p)}
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)}
163170 }
164171
165-func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_BeginRegistration_Call {
172+func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, name string)) *WebPasskeyService_BeginRegistration_Call {
166173 _c.Call.Run(func(args mock.Arguments) {
167174 var arg0 context.Context
168175 if args[0] != nil {
@@ -172,9 +179,14 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Con
172179 if args[1] != nil {
173180 arg1 = args[1].(domain.Principal)
174181 }
182+ var arg2 string
183+ if args[2] != nil {
184+ arg2 = args[2].(string)
185+ }
175186 run(
176187 arg0,
177188 arg1,
189+ arg2,
178190 )
179191 })
180192 return _c
@@ -185,7 +197,7 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, opti
185197 return _c
186198 }
187199
188-func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {
200+func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name string) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {
189201 _c.Call.Return(run)
190202 return _c
191203 }
@@ -49,8 +49,8 @@ func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter {
49 }49 }
50 50
51 // BeginLogin provides a mock function for the type WebPasskeyService51 // BeginLogin provides a mock function for the type WebPasskeyService
52-func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte, error) {52+func (_mock *WebPasskeyService) BeginLogin(ctx context.Context, client string) (string, []byte, error) {
53- ret := _mock.Called(ctx)53+ ret := _mock.Called(ctx, client)
54 54
55 if len(ret) == 0 {55 if len(ret) == 0 {
56 panic("no return value specified for BeginLogin")56 panic("no return value specified for BeginLogin")
@@ -59,23 +59,23 @@ func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte,
59 var r0 string59 var r0 string
60 var r1 []byte60 var r1 []byte
61 var r2 error61 var r2 error
62- if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {62+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, []byte, error)); ok {
63- return returnFunc(ctx)63+ return returnFunc(ctx, client)
64 }64 }
65- if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {65+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
66- r0 = returnFunc(ctx)66+ r0 = returnFunc(ctx, client)
67 } else {67 } else {
68 r0 = ret.Get(0).(string)68 r0 = ret.Get(0).(string)
69 }69 }
70- if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {70+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) []byte); ok {
71- r1 = returnFunc(ctx)71+ r1 = returnFunc(ctx, client)
72 } else {72 } else {
73 if ret.Get(1) != nil {73 if ret.Get(1) != nil {
74 r1 = ret.Get(1).([]byte)74 r1 = ret.Get(1).([]byte)
75 }75 }
76 }76 }
77- if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {77+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
78- r2 = returnFunc(ctx)78+ r2 = returnFunc(ctx, client)
79 } else {79 } else {
80 r2 = ret.Error(2)80 r2 = ret.Error(2)
81 }81 }
@@ -89,18 +89,24 @@ type WebPasskeyService_BeginLogin_Call struct {
89 89
90 // BeginLogin is a helper method to define mock.On call90 // BeginLogin is a helper method to define mock.On call
91 // - ctx context.Context91 // - ctx context.Context
92-func (_e *WebPasskeyService_Expecter) BeginLogin(ctx any) *WebPasskeyService_BeginLogin_Call {92+// - client string
93- return &WebPasskeyService_BeginLogin_Call{Call: _e.mock.On("BeginLogin", ctx)}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)}
94 }95 }
95 96
96-func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context)) *WebPasskeyService_BeginLogin_Call {97+func (_c *WebPasskeyService_BeginLogin_Call) Run(run func(ctx context.Context, client string)) *WebPasskeyService_BeginLogin_Call {
97 _c.Call.Run(func(args mock.Arguments) {98 _c.Call.Run(func(args mock.Arguments) {
98 var arg0 context.Context99 var arg0 context.Context
99 if args[0] != nil {100 if args[0] != nil {
100 arg0 = args[0].(context.Context)101 arg0 = args[0].(context.Context)
101 }102 }
103+ var arg1 string
104+ if args[1] != nil {
105+ arg1 = args[1].(string)
106+ }
102 run(107 run(
103 arg0,108 arg0,
109+ arg1,
104 )110 )
105 })111 })
106 return _c112 return _c
@@ -111,14 +117,14 @@ func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []b
111 return _c117 return _c
112 }118 }
113 119
114-func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {120+func (_c *WebPasskeyService_BeginLogin_Call) RunAndReturn(run func(ctx context.Context, client string) (string, []byte, error)) *WebPasskeyService_BeginLogin_Call {
115 _c.Call.Return(run)121 _c.Call.Return(run)
116 return _c122 return _c
117 }123 }
118 124
119 // BeginRegistration provides a mock function for the type WebPasskeyService125 // BeginRegistration provides a mock function for the type WebPasskeyService
120-func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {126+func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
121- ret := _mock.Called(ctx, p)127+ ret := _mock.Called(ctx, p, name)
122 128
123 if len(ret) == 0 {129 if len(ret) == 0 {
124 panic("no return value specified for BeginRegistration")130 panic("no return value specified for BeginRegistration")
@@ -127,23 +133,23 @@ func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain.
127 var r0 string133 var r0 string
128 var r1 []byte134 var r1 []byte
129 var r2 error135 var r2 error
130- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (string, []byte, error)); ok {136+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) (string, []byte, error)); ok {
131- return returnFunc(ctx, p)137+ return returnFunc(ctx, p, name)
132 }138 }
133- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) string); ok {139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) string); ok {
134- r0 = returnFunc(ctx, p)140+ r0 = returnFunc(ctx, p, name)
135 } else {141 } else {
136 r0 = ret.Get(0).(string)142 r0 = ret.Get(0).(string)
137 }143 }
138- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) []byte); ok {144+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string) []byte); ok {
139- r1 = returnFunc(ctx, p)145+ r1 = returnFunc(ctx, p, name)
140 } else {146 } else {
141 if ret.Get(1) != nil {147 if ret.Get(1) != nil {
142 r1 = ret.Get(1).([]byte)148 r1 = ret.Get(1).([]byte)
143 }149 }
144 }150 }
145- if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal) error); ok {151+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string) error); ok {
146- r2 = returnFunc(ctx, p)152+ r2 = returnFunc(ctx, p, name)
147 } else {153 } else {
148 r2 = ret.Error(2)154 r2 = ret.Error(2)
149 }155 }
@@ -158,11 +164,12 @@ type WebPasskeyService_BeginRegistration_Call struct {
158 // BeginRegistration is a helper method to define mock.On call164 // BeginRegistration is a helper method to define mock.On call
159 // - ctx context.Context165 // - ctx context.Context
160 // - p domain.Principal166 // - p domain.Principal
161-func (_e *WebPasskeyService_Expecter) BeginRegistration(ctx any, p any) *WebPasskeyService_BeginRegistration_Call {167+// - name string
162- return &WebPasskeyService_BeginRegistration_Call{Call: _e.mock.On("BeginRegistration", ctx, p)}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)}
163 }170 }
164 171
165-func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal)) *WebPasskeyService_BeginRegistration_Call {172+func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Context, p domain.Principal, name string)) *WebPasskeyService_BeginRegistration_Call {
166 _c.Call.Run(func(args mock.Arguments) {173 _c.Call.Run(func(args mock.Arguments) {
167 var arg0 context.Context174 var arg0 context.Context
168 if args[0] != nil {175 if args[0] != nil {
@@ -172,9 +179,14 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Con
172 if args[1] != nil {179 if args[1] != nil {
173 arg1 = args[1].(domain.Principal)180 arg1 = args[1].(domain.Principal)
174 }181 }
182+ var arg2 string
183+ if args[2] != nil {
184+ arg2 = args[2].(string)
185+ }
175 run(186 run(
176 arg0,187 arg0,
177 arg1,188 arg1,
189+ arg2,
178 )190 )
179 })191 })
180 return _c192 return _c
@@ -185,7 +197,7 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, opti
185 return _c197 return _c
186 }198 }
187 199
188-func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {200+func (_c *WebPasskeyService_BeginRegistration_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name string) (string, []byte, error)) *WebPasskeyService_BeginRegistration_Call {
189 _c.Call.Return(run)201 _c.Call.Return(run)
190 return _c202 return _c
191 }203 }
M internal/ports/services.go +8 -4
@@ -135,13 +135,17 @@ type PasskeyService interface {
135135 type WebPasskeyService interface {
136136 List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
137137 Remove(ctx context.Context, p domain.Principal, id []byte) error
138- // BeginLogin starts a sign-in. Anonymous callers may.
139- BeginLogin(ctx context.Context) (ceremony string, options []byte, err error)
138+ // BeginLogin starts a sign-in. Anonymous callers may. client is the
139+ // address the request is attributed to (trusted proxies applied, the one the
140+ // failure limiter uses); it bounds how many sign-ins a client keeps waiting.
141+ BeginLogin(ctx context.Context, client string) (ceremony string, options []byte, err error)
140142 // FinishLogin checks the browser's response and returns the session token
141143 // and how long it lasts, or ErrLinkRejected for any refusal.
142144 FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)
143- // BeginRegistration starts adding a passkey. Only the owner may.
144- BeginRegistration(ctx context.Context, p domain.Principal) (ceremony string, options []byte, 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 before any ceremony starts.
148+ BeginRegistration(ctx context.Context, p domain.Principal, name string) (ceremony string, options []byte, err error)
145149 // FinishRegistration checks the browser's response and stores the
146150 // passkey under name (empty means the default name).
147151 FinishRegistration(ctx context.Context, p domain.Principal, ceremony, name string, response []byte) error
@@ -135,13 +135,17 @@ type PasskeyService interface {
135 type WebPasskeyService interface {135 type WebPasskeyService interface {
136 List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)136 List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error)
137 Remove(ctx context.Context, p domain.Principal, id []byte) error137 Remove(ctx context.Context, p domain.Principal, id []byte) error
138- // BeginLogin starts a sign-in. Anonymous callers may.138+ // BeginLogin starts a sign-in. Anonymous callers may. client is the
139- BeginLogin(ctx context.Context) (ceremony string, options []byte, err error)139+ // address the request is attributed to (trusted proxies applied, the one the
140+ // failure limiter uses); it bounds how many sign-ins a client keeps waiting.
141+ BeginLogin(ctx context.Context, client string) (ceremony string, options []byte, err error)
140 // FinishLogin checks the browser's response and returns the session token142 // FinishLogin checks the browser's response and returns the session token
141 // and how long it lasts, or ErrLinkRejected for any refusal.143 // and how long it lasts, or ErrLinkRejected for any refusal.
142 FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)144 FinishLogin(ctx context.Context, ceremony string, response []byte) (token string, lifetime time.Duration, err error)
143- // BeginRegistration starts adding a passkey. Only the owner may.145+ // BeginRegistration starts adding a passkey. Only the owner may. name is
144- BeginRegistration(ctx context.Context, p domain.Principal) (ceremony string, options []byte, err error)146+ // the name the passkey will get (empty means the default): a name that
147+ // breaks the rules is refused before any ceremony starts.
148+ BeginRegistration(ctx context.Context, p domain.Principal, name string) (ceremony string, options []byte, err error)
145 // FinishRegistration checks the browser's response and stores the149 // FinishRegistration checks the browser's response and stores the
146 // passkey under name (empty means the default name).150 // passkey under name (empty means the default name).
147 FinishRegistration(ctx context.Context, p domain.Principal, ceremony, name string, response []byte) error151 FinishRegistration(ctx context.Context, p domain.Principal, ceremony, name string, response []byte) error
M internal/usecase/passkeys.go +171 -38
@@ -3,6 +3,7 @@
33 package usecase
44
55 import (
6+ "bytes"
67 "context"
78 "crypto/rand"
89 "crypto/sha256"
@@ -10,6 +11,7 @@ import (
1011 "encoding/hex"
1112 "errors"
1213 "fmt"
14+ "net/netip"
1315 "sync"
1416 "time"
1517
@@ -28,9 +30,12 @@ const (
2830 // finished. Only the owner starts one.
2931 MaxPendingPasskeyRegistrations = 16
3032 // MaxPendingPasskeyLogins bounds the logins that wait to be finished.
31- // Anyone may start one, so on overflow the oldest is dropped instead of
32- // refusing: anonymous callers cannot lock the owner out.
33+ // Anyone may start one, so a login is never refused: on overflow one is
34+ // dropped instead, and anonymous callers cannot lock the owner out.
3335 MaxPendingPasskeyLogins = 256
36+ // MaxPendingPasskeyLoginsPerClient bounds the logins one client (see
37+ // clientKey) may have waiting. A client over it loses its own oldest.
38+ MaxPendingPasskeyLoginsPerClient = 8
3439
3540 // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.
3641 ceremonyIDBytes = 32
@@ -49,6 +54,7 @@ type ceremony struct {
4954 data []byte // opaque, from the verifier
5055 expires time.Time
5156 seq uint64 // creation order
57+ client string // who began a login (clientKey); "" for a registration
5258 }
5359
5460 // Passkeys are the use cases of the owner's passkeys: registering, signing in,
@@ -76,10 +82,48 @@ func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessi
7682 return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}
7783 }
7884
85+// clientKey is what a login ceremony is attributed to: the address of the
86+// client as the web resolved it (trusted proxies applied), an IPv6 address
87+// reduced to its /64 network, since one host owns a whole /64. Anything that is
88+// not an address is kept as it is.
89+func clientKey(addr string) string {
90+ a, err := netip.ParseAddr(addr)
91+ if err != nil {
92+ return addr
93+ }
94+ a = a.Unmap()
95+ if a.Is6() {
96+ p, err := a.Prefix(64)
97+ if err != nil {
98+ return addr
99+ }
100+ return p.String()
101+ }
102+ return a.String()
103+}
104+
105+// pendingLogins is what begin knows of the logins that wait, per client.
106+type pendingLogins struct {
107+ count int
108+ oldest [sha256.Size]byte
109+ oldestSeq uint64
110+ hasOldestSeq bool
111+}
112+
113+func (p *pendingLogins) add(h [sha256.Size]byte, seq uint64) {
114+ p.count++
115+ if !p.hasOldestSeq || seq < p.oldestSeq {
116+ p.oldest, p.oldestSeq, p.hasOldestSeq = h, seq, true
117+ }
118+}
119+
79120 // begin keeps a ceremony and returns its identifier. It sweeps the expired
80-// ones first. A registration over the limit gives domain.ErrResourceExhausted;
81-// a login over it drops the oldest login.
82-func (s *Passkeys) begin(kind ceremonyKind, data []byte) (string, error) {
121+// ones first. A registration over the limit gives domain.ErrResourceExhausted.
122+// A login is never refused, but it can cost another login its place: over the
123+// per-client cap it drops that client's own oldest; over the global cap it drops
124+// the oldest of the client with the most logins waiting. A client that floods
125+// therefore only ever loses its own ceremonies, never another client's.
126+func (s *Passkeys) begin(kind ceremonyKind, client string, data []byte) (string, error) {
83127 var raw [ceremonyIDBytes]byte
84128 if _, err := rand.Read(raw[:]); err != nil {
85129 return "", fmt.Errorf("generate passkey ceremony: %w", err)
@@ -89,37 +133,77 @@ func (s *Passkeys) begin(kind ceremonyKind, data []byte) (string, error) {
89133
90134 s.mu.Lock()
91135 defer s.mu.Unlock()
92- pending := 0
93- var oldest [sha256.Size]byte
94- var oldestSeq uint64
136+ registrations := 0
137+ logins := 0
138+ byClient := map[string]*pendingLogins{}
95139 for h, c := range s.ceremonies {
96140 if !now.Before(c.expires) {
97141 delete(s.ceremonies, h)
98142 continue
99143 }
100- if c.kind != kind {
101- continue
102- }
103- if pending == 0 || c.seq < oldestSeq {
104- oldest, oldestSeq = h, c.seq
144+ switch c.kind {
145+ case ceremonyRegister:
146+ registrations++
147+ case ceremonyLogin:
148+ logins++
149+ pl := byClient[c.client]
150+ if pl == nil {
151+ pl = &pendingLogins{}
152+ byClient[c.client] = pl
153+ }
154+ pl.add(h, c.seq)
105155 }
106- pending++
107156 }
108157 switch kind {
109158 case ceremonyRegister:
110- if pending >= MaxPendingPasskeyRegistrations {
159+ if registrations >= MaxPendingPasskeyRegistrations {
111160 return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)
112161 }
113162 case ceremonyLogin:
114- if pending >= MaxPendingPasskeyLogins {
115- delete(s.ceremonies, oldest)
163+ if pl := byClient[client]; pl != nil && pl.count >= MaxPendingPasskeyLoginsPerClient {
164+ delete(s.ceremonies, pl.oldest)
165+ logins--
166+ // The client is back under its cap; the global step below needs
167+ // its new count and its new oldest.
168+ pl.count--
169+ s.refreshOldest(pl, client)
170+ }
171+ if logins >= MaxPendingPasskeyLogins {
172+ // The client with the most waiting loses its oldest; among equals,
173+ // the one whose oldest waited longest.
174+ var victim *pendingLogins
175+ for _, pl := range byClient {
176+ if pl.count == 0 {
177+ continue
178+ }
179+ if victim == nil || pl.count > victim.count || (pl.count == victim.count && pl.oldestSeq < victim.oldestSeq) {
180+ victim = pl
181+ }
182+ }
183+ if victim != nil {
184+ delete(s.ceremonies, victim.oldest)
185+ }
116186 }
117187 }
118188 s.seq++
119- s.ceremonies[sha256.Sum256([]byte(id))] = ceremony{kind: kind, data: data, expires: now.Add(PasskeyCeremonyTTL), seq: s.seq}
189+ s.ceremonies[sha256.Sum256([]byte(id))] = ceremony{kind: kind, data: data, expires: now.Add(PasskeyCeremonyTTL), seq: s.seq, client: client}
120190 return id, nil
121191 }
122192
193+// refreshOldest recomputes the oldest login of a client after one was dropped.
194+// The caller holds s.mu.
195+func (s *Passkeys) refreshOldest(pl *pendingLogins, client string) {
196+ pl.hasOldestSeq = false
197+ for h, c := range s.ceremonies {
198+ if c.kind != ceremonyLogin || c.client != client {
199+ continue
200+ }
201+ if !pl.hasOldestSeq || c.seq < pl.oldestSeq {
202+ pl.oldest, pl.oldestSeq, pl.hasOldestSeq = h, c.seq, true
203+ }
204+ }
205+}
206+
123207 // take removes the ceremony and returns its data. It is single use, also when
124208 // the ceremony expired or is of another kind. ok is false for an unknown,
125209 // expired or mismatched ceremony.
@@ -135,14 +219,19 @@ func (s *Passkeys) take(id string, kind ceremonyKind) (data []byte, ok bool) {
135219 return c.data, true
136220 }
137221
138-// BeginRegistration starts adding a passkey. Only the owner may. It gives
139-// domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too many
140-// registrations wait. It returns the ceremony identifier and the options for
141-// the browser.
142-func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {
222+// BeginRegistration starts adding a passkey. Only the owner may. name is
223+// checked now, so that a name the browser would be asked about and then refused
224+// costs no ceremony (an empty name means the default); finish checks it again.
225+// It gives domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too
226+// many registrations wait. It returns the ceremony identifier and the options
227+// for the browser.
228+func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
143229 if err := requireOwner(p); err != nil {
144230 return "", nil, err
145231 }
232+ if _, err := parsePasskeyName(name); err != nil {
233+ return "", nil, err
234+ }
146235 existing, err := s.store.List(ctx)
147236 if err != nil {
148237 return "", nil, fmt.Errorf("list passkeys: %w", err)
@@ -158,7 +247,7 @@ func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal) (s
158247 if err != nil {
159248 return "", nil, err
160249 }
161- id, err := s.begin(ceremonyRegister, data)
250+ id, err := s.begin(ceremonyRegister, "", data)
162251 if err != nil {
163252 return "", nil, err
164253 }
@@ -175,16 +264,16 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c
175264 if err := requireOwner(p); err != nil {
176265 return domain.Passkey{}, err
177266 }
178- if name == "" {
179- name = domain.DefaultPasskeyName
180- }
181- parsed, err := domain.ParsePasskeyName(name) // before take: a bad name does not burn the ceremony
267+ parsed, err := parsePasskeyName(name) // before take: a bad name does not burn the ceremony
182268 if err != nil {
183- return domain.Passkey{}, fmt.Errorf("passkey name: %w", err)
269+ return domain.Passkey{}, err
184270 }
271+ log := zerowrap.FromCtx(ctx)
185272 data, ok := s.take(ceremonyID, ceremonyRegister)
186273 if !ok {
187- return domain.Passkey{}, fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
274+ err := fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
275+ log.Info().Err(err).Msg("passkey registration refused")
276+ return domain.Passkey{}, err
188277 }
189278 handle, err := s.store.UserHandle(ctx)
190279 if err != nil {
@@ -192,6 +281,10 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c
192281 }
193282 pk, err := s.verifier.FinishRegistration(handle, data, response)
194283 if err != nil {
284+ if errors.Is(err, domain.ErrUnauthenticated) {
285+ // The reason is for the log (it holds no key); the caller gets a 400.
286+ log.Info().Err(err).Msg("passkey registration refused")
287+ }
195288 return domain.Passkey{}, err
196289 }
197290 if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {
@@ -213,19 +306,20 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c
213306 if err := s.store.Create(ctx, pk); err != nil {
214307 return domain.Passkey{}, err
215308 }
216- log := zerowrap.FromCtx(ctx)
217309 log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")
218310 return pk, nil
219311 }
220312
221-// BeginLogin starts a passkey sign-in. Anonymous callers may. It returns the
313+// BeginLogin starts a passkey sign-in. Anonymous callers may. client is the
314+// address the web resolved for the caller (the one the failure limiter uses);
315+// it bounds how many sign-ins one client keeps waiting. It returns the
222316 // ceremony identifier and the options for the browser.
223-func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) {
317+func (s *Passkeys) BeginLogin(_ context.Context, client string) (string, []byte, error) {
224318 options, data, err := s.verifier.BeginLogin()
225319 if err != nil {
226320 return "", nil, err
227321 }
228- id, err := s.begin(ceremonyLogin, data)
322+ id, err := s.begin(ceremonyLogin, clientKey(client), data)
229323 if err != nil {
230324 return "", nil, err
231325 }
@@ -238,30 +332,57 @@ func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) {
238332 // cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the
239333 // store is returned as it is, so that the caller fails closed.
240334 func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {
335+ log := zerowrap.FromCtx(ctx)
241336 data, ok := s.take(ceremonyID, ceremonyLogin)
242337 if !ok {
338+ log.Info().Msg("passkey sign-in refused: the ceremony expired or is unknown")
243339 return "", time.Time{}, domain.ErrUnauthenticated
244340 }
245341 handle, err := s.store.UserHandle(ctx)
246342 if err != nil {
247343 return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)
248344 }
249- log := zerowrap.FromCtx(ctx)
250- pk, err := s.verifier.FinishLogin(handle, data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) })
345+ var looked []byte // the credential the response named, for the log
346+ pk, err := s.verifier.FinishLogin(handle, data, response, func(id []byte) (domain.Passkey, error) {
347+ looked = bytes.Clone(id)
348+ return s.store.Get(ctx, id)
349+ })
251350 if err != nil {
252351 if errors.Is(err, domain.ErrPasskeyCloned) {
253- log.Warn().Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
352+ ev := log.Warn().Err(err)
353+ if looked != nil {
354+ ev = ev.Str("passkey", passkeyRef(looked))
355+ }
356+ ev.Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
254357 }
255358 if errors.Is(err, domain.ErrUnauthenticated) {
359+ // The reason is for the log (it holds no key); the caller learns nothing.
360+ log.Info().Err(err).Msg("passkey sign-in refused")
256361 return "", time.Time{}, domain.ErrUnauthenticated
257362 }
258363 return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)
259364 }
260365
261366 // Recording the use and opening the session are one step with respect to
262- // Remove, so that a passkey removed meanwhile cannot sign in.
367+ // Remove and to a concurrent login, so that a passkey removed meanwhile
368+ // cannot sign in, nor two logins with the same counter.
263369 s.writeMu.Lock()
264370 defer s.writeMu.Unlock()
371+ cur, err := s.store.Get(ctx, pk.ID)
372+ if err != nil {
373+ if errors.Is(err, domain.ErrNotFound) {
374+ log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the passkey was removed meanwhile")
375+ return "", time.Time{}, domain.ErrUnauthenticated
376+ }
377+ return "", time.Time{}, fmt.Errorf("read passkey: %w", err)
378+ }
379+ // The verifier compared the counter with the one it was shown; another
380+ // login may have moved it since. A counter that does not advance is the
381+ // clone signal (synced passkeys report 0 and are exempt).
382+ if cur.SignCount != 0 && pk.SignCount <= cur.SignCount {
383+ log.Warn().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
384+ return "", time.Time{}, domain.ErrUnauthenticated
385+ }
265386 pk.LastUsedAt = s.clock.Now().UTC()
266387 if err := s.store.Update(ctx, pk); err != nil {
267388 if errors.Is(err, domain.ErrNotFound) {
@@ -307,6 +428,18 @@ func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) er
307428 return nil
308429 }
309430
431+// parsePasskeyName validates a name; an empty one is domain.DefaultPasskeyName.
432+func parsePasskeyName(name string) (domain.PasskeyName, error) {
433+ if name == "" {
434+ name = domain.DefaultPasskeyName
435+ }
436+ parsed, err := domain.ParsePasskeyName(name)
437+ if err != nil {
438+ return domain.PasskeyName{}, fmt.Errorf("passkey name: %w", err)
439+ }
440+ return parsed, nil
441+}
442+
310443 // passkeyRef is how logs name a passkey: the first 8 hex characters of the
311444 // SHA-256 of its credential id.
312445 func passkeyRef(id []byte) string {
@@ -3,6 +3,7 @@
3 package usecase3 package usecase
4 4
5 import (5 import (
6+ "bytes"
6 "context"7 "context"
7 "crypto/rand"8 "crypto/rand"
8 "crypto/sha256"9 "crypto/sha256"
@@ -10,6 +11,7 @@ import (
10 "encoding/hex"11 "encoding/hex"
11 "errors"12 "errors"
12 "fmt"13 "fmt"
14+ "net/netip"
13 "sync"15 "sync"
14 "time"16 "time"
15 17
@@ -28,9 +30,12 @@ const (
28 // finished. Only the owner starts one.30 // finished. Only the owner starts one.
29 MaxPendingPasskeyRegistrations = 1631 MaxPendingPasskeyRegistrations = 16
30 // MaxPendingPasskeyLogins bounds the logins that wait to be finished.32 // MaxPendingPasskeyLogins bounds the logins that wait to be finished.
31- // Anyone may start one, so on overflow the oldest is dropped instead of33+ // Anyone may start one, so a login is never refused: on overflow one is
32- // refusing: anonymous callers cannot lock the owner out.34+ // dropped instead, and anonymous callers cannot lock the owner out.
33 MaxPendingPasskeyLogins = 25635 MaxPendingPasskeyLogins = 256
36+ // MaxPendingPasskeyLoginsPerClient bounds the logins one client (see
37+ // clientKey) may have waiting. A client over it loses its own oldest.
38+ MaxPendingPasskeyLoginsPerClient = 8
34 39
35 // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.40 // ceremonyIDBytes is the size of a ceremony identifier: 256 bits.
36 ceremonyIDBytes = 3241 ceremonyIDBytes = 32
@@ -49,6 +54,7 @@ type ceremony struct {
49 data []byte // opaque, from the verifier54 data []byte // opaque, from the verifier
50 expires time.Time55 expires time.Time
51 seq uint64 // creation order56 seq uint64 // creation order
57+ client string // who began a login (clientKey); "" for a registration
52 }58 }
53 59
54 // Passkeys are the use cases of the owner's passkeys: registering, signing in,60 // Passkeys are the use cases of the owner's passkeys: registering, signing in,
@@ -76,10 +82,48 @@ func NewPasskeys(store ports.PasskeyStore, verifier ports.PasskeyVerifier, sessi
76 return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}82 return &Passkeys{store: store, verifier: verifier, sessions: sessions, clock: clock, ceremonies: map[[sha256.Size]byte]ceremony{}}
77 }83 }
78 84
85+// clientKey is what a login ceremony is attributed to: the address of the
86+// client as the web resolved it (trusted proxies applied), an IPv6 address
87+// reduced to its /64 network, since one host owns a whole /64. Anything that is
88+// not an address is kept as it is.
89+func clientKey(addr string) string {
90+ a, err := netip.ParseAddr(addr)
91+ if err != nil {
92+ return addr
93+ }
94+ a = a.Unmap()
95+ if a.Is6() {
96+ p, err := a.Prefix(64)
97+ if err != nil {
98+ return addr
99+ }
100+ return p.String()
101+ }
102+ return a.String()
103+}
104+
105+// pendingLogins is what begin knows of the logins that wait, per client.
106+type pendingLogins struct {
107+ count int
108+ oldest [sha256.Size]byte
109+ oldestSeq uint64
110+ hasOldestSeq bool
111+}
112+
113+func (p *pendingLogins) add(h [sha256.Size]byte, seq uint64) {
114+ p.count++
115+ if !p.hasOldestSeq || seq < p.oldestSeq {
116+ p.oldest, p.oldestSeq, p.hasOldestSeq = h, seq, true
117+ }
118+}
119+
79 // begin keeps a ceremony and returns its identifier. It sweeps the expired120 // begin keeps a ceremony and returns its identifier. It sweeps the expired
80-// ones first. A registration over the limit gives domain.ErrResourceExhausted;121+// ones first. A registration over the limit gives domain.ErrResourceExhausted.
81-// a login over it drops the oldest login.122+// A login is never refused, but it can cost another login its place: over the
82-func (s *Passkeys) begin(kind ceremonyKind, data []byte) (string, error) {123+// per-client cap it drops that client's own oldest; over the global cap it drops
124+// the oldest of the client with the most logins waiting. A client that floods
125+// therefore only ever loses its own ceremonies, never another client's.
126+func (s *Passkeys) begin(kind ceremonyKind, client string, data []byte) (string, error) {
83 var raw [ceremonyIDBytes]byte127 var raw [ceremonyIDBytes]byte
84 if _, err := rand.Read(raw[:]); err != nil {128 if _, err := rand.Read(raw[:]); err != nil {
85 return "", fmt.Errorf("generate passkey ceremony: %w", err)129 return "", fmt.Errorf("generate passkey ceremony: %w", err)
@@ -89,37 +133,77 @@ func (s *Passkeys) begin(kind ceremonyKind, data []byte) (string, error) {
89 133
90 s.mu.Lock()134 s.mu.Lock()
91 defer s.mu.Unlock()135 defer s.mu.Unlock()
92- pending := 0136+ registrations := 0
93- var oldest [sha256.Size]byte137+ logins := 0
94- var oldestSeq uint64138+ byClient := map[string]*pendingLogins{}
95 for h, c := range s.ceremonies {139 for h, c := range s.ceremonies {
96 if !now.Before(c.expires) {140 if !now.Before(c.expires) {
97 delete(s.ceremonies, h)141 delete(s.ceremonies, h)
98 continue142 continue
99 }143 }
100- if c.kind != kind {144+ switch c.kind {
101- continue145+ case ceremonyRegister:
102- }146+ registrations++
103- if pending == 0 || c.seq < oldestSeq {147+ case ceremonyLogin:
104- oldest, oldestSeq = h, c.seq148+ logins++
149+ pl := byClient[c.client]
150+ if pl == nil {
151+ pl = &pendingLogins{}
152+ byClient[c.client] = pl
153+ }
154+ pl.add(h, c.seq)
105 }155 }
106- pending++
107 }156 }
108 switch kind {157 switch kind {
109 case ceremonyRegister:158 case ceremonyRegister:
110- if pending >= MaxPendingPasskeyRegistrations {159+ if registrations >= MaxPendingPasskeyRegistrations {
111 return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)160 return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations)
112 }161 }
113 case ceremonyLogin:162 case ceremonyLogin:
114- if pending >= MaxPendingPasskeyLogins {163+ if pl := byClient[client]; pl != nil && pl.count >= MaxPendingPasskeyLoginsPerClient {
115- delete(s.ceremonies, oldest)164+ delete(s.ceremonies, pl.oldest)
165+ logins--
166+ // The client is back under its cap; the global step below needs
167+ // its new count and its new oldest.
168+ pl.count--
169+ s.refreshOldest(pl, client)
170+ }
171+ if logins >= MaxPendingPasskeyLogins {
172+ // The client with the most waiting loses its oldest; among equals,
173+ // the one whose oldest waited longest.
174+ var victim *pendingLogins
175+ for _, pl := range byClient {
176+ if pl.count == 0 {
177+ continue
178+ }
179+ if victim == nil || pl.count > victim.count || (pl.count == victim.count && pl.oldestSeq < victim.oldestSeq) {
180+ victim = pl
181+ }
182+ }
183+ if victim != nil {
184+ delete(s.ceremonies, victim.oldest)
185+ }
116 }186 }
117 }187 }
118 s.seq++188 s.seq++
119- s.ceremonies[sha256.Sum256([]byte(id))] = ceremony{kind: kind, data: data, expires: now.Add(PasskeyCeremonyTTL), seq: s.seq}189+ s.ceremonies[sha256.Sum256([]byte(id))] = ceremony{kind: kind, data: data, expires: now.Add(PasskeyCeremonyTTL), seq: s.seq, client: client}
120 return id, nil190 return id, nil
121 }191 }
122 192
193+// refreshOldest recomputes the oldest login of a client after one was dropped.
194+// The caller holds s.mu.
195+func (s *Passkeys) refreshOldest(pl *pendingLogins, client string) {
196+ pl.hasOldestSeq = false
197+ for h, c := range s.ceremonies {
198+ if c.kind != ceremonyLogin || c.client != client {
199+ continue
200+ }
201+ if !pl.hasOldestSeq || c.seq < pl.oldestSeq {
202+ pl.oldest, pl.oldestSeq, pl.hasOldestSeq = h, c.seq, true
203+ }
204+ }
205+}
206+
123 // take removes the ceremony and returns its data. It is single use, also when207 // take removes the ceremony and returns its data. It is single use, also when
124 // the ceremony expired or is of another kind. ok is false for an unknown,208 // the ceremony expired or is of another kind. ok is false for an unknown,
125 // expired or mismatched ceremony.209 // expired or mismatched ceremony.
@@ -135,14 +219,19 @@ func (s *Passkeys) take(id string, kind ceremonyKind) (data []byte, ok bool) {
135 return c.data, true219 return c.data, true
136 }220 }
137 221
138-// BeginRegistration starts adding a passkey. Only the owner may. It gives222+// BeginRegistration starts adding a passkey. Only the owner may. name is
139-// domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too many223+// checked now, so that a name the browser would be asked about and then refused
140-// registrations wait. It returns the ceremony identifier and the options for224+// costs no ceremony (an empty name means the default); finish checks it again.
141-// the browser.225+// It gives domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too
142-func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal) (string, []byte, error) {226+// many registrations wait. It returns the ceremony identifier and the options
227+// for the browser.
228+func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal, name string) (string, []byte, error) {
143 if err := requireOwner(p); err != nil {229 if err := requireOwner(p); err != nil {
144 return "", nil, err230 return "", nil, err
145 }231 }
232+ if _, err := parsePasskeyName(name); err != nil {
233+ return "", nil, err
234+ }
146 existing, err := s.store.List(ctx)235 existing, err := s.store.List(ctx)
147 if err != nil {236 if err != nil {
148 return "", nil, fmt.Errorf("list passkeys: %w", err)237 return "", nil, fmt.Errorf("list passkeys: %w", err)
@@ -158,7 +247,7 @@ func (s *Passkeys) BeginRegistration(ctx context.Context, p domain.Principal) (s
158 if err != nil {247 if err != nil {
159 return "", nil, err248 return "", nil, err
160 }249 }
161- id, err := s.begin(ceremonyRegister, data)250+ id, err := s.begin(ceremonyRegister, "", data)
162 if err != nil {251 if err != nil {
163 return "", nil, err252 return "", nil, err
164 }253 }
@@ -175,16 +264,16 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c
175 if err := requireOwner(p); err != nil {264 if err := requireOwner(p); err != nil {
176 return domain.Passkey{}, err265 return domain.Passkey{}, err
177 }266 }
178- if name == "" {267+ parsed, err := parsePasskeyName(name) // before take: a bad name does not burn the ceremony
179- name = domain.DefaultPasskeyName
180- }
181- parsed, err := domain.ParsePasskeyName(name) // before take: a bad name does not burn the ceremony
182 if err != nil {268 if err != nil {
183- return domain.Passkey{}, fmt.Errorf("passkey name: %w", err)269+ return domain.Passkey{}, err
184 }270 }
271+ log := zerowrap.FromCtx(ctx)
185 data, ok := s.take(ceremonyID, ceremonyRegister)272 data, ok := s.take(ceremonyID, ceremonyRegister)
186 if !ok {273 if !ok {
187- return domain.Passkey{}, fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)274+ err := fmt.Errorf("%w: passkey registration expired or unknown, start again", domain.ErrUnauthenticated)
275+ log.Info().Err(err).Msg("passkey registration refused")
276+ return domain.Passkey{}, err
188 }277 }
189 handle, err := s.store.UserHandle(ctx)278 handle, err := s.store.UserHandle(ctx)
190 if err != nil {279 if err != nil {
@@ -192,6 +281,10 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c
192 }281 }
193 pk, err := s.verifier.FinishRegistration(handle, data, response)282 pk, err := s.verifier.FinishRegistration(handle, data, response)
194 if err != nil {283 if err != nil {
284+ if errors.Is(err, domain.ErrUnauthenticated) {
285+ // The reason is for the log (it holds no key); the caller gets a 400.
286+ log.Info().Err(err).Msg("passkey registration refused")
287+ }
195 return domain.Passkey{}, err288 return domain.Passkey{}, err
196 }289 }
197 if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {290 if n := len(pk.ID); n == 0 || n > domain.MaxPasskeyIDLen {
@@ -213,19 +306,20 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c
213 if err := s.store.Create(ctx, pk); err != nil {306 if err := s.store.Create(ctx, pk); err != nil {
214 return domain.Passkey{}, err307 return domain.Passkey{}, err
215 }308 }
216- log := zerowrap.FromCtx(ctx)
217 log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")309 log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added")
218 return pk, nil310 return pk, nil
219 }311 }
220 312
221-// BeginLogin starts a passkey sign-in. Anonymous callers may. It returns the313+// BeginLogin starts a passkey sign-in. Anonymous callers may. client is the
314+// address the web resolved for the caller (the one the failure limiter uses);
315+// it bounds how many sign-ins one client keeps waiting. It returns the
222 // ceremony identifier and the options for the browser.316 // ceremony identifier and the options for the browser.
223-func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) {317+func (s *Passkeys) BeginLogin(_ context.Context, client string) (string, []byte, error) {
224 options, data, err := s.verifier.BeginLogin()318 options, data, err := s.verifier.BeginLogin()
225 if err != nil {319 if err != nil {
226 return "", nil, err320 return "", nil, err
227 }321 }
228- id, err := s.begin(ceremonyLogin, data)322+ id, err := s.begin(ceremonyLogin, clientKey(client), data)
229 if err != nil {323 if err != nil {
230 return "", nil, err324 return "", nil, err
231 }325 }
@@ -238,30 +332,57 @@ func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) {
238 // cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the332 // cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the
239 // store is returned as it is, so that the caller fails closed.333 // store is returned as it is, so that the caller fails closed.
240 func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {334 func (s *Passkeys) FinishLogin(ctx context.Context, ceremonyID string, response []byte) (token string, expiresAt time.Time, err error) {
335+ log := zerowrap.FromCtx(ctx)
241 data, ok := s.take(ceremonyID, ceremonyLogin)336 data, ok := s.take(ceremonyID, ceremonyLogin)
242 if !ok {337 if !ok {
338+ log.Info().Msg("passkey sign-in refused: the ceremony expired or is unknown")
243 return "", time.Time{}, domain.ErrUnauthenticated339 return "", time.Time{}, domain.ErrUnauthenticated
244 }340 }
245 handle, err := s.store.UserHandle(ctx)341 handle, err := s.store.UserHandle(ctx)
246 if err != nil {342 if err != nil {
247 return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)343 return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err)
248 }344 }
249- log := zerowrap.FromCtx(ctx)345+ var looked []byte // the credential the response named, for the log
250- pk, err := s.verifier.FinishLogin(handle, data, response, func(id []byte) (domain.Passkey, error) { return s.store.Get(ctx, id) })346+ pk, err := s.verifier.FinishLogin(handle, data, response, func(id []byte) (domain.Passkey, error) {
347+ looked = bytes.Clone(id)
348+ return s.store.Get(ctx, id)
349+ })
251 if err != nil {350 if err != nil {
252 if errors.Is(err, domain.ErrPasskeyCloned) {351 if errors.Is(err, domain.ErrPasskeyCloned) {
253- log.Warn().Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")352+ ev := log.Warn().Err(err)
353+ if looked != nil {
354+ ev = ev.Str("passkey", passkeyRef(looked))
355+ }
356+ ev.Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
254 }357 }
255 if errors.Is(err, domain.ErrUnauthenticated) {358 if errors.Is(err, domain.ErrUnauthenticated) {
359+ // The reason is for the log (it holds no key); the caller learns nothing.
360+ log.Info().Err(err).Msg("passkey sign-in refused")
256 return "", time.Time{}, domain.ErrUnauthenticated361 return "", time.Time{}, domain.ErrUnauthenticated
257 }362 }
258 return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)363 return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err)
259 }364 }
260 365
261 // Recording the use and opening the session are one step with respect to366 // Recording the use and opening the session are one step with respect to
262- // Remove, so that a passkey removed meanwhile cannot sign in.367+ // Remove and to a concurrent login, so that a passkey removed meanwhile
368+ // cannot sign in, nor two logins with the same counter.
263 s.writeMu.Lock()369 s.writeMu.Lock()
264 defer s.writeMu.Unlock()370 defer s.writeMu.Unlock()
371+ cur, err := s.store.Get(ctx, pk.ID)
372+ if err != nil {
373+ if errors.Is(err, domain.ErrNotFound) {
374+ log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the passkey was removed meanwhile")
375+ return "", time.Time{}, domain.ErrUnauthenticated
376+ }
377+ return "", time.Time{}, fmt.Errorf("read passkey: %w", err)
378+ }
379+ // The verifier compared the counter with the one it was shown; another
380+ // login may have moved it since. A counter that does not advance is the
381+ // clone signal (synced passkeys report 0 and are exempt).
382+ if cur.SignCount != 0 && pk.SignCount <= cur.SignCount {
383+ log.Warn().Str("passkey", passkeyRef(pk.ID)).Msg("passkey sign-in refused: the counter did not advance, the authenticator may be cloned")
384+ return "", time.Time{}, domain.ErrUnauthenticated
385+ }
265 pk.LastUsedAt = s.clock.Now().UTC()386 pk.LastUsedAt = s.clock.Now().UTC()
266 if err := s.store.Update(ctx, pk); err != nil {387 if err := s.store.Update(ctx, pk); err != nil {
267 if errors.Is(err, domain.ErrNotFound) {388 if errors.Is(err, domain.ErrNotFound) {
@@ -307,6 +428,18 @@ func (s *Passkeys) Remove(ctx context.Context, p domain.Principal, id []byte) er
307 return nil428 return nil
308 }429 }
309 430
431+// parsePasskeyName validates a name; an empty one is domain.DefaultPasskeyName.
432+func parsePasskeyName(name string) (domain.PasskeyName, error) {
433+ if name == "" {
434+ name = domain.DefaultPasskeyName
435+ }
436+ parsed, err := domain.ParsePasskeyName(name)
437+ if err != nil {
438+ return domain.PasskeyName{}, fmt.Errorf("passkey name: %w", err)
439+ }
440+ return parsed, nil
441+}
442+
310 // passkeyRef is how logs name a passkey: the first 8 hex characters of the443 // passkeyRef is how logs name a passkey: the first 8 hex characters of the
311 // SHA-256 of its credential id.444 // SHA-256 of its credential id.
312 func passkeyRef(id []byte) string {445 func passkeyRef(id []byte) string {
M internal/usecase/passkeys_test.go +363 -59
@@ -5,14 +5,18 @@ package usecase_test
55 import (
66 "bytes"
77 "context"
8+ "crypto/sha256"
9+ "encoding/hex"
810 "errors"
911 "fmt"
1012 "slices"
13+ "strings"
1114 "sync"
1215 "sync/atomic"
1316 "testing"
1417 "time"
1518
19+ "github.com/bnema/zerowrap"
1620 "github.com/stretchr/testify/assert"
1721 "github.com/stretchr/testify/mock"
1822 "github.com/stretchr/testify/require"
@@ -126,9 +130,12 @@ func (f *passkeyFixture) acceptRegistration(id string) {
126130 }).Maybe()
127131 }
128132
129-// acceptLogin makes the verifier accept a login with the stored passkey `id`,
130-// reporting the sign count.
131-func (f *passkeyFixture) acceptLogin(id string, signCount uint32) {
133+// acceptLogin makes the verifier accept a login with the stored passkey `id`.
134+// first is the counter of the first sign-in; like a real authenticator, each
135+// later one reports a higher counter. 0 means a synced passkey: always 0.
136+func (f *passkeyFixture) acceptLogin(id string, first uint32) {
137+ var next atomic.Uint32
138+ next.Store(first)
132139 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
133140 func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
134141 if !bytes.HasPrefix(ceremony, []byte("login-")) {
@@ -138,7 +145,9 @@ func (f *passkeyFixture) acceptLogin(id string, signCount uint32) {
138145 if err != nil {
139146 return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)
140147 }
141- p.SignCount = signCount
148+ if first != 0 {
149+ p.SignCount = next.Add(1) - 1
150+ }
142151 p.BackupState = true
143152 return p, nil
144153 }).Maybe()
@@ -178,13 +187,13 @@ func TestPasskeysAreOwnerOnly(t *testing.T) {
178187 assert.ErrorIs(t, err, tc.err, what)
179188 }
180189 }
181- _, _, err := f.passkeys.BeginRegistration(ctx, tc.p)
190+ _, _, err := f.passkeys.BeginRegistration(ctx, tc.p, "")
182191 check("begin registration", err)
183192 _, err = f.passkeys.List(ctx, tc.p)
184193 check("list", err)
185194
186195 // A ceremony begun by the owner cannot be finished by anyone else.
187- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
196+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
188197 require.NoError(t, err)
189198 _, err = f.passkeys.FinishRegistration(ctx, tc.p, id, "n", []byte("{}"))
190199 check("finish registration", err)
@@ -205,7 +214,7 @@ func TestPasskeysAreOwnerOnly(t *testing.T) {
205214 func TestBeginLoginIsAnonymous(t *testing.T) {
206215 t.Parallel()
207216 f := newPasskeyFixture(t)
208- id, options, err := f.passkeys.BeginLogin(context.Background())
217+ id, options, err := f.passkeys.BeginLogin(context.Background(), "192.0.2.1")
209218 require.NoError(t, err)
210219 assert.Len(t, id, 43, "256 bits")
211220 assert.JSONEq(t, `{"publicKey":{}}`, string(options))
@@ -217,7 +226,7 @@ func TestRegistration(t *testing.T) {
217226 f := newPasskeyFixture(t)
218227 f.acceptRegistration("cred-1")
219228
220- id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
229+ id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
221230 require.NoError(t, err)
222231 assert.JSONEq(t, `{"publicKey":{}}`, string(options))
223232
@@ -243,7 +252,7 @@ func TestRegistrationDefaultNameAndBadName(t *testing.T) {
243252 f := newPasskeyFixture(t)
244253 f.acceptRegistration("cred-1")
245254
246- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
255+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
247256 require.NoError(t, err)
248257 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "evil \u202e name", []byte(`{}`))
249258 require.ErrorIs(t, err, domain.ErrInvalidText)
@@ -262,7 +271,7 @@ func TestRegistrationDuplicate(t *testing.T) {
262271 f.add("cred-1")
263272 f.acceptRegistration("cred-1")
264273
265- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
274+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
266275 require.NoError(t, err)
267276 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "dup", []byte(`{}`))
268277 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
@@ -276,7 +285,7 @@ func TestRegistrationRefusedByVerifier(t *testing.T) {
276285 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
277286 Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))
278287
279- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
288+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
280289 require.NoError(t, err)
281290 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
282291 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -289,7 +298,7 @@ func TestRegistrationRefusesAnIdTooLong(t *testing.T) {
289298 f := newPasskeyFixture(t)
290299 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
291300 Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)
292- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
301+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
293302 require.NoError(t, err)
294303 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
295304 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -306,7 +315,7 @@ func TestBeginRegistrationExcludesExisting(t *testing.T) {
306315 f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {
307316 return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"
308317 })).Return([]byte(`{}`), []byte("register-x"), nil).Once()
309- _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner())
318+ _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner(), "")
310319 require.NoError(t, err)
311320 }
312321
@@ -319,13 +328,13 @@ func TestMaxPasskeys(t *testing.T) {
319328 f.add(fmt.Sprintf("p%02d", i))
320329 }
321330 // A ceremony begun with room for one more.
322- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
331+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
323332 require.NoError(t, err)
324333 f.add("last")
325334 assert.Equal(t, domain.MaxPasskeys, f.count())
326335
327336 // Full: no new ceremony, and the one in flight is refused at the end.
328- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
337+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
329338 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
330339 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
331340 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
@@ -333,7 +342,7 @@ func TestMaxPasskeys(t *testing.T) {
333342
334343 // Removing one makes room again.
335344 require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))
336- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
345+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
337346 assert.NoError(t, err)
338347 }
339348
@@ -346,9 +355,9 @@ func TestCeremonyExpiry(t *testing.T) {
346355 f.acceptLogin("login-cred", 1)
347356
348357 // One second short of the TTL still works.
349- reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
358+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
350359 require.NoError(t, err)
351- login, _, err := f.passkeys.BeginLogin(ctx)
360+ login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
352361 require.NoError(t, err)
353362 f.advance(usecase.PasskeyCeremonyTTL - time.Second)
354363 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))
@@ -357,9 +366,9 @@ func TestCeremonyExpiry(t *testing.T) {
357366 assert.NoError(t, err)
358367
359368 // At the TTL it is gone, and it stays gone.
360- reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
369+ reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
361370 require.NoError(t, err)
362- login, _, err = f.passkeys.BeginLogin(ctx)
371+ login, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
363372 require.NoError(t, err)
364373 assert.Equal(t, 2*time.Minute, usecase.PasskeyCeremonyTTL)
365374 f.advance(usecase.PasskeyCeremonyTTL)
@@ -375,13 +384,13 @@ func TestExpiredRegistrationsDoNotCount(t *testing.T) {
375384 ctx := context.Background()
376385 f := newPasskeyFixture(t)
377386 for range usecase.MaxPendingPasskeyRegistrations {
378- _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
387+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
379388 require.NoError(t, err)
380389 }
381- _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
390+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
382391 require.ErrorIs(t, err, domain.ErrResourceExhausted)
383392 f.advance(usecase.PasskeyCeremonyTTL)
384- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
393+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
385394 assert.NoError(t, err)
386395 }
387396
@@ -392,49 +401,139 @@ func TestPendingRegistrationsAreBounded(t *testing.T) {
392401 f.acceptRegistration("cred-1")
393402 var ids []string
394403 for range usecase.MaxPendingPasskeyRegistrations {
395- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
404+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
396405 require.NoError(t, err)
397406 ids = append(ids, id)
398407 }
399408 assert.Len(t, ids, 16)
400- _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
409+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
401410 require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")
402411
403412 // Pending logins do not use the registrations' room, and a finished
404413 // registration frees one.
405- _, _, err = f.passkeys.BeginLogin(ctx)
414+ _, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
406415 require.NoError(t, err)
407416 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], "x", []byte(`{}`))
408417 require.NoError(t, err)
409- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())
418+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
410419 assert.NoError(t, err)
411420 }
412421
413-// Anonymous callers cannot fill the room: the 257th pending login evicts the
414-// oldest, which then fails; the newest works.
415-func TestPendingLoginsEvictTheOldest(t *testing.T) {
422+// loginIDs begins n logins for the client, a millisecond apart so that the order
423+// of creation is the order of time too.
424+func (f *passkeyFixture) loginIDs(t *testing.T, client string, n int) []string {
425+ t.Helper()
426+ var ids []string
427+ for range n {
428+ id, _, err := f.passkeys.BeginLogin(context.Background(), client)
429+ require.NoError(t, err, "a login is never refused")
430+ ids = append(ids, id)
431+ f.advance(time.Millisecond)
432+ }
433+ return ids
434+}
435+
436+// finishes tells whether the ceremony still opens a session.
437+func (f *passkeyFixture) finishes(id string) bool {
438+ _, _, err := f.passkeys.FinishLogin(context.Background(), id, []byte(`{}`))
439+ return err == nil
440+}
441+
442+// A client keeps at most 8 logins waiting: the 9th costs it its own oldest.
443+func TestPendingLoginsPerClientCap(t *testing.T) {
416444 t.Parallel()
417- ctx := context.Background()
418445 f := newPasskeyFixture(t)
419446 f.add("cred-1")
420447 f.acceptLogin("cred-1", 1)
421448
422- var ids []string
423- for range usecase.MaxPendingPasskeyLogins {
424- id, _, err := f.passkeys.BeginLogin(ctx)
425- require.NoError(t, err)
426- ids = append(ids, id)
427- f.advance(time.Millisecond) // the order of creation is the order of time too
449+ other := f.loginIDs(t, "198.51.100.7", 1)[0]
450+ ids := f.loginIDs(t, "192.0.2.1", usecase.MaxPendingPasskeyLoginsPerClient)
451+ assert.Equal(t, 8, usecase.MaxPendingPasskeyLoginsPerClient)
452+ newest := f.loginIDs(t, "192.0.2.1", 1)[0]
453+
454+ assert.False(t, f.finishes(ids[0]), "the 9th evicted this client's oldest")
455+ assert.True(t, f.finishes(ids[1]), "the next oldest is still there")
456+ assert.True(t, f.finishes(newest), "the newest works")
457+ assert.True(t, f.finishes(other), "another client's ceremony is untouched")
458+}
459+
460+// One address flooding can never evict another address's ceremony.
461+func TestLoginFloodDoesNotEvictAnotherClient(t *testing.T) {
462+ t.Parallel()
463+ f := newPasskeyFixture(t)
464+ f.add("cred-1")
465+ f.acceptLogin("cred-1", 1)
466+
467+ owner := f.loginIDs(t, "198.51.100.7", 1)[0]
468+ f.loginIDs(t, "192.0.2.1", 2*usecase.MaxPendingPasskeyLogins)
469+ assert.True(t, f.finishes(owner), "the owner's ceremony from another address still works")
470+}
471+
472+// Over the global cap, the client with the most waiting loses its oldest; a
473+// client with one ceremony is never the victim.
474+func TestPendingLoginsGlobalOverflow(t *testing.T) {
475+ t.Parallel()
476+ f := newPasskeyFixture(t)
477+ f.add("cred-1")
478+ f.acceptLogin("cred-1", 1)
479+
480+ owner := f.loginIDs(t, "198.51.100.7", 1)[0]
481+ perClient := usecase.MaxPendingPasskeyLoginsPerClient
482+ var first, second []string
483+ n := 0
484+ for total := 1; total+perClient <= usecase.MaxPendingPasskeyLogins; total += perClient {
485+ ids := f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)
486+ if n == 0 {
487+ first = ids
488+ }
489+ if n == 1 {
490+ second = ids
491+ }
492+ n++
493+ }
494+ // 1 + 31*8 = 249 waiting; top up to the cap with a client below the cap.
495+ small := f.loginIDs(t, "10.1.0.1", usecase.MaxPendingPasskeyLogins-1-n*perClient)
496+ require.Len(t, small, 7)
497+
498+ // The 257th comes from a client that has none: nobody is refused, the
499+ // oldest of a client with 8 waiting goes (the one that waited longest).
500+ fresh := f.loginIDs(t, "203.0.113.9", 1)[0]
501+ assert.False(t, f.finishes(first[0]), "the oldest of the fullest, oldest client was evicted")
502+ assert.True(t, f.finishes(first[1]))
503+ assert.True(t, f.finishes(second[0]), "other full clients keep theirs")
504+ assert.True(t, f.finishes(owner), "a client with one ceremony is never the victim")
505+ assert.True(t, f.finishes(small[0]), "nor one below the others")
506+ assert.True(t, f.finishes(fresh), "the new login works")
507+}
508+
509+// IPv6 clients share a cap per /64; an IPv4-mapped address is the IPv4 one.
510+func TestPendingLoginsClientKey(t *testing.T) {
511+ t.Parallel()
512+ f := newPasskeyFixture(t)
513+ f.add("cred-1")
514+ f.acceptLogin("cred-1", 1)
515+ perClient := usecase.MaxPendingPasskeyLoginsPerClient
516+
517+ // Different addresses of one /64 count together.
518+ ids := f.loginIDs(t, "2001:db8:1:2::1", perClient)
519+ next := f.loginIDs(t, "2001:db8:1:2:ffff:ffff:ffff:ffff", 1)[0]
520+ assert.False(t, f.finishes(ids[0]), "the same /64: the 9th evicted the oldest")
521+ assert.True(t, f.finishes(next))
522+
523+ // Another /64 has its own cap.
524+ other := f.loginIDs(t, "2001:db8:1:3::1", perClient)
525+ for _, id := range other {
526+ assert.True(t, f.finishes(id), "a different /64 is another client")
428527 }
429- newest, _, err := f.passkeys.BeginLogin(ctx)
430- require.NoError(t, err, "the 257th is not refused")
431528
432- _, _, err = f.passkeys.FinishLogin(ctx, ids[0], []byte(`{}`))
433- assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the oldest was evicted")
434- _, _, err = f.passkeys.FinishLogin(ctx, ids[1], []byte(`{}`))
435- assert.NoError(t, err, "the second oldest is still there")
436- _, _, err = f.passkeys.FinishLogin(ctx, newest, []byte(`{}`))
437- assert.NoError(t, err, "the newest works")
529+ // ::ffff:192.0.2.1 and 192.0.2.1 are one client.
530+ v4 := f.loginIDs(t, "192.0.2.1", perClient)
531+ f.loginIDs(t, "::ffff:192.0.2.1", 1)
532+ assert.False(t, f.finishes(v4[0]))
533+ // Two IPv4 addresses of one /24 are two clients.
534+ a := f.loginIDs(t, "192.0.2.50", perClient)
535+ f.loginIDs(t, "192.0.2.51", perClient)
536+ assert.True(t, f.finishes(a[0]))
438537 }
439538
440539 // Logins never push out a registration, and registrations never push out a login.
@@ -446,9 +545,9 @@ func TestCeremonyKindsAreSeparate(t *testing.T) {
446545 f.acceptLogin("cred-1", 1)
447546 f.acceptRegistration("cred-2")
448547
449- reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())
548+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
450549 require.NoError(t, err)
451- login, _, err := f.passkeys.BeginLogin(ctx)
550+ login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
452551 require.NoError(t, err)
453552
454553 // A registration ceremony does not finish a login, nor the reverse; the
@@ -482,7 +581,7 @@ func TestLoginIssuesASession(t *testing.T) {
482581 f.add("cred-1")
483582 f.acceptLogin("cred-1", 9)
484583
485- id, _, err := f.passkeys.BeginLogin(ctx)
584+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
486585 require.NoError(t, err)
487586 f.advance(10 * time.Second)
488587 token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
@@ -506,6 +605,191 @@ func TestLoginIssuesASession(t *testing.T) {
506605 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
507606 }
508607
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+// GO-001: the reason of a refusal is logged (info), never answered, and holds
634+// no key material.
635+func TestRefusalsAreLogged(t *testing.T) {
636+ t.Parallel()
637+
638+ t.Run("sign-in", func(t *testing.T) {
639+ t.Parallel()
640+ ctx, logs := logCtx(t)
641+ f := newPasskeyFixture(t)
642+ f.add("cred-1")
643+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
644+ Return(domain.Passkey{}, fmt.Errorf("%w: passkey login: origin mismatch", domain.ErrUnauthenticated))
645+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
646+ require.NoError(t, err)
647+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{"secret":"response-bytes"}`))
648+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
649+ out := logs.String()
650+ assert.Contains(t, out, `"message":"passkey sign-in refused"`)
651+ assert.Contains(t, out, `"level":"info"`)
652+ assert.Contains(t, out, "origin mismatch")
653+ assert.NotContains(t, out, "response-bytes")
654+ })
655+
656+ t.Run("sign-in with an unknown ceremony", func(t *testing.T) {
657+ t.Parallel()
658+ ctx, logs := logCtx(t)
659+ f := newPasskeyFixture(t)
660+ _, _, err := f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`))
661+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
662+ assert.Contains(t, logs.String(), "passkey sign-in refused")
663+ })
664+
665+ t.Run("registration", func(t *testing.T) {
666+ t.Parallel()
667+ ctx, logs := logCtx(t)
668+ f := newPasskeyFixture(t)
669+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
670+ Return(domain.Passkey{}, fmt.Errorf("%w: passkey registration: user verification missing", domain.ErrUnauthenticated))
671+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
672+ require.NoError(t, err)
673+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{"secret":"response-bytes"}`))
674+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
675+ out := logs.String()
676+ assert.Contains(t, out, `"message":"passkey registration refused"`)
677+ assert.Contains(t, out, "user verification missing")
678+ assert.NotContains(t, out, "response-bytes")
679+
680+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "unknown", "x", []byte(`{}`))
681+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
682+ assert.Equal(t, 2, strings.Count(logs.String(), "passkey registration refused"))
683+ })
684+}
685+
686+// GO-002: the clone warning says which passkey, by its short reference.
687+func TestCloneWarningNamesThePasskey(t *testing.T) {
688+ t.Parallel()
689+ ctx, logs := logCtx(t)
690+ f := newPasskeyFixture(t)
691+ f.add("cred-1")
692+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
693+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
694+ _, err := lookup([]byte("cred-1"))
695+ require.NoError(t, err)
696+ return domain.Passkey{}, domain.ErrPasskeyCloned
697+ })
698+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
699+ require.NoError(t, err)
700+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
701+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
702+
703+ sum := sha256.Sum256([]byte("cred-1"))
704+ ref := hex.EncodeToString(sum[:])[:8]
705+ out := logs.String()
706+ assert.Contains(t, out, `"level":"warn"`)
707+ assert.Contains(t, out, `"passkey":"`+ref+`"`)
708+ assert.Contains(t, out, "may be cloned")
709+ assert.NotContains(t, out, "cred-1", "the credential id itself is never logged")
710+}
711+
712+// GO-003: two logins verified against the same stored counter cannot both
713+// succeed. The verifier saw counter 5; by the time the second one writes, the
714+// first has stored 6.
715+func TestLoginRefusesACounterThatDidNotAdvance(t *testing.T) {
716+ t.Parallel()
717+ ctx := context.Background()
718+
719+ t.Run("another login moved the counter meanwhile", func(t *testing.T) {
720+ t.Parallel()
721+ f := newPasskeyFixture(t)
722+ stored := f.add("cred-1")
723+ stored.SignCount = 5
724+ f.mu.Lock()
725+ f.stored["cred-1"] = stored
726+ f.mu.Unlock()
727+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
728+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
729+ p, err := lookup([]byte("cred-1"))
730+ require.NoError(t, err)
731+ p.SignCount = 6 // what the response reports, against the 5 it was shown
732+ // A concurrent login stores 6 before this one gets the lock.
733+ f.mu.Lock()
734+ f.stored["cred-1"] = p
735+ f.mu.Unlock()
736+ return p, nil
737+ })
738+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
739+ require.NoError(t, err)
740+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
741+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
742+ assert.Empty(t, token, "no session")
743+ f.mu.Lock()
744+ assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded")
745+ f.mu.Unlock()
746+ })
747+
748+ t.Run("a counter that advances is accepted", func(t *testing.T) {
749+ t.Parallel()
750+ f := newPasskeyFixture(t)
751+ p := f.add("cred-1")
752+ p.SignCount = 5
753+ f.mu.Lock()
754+ f.stored["cred-1"] = p
755+ f.mu.Unlock()
756+ f.acceptLogin("cred-1", 6)
757+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
758+ require.NoError(t, err)
759+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
760+ assert.NoError(t, err)
761+ })
762+
763+ t.Run("a synced passkey reports 0 and is exempt", func(t *testing.T) {
764+ t.Parallel()
765+ f := newPasskeyFixture(t)
766+ f.add("cred-1") // stored counter 0
767+ f.acceptLogin("cred-1", 0)
768+ for range 2 {
769+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
770+ require.NoError(t, err)
771+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
772+ assert.NoError(t, err)
773+ }
774+ })
775+
776+ t.Run("equal counters are refused when the stored one is not 0", func(t *testing.T) {
777+ t.Parallel()
778+ f := newPasskeyFixture(t)
779+ p := f.add("cred-1")
780+ p.SignCount = 5
781+ f.mu.Lock()
782+ f.stored["cred-1"] = p
783+ f.mu.Unlock()
784+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
785+ Return(domain.Passkey{ID: []byte("cred-1"), SignCount: 5}, nil)
786+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
787+ require.NoError(t, err)
788+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
789+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
790+ })
791+}
792+
509793 func TestLoginRefusals(t *testing.T) {
510794 t.Parallel()
511795 ctx := context.Background()
@@ -514,7 +798,7 @@ func TestLoginRefusals(t *testing.T) {
514798 t.Parallel()
515799 f := newPasskeyFixture(t)
516800 f.acceptLogin("ghost", 1)
517- id, _, err := f.passkeys.BeginLogin(ctx)
801+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
518802 require.NoError(t, err)
519803 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
520804 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -526,7 +810,7 @@ func TestLoginRefusals(t *testing.T) {
526810 f.add("cred-1")
527811 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
528812 Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))
529- id, _, err := f.passkeys.BeginLogin(ctx)
813+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
530814 require.NoError(t, err)
531815 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
532816 require.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -539,7 +823,7 @@ func TestLoginRefusals(t *testing.T) {
539823 p := f.add("cred-1")
540824 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
541825 Return(domain.Passkey{}, domain.ErrPasskeyCloned)
542- id, _, err := f.passkeys.BeginLogin(ctx)
826+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
543827 require.NoError(t, err)
544828 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
545829 require.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -554,7 +838,7 @@ func TestLoginRefusals(t *testing.T) {
554838 boom := errors.New("disk on fire")
555839 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
556840 Return(domain.Passkey{}, boom)
557- id, _, err := f.passkeys.BeginLogin(ctx)
841+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
558842 require.NoError(t, err)
559843 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
560844 require.ErrorIs(t, err, boom)
@@ -568,13 +852,14 @@ func TestLoginRefusals(t *testing.T) {
568852 store := mocks.NewPasskeyStore(t)
569853 boom := errors.New("disk on fire")
570854 store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)
855+ store.EXPECT().Get(mock.Anything, []byte("cred-1")).Return(domain.Passkey{ID: []byte("cred-1")}, nil)
571856 store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)
572857 verifier := mocks.NewPasskeyVerifier(t)
573858 verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)
574859 verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
575860 Return(domain.Passkey{ID: []byte("cred-1")}, nil)
576861 pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)
577- id, _, err := pk.BeginLogin(ctx)
862+ id, _, err := pk.BeginLogin(ctx, "192.0.2.1")
578863 require.NoError(t, err)
579864 token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
580865 require.ErrorIs(t, err, boom)
@@ -594,7 +879,7 @@ func TestLoginRefusals(t *testing.T) {
594879 f.mu.Unlock()
595880 return p, nil
596881 })
597- id, _, err := f.passkeys.BeginLogin(ctx)
882+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
598883 require.NoError(t, err)
599884 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
600885 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -612,7 +897,7 @@ func TestRemoveBumpsTheEpoch(t *testing.T) {
612897 f.add("cred-2")
613898 f.acceptLogin("cred-1", 1)
614899
615- id, _, err := f.passkeys.BeginLogin(ctx)
900+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
616901 require.NoError(t, err)
617902 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
618903 require.NoError(t, err)
@@ -632,7 +917,7 @@ func TestRemoveBumpsTheEpoch(t *testing.T) {
632917 }
633918
634919 // A new login works with the epoch it finds now.
635- id, _, err = f.passkeys.BeginLogin(ctx)
920+ id, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
636921 require.NoError(t, err)
637922 token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
638923 require.NoError(t, err)
@@ -680,19 +965,38 @@ func TestPasskeysConcurrent(t *testing.T) {
680965 ctx := context.Background()
681966 f := newPasskeyFixture(t)
682967 f.add("cred-1")
683- f.acceptLogin("cred-1", 1)
968+ f.acceptLogin("cred-1", 0) // synced: counters racing in order would be refused, rightly
684969 var wg sync.WaitGroup
685970 for range 32 {
686971 wg.Add(1)
687972 go func() {
688973 defer wg.Done()
689- id, _, err := f.passkeys.BeginLogin(ctx)
974+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
690975 if assert.NoError(t, err) {
691976 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
692977 assert.NoError(t, err)
693978 }
694- _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner())
979+ _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
695980 }()
696981 }
697982 wg.Wait()
698983 }
984+
985+// TS-001: a name that breaks the rules is refused before any ceremony starts,
986+// and does not use up one of the pending registrations.
987+func TestBeginRegistrationChecksTheName(t *testing.T) {
988+ t.Parallel()
989+ ctx := context.Background()
990+ f := newPasskeyFixture(t)
991+ for _, name := range []string{"evil \u202e name", "a\x00b", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "two\nlines", "\xff"} {
992+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), name)
993+ assert.ErrorIs(t, err, domain.ErrInvalidText, "%q", name)
994+ }
995+ // None of those counted as a pending registration.
996+ for range usecase.MaxPendingPasskeyRegistrations {
997+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "ok")
998+ require.NoError(t, err)
999+ }
1000+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
1001+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1002+}
@@ -5,14 +5,18 @@ package usecase_test
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "crypto/sha256"
9+ "encoding/hex"
8 "errors"10 "errors"
9 "fmt"11 "fmt"
10 "slices"12 "slices"
13+ "strings"
11 "sync"14 "sync"
12 "sync/atomic"15 "sync/atomic"
13 "testing"16 "testing"
14 "time"17 "time"
15 18
19+ "github.com/bnema/zerowrap"
16 "github.com/stretchr/testify/assert"20 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/mock"21 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"22 "github.com/stretchr/testify/require"
@@ -126,9 +130,12 @@ func (f *passkeyFixture) acceptRegistration(id string) {
126 }).Maybe()130 }).Maybe()
127 }131 }
128 132
129-// acceptLogin makes the verifier accept a login with the stored passkey `id`,133+// acceptLogin makes the verifier accept a login with the stored passkey `id`.
130-// reporting the sign count.134+// first is the counter of the first sign-in; like a real authenticator, each
131-func (f *passkeyFixture) acceptLogin(id string, signCount uint32) {135+// later one reports a higher counter. 0 means a synced passkey: always 0.
136+func (f *passkeyFixture) acceptLogin(id string, first uint32) {
137+ var next atomic.Uint32
138+ next.Store(first)
132 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(139 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
133 func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {140 func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
134 if !bytes.HasPrefix(ceremony, []byte("login-")) {141 if !bytes.HasPrefix(ceremony, []byte("login-")) {
@@ -138,7 +145,9 @@ func (f *passkeyFixture) acceptLogin(id string, signCount uint32) {
138 if err != nil {145 if err != nil {
139 return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)146 return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err)
140 }147 }
141- p.SignCount = signCount148+ if first != 0 {
149+ p.SignCount = next.Add(1) - 1
150+ }
142 p.BackupState = true151 p.BackupState = true
143 return p, nil152 return p, nil
144 }).Maybe()153 }).Maybe()
@@ -178,13 +187,13 @@ func TestPasskeysAreOwnerOnly(t *testing.T) {
178 assert.ErrorIs(t, err, tc.err, what)187 assert.ErrorIs(t, err, tc.err, what)
179 }188 }
180 }189 }
181- _, _, err := f.passkeys.BeginRegistration(ctx, tc.p)190+ _, _, err := f.passkeys.BeginRegistration(ctx, tc.p, "")
182 check("begin registration", err)191 check("begin registration", err)
183 _, err = f.passkeys.List(ctx, tc.p)192 _, err = f.passkeys.List(ctx, tc.p)
184 check("list", err)193 check("list", err)
185 194
186 // A ceremony begun by the owner cannot be finished by anyone else.195 // A ceremony begun by the owner cannot be finished by anyone else.
187- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())196+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
188 require.NoError(t, err)197 require.NoError(t, err)
189 _, err = f.passkeys.FinishRegistration(ctx, tc.p, id, "n", []byte("{}"))198 _, err = f.passkeys.FinishRegistration(ctx, tc.p, id, "n", []byte("{}"))
190 check("finish registration", err)199 check("finish registration", err)
@@ -205,7 +214,7 @@ func TestPasskeysAreOwnerOnly(t *testing.T) {
205 func TestBeginLoginIsAnonymous(t *testing.T) {214 func TestBeginLoginIsAnonymous(t *testing.T) {
206 t.Parallel()215 t.Parallel()
207 f := newPasskeyFixture(t)216 f := newPasskeyFixture(t)
208- id, options, err := f.passkeys.BeginLogin(context.Background())217+ id, options, err := f.passkeys.BeginLogin(context.Background(), "192.0.2.1")
209 require.NoError(t, err)218 require.NoError(t, err)
210 assert.Len(t, id, 43, "256 bits")219 assert.Len(t, id, 43, "256 bits")
211 assert.JSONEq(t, `{"publicKey":{}}`, string(options))220 assert.JSONEq(t, `{"publicKey":{}}`, string(options))
@@ -217,7 +226,7 @@ func TestRegistration(t *testing.T) {
217 f := newPasskeyFixture(t)226 f := newPasskeyFixture(t)
218 f.acceptRegistration("cred-1")227 f.acceptRegistration("cred-1")
219 228
220- id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner())229+ id, options, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
221 require.NoError(t, err)230 require.NoError(t, err)
222 assert.JSONEq(t, `{"publicKey":{}}`, string(options))231 assert.JSONEq(t, `{"publicKey":{}}`, string(options))
223 232
@@ -243,7 +252,7 @@ func TestRegistrationDefaultNameAndBadName(t *testing.T) {
243 f := newPasskeyFixture(t)252 f := newPasskeyFixture(t)
244 f.acceptRegistration("cred-1")253 f.acceptRegistration("cred-1")
245 254
246- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())255+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
247 require.NoError(t, err)256 require.NoError(t, err)
248 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "evil \u202e name", []byte(`{}`))257 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "evil \u202e name", []byte(`{}`))
249 require.ErrorIs(t, err, domain.ErrInvalidText)258 require.ErrorIs(t, err, domain.ErrInvalidText)
@@ -262,7 +271,7 @@ func TestRegistrationDuplicate(t *testing.T) {
262 f.add("cred-1")271 f.add("cred-1")
263 f.acceptRegistration("cred-1")272 f.acceptRegistration("cred-1")
264 273
265- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())274+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
266 require.NoError(t, err)275 require.NoError(t, err)
267 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "dup", []byte(`{}`))276 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "dup", []byte(`{}`))
268 assert.ErrorIs(t, err, domain.ErrAlreadyExists)277 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
@@ -276,7 +285,7 @@ func TestRegistrationRefusedByVerifier(t *testing.T) {
276 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).285 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
277 Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))286 Return(domain.Passkey{}, fmt.Errorf("%w: bad origin", domain.ErrUnauthenticated))
278 287
279- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())288+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
280 require.NoError(t, err)289 require.NoError(t, err)
281 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))290 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
282 assert.ErrorIs(t, err, domain.ErrUnauthenticated)291 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -289,7 +298,7 @@ func TestRegistrationRefusesAnIdTooLong(t *testing.T) {
289 f := newPasskeyFixture(t)298 f := newPasskeyFixture(t)
290 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).299 f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
291 Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)300 Return(domain.Passkey{ID: make([]byte, domain.MaxPasskeyIDLen+1), PublicKey: []byte("k")}, nil)
292- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())301+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
293 require.NoError(t, err)302 require.NoError(t, err)
294 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))303 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
295 assert.ErrorIs(t, err, domain.ErrUnauthenticated)304 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -306,7 +315,7 @@ func TestBeginRegistrationExcludesExisting(t *testing.T) {
306 f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {315 f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool {
307 return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"316 return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b"
308 })).Return([]byte(`{}`), []byte("register-x"), nil).Once()317 })).Return([]byte(`{}`), []byte("register-x"), nil).Once()
309- _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner())318+ _, _, err := f.passkeys.BeginRegistration(context.Background(), domain.Owner(), "")
310 require.NoError(t, err)319 require.NoError(t, err)
311 }320 }
312 321
@@ -319,13 +328,13 @@ func TestMaxPasskeys(t *testing.T) {
319 f.add(fmt.Sprintf("p%02d", i))328 f.add(fmt.Sprintf("p%02d", i))
320 }329 }
321 // A ceremony begun with room for one more.330 // A ceremony begun with room for one more.
322- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())331+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
323 require.NoError(t, err)332 require.NoError(t, err)
324 f.add("last")333 f.add("last")
325 assert.Equal(t, domain.MaxPasskeys, f.count())334 assert.Equal(t, domain.MaxPasskeys, f.count())
326 335
327 // Full: no new ceremony, and the one in flight is refused at the end.336 // Full: no new ceremony, and the one in flight is refused at the end.
328- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())337+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
329 assert.ErrorIs(t, err, domain.ErrResourceExhausted)338 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
330 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))339 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`))
331 assert.ErrorIs(t, err, domain.ErrResourceExhausted)340 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
@@ -333,7 +342,7 @@ func TestMaxPasskeys(t *testing.T) {
333 342
334 // Removing one makes room again.343 // Removing one makes room again.
335 require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))344 require.NoError(t, f.passkeys.Remove(ctx, domain.Owner(), []byte("last")))
336- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())345+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
337 assert.NoError(t, err)346 assert.NoError(t, err)
338 }347 }
339 348
@@ -346,9 +355,9 @@ func TestCeremonyExpiry(t *testing.T) {
346 f.acceptLogin("login-cred", 1)355 f.acceptLogin("login-cred", 1)
347 356
348 // One second short of the TTL still works.357 // One second short of the TTL still works.
349- reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())358+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
350 require.NoError(t, err)359 require.NoError(t, err)
351- login, _, err := f.passkeys.BeginLogin(ctx)360+ login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
352 require.NoError(t, err)361 require.NoError(t, err)
353 f.advance(usecase.PasskeyCeremonyTTL - time.Second)362 f.advance(usecase.PasskeyCeremonyTTL - time.Second)
354 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))363 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`))
@@ -357,9 +366,9 @@ func TestCeremonyExpiry(t *testing.T) {
357 assert.NoError(t, err)366 assert.NoError(t, err)
358 367
359 // At the TTL it is gone, and it stays gone.368 // At the TTL it is gone, and it stays gone.
360- reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())369+ reg, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
361 require.NoError(t, err)370 require.NoError(t, err)
362- login, _, err = f.passkeys.BeginLogin(ctx)371+ login, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
363 require.NoError(t, err)372 require.NoError(t, err)
364 assert.Equal(t, 2*time.Minute, usecase.PasskeyCeremonyTTL)373 assert.Equal(t, 2*time.Minute, usecase.PasskeyCeremonyTTL)
365 f.advance(usecase.PasskeyCeremonyTTL)374 f.advance(usecase.PasskeyCeremonyTTL)
@@ -375,13 +384,13 @@ func TestExpiredRegistrationsDoNotCount(t *testing.T) {
375 ctx := context.Background()384 ctx := context.Background()
376 f := newPasskeyFixture(t)385 f := newPasskeyFixture(t)
377 for range usecase.MaxPendingPasskeyRegistrations {386 for range usecase.MaxPendingPasskeyRegistrations {
378- _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())387+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
379 require.NoError(t, err)388 require.NoError(t, err)
380 }389 }
381- _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())390+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
382 require.ErrorIs(t, err, domain.ErrResourceExhausted)391 require.ErrorIs(t, err, domain.ErrResourceExhausted)
383 f.advance(usecase.PasskeyCeremonyTTL)392 f.advance(usecase.PasskeyCeremonyTTL)
384- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())393+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
385 assert.NoError(t, err)394 assert.NoError(t, err)
386 }395 }
387 396
@@ -392,49 +401,139 @@ func TestPendingRegistrationsAreBounded(t *testing.T) {
392 f.acceptRegistration("cred-1")401 f.acceptRegistration("cred-1")
393 var ids []string402 var ids []string
394 for range usecase.MaxPendingPasskeyRegistrations {403 for range usecase.MaxPendingPasskeyRegistrations {
395- id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())404+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
396 require.NoError(t, err)405 require.NoError(t, err)
397 ids = append(ids, id)406 ids = append(ids, id)
398 }407 }
399 assert.Len(t, ids, 16)408 assert.Len(t, ids, 16)
400- _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())409+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
401 require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")410 require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused")
402 411
403 // Pending logins do not use the registrations' room, and a finished412 // Pending logins do not use the registrations' room, and a finished
404 // registration frees one.413 // registration frees one.
405- _, _, err = f.passkeys.BeginLogin(ctx)414+ _, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
406 require.NoError(t, err)415 require.NoError(t, err)
407 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], "x", []byte(`{}`))416 _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], "x", []byte(`{}`))
408 require.NoError(t, err)417 require.NoError(t, err)
409- _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner())418+ _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
410 assert.NoError(t, err)419 assert.NoError(t, err)
411 }420 }
412 421
413-// Anonymous callers cannot fill the room: the 257th pending login evicts the422+// loginIDs begins n logins for the client, a millisecond apart so that the order
414-// oldest, which then fails; the newest works.423+// of creation is the order of time too.
415-func TestPendingLoginsEvictTheOldest(t *testing.T) {424+func (f *passkeyFixture) loginIDs(t *testing.T, client string, n int) []string {
425+ t.Helper()
426+ var ids []string
427+ for range n {
428+ id, _, err := f.passkeys.BeginLogin(context.Background(), client)
429+ require.NoError(t, err, "a login is never refused")
430+ ids = append(ids, id)
431+ f.advance(time.Millisecond)
432+ }
433+ return ids
434+}
435+
436+// finishes tells whether the ceremony still opens a session.
437+func (f *passkeyFixture) finishes(id string) bool {
438+ _, _, err := f.passkeys.FinishLogin(context.Background(), id, []byte(`{}`))
439+ return err == nil
440+}
441+
442+// A client keeps at most 8 logins waiting: the 9th costs it its own oldest.
443+func TestPendingLoginsPerClientCap(t *testing.T) {
416 t.Parallel()444 t.Parallel()
417- ctx := context.Background()
418 f := newPasskeyFixture(t)445 f := newPasskeyFixture(t)
419 f.add("cred-1")446 f.add("cred-1")
420 f.acceptLogin("cred-1", 1)447 f.acceptLogin("cred-1", 1)
421 448
422- var ids []string449+ other := f.loginIDs(t, "198.51.100.7", 1)[0]
423- for range usecase.MaxPendingPasskeyLogins {450+ ids := f.loginIDs(t, "192.0.2.1", usecase.MaxPendingPasskeyLoginsPerClient)
424- id, _, err := f.passkeys.BeginLogin(ctx)451+ assert.Equal(t, 8, usecase.MaxPendingPasskeyLoginsPerClient)
425- require.NoError(t, err)452+ newest := f.loginIDs(t, "192.0.2.1", 1)[0]
426- ids = append(ids, id)453+
427- f.advance(time.Millisecond) // the order of creation is the order of time too454+ assert.False(t, f.finishes(ids[0]), "the 9th evicted this client's oldest")
455+ assert.True(t, f.finishes(ids[1]), "the next oldest is still there")
456+ assert.True(t, f.finishes(newest), "the newest works")
457+ assert.True(t, f.finishes(other), "another client's ceremony is untouched")
458+}
459+
460+// One address flooding can never evict another address's ceremony.
461+func TestLoginFloodDoesNotEvictAnotherClient(t *testing.T) {
462+ t.Parallel()
463+ f := newPasskeyFixture(t)
464+ f.add("cred-1")
465+ f.acceptLogin("cred-1", 1)
466+
467+ owner := f.loginIDs(t, "198.51.100.7", 1)[0]
468+ f.loginIDs(t, "192.0.2.1", 2*usecase.MaxPendingPasskeyLogins)
469+ assert.True(t, f.finishes(owner), "the owner's ceremony from another address still works")
470+}
471+
472+// Over the global cap, the client with the most waiting loses its oldest; a
473+// client with one ceremony is never the victim.
474+func TestPendingLoginsGlobalOverflow(t *testing.T) {
475+ t.Parallel()
476+ f := newPasskeyFixture(t)
477+ f.add("cred-1")
478+ f.acceptLogin("cred-1", 1)
479+
480+ owner := f.loginIDs(t, "198.51.100.7", 1)[0]
481+ perClient := usecase.MaxPendingPasskeyLoginsPerClient
482+ var first, second []string
483+ n := 0
484+ for total := 1; total+perClient <= usecase.MaxPendingPasskeyLogins; total += perClient {
485+ ids := f.loginIDs(t, fmt.Sprintf("10.0.%d.1", n), perClient)
486+ if n == 0 {
487+ first = ids
488+ }
489+ if n == 1 {
490+ second = ids
491+ }
492+ n++
493+ }
494+ // 1 + 31*8 = 249 waiting; top up to the cap with a client below the cap.
495+ small := f.loginIDs(t, "10.1.0.1", usecase.MaxPendingPasskeyLogins-1-n*perClient)
496+ require.Len(t, small, 7)
497+
498+ // The 257th comes from a client that has none: nobody is refused, the
499+ // oldest of a client with 8 waiting goes (the one that waited longest).
500+ fresh := f.loginIDs(t, "203.0.113.9", 1)[0]
501+ assert.False(t, f.finishes(first[0]), "the oldest of the fullest, oldest client was evicted")
502+ assert.True(t, f.finishes(first[1]))
503+ assert.True(t, f.finishes(second[0]), "other full clients keep theirs")
504+ assert.True(t, f.finishes(owner), "a client with one ceremony is never the victim")
505+ assert.True(t, f.finishes(small[0]), "nor one below the others")
506+ assert.True(t, f.finishes(fresh), "the new login works")
507+}
508+
509+// IPv6 clients share a cap per /64; an IPv4-mapped address is the IPv4 one.
510+func TestPendingLoginsClientKey(t *testing.T) {
511+ t.Parallel()
512+ f := newPasskeyFixture(t)
513+ f.add("cred-1")
514+ f.acceptLogin("cred-1", 1)
515+ perClient := usecase.MaxPendingPasskeyLoginsPerClient
516+
517+ // Different addresses of one /64 count together.
518+ ids := f.loginIDs(t, "2001:db8:1:2::1", perClient)
519+ next := f.loginIDs(t, "2001:db8:1:2:ffff:ffff:ffff:ffff", 1)[0]
520+ assert.False(t, f.finishes(ids[0]), "the same /64: the 9th evicted the oldest")
521+ assert.True(t, f.finishes(next))
522+
523+ // Another /64 has its own cap.
524+ other := f.loginIDs(t, "2001:db8:1:3::1", perClient)
525+ for _, id := range other {
526+ assert.True(t, f.finishes(id), "a different /64 is another client")
428 }527 }
429- newest, _, err := f.passkeys.BeginLogin(ctx)
430- require.NoError(t, err, "the 257th is not refused")
431 528
432- _, _, err = f.passkeys.FinishLogin(ctx, ids[0], []byte(`{}`))529+ // ::ffff:192.0.2.1 and 192.0.2.1 are one client.
433- assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the oldest was evicted")530+ v4 := f.loginIDs(t, "192.0.2.1", perClient)
434- _, _, err = f.passkeys.FinishLogin(ctx, ids[1], []byte(`{}`))531+ f.loginIDs(t, "::ffff:192.0.2.1", 1)
435- assert.NoError(t, err, "the second oldest is still there")532+ assert.False(t, f.finishes(v4[0]))
436- _, _, err = f.passkeys.FinishLogin(ctx, newest, []byte(`{}`))533+ // Two IPv4 addresses of one /24 are two clients.
437- assert.NoError(t, err, "the newest works")534+ a := f.loginIDs(t, "192.0.2.50", perClient)
535+ f.loginIDs(t, "192.0.2.51", perClient)
536+ assert.True(t, f.finishes(a[0]))
438 }537 }
439 538
440 // Logins never push out a registration, and registrations never push out a login.539 // Logins never push out a registration, and registrations never push out a login.
@@ -446,9 +545,9 @@ func TestCeremonyKindsAreSeparate(t *testing.T) {
446 f.acceptLogin("cred-1", 1)545 f.acceptLogin("cred-1", 1)
447 f.acceptRegistration("cred-2")546 f.acceptRegistration("cred-2")
448 547
449- reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner())548+ reg, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
450 require.NoError(t, err)549 require.NoError(t, err)
451- login, _, err := f.passkeys.BeginLogin(ctx)550+ login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
452 require.NoError(t, err)551 require.NoError(t, err)
453 552
454 // A registration ceremony does not finish a login, nor the reverse; the553 // A registration ceremony does not finish a login, nor the reverse; the
@@ -482,7 +581,7 @@ func TestLoginIssuesASession(t *testing.T) {
482 f.add("cred-1")581 f.add("cred-1")
483 f.acceptLogin("cred-1", 9)582 f.acceptLogin("cred-1", 9)
484 583
485- id, _, err := f.passkeys.BeginLogin(ctx)584+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
486 require.NoError(t, err)585 require.NoError(t, err)
487 f.advance(10 * time.Second)586 f.advance(10 * time.Second)
488 token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))587 token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
@@ -506,6 +605,191 @@ func TestLoginIssuesASession(t *testing.T) {
506 assert.ErrorIs(t, err, domain.ErrUnauthenticated)605 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
507 }606 }
508 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+// GO-001: the reason of a refusal is logged (info), never answered, and holds
634+// no key material.
635+func TestRefusalsAreLogged(t *testing.T) {
636+ t.Parallel()
637+
638+ t.Run("sign-in", func(t *testing.T) {
639+ t.Parallel()
640+ ctx, logs := logCtx(t)
641+ f := newPasskeyFixture(t)
642+ f.add("cred-1")
643+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
644+ Return(domain.Passkey{}, fmt.Errorf("%w: passkey login: origin mismatch", domain.ErrUnauthenticated))
645+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
646+ require.NoError(t, err)
647+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{"secret":"response-bytes"}`))
648+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
649+ out := logs.String()
650+ assert.Contains(t, out, `"message":"passkey sign-in refused"`)
651+ assert.Contains(t, out, `"level":"info"`)
652+ assert.Contains(t, out, "origin mismatch")
653+ assert.NotContains(t, out, "response-bytes")
654+ })
655+
656+ t.Run("sign-in with an unknown ceremony", func(t *testing.T) {
657+ t.Parallel()
658+ ctx, logs := logCtx(t)
659+ f := newPasskeyFixture(t)
660+ _, _, err := f.passkeys.FinishLogin(ctx, "nope", []byte(`{}`))
661+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
662+ assert.Contains(t, logs.String(), "passkey sign-in refused")
663+ })
664+
665+ t.Run("registration", func(t *testing.T) {
666+ t.Parallel()
667+ ctx, logs := logCtx(t)
668+ f := newPasskeyFixture(t)
669+ f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything).
670+ Return(domain.Passkey{}, fmt.Errorf("%w: passkey registration: user verification missing", domain.ErrUnauthenticated))
671+ id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
672+ require.NoError(t, err)
673+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{"secret":"response-bytes"}`))
674+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
675+ out := logs.String()
676+ assert.Contains(t, out, `"message":"passkey registration refused"`)
677+ assert.Contains(t, out, "user verification missing")
678+ assert.NotContains(t, out, "response-bytes")
679+
680+ _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), "unknown", "x", []byte(`{}`))
681+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
682+ assert.Equal(t, 2, strings.Count(logs.String(), "passkey registration refused"))
683+ })
684+}
685+
686+// GO-002: the clone warning says which passkey, by its short reference.
687+func TestCloneWarningNamesThePasskey(t *testing.T) {
688+ t.Parallel()
689+ ctx, logs := logCtx(t)
690+ f := newPasskeyFixture(t)
691+ f.add("cred-1")
692+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
693+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
694+ _, err := lookup([]byte("cred-1"))
695+ require.NoError(t, err)
696+ return domain.Passkey{}, domain.ErrPasskeyCloned
697+ })
698+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
699+ require.NoError(t, err)
700+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
701+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
702+
703+ sum := sha256.Sum256([]byte("cred-1"))
704+ ref := hex.EncodeToString(sum[:])[:8]
705+ out := logs.String()
706+ assert.Contains(t, out, `"level":"warn"`)
707+ assert.Contains(t, out, `"passkey":"`+ref+`"`)
708+ assert.Contains(t, out, "may be cloned")
709+ assert.NotContains(t, out, "cred-1", "the credential id itself is never logged")
710+}
711+
712+// GO-003: two logins verified against the same stored counter cannot both
713+// succeed. The verifier saw counter 5; by the time the second one writes, the
714+// first has stored 6.
715+func TestLoginRefusesACounterThatDidNotAdvance(t *testing.T) {
716+ t.Parallel()
717+ ctx := context.Background()
718+
719+ t.Run("another login moved the counter meanwhile", func(t *testing.T) {
720+ t.Parallel()
721+ f := newPasskeyFixture(t)
722+ stored := f.add("cred-1")
723+ stored.SignCount = 5
724+ f.mu.Lock()
725+ f.stored["cred-1"] = stored
726+ f.mu.Unlock()
727+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
728+ func(_, _, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) {
729+ p, err := lookup([]byte("cred-1"))
730+ require.NoError(t, err)
731+ p.SignCount = 6 // what the response reports, against the 5 it was shown
732+ // A concurrent login stores 6 before this one gets the lock.
733+ f.mu.Lock()
734+ f.stored["cred-1"] = p
735+ f.mu.Unlock()
736+ return p, nil
737+ })
738+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
739+ require.NoError(t, err)
740+ token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
741+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
742+ assert.Empty(t, token, "no session")
743+ f.mu.Lock()
744+ assert.True(t, f.stored["cred-1"].LastUsedAt.IsZero(), "nothing was recorded")
745+ f.mu.Unlock()
746+ })
747+
748+ t.Run("a counter that advances is accepted", func(t *testing.T) {
749+ t.Parallel()
750+ f := newPasskeyFixture(t)
751+ p := f.add("cred-1")
752+ p.SignCount = 5
753+ f.mu.Lock()
754+ f.stored["cred-1"] = p
755+ f.mu.Unlock()
756+ f.acceptLogin("cred-1", 6)
757+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
758+ require.NoError(t, err)
759+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
760+ assert.NoError(t, err)
761+ })
762+
763+ t.Run("a synced passkey reports 0 and is exempt", func(t *testing.T) {
764+ t.Parallel()
765+ f := newPasskeyFixture(t)
766+ f.add("cred-1") // stored counter 0
767+ f.acceptLogin("cred-1", 0)
768+ for range 2 {
769+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
770+ require.NoError(t, err)
771+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
772+ assert.NoError(t, err)
773+ }
774+ })
775+
776+ t.Run("equal counters are refused when the stored one is not 0", func(t *testing.T) {
777+ t.Parallel()
778+ f := newPasskeyFixture(t)
779+ p := f.add("cred-1")
780+ p.SignCount = 5
781+ f.mu.Lock()
782+ f.stored["cred-1"] = p
783+ f.mu.Unlock()
784+ f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
785+ Return(domain.Passkey{ID: []byte("cred-1"), SignCount: 5}, nil)
786+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
787+ require.NoError(t, err)
788+ _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
789+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
790+ })
791+}
792+
509 func TestLoginRefusals(t *testing.T) {793 func TestLoginRefusals(t *testing.T) {
510 t.Parallel()794 t.Parallel()
511 ctx := context.Background()795 ctx := context.Background()
@@ -514,7 +798,7 @@ func TestLoginRefusals(t *testing.T) {
514 t.Parallel()798 t.Parallel()
515 f := newPasskeyFixture(t)799 f := newPasskeyFixture(t)
516 f.acceptLogin("ghost", 1)800 f.acceptLogin("ghost", 1)
517- id, _, err := f.passkeys.BeginLogin(ctx)801+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
518 require.NoError(t, err)802 require.NoError(t, err)
519 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))803 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
520 assert.ErrorIs(t, err, domain.ErrUnauthenticated)804 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -526,7 +810,7 @@ func TestLoginRefusals(t *testing.T) {
526 f.add("cred-1")810 f.add("cred-1")
527 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).811 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
528 Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))812 Return(domain.Passkey{}, fmt.Errorf("%w: bad signature", domain.ErrUnauthenticated))
529- id, _, err := f.passkeys.BeginLogin(ctx)813+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
530 require.NoError(t, err)814 require.NoError(t, err)
531 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))815 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
532 require.ErrorIs(t, err, domain.ErrUnauthenticated)816 require.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -539,7 +823,7 @@ func TestLoginRefusals(t *testing.T) {
539 p := f.add("cred-1")823 p := f.add("cred-1")
540 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).824 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
541 Return(domain.Passkey{}, domain.ErrPasskeyCloned)825 Return(domain.Passkey{}, domain.ErrPasskeyCloned)
542- id, _, err := f.passkeys.BeginLogin(ctx)826+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
543 require.NoError(t, err)827 require.NoError(t, err)
544 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))828 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
545 require.ErrorIs(t, err, domain.ErrUnauthenticated)829 require.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -554,7 +838,7 @@ func TestLoginRefusals(t *testing.T) {
554 boom := errors.New("disk on fire")838 boom := errors.New("disk on fire")
555 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).839 f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
556 Return(domain.Passkey{}, boom)840 Return(domain.Passkey{}, boom)
557- id, _, err := f.passkeys.BeginLogin(ctx)841+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
558 require.NoError(t, err)842 require.NoError(t, err)
559 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))843 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
560 require.ErrorIs(t, err, boom)844 require.ErrorIs(t, err, boom)
@@ -568,13 +852,14 @@ func TestLoginRefusals(t *testing.T) {
568 store := mocks.NewPasskeyStore(t)852 store := mocks.NewPasskeyStore(t)
569 boom := errors.New("disk on fire")853 boom := errors.New("disk on fire")
570 store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)854 store.EXPECT().UserHandle(mock.Anything).Return(testHandle, nil)
855+ store.EXPECT().Get(mock.Anything, []byte("cred-1")).Return(domain.Passkey{ID: []byte("cred-1")}, nil)
571 store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)856 store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom)
572 verifier := mocks.NewPasskeyVerifier(t)857 verifier := mocks.NewPasskeyVerifier(t)
573 verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)858 verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil)
574 verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).859 verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).
575 Return(domain.Passkey{ID: []byte("cred-1")}, nil)860 Return(domain.Passkey{ID: []byte("cred-1")}, nil)
576 pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)861 pk := usecase.NewPasskeys(store, verifier, f.sessions, f.clock)
577- id, _, err := pk.BeginLogin(ctx)862+ id, _, err := pk.BeginLogin(ctx, "192.0.2.1")
578 require.NoError(t, err)863 require.NoError(t, err)
579 token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))864 token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`))
580 require.ErrorIs(t, err, boom)865 require.ErrorIs(t, err, boom)
@@ -594,7 +879,7 @@ func TestLoginRefusals(t *testing.T) {
594 f.mu.Unlock()879 f.mu.Unlock()
595 return p, nil880 return p, nil
596 })881 })
597- id, _, err := f.passkeys.BeginLogin(ctx)882+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
598 require.NoError(t, err)883 require.NoError(t, err)
599 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))884 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
600 assert.ErrorIs(t, err, domain.ErrUnauthenticated)885 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
@@ -612,7 +897,7 @@ func TestRemoveBumpsTheEpoch(t *testing.T) {
612 f.add("cred-2")897 f.add("cred-2")
613 f.acceptLogin("cred-1", 1)898 f.acceptLogin("cred-1", 1)
614 899
615- id, _, err := f.passkeys.BeginLogin(ctx)900+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
616 require.NoError(t, err)901 require.NoError(t, err)
617 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))902 token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
618 require.NoError(t, err)903 require.NoError(t, err)
@@ -632,7 +917,7 @@ func TestRemoveBumpsTheEpoch(t *testing.T) {
632 }917 }
633 918
634 // A new login works with the epoch it finds now.919 // A new login works with the epoch it finds now.
635- id, _, err = f.passkeys.BeginLogin(ctx)920+ id, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1")
636 require.NoError(t, err)921 require.NoError(t, err)
637 token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))922 token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
638 require.NoError(t, err)923 require.NoError(t, err)
@@ -680,19 +965,38 @@ func TestPasskeysConcurrent(t *testing.T) {
680 ctx := context.Background()965 ctx := context.Background()
681 f := newPasskeyFixture(t)966 f := newPasskeyFixture(t)
682 f.add("cred-1")967 f.add("cred-1")
683- f.acceptLogin("cred-1", 1)968+ f.acceptLogin("cred-1", 0) // synced: counters racing in order would be refused, rightly
684 var wg sync.WaitGroup969 var wg sync.WaitGroup
685 for range 32 {970 for range 32 {
686 wg.Add(1)971 wg.Add(1)
687 go func() {972 go func() {
688 defer wg.Done()973 defer wg.Done()
689- id, _, err := f.passkeys.BeginLogin(ctx)974+ id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1")
690 if assert.NoError(t, err) {975 if assert.NoError(t, err) {
691 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))976 _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`))
692 assert.NoError(t, err)977 assert.NoError(t, err)
693 }978 }
694- _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner())979+ _, _, _ = f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
695 }()980 }()
696 }981 }
697 wg.Wait()982 wg.Wait()
698 }983 }
984+
985+// TS-001: a name that breaks the rules is refused before any ceremony starts,
986+// and does not use up one of the pending registrations.
987+func TestBeginRegistrationChecksTheName(t *testing.T) {
988+ t.Parallel()
989+ ctx := context.Background()
990+ f := newPasskeyFixture(t)
991+ for _, name := range []string{"evil \u202e name", "a\x00b", strings.Repeat("a", domain.MaxPasskeyNameLen+1), "two\nlines", "\xff"} {
992+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), name)
993+ assert.ErrorIs(t, err, domain.ErrInvalidText, "%q", name)
994+ }
995+ // None of those counted as a pending registration.
996+ for range usecase.MaxPendingPasskeyRegistrations {
997+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "ok")
998+ require.NoError(t, err)
999+ }
1000+ _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "")
1001+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1002+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close