feat(passkey): advance sign_count, reject clone-warned assertions
Both login doors (username-first and discoverable) now run a shared applyAssertionCounter after a verified assertion. A signature-counter regression — go-webauthn's CloneWarning, the possible-cloned-authenticator signal — is refused fail-closed with the same opaque passkey_login_invalid envelope any other finish failure returns (no clone oracle to a prober) and audited distinctly as auth.passkey_clone_rejected under the resolved account. A clean assertion advances the stored sign_count to the asserted value and stamps last_used_at, before any session is minted. Counter-less/synced authenticators report 0 and never warn, so they pass through and simply re-stamp 0; the check gates only counter-keeping hardware authenticators, where a rollback is the meaningful signal. Email-OTP and username-first passkey remain fallbacks, so a rejected clone is never bricked. Adds Repo.AdvanceCredentialSignCount (pgrepo UPDATE by credential_id) and surfaces CloneWarning from the internal/passkey adapter's FinishLogin/FinishDiscoverableLogin. Proven by real-crypto adapter tests (a counter regression still verifies but flags CloneWarning), handler tests (advance-and-stamp on success, fail-closed on clone), and a symmetric test on each door so both call sites of the shared helper are covered.
This commit is contained in:
9 files changed
+297
-17
No files matched your search
@@ -460,6 +460,23 @@ func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdvanceCredentialSignCount mirrors PGRepo: find the passkey by its UNIQUE credential_id, set
|
||||
// the stored counter to newSignCount, and stamp last_used_at. A credential_id matching no row
|
||||
// is a successful no-op (the PG UPDATE touches zero rows), so a test can assert both the
|
||||
// advance-on-success and the never-error-on-missing contracts.
|
||||
func (f *fakeRepo) AdvanceCredentialSignCount(_ context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error {
|
||||
for id, c := range f.passkeyCreds {
|
||||
if c.CredentialID == credentialID {
|
||||
c.SignCount = newSignCount
|
||||
t := usedAt
|
||||
c.LastUsedAt = &t
|
||||
f.passkeyCreds[id] = c
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation
|
||||
// or assertion crypto, so it exercises the enrollment AND login STATE MACHINES (challenge
|
||||
// persistence, consume, conflict, audit, session mint) without go-webauthn.
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -154,21 +155,25 @@ type VerifiedCredential struct {
|
||||
}
|
||||
|
||||
// VerifiedAssertion is the output of a finished LOGIN (assertion) ceremony: which of the
|
||||
// user's bound credentials proved itself and the signature counter the authenticator
|
||||
// reported. Like VerifiedCredential it carries no secret. SignCount is the raw ceremony
|
||||
// fact, NOT a policy verdict: the handler that eventually consumes this holds the
|
||||
// previously-stored counter and decides whether a non-increase is a cloned-authenticator
|
||||
// signal — the verifier deliberately does not, so clone policy lives in one place with
|
||||
// the stored state. SignCount is legitimately 0 for authenticators that keep no counter.
|
||||
// user's bound credentials proved itself, the signature counter the authenticator reported,
|
||||
// and whether that counter regressed (a possible clone). Like VerifiedCredential it carries
|
||||
// no secret. SignCount is a raw ceremony fact, NOT a policy verdict; CloneWarning IS the
|
||||
// verifier's regression verdict, but the refuse-vs-allow decision is the handler's. Clone
|
||||
// policy therefore lives in one place with the stored counter (applyAssertionCounter, which
|
||||
// both login doors call). SignCount is legitimately 0 for authenticators that keep no counter.
|
||||
//
|
||||
// The login handler below (handlePasskeyLoginFinish) obtains this from FinishLogin but
|
||||
// currently checks only that the assertion verified — the SignCount/UserVerified consumer
|
||||
// the note above anticipates is still future. It is the stable seam output the production
|
||||
// adapter (internal/passkey) produces and its Oracle test asserts on, so handler and
|
||||
// adapter agree on shape without either reshaping the other.
|
||||
// applyAssertionCounter is that single consumer: it refuses a CloneWarning fail-closed and,
|
||||
// on success, advances the stored counter and stamps last_used_at. This is the stable seam
|
||||
// output the production adapter (internal/passkey) produces and its Oracle test asserts on,
|
||||
// so handler and adapter agree on shape without either reshaping the other.
|
||||
type VerifiedAssertion struct {
|
||||
CredentialID string // base64url(raw credential id) — which bound credential signed
|
||||
SignCount uint32
|
||||
// CloneWarning is go-webauthn's verdict that the signature counter did not advance past
|
||||
// the stored value (WebAuthn §6.1.1 clone detection). It is meaningful only for
|
||||
// counter-keeping authenticators: synced/counter-less keys report SignCount 0 on every
|
||||
// assertion and structurally never raise it. The login handlers refuse it fail-closed.
|
||||
CloneWarning bool
|
||||
// UserVerified records that a PIN/biometric (not mere presence) was performed
|
||||
// during the assertion ceremony. The verifier enforces UV=required at BeginLogin,
|
||||
// so this is always true for a successful assertion; persisting it makes the
|
||||
@@ -181,6 +186,28 @@ type VerifiedAssertion struct {
|
||||
var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable",
|
||||
"passkey subsystem is not configured")
|
||||
|
||||
// errPasskeyClonedAuthenticator is the internal signal from applyAssertionCounter that a
|
||||
// verified assertion carried a clone warning (its signature counter did not advance past the
|
||||
// stored value). It never reaches the client verbatim: the login doors map it to the generic
|
||||
// passkey_login_invalid envelope — no clone oracle to a prober — and audit it distinctly.
|
||||
var errPasskeyClonedAuthenticator = errors.New("passkey assertion rejected: clone warning")
|
||||
|
||||
// applyAssertionCounter is the single consumer of a verified assertion's signature-counter
|
||||
// facts, shared by the username-first (handlePasskeyLoginFinish) and discoverable
|
||||
// (handlePasskeyLoginDiscoverableFinish) login doors so clone policy lives in one place with
|
||||
// the stored counter. A CloneWarning fails closed with errPasskeyClonedAuthenticator;
|
||||
// otherwise it advances the stored counter to the asserted value and stamps last_used_at.
|
||||
// Counter-less/synced authenticators report 0 and never warn, so they pass through and simply
|
||||
// re-stamp 0 — the check gates only counter-keeping authenticators, where a rollback is the
|
||||
// meaningful clone signal. It runs BEFORE the session is minted, so a clone or a persist
|
||||
// failure denies the login rather than leaving an advanced counter with no session.
|
||||
func (a *API) applyAssertionCounter(ctx context.Context, va VerifiedAssertion) error {
|
||||
if va.CloneWarning {
|
||||
return errPasskeyClonedAuthenticator
|
||||
}
|
||||
return a.Repo.AdvanceCredentialSignCount(ctx, va.CredentialID, va.SignCount, a.now())
|
||||
}
|
||||
|
||||
// newPasskeyID returns an opaque random row id (128 bits, hex) for a passkey row.
|
||||
func newPasskeyID() (string, error) {
|
||||
var b [16]byte
|
||||
@@ -577,12 +604,25 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
DisplayName: u.Username,
|
||||
Credentials: creds,
|
||||
}
|
||||
_, err = a.Passkey.FinishLogin(user, sessionData, bytes.NewReader(req.Assertion))
|
||||
va, err := a.Passkey.FinishLogin(user, sessionData, bytes.NewReader(req.Assertion))
|
||||
if err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
// Clone policy + counter advance, in one place shared with the discoverable door. A
|
||||
// regressed counter is refused with the same opaque envelope (no clone oracle) but audited
|
||||
// distinctly; a successful assertion advances the stored counter and stamps last_used_at.
|
||||
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||
a.audit(r, u.Username, "auth.passkey_clone_rejected", va.CredentialID)
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
|
||||
@@ -157,7 +157,8 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
||||
// stable username so a nil email never matters.
|
||||
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
|
||||
}
|
||||
if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil {
|
||||
va, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion))
|
||||
if err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
@@ -171,6 +172,19 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
// Same clone policy + counter advance as the username-first door (applyAssertionCounter): a
|
||||
// regressed counter is refused with the identical opaque envelope but audited under the
|
||||
// resolved account; a successful assertion advances the stored counter and stamps last_used_at.
|
||||
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||
a.audit(r, resolved.Username, "auth.passkey_clone_rejected", va.CredentialID)
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
|
||||
@@ -317,3 +317,81 @@ func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) {
|
||||
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// beginDiscoverableLogin runs the from-zero begin and returns the stashed login_id, so the
|
||||
// counter/clone tests below need not re-inline the begin ceremony each time.
|
||||
func beginDiscoverableLogin(t *testing.T, eh http.Handler) string {
|
||||
t.Helper()
|
||||
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
loginID, _ := acctBody(t, w)["login_id"].(string)
|
||||
if loginID == "" {
|
||||
t.Fatalf("begin returned empty login_id: %s", w.Body.String())
|
||||
}
|
||||
return loginID
|
||||
}
|
||||
|
||||
// TestPasskeyDiscoverableLoginAdvancesSignCount proves the success half of task #40 item 5: a
|
||||
// verified from-zero assertion advances the stored signature counter to the value the
|
||||
// authenticator reported and stamps last_used_at. Without this the stored counter would sit at
|
||||
// the enrollment-time 0 forever, leaving the clone check below no moving baseline to judge a
|
||||
// later regression against.
|
||||
func TestPasskeyDiscoverableLoginAdvancesSignCount(t *testing.T) {
|
||||
api, repo, v := seedDiscoverableLoginAPI(t)
|
||||
// A clean (non-clone) assertion reporting an advanced counter.
|
||||
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 42}
|
||||
eh := api.ExternalHandler()
|
||||
|
||||
loginID := beginDiscoverableLogin(t, eh)
|
||||
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
|
||||
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
got := repo.passkeyCreds["row1"]
|
||||
if got.SignCount != 42 {
|
||||
t.Errorf("stored SignCount = %d, want 42 (advanced to the asserted counter)", got.SignCount)
|
||||
}
|
||||
if got.LastUsedAt == nil || !got.LastUsedAt.Equal(frozenNow) {
|
||||
t.Errorf("stored LastUsedAt = %v, want %v (stamped on a successful assertion)", got.LastUsedAt, frozenNow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasskeyDiscoverableLoginCloneRejected proves the fail-closed half of task #40 item 5: a
|
||||
// verified assertion carrying a CloneWarning (signature-counter regression — a possible cloned
|
||||
// authenticator) is refused. The refusal (1) collapses into the same passkey_login_invalid
|
||||
// envelope as any other finish failure so a prober gets no clone oracle, (2) mints NO session,
|
||||
// (3) does NOT advance or stamp the stored credential, and (4) is audited distinctly for the
|
||||
// operator under the resolved account.
|
||||
func TestPasskeyDiscoverableLoginCloneRejected(t *testing.T) {
|
||||
api, repo, v := seedDiscoverableLoginAPI(t)
|
||||
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 3, CloneWarning: true}
|
||||
eh := api.ExternalHandler()
|
||||
|
||||
loginID := beginDiscoverableLogin(t, eh)
|
||||
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
|
||||
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||||
t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.sessions) != 0 {
|
||||
t.Errorf("clone refusal must mint no session, got %d", len(repo.sessions))
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Errorf("clone refusal must set no session cookie, got %v", w.Result().Cookies())
|
||||
}
|
||||
// The stored credential is untouched: still at enrollment-time counter 0, never stamped.
|
||||
if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
|
||||
t.Errorf("clone refusal must not advance/stamp the credential, got SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
|
||||
}
|
||||
// Audited distinctly, under the resolved account, so the operator sees the clone signal.
|
||||
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_clone_rejected" {
|
||||
t.Fatalf("want exactly 1 auth.passkey_clone_rejected audit, got %+v", repo.audits)
|
||||
}
|
||||
if repo.audits[0].Actor != "player" {
|
||||
t.Errorf("clone audit actor = %q, want player (the resolved account)", repo.audits[0].Actor)
|
||||
}
|
||||
}
|
||||
@@ -458,3 +458,39 @@ func TestPasskeyLoginFaceSeparation(t *testing.T) {
|
||||
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasskeyLoginFinishCloneRejected is the username-first mirror of the discoverable door's
|
||||
// clone refusal (task #40 item 5). Both doors share applyAssertionCounter, but each WIRES it
|
||||
// independently, so this proves the username-first finish also fails closed on a CloneWarning:
|
||||
// the same opaque passkey_login_invalid envelope (no clone oracle), no session minted, the stored
|
||||
// credential left untouched at its enrollment-time counter, and a distinct auth.passkey_clone_
|
||||
// rejected audit under the account. Challenge is planted directly so finish is reachable under
|
||||
// the frozen clock without a live begin.
|
||||
func TestPasskeyLoginFinishCloneRejected(t *testing.T) {
|
||||
api, repo, v := seedLoginPasskeyAPI(t)
|
||||
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 3, CloneWarning: true}
|
||||
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
|
||||
eh := api.ExternalHandler()
|
||||
|
||||
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
|
||||
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||||
t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.sessions) != 0 {
|
||||
t.Errorf("clone refusal must mint no session, got %d", len(repo.sessions))
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Errorf("clone refusal must set no session cookie, got %v", w.Result().Cookies())
|
||||
}
|
||||
if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
|
||||
t.Errorf("clone refusal must not advance/stamp the credential, got SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
|
||||
}
|
||||
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_clone_rejected" {
|
||||
t.Fatalf("want exactly 1 auth.passkey_clone_rejected audit, got %+v", repo.audits)
|
||||
}
|
||||
if repo.audits[0].Actor != "player" {
|
||||
t.Errorf("clone audit actor = %q, want player (the resolved account)", repo.audits[0].Actor)
|
||||
}
|
||||
}
|
||||
@@ -1150,6 +1150,18 @@ func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AdvanceCredentialSignCount records a successful assertion on the passkey identified by
|
||||
// credentialID: it advances the stored signature counter to newSignCount and stamps
|
||||
// last_used_at. credential_id is UNIQUE so exactly one row is touched; a missing row (the
|
||||
// credential was unbound mid-ceremony) affects zero rows and is a successful no-op, never an
|
||||
// error — the assertion is already cryptographically complete by the time this runs.
|
||||
func (p *PGRepo) AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`UPDATE webauthn_credentials SET sign_count = $2, last_used_at = $3 WHERE credential_id = $1`,
|
||||
credentialID, int64(newSignCount), usedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller can
|
||||
// only unbind their OWN credential. No matching (user, id) row → ErrNotFound via a zero
|
||||
// RowsAffected, so a stale or cross-user id cannot silently no-op as success.
|
||||
|
||||
@@ -392,6 +392,13 @@ type Repo interface {
|
||||
// unbinding leaves a re-enrollable credential. Removing zero rows is success, not an
|
||||
// error — an account with no passkeys is the intended post-condition either way.
|
||||
DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error
|
||||
// AdvanceCredentialSignCount records a successful assertion on the passkey identified by
|
||||
// credentialID (base64url): it sets the stored signature counter to newSignCount and stamps
|
||||
// last_used_at. credential_id is UNIQUE, so exactly one row is updated; a missing row (the
|
||||
// credential was unbound mid-ceremony) is a successful no-op, never an error. The login doors
|
||||
// call it only after clone policy allows the assertion, so for a counter-keeping authenticator
|
||||
// the stored counter only ever moves forward — the baseline a later regression is judged against.
|
||||
AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error
|
||||
|
||||
// ---- player game-login: username-collision reclaim (spec §B3) ----
|
||||
|
||||
|
||||
@@ -192,10 +192,11 @@ func (v *Verifier) BeginLogin(user api.PasskeyUser) (json.RawMessage, []byte, er
|
||||
// reported. go-webauthn checks the challenge, RP id, and origin against server-held values,
|
||||
// that the asserted credential id is one the user actually holds (it returns
|
||||
// protocol.ErrorUnknownCredential otherwise), and the signature against the stored COSE
|
||||
// public key. It does NOT decide clone/regression policy here: the returned SignCount is
|
||||
// the raw ceremony fact, and the handler — which holds the previously-stored counter —
|
||||
// decides whether a non-increase is a cloned-authenticator signal. The verified credential
|
||||
// id is returned base64url so the handler can look up the exact row to update.
|
||||
// public key, and runs go-webauthn's UpdateCounter so a signature counter that fails to
|
||||
// advance past the stored value raises CloneWarning. It does NOT decide clone policy here:
|
||||
// the returned SignCount and CloneWarning are raw ceremony facts, and the handler — the one
|
||||
// consumer, holding the stored counter — decides (it refuses, fail-closed). The verified
|
||||
// credential id is returned base64url so the handler can look up the exact row to update.
|
||||
func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) {
|
||||
var session webauthn.SessionData
|
||||
if err := json.Unmarshal(sessionData, &session); err != nil {
|
||||
@@ -212,6 +213,7 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti
|
||||
return api.VerifiedAssertion{
|
||||
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
||||
SignCount: cred.Authenticator.SignCount,
|
||||
CloneWarning: cred.Authenticator.CloneWarning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -274,6 +276,7 @@ func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) (
|
||||
return api.VerifiedAssertion{
|
||||
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
||||
SignCount: cred.Authenticator.SignCount,
|
||||
CloneWarning: cred.Authenticator.CloneWarning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -480,6 +480,79 @@ func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCloneWarningSurfaced proves the adapter SURFACES go-webauthn's clone verdict (task #40
|
||||
// item 5) on the username-first door: when the authenticator presents a signature counter at or
|
||||
// below the stored value, go-webauthn raises CloneWarning but does NOT itself reject (the counter
|
||||
// is advisory; the RP decides). The adapter must carry that verdict out in VerifiedAssertion so
|
||||
// the handler can fail closed — without this the handler would have nothing to key clone policy
|
||||
// on. Note the assertion still VERIFIES (err is nil): a regressed counter is a policy signal, not
|
||||
// a broken signature.
|
||||
func TestLoginCloneWarningSurfaced(t *testing.T) {
|
||||
v := newTestVerifier(t)
|
||||
rp := virtualRP()
|
||||
authenticator := virtualwebauthn.NewAuthenticator()
|
||||
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||
stored := enrollCredential(t, v, rp, authenticator, cred)
|
||||
|
||||
// The stored counter is AHEAD of what the authenticator will present: a regression, which is
|
||||
// exactly the cloned-authenticator signal go-webauthn's UpdateCounter raises.
|
||||
stored.SignCount = 100
|
||||
cred.Counter = 50
|
||||
|
||||
options, sessionData, err := v.BeginLogin(testUser(stored))
|
||||
if err != nil {
|
||||
t.Fatalf("BeginLogin: %v", err)
|
||||
}
|
||||
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAssertionOptions: %v", err)
|
||||
}
|
||||
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
||||
va, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse))
|
||||
if err != nil {
|
||||
t.Fatalf("FinishLogin: %v (a counter regression must still VERIFY, only flag CloneWarning)", err)
|
||||
}
|
||||
if !va.CloneWarning {
|
||||
t.Fatal("va.CloneWarning = false, want true (presented counter at/below the stored counter is a clone signal)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverableLoginCloneWarningSurfaced is the same clone-verdict proof for the usernameless
|
||||
// door (task #40 item 5): a from-zero assertion whose counter regressed must come back VERIFIED
|
||||
// but with CloneWarning set, so the discoverable handler refuses it in the one shared place the
|
||||
// username-first door uses. Chained onto a real enrollment so the assertion is genuine crypto.
|
||||
func TestDiscoverableLoginCloneWarningSurfaced(t *testing.T) {
|
||||
v := newTestVerifier(t)
|
||||
rp := virtualRP()
|
||||
authenticator := virtualwebauthn.NewAuthenticator()
|
||||
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||
stored := enrollCredential(t, v, rp, authenticator, cred)
|
||||
|
||||
authenticator.Options.UserHandle = []byte(testUserID)
|
||||
stored.SignCount = 100
|
||||
cred.Counter = 50
|
||||
|
||||
options, sessionData, err := v.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
||||
}
|
||||
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
|
||||
}
|
||||
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
||||
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
||||
return testUser(stored), nil
|
||||
}
|
||||
va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse))
|
||||
if err != nil {
|
||||
t.Fatalf("FinishDiscoverableLogin: %v (a counter regression must still VERIFY, only flag CloneWarning)", err)
|
||||
}
|
||||
if !va.CloneWarning {
|
||||
t.Fatal("va.CloneWarning = false, want true (presented counter at/below the stored counter is a clone signal)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a
|
||||
// unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e.
|
||||
// the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real
|
||||
|
||||
Reference in new issue
Block a user