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,406 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.HttpCookie;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ankurm.passkeys.virtual.VirtualAuthenticator;
/**
* Drives both WebAuthn ceremonies against a running instance, with no browser.
*
* <pre>
* java --class-path target/classes:target/deps/* tools/PasskeyCeremony.java [scenario]
* </pre>
*
* Scenarios:
* <ul>
* <li>{@code register-and-login} (default) - password login, register a passkey, log out, log
* back in with the passkey</li>
* <li>{@code clone-counter} - register, authenticate with an increasing counter, then replay a
* stale counter and see whether the relying party notices</li>
* <li>{@code no-uv} - register and authenticate with the UV flag clear</li>
* <li>{@code wrong-origin} - register with client data from an origin the relying party did
* not allow</li>
* <li>{@code wrong-origin-login} - assert from a disallowed origin, which fails differently
* from registering from one</li>
* <li>{@code duplicate} - register the same credential id twice</li>
* <li>{@code ott} - request a one-time token and redeem it, the fallback path</li>
* <li>{@code filters} - print the live security filter chain</li>
* <li>{@code bootstrap} - what happens when nobody is logged in yet</li>
* <li>{@code stepup} - an endpoint that only a passkey session can reach</li>
* </ul>
*
* Everything it prints is the real request and the real response. Nothing is transcribed.
*/
public final class PasskeyCeremony {
private static final String BASE = System.getProperty("demo.base", "http://localhost:8080");
private static final String ORIGIN = System.getProperty("demo.origin", BASE);
private static final String RP_ID = System.getProperty("demo.rpId", "localhost");
private static final Pattern CSRF = Pattern
.compile("name=\"_csrf\"[^>]*value=\"([^\"]+)\"|value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
private final HttpClient http;
private final CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
private PasskeyCeremony() {
CookieHandler.setDefault(this.cookies);
this.http = HttpClient.newBuilder()
.cookieHandler(this.cookies)
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public static void main(String[] args) throws Exception {
String scenario = (args.length > 0) ? args[0] : "register-and-login";
PasskeyCeremony ceremony = new PasskeyCeremony();
switch (scenario) {
case "register-and-login" -> ceremony.registerAndLogin();
case "clone-counter" -> ceremony.cloneCounter();
case "no-uv" -> ceremony.noUserVerification();
case "wrong-origin" -> ceremony.wrongOrigin();
case "wrong-origin-login" -> ceremony.wrongOriginLogin();
case "duplicate" -> ceremony.duplicateRegistration();
case "ott" -> ceremony.oneTimeToken();
case "filters" -> ceremony.filters();
case "bootstrap" -> ceremony.bootstrap();
case "stepup" -> ceremony.stepUp();
default -> throw new IllegalArgumentException("unknown scenario: " + scenario);
}
}
// ---------------------------------------------------------------- scenarios
private void registerAndLogin() throws Exception {
banner("Registration ceremony, then authentication ceremony, no browser involved");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "yubikey-on-my-desk");
print("GET /diag/credentials", get("/diag/credentials"));
logout();
banner("Session dropped. Authenticating with the passkey alone");
authenticate(authenticator, null);
print("GET /me", get("/me"));
}
private void cloneCounter() throws Exception {
banner("Signature counter: does the relying party detect a cloned authenticator?");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "counter-demo");
logout();
for (int i = 1; i <= 3; i++) {
authenticator.signCount(i);
System.out.printf("%n--- assertion %d, authenticator signCount = %d%n", i, authenticator.signCount());
authenticate(authenticator, null);
System.out.println("stored signatureCount now: " + storedSignatureCount("user", "password"));
logout();
}
banner("Replaying a stale counter. A cloned key would look exactly like this");
authenticator.signCount(1);
System.out.println("--- assertion 4, authenticator signCount = 1 (lower than the stored 3)");
HttpResponse<String> replay = authenticate(authenticator, "replay");
System.out.printf("%nreplayed a counter of 1 after the relying party had stored 3: HTTP %d%n",
replay.statusCode());
}
private void noUserVerification() throws Exception {
banner("A credential created and asserted with the UV flag clear");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
HttpResponse<String> registration = register(authenticator, "no-uv");
if (registration.statusCode() != 200) {
System.out.println("registration refused, which is what userVerification REQUIRED does");
return;
}
print("GET /diag/credentials (note uvInitialized)", get("/diag/credentials"));
logout();
authenticator.signCount(1);
HttpResponse<String> assertion = authenticate(authenticator, "no-uv");
System.out.printf("%nauthentication with UV clear: HTTP %d%n", assertion.statusCode());
}
private void wrongOrigin() throws Exception {
banner("Client data from an origin the relying party did not allow");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
String optionsJson = post("/webauthn/register/options", "").body();
String challenge = jsonString(optionsJson, "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID,
"http://evil.localhost:8080", challenge);
String body = registrationBody(credential, "phished");
HttpResponse<String> response = post("/webauthn/register", body);
System.out.println("origin sent by the client: http://evil.localhost:8080");
System.out.println("origin allowed by the relying party: " + ORIGIN);
print("POST /webauthn/register", response);
}
private void wrongOriginLogin() throws Exception {
banner("An assertion from a disallowed origin - the same mistake, one ceremony later");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "phishable");
logout();
HttpResponse<String> options = post("/webauthn/authenticate/options", "");
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, "http://evil.localhost:8080",
challenge, null);
String body = """
{"id":"%s","rawId":"%s","response":{"authenticatorData":"%s","clientDataJSON":"%s","signature":"%s"},\
"clientExtensionResults":{},"type":"public-key","authenticatorAttachment":"platform"}"""
.formatted(assertion.credentialId(), assertion.credentialId(), assertion.authenticatorData(),
assertion.clientDataJson(), assertion.signature());
print("POST /login/webauthn (origin http://evil.localhost:8080)", post("/login/webauthn", body));
}
private void duplicateRegistration() throws Exception {
banner("Registering the same credential id twice");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "first");
System.out.println("\nsame authenticator, same credential id, second registration:");
HttpResponse<String> options = post("/webauthn/register/options", "");
System.out.println("excludeCredentials now: " + jsonArray(options.body(), "excludeCredentials"));
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID, ORIGIN, challenge);
print("POST /webauthn/register", post("/webauthn/register", registrationBody(credential, "second")));
}
private void oneTimeToken() throws Exception {
banner("One-time token: the way in when there is no passkey yet, and the way back");
String csrf = csrfToken("/login");
HttpResponse<String> generated = form("/ott/generate", "username=user", csrf);
System.out.printf("POST /ott/generate -> HTTP %d, Location: %s%n", generated.statusCode(),
generated.headers().firstValue("location").orElse("-"));
String token = java.nio.file.Files
.readString(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt"))
.trim();
System.out.println("token delivered out of band (the handler wrote it to a file): " + token);
String submitCsrf = csrfToken("/login/ott?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8));
HttpResponse<String> redeemed = form("/login/ott",
"token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), submitCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s%n", redeemed.statusCode(),
redeemed.headers().firstValue("location").orElse("-"));
print("GET /me", get("/me"));
banner("The same token, a second time");
String replayCsrf = csrfToken("/login");
HttpResponse<String> replay = form("/login/ott", "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8),
replayCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s%n", replay.statusCode(),
replay.headers().firstValue("location").orElse("-"));
}
private void bootstrap() throws Exception {
banner("Asking for registration options with nobody logged in");
print("POST /webauthn/register/options (anonymous)", post("/webauthn/register/options", ""));
banner("A one-time token for a username that does not exist");
String csrf = csrfToken("/login");
HttpResponse<String> generated = form("/ott/generate", "username=nosuchuser", csrf);
System.out.printf("POST /ott/generate -> HTTP %d, Location: %s%n", generated.statusCode(),
generated.headers().firstValue("location").orElse("-"));
String token = java.nio.file.Files
.readString(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt"))
.trim();
System.out.println("a token was still generated and delivered: " + token);
System.out.println("the response is byte-for-byte what a real username produces - no enumeration oracle");
String submitCsrf = csrfToken("/login");
HttpResponse<String> redeemed = form("/login/ott",
"token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), submitCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s (the failure lands here instead)%n",
redeemed.statusCode(), redeemed.headers().firstValue("location").orElse("-"));
}
private void stepUp() throws Exception {
banner("An endpoint guarded by hasAuthority(\"FACTOR_WEBAUTHN\")");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "step-up-demo");
HttpResponse<String> passwordAttempt = get("/passkey-only");
System.out.printf("%npassword session -> GET /passkey-only: HTTP %d, Location: %s%n",
passwordAttempt.statusCode(), passwordAttempt.headers().firstValue("location").orElse("-"));
logout();
oneTimeToken();
HttpResponse<String> ottAttempt = get("/passkey-only");
System.out.printf("%none-time-token session -> GET /passkey-only: HTTP %d, Location: %s%n",
ottAttempt.statusCode(), ottAttempt.headers().firstValue("location").orElse("-"));
logout();
authenticator.signCount(1);
authenticate(authenticator, "stepup");
print("passkey session -> GET /passkey-only", get("/passkey-only"));
}
private void filters() throws Exception {
passwordLogin("user", "password");
String body = get("/diag/filters").body();
Matcher matcher = Pattern.compile("\"( ?\\d+ [A-Za-z0-9]+)\"").matcher(body);
while (matcher.find()) {
System.out.println(matcher.group(1));
}
}
// ---------------------------------------------------------------- ceremony steps
private HttpResponse<String> register(VirtualAuthenticator authenticator, String label) throws Exception {
HttpResponse<String> options = post("/webauthn/register/options", "");
print("POST /webauthn/register/options", options);
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID, ORIGIN, challenge);
System.out.println("authenticator produced credentialId " + credential.credentialId() + " and a "
+ VirtualAuthenticator.decodeBase64Url(credential.attestationObject()).length
+ "-byte CBOR attestation object");
HttpResponse<String> response = post("/webauthn/register", registrationBody(credential, label));
print("POST /webauthn/register", response);
return response;
}
private HttpResponse<String> authenticate(VirtualAuthenticator authenticator, String note) throws Exception {
HttpResponse<String> options = post("/webauthn/authenticate/options", "");
if (note == null) {
print("POST /webauthn/authenticate/options", options);
}
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, ORIGIN, challenge, null);
String body = """
{"id":"%s","rawId":"%s","response":{"authenticatorData":"%s","clientDataJSON":"%s","signature":"%s"},\
"clientExtensionResults":{},"type":"public-key","authenticatorAttachment":"platform"}"""
.formatted(assertion.credentialId(), assertion.credentialId(), assertion.authenticatorData(),
assertion.clientDataJson(), assertion.signature());
HttpResponse<String> response = post("/login/webauthn", body);
print("POST /login/webauthn", response);
return response;
}
private String registrationBody(VirtualAuthenticator.Registration credential, String label) {
return """
{"publicKey":{"credential":{"id":"%s","rawId":"%s","response":{"attestationObject":"%s",\
"clientDataJSON":"%s","transports":["internal","hybrid"]},"type":"public-key",\
"clientExtensionResults":{},"authenticatorAttachment":"platform"},"label":"%s"}}"""
.formatted(credential.credentialId(), credential.credentialId(), credential.attestationObject(),
credential.clientDataJson(), label);
}
private void passwordLogin(String username, String password) throws Exception {
String csrf = csrfToken("/login");
HttpResponse<String> response = form("/login", "username=" + username + "&password=" + password, csrf);
System.out.printf("POST /login (password) -> HTTP %d, Location: %s%n", response.statusCode(),
response.headers().firstValue("location").orElse("-"));
}
private void logout() throws Exception {
String csrf = csrfToken("/login");
form("/logout", "", csrf);
this.cookies.getCookieStore().removeAll();
System.out.println("logged out, cookie jar emptied");
}
private long storedSignatureCount(String username, String password) throws Exception {
CookieManager saved = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
List<HttpCookie> current = this.cookies.getCookieStore().getCookies();
current.forEach((c) -> saved.getCookieStore().add(null, c));
passwordLogin(username, password);
String body = get("/diag/credentials").body();
Matcher matcher = Pattern.compile("\"signatureCount\"\\s*:\\s*(\\d+)").matcher(body);
return matcher.find() ? Long.parseLong(matcher.group(1)) : -1;
}
// ---------------------------------------------------------------- plumbing
private String csrfToken(String path) throws Exception {
HttpResponse<String> page = get(path);
Matcher matcher = CSRF.matcher(page.body());
if (matcher.find()) {
return (matcher.group(1) != null) ? matcher.group(1) : matcher.group(2);
}
return this.cookies.getCookieStore()
.getCookies()
.stream()
.filter((c) -> "XSRF-TOKEN".equals(c.getName()))
.map(HttpCookie::getValue)
.findFirst()
.orElseThrow(() -> new IllegalStateException("no CSRF token found on " + path));
}
private HttpResponse<String> get(String path) throws Exception {
return this.http.send(HttpRequest.newBuilder(URI.create(BASE + path)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}
/** JSON POST with the CSRF token in a header, which is how the WebAuthn endpoints work. */
private HttpResponse<String> post(String path, String json) throws Exception {
String csrf = csrfToken("/login");
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("X-CSRF-TOKEN", csrf)
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
return this.http.send(request, HttpResponse.BodyHandlers.ofString());
}
private HttpResponse<String> form(String path, String body, String csrf) throws Exception {
String payload = body.isEmpty() ? "_csrf=" + URLEncoder.encode(csrf, StandardCharsets.UTF_8)
: body + "&_csrf=" + URLEncoder.encode(csrf, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
return this.http.send(request, HttpResponse.BodyHandlers.ofString());
}
private static String jsonArray(String json, String field) {
Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*(\\[[^\\]]*\\])").matcher(json);
return matcher.find() ? matcher.group(1) : "?";
}
private static String jsonString(String json, String field) {
Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
if (!matcher.find()) {
throw new IllegalStateException("no \"" + field + "\" in: " + json);
}
return matcher.group(1);
}
private static void print(String what, HttpResponse<String> response) {
String body = response.body();
if (body.length() > 1200) {
body = body.substring(0, 1200) + "\n... (truncated)";
}
System.out.printf("%n$ %s%nHTTP %d%n%s%n", what, response.statusCode(), body.isBlank() ? "(empty body)" : body);
}
private static void banner(String text) {
System.out.printf("%n=== %s ===%n", text);
}
}