/* * 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. * *
 * java --class-path target/classes:target/deps/* tools/PasskeyCeremony.java [scenario]
 * 
* * Scenarios: * * * 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 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 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 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 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 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 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 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 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 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 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 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 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 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 register(VirtualAuthenticator authenticator, String label) throws Exception { HttpResponse 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 response = post("/webauthn/register", registrationBody(credential, label)); print("POST /webauthn/register", response); return response; } private HttpResponse authenticate(VirtualAuthenticator authenticator, String note) throws Exception { HttpResponse 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 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 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 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 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 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 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 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 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); } }