Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback
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.
This commit is contained in:
138
docs/passkeys/10-signature-counter.md
Normal file
138
docs/passkeys/10-signature-counter.md
Normal file
@@ -0,0 +1,138 @@
|
||||
[← 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)
|
||||
Reference in New Issue
Block a user