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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 144 | 144 | ||
| 145 | 145 | // passkeyLoginBegin is POST /-/passkeys/login/begin. | |
| 146 | 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 | 149 | return | |
| 149 | 150 | } | |
| 150 | 151 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 151 | 152 | defer cancel() | |
| 152 | - | ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx) | |
| 153 | + | ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, addr) | |
| 153 | 154 | if err != nil { | |
| 154 | 155 | s.jsonInternal(ctx, w, r, err) | |
| 155 | 156 | return | |
| @@ -169,14 +170,17 @@ type finishRequest struct { | |||
| 169 | 170 | // A refusal counts as a failed authentication of the client, whatever the | |
| 170 | 171 | // reason. | |
| 171 | 172 | 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. | |
| 176 | 174 | addr, blocked := s.blocked(w, r) | |
| 177 | 175 | if blocked { | |
| 178 | 176 | 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 | 184 | var req finishRequest | |
| 181 | 185 | if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 { | |
| 182 | 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 | 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 | 277 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 258 | 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 | 280 | if err != nil { | |
| 261 | 281 | s.registerError(ctx, w, r, err) | |
| 262 | 282 | 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 | return | 149 | 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 | return | 156 | return | ||
| @@ -169,14 +170,17 @@ type finishRequest struct { | |||||
| 169 | // A refusal counts as a failed authentication of the client, whatever the | 170 | // 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 | return | 176 | 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 finishRequest | 184 | 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 | return | 282 | return | ||
M internal/adapters/web/passkeys_test.go +70 -6
| @@ -136,7 +136,7 @@ func TestPasskeyAnonymousPostsAreExact(t *testing.T) { | |||
| 136 | 136 | func TestPasskeyLoginBegin(t *testing.T) { | |
| 137 | 137 | t.Parallel() | |
| 138 | 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 | 141 | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |
| 142 | 142 | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| @@ -149,7 +149,7 @@ func TestPasskeyLoginBegin(t *testing.T) { | |||
| 149 | 149 | func TestPasskeyLoginBeginFailure(t *testing.T) { | |
| 150 | 150 | t.Parallel() | |
| 151 | 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 | 153 | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |
| 154 | 154 | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 155 | 155 | assert.NotContains(t, rec.Body.String(), "secret detail") | |
| @@ -295,8 +295,8 @@ func TestPasskeyFinishMalformed(t *testing.T) { | |||
| 295 | 295 | func TestPasskeyRegistration(t *testing.T) { | |
| 296 | 296 | t.Parallel() | |
| 297 | 297 | 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", `{}`)) | |
| 300 | 300 | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 301 | 301 | assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String()) | |
| 302 | 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 | 340 | 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", `{}`)) | |
| 343 | 343 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 344 | 344 | } | |
| 345 | 345 | ||
| @@ -545,3 +545,67 @@ func TestIsPasskeyLoginPath(t *testing.T) { | |||
| 545 | 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 | + | } | |
| @@ -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 { | |||
| 82 | 82 | // register adds the authenticator's credential as the owner holding token. | |
| 83 | 83 | func (p *passkeyHarness) register(token, name string) page { | |
| 84 | 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 | 86 | require.Equal(p.t, http.StatusOK, begin.status, begin.body) | |
| 87 | 87 | var br beginReply | |
| 88 | 88 | require.NoError(p.t, json.Unmarshal([]byte(begin.body), &br)) | |
| @@ -135,7 +135,7 @@ func TestPasskeyEndToEnd(t *testing.T) { | |||
| 135 | 135 | ||
| 136 | 136 | // Anonymous: the list is a 404, registration is refused. | |
| 137 | 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 | 140 | // Sign in with the CLI link (the way in before any passkey), add a passkey. | |
| 141 | 141 | 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 beginReply | 87 | 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 { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | // 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) | |
| 54 | 54 | ||
| 55 | 55 | if len(ret) == 0 { | |
| 56 | 56 | panic("no return value specified for BeginLogin") | |
| @@ -59,23 +59,23 @@ func (_mock *WebPasskeyService) BeginLogin(ctx context.Context) (string, []byte, | |||
| 59 | 59 | var r0 string | |
| 60 | 60 | var r1 []byte | |
| 61 | 61 | 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) | |
| 64 | 64 | } | |
| 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) | |
| 67 | 67 | } else { | |
| 68 | 68 | r0 = ret.Get(0).(string) | |
| 69 | 69 | } | |
| 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) | |
| 72 | 72 | } else { | |
| 73 | 73 | if ret.Get(1) != nil { | |
| 74 | 74 | r1 = ret.Get(1).([]byte) | |
| 75 | 75 | } | |
| 76 | 76 | } | |
| 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) | |
| 79 | 79 | } else { | |
| 80 | 80 | r2 = ret.Error(2) | |
| 81 | 81 | } | |
| @@ -89,18 +89,24 @@ type WebPasskeyService_BeginLogin_Call struct { | |||
| 89 | 89 | ||
| 90 | 90 | // BeginLogin is a helper method to define mock.On call | |
| 91 | 91 | // - 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)} | |
| 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 | 98 | _c.Call.Run(func(args mock.Arguments) { | |
| 98 | 99 | var arg0 context.Context | |
| 99 | 100 | if args[0] != nil { | |
| 100 | 101 | arg0 = args[0].(context.Context) | |
| 101 | 102 | } | |
| 103 | + | var arg1 string | |
| 104 | + | if args[1] != nil { | |
| 105 | + | arg1 = args[1].(string) | |
| 106 | + | } | |
| 102 | 107 | run( | |
| 103 | 108 | arg0, | |
| 109 | + | arg1, | |
| 104 | 110 | ) | |
| 105 | 111 | }) | |
| 106 | 112 | return _c | |
| @@ -111,14 +117,14 @@ func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []b | |||
| 111 | 117 | 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 | 121 | _c.Call.Return(run) | |
| 116 | 122 | return _c | |
| 117 | 123 | } | |
| 118 | 124 | ||
| 119 | 125 | // 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) | |
| 122 | 128 | ||
| 123 | 129 | if len(ret) == 0 { | |
| 124 | 130 | panic("no return value specified for BeginRegistration") | |
| @@ -127,23 +133,23 @@ func (_mock *WebPasskeyService) BeginRegistration(ctx context.Context, p domain. | |||
| 127 | 133 | var r0 string | |
| 128 | 134 | var r1 []byte | |
| 129 | 135 | 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) | |
| 132 | 138 | } | |
| 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) | |
| 135 | 141 | } else { | |
| 136 | 142 | r0 = ret.Get(0).(string) | |
| 137 | 143 | } | |
| 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) | |
| 140 | 146 | } else { | |
| 141 | 147 | if ret.Get(1) != nil { | |
| 142 | 148 | r1 = ret.Get(1).([]byte) | |
| 143 | 149 | } | |
| 144 | 150 | } | |
| 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) | |
| 147 | 153 | } else { | |
| 148 | 154 | r2 = ret.Error(2) | |
| 149 | 155 | } | |
| @@ -158,11 +164,12 @@ type WebPasskeyService_BeginRegistration_Call struct { | |||
| 158 | 164 | // BeginRegistration is a helper method to define mock.On call | |
| 159 | 165 | // - ctx context.Context | |
| 160 | 166 | // - 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)} | |
| 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 | 173 | _c.Call.Run(func(args mock.Arguments) { | |
| 167 | 174 | var arg0 context.Context | |
| 168 | 175 | if args[0] != nil { | |
| @@ -172,9 +179,14 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Run(run func(ctx context.Con | |||
| 172 | 179 | if args[1] != nil { | |
| 173 | 180 | arg1 = args[1].(domain.Principal) | |
| 174 | 181 | } | |
| 182 | + | var arg2 string | |
| 183 | + | if args[2] != nil { | |
| 184 | + | arg2 = args[2].(string) | |
| 185 | + | } | |
| 175 | 186 | run( | |
| 176 | 187 | arg0, | |
| 177 | 188 | arg1, | |
| 189 | + | arg2, | |
| 178 | 190 | ) | |
| 179 | 191 | }) | |
| 180 | 192 | return _c | |
| @@ -185,7 +197,7 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, opti | |||
| 185 | 197 | 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 | 201 | _c.Call.Return(run) | |
| 190 | 202 | return _c | |
| 191 | 203 | } | |
| @@ -49,8 +49,8 @@ func (_m *WebPasskeyService) EXPECT() *WebPasskeyService_Expecter { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | // BeginLogin provides a mock function for the type WebPasskeyService | 51 | // 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 string | 59 | var r0 string | ||
| 60 | var r1 []byte | 60 | var r1 []byte | ||
| 61 | var r2 error | 61 | 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 call | 90 | // BeginLogin is a helper method to define mock.On call | ||
| 91 | // - ctx context.Context | 91 | // - 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.Context | 99 | 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 _c | 112 | return _c | ||
| @@ -111,14 +117,14 @@ func (_c *WebPasskeyService_BeginLogin_Call) Return(ceremony string, options []b | |||||
| 111 | return _c | 117 | 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 _c | 122 | return _c | ||
| 117 | } | 123 | } | ||
| 118 | 124 | ||||
| 119 | // BeginRegistration provides a mock function for the type WebPasskeyService | 125 | // 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 string | 133 | var r0 string | ||
| 128 | var r1 []byte | 134 | var r1 []byte | ||
| 129 | var r2 error | 135 | 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 call | 164 | // BeginRegistration is a helper method to define mock.On call | ||
| 159 | // - ctx context.Context | 165 | // - ctx context.Context | ||
| 160 | // - p domain.Principal | 166 | // - 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.Context | 174 | 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 _c | 192 | return _c | ||
| @@ -185,7 +197,7 @@ func (_c *WebPasskeyService_BeginRegistration_Call) Return(ceremony string, opti | |||||
| 185 | return _c | 197 | 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 _c | 202 | return _c | ||
| 191 | } | 203 | } | ||
M internal/ports/services.go +8 -4
| @@ -135,13 +135,17 @@ type PasskeyService interface { | |||
| 135 | 135 | type WebPasskeyService interface { | |
| 136 | 136 | List(ctx context.Context, p domain.Principal) ([]domain.Passkey, error) | |
| 137 | 137 | 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) | |
| 140 | 142 | // FinishLogin checks the browser's response and returns the session token | |
| 141 | 143 | // and how long it lasts, or ErrLinkRejected for any refusal. | |
| 142 | 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. | |
| 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) | |
| 145 | 149 | // FinishRegistration checks the browser's response and stores the | |
| 146 | 150 | // passkey under name (empty means the default name). | |
| 147 | 151 | 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) error | 137 | 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 token | 142 | // 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 the | 149 | // 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) error | 151 | FinishRegistration(ctx context.Context, p domain.Principal, ceremony, name string, response []byte) error | ||
M internal/usecase/passkeys.go +171 -38
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "crypto/rand" | |
| 8 | 9 | "crypto/sha256" | |
| @@ -10,6 +11,7 @@ import ( | |||
| 10 | 11 | "encoding/hex" | |
| 11 | 12 | "errors" | |
| 12 | 13 | "fmt" | |
| 14 | + | "net/netip" | |
| 13 | 15 | "sync" | |
| 14 | 16 | "time" | |
| 15 | 17 | ||
| @@ -28,9 +30,12 @@ const ( | |||
| 28 | 30 | // finished. Only the owner starts one. | |
| 29 | 31 | MaxPendingPasskeyRegistrations = 16 | |
| 30 | 32 | // 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. | |
| 33 | 35 | 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 | 40 | // ceremonyIDBytes is the size of a ceremony identifier: 256 bits. | |
| 36 | 41 | ceremonyIDBytes = 32 | |
| @@ -49,6 +54,7 @@ type ceremony struct { | |||
| 49 | 54 | data []byte // opaque, from the verifier | |
| 50 | 55 | expires time.Time | |
| 51 | 56 | seq uint64 // creation order | |
| 57 | + | client string // who began a login (clientKey); "" for a registration | |
| 52 | 58 | } | |
| 53 | 59 | ||
| 54 | 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 | 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 | 120 | // 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) { | |
| 83 | 127 | var raw [ceremonyIDBytes]byte | |
| 84 | 128 | if _, err := rand.Read(raw[:]); err != nil { | |
| 85 | 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 | 134 | s.mu.Lock() | |
| 91 | 135 | 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{} | |
| 95 | 139 | for h, c := range s.ceremonies { | |
| 96 | 140 | if !now.Before(c.expires) { | |
| 97 | 141 | delete(s.ceremonies, h) | |
| 98 | 142 | continue | |
| 99 | 143 | } | |
| 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) | |
| 105 | 155 | } | |
| 106 | - | pending++ | |
| 107 | 156 | } | |
| 108 | 157 | switch kind { | |
| 109 | 158 | case ceremonyRegister: | |
| 110 | - | if pending >= MaxPendingPasskeyRegistrations { | |
| 159 | + | if registrations >= MaxPendingPasskeyRegistrations { | |
| 111 | 160 | return "", fmt.Errorf("%w: %d passkey registrations are already pending, finish one or wait two minutes", domain.ErrResourceExhausted, MaxPendingPasskeyRegistrations) | |
| 112 | 161 | } | |
| 113 | 162 | 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 | + | } | |
| 116 | 186 | } | |
| 117 | 187 | } | |
| 118 | 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 | 190 | 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 | 207 | // take removes the ceremony and returns its data. It is single use, also when | |
| 124 | 208 | // the ceremony expired or is of another kind. ok is false for an unknown, | |
| 125 | 209 | // expired or mismatched ceremony. | |
| @@ -135,14 +219,19 @@ func (s *Passkeys) take(id string, kind ceremonyKind) (data []byte, ok bool) { | |||
| 135 | 219 | return c.data, true | |
| 136 | 220 | } | |
| 137 | 221 | ||
| 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) { | |
| 143 | 229 | if err := requireOwner(p); err != nil { | |
| 144 | 230 | return "", nil, err | |
| 145 | 231 | } | |
| 232 | + | if _, err := parsePasskeyName(name); err != nil { | |
| 233 | + | return "", nil, err | |
| 234 | + | } | |
| 146 | 235 | existing, err := s.store.List(ctx) | |
| 147 | 236 | if err != nil { | |
| 148 | 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 | 247 | if err != nil { | |
| 159 | 248 | return "", nil, err | |
| 160 | 249 | } | |
| 161 | - | id, err := s.begin(ceremonyRegister, data) | |
| 250 | + | id, err := s.begin(ceremonyRegister, "", data) | |
| 162 | 251 | if err != nil { | |
| 163 | 252 | return "", nil, err | |
| 164 | 253 | } | |
| @@ -175,16 +264,16 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c | |||
| 175 | 264 | if err := requireOwner(p); err != nil { | |
| 176 | 265 | return domain.Passkey{}, err | |
| 177 | 266 | } | |
| 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 | |
| 182 | 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 | 272 | data, ok := s.take(ceremonyID, ceremonyRegister) | |
| 186 | 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 | 278 | handle, err := s.store.UserHandle(ctx) | |
| 190 | 279 | if err != nil { | |
| @@ -192,6 +281,10 @@ func (s *Passkeys) FinishRegistration(ctx context.Context, p domain.Principal, c | |||
| 192 | 281 | } | |
| 193 | 282 | pk, err := s.verifier.FinishRegistration(handle, data, response) | |
| 194 | 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 | 288 | return domain.Passkey{}, err | |
| 196 | 289 | } | |
| 197 | 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 | 306 | if err := s.store.Create(ctx, pk); err != nil { | |
| 214 | 307 | return domain.Passkey{}, err | |
| 215 | 308 | } | |
| 216 | - | log := zerowrap.FromCtx(ctx) | |
| 217 | 309 | log.Info().Str("passkey", passkeyRef(pk.ID)).Msg("passkey added") | |
| 218 | 310 | return pk, nil | |
| 219 | 311 | } | |
| 220 | 312 | ||
| 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 | |
| 222 | 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 | 318 | options, data, err := s.verifier.BeginLogin() | |
| 225 | 319 | if err != nil { | |
| 226 | 320 | return "", nil, err | |
| 227 | 321 | } | |
| 228 | - | id, err := s.begin(ceremonyLogin, data) | |
| 322 | + | id, err := s.begin(ceremonyLogin, clientKey(client), data) | |
| 229 | 323 | if err != nil { | |
| 230 | 324 | return "", nil, err | |
| 231 | 325 | } | |
| @@ -238,30 +332,57 @@ func (s *Passkeys) BeginLogin(context.Context) (string, []byte, error) { | |||
| 238 | 332 | // cloned authenticator), it gives domain.ErrUnauthenticated; a failure of the | |
| 239 | 333 | // store is returned as it is, so that the caller fails closed. | |
| 240 | 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 | 336 | data, ok := s.take(ceremonyID, ceremonyLogin) | |
| 242 | 337 | if !ok { | |
| 338 | + | log.Info().Msg("passkey sign-in refused: the ceremony expired or is unknown") | |
| 243 | 339 | return "", time.Time{}, domain.ErrUnauthenticated | |
| 244 | 340 | } | |
| 245 | 341 | handle, err := s.store.UserHandle(ctx) | |
| 246 | 342 | if err != nil { | |
| 247 | 343 | return "", time.Time{}, fmt.Errorf("load passkey user handle: %w", err) | |
| 248 | 344 | } | |
| 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 | + | }) | |
| 251 | 350 | if err != nil { | |
| 252 | 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 | 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 | 361 | return "", time.Time{}, domain.ErrUnauthenticated | |
| 257 | 362 | } | |
| 258 | 363 | return "", time.Time{}, fmt.Errorf("verify passkey login: %w", err) | |
| 259 | 364 | } | |
| 260 | 365 | ||
| 261 | 366 | // 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 | 369 | s.writeMu.Lock() | |
| 264 | 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 | 386 | pk.LastUsedAt = s.clock.Now().UTC() | |
| 266 | 387 | if err := s.store.Update(ctx, pk); err != nil { | |
| 267 | 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 | 428 | 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 | 443 | // passkeyRef is how logs name a passkey: the first 8 hex characters of the | |
| 311 | 444 | // SHA-256 of its credential id. | |
| 312 | 445 | func passkeyRef(id []byte) string { | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package usecase | 3 | 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 = 16 | 31 | 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 of | 33 | + | // 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 = 256 | 35 | 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 = 32 | 41 | ceremonyIDBytes = 32 | ||
| @@ -49,6 +54,7 @@ type ceremony struct { | |||||
| 49 | data []byte // opaque, from the verifier | 54 | data []byte // opaque, from the verifier | ||
| 50 | expires time.Time | 55 | expires time.Time | ||
| 51 | seq uint64 // creation order | 56 | 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 expired | 120 | // 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]byte | 127 | 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 := 0 | 136 | + | registrations := 0 |
| 93 | - | var oldest [sha256.Size]byte | 137 | + | logins := 0 |
| 94 | - | var oldestSeq uint64 | 138 | + | 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 | continue | 142 | continue | ||
| 99 | } | 143 | } | ||
| 100 | - | if c.kind != kind { | 144 | + | switch c.kind { |
| 101 | - | continue | 145 | + | case ceremonyRegister: |
| 102 | - | } | 146 | + | registrations++ |
| 103 | - | if pending == 0 || c.seq < oldestSeq { | 147 | + | case ceremonyLogin: |
| 104 | - | oldest, oldestSeq = h, c.seq | 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) | |||
| 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, nil | 190 | 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 when | 207 | // 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, true | 219 | return c.data, true | ||
| 136 | } | 220 | } | ||
| 137 | 221 | ||||
| 138 | - | // BeginRegistration starts adding a passkey. Only the owner may. It gives | 222 | + | // BeginRegistration starts adding a passkey. Only the owner may. name is |
| 139 | - | // domain.ErrResourceExhausted when MaxPasskeys passkeys exist or too many | 223 | + | // 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 for | 224 | + | // 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, err | 230 | 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, err | 248 | 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, err | 252 | 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{}, err | 265 | 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{}, err | 288 | 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{}, err | 307 | 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, nil | 310 | return pk, nil | ||
| 219 | } | 311 | } | ||
| 220 | 312 | ||||
| 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 | |||
| 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, err | 320 | 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, err | 324 | 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 the | 332 | // 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.ErrUnauthenticated | 339 | 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.ErrUnauthenticated | 361 | 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 to | 366 | // 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 nil | 428 | 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 the | 443 | // 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 8 | 10 | "errors" | |
| 9 | 11 | "fmt" | |
| 10 | 12 | "slices" | |
| 13 | + | "strings" | |
| 11 | 14 | "sync" | |
| 12 | 15 | "sync/atomic" | |
| 13 | 16 | "testing" | |
| 14 | 17 | "time" | |
| 15 | 18 | ||
| 19 | + | "github.com/bnema/zerowrap" | |
| 16 | 20 | "github.com/stretchr/testify/assert" | |
| 17 | 21 | "github.com/stretchr/testify/mock" | |
| 18 | 22 | "github.com/stretchr/testify/require" | |
| @@ -126,9 +130,12 @@ func (f *passkeyFixture) acceptRegistration(id string) { | |||
| 126 | 130 | }).Maybe() | |
| 127 | 131 | } | |
| 128 | 132 | ||
| 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) | |
| 132 | 139 | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 133 | 140 | func(_, ceremony, _ []byte, lookup func([]byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 134 | 141 | if !bytes.HasPrefix(ceremony, []byte("login-")) { | |
| @@ -138,7 +145,9 @@ func (f *passkeyFixture) acceptLogin(id string, signCount uint32) { | |||
| 138 | 145 | if err != nil { | |
| 139 | 146 | return domain.Passkey{}, fmt.Errorf("%w: lookup: %v", domain.ErrUnauthenticated, err) | |
| 140 | 147 | } | |
| 141 | - | p.SignCount = signCount | |
| 148 | + | if first != 0 { | |
| 149 | + | p.SignCount = next.Add(1) - 1 | |
| 150 | + | } | |
| 142 | 151 | p.BackupState = true | |
| 143 | 152 | return p, nil | |
| 144 | 153 | }).Maybe() | |
| @@ -178,13 +187,13 @@ func TestPasskeysAreOwnerOnly(t *testing.T) { | |||
| 178 | 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 | 191 | check("begin registration", err) | |
| 183 | 192 | _, err = f.passkeys.List(ctx, tc.p) | |
| 184 | 193 | check("list", err) | |
| 185 | 194 | ||
| 186 | 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 | 197 | require.NoError(t, err) | |
| 189 | 198 | _, err = f.passkeys.FinishRegistration(ctx, tc.p, id, "n", []byte("{}")) | |
| 190 | 199 | check("finish registration", err) | |
| @@ -205,7 +214,7 @@ func TestPasskeysAreOwnerOnly(t *testing.T) { | |||
| 205 | 214 | func TestBeginLoginIsAnonymous(t *testing.T) { | |
| 206 | 215 | t.Parallel() | |
| 207 | 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 | 218 | require.NoError(t, err) | |
| 210 | 219 | assert.Len(t, id, 43, "256 bits") | |
| 211 | 220 | assert.JSONEq(t, `{"publicKey":{}}`, string(options)) | |
| @@ -217,7 +226,7 @@ func TestRegistration(t *testing.T) { | |||
| 217 | 226 | f := newPasskeyFixture(t) | |
| 218 | 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 | 230 | require.NoError(t, err) | |
| 222 | 231 | assert.JSONEq(t, `{"publicKey":{}}`, string(options)) | |
| 223 | 232 | ||
| @@ -243,7 +252,7 @@ func TestRegistrationDefaultNameAndBadName(t *testing.T) { | |||
| 243 | 252 | f := newPasskeyFixture(t) | |
| 244 | 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 | 256 | require.NoError(t, err) | |
| 248 | 257 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "evil \u202e name", []byte(`{}`)) | |
| 249 | 258 | require.ErrorIs(t, err, domain.ErrInvalidText) | |
| @@ -262,7 +271,7 @@ func TestRegistrationDuplicate(t *testing.T) { | |||
| 262 | 271 | f.add("cred-1") | |
| 263 | 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 | 275 | require.NoError(t, err) | |
| 267 | 276 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "dup", []byte(`{}`)) | |
| 268 | 277 | assert.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| @@ -276,7 +285,7 @@ func TestRegistrationRefusedByVerifier(t *testing.T) { | |||
| 276 | 285 | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything). | |
| 277 | 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 | 289 | require.NoError(t, err) | |
| 281 | 290 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`)) | |
| 282 | 291 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -289,7 +298,7 @@ func TestRegistrationRefusesAnIdTooLong(t *testing.T) { | |||
| 289 | 298 | f := newPasskeyFixture(t) | |
| 290 | 299 | f.verifier.EXPECT().FinishRegistration(testHandle, mock.Anything, mock.Anything). | |
| 291 | 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 | 302 | require.NoError(t, err) | |
| 294 | 303 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`)) | |
| 295 | 304 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -306,7 +315,7 @@ func TestBeginRegistrationExcludesExisting(t *testing.T) { | |||
| 306 | 315 | f.verifier.EXPECT().BeginRegistration(testHandle, mock.MatchedBy(func(ps []domain.Passkey) bool { | |
| 307 | 316 | return len(ps) == 2 && string(ps[0].ID) == "a" && string(ps[1].ID) == "b" | |
| 308 | 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 | 319 | require.NoError(t, err) | |
| 311 | 320 | } | |
| 312 | 321 | ||
| @@ -319,13 +328,13 @@ func TestMaxPasskeys(t *testing.T) { | |||
| 319 | 328 | f.add(fmt.Sprintf("p%02d", i)) | |
| 320 | 329 | } | |
| 321 | 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 | 332 | require.NoError(t, err) | |
| 324 | 333 | f.add("last") | |
| 325 | 334 | assert.Equal(t, domain.MaxPasskeys, f.count()) | |
| 326 | 335 | ||
| 327 | 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 | 338 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 330 | 339 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), id, "x", []byte(`{}`)) | |
| 331 | 340 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| @@ -333,7 +342,7 @@ func TestMaxPasskeys(t *testing.T) { | |||
| 333 | 342 | ||
| 334 | 343 | // Removing one makes room again. | |
| 335 | 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 | 346 | assert.NoError(t, err) | |
| 338 | 347 | } | |
| 339 | 348 | ||
| @@ -346,9 +355,9 @@ func TestCeremonyExpiry(t *testing.T) { | |||
| 346 | 355 | f.acceptLogin("login-cred", 1) | |
| 347 | 356 | ||
| 348 | 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 | 359 | require.NoError(t, err) | |
| 351 | - | login, _, err := f.passkeys.BeginLogin(ctx) | |
| 360 | + | login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 352 | 361 | require.NoError(t, err) | |
| 353 | 362 | f.advance(usecase.PasskeyCeremonyTTL - time.Second) | |
| 354 | 363 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), reg, "x", []byte(`{}`)) | |
| @@ -357,9 +366,9 @@ func TestCeremonyExpiry(t *testing.T) { | |||
| 357 | 366 | assert.NoError(t, err) | |
| 358 | 367 | ||
| 359 | 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 | 370 | require.NoError(t, err) | |
| 362 | - | login, _, err = f.passkeys.BeginLogin(ctx) | |
| 371 | + | login, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 363 | 372 | require.NoError(t, err) | |
| 364 | 373 | assert.Equal(t, 2*time.Minute, usecase.PasskeyCeremonyTTL) | |
| 365 | 374 | f.advance(usecase.PasskeyCeremonyTTL) | |
| @@ -375,13 +384,13 @@ func TestExpiredRegistrationsDoNotCount(t *testing.T) { | |||
| 375 | 384 | ctx := context.Background() | |
| 376 | 385 | f := newPasskeyFixture(t) | |
| 377 | 386 | for range usecase.MaxPendingPasskeyRegistrations { | |
| 378 | - | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner()) | |
| 387 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 379 | 388 | require.NoError(t, err) | |
| 380 | 389 | } | |
| 381 | - | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner()) | |
| 390 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 382 | 391 | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 383 | 392 | f.advance(usecase.PasskeyCeremonyTTL) | |
| 384 | - | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner()) | |
| 393 | + | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 385 | 394 | assert.NoError(t, err) | |
| 386 | 395 | } | |
| 387 | 396 | ||
| @@ -392,49 +401,139 @@ func TestPendingRegistrationsAreBounded(t *testing.T) { | |||
| 392 | 401 | f.acceptRegistration("cred-1") | |
| 393 | 402 | var ids []string | |
| 394 | 403 | for range usecase.MaxPendingPasskeyRegistrations { | |
| 395 | - | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner()) | |
| 404 | + | id, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 396 | 405 | require.NoError(t, err) | |
| 397 | 406 | ids = append(ids, id) | |
| 398 | 407 | } | |
| 399 | 408 | assert.Len(t, ids, 16) | |
| 400 | - | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner()) | |
| 409 | + | _, _, err := f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 401 | 410 | require.ErrorIs(t, err, domain.ErrResourceExhausted, "the 17th is refused") | |
| 402 | 411 | ||
| 403 | 412 | // Pending logins do not use the registrations' room, and a finished | |
| 404 | 413 | // registration frees one. | |
| 405 | - | _, _, err = f.passkeys.BeginLogin(ctx) | |
| 414 | + | _, _, err = f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 406 | 415 | require.NoError(t, err) | |
| 407 | 416 | _, err = f.passkeys.FinishRegistration(ctx, domain.Owner(), ids[0], "x", []byte(`{}`)) | |
| 408 | 417 | require.NoError(t, err) | |
| 409 | - | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner()) | |
| 418 | + | _, _, err = f.passkeys.BeginRegistration(ctx, domain.Owner(), "") | |
| 410 | 419 | assert.NoError(t, err) | |
| 411 | 420 | } | |
| 412 | 421 | ||
| 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) { | |
| 416 | 444 | t.Parallel() | |
| 417 | - | ctx := context.Background() | |
| 418 | 445 | f := newPasskeyFixture(t) | |
| 419 | 446 | f.add("cred-1") | |
| 420 | 447 | f.acceptLogin("cred-1", 1) | |
| 421 | 448 | ||
| 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") | |
| 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(`{}`)) | |
| 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])) | |
| 438 | 537 | } | |
| 439 | 538 | ||
| 440 | 539 | // Logins never push out a registration, and registrations never push out a login. | |
| @@ -446,9 +545,9 @@ func TestCeremonyKindsAreSeparate(t *testing.T) { | |||
| 446 | 545 | f.acceptLogin("cred-1", 1) | |
| 447 | 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 | 549 | require.NoError(t, err) | |
| 451 | - | login, _, err := f.passkeys.BeginLogin(ctx) | |
| 550 | + | login, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 452 | 551 | require.NoError(t, err) | |
| 453 | 552 | ||
| 454 | 553 | // A registration ceremony does not finish a login, nor the reverse; the | |
| @@ -482,7 +581,7 @@ func TestLoginIssuesASession(t *testing.T) { | |||
| 482 | 581 | f.add("cred-1") | |
| 483 | 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 | 585 | require.NoError(t, err) | |
| 487 | 586 | f.advance(10 * time.Second) | |
| 488 | 587 | token, exp, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| @@ -506,6 +605,191 @@ func TestLoginIssuesASession(t *testing.T) { | |||
| 506 | 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 | 793 | func TestLoginRefusals(t *testing.T) { | |
| 510 | 794 | t.Parallel() | |
| 511 | 795 | ctx := context.Background() | |
| @@ -514,7 +798,7 @@ func TestLoginRefusals(t *testing.T) { | |||
| 514 | 798 | t.Parallel() | |
| 515 | 799 | f := newPasskeyFixture(t) | |
| 516 | 800 | f.acceptLogin("ghost", 1) | |
| 517 | - | id, _, err := f.passkeys.BeginLogin(ctx) | |
| 801 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 518 | 802 | require.NoError(t, err) | |
| 519 | 803 | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 520 | 804 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -526,7 +810,7 @@ func TestLoginRefusals(t *testing.T) { | |||
| 526 | 810 | f.add("cred-1") | |
| 527 | 811 | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 528 | 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 | 814 | require.NoError(t, err) | |
| 531 | 815 | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 532 | 816 | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -539,7 +823,7 @@ func TestLoginRefusals(t *testing.T) { | |||
| 539 | 823 | p := f.add("cred-1") | |
| 540 | 824 | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 541 | 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 | 827 | require.NoError(t, err) | |
| 544 | 828 | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 545 | 829 | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -554,7 +838,7 @@ func TestLoginRefusals(t *testing.T) { | |||
| 554 | 838 | boom := errors.New("disk on fire") | |
| 555 | 839 | f.verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 556 | 840 | Return(domain.Passkey{}, boom) | |
| 557 | - | id, _, err := f.passkeys.BeginLogin(ctx) | |
| 841 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 558 | 842 | require.NoError(t, err) | |
| 559 | 843 | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 560 | 844 | require.ErrorIs(t, err, boom) | |
| @@ -568,13 +852,14 @@ func TestLoginRefusals(t *testing.T) { | |||
| 568 | 852 | store := mocks.NewPasskeyStore(t) | |
| 569 | 853 | boom := errors.New("disk on fire") | |
| 570 | 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 | 856 | store.EXPECT().Update(mock.Anything, mock.Anything).Return(boom) | |
| 572 | 857 | verifier := mocks.NewPasskeyVerifier(t) | |
| 573 | 858 | verifier.EXPECT().BeginLogin().Return([]byte(`{}`), []byte("login-1"), nil) | |
| 574 | 859 | verifier.EXPECT().FinishLogin(testHandle, mock.Anything, mock.Anything, mock.Anything). | |
| 575 | 860 | Return(domain.Passkey{ID: []byte("cred-1")}, nil) | |
| 576 | 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 | 863 | require.NoError(t, err) | |
| 579 | 864 | token, _, err := pk.FinishLogin(ctx, id, []byte(`{}`)) | |
| 580 | 865 | require.ErrorIs(t, err, boom) | |
| @@ -594,7 +879,7 @@ func TestLoginRefusals(t *testing.T) { | |||
| 594 | 879 | f.mu.Unlock() | |
| 595 | 880 | return p, nil | |
| 596 | 881 | }) | |
| 597 | - | id, _, err := f.passkeys.BeginLogin(ctx) | |
| 882 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 598 | 883 | require.NoError(t, err) | |
| 599 | 884 | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 600 | 885 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -612,7 +897,7 @@ func TestRemoveBumpsTheEpoch(t *testing.T) { | |||
| 612 | 897 | f.add("cred-2") | |
| 613 | 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 | 901 | require.NoError(t, err) | |
| 617 | 902 | token, _, err := f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 618 | 903 | require.NoError(t, err) | |
| @@ -632,7 +917,7 @@ func TestRemoveBumpsTheEpoch(t *testing.T) { | |||
| 632 | 917 | } | |
| 633 | 918 | ||
| 634 | 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 | 921 | require.NoError(t, err) | |
| 637 | 922 | token, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 638 | 923 | require.NoError(t, err) | |
| @@ -680,19 +965,38 @@ func TestPasskeysConcurrent(t *testing.T) { | |||
| 680 | 965 | ctx := context.Background() | |
| 681 | 966 | f := newPasskeyFixture(t) | |
| 682 | 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 | 969 | var wg sync.WaitGroup | |
| 685 | 970 | for range 32 { | |
| 686 | 971 | wg.Add(1) | |
| 687 | 972 | go func() { | |
| 688 | 973 | defer wg.Done() | |
| 689 | - | id, _, err := f.passkeys.BeginLogin(ctx) | |
| 974 | + | id, _, err := f.passkeys.BeginLogin(ctx, "192.0.2.1") | |
| 690 | 975 | if assert.NoError(t, err) { | |
| 691 | 976 | _, _, err = f.passkeys.FinishLogin(ctx, id, []byte(`{}`)) | |
| 692 | 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 | 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 | + | } | |
| @@ -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 = signCount | 148 | + | if first != 0 { |
| 149 | + | p.SignCount = next.Add(1) - 1 | |||
| 150 | + | } | |||
| 142 | p.BackupState = true | 151 | p.BackupState = true | ||
| 143 | return p, nil | 152 | 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 []string | 402 | 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 finished | 412 | // 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 the | 422 | + | // 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 []string | 449 | + | 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 too | 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") | |||
| 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; the | 553 | // 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, nil | 880 | 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.WaitGroup | 969 | 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 | + | } | |||