1
0
Files
spring-auth-demo/docs/passkeys/03-the-two-ceremonies.md
Ankur Mhatre f6dd692177 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.
2026-08-25 23:00:27 +05:30

98 lines
4.9 KiB
Markdown

[← 02 — The minimum configuration](02-minimum-configuration.md) · [index](README.md) · next: [04 — A software authenticator](04-virtual-authenticator.md)
# 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`](../output/pk-ceremony.txt)):
```json
{"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](05-defaults.md) |
| `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
```
```json
{"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](07-failure-modes.md)
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](06-the-bootstrap-problem.md).
[← 02 — The minimum configuration](02-minimum-configuration.md) · [index](README.md) · next: [04 — A software authenticator](04-virtual-authenticator.md)