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.
5.7 KiB
← 04 — A software authenticator · index · next: 06 — The bootstrap problem
The defaults, and what they do not check
Spring Security's passkey defaults are reasonable. They are also weaker than most people assume, in ways that do not show up until you go looking.
userVerification is preferred, which means optional
Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions builds:
AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.PREFERRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build();
and both registerCredential and authenticate decide whether to enforce UV like this:
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
PREFERRED is not REQUIRED, so the flag is not checked. An authenticator that answers with
UV clear registers successfully and logs in successfully. From
docs/output/pk-user-verification.txt:
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials (note uvInitialized)
{"credentials":[{"label":"no-uv", ... "signatureCount":0,"uvInitialized":false, ...}]}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
uvInitialized: false is recorded faithfully — and then nothing consults it. What you
have is a single-factor credential: possession of the authenticator, with no proof that the
person holding it is the enrolled user. For most consumer sites that is an acceptable trade
(it is roughly what a password gives you, minus the phishing). For anything where the passkey
is the second factor, it is not, and the fix is one setting on both ceremonies:
operations.setCustomizeCreationOptions((options) -> options.authenticatorSelection(
AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build()));
operations.setCustomizeRequestOptions((options) ->
options.userVerification(UserVerificationRequirement.REQUIRED));
Setting only the first leaves logins unverified forever, which is the worst of the three
outcomes because registration looks correct. The uvrequired profile in this module sets
both; with it, the same authenticator is refused:
com.webauthn4j.verifier.exception.UserNotVerifiedException:
Verifier is configured to check user verified, but UV flag in authenticatorData is not set.
AuthenticatorSelectionCriteria.builder() has no copy constructor, so overriding
userVerification means restating residentKey too. Forget it and you silently drop back to
a non-discoverable credential, and usernameless login stops working.
Asking for attestation is not verifying attestation
The default is AttestationConveyancePreference.NONE. Switch it to DIRECT and the options
change:
{"attestation":"direct","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"}, ...}
Then register with an authenticator that answers fmt: "none" and an all-zero AAGUID. From
docs/output/pk-attestation.txt:
$ POST /webauthn/register
HTTP 200
{"success":true}
The reason is the manager. Webauthn4JRelyingPartyOperations initialises with:
private WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
and that factory installs NullFIDOU2FAttestationStatementVerifier,
NullPackedAttestationStatementVerifier, NullTPMAttestationStatementVerifier,
NullAndroidKeyAttestationStatementVerifier, NullAndroidSafetyNetAttestationStatementVerifier,
NullAppleAnonymousAttestationStatementVerifier, a NullCertPathTrustworthinessVerifier and a
NullSelfAttestationTrustworthinessVerifier. Everything that could check an attestation
statement is a null object.
WebAuthn4J 0.31.9 does not ship a strict counterpart. WebAuthnManager has exactly two static
factories, createNonStrictWebAuthnManager() and its ObjectConverter overload; a strict
manager has to be assembled from real verifiers and a TrustAnchorRepository by hand, and
then setWebAuthnManager on the operations bean. That is a real project, and it is only worth
starting if you have an enterprise reason to care which model of key your users hold. For a
consumer product, attestation is usually the wrong thing to spend effort on — but you
should know that the direct in your options object is currently decorative.
What is checked, by default, and correctly
Not everything is a caveat. Out of the box, all of these hold:
- Origin. A mismatch is rejected in both ceremonies. This is the phishing defence and it works.
- Challenge. Persisted in the
HttpSessionbetween the options call and the ceremony call, so a replayed or fabricated challenge fails. - rpId hash. Compared against the configured relying party.
- Signature. Verified against the stored COSE key, with the algorithm the key declares.
- User presence.
userPresenceRequiredis hard-codedtrueat registration, per the specification. - Duplicate credential ids.
registerCredentialrejects an id that already exists.
The unchecked list is short: user verification unless you ask for it, attestation unless you build for it, and the signature counter — which gets its own chapter, 10 — The signature counter.
← 04 — A software authenticator · index · next: 06 — The bootstrap problem