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.
98 lines
4.9 KiB
Markdown
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)
|