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.
407 lines
18 KiB
Java
407 lines
18 KiB
Java
/*
|
|
* 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);
|
|
}
|
|
|
|
}
|