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
@@ -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