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:
406
passkeys/tools/PasskeyCeremony.java
Normal file
406
passkeys/tools/PasskeyCeremony.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user