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:
84
passkeys/pom.xml
Normal file
84
passkeys/pom.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>passkeys-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>passkeys-demo</name>
|
||||
<description>Passkeys and WebAuthn with Spring Security 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com</description>
|
||||
|
||||
<!--
|
||||
One application, port 8080, rpId "localhost".
|
||||
|
||||
The interesting part of this module is that it needs no browser. tools/PasskeyCeremony.java
|
||||
drives the real HTTP endpoints (/webauthn/register/options, /webauthn/register,
|
||||
/webauthn/authenticate/options, /login/webauthn) using a software authenticator that
|
||||
produces genuine CBOR attestation objects and genuine ES256 assertion signatures -
|
||||
see src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java and
|
||||
docs/passkeys/04-virtual-authenticator.md.
|
||||
|
||||
spring-security-webauthn is NOT part of spring-boot-starter-security. As of Spring
|
||||
Security 7.0 the WebAuthn classes live in their own artifact; in 6.4 and 6.5 they were
|
||||
inside spring-security-web. See docs/passkeys/01-versions.md.
|
||||
-->
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<!-- The one dependency the passkey docs are easy to miss. Version managed by Boot. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-webauthn</artifactId>
|
||||
</dependency>
|
||||
<!-- Only used by the "jdbc" profile, to show what JDBC persistence actually requires. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
9
passkeys/scripts/attestation.sh
Executable file
9
passkeys/scripts/attestation.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ask for DIRECT attestation. Register with fmt "none" and an all-zero AAGUID anyway.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app attestationdirect > /dev/null
|
||||
{
|
||||
header "attestation: DIRECT requested, attestation: none accepted"
|
||||
ceremony register-and-login
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-attestation.txt"
|
||||
stop_app
|
||||
9
passkeys/scripts/bootstrap.sh
Executable file
9
passkeys/scripts/bootstrap.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# The chicken-and-egg problem: a passkey cannot be a user's first credential.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "Bootstrapping - registering a passkey requires an existing authenticated session"
|
||||
ceremony bootstrap
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-bootstrap.txt"
|
||||
stop_app
|
||||
11
passkeys/scripts/ceremony.sh
Executable file
11
passkeys/scripts/ceremony.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# The whole thing, end to end: password login, passkey registration, logout, passkey login.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "Registration and authentication ceremonies, driven without a browser"
|
||||
echo "Spring Security 7.1.1, Spring Boot 4.1.1, rpId localhost, default settings."
|
||||
echo "The authenticator is src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java."
|
||||
ceremony register-and-login
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-ceremony.txt"
|
||||
stop_app
|
||||
9
passkeys/scripts/counter.sh
Executable file
9
passkeys/scripts/counter.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Three assertions with an increasing signature counter, then a replay of a stale one.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "Signature counter: stored on every assertion, compared against on none"
|
||||
ceremony clone-counter
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-counter.txt"
|
||||
stop_app
|
||||
9
passkeys/scripts/duplicate.sh
Executable file
9
passkeys/scripts/duplicate.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# excludeCredentials, and what happens when the client ignores it.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "Registering the same credential id twice"
|
||||
ceremony duplicate
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-duplicate.txt"
|
||||
stop_app
|
||||
9
passkeys/scripts/filters.sh
Executable file
9
passkeys/scripts/filters.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Where the four WebAuthn filters and the two one-time-token filters sit in the chain.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "The security filter chain with webAuthn() and oneTimeTokenLogin() configured"
|
||||
ceremony filters
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-filters.txt"
|
||||
stop_app
|
||||
13
passkeys/scripts/jdbc.sh
Executable file
13
passkeys/scripts/jdbc.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Credentials in a database, using the DDL that ships inside spring-security-web.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app jdbc > /dev/null
|
||||
{
|
||||
header "JDBC persistence - H2, with Spring Security's own schema"
|
||||
echo "schema-locations point at classpath:org/springframework/security/user-entities-schema.sql"
|
||||
echo "and user-credentials-schema.sql, which live in spring-security-web, not in"
|
||||
echo "spring-security-webauthn. Nothing creates these tables for you."
|
||||
echo
|
||||
ceremony register-and-login
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-jdbc.txt"
|
||||
stop_app
|
||||
63
passkeys/scripts/lib.sh
Executable file
63
passkeys/scripts/lib.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers. Sourced by every script in this directory.
|
||||
#
|
||||
# Two traps are baked in here because both have cost real time:
|
||||
#
|
||||
# * never `pkill -f spring-boot` - the pattern matches the shell that is running this
|
||||
# script and kills it. Kill by main class instead, which is what stop_app does.
|
||||
# * `mvn -o` cannot run the Boot plugin until one online build has cached it, so the first
|
||||
# run of run.sh is deliberately not offline.
|
||||
set -euo pipefail
|
||||
|
||||
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="$(cd "$MODULE_DIR/.." && pwd)/docs/output"
|
||||
MAIN_CLASS="PasskeysDemoApplication"
|
||||
BASE_URL="${BASE_URL:-http://localhost:8080}"
|
||||
APP_LOG="${APP_LOG:-/tmp/passkeys-demo.log}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
stop_app() {
|
||||
for pid in $(ps -eo pid,cmd | grep "[${MAIN_CLASS:0:1}]${MAIN_CLASS:1}" | awk '{print $1}'); do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
# ss -lptn sometimes reports the port with no PID, so a port-based kill silently does
|
||||
# nothing and the stale process keeps serving. Wait for the port to actually close.
|
||||
for _ in $(seq 1 20); do
|
||||
curl -sf -o /dev/null "$BASE_URL/health" || return 0
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
|
||||
start_app() {
|
||||
local profiles="${1:-}"
|
||||
stop_app
|
||||
cd "$MODULE_DIR"
|
||||
local args=(-B org.springframework.boot:spring-boot-maven-plugin:run)
|
||||
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
|
||||
setsid nohup mvn "${args[@]}" > "$APP_LOG" 2>&1 < /dev/null &
|
||||
for _ in $(seq 1 90); do
|
||||
curl -sf -o /dev/null "$BASE_URL/health" && { echo "started${profiles:+ with profiles: $profiles}"; return 0; }
|
||||
sleep 2
|
||||
done
|
||||
echo "the application did not come up; see $APP_LOG" >&2
|
||||
tail -40 "$APP_LOG" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
classpath() {
|
||||
cd "$MODULE_DIR"
|
||||
[ -f target/deps.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/deps.txt -DincludeScope=runtime
|
||||
echo "target/classes:$(cat target/deps.txt)"
|
||||
}
|
||||
|
||||
ceremony() {
|
||||
cd "$MODULE_DIR"
|
||||
java --class-path "$(classpath)" tools/PasskeyCeremony.java "$@"
|
||||
}
|
||||
|
||||
header() {
|
||||
echo "=============================================================================="
|
||||
echo "$1"
|
||||
echo "=============================================================================="
|
||||
}
|
||||
15
passkeys/scripts/origin.sh
Executable file
15
passkeys/scripts/origin.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# What a phishing attempt looks like from the relying party's side, in both ceremonies.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "Registration from a disallowed origin"
|
||||
ceremony wrong-origin
|
||||
echo
|
||||
echo "--- what the server logged ---"
|
||||
grep -A4 -m1 -E 'BadOriginException|InconsistentClientDataTypeException' "$APP_LOG" || tail -5 "$APP_LOG"
|
||||
|
||||
header "Assertion from a disallowed origin"
|
||||
ceremony wrong-origin-login
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-origin.txt"
|
||||
stop_app
|
||||
9
passkeys/scripts/ott-fallback.sh
Executable file
9
passkeys/scripts/ott-fallback.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# The one-time token path: generate, redeem, then try to redeem again.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "One-time token login - the way in, and the way back after a lost device"
|
||||
ceremony ott
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-ott.txt"
|
||||
stop_app
|
||||
13
passkeys/scripts/run-all.sh
Executable file
13
passkeys/scripts/run-all.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every docs/output/pk-*.txt file in this repository.
|
||||
#
|
||||
# Timings and instants differ between runs; nothing else should.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cd "$MODULE_DIR"
|
||||
mvn -B -q compile
|
||||
for script in ceremony counter user-verification origin attestation duplicate bootstrap step-up ott-fallback jdbc filters test-run; do
|
||||
echo ">>> scripts/$script.sh"
|
||||
"./scripts/$script.sh" > /dev/null
|
||||
done
|
||||
stop_app
|
||||
ls -la "$OUTPUT_DIR"/pk-*.txt
|
||||
13
passkeys/scripts/run.sh
Executable file
13
passkeys/scripts/run.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the demo with the given profiles.
|
||||
#
|
||||
# ./scripts/run.sh defaults: rpId localhost, UV preferred, in memory
|
||||
# ./scripts/run.sh uvrequired user verification REQUIRED on both ceremonies
|
||||
# ./scripts/run.sh attestationdirect ask for DIRECT attestation and watch nothing change
|
||||
# ./scripts/run.sh badorigin relying party expects an origin the client won't send
|
||||
# ./scripts/run.sh jdbc credentials in H2 using Spring Security's own DDL
|
||||
# ./scripts/run.sh trace every WebAuthn log line the framework emits
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "${1:-}"
|
||||
echo "log: $APP_LOG"
|
||||
echo "browser: $BASE_URL/login (user/password, then $BASE_URL/webauthn/register)"
|
||||
9
passkeys/scripts/step-up.sh
Executable file
9
passkeys/scripts/step-up.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# The same user, three sessions, one endpoint that only one of them can reach.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
start_app "" > /dev/null
|
||||
{
|
||||
header "FactorGrantedAuthority - password, magic link and passkey are not interchangeable"
|
||||
ceremony stepup
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-step-up.txt"
|
||||
stop_app
|
||||
5
passkeys/scripts/test-run.sh
Executable file
5
passkeys/scripts/test-run.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# The contract tests. These need no running server.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cd "$MODULE_DIR"
|
||||
mvn -B test 2>&1 | sed -n '/T E S T S/,$p' | tee "$OUTPUT_DIR/pk-test-run.txt"
|
||||
14
passkeys/scripts/user-verification.sh
Executable file
14
passkeys/scripts/user-verification.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# The same authenticator, with the UV flag clear, against both settings.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
{
|
||||
header "userVerification PREFERRED - the default"
|
||||
start_app "" > /dev/null
|
||||
ceremony no-uv
|
||||
stop_app
|
||||
|
||||
header "userVerification REQUIRED - the uvrequired profile"
|
||||
start_app uvrequired > /dev/null
|
||||
ceremony no-uv
|
||||
stop_app
|
||||
} 2>&1 | tee "$OUTPUT_DIR/pk-user-verification.txt"
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Passkeys and WebAuthn on Spring Security 7.1 / Spring Boot 4.1.
|
||||
*
|
||||
* <p>Runs on port 8080 with a relying party id of {@code localhost}, because {@code localhost}
|
||||
* is the one origin browsers treat as a secure context without TLS. Every other host needs
|
||||
* HTTPS before {@code navigator.credentials.create()} will even be offered.
|
||||
*
|
||||
* <p>Nothing here is auto-configured. Spring Boot 4.1 ships no WebAuthn auto-configuration and
|
||||
* no {@code spring.security.webauthn.*} properties - see docs/passkeys/01-versions.md.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class PasskeysDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PasskeysDemoApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
|
||||
/**
|
||||
* Two users, in memory, with passwords - which is the first thing worth noticing about a
|
||||
* passkeys demo.
|
||||
*
|
||||
* <p>{@code WebAuthnConfigurer.configure} throws {@code IllegalStateException: Missing
|
||||
* UserDetailsService Bean} without one, and
|
||||
* {@code Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions} throws
|
||||
* {@code IllegalArgumentException: Authentication must be authenticated} unless the caller is
|
||||
* already logged in. A passkey cannot be the first credential a user has; something else has
|
||||
* to carry them to the point where they can register one. See
|
||||
* docs/passkeys/06-the-bootstrap-problem.md.
|
||||
*/
|
||||
@Configuration
|
||||
public class AppUsers {
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService() {
|
||||
UserDetails user = User.withDefaultPasswordEncoder()
|
||||
.username("user")
|
||||
.password("password")
|
||||
.roles("USER")
|
||||
.build();
|
||||
UserDetails admin = User.withDefaultPasswordEncoder()
|
||||
.username("admin")
|
||||
.password("password")
|
||||
.roles("USER", "ADMIN")
|
||||
.build();
|
||||
return new InMemoryUserDetailsManager(user, admin);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||
|
||||
/**
|
||||
* Where credentials live.
|
||||
*
|
||||
* <p>{@code WebAuthnConfigurer} will happily create {@code MapUserCredentialRepository} and
|
||||
* {@code MapPublicKeyCredentialUserEntityRepository} for you if no beans exist. Declaring them
|
||||
* explicitly costs nothing and buys two things: the diagnostics endpoint can read them, and
|
||||
* the in-memory default stops being invisible. It is in-memory - every passkey your users
|
||||
* registered disappears on restart, and they have no password to fall back on.
|
||||
*
|
||||
* <p>The {@code jdbc} profile switches to the JDBC repositories. Their schema is not created
|
||||
* for you; {@code schema-jdbc.sql} in this module is Spring Security's own DDL, loaded through
|
||||
* {@code spring.sql.init}. See docs/passkeys/09-persistence.md.
|
||||
*/
|
||||
@Configuration
|
||||
public class PersistenceConfig {
|
||||
|
||||
@Configuration
|
||||
@Profile("!jdbc")
|
||||
static class InMemory {
|
||||
|
||||
@Bean
|
||||
PublicKeyCredentialUserEntityRepository userEntityRepository() {
|
||||
return new MapPublicKeyCredentialUserEntityRepository();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserCredentialRepository userCredentialRepository() {
|
||||
return new MapUserCredentialRepository();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@Profile("jdbc")
|
||||
static class Jdbc {
|
||||
|
||||
@Bean
|
||||
PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
|
||||
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
|
||||
return new JdbcUserCredentialRepository(jdbc);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.config;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.web.webauthn.api.AttestationConveyancePreference;
|
||||
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
|
||||
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
|
||||
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
|
||||
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
|
||||
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
|
||||
|
||||
/**
|
||||
* The relying party operations bean, built by hand so the defaults can be changed one at a
|
||||
* time and the difference observed.
|
||||
*
|
||||
* <p>Exposing this bean at all is a decision with a consequence: from that point on the
|
||||
* {@code rpId}, {@code rpName} and {@code allowedOrigins} in {@link SecurityConfig} are dead
|
||||
* configuration. {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} looks for a bean
|
||||
* of this type first and, when it finds one, never reads its own fields. That is why the
|
||||
* values are repeated here rather than shared.
|
||||
*
|
||||
* <p>Profiles:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>uvrequired</b> - raises user verification from the default {@code PREFERRED} to
|
||||
* {@code REQUIRED}, on both ceremonies. The default accepts a credential created and asserted
|
||||
* with the UV flag clear, which means "something was touched" rather than "someone was
|
||||
* verified".</li>
|
||||
* <li><b>attestationdirect</b> - asks for {@code DIRECT} attestation instead of the default
|
||||
* {@code NONE}. Registration still succeeds against an authenticator that sends
|
||||
* {@code fmt: "none"} and an all-zero AAGUID, because the default
|
||||
* {@code WebAuthnManager.createNonStrictWebAuthnManager()} verifies no attestation at all.
|
||||
* Asking is not checking.</li>
|
||||
* <li><b>badorigin</b> - the same relying party, told to expect a different origin. This is
|
||||
* what a phishing attempt looks like from the server's side.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/05-defaults.md">docs/passkeys/05-defaults.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
@Profile({ "uvrequired", "attestationdirect", "badorigin" })
|
||||
public class RelyingPartyConfig {
|
||||
|
||||
@Bean
|
||||
WebAuthnRelyingPartyOperations relyingPartyOperations(PublicKeyCredentialUserEntityRepository userEntities,
|
||||
UserCredentialRepository userCredentials, @Value("${demo.rp-id:localhost}") String rpId,
|
||||
@Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin,
|
||||
@Value("${demo.user-verification-required:false}") boolean userVerificationRequired,
|
||||
@Value("${demo.attestation-direct:false}") boolean attestationDirect) {
|
||||
|
||||
PublicKeyCredentialRpEntity rp = PublicKeyCredentialRpEntity.builder()
|
||||
.id(rpId)
|
||||
.name("ankurm passkeys demo")
|
||||
.build();
|
||||
Webauthn4JRelyingPartyOperations operations = new Webauthn4JRelyingPartyOperations(userEntities,
|
||||
userCredentials, rp, Set.of(allowedOrigin));
|
||||
|
||||
if (userVerificationRequired) {
|
||||
// Both halves have to be set. registerCredential() reads userVerification off the
|
||||
// creation options; authenticate() reads it off the request options. Setting only
|
||||
// one leaves the other ceremony at PREFERRED, which verifies nothing.
|
||||
operations.setCustomizeCreationOptions((options) -> options
|
||||
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
|
||||
.userVerification(UserVerificationRequirement.REQUIRED)
|
||||
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||
.build()));
|
||||
operations.setCustomizeRequestOptions(
|
||||
(options) -> options.userVerification(UserVerificationRequirement.REQUIRED));
|
||||
}
|
||||
|
||||
if (attestationDirect) {
|
||||
operations
|
||||
.setCustomizeCreationOptions((options) -> options.attestation(AttestationConveyancePreference.DIRECT));
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.config;
|
||||
|
||||
import com.ankurm.passkeys.ott.ConsoleOneTimeTokenHandler;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* The whole passkey configuration is the {@code webAuthn(..)} block. Everything else is
|
||||
* scaffolding.
|
||||
*
|
||||
* <p>Three things about this file are worth more than they look:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code rpId} and {@code allowedOrigins} are two separate settings that must agree. The
|
||||
* rpId is the domain the credential is scoped to; the origin is the exact scheme, host and
|
||||
* port the browser reports. {@code localhost} and {@code http://localhost:8080} agree.
|
||||
* {@code localhost} and {@code http://127.0.0.1:8080} do not, and the failure arrives as a
|
||||
* flat 401 - see docs/passkeys/07-failure-modes.md.</li>
|
||||
* <li>If a {@code WebAuthnRelyingPartyOperations} bean exists, the {@code rpId},
|
||||
* {@code rpName} and {@code allowedOrigins} set here are silently ignored:
|
||||
* {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} returns the bean and never reads
|
||||
* its own fields. {@link RelyingPartyConfig} exposes such a bean under several profiles,
|
||||
* which is precisely why the values are repeated there.</li>
|
||||
* <li>{@code oneTimeTokenLogin} is not decoration. Registering a passkey requires an existing
|
||||
* authenticated session, so a passwordless system still needs a way in and a way back after a
|
||||
* lost device.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/02-minimum-configuration.md">docs/passkeys/02-minimum-configuration.md</a>
|
||||
*/
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
private final String rpId;
|
||||
|
||||
private final String allowedOrigin;
|
||||
|
||||
public SecurityConfig(@Value("${demo.rp-id:localhost}") String rpId,
|
||||
@Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin) {
|
||||
this.rpId = rpId;
|
||||
this.allowedOrigin = allowedOrigin;
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http, ConsoleOneTimeTokenHandler oneTimeTokenHandler)
|
||||
throws Exception {
|
||||
http.authorizeHttpRequests((requests) -> requests.requestMatchers("/", "/health")
|
||||
.permitAll()
|
||||
// Step-up. A session established by a magic link carries FACTOR_OTT; one
|
||||
// established by a passkey carries FACTOR_WEBAUTHN. Both are ordinary
|
||||
// authorities, so requiring a real passkey for a sensitive endpoint is one
|
||||
// matcher - see docs/passkeys/06-the-bootstrap-problem.md.
|
||||
.requestMatchers("/passkey-only")
|
||||
.hasAuthority("FACTOR_WEBAUTHN")
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
.formLogin(Customizer.withDefaults())
|
||||
// The magic-link fallback. Without a OneTimeTokenGenerationSuccessHandler the
|
||||
// context fails to start - Spring Security refuses to guess how to deliver a
|
||||
// token. See docs/passkeys/08-one-time-token-fallback.md.
|
||||
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(oneTimeTokenHandler))
|
||||
.webAuthn((webAuthn) -> webAuthn.rpId(this.rpId)
|
||||
.rpName("ankurm passkeys demo")
|
||||
.allowedOrigins(this.allowedOrigin))
|
||||
.logout(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.diag;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
|
||||
import org.springframework.security.web.webauthn.api.CredentialRecord;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
|
||||
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Prints the state that is otherwise invisible: what a stored {@code CredentialRecord}
|
||||
* actually contains, and where the WebAuthn filters sit in the chain.
|
||||
*
|
||||
* <p>{@code /diag/credentials} is the exhibit for the signature counter. The
|
||||
* {@code signatureCount} it reports is the value the relying party persisted after the last
|
||||
* assertion - which is not the value it compares the next assertion against. See
|
||||
* docs/passkeys/10-signature-counter.md.
|
||||
*
|
||||
* <p>Delete this class before shipping. It reports credential ids and user handles to any
|
||||
* authenticated caller.
|
||||
*/
|
||||
@RestController
|
||||
public class CredentialDiagnostics {
|
||||
|
||||
private final PublicKeyCredentialUserEntityRepository userEntities;
|
||||
|
||||
private final UserCredentialRepository userCredentials;
|
||||
|
||||
private final FilterChainProxy filterChainProxy;
|
||||
|
||||
public CredentialDiagnostics(PublicKeyCredentialUserEntityRepository userEntities,
|
||||
UserCredentialRepository userCredentials, FilterChainProxy filterChainProxy) {
|
||||
this.userEntities = userEntities;
|
||||
this.userCredentials = userCredentials;
|
||||
this.filterChainProxy = filterChainProxy;
|
||||
}
|
||||
|
||||
@GetMapping("/diag/credentials")
|
||||
public Map<String, Object> credentials(Authentication authentication) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("principal", authentication.getName());
|
||||
result.put("principalType", authentication.getClass().getSimpleName());
|
||||
result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
|
||||
|
||||
PublicKeyCredentialUserEntity userEntity = this.userEntities.findByUsername(authentication.getName());
|
||||
if (userEntity == null) {
|
||||
result.put("userEntity", null);
|
||||
result.put("credentials", List.of());
|
||||
return result;
|
||||
}
|
||||
result.put("userEntity", Map.of("id", userEntity.getId().toBase64UrlString(), "name", userEntity.getName(),
|
||||
"displayName", userEntity.getDisplayName()));
|
||||
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
for (CredentialRecord record : this.userCredentials.findByUserId(userEntity.getId())) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("label", record.getLabel());
|
||||
entry.put("credentialId", record.getCredentialId().toBase64UrlString());
|
||||
entry.put("signatureCount", record.getSignatureCount());
|
||||
entry.put("uvInitialized", record.isUvInitialized());
|
||||
entry.put("backupEligible", record.isBackupEligible());
|
||||
entry.put("backupState", record.isBackupState());
|
||||
entry.put("transports", record.getTransports().stream().map(AuthenticatorTransport::getValue).sorted().toList());
|
||||
entry.put("attestationObjectBytes",
|
||||
(record.getAttestationObject() != null) ? record.getAttestationObject().getBytes().length : null);
|
||||
entry.put("created", String.valueOf(record.getCreated()));
|
||||
entry.put("lastUsed", String.valueOf(record.getLastUsed()));
|
||||
records.add(entry);
|
||||
}
|
||||
result.put("credentials", records);
|
||||
return result;
|
||||
}
|
||||
|
||||
@GetMapping("/diag/filters")
|
||||
public Map<String, Object> filters() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> chains = new ArrayList<>();
|
||||
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
|
||||
List<String> names = new ArrayList<>();
|
||||
int position = 0;
|
||||
for (Filter filter : chain.getFilters()) {
|
||||
names.add("%2d %s".formatted(++position, filter.getClass().getSimpleName()));
|
||||
}
|
||||
chains.add(Map.of("matcher", String.valueOf(chain), "filters", names));
|
||||
}
|
||||
result.put("chains", chains);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.ott;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
|
||||
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Delivers the one-time token by printing it, and by writing it to a file the demo scripts
|
||||
* read.
|
||||
*
|
||||
* <p>In production this is where you send an email or an SMS. Spring Security deliberately has
|
||||
* no default: {@code OneTimeTokenLoginConfigurer} throws at startup rather than guess a
|
||||
* delivery channel, which is the correct decision and also the most common first error - see
|
||||
* docs/passkeys/08-one-time-token-fallback.md.
|
||||
*
|
||||
* <p>The important behaviour here is what happens for a username that does not exist. The
|
||||
* token is still generated, the file is still written, and the browser still lands on
|
||||
* {@code /login/ott}. {@code InMemoryOneTimeTokenService} has no {@code UserDetailsService}
|
||||
* and cannot tell; the failure surfaces later, in
|
||||
* {@code OneTimeTokenAuthenticationProvider}, when the token is redeemed. The account
|
||||
* enumeration oracle that a "no such user" response would create is closed by accident rather
|
||||
* than by design, but it is closed.
|
||||
*
|
||||
* <p>{@code TOKEN_FILE} is a demo affordance. Deleting it is the first thing you should do
|
||||
* with this class.
|
||||
*/
|
||||
@Component
|
||||
public class ConsoleOneTimeTokenHandler implements OneTimeTokenGenerationSuccessHandler {
|
||||
|
||||
/** Where {@code scripts/ott-fallback.sh} picks the token up. */
|
||||
public static final Path TOKEN_FILE = Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt");
|
||||
|
||||
private final OneTimeTokenGenerationSuccessHandler redirect = new RedirectOneTimeTokenGenerationSuccessHandler(
|
||||
"/login/ott");
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken)
|
||||
throws IOException, ServletException {
|
||||
String link = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
|
||||
.replacePath(request.getContextPath())
|
||||
.replaceQuery(null)
|
||||
.fragment(null)
|
||||
.path("/login/ott")
|
||||
.queryParam("token", oneTimeToken.getTokenValue())
|
||||
.toUriString();
|
||||
System.out.printf("%n[one-time-token] username=%s expires=%s%n[one-time-token] %s%n%n",
|
||||
oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
|
||||
Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
|
||||
this.redirect.handle(request, response, oneTimeToken);
|
||||
}
|
||||
|
||||
}
|
||||
87
passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java
Normal file
87
passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.virtual;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* The smallest CBOR encoder that can produce a WebAuthn attestation object.
|
||||
*
|
||||
* <p>An authenticator emits CBOR for exactly two things: the COSE public key inside attested
|
||||
* credential data, and the attestation object that wraps it. Both are fixed-shape maps of a
|
||||
* handful of entries, so the code below is enough - there is no reason to pull a CBOR library
|
||||
* onto the authenticator side, and writing the bytes by hand keeps the structure visible
|
||||
* instead of hiding it behind a serializer.
|
||||
*
|
||||
* <p>Only the four major types WebAuthn needs are implemented: unsigned integers (major 0),
|
||||
* negative integers (major 1), byte strings (major 2), text strings (major 3) and maps
|
||||
* (major 5). Everything is written in the shortest form, which is what canonical CBOR
|
||||
* requires anyway.
|
||||
*
|
||||
* @see <a href="https://www.rfc-editor.org/rfc/rfc8949">RFC 8949 - Concise Binary Object
|
||||
* Representation</a>
|
||||
* @see VirtualAuthenticator
|
||||
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/04-virtual-authenticator.md">docs/passkeys/04-virtual-authenticator.md</a>
|
||||
*/
|
||||
public final class Cbor {
|
||||
|
||||
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
|
||||
/** Writes the initial byte plus whatever length bytes the argument needs. */
|
||||
private Cbor head(int major, long value) {
|
||||
int m = major << 5;
|
||||
if (value < 24) {
|
||||
this.out.write(m | (int) value);
|
||||
}
|
||||
else if (value < 256) {
|
||||
this.out.write(m | 24);
|
||||
this.out.write((int) value);
|
||||
}
|
||||
else if (value < 65536) {
|
||||
this.out.write(m | 25);
|
||||
this.out.write((int) (value >> 8) & 0xFF);
|
||||
this.out.write((int) value & 0xFF);
|
||||
}
|
||||
else {
|
||||
this.out.write(m | 26);
|
||||
this.out.write((int) (value >> 24) & 0xFF);
|
||||
this.out.write((int) (value >> 16) & 0xFF);
|
||||
this.out.write((int) (value >> 8) & 0xFF);
|
||||
this.out.write((int) value & 0xFF);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Starts a definite-length map with {@code entries} key/value pairs. */
|
||||
public Cbor map(int entries) {
|
||||
return head(5, entries);
|
||||
}
|
||||
|
||||
/** An integer key or value. Negative values use CBOR major type 1. */
|
||||
public Cbor num(long value) {
|
||||
return (value >= 0) ? head(0, value) : head(1, -1 - value);
|
||||
}
|
||||
|
||||
/** A text string - the attestation object's keys are text, the COSE key's are integers. */
|
||||
public Cbor text(String value) {
|
||||
byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
|
||||
head(3, utf8.length);
|
||||
this.out.writeBytes(utf8);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** A byte string - authenticator data, and the COSE key coordinates. */
|
||||
public Cbor bytes(byte[] value) {
|
||||
head(2, value.length);
|
||||
this.out.writeBytes(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public byte[] toByteArray() {
|
||||
return this.out.toByteArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.virtual;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.Signature;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* A software authenticator: everything a security key or a phone does, in about two hundred
|
||||
* lines, with no browser and no hardware.
|
||||
*
|
||||
* <p>This exists so the ceremonies in this module can be executed rather than described.
|
||||
* {@link #makeCredential} produces a real CBOR attestation object with a real COSE P-256
|
||||
* public key inside it, and {@link #getAssertion} produces a real ECDSA-SHA256 signature over
|
||||
* the concatenation the specification requires. Spring Security's
|
||||
* {@code Webauthn4JRelyingPartyOperations} - and WebAuthn4J underneath it - verifies both
|
||||
* without knowing or caring that no hardware was involved.
|
||||
*
|
||||
* <p>What it deliberately does <em>not</em> do is any of the things that make a real
|
||||
* authenticator a security boundary: there is no user presence test, no user verification, no
|
||||
* secure element, and the private key sits in the heap. It sets the UP, UV, BE and BS flags
|
||||
* because it is asked to, not because anything happened. That is exactly why it is useful for
|
||||
* showing which of those flags the relying party actually checks.
|
||||
*
|
||||
* <p>The signature counter is a field you control, which is the point of
|
||||
* {@code scripts/clone-counter.sh}.
|
||||
*
|
||||
* @see <a href="https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data">WebAuthn Level 3,
|
||||
* section 6.1 - Authenticator Data</a>
|
||||
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/04-virtual-authenticator.md">docs/passkeys/04-virtual-authenticator.md</a>
|
||||
*/
|
||||
public final class VirtualAuthenticator {
|
||||
|
||||
/** User Present. Set when the authenticator believes a human touched it. */
|
||||
public static final int FLAG_UP = 0x01;
|
||||
|
||||
/** User Verified. Set when a PIN or biometric was checked, not merely a touch. */
|
||||
public static final int FLAG_UV = 0x04;
|
||||
|
||||
/** Backup Eligible. Set by syncable passkeys - a phone or a password manager. */
|
||||
public static final int FLAG_BE = 0x08;
|
||||
|
||||
/** Backup State. Set when the credential is currently synced to a backup. */
|
||||
public static final int FLAG_BS = 0x10;
|
||||
|
||||
/** Attested Credential Data included. Set during registration, never during assertion. */
|
||||
public static final int FLAG_AT = 0x40;
|
||||
|
||||
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
|
||||
|
||||
private static final Base64.Decoder B64URL_DEC = Base64.getUrlDecoder();
|
||||
|
||||
private final KeyPair keyPair;
|
||||
|
||||
private final byte[] credentialId;
|
||||
|
||||
private final byte[] aaguid;
|
||||
|
||||
private int flags = FLAG_UP | FLAG_UV | FLAG_BE | FLAG_BS;
|
||||
|
||||
private long signCount;
|
||||
|
||||
public VirtualAuthenticator() {
|
||||
this(randomBytes(16), new byte[16], 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param credentialId the credential id this authenticator will hand out
|
||||
* @param aaguid the authenticator model identifier; all-zero means "not disclosed", which
|
||||
* is what platform authenticators send when attestation is {@code none}
|
||||
* @param initialSignCount the starting value of the signature counter. Real platform
|
||||
* authenticators (Touch ID, Windows Hello, iCloud Keychain) leave this at zero forever
|
||||
*/
|
||||
public VirtualAuthenticator(byte[] credentialId, byte[] aaguid, long initialSignCount) {
|
||||
this.credentialId = credentialId;
|
||||
this.aaguid = aaguid;
|
||||
this.signCount = initialSignCount;
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
this.keyPair = generator.generateKeyPair();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalStateException("cannot generate a P-256 key pair", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** Overrides the flag byte, so a caller can withhold UV or BE and see what breaks. */
|
||||
public VirtualAuthenticator flags(int flags) {
|
||||
this.flags = flags;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets the signature counter used by the next assertion. */
|
||||
public VirtualAuthenticator signCount(long signCount) {
|
||||
this.signCount = signCount;
|
||||
return this;
|
||||
}
|
||||
|
||||
public long signCount() {
|
||||
return this.signCount;
|
||||
}
|
||||
|
||||
public String credentialIdBase64Url() {
|
||||
return B64URL.encodeToString(this.credentialId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration half of the ceremony: {@code navigator.credentials.create()}.
|
||||
* @param rpId the relying party id, hashed into authenticator data
|
||||
* @param origin the origin that will appear in client data - the relying party compares
|
||||
* it against its allowed origins, and a mismatch is the whole phishing defence
|
||||
* @param challengeBase64Url the challenge from
|
||||
* {@code POST /webauthn/register/options}, still base64url encoded
|
||||
* @return the attestation object and client data, base64url encoded, ready to post
|
||||
*/
|
||||
public Registration makeCredential(String rpId, String origin, String challengeBase64Url) {
|
||||
byte[] clientDataJson = clientData("webauthn.create", origin, challengeBase64Url);
|
||||
byte[] authData = authenticatorData(rpId, this.flags | FLAG_AT, this.signCount, attestedCredentialData());
|
||||
// "none" attestation: an empty statement. Platform authenticators send this, and it
|
||||
// is what Spring Security asks for by default (AttestationConveyancePreference.NONE).
|
||||
byte[] attestationObject = new Cbor().map(3)
|
||||
.text("fmt")
|
||||
.text("none")
|
||||
.text("attStmt")
|
||||
.map(0)
|
||||
.text("authData")
|
||||
.bytes(authData)
|
||||
.toByteArray();
|
||||
return new Registration(B64URL.encodeToString(this.credentialId), B64URL.encodeToString(attestationObject),
|
||||
B64URL.encodeToString(clientDataJson));
|
||||
}
|
||||
|
||||
/**
|
||||
* The authentication half of the ceremony: {@code navigator.credentials.get()}.
|
||||
*
|
||||
* <p>The counter is incremented before signing, which is what a hardware key does. Pass an
|
||||
* explicit value to {@link #signCount(long)} first to replay an old one.
|
||||
* @param rpId the relying party id
|
||||
* @param origin the origin placed in client data
|
||||
* @param challengeBase64Url the challenge from {@code POST /webauthn/authenticate/options}
|
||||
* @param userHandleBase64Url the user handle to return, or null to omit it
|
||||
* @return authenticator data, client data and the assertion signature, base64url encoded
|
||||
*/
|
||||
public Assertion getAssertion(String rpId, String origin, String challengeBase64Url, String userHandleBase64Url) {
|
||||
byte[] clientDataJson = clientData("webauthn.get", origin, challengeBase64Url);
|
||||
// No FLAG_AT: attested credential data is registration-only.
|
||||
byte[] authData = authenticatorData(rpId, this.flags, this.signCount, new byte[0]);
|
||||
byte[] clientDataHash = sha256(clientDataJson);
|
||||
byte[] signed = concat(authData, clientDataHash);
|
||||
byte[] signature;
|
||||
try {
|
||||
Signature ecdsa = Signature.getInstance("SHA256withECDSA");
|
||||
ecdsa.initSign(this.keyPair.getPrivate());
|
||||
ecdsa.update(signed);
|
||||
signature = ecdsa.sign();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalStateException("cannot sign the assertion", ex);
|
||||
}
|
||||
return new Assertion(B64URL.encodeToString(this.credentialId), B64URL.encodeToString(authData),
|
||||
B64URL.encodeToString(clientDataJson), B64URL.encodeToString(signature), userHandleBase64Url);
|
||||
}
|
||||
|
||||
/** Increments the counter the way a hardware key does, and returns the new value. */
|
||||
public long tick() {
|
||||
return ++this.signCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code rpIdHash || flags || signCount || attestedCredentialData}. Fixed layout, big
|
||||
* endian counter, no padding: this is the buffer the signature is computed over.
|
||||
*/
|
||||
private byte[] authenticatorData(String rpId, int flagByte, long counter, byte[] attested) {
|
||||
byte[] rpIdHash = sha256(rpId.getBytes(StandardCharsets.UTF_8));
|
||||
ByteBuffer buffer = ByteBuffer.allocate(32 + 1 + 4 + attested.length);
|
||||
buffer.put(rpIdHash);
|
||||
buffer.put((byte) flagByte);
|
||||
buffer.putInt((int) counter);
|
||||
buffer.put(attested);
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
/** {@code aaguid || credentialIdLength || credentialId || COSE public key}. */
|
||||
private byte[] attestedCredentialData() {
|
||||
byte[] cose = cosePublicKey();
|
||||
ByteBuffer buffer = ByteBuffer.allocate(16 + 2 + this.credentialId.length + cose.length);
|
||||
buffer.put(this.aaguid);
|
||||
buffer.putShort((short) this.credentialId.length);
|
||||
buffer.put(this.credentialId);
|
||||
buffer.put(cose);
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* A COSE_Key for ES256: kty=EC2(2), alg=-7, crv=P-256(1), and the two 32-byte affine
|
||||
* coordinates. The coordinates must be left-padded to exactly 32 bytes - a BigInteger
|
||||
* whose top byte happens to be zero will otherwise serialise short and the relying party
|
||||
* will reject the key.
|
||||
*/
|
||||
private byte[] cosePublicKey() {
|
||||
ECPublicKey publicKey = (ECPublicKey) this.keyPair.getPublic();
|
||||
byte[] x = fixedLength(publicKey.getW().getAffineX().toByteArray(), 32);
|
||||
byte[] y = fixedLength(publicKey.getW().getAffineY().toByteArray(), 32);
|
||||
return 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)
|
||||
.num(-3)
|
||||
.bytes(y)
|
||||
.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] clientData(String type, String origin, String challengeBase64Url) {
|
||||
String json = "{\"type\":\"" + type + "\",\"challenge\":\"" + challengeBase64Url + "\",\"origin\":\"" + origin
|
||||
+ "\",\"crossOrigin\":false}";
|
||||
return json.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a BigInteger's sign byte or left-pads a short magnitude, so the result is exactly
|
||||
* {@code length} bytes.
|
||||
*/
|
||||
private static byte[] fixedLength(byte[] value, int length) {
|
||||
if (value.length == length) {
|
||||
return value;
|
||||
}
|
||||
byte[] result = new byte[length];
|
||||
if (value.length > length) {
|
||||
System.arraycopy(value, value.length - length, result, 0, length);
|
||||
}
|
||||
else {
|
||||
System.arraycopy(value, 0, result, length - value.length, value.length);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] input) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(input);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[] first, byte[] second) {
|
||||
byte[] result = new byte[first.length + second.length];
|
||||
System.arraycopy(first, 0, result, 0, first.length);
|
||||
System.arraycopy(second, 0, result, first.length, second.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int length) {
|
||||
byte[] bytes = new byte[length];
|
||||
new java.security.SecureRandom().nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static byte[] decodeBase64Url(String value) {
|
||||
return B64URL_DEC.decode(value);
|
||||
}
|
||||
|
||||
public static String encodeBase64Url(byte[] value) {
|
||||
return B64URL.encodeToString(value);
|
||||
}
|
||||
|
||||
/** What {@code navigator.credentials.create()} hands back, already base64url encoded. */
|
||||
public record Registration(String credentialId, String attestationObject, String clientDataJson) {
|
||||
}
|
||||
|
||||
/** What {@code navigator.credentials.get()} hands back, already base64url encoded. */
|
||||
public record Assertion(String credentialId, String authenticatorData, String clientDataJson, String signature,
|
||||
String userHandle) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys.web;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Two endpoints: one anyone can reach, one that proves a login happened. */
|
||||
@RestController
|
||||
public class ApiControllers {
|
||||
|
||||
@GetMapping("/health")
|
||||
public Map<String, String> health() {
|
||||
return Map.of("status", "UP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports which factors the current session actually carries. A passkey login adds
|
||||
* {@code FACTOR_WEBAUTHN}; a one-time token login adds {@code FACTOR_OTT}; a password
|
||||
* login adds {@code FACTOR_PASSWORD}. That is how Spring Security 7 expresses
|
||||
* multi-factor state, and it is why the same endpoint can tell the three apart.
|
||||
*/
|
||||
/**
|
||||
* Reachable only by a session that authenticated with a passkey. A one-time-token session
|
||||
* is authenticated and still gets 403, which is the whole point of step-up.
|
||||
*/
|
||||
@GetMapping("/passkey-only")
|
||||
public Map<String, String> passkeyOnly() {
|
||||
return Map.of("ok", "this endpoint required FACTOR_WEBAUTHN");
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> me(Authentication authentication) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("name", authentication.getName());
|
||||
result.put("authenticationType", authentication.getClass().getSimpleName());
|
||||
result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Ask the authenticator for DIRECT attestation instead of the default NONE.
|
||||
#
|
||||
# Registration still succeeds against an authenticator that answers with fmt "none" and an
|
||||
# all-zero AAGUID, because the default WebAuthnManager verifies no attestation statement at
|
||||
# all. Asking is not checking.
|
||||
demo:
|
||||
attestation-direct: true
|
||||
4
passkeys/src/main/resources/application-badorigin.yaml
Normal file
4
passkeys/src/main/resources/application-badorigin.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
# The relying party expects an origin the client will not send. This is the server side of a
|
||||
# phishing attempt: same rpId, wrong origin, ceremony refused.
|
||||
demo:
|
||||
allowed-origin: https://passkeys.example.com
|
||||
18
passkeys/src/main/resources/application-jdbc.yaml
Normal file
18
passkeys/src/main/resources/application-jdbc.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# JDBC persistence for credentials and user entities.
|
||||
#
|
||||
# The two DDL files below are Spring Security's own. They ship inside spring-security-web,
|
||||
# not spring-security-webauthn - the 7.0 artifact split moved the classes and left the SQL
|
||||
# and the JavaScript behind. Nothing creates these tables for you.
|
||||
spring:
|
||||
autoconfigure:
|
||||
exclude: []
|
||||
datasource:
|
||||
url: jdbc:h2:mem:passkeys;DB_CLOSE_DELAY=-1
|
||||
username: sa
|
||||
password:
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations:
|
||||
- classpath:org/springframework/security/user-entities-schema.sql
|
||||
- classpath:org/springframework/security/user-credentials-schema.sql
|
||||
10
passkeys/src/main/resources/application-trace.yaml
Normal file
10
passkeys/src/main/resources/application-trace.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# Everything the WebAuthn and one-time-token machinery has to say.
|
||||
#
|
||||
# WebAuthnAuthenticationProvider collapses every failure into BadCredentialsException, so the
|
||||
# HTTP response is a bare 401 with no body. The reason is only ever visible in the log, and
|
||||
# only at DEBUG. Start here when a ceremony fails.
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: DEBUG
|
||||
org.springframework.security.web.webauthn: TRACE
|
||||
com.webauthn4j: DEBUG
|
||||
3
passkeys/src/main/resources/application-uvrequired.yaml
Normal file
3
passkeys/src/main/resources/application-uvrequired.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
# User verification promoted from PREFERRED (the default) to REQUIRED, on both ceremonies.
|
||||
demo:
|
||||
user-verification-required: true
|
||||
24
passkeys/src/main/resources/application.yaml
Normal file
24
passkeys/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Defaults: rpId "localhost", origin http://localhost:8080, in-memory credentials.
|
||||
#
|
||||
# localhost is the only host a browser treats as a secure context without TLS, which is why
|
||||
# every WebAuthn tutorial uses it and why every WebAuthn deployment then breaks on the first
|
||||
# real hostname. See docs/passkeys/07-failure-modes.md.
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
demo:
|
||||
rp-id: localhost
|
||||
allowed-origin: http://localhost:8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: passkeys-demo
|
||||
# No datasource is needed unless the jdbc profile is active; the H2 auto-configuration is
|
||||
# switched off here so the default profile starts without one.
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.web.webauthn: INFO
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
|
||||
* repository root.
|
||||
*/
|
||||
package com.ankurm.passkeys;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import com.ankurm.passkeys.virtual.VirtualAuthenticator;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
|
||||
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
|
||||
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
|
||||
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
|
||||
import org.springframework.security.web.webauthn.api.Bytes;
|
||||
import org.springframework.security.web.webauthn.api.CredentialRecord;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
|
||||
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
|
||||
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
|
||||
import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialCreationOptionsRequest;
|
||||
import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialRequestOptionsRequest;
|
||||
import org.springframework.security.web.webauthn.management.ImmutableRelyingPartyRegistrationRequest;
|
||||
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
|
||||
import org.springframework.security.web.webauthn.management.RelyingPartyPublicKey;
|
||||
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Contract tests for the relying party operations, driven by a software authenticator.
|
||||
*
|
||||
* <p>These pin behaviour rather than the happy path. Several of them assert that something
|
||||
* <em>is not</em> checked, which is the only honest way to record a default: if a later Spring
|
||||
* Security release starts enforcing it, the test fails and the article needs updating.
|
||||
*
|
||||
* <p>No HTTP, no browser and no Spring context - the ceremony is exercised straight through
|
||||
* {@code Webauthn4JRelyingPartyOperations}, so a failure here is the framework's behaviour and
|
||||
* not a filter-ordering accident.
|
||||
*
|
||||
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/05-defaults.md">docs/passkeys/05-defaults.md</a>
|
||||
*/
|
||||
class PasskeyContractTests {
|
||||
|
||||
private static final String RP_ID = "localhost";
|
||||
|
||||
private static final String ORIGIN = "http://localhost:8080";
|
||||
|
||||
private PublicKeyCredentialUserEntityRepository userEntities;
|
||||
|
||||
private UserCredentialRepository userCredentials;
|
||||
|
||||
private Webauthn4JRelyingPartyOperations operations;
|
||||
|
||||
private final Authentication authentication = UsernamePasswordAuthenticationToken.authenticated("user", null,
|
||||
AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.userEntities = new MapPublicKeyCredentialUserEntityRepository();
|
||||
this.userCredentials = new MapUserCredentialRepository();
|
||||
this.operations = newOperations();
|
||||
}
|
||||
|
||||
private Webauthn4JRelyingPartyOperations newOperations() {
|
||||
return new Webauthn4JRelyingPartyOperations(this.userEntities, this.userCredentials,
|
||||
PublicKeyCredentialRpEntity.builder().id(RP_ID).name("test").build(), Set.of(ORIGIN));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a software authenticator completes both ceremonies")
|
||||
void bothCeremoniesSucceed() {
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator();
|
||||
CredentialRecord record = register(authenticator, ORIGIN);
|
||||
|
||||
assertThat(record.getCredentialId().toBase64UrlString()).isEqualTo(authenticator.credentialIdBase64Url());
|
||||
assertThat(record.getSignatureCount()).isZero();
|
||||
assertThat(record.isUvInitialized()).isTrue();
|
||||
|
||||
authenticator.signCount(1);
|
||||
assertThat(authenticate(authenticator, ORIGIN).getName()).isEqualTo("user");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the signature counter is persisted on every assertion and compared against on none")
|
||||
void signatureCounterIsStoredButNotVerified() {
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator();
|
||||
CredentialRecord record = register(authenticator, ORIGIN);
|
||||
Bytes credentialId = record.getCredentialId();
|
||||
|
||||
authenticator.signCount(1);
|
||||
authenticate(authenticator, ORIGIN);
|
||||
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(1);
|
||||
|
||||
authenticator.signCount(2);
|
||||
authenticate(authenticator, ORIGIN);
|
||||
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(2);
|
||||
|
||||
// A cloned authenticator would present a counter it had already used. The relying
|
||||
// party has stored 2. It accepts 1 anyway, because authenticate() rebuilds the
|
||||
// WebAuthn4J credential record from the stored attestation object, whose counter is
|
||||
// frozen at its registration value of 0 - the persisted signatureCount above is never
|
||||
// read back. If this assertion ever starts failing, clone detection has been fixed.
|
||||
authenticator.signCount(1);
|
||||
assertThatCode(() -> authenticate(authenticator, ORIGIN)).doesNotThrowAnyException();
|
||||
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("user verification is not required by default, and the UV flag is recorded either way")
|
||||
void userVerificationIsPreferredNotRequired() {
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator()
|
||||
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
|
||||
CredentialRecord record = register(authenticator, ORIGIN);
|
||||
|
||||
assertThat(record.isUvInitialized()).isFalse();
|
||||
authenticator.signCount(1);
|
||||
assertThatCode(() -> authenticate(authenticator, ORIGIN)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("userVerification REQUIRED refuses a registration whose UV flag is clear")
|
||||
void userVerificationRequiredIsEnforcedAtRegistration() {
|
||||
this.operations = newOperations();
|
||||
this.operations.setCustomizeCreationOptions((options) -> options
|
||||
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
|
||||
.userVerification(UserVerificationRequirement.REQUIRED)
|
||||
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||
.build()));
|
||||
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator()
|
||||
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
|
||||
|
||||
assertThatThrownBy(() -> register(authenticator, ORIGIN))
|
||||
.isInstanceOf(com.webauthn4j.verifier.exception.UserNotVerifiedException.class)
|
||||
.hasMessageContaining("UV flag in authenticatorData is not set");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an origin the relying party did not allow is rejected in both ceremonies")
|
||||
void originIsChecked() {
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator();
|
||||
assertThatThrownBy(() -> register(authenticator, "http://evil.localhost:8080"))
|
||||
.isInstanceOf(com.webauthn4j.verifier.exception.BadOriginException.class);
|
||||
|
||||
register(authenticator, ORIGIN);
|
||||
authenticator.signCount(1);
|
||||
assertThatThrownBy(() -> authenticate(authenticator, "http://evil.localhost:8080"))
|
||||
.isInstanceOf(com.webauthn4j.verifier.exception.BadOriginException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("asking for DIRECT attestation does not make anything verify attestation")
|
||||
void directAttestationIsRequestedNotVerified() {
|
||||
this.operations = newOperations();
|
||||
this.operations.setCustomizeCreationOptions((options) -> options
|
||||
.attestation(org.springframework.security.web.webauthn.api.AttestationConveyancePreference.DIRECT));
|
||||
|
||||
PublicKeyCredentialCreationOptions creationOptions = this.operations
|
||||
.createPublicKeyCredentialCreationOptions(new ImmutablePublicKeyCredentialCreationOptionsRequest(
|
||||
this.authentication));
|
||||
assertThat(creationOptions.getAttestation().getValue()).isEqualTo("direct");
|
||||
|
||||
// The authenticator answers "none" with an all-zero AAGUID. It is registered anyway.
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator();
|
||||
assertThatCode(() -> registerWith(creationOptions, authenticator, ORIGIN)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same credential id cannot be registered twice")
|
||||
void duplicateCredentialIdIsRejected() {
|
||||
VirtualAuthenticator authenticator = new VirtualAuthenticator();
|
||||
register(authenticator, ORIGIN);
|
||||
assertThatThrownBy(() -> register(authenticator, ORIGIN)).hasMessageContaining("already exists");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ceremony helpers
|
||||
|
||||
private CredentialRecord register(VirtualAuthenticator authenticator, String origin) {
|
||||
PublicKeyCredentialCreationOptions creationOptions = this.operations
|
||||
.createPublicKeyCredentialCreationOptions(new ImmutablePublicKeyCredentialCreationOptionsRequest(
|
||||
this.authentication));
|
||||
return registerWith(creationOptions, authenticator, origin);
|
||||
}
|
||||
|
||||
private CredentialRecord registerWith(PublicKeyCredentialCreationOptions creationOptions,
|
||||
VirtualAuthenticator authenticator, String origin) {
|
||||
VirtualAuthenticator.Registration created = authenticator.makeCredential(RP_ID, origin,
|
||||
creationOptions.getChallenge().toBase64UrlString());
|
||||
|
||||
AuthenticatorAttestationResponse response = AuthenticatorAttestationResponse.builder()
|
||||
.attestationObject(Bytes.fromBase64(created.attestationObject()))
|
||||
.clientDataJSON(Bytes.fromBase64(created.clientDataJson()))
|
||||
.transports(AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID)
|
||||
.build();
|
||||
PublicKeyCredential<AuthenticatorAttestationResponse> credential = PublicKeyCredential
|
||||
.<AuthenticatorAttestationResponse>builder()
|
||||
.id(created.credentialId())
|
||||
.rawId(Bytes.fromBase64(created.credentialId()))
|
||||
.type(PublicKeyCredentialType.PUBLIC_KEY)
|
||||
.response(response)
|
||||
.build();
|
||||
|
||||
return this.operations.registerCredential(new ImmutableRelyingPartyRegistrationRequest(creationOptions,
|
||||
new RelyingPartyPublicKey(credential, "test-credential")));
|
||||
}
|
||||
|
||||
private org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity authenticate(
|
||||
VirtualAuthenticator authenticator, String origin) {
|
||||
PublicKeyCredentialRequestOptions requestOptions = this.operations
|
||||
.createCredentialRequestOptions(new ImmutablePublicKeyCredentialRequestOptionsRequest(null));
|
||||
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, origin,
|
||||
requestOptions.getChallenge().toBase64UrlString(), null);
|
||||
|
||||
AuthenticatorAssertionResponse response = AuthenticatorAssertionResponse.builder()
|
||||
.authenticatorData(Bytes.fromBase64(assertion.authenticatorData()))
|
||||
.clientDataJSON(Bytes.fromBase64(assertion.clientDataJson()))
|
||||
.signature(Bytes.fromBase64(assertion.signature()))
|
||||
.build();
|
||||
PublicKeyCredential<AuthenticatorAssertionResponse> credential = PublicKeyCredential
|
||||
.<AuthenticatorAssertionResponse>builder()
|
||||
.id(assertion.credentialId())
|
||||
.rawId(Bytes.fromBase64(assertion.credentialId()))
|
||||
.type(PublicKeyCredentialType.PUBLIC_KEY)
|
||||
.response(response)
|
||||
.build();
|
||||
|
||||
return this.operations.authenticate(new RelyingPartyAuthenticationRequest(requestOptions, credential));
|
||||
}
|
||||
|
||||
}
|
||||
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