1
0

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:
2026-08-25 22:54:40 +05:30
parent e9381dc5be
commit f6dd692177
59 changed files with 3567 additions and 4 deletions

View File

@@ -0,0 +1,93 @@
[← 08 — The one-time-token fallback](08-one-time-token-fallback.md) · [index](README.md) · next: [10 — The signature counter](10-signature-counter.md)
# Persistence
Two repositories, and the default for both is a `HashMap`.
| interface | default | JDBC implementation |
|---|---|---|
| `PublicKeyCredentialUserEntityRepository` | `MapPublicKeyCredentialUserEntityRepository` | `JdbcPublicKeyCredentialUserEntityRepository` |
| `UserCredentialRepository` | `MapUserCredentialRepository` | `JdbcUserCredentialRepository` |
`WebAuthnConfigurer.configure` constructs the map-backed ones if no beans exist, without
logging anything. In a passwordless application that is a trap with teeth: restart the process
and every user's only credential is gone, and by design they have no password to fall back on.
## Switching to JDBC
```java
@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}
@Bean
UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
return new JdbcUserCredentialRepository(jdbc);
}
```
Nothing creates the tables. The Javadoc points at
`classpath:org/springframework/security/user-credentials-schema.sql`, and searching
`spring-security-webauthn-7.1.1.jar` for it finds nothing — the two DDL files stayed in
`spring-security-web` when the classes moved out in 7.0 ([01](01-versions.md)). They are still
on the classpath transitively, so this works:
```yaml
spring:
sql:
init:
mode: always
schema-locations:
- classpath:org/springframework/security/user-entities-schema.sql
- classpath:org/springframework/security/user-credentials-schema.sql
```
There is a Postgres variant, `user-credentials-schema-postgres.sql`, which differs only in
`bytea` versus `blob`. There is no Postgres variant of the user entities schema, because it
does not need one.
Run it with `./scripts/run.sh jdbc`; the ceremony transcript is
[`docs/output/pk-jdbc.txt`](../output/pk-jdbc.txt) and is identical to the in-memory one, which
is the point.
## What a credential record holds
```
credential_id the raw id, base64url
user_entity_user_id the random user handle, not the username
public_key the COSE key, as CBOR bytes
signature_count updated on every assertion - see chapter 10
uv_initialized whether UV was set at registration
backup_eligible / backup_state whether this is a syncable passkey, and whether it is synced
authenticator_transports internal, hybrid, usb, nfc, ble
attestation_object the full attestation object from registration
attestation_client_data_json the client data from registration
created / last_used timestamps
label whatever the user typed
```
`attestation_object` is nullable in the DDL and mandatory in practice.
`Webauthn4JRelyingPartyOperations.authenticate` re-parses it on every login to recover the COSE
key:
```java
Bytes attestationObject = credentialRecord.getAttestationObject();
Assert.notNull(attestationObject, "attestationObject cannot be null");
AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), ...);
```
So a custom `UserCredentialRepository` that stores the public key but drops the attestation
object — a reasonable-looking optimisation, since the public key is right there in its
own column — produces credentials that register fine and can never log in. The symptom is
a bare 401. That re-parse is also the mechanism behind
[chapter 10](10-signature-counter.md).
## Things to decide before you ship
- **Deleting a credential.** `DELETE /webauthn/register/{id}` exists and is guarded by `CredentialRecordOwnerAuthorizationManager`, so users cannot delete each other's passkeys. Make sure your UI exposes it, and make sure a user cannot delete their last one.
- **Labels.** The label is user-supplied and displayed back; treat it as untrusted, and cap its length. The column is `varchar(1000)`.
- **Session storage.** The challenge lives in the `HttpSession` between the options call and the ceremony call. Behind a load balancer without sticky sessions, registration fails intermittently and nothing else does. Spring Session with Redis has its own open issue — [spring-security#16328](https://github.com/spring-projects/spring-security/issues/16328).
- **Backups.** In a passwordless application the credential table *is* the account. Losing it is not a data-loss incident, it is a lockout incident for every user at once.
[← 08 — The one-time-token fallback](08-one-time-token-fallback.md) · [index](README.md) · next: [10 — The signature counter](10-signature-counter.md)