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.
4.9 KiB
← 02 — The minimum configuration · index · next: 04 — A software authenticator
The two ceremonies
WebAuthn has exactly two flows, and both have the same shape: the server issues a challenge, the authenticator answers it, the server verifies the answer. Everything else is detail.
Registration
POST /webauthn/register/options (authenticated session + CSRF token required)
-> challenge, rp, user, pubKeyCredParams, authenticatorSelection, attestation, timeout
and the challenge is stored in the HttpSession
navigator.credentials.create({ publicKey: options })
-> a key pair inside the authenticator, and an attestation object containing the public half
POST /webauthn/register { publicKey: { credential: {...}, label: "..." } }
-> { "success": true }, and a CredentialRecord in the UserCredentialRepository
The real options object, from a live run
(docs/output/pk-ceremony.txt):
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},
"challenge":"8H0qrJXIL_StIdOetvSm31hEcEy0NMoN8xz67u4UwFM","excludeCredentials":[],
"extensions":{"credProps":true},
"pubKeyCredParams":[{"alg":-8,"type":"public-key"},{"alg":-7,"type":"public-key"},{"alg":-257,"type":"public-key"}],
"rp":{"id":"localhost","name":"ankurm passkeys demo"},"timeout":300000,
"user":{"name":"user","id":"9RZ4HDuLE38GIFoTpth0hyAooU7i1HV49qJ46isEtAs","displayName":"user"}}
Everything in there is a Spring Security default, and every one of them is a decision:
| field | default | what it means |
|---|---|---|
attestation |
none |
do not ask the authenticator to identify its make and model |
residentKey |
required |
a discoverable credential, so the user need not type a username |
userVerification |
preferred |
check a PIN or biometric if convenient — see 05 |
pubKeyCredParams |
EdDSA, ES256, RS256 | in that order of preference |
timeout |
300000 ms | five minutes to complete the ceremony |
extensions |
credProps |
ask the browser whether the credential ended up discoverable |
excludeCredentials |
this user's existing credentials | so the same authenticator is not enrolled twice |
user.id is a random 32-byte handle generated by
Webauthn4JRelyingPartyOperations.findUserEntityOrCreateAndSave on first use. It is not
the username, and it must not be: it is stored in the authenticator, it syncs to the user's
other devices, and it is visible to anything that can talk to the authenticator.
Authentication
POST /webauthn/authenticate/options (CSRF token required; no session needed)
-> challenge, rpId, allowCredentials, userVerification, timeout
navigator.credentials.get({ publicKey: options })
-> authenticatorData, clientDataJSON, an ECDSA signature, and a userHandle
POST /login/webauthn { id, rawId, response: {...}, type: "public-key" }
-> { "authenticated": true, "redirectUrl": "/" } or a bare 401
{"allowCredentials":[],"challenge":"18HVET3lRrveXvRdKS1K5k2Ji_3nqm1Krx_l3ZOB0wU","extensions":{},
"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
allowCredentials is empty because the caller is anonymous:
Webauthn4JRelyingPartyOperations.findCredentialRecords returns an empty list when there is
no authenticated user, and the browser falls back to offering whatever discoverable
credentials it holds for that rpId. That empty array is the usernameless login most people
mean when they say “passkey”, and it only works because residentKey defaulted to
required during registration. The two settings are one decision made in two places.
What the signature actually covers
signature = ECDSA-SHA256( authenticatorData || SHA-256(clientDataJSON) )
authenticatorData is rpIdHash(32) || flags(1) || signCount(4), with attested credential
data appended during registration and omitted during assertion. clientDataJSON carries the
type, the challenge and the origin — and it is the browser, not the page, that fills
the origin in. That is the entire phishing defence: a credential minted for bank.example is
never offered to bank-example.evil, and even if it were, the origin in client data would not
match and the relying party would refuse. 07 — Failure modes
shows that refusal happening.
The bit that surprises people
Nothing above involves a password, and nothing above involves a username either — but the registration ceremony required an authenticated session to even start. The credential is bound to a user who was already identified some other way. See 06 — The bootstrap problem.
← 02 — The minimum configuration · index · next: 04 — A software authenticator