1
0
Files
spring-auth-demo/docs/passkeys/04-virtual-authenticator.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

4.3 KiB

← 03 — The two ceremonies · index · next: 05 — The defaults

A software authenticator, so the ceremonies can be executed

Every claim in these chapters comes from a run, which means something had to play the part of the security key. Chrome's DevTools virtual authenticator can do it interactively; it cannot be scripted into run-all.sh, and it cannot be checked into a repository.

VirtualAuthenticator is about two hundred lines and does the whole job. Spring Security and WebAuthn4J verify its output without knowing that no hardware was involved — which is itself worth noticing.

What it has to produce

Authenticator data, a fixed binary layout:

rpIdHash          32 bytes   SHA-256 of the rpId string, not of the origin
flags              1 byte    UP 0x01, UV 0x04, BE 0x08, BS 0x10, AT 0x40
signCount          4 bytes   big endian
attestedCredData   variable  registration only: aaguid(16) || credIdLen(2) || credId || COSE key

A COSE public key, CBOR, for ES256:

new Cbor().map(5)
    .num(1).num(2)      // kty: EC2
    .num(3).num(-7)     // alg: ES256
    .num(-1).num(1)     // crv: P-256
    .num(-2).bytes(x)   // 32 bytes, left-padded
    .num(-3).bytes(y)   // 32 bytes, left-padded
    .toByteArray();

The left-padding matters. BigInteger.toByteArray() returns a two's-complement encoding: it prepends a zero byte when the top bit is set, and it drops leading zero bytes when they are not. Either way you get 31 or 33 bytes roughly half the time, and the relying party rejects the key without telling you why.

An attestation object, also CBOR:

new Cbor().map(3)
    .text("fmt").text("none")
    .text("attStmt").map(0)
    .text("authData").bytes(authData)
    .toByteArray();

178 bytes, in this module's case. Cbor is a forty-line encoder covering the four CBOR major types WebAuthn needs; pulling in a library for this would hide the structure rather than explain it.

A signature over authenticatorData || SHA-256(clientDataJSON), using SHA256withECDSA, which emits the DER encoding WebAuthn expects.

What it deliberately does not do

It sets the UP and UV flags because it was asked to, not because anything happened. There is no user presence test, no biometric, no secure element, and the private key sits in the heap next to everything else.

That is not a shortcoming, it is the point. A relying party cannot tell the difference between this and a real authenticator unless it verifies attestation — and by default Spring Security does not. See 05 — The defaults.

Driving it

tools/PasskeyCeremony.java is a single-file source program (JEP 458, so it compiles its dependencies from the same directory on the fly). It uses java.net.http.HttpClient with a real cookie jar and drives the actual HTTP endpoints, CSRF tokens and all — not the operations bean directly — so filter ordering, session handling and response codes are exercised too.

java --class-path "target/classes:$(cat target/deps.txt)" tools/PasskeyCeremony.java register-and-login
scenario what it shows
register-and-login both ceremonies end to end
clone-counter the signature counter, and what is done with it
no-uv a credential whose UV flag was never set
wrong-origin / wrong-origin-login a phishing attempt, in each ceremony
duplicate excludeCredentials, and a client that ignores it
bootstrap asking for registration options with nobody logged in
ott the one-time-token fallback
filters the live filter chain

The contract tests use the same authenticator against Webauthn4JRelyingPartyOperations directly, with no HTTP and no Spring context, so a failure there is the framework's behaviour rather than a filter accident.

← 03 — The two ceremonies · index · next: 05 — The defaults