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:
126
docs/passkeys/05-defaults.md
Normal file
126
docs/passkeys/05-defaults.md
Normal file
@@ -0,0 +1,126 @@
|
||||
[← 04 — A software authenticator](04-virtual-authenticator.md) · [index](README.md) · next: [06 — The bootstrap problem](06-the-bootstrap-problem.md)
|
||||
|
||||
# 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:
|
||||
|
||||
```java
|
||||
AuthenticatorSelectionCriteria.builder()
|
||||
.userVerification(UserVerificationRequirement.PREFERRED)
|
||||
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||
.build();
|
||||
```
|
||||
|
||||
and both `registerCredential` and `authenticate` decide whether to enforce UV like this:
|
||||
|
||||
```java
|
||||
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`](../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:
|
||||
|
||||
```java
|
||||
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:
|
||||
|
||||
```json
|
||||
{"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`](../output/pk-attestation.txt):
|
||||
|
||||
```
|
||||
$ POST /webauthn/register
|
||||
HTTP 200
|
||||
{"success":true}
|
||||
```
|
||||
|
||||
The reason is the manager. `Webauthn4JRelyingPartyOperations` initialises with:
|
||||
|
||||
```java
|
||||
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 `HttpSession` between 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.** `userPresenceRequired` is hard-coded `true` at registration, per the specification.
|
||||
- **Duplicate credential ids.** `registerCredential` rejects 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](10-signature-counter.md).
|
||||
|
||||
[← 04 — A software authenticator](04-virtual-authenticator.md) · [index](README.md) · next: [06 — The bootstrap problem](06-the-bootstrap-problem.md)
|
||||
Reference in New Issue
Block a user