Fourth Maven project in the repository. Registration and authentication run end to end with no browser and no hardware key: VirtualAuthenticator emits real CBOR attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java drives the live HTTP endpoints with them. Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
139 lines
6.4 KiB
Markdown
139 lines
6.4 KiB
Markdown
[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)
|
|
|
|
# The signature counter is stored on every login and compared against on none
|
|
|
|
WebAuthn's signature counter exists for one purpose: detecting a cloned authenticator. A
|
|
hardware key increments a monotonic counter each time it signs, so if the relying party ever
|
|
sees a counter it has already seen, two copies of the private key are in circulation.
|
|
|
|
Spring Security persists that counter faithfully. It never reads it back for verification.
|
|
|
|
## Watching it happen
|
|
|
|
[`scripts/counter.sh`](../../passkeys/scripts/counter.sh) registers one credential, then
|
|
authenticates three times with counters 1, 2 and 3, checking what the relying party stored
|
|
after each. Then it presents a counter of 1 again. From
|
|
[`docs/output/pk-counter.txt`](../output/pk-counter.txt):
|
|
|
|
```
|
|
--- assertion 1, authenticator signCount = 1
|
|
$ POST /login/webauthn
|
|
HTTP 200
|
|
{"authenticated":true,"redirectUrl":"/"}
|
|
stored signatureCount now: 1
|
|
|
|
--- assertion 2, authenticator signCount = 2
|
|
stored signatureCount now: 2
|
|
|
|
--- assertion 3, authenticator signCount = 3
|
|
stored signatureCount now: 3
|
|
|
|
=== Replaying a stale counter. A cloned key would look exactly like this ===
|
|
--- assertion 4, authenticator signCount = 1 (lower than the stored 3)
|
|
$ POST /login/webauthn
|
|
HTTP 200
|
|
{"authenticated":true,"redirectUrl":"/"}
|
|
|
|
replayed a counter of 1 after the relying party had stored 3: HTTP 200
|
|
```
|
|
|
|
The stored value walks 1, 2, 3 exactly as you would hope. Then a counter of 1 is accepted.
|
|
|
|
## Why
|
|
|
|
WebAuthn4J *does* implement the check. `AuthenticationDataVerifier` follows the specification
|
|
line by line:
|
|
|
|
```java
|
|
long presentedSignCount = authenticatorData.getSignCount();
|
|
long storedSignCount = authenticator.getCounter();
|
|
if (presentedSignCount > 0 || storedSignCount > 0) {
|
|
if (presentedSignCount > storedSignCount) { ... }
|
|
else { maliciousCounterValueHandler.maliciousCounterValueDetected(authenticationObject); }
|
|
}
|
|
```
|
|
|
|
and the default handler throws `MaliciousCounterValueException` with the message
|
|
“Malicious counter value is detected. Cloned authenticators exist in parallel.”
|
|
|
|
The gap is in what `authenticator.getCounter()` returns. Spring builds the WebAuthn4J
|
|
credential record like this:
|
|
|
|
```java
|
|
AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), AttestationObject.class);
|
|
com.webauthn4j.credential.CredentialRecord wa4jCredentialRecord =
|
|
new CredentialRecordImpl(wa4jAttestationObject, null, null, transports);
|
|
```
|
|
|
|
— `Webauthn4JRelyingPartyOperations.authenticate`, Spring Security 7.1.1
|
|
|
|
and that constructor derives the counter from the attestation object:
|
|
|
|
```java
|
|
public CoreCredentialRecordImpl(@NotNull AttestationObject attestationObject) {
|
|
super(attestationObject.getAuthenticatorData().getAttestedCredentialData(),
|
|
attestationObject.getAttestationStatement(),
|
|
attestationObject.getAuthenticatorData().getSignCount(), // <-- the counter
|
|
attestationObject.getAuthenticatorData().getExtensions());
|
|
```
|
|
|
|
— `CoreCredentialRecordImpl`, WebAuthn4J 0.31.9
|
|
|
|
The attestation object is the one captured at **registration**. Its counter is frozen at
|
|
whatever the authenticator reported then — almost always 0. So the comparison is always
|
|
“is the presented counter greater than 0?”, the persisted `signature_count` column
|
|
is never consulted, and the only assertion that would ever trip the check is one presenting a
|
|
counter of exactly 0 against a registration counter of 0. Which is precisely what every
|
|
synced passkey does, on every login, forever — and those are not rejected either, because
|
|
of the `presentedSignCount > 0 || storedSignCount > 0` guard.
|
|
|
|
The contract test
|
|
[`signatureCounterIsStoredButNotVerified`](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
|
|
pins this at the operations level, with no HTTP involved, and will start failing the day it
|
|
changes.
|
|
|
|
## How much does this matter?
|
|
|
|
Less than the paragraphs above suggest, and it is worth being precise about why.
|
|
|
|
**The specification treats a mismatch as a signal, not a verdict.** WebAuthn Level 3 does make
|
|
the comparison a step in the assertion verification procedure. What it does *not* do is
|
|
prescribe the response: a counter that fails to increase is “a signal, but not proof,
|
|
that the authenticator may be cloned”, since it might equally be a malfunctioning
|
|
authenticator or assertions processed out of order, and relying parties are told to
|
|
“evaluate their own operational characteristics and incorporate this information into
|
|
their risk scoring”. Spring Security sits at the permissive end of that range rather
|
|
than outside it.
|
|
|
|
**Modern passkeys have no counter.** A credential synced through iCloud Keychain, Google
|
|
Password Manager or 1Password exists on several devices by design; a monotonic counter is
|
|
meaningless across them, so those authenticators report 0 permanently. For a consumer
|
|
application where most credentials are syncable, counter checking would detect nothing and
|
|
would risk false positives.
|
|
|
|
**It is still worth knowing.** If you are enrolling FIDO2 hardware keys in an enterprise
|
|
setting, the counter is real, it increments, and the check is a genuine clone detector —
|
|
and you do not have it. The `signature_count` column filling up correctly makes it look like
|
|
you do, which is the actual hazard here: not a hole, but a control you might believe you have.
|
|
|
|
## If you need it
|
|
|
|
Nothing in the API blocks you. Wrap the operations bean:
|
|
|
|
```java
|
|
@Bean
|
|
WebAuthnRelyingPartyOperations relyingPartyOperations(UserCredentialRepository credentials, ...) {
|
|
Webauthn4JRelyingPartyOperations delegate = new Webauthn4JRelyingPartyOperations(...);
|
|
return new CounterCheckingRelyingPartyOperations(delegate, credentials);
|
|
}
|
|
```
|
|
|
|
where the wrapper reads `credentials.findByCredentialId(id).getSignatureCount()` before
|
|
delegating, remembers it, and after `authenticate` returns compares it against the value the
|
|
delegate has just written — rejecting the login if it did not increase and either value
|
|
was non-zero. That reproduces the specification's rule using the column Spring already
|
|
maintains. Alert rather than block if syncable passkeys are in the mix; a permanent 0 must not
|
|
lock anybody out.
|
|
|
|
[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)
|