diff --git a/Dockerfile_desktop b/Dockerfile_desktop index 506121856c5..6d30206ba10 100644 --- a/Dockerfile_desktop +++ b/Dockerfile_desktop @@ -36,13 +36,21 @@ ARG ZAP_VERSION=2.17.0 ENV ZAP_VERSION=${ZAP_VERSION} \ JAVA_HOME=/config/java-jdk \ PATH="${PATH}:/config/java-jdk/bin" \ - ZAP_HOME=/config/zap + ZAP_HOME=/config/zap \ + ZAP_PROXY_PORT=8090 WORKDIR /config -# Install editors and gzip support +# Install Firefox, editors, and gzip support RUN apt-get update \ - && apt-get --yes install --no-install-recommends vim nano gzip \ + && apt-get --yes install --no-install-recommends ca-certificates curl gnupg vim nano gzip \ + && install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://packages.mozilla.org/apt/repo-signing-key.gpg -o /etc/apt/keyrings/packages.mozilla.org.asc \ + && test "$(gpg --show-keys --with-colons /etc/apt/keyrings/packages.mozilla.org.asc | awk -F: '$1 == "fpr" { print $10; exit }')" = "35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3" \ + && echo "deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main" > /etc/apt/sources.list.d/mozilla.list \ + && printf '%s\n' 'Package: *' 'Pin: origin packages.mozilla.org' 'Pin-Priority: 1000' '' 'Package: firefox' 'Pin: release o=Ubuntu' 'Pin-Priority: -1' > /etc/apt/preferences.d/mozilla \ + && apt-get update \ + && apt-get --yes install --no-install-recommends firefox \ && rm -rf /var/lib/apt/lists/* # Application artifacts @@ -50,6 +58,7 @@ COPY target/webgoat-*.jar /config/webgoat.jar COPY config/desktop/start_webgoat.sh /config/start_webgoat.sh COPY config/desktop/start_zap.sh /config/start_zap.sh COPY config/desktop/WebGoat.txt /config/Desktop/ +COPY config/desktop/firefox-policies.json /usr/lib/firefox/distribution/policies.json # Runtime dependencies produced in the builder stages COPY --from=zap /zap ${ZAP_HOME} diff --git a/config/desktop/WebGoat.txt b/config/desktop/WebGoat.txt index f812bf223aa..09b3b9ef505 100644 --- a/config/desktop/WebGoat.txt +++ b/config/desktop/WebGoat.txt @@ -6,5 +6,9 @@ You can start WebGoat and ZAP by opening a terminal and type: ./start_webgoat.sh ./start_zap.sh +Start WebGoat first, then start ZAP. Always launch Firefox using the Firefox button in ZAP's toolbar. +Firefox launched from ZAP is configured to proxy through ZAP. Starting Firefox from the desktop menu or another launcher will not proxy traffic through ZAP. +The Firefox bookmarks toolbar has a WebGoat and WebWolf folder. + Happy hacking, Team WebGoat diff --git a/config/desktop/firefox-policies.json b/config/desktop/firefox-policies.json new file mode 100644 index 00000000000..959a5fe4237 --- /dev/null +++ b/config/desktop/firefox-policies.json @@ -0,0 +1,23 @@ +{ + "policies": { + "Homepage": { + "URL": "http://localhost:8080/WebGoat", + "Locked": true + }, + "OverrideFirstRunPage": "", + "ManagedBookmarks": [ + { + "toplevel_name": "WebGoat and WebWolf" + }, + { + "name": "WebGoat", + "url": "http://localhost:8080/WebGoat" + }, + { + "name": "WebWolf", + "url": "http://localhost:9090/WebWolf" + } + ], + "DisplayBookmarksToolbar": "always" + } +} diff --git a/config/desktop/start_zap.sh b/config/desktop/start_zap.sh index daced2fa36f..b490acf12d3 100644 --- a/config/desktop/start_zap.sh +++ b/config/desktop/start_zap.sh @@ -2,6 +2,8 @@ set -eu ZAP_DIR=${ZAP_HOME:-/config/zap} +ZAP_PROXY_PORT=${ZAP_PROXY_PORT:-8090} +WEBGOAT_PAC_URL=${WEBGOAT_PAC_URL:-http://127.0.0.1:8080/WebGoat/proxy.pac} if [ ! -d "$ZAP_DIR" ]; then echo "ZAP directory $ZAP_DIR not found" >&2 @@ -15,4 +17,28 @@ if [ -z "${ZAP_JAR:-}" ]; then exit 1 fi -exec /config/java-jdk/bin/java -jar "$ZAP_JAR" +KWRITE_CONFIG=$(command -v kwriteconfig6 || command -v kwriteconfig5 || true) +if [ -n "$KWRITE_CONFIG" ]; then + "$KWRITE_CONFIG" --file kioslaverc --group "Proxy Settings" --key ProxyType 2 + "$KWRITE_CONFIG" --file kioslaverc --group "Proxy Settings" --key "Proxy Config Script" "$WEBGOAT_PAC_URL" + if command -v dbus-send >/dev/null 2>&1; then + dbus-send --type=signal /KIO/Scheduler org.kde.KIO.Scheduler.reparseSlaveConfiguration string:'' || true + fi +else + echo "Unable to configure KDE proxy settings automatically" >&2 +fi + +FIREFOX_BIN=$(command -v firefox || true) +export _JAVA_AWT_WM_NONREPARENTING=1 +if [ -n "$FIREFOX_BIN" ]; then + exec /config/java-jdk/bin/java \ + "-Dselenium.firefoxBinary=$FIREFOX_BIN" \ + -jar "$ZAP_JAR" \ + -port "$ZAP_PROXY_PORT" \ + -config quickstart.launch.startPage=http://localhost:8080/WebGoat +fi +echo "Unable to locate Firefox; ZAP will start without a configured browser binary" >&2 +exec /config/java-jdk/bin/java \ + -jar "$ZAP_JAR" \ + -port "$ZAP_PROXY_PORT" \ + -config quickstart.launch.startPage=http://localhost:8080/WebGoat diff --git a/pom.xml b/pom.xml index d399f04deb8..45fc6d9ef4b 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.owasp.webgoat webgoat - 2026.5 + 2026.6-SNAPSHOT jar WebGoat diff --git a/src/it/java/org/owasp/webgoat/integration/LabelAndHintIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/LabelAndHintIntegrationTest.java index 44766eb562e..be579e7fc66 100644 --- a/src/it/java/org/owasp/webgoat/integration/LabelAndHintIntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/LabelAndHintIntegrationTest.java @@ -125,6 +125,7 @@ public void testHints() { "CrossSiteScripting", "CSRF", "HijackSession", + "WebAuthn", "SqlInjection", "SqlInjectionMitigations", "SqlInjectionAdvanced", diff --git a/src/it/java/org/owasp/webgoat/integration/WebAuthnIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/WebAuthnIntegrationTest.java new file mode 100644 index 00000000000..f84db10d955 --- /dev/null +++ b/src/it/java/org/owasp/webgoat/integration/WebAuthnIntegrationTest.java @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.integration; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.lessons.webauthn.VirtualAuthenticator; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport; + +public class WebAuthnIntegrationTest extends IntegrationTest { + + @Test + public void runTests() { + startLesson("WebAuthn", true); + + // Registration: bind a new key to Jerry's account by changing the user handle + Map registrationOptions = post("WebAuthn/registration/options", Map.of()); + Map credential = post("WebAuthn/authenticator/create", registrationOptions); + String jerrysUserHandle = + WebAuthnSupport.base64Url("jerry".getBytes(StandardCharsets.UTF_8)); + checkAssignment( + webGoatUrlConfig.url("WebAuthn/registration/finish"), + ContentType.JSON, + json(Map.of("userHandle", jerrysUserHandle, "credential", credential)), + true); + + // Authentication: replay Jerry's captured login, the challenge is never checked + String capturedLogin = + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .get(webGoatUrlConfig.url("WebAuthn/support-ticket")) + .then() + .statusCode(200) + .extract() + .asString(); + post("WebAuthn/authentication/options", Map.of("username", "jerry")); + checkAssignment( + webGoatUrlConfig.url("WebAuthn/authentication/finish"), + ContentType.JSON, + capturedLogin, + true); + + // Quiz + checkAssignment( + webGoatUrlConfig.url("WebAuthn/quiz"), + Map.of( + "question_0_solution", "Solution 2", + "question_1_solution", "Solution 2", + "question_2_solution", "Solution 2", + "question_3_solution", "Solution 2", + "question_4_solution", "Solution 1"), + true); + + // Bonus: a software security key against the secure implementation + URI base = URI.create(webGoatUrlConfig.getBaseUrl()); + String origin = webGoatUrlConfig.getBaseUrl(); + String rpId = base.getHost(); + var securityKey = new VirtualAuthenticator(); + Map skOptions = post("WebAuthn/securitykey/registration/options", Map.of()); + var skCredential = + securityKey.create( + origin, + rpId, + (String) skOptions.get("challenge"), + WebAuthnSupport.fromBase64Url((String) ((Map) skOptions.get("user")).get("id"))); + assertThat( + post("WebAuthn/securitykey/registration/finish", skCredential).get("registered"), + is(true)); + + Map loginOptions = post("WebAuthn/securitykey/authentication/options", Map.of()); + List allowed = + ((List) loginOptions.get("allowCredentials")) + .stream().map(d -> (String) ((Map) d).get("id")).toList(); + var assertion = + securityKey.get(origin, rpId, (String) loginOptions.get("challenge"), allowed); + checkAssignment( + webGoatUrlConfig.url("WebAuthn/securitykey/authentication/finish"), + ContentType.JSON, + json(assertion), + true); + + // replaying the same assertion against the secure implementation fails + post("WebAuthn/securitykey/authentication/options", Map.of()); + postAssignment("WebAuthn/securitykey/authentication/finish", json(assertion)) + .body("lessonCompleted", is(false)) + .body("feedback", containsString("challenge mismatch")); + + checkResults("WebAuthn"); + } + + private ValidatableResponse postAssignment(String path, String body) { + return given() + .when() + .relaxedHTTPSValidation() + .contentType(ContentType.JSON) + .cookie("JSESSIONID", getWebGoatCookie()) + .body(body) + .post(webGoatUrlConfig.url(path)) + .then() + .statusCode(200); + } + + private Map post(String path, Object body) { + return given() + .when() + .relaxedHTTPSValidation() + .contentType(ContentType.JSON) + .cookie("JSESSIONID", getWebGoatCookie()) + .body(json(body)) + .post(webGoatUrlConfig.url(path)) + .then() + .statusCode(200) + .extract() + .jsonPath() + .getMap("$"); + } + + private static String json(Object value) { + return WebAuthnSupport.toJson(value); + } +} diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/WebAuthnLessonUITest.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/WebAuthnLessonUITest.java new file mode 100644 index 00000000000..5ec5087c43e --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/WebAuthnLessonUITest.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.playwright.webgoat.lessons; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Browser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.lessons.LessonName; +import org.owasp.webgoat.playwright.webgoat.PlaywrightTest; +import org.owasp.webgoat.playwright.webgoat.helpers.Authentication; +import org.owasp.webgoat.playwright.webgoat.pages.lessons.WebAuthnLessonPage; + +public class WebAuthnLessonUITest extends PlaywrightTest { + + private WebAuthnLessonPage lessonPage; + + @BeforeEach + void navigateToLesson(Browser browser) { + var lessonName = new LessonName("WebAuthn"); + var page = Authentication.sylvester(browser); + + this.lessonPage = new WebAuthnLessonPage(page); + lessonPage.resetLesson(lessonName); + lessonPage.open(lessonName); + } + + @Test + @DisplayName("Solve the GoatBank registration and authentication assignments and the quiz") + void shouldSolveGoatBankAssignments() { + lessonPage.navigateTo(4); + lessonPage.registerKeyForJerry(); + assertThat(lessonPage.registrationFeedback()).containsText("Jerry"); + assertThat(lessonPage.registrationFeedback()).containsText("Well done"); + + lessonPage.navigateTo(6); + lessonPage.replayCapturedLogin(); + assertThat(lessonPage.authenticationFeedback()).containsText("signed in as Jerry"); + + lessonPage.navigateTo(8); + lessonPage.solveQuiz(); + } + + @Test + @DisplayName("Register and sign in with a (virtual) roaming security key") + void shouldSignInWithSecurityKey() { + lessonPage.attachVirtualSecurityKey(); + lessonPage.navigateTo(10); + + lessonPage.registerSecurityKey(); + assertThat(lessonPage.securityKeyRegistration()).containsText("\"registered\": true"); + assertThat(lessonPage.securityKeyRegistration()).containsText("BE=false"); + + lessonPage.signInWithSecurityKey(); + assertThat(lessonPage.securityKeyFeedback()).containsText("You signed in with your security key"); + } +} diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/WebAuthnLessonPage.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/WebAuthnLessonPage.java new file mode 100644 index 00000000000..463a55dd04b --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/WebAuthnLessonPage.java @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.playwright.webgoat.pages.lessons; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.google.gson.JsonObject; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import java.util.regex.Pattern; + +public class WebAuthnLessonPage extends LessonPage { + + public WebAuthnLessonPage(Page page) { + super(page); + } + + /** Registers a new key while replacing Tom's user handle ("dG9t") with Jerry's. */ + public void registerKeyForJerry() { + Page page = getPage(); + page.locator("#webauthn-reg-begin").click(); + assertThat(page.locator("#webauthn-reg-touch")).isEnabled(); + page.locator("#webauthn-reg-touch").click(); + Locator body = page.locator("#webauthn-reg-body"); + assertThat(body).hasValue(Pattern.compile("attestationObject")); + body.fill(body.inputValue().replace("\"dG9t\"", "\"amVycnk\"")); + page.locator("form[action$='/WebAuthn/registration/finish'] button[type='submit']").click(); + } + + public Locator registrationFeedback() { + return feedbackOf("#webauthn-reg-body"); + } + + /** Copies the request from the support ticket into the sign in form and sends it. */ + public void replayCapturedLogin() { + Page page = getPage(); + page.locator("#webauthn-ticket-open").click(); + Locator ticket = page.locator("#webauthn-ticket-content"); + assertThat(ticket).containsText("authenticatorData"); + String captured = ticket.textContent(); + page.locator("#webauthn-login-body").fill(captured.substring(captured.indexOf('{'))); + page.locator("form[action$='/WebAuthn/authentication/finish'] button[type='submit']").click(); + } + + public Locator authenticationFeedback() { + return feedbackOf("#webauthn-login-body"); + } + + public void solveQuiz() { + Page page = getPage(); + page.waitForSelector("#q_container input[name='question_0_solution']"); + page.locator("#question_0_2_input").check(); + page.locator("#question_1_2_input").check(); + page.locator("#question_2_2_input").check(); + page.locator("#question_3_2_input").check(); + page.locator("#question_4_1_input").check(); + page.locator("#quiz-form input[type='SUBMIT']").click(); + page.waitForSelector("#q_container .quiz_question.correct:nth-of-type(5)"); + } + + /** + * Attaches a virtual roaming authenticator (CTAP2 over USB) to the browser through the Chrome + * DevTools Protocol, the same as the WebAuthn panel in DevTools. + */ + public void attachVirtualSecurityKey() { + var cdp = getPage().context().newCDPSession(getPage()); + cdp.send("WebAuthn.enable"); + JsonObject options = new JsonObject(); + options.addProperty("protocol", "ctap2"); + options.addProperty("transport", "usb"); + options.addProperty("hasResidentKey", false); + options.addProperty("hasUserVerification", false); + options.addProperty("automaticPresenceSimulation", true); + JsonObject arguments = new JsonObject(); + arguments.add("options", options); + cdp.send("WebAuthn.addVirtualAuthenticator", arguments); + } + + public void registerSecurityKey() { + getPage().locator("#webauthn-sk-register").click(); + } + + public Locator securityKeyRegistration() { + return getPage().locator("#webauthn-sk-registration"); + } + + public void signInWithSecurityKey() { + getPage().locator("#webauthn-sk-login").click(); + } + + public Locator securityKeyFeedback() { + return feedbackOf("#webauthn-sk-form"); + } + + private Locator feedbackOf(String selector) { + Page page = getPage(); + return page.locator( + ".attack-container", new Page.LocatorOptions().setHas(page.locator(selector))) + .locator(".attack-feedback"); + } +} diff --git a/src/main/java/org/owasp/webgoat/container/ProxyAutoConfigController.java b/src/main/java/org/owasp/webgoat/container/ProxyAutoConfigController.java new file mode 100644 index 00000000000..3f19a68e4e7 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/container/ProxyAutoConfigController.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2016 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.container; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ProxyAutoConfigController { + + private final int proxyPort; + + public ProxyAutoConfigController(@Value("${ZAP_PROXY_PORT:8090}") int proxyPort) { + this.proxyPort = proxyPort; + } + + @GetMapping(value = "/proxy.pac", produces = "application/x-ns-proxy-autoconfig") + public String proxyAutoConfig() { + return "function FindProxyForURL(url, host) {\n" + + " return \"PROXY 127.0.0.1:" + proxyPort + "\";\n" + + "}\n"; + } +} diff --git a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java index 81e4d23cf33..6877c85f6c4 100644 --- a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/js/**", "/fonts/**", "/plugins/**", + "/proxy.pac", "/registration", "/register.mvc", "/actuator/**") diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/Cbor.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/Cbor.java new file mode 100644 index 00000000000..742143cadd3 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/Cbor.java @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal CBOR (RFC 8949) codec covering the subset used by WebAuthn: unsigned/negative integers, + * byte strings, text strings, arrays, maps and the simple values false/true/null. Integers are + * decoded as {@link Long}, byte strings as {@code byte[]}. + * + *

This exists so the lesson can show every verification step. A production relying party + * should use a vetted WebAuthn library instead of hand-written parsing. + */ +public final class Cbor { + + private static final int MAX_DEPTH = 16; + + private Cbor() {} + + public static Object decode(byte[] data) { + Decoder decoder = new Decoder(data, 0); + Object value = decoder.read(0); + if (decoder.position != data.length) { + throw new IllegalArgumentException("Trailing bytes after CBOR item"); + } + return value; + } + + /** Decodes the first CBOR item starting at {@code offset}; returns the item and its length. */ + public static Item decodeFirst(byte[] data, int offset) { + Decoder decoder = new Decoder(data, offset); + Object value = decoder.read(0); + return new Item(value, decoder.position - offset); + } + + public record Item(Object value, int length) {} + + public static byte[] encode(Object value) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + write(out, value); + return out.toByteArray(); + } + + private static void write(ByteArrayOutputStream out, Object value) { + switch (value) { + case null -> out.write(0xf6); + case Boolean b -> out.write(b ? 0xf5 : 0xf4); + case Integer i -> write(out, i.longValue()); + case Long l -> { + if (l >= 0) { + writeHead(out, 0, l); + } else { + writeHead(out, 1, -1 - l); + } + } + case byte[] bytes -> { + writeHead(out, 2, bytes.length); + out.writeBytes(bytes); + } + case String s -> { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + writeHead(out, 3, bytes.length); + out.writeBytes(bytes); + } + case List list -> { + writeHead(out, 4, list.size()); + list.forEach(item -> write(out, item)); + } + case Map map -> { + writeHead(out, 5, map.size()); + map.forEach( + (k, v) -> { + write(out, k); + write(out, v); + }); + } + default -> + throw new IllegalArgumentException("Unsupported CBOR type: " + value.getClass()); + } + } + + private static void writeHead(ByteArrayOutputStream out, int majorType, long length) { + int major = majorType << 5; + if (length < 24) { + out.write(major | (int) length); + } else if (length < 0x100) { + out.write(major | 24); + out.write((int) length); + } else if (length < 0x10000) { + out.write(major | 25); + writeBigEndian(out, length, 2); + } else if (length < 0x100000000L) { + out.write(major | 26); + writeBigEndian(out, length, 4); + } else { + out.write(major | 27); + writeBigEndian(out, length, 8); + } + } + + private static void writeBigEndian(ByteArrayOutputStream out, long value, int bytes) { + for (int i = bytes - 1; i >= 0; i--) { + out.write((int) (value >>> (8 * i)) & 0xff); + } + } + + private static final class Decoder { + private final byte[] data; + private int position; + + Decoder(byte[] data, int position) { + this.data = data; + this.position = position; + } + + Object read(int depth) { + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("CBOR nesting too deep"); + } + int initial = nextByte(); + int majorType = initial >>> 5; + int additional = initial & 0x1f; + if (majorType == 7) { + return switch (additional) { + case 20 -> Boolean.FALSE; + case 21 -> Boolean.TRUE; + case 22 -> null; + default -> throw new IllegalArgumentException("Unsupported CBOR simple value"); + }; + } + long argument = readArgument(additional); + return switch (majorType) { + case 0 -> argument; + case 1 -> -1 - argument; + case 2 -> readBytes(argument); + case 3 -> new String(readBytes(argument), StandardCharsets.UTF_8); + case 4 -> { + long size = checkedLength(argument); + List list = new ArrayList<>(); + for (long i = 0; i < size; i++) { + list.add(read(depth + 1)); + } + yield list; + } + case 5 -> { + long size = checkedLength(argument); + Map map = new LinkedHashMap<>(); + for (long i = 0; i < size; i++) { + Object key = read(depth + 1); + if (map.containsKey(key)) { + throw new IllegalArgumentException("Duplicate CBOR map key: " + key); + } + map.put(key, read(depth + 1)); + } + yield map; + } + default -> throw new IllegalArgumentException("Unsupported CBOR major type " + majorType); + }; + } + + private long readArgument(int additional) { + if (additional < 24) { + return additional; + } + int length = + switch (additional) { + case 24 -> 1; + case 25 -> 2; + case 26 -> 4; + case 27 -> 8; + default -> throw new IllegalArgumentException("Indefinite lengths are not supported"); + }; + long value = 0; + for (int i = 0; i < length; i++) { + value = (value << 8) | nextByte(); + } + if (value < 0) { + throw new IllegalArgumentException("CBOR argument too large"); + } + return value; + } + + private long checkedLength(long length) { + // every element needs at least one byte, so this bounds allocations by the input size + if (length > data.length - position) { + throw new IllegalArgumentException("CBOR length exceeds input"); + } + return length; + } + + private byte[] readBytes(long length) { + int len = (int) checkedLength(length); + byte[] result = new byte[len]; + System.arraycopy(data, position, result, 0, len); + position += len; + return result; + } + + private int nextByte() { + if (position >= data.length) { + throw new IllegalArgumentException("Unexpected end of CBOR input"); + } + return data[position++] & 0xff; + } + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/GoatBankCeremonyEndpoints.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/GoatBankCeremonyEndpoints.java new file mode 100644 index 00000000000..a13cf08a6bc --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/GoatBankCeremonyEndpoints.java @@ -0,0 +1,164 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.RP_NAME; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.TOM; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.base64Url; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.owasp.webgoat.container.CurrentUsername; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.StoredCredential; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.RelyingParty; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * Supporting endpoints for the GoatBank assignments: the relying party's "options" endpoints, a + * simulated security key for Tom and the leaked support ticket. + */ +@RestController +@RequiredArgsConstructor +public class GoatBankCeremonyEndpoints { + + static final String REGISTRATION_CHALLENGE = "webauthn.goatbank.registration.challenge"; + static final String AUTHENTICATION_CHALLENGE = "webauthn.goatbank.authentication.challenge"; + + private final WebAuthnLessonState lessonState; + + /** Tom is signed in to GoatBank and adds a new security key to his account. */ + @PostMapping(value = "/WebAuthn/registration/options", produces = MediaType.APPLICATION_JSON_VALUE) + public Map registrationOptions( + @CurrentUsername String username, HttpServletRequest request, HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + String challenge = WebAuthnSupport.randomChallenge(); + session.setAttribute(REGISTRATION_CHALLENGE, challenge); + + Map options = new LinkedHashMap<>(); + options.put("challenge", challenge); + options.put("rp", Map.of("id", rp.id(), "name", rp.name())); + options.put( + "user", + Map.of( + "id", base64Url(WebAuthnLessonState.userHandle(TOM)), + "name", TOM, + "displayName", "Tom Cat")); + options.put("pubKeyCredParams", List.of(Map.of("type", "public-key", "alg", -7))); + options.put("timeout", 60000); + options.put("attestation", "none"); + options.put( + "excludeCredentials", + descriptors(lessonState.forUser(username).bankCredentialsOf(TOM))); + return options; + } + + @PostMapping( + value = "/WebAuthn/authentication/options", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Map authenticationOptions( + @CurrentUsername String username, + @RequestBody Map body, + HttpServletRequest request, + HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + String challenge = WebAuthnSupport.randomChallenge(); + session.setAttribute(AUTHENTICATION_CHALLENGE, challenge); + String account = String.valueOf(body.getOrDefault("username", "")); + + Map options = new LinkedHashMap<>(); + options.put("challenge", challenge); + options.put("rpId", rp.id()); + options.put( + "allowCredentials", descriptors(lessonState.forUser(username).bankCredentialsOf(account))); + options.put("userVerification", "discouraged"); + options.put("timeout", 60000); + return options; + } + + /** Simulates the browser calling Tom's security key with {@code navigator.credentials.create}. */ + @PostMapping( + value = "/WebAuthn/authenticator/create", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> simulateCreate( + @CurrentUsername String username, + @RequestBody Map options, + HttpServletRequest request) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + String rpId = WebAuthnSupport.path(options, "rp", "id"); + if (!rp.id().equals(rpId)) { + return error("SecurityError: rp.id is not valid for origin " + rp.origin()); + } + try { + byte[] userHandle = WebAuthnSupport.fromBase64Url(WebAuthnSupport.path(options, "user", "id")); + String challenge = WebAuthnSupport.path(options, "challenge"); + return ResponseEntity.ok( + lessonState + .forUser(username) + .tomsSecurityKey() + .create(rp.origin(), rpId, challenge, userHandle)); + } catch (IllegalArgumentException e) { + return error("TypeError: " + e.getMessage()); + } + } + + /** Simulates the browser calling Tom's security key with {@code navigator.credentials.get}. */ + @PostMapping( + value = "/WebAuthn/authenticator/get", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> simulateGet( + @CurrentUsername String username, + @RequestBody Map options, + HttpServletRequest request) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + String rpId = WebAuthnSupport.path(options, "rpId"); + if (!rp.id().equals(rpId)) { + return error("SecurityError: rpId is not valid for origin " + rp.origin()); + } + List allowed = + options.get("allowCredentials") instanceof List list + ? list.stream() + .filter(Map.class::isInstance) + .map(d -> String.valueOf(((Map) d).get("id"))) + .toList() + : List.of(); + try { + return ResponseEntity.ok( + lessonState + .forUser(username) + .tomsSecurityKey() + .get(rp.origin(), rpId, WebAuthnSupport.path(options, "challenge"), allowed)); + } catch (IllegalStateException e) { + return error(e.getMessage()); + } + } + + /** Attachment of support ticket #4711: a request captured while debugging Jerry's login. */ + @GetMapping(value = "/WebAuthn/support-ticket", produces = MediaType.APPLICATION_JSON_VALUE) + public Map supportTicket(HttpServletRequest request) { + return lessonState.capturedLoginOfJerry(RelyingParty.from(request, RP_NAME)); + } + + private static List> descriptors(List credentials) { + return credentials.stream() + .map(c -> Map.of("type", "public-key", "id", c.credentialId())) + .toList(); + } + + private static ResponseEntity> error(String message) { + return ResponseEntity.badRequest().body(Map.of("error", message)); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/SecurityKeyCeremonyEndpoints.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/SecurityKeyCeremonyEndpoints.java new file mode 100644 index 00000000000..e7124439b41 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/SecurityKeyCeremonyEndpoints.java @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.base64Url; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.path; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import lombok.RequiredArgsConstructor; +import org.owasp.webgoat.container.CurrentUsername; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.StoredCredential; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.UserState; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.AttestationObject; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.AttestationResult; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.AuthenticatorData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.ClientData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.RelyingParty; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * Registration of a real roaming authenticator (security key) using the browser WebAuthn API. In + * contrast to GoatBank this implements the verification steps of the WebAuthn specification. + */ +@RestController +@RequiredArgsConstructor +public class SecurityKeyCeremonyEndpoints { + + static final String RP_NAME = "WebGoat"; + static final String REGISTRATION_CHALLENGE = "webauthn.securitykey.registration.challenge"; + static final String AUTHENTICATION_CHALLENGE = "webauthn.securitykey.authentication.challenge"; + static final List ALLOWED_ALGORITHMS = + List.of(WebAuthnSupport.ES256, WebAuthnSupport.EDDSA, WebAuthnSupport.RS256); + + private final WebAuthnLessonState lessonState; + + @PostMapping( + value = "/WebAuthn/securitykey/registration/options", + produces = MediaType.APPLICATION_JSON_VALUE) + public Map registrationOptions( + @CurrentUsername String username, HttpServletRequest request, HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + UserState state = lessonState.forUser(username); + String challenge = WebAuthnSupport.randomChallenge(); + session.setAttribute(REGISTRATION_CHALLENGE, challenge); + + Map options = new LinkedHashMap<>(); + options.put("challenge", challenge); + options.put("rp", Map.of("id", rp.id(), "name", rp.name())); + // the user handle is a random value, not the user name + options.put( + "user", + Map.of( + "id", base64Url(state.securityKeyUserHandle()), + "name", username, + "displayName", username)); + options.put( + "pubKeyCredParams", + ALLOWED_ALGORITHMS.stream().map(alg -> Map.of("type", "public-key", "alg", alg)).toList()); + options.put("timeout", 120000); + options.put("attestation", "direct"); + options.put( + "authenticatorSelection", + Map.of( + "authenticatorAttachment", "cross-platform", + "residentKey", "discouraged", + "requireResidentKey", false, + "userVerification", "discouraged")); + options.put("hints", List.of("security-key")); + options.put("excludeCredentials", descriptors(state.securityKeys())); + return options; + } + + @PostMapping( + value = "/WebAuthn/securitykey/registration/finish", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> finishRegistration( + @CurrentUsername String username, + @RequestBody Map credential, + HttpServletRequest request, + HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + UserState state = lessonState.forUser(username); + // challenges are single use + String expectedChallenge = (String) session.getAttribute(REGISTRATION_CHALLENGE); + session.removeAttribute(REGISTRATION_CHALLENGE); + if (expectedChallenge == null) { + return error("No registration in progress, request new options first"); + } + try { + if (!"public-key".equals(credential.get("type"))) { + return error("credential.type must be 'public-key'"); + } + ClientData clientData = + WebAuthnSupport.parseClientData(path(credential, "response", "clientDataJSON")); + if (!"webauthn.create".equals(clientData.type())) { + return error("clientDataJSON.type must be 'webauthn.create'"); + } + if (!WebAuthnSupport.sameBase64Url(expectedChallenge, clientData.challenge())) { + return error("challenge mismatch"); + } + if (!rp.origin().equals(clientData.origin())) { + return error("origin mismatch, expected " + rp.origin()); + } + + AttestationObject attestation = + WebAuthnSupport.parseAttestationObject(path(credential, "response", "attestationObject")); + AuthenticatorData authData = attestation.authData(); + if (!MessageDigest.isEqual(rp.idHash(), authData.rpIdHash())) { + return error("rpIdHash mismatch, expected hash of " + rp.id()); + } + if (!authData.userPresent()) { + return error("user presence flag not set"); + } + if (!authData.hasAttestedCredentialData()) { + return error("no attested credential data"); + } + long alg = WebAuthnSupport.coseAlgorithm(authData.credentialPublicKey()); + if (!ALLOWED_ALGORITHMS.contains(alg)) { + return error("algorithm %d was not requested".formatted(alg)); + } + WebAuthnSupport.toPublicKey(authData.credentialPublicKey()); + AttestationResult attestationResult = + WebAuthnSupport.verifyAttestation(attestation, clientData.hash()); + if (!attestationResult.valid()) { + return error(attestationResult.description()); + } + String credentialId = base64Url(authData.credentialId()); + if (!credentialId.equals(credential.get("id"))) { + return error("credential id does not match the attested credential"); + } + if (state.securityKey(credentialId).isPresent()) { + return error("this security key is already registered"); + } + + state.addSecurityKey( + new StoredCredential( + credentialId, + username, + state.securityKeyUserHandle(), + authData.credentialPublicKey(), + new AtomicLong(authData.signCount()), + attestation.fmt(), + authData.aaguidAsUuid())); + + Map result = new LinkedHashMap<>(); + result.put("registered", true); + result.put("credentialId", credentialId); + result.put("algorithm", alg); + result.put("aaguid", authData.aaguidAsUuid()); + result.put("attestationFormat", attestation.fmt()); + result.put("attestationType", attestationResult.type()); + result.put("attestation", attestationResult.description()); + result.put("flags", authData.flagsDescription()); + result.put("signCount", authData.signCount()); + // client reported values, informative only: the server cannot verify them + result.put("authenticatorAttachment", credential.get("authenticatorAttachment")); + result.put( + "transports", + credential.get("response") instanceof Map response + ? response.get("transports") + : null); + return ResponseEntity.ok(result); + } catch (IllegalArgumentException e) { + return error(e.getMessage()); + } + } + + @PostMapping( + value = "/WebAuthn/securitykey/authentication/options", + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> authenticationOptions( + @CurrentUsername String username, HttpServletRequest request, HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + List keys = lessonState.forUser(username).securityKeys(); + if (keys.isEmpty()) { + return error("Register a security key first"); + } + String challenge = WebAuthnSupport.randomChallenge(); + session.setAttribute(AUTHENTICATION_CHALLENGE, challenge); + + Map options = new LinkedHashMap<>(); + options.put("challenge", challenge); + options.put("rpId", rp.id()); + options.put("allowCredentials", descriptors(keys)); + options.put("userVerification", "discouraged"); + options.put("hints", List.of("security-key")); + options.put("timeout", 120000); + return ResponseEntity.ok(options); + } + + private static List> descriptors(List credentials) { + return credentials.stream() + .map(c -> Map.of("type", "public-key", "id", c.credentialId())) + .toList(); + } + + private static ResponseEntity> error(String message) { + return ResponseEntity.badRequest().body(Map.of("error", message)); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/VirtualAuthenticator.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/VirtualAuthenticator.java new file mode 100644 index 00000000000..878556f2f54 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/VirtualAuthenticator.java @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.base64Url; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.concat; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.sha256; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A software "security key" plus the part of the browser that builds {@code clientDataJSON}. It + * creates ES256 credentials with "none" attestation, just like the virtual authenticator in the + * Chrome DevTools WebAuthn panel. The lesson uses it so the assignments work without hardware. + */ +public class VirtualAuthenticator { + + public static final UUID AAGUID = UUID.nameUUIDFromBytes("WebGoat virtual security key".getBytes(StandardCharsets.UTF_8)); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private record Key(KeyPair keyPair, byte[] userHandle, AtomicLong counter) {} + + private final Map keys = new ConcurrentHashMap<>(); + + /** Creates a new key pair and returns its base64url credential id. */ + public String createKey(byte[] userHandle, long initialCounter) { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + String credentialId = base64Url(WebAuthnSupport.randomBytes(32)); + keys.put( + credentialId, + new Key(generator.generateKeyPair(), userHandle, new AtomicLong(initialCounter))); + return credentialId; + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + public boolean hasCredential(String credentialId) { + return keys.containsKey(credentialId); + } + + public Map coseKey(String credentialId) { + ECPublicKey publicKey = (ECPublicKey) keys.get(credentialId).keyPair().getPublic(); + Map cose = new LinkedHashMap<>(); + cose.put(1L, 2L); // kty: EC2 + cose.put(3L, WebAuthnSupport.ES256); // alg + cose.put(-1L, 1L); // crv: P-256 + cose.put(-2L, unsigned32(publicKey.getW().getAffineX())); + cose.put(-3L, unsigned32(publicKey.getW().getAffineY())); + return cose; + } + + /** Equivalent of {@code navigator.credentials.create()}. */ + public Map create( + String origin, String rpId, String challenge, byte[] userHandle) { + String credentialId = createKey(userHandle, 0); + byte[] clientDataJson = clientDataJson("webauthn.create", challenge, origin); + byte[] credentialIdBytes = WebAuthnSupport.fromBase64Url(credentialId); + byte[] attestedCredentialData = + concat( + uuidBytes(AAGUID), + new byte[] {0, (byte) credentialIdBytes.length}, + credentialIdBytes, + Cbor.encode(coseKey(credentialId))); + byte[] authData = + concat( + authDataHeader( + rpId, WebAuthnSupport.FLAG_UP | WebAuthnSupport.FLAG_AT, 0), + attestedCredentialData); + Map attestationObject = new LinkedHashMap<>(); + attestationObject.put("fmt", "none"); + attestationObject.put("attStmt", Map.of()); + attestationObject.put("authData", authData); + + Map response = new LinkedHashMap<>(); + response.put("clientDataJSON", base64Url(clientDataJson)); + response.put("attestationObject", base64Url(Cbor.encode(attestationObject))); + response.put("transports", List.of("usb")); + return credential(credentialId, response); + } + + /** Equivalent of {@code navigator.credentials.get()}. */ + public Map get( + String origin, String rpId, String challenge, Collection allowCredentials) { + String credentialId = + allowCredentials.stream() + .filter(keys::containsKey) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "NotAllowedError: this security key holds none of the allowed" + + " credentials")); + Key key = keys.get(credentialId); + byte[] clientDataJson = clientDataJson("webauthn.get", challenge, origin); + byte[] authData = + authDataHeader(rpId, WebAuthnSupport.FLAG_UP, key.counter().incrementAndGet()); + byte[] signature = sign(key.keyPair(), concat(authData, sha256(clientDataJson))); + + Map response = new LinkedHashMap<>(); + response.put("clientDataJSON", base64Url(clientDataJson)); + response.put("authenticatorData", base64Url(authData)); + response.put("signature", base64Url(signature)); + response.put("userHandle", base64Url(key.userHandle())); + return credential(credentialId, response); + } + + private static Map credential( + String credentialId, Map response) { + Map credential = new LinkedHashMap<>(); + credential.put("id", credentialId); + credential.put("rawId", credentialId); + credential.put("type", "public-key"); + credential.put("authenticatorAttachment", "cross-platform"); + credential.put("response", response); + credential.put("clientExtensionResults", Map.of()); + return credential; + } + + private static byte[] clientDataJson(String type, String challenge, String origin) { + Map clientData = new LinkedHashMap<>(); + clientData.put("type", type); + clientData.put("challenge", challenge); + clientData.put("origin", origin); + clientData.put("crossOrigin", false); + try { + return MAPPER.writeValueAsBytes(clientData); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private static byte[] authDataHeader(String rpId, int flags, long counter) { + return ByteBuffer.allocate(37) + .put(sha256(rpId.getBytes(StandardCharsets.UTF_8))) + .put((byte) flags) + .putInt((int) counter) + .array(); + } + + private static byte[] sign(KeyPair keyPair, byte[] data) { + try { + Signature signature = Signature.getInstance("SHA256withECDSA"); + signature.initSign(keyPair.getPrivate()); + signature.update(data); + return signature.sign(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + private static byte[] unsigned32(BigInteger value) { + byte[] bytes = value.toByteArray(); + byte[] result = new byte[32]; + int length = Math.min(bytes.length, 32); + System.arraycopy(bytes, bytes.length - length, result, 32 - length, length); + return result; + } + + private static byte[] uuidBytes(UUID uuid) { + return ByteBuffer.allocate(16) + .putLong(uuid.getMostSignificantBits()) + .putLong(uuid.getLeastSignificantBits()) + .array(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthn.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthn.java new file mode 100644 index 00000000000..415deb117ef --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthn.java @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import org.owasp.webgoat.container.lessons.Category; +import org.owasp.webgoat.container.lessons.Lesson; +import org.springframework.stereotype.Component; + +@Component +public class WebAuthn extends Lesson { + + @Override + public Category getDefaultCategory() { + return Category.A7; + } + + @Override + public String getTitle() { + return "webauthn.title"; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnAuthenticationAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnAuthenticationAssignment.java new file mode 100644 index 00000000000..0a6f6a3df24 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnAuthenticationAssignment.java @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import static org.owasp.webgoat.lessons.webauthn.GoatBankCeremonyEndpoints.AUTHENTICATION_CHALLENGE; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.JERRY; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.RP_NAME; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.path; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.security.MessageDigest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.owasp.webgoat.container.CurrentUsername; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.StoredCredential; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.AuthenticatorData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.ClientData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.RelyingParty; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; + +/** + * GoatBank's login endpoint. The signature is verified, but the assertion is never tied to the + * challenge issued for this login attempt, so a recorded assertion can be replayed. + */ +@RestController +@RequiredArgsConstructor +@AssignmentHints({ + "webauthn.authentication.hint1", + "webauthn.authentication.hint2", + "webauthn.authentication.hint3", + "webauthn.authentication.hint4" +}) +public class WebAuthnAuthenticationAssignment implements AssignmentEndpoint { + + private final WebAuthnLessonState lessonState; + + @PostMapping( + value = "/WebAuthn/authentication/finish", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult finishAuthentication( + @CurrentUsername String username, + @RequestBody Map body, + HttpServletRequest request, + HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + // the challenge is single use, so it is removed from the session... + session.removeAttribute(AUTHENTICATION_CHALLENGE); + + try { + String account = String.valueOf(body.get("username")); + StoredCredential credential = + lessonState.forUser(username).bankCredential(path(body, "credential", "id")).orElse(null); + if (credential == null || !credential.account().equals(account)) { + return rejected("unknown credential for this account"); + } + + ClientData clientData = + WebAuthnSupport.parseClientData(path(body, "credential", "response", "clientDataJSON")); + if (!"webauthn.get".equals(clientData.type())) { + return rejected("clientDataJSON.type must be 'webauthn.get'"); + } + // ...but never compared with clientData.challenge() + if (!rp.origin().equals(clientData.origin())) { + return rejected("unexpected origin " + clientData.origin()); + } + + AuthenticatorData authData = + WebAuthnSupport.parseAuthenticatorData( + WebAuthnSupport.fromBase64Url( + path(body, "credential", "response", "authenticatorData"))); + if (!MessageDigest.isEqual(rp.idHash(), authData.rpIdHash())) { + return rejected("rpIdHash does not match " + rp.id()); + } + if (!authData.userPresent()) { + return rejected("user presence flag not set"); + } + + byte[] signedData = WebAuthnSupport.concat(authData.raw(), clientData.hash()); + byte[] signature = + WebAuthnSupport.fromBase64Url(path(body, "credential", "response", "signature")); + if (!WebAuthnSupport.verifySignature( + credential.algorithm(), credential.publicKey(), signedData, signature)) { + return rejected("signature verification failed"); + } + + // many passkeys always report 0, so the counter is stored but not enforced + credential.signCount().set(authData.signCount()); + + String output = + "Signed in as %s (signCount %d, challenge in assertion: %s)" + .formatted( + account, + authData.signCount(), + HtmlUtils.htmlEscape(String.valueOf(clientData.challenge()))); + if (JERRY.equals(account)) { + return success(this).feedback("webauthn.authentication.success").output(output).build(); + } + return informationMessage(this) + .feedback("webauthn.authentication.signed-in") + .feedbackArgs(account) + .output(output) + .build(); + } catch (IllegalArgumentException e) { + return rejected(e.getMessage()); + } + } + + private AttackResult rejected(String reason) { + return failed(this) + .feedback("webauthn.authentication.rejected") + .feedbackArgs(HtmlUtils.htmlEscape(String.valueOf(reason))) + .build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnLessonState.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnLessonState.java new file mode 100644 index 00000000000..09dbebf87e8 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnLessonState.java @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.RelyingParty; +import org.springframework.stereotype.Component; + +/** + * Credential storage of the fictional "GoatBank" relying party, kept per WebGoat user so students + * cannot interfere with each other. Restarting the lesson clears the state. + */ +@Component +public class WebAuthnLessonState implements Initializable { + + public static final String TOM = "tom"; + public static final String JERRY = "jerry"; + public static final Set ACCOUNTS = Set.of(TOM, JERRY); + public static final String RP_NAME = "GoatBank"; + + public record StoredCredential( + String credentialId, + String account, + byte[] userHandle, + Map coseKey, + AtomicLong signCount, + String attestation, + String aaguid) { + + public PublicKey publicKey() { + return WebAuthnSupport.toPublicKey(coseKey); + } + + public long algorithm() { + return WebAuthnSupport.coseAlgorithm(coseKey); + } + } + + public static final class UserState { + private final VirtualAuthenticator tomsSecurityKey = new VirtualAuthenticator(); + private final Map bankCredentials = new ConcurrentHashMap<>(); + private final Map securityKeys = new ConcurrentHashMap<>(); + private final byte[] securityKeyUserHandle = WebAuthnSupport.randomBytes(32); + + public VirtualAuthenticator tomsSecurityKey() { + return tomsSecurityKey; + } + + public Optional bankCredential(String credentialId) { + return Optional.ofNullable(credentialId).map(bankCredentials::get); + } + + public List bankCredentialsOf(String account) { + return bankCredentials.values().stream().filter(c -> c.account().equals(account)).toList(); + } + + public void addBankCredential(StoredCredential credential) { + bankCredentials.put(credential.credentialId(), credential); + } + + public Optional securityKey(String credentialId) { + return Optional.ofNullable(credentialId).map(securityKeys::get); + } + + public List securityKeys() { + return List.copyOf(securityKeys.values()); + } + + public void addSecurityKey(StoredCredential credential) { + securityKeys.put(credential.credentialId(), credential); + } + + public byte[] securityKeyUserHandle() { + return securityKeyUserHandle.clone(); + } + } + + private final Map users = new ConcurrentHashMap<>(); + + // Jerry's security key is shared by all users; only its public key is stored per user. + private final VirtualAuthenticator jerrysSecurityKey = new VirtualAuthenticator(); + private final String jerrysCredentialId = jerrysSecurityKey.createKey(userHandle(JERRY), 40); + private final Map> capturedLogins = new ConcurrentHashMap<>(); + + /** + * GoatBank derives the WebAuthn user handle from the user name. The specification requires an + * opaque random value without personal information, this makes handles guessable. + */ + public static byte[] userHandle(String account) { + return account.getBytes(StandardCharsets.UTF_8); + } + + public UserState forUser(String webGoatUser) { + return users.computeIfAbsent(webGoatUser, u -> newState()); + } + + @Override + public void initialize(WebGoatUser user) { + users.remove(user.getUsername()); + } + + private UserState newState() { + UserState state = new UserState(); + String tomsCredentialId = state.tomsSecurityKey().createKey(userHandle(TOM), 0); + state.addBankCredential( + new StoredCredential( + tomsCredentialId, + TOM, + userHandle(TOM), + state.tomsSecurityKey().coseKey(tomsCredentialId), + new AtomicLong(), + "none", + VirtualAuthenticator.AAGUID.toString())); + state.addBankCredential( + new StoredCredential( + jerrysCredentialId, + JERRY, + userHandle(JERRY), + jerrysSecurityKey.coseKey(jerrysCredentialId), + new AtomicLong(40), + "none", + VirtualAuthenticator.AAGUID.toString())); + return state; + } + + /** + * The request body of one of Jerry's earlier logins, as it ended up in a support ticket. It is + * generated once per origin so the origin and RP ID hash inside match the current deployment. + */ + public Map capturedLoginOfJerry(RelyingParty rp) { + return capturedLogins.computeIfAbsent( + rp.origin(), + origin -> { + Map body = new LinkedHashMap<>(); + body.put("username", JERRY); + body.put( + "credential", + jerrysSecurityKey.get( + origin, + rp.id(), + WebAuthnSupport.randomChallenge(), + List.of(jerrysCredentialId))); + return body; + }); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnQuiz.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnQuiz.java new file mode 100644 index 00000000000..3f7b8e7e32b --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnQuiz.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.owasp.webgoat.container.CurrentUsername; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WebAuthnQuiz implements AssignmentEndpoint { + + private static final String[] SOLUTIONS = { + "Solution 2", "Solution 2", "Solution 2", "Solution 2", "Solution 1" + }; + private final Map guesses = new ConcurrentHashMap<>(); + + @PostMapping("/WebAuthn/quiz") + @ResponseBody + public AttackResult completed( + @CurrentUsername String username, + @RequestParam String[] question_0_solution, + @RequestParam String[] question_1_solution, + @RequestParam String[] question_2_solution, + @RequestParam String[] question_3_solution, + @RequestParam String[] question_4_solution) { + String[] givenAnswers = { + question_0_solution[0], + question_1_solution[0], + question_2_solution[0], + question_3_solution[0], + question_4_solution[0] + }; + boolean[] results = new boolean[SOLUTIONS.length]; + int correctAnswers = 0; + for (int i = 0; i < SOLUTIONS.length; i++) { + results[i] = givenAnswers[i].contains(SOLUTIONS[i]); + if (results[i]) { + correctAnswers++; + } + } + guesses.put(username, results); + + if (correctAnswers == SOLUTIONS.length) { + return success(this).build(); + } + return failed(this).build(); + } + + @GetMapping("/WebAuthn/quiz") + @ResponseBody + public boolean[] getResults(@CurrentUsername String username) { + return guesses.getOrDefault(username, new boolean[SOLUTIONS.length]); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnRegistrationAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnRegistrationAssignment.java new file mode 100644 index 00000000000..ab4a2adf1b4 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnRegistrationAssignment.java @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import static org.owasp.webgoat.lessons.webauthn.GoatBankCeremonyEndpoints.REGISTRATION_CHALLENGE; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.JERRY; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.RP_NAME; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.path; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import lombok.RequiredArgsConstructor; +import org.owasp.webgoat.container.CurrentUsername; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.StoredCredential; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.UserState; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.AttestationObject; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.ClientData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.RelyingParty; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; + +/** + * GoatBank's registration endpoint. All checks on the authenticator response are in place, but + * the account the new credential is bound to is taken from the request body. + */ +@RestController +@RequiredArgsConstructor +@AssignmentHints({ + "webauthn.registration.hint1", + "webauthn.registration.hint2", + "webauthn.registration.hint3", + "webauthn.registration.hint4" +}) +public class WebAuthnRegistrationAssignment implements AssignmentEndpoint { + + private final WebAuthnLessonState lessonState; + + @PostMapping( + value = "/WebAuthn/registration/finish", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult finishRegistration( + @CurrentUsername String username, + @RequestBody Map body, + HttpServletRequest request, + HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + String expectedChallenge = (String) session.getAttribute(REGISTRATION_CHALLENGE); + session.removeAttribute(REGISTRATION_CHALLENGE); + if (expectedChallenge == null) { + return failed(this).feedback("webauthn.registration.no-challenge").build(); + } + UserState state = lessonState.forUser(username); + + try { + ClientData clientData = + WebAuthnSupport.parseClientData(path(body, "credential", "response", "clientDataJSON")); + if (!"webauthn.create".equals(clientData.type())) { + return rejected("clientDataJSON.type must be 'webauthn.create'"); + } + if (!WebAuthnSupport.sameBase64Url(expectedChallenge, clientData.challenge())) { + return rejected("challenge does not match the challenge issued for this session"); + } + if (!rp.origin().equals(clientData.origin())) { + return rejected("unexpected origin " + clientData.origin()); + } + + AttestationObject attestation = + WebAuthnSupport.parseAttestationObject( + path(body, "credential", "response", "attestationObject")); + var authData = attestation.authData(); + if (!MessageDigest.isEqual(rp.idHash(), authData.rpIdHash())) { + return rejected("rpIdHash does not match " + rp.id()); + } + if (!authData.userPresent()) { + return rejected("user presence flag not set"); + } + if (!authData.hasAttestedCredentialData()) { + return rejected("no attested credential data"); + } + if (WebAuthnSupport.coseAlgorithm(authData.credentialPublicKey()) != WebAuthnSupport.ES256) { + return rejected("only ES256 credentials are accepted"); + } + WebAuthnSupport.toPublicKey(authData.credentialPublicKey()); + if (!WebAuthnSupport.verifyAttestation(attestation, clientData.hash()).valid()) { + return rejected("invalid attestation statement"); + } + String credentialId = WebAuthnSupport.base64Url(authData.credentialId()); + if (state.bankCredential(credentialId).isPresent()) { + return rejected("credential is already registered"); + } + + // The options endpoint sent Tom's user handle to the browser, the browser sends it back + // and we use it to find out which account the new key belongs to. + byte[] userHandle = WebAuthnSupport.fromBase64Url((String) body.get("userHandle")); + String account = new String(userHandle, StandardCharsets.UTF_8); + if (!WebAuthnLessonState.ACCOUNTS.contains(account)) { + return failed(this) + .feedback("webauthn.registration.unknown-account") + .feedbackArgs(HtmlUtils.htmlEscape(account)) + .build(); + } + + state.addBankCredential( + new StoredCredential( + credentialId, + account, + userHandle, + authData.credentialPublicKey(), + new AtomicLong(authData.signCount()), + attestation.fmt(), + authData.aaguidAsUuid())); + + String output = + "Credential %s registered for account %s
clientDataJSON: %s
flags: %s" + .formatted( + credentialId, + account, + HtmlUtils.htmlEscape(new String(clientData.raw(), StandardCharsets.UTF_8)), + authData.flagsDescription()); + if (JERRY.equals(account)) { + return success(this).feedback("webauthn.registration.success").output(output).build(); + } + return informationMessage(this) + .feedback("webauthn.registration.registered") + .feedbackArgs(account) + .output(output) + .build(); + } catch (IllegalArgumentException e) { + return rejected(e.getMessage()); + } + } + + private AttackResult rejected(String reason) { + return failed(this).feedback("webauthn.registration.rejected").feedbackArgs(HtmlUtils.htmlEscape(String.valueOf(reason))) + .build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnSecurityKeyAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnSecurityKeyAssignment.java new file mode 100644 index 00000000000..02c88e6f7df --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnSecurityKeyAssignment.java @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import static org.owasp.webgoat.lessons.webauthn.SecurityKeyCeremonyEndpoints.AUTHENTICATION_CHALLENGE; +import static org.owasp.webgoat.lessons.webauthn.SecurityKeyCeremonyEndpoints.RP_NAME; +import static org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.path; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.security.MessageDigest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.owasp.webgoat.container.CurrentUsername; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.lessons.webauthn.WebAuthnLessonState.StoredCredential; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.AuthenticatorData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.ClientData; +import org.owasp.webgoat.lessons.webauthn.WebAuthnSupport.RelyingParty; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; + +/** + * Bonus assignment: sign in with a real roaming authenticator (USB/NFC/BLE security key) that was + * registered through {@link SecurityKeyCeremonyEndpoints}. Implements the authentication + * verification steps of the WebAuthn specification. + */ +@RestController +@RequiredArgsConstructor +@AssignmentHints({ + "webauthn.securitykey.hint1", + "webauthn.securitykey.hint2", + "webauthn.securitykey.hint3" +}) +public class WebAuthnSecurityKeyAssignment implements AssignmentEndpoint { + + private final WebAuthnLessonState lessonState; + + @PostMapping( + value = "/WebAuthn/securitykey/authentication/finish", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult finishAuthentication( + @CurrentUsername String username, + @RequestBody Map credential, + HttpServletRequest request, + HttpSession session) { + RelyingParty rp = RelyingParty.from(request, RP_NAME); + String expectedChallenge = (String) session.getAttribute(AUTHENTICATION_CHALLENGE); + session.removeAttribute(AUTHENTICATION_CHALLENGE); + if (expectedChallenge == null) { + return rejected("no authentication in progress, request new options first"); + } + var state = lessonState.forUser(username); + + try { + StoredCredential stored = state.securityKey(path(credential, "id")).orElse(null); + if (stored == null) { + return rejected("credential is not registered for your account"); + } + String userHandle = path(credential, "response", "userHandle"); + if (userHandle != null + && !MessageDigest.isEqual( + stored.userHandle(), WebAuthnSupport.fromBase64Url(userHandle))) { + return rejected("user handle does not belong to the credential owner"); + } + + ClientData clientData = + WebAuthnSupport.parseClientData(path(credential, "response", "clientDataJSON")); + if (!"webauthn.get".equals(clientData.type())) { + return rejected("clientDataJSON.type must be 'webauthn.get'"); + } + if (!WebAuthnSupport.sameBase64Url(expectedChallenge, clientData.challenge())) { + return rejected("challenge mismatch (replayed assertion?)"); + } + if (!rp.origin().equals(clientData.origin())) { + return rejected("origin mismatch"); + } + + AuthenticatorData authData = + WebAuthnSupport.parseAuthenticatorData( + WebAuthnSupport.fromBase64Url(path(credential, "response", "authenticatorData"))); + if (!MessageDigest.isEqual(rp.idHash(), authData.rpIdHash())) { + return rejected("rpIdHash mismatch"); + } + if (!authData.userPresent()) { + return rejected("user presence flag not set, touch your security key"); + } + + byte[] signedData = WebAuthnSupport.concat(authData.raw(), clientData.hash()); + byte[] signature = WebAuthnSupport.fromBase64Url(path(credential, "response", "signature")); + if (!WebAuthnSupport.verifySignature( + stored.algorithm(), stored.publicKey(), signedData, signature)) { + return rejected("signature verification failed"); + } + + long storedCount = stored.signCount().get(); + long newCount = authData.signCount(); + if ((storedCount != 0 || newCount != 0) && newCount <= storedCount) { + return rejected( + "signature counter went from %d to %d, the key may be cloned" + .formatted(storedCount, newCount)); + } + stored.signCount().set(newCount); + + return success(this) + .feedback("webauthn.securitykey.success") + .output( + "Verified assertion of credential %s
AAGUID: %s, attestation: %s
flags: %s," + .formatted(stored.credentialId(), stored.aaguid(), stored.attestation(), + authData.flagsDescription()) + + " signCount: %d -> %d".formatted(storedCount, newCount)) + .build(); + } catch (IllegalArgumentException e) { + return rejected(e.getMessage()); + } + } + + private AttackResult rejected(String reason) { + return failed(this) + .feedback("webauthn.securitykey.rejected") + .feedbackArgs(HtmlUtils.htmlEscape(String.valueOf(reason))) + .build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnSupport.java b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnSupport.java new file mode 100644 index 00000000000..2e2fc7a7a04 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webauthn/WebAuthnSupport.java @@ -0,0 +1,452 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +/** + * Parsing and verification building blocks for WebAuthn. The assignment endpoints compose these + * steps themselves so the (missing) checks are visible in the endpoint code. + */ +public final class WebAuthnSupport { + + public static final long ES256 = -7; + public static final long EDDSA = -8; + public static final long RS256 = -257; + + public static final int FLAG_UP = 0x01; + public static final int FLAG_UV = 0x04; + public static final int FLAG_BE = 0x08; + public static final int FLAG_BS = 0x10; + public static final int FLAG_AT = 0x40; + public static final int FLAG_ED = 0x80; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final byte[] ED25519_X509_PREFIX = + HexFormat.of().parseHex("302a300506032b6570032100"); + private static final String AAGUID_EXTENSION_OID = "1.3.6.1.4.1.45724.1.1.4"; + + private WebAuthnSupport() {} + + /** + * The relying party as seen by the browser. WebGoat can be reached on any host name, so the + * expected origin is derived from the request. A production relying party must use a fixed, + * configured RP ID and list of allowed origins instead. + */ + public record RelyingParty(String id, String name, String origin) { + + public static RelyingParty from(HttpServletRequest request, String name) { + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + boolean defaultPort = + ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443); + String origin = scheme + "://" + host + (defaultPort ? "" : ":" + port); + return new RelyingParty(host, name, origin); + } + + public byte[] idHash() { + return sha256(id.getBytes(StandardCharsets.UTF_8)); + } + } + + public record ClientData(String type, String challenge, String origin, byte[] raw) { + + public byte[] hash() { + return sha256(raw); + } + } + + public record AuthenticatorData( + byte[] raw, + byte[] rpIdHash, + int flags, + long signCount, + byte[] aaguid, + byte[] credentialId, + Map credentialPublicKey) { + + public boolean userPresent() { + return (flags & FLAG_UP) != 0; + } + + public boolean userVerified() { + return (flags & FLAG_UV) != 0; + } + + public boolean backupEligible() { + return (flags & FLAG_BE) != 0; + } + + public boolean backedUp() { + return (flags & FLAG_BS) != 0; + } + + public boolean hasAttestedCredentialData() { + return (flags & FLAG_AT) != 0; + } + + public String aaguidAsUuid() { + if (aaguid == null) { + return null; + } + String hex = HexFormat.of().formatHex(aaguid); + return "%s-%s-%s-%s-%s" + .formatted( + hex.substring(0, 8), + hex.substring(8, 12), + hex.substring(12, 16), + hex.substring(16, 20), + hex.substring(20)); + } + + public String flagsDescription() { + return "UP=%s, UV=%s, BE=%s, BS=%s, AT=%s" + .formatted( + userPresent(), + userVerified(), + backupEligible(), + backedUp(), + hasAttestedCredentialData()); + } + } + + public record AttestationObject( + String fmt, Map attStmt, AuthenticatorData authData) {} + + public static String randomChallenge() { + byte[] challenge = new byte[32]; + RANDOM.nextBytes(challenge); + return base64Url(challenge); + } + + public static byte[] randomBytes(int length) { + byte[] bytes = new byte[length]; + RANDOM.nextBytes(bytes); + return bytes; + } + + public static String base64Url(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + public static byte[] fromBase64Url(String data) { + if (data == null) { + throw new IllegalArgumentException("Missing base64url value"); + } + // be lenient for values copied from tools that use standard base64 with padding + String normalized = data.trim().replace('+', '-').replace('/', '_').replace("=", ""); + return Base64.getUrlDecoder().decode(normalized); + } + + public static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + public static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) { + out.writeBytes(part); + } + return out.toByteArray(); + } + + /** Constant time comparison of two base64url encoded values. */ + public static boolean sameBase64Url(String expected, String actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual(fromBase64Url(expected), fromBase64Url(actual)); + } + + public static ClientData parseClientData(String clientDataJsonB64) { + byte[] raw = fromBase64Url(clientDataJsonB64); + try { + JsonNode node = MAPPER.readTree(raw); + return new ClientData( + node.path("type").asText(null), + node.path("challenge").asText(null), + node.path("origin").asText(null), + raw); + } catch (IOException e) { + throw new IllegalArgumentException("clientDataJSON is not valid JSON", e); + } + } + + @SuppressWarnings("unchecked") + public static AuthenticatorData parseAuthenticatorData(byte[] raw) { + if (raw.length < 37) { + throw new IllegalArgumentException("authenticatorData is too short"); + } + ByteBuffer buffer = ByteBuffer.wrap(raw); + byte[] rpIdHash = new byte[32]; + buffer.get(rpIdHash); + int flags = buffer.get() & 0xff; + long signCount = buffer.getInt() & 0xffffffffL; + byte[] aaguid = null; + byte[] credentialId = null; + Map publicKey = null; + if ((flags & FLAG_AT) != 0) { + if (buffer.remaining() < 18) { + throw new IllegalArgumentException("attested credential data is too short"); + } + aaguid = new byte[16]; + buffer.get(aaguid); + int idLength = buffer.getShort() & 0xffff; + if (idLength > 1023 || buffer.remaining() < idLength) { + throw new IllegalArgumentException("Invalid credential id length"); + } + credentialId = new byte[idLength]; + buffer.get(credentialId); + Cbor.Item key = Cbor.decodeFirst(raw, buffer.position()); + if (!(key.value() instanceof Map)) { + throw new IllegalArgumentException("credentialPublicKey is not a COSE key"); + } + publicKey = (Map) key.value(); + buffer.position(buffer.position() + key.length()); + } + if ((flags & FLAG_ED) != 0) { + Cbor.Item extensions = Cbor.decodeFirst(raw, buffer.position()); + buffer.position(buffer.position() + extensions.length()); + } + if (buffer.hasRemaining()) { + throw new IllegalArgumentException("Unexpected trailing bytes in authenticatorData"); + } + return new AuthenticatorData( + raw, rpIdHash, flags, signCount, aaguid, credentialId, publicKey); + } + + @SuppressWarnings("unchecked") + public static AttestationObject parseAttestationObject(String attestationObjectB64) { + Object decoded = Cbor.decode(fromBase64Url(attestationObjectB64)); + if (!(decoded instanceof Map map) + || !(map.get("fmt") instanceof String fmt) + || !(map.get("attStmt") instanceof Map attStmt) + || !(map.get("authData") instanceof byte[] authData)) { + throw new IllegalArgumentException("attestationObject must contain fmt, attStmt, authData"); + } + return new AttestationObject( + fmt, (Map) attStmt, parseAuthenticatorData(authData)); + } + + public static long coseAlgorithm(Map coseKey) { + if (coseKey.get(3L) instanceof Long alg) { + return alg; + } + throw new IllegalArgumentException("COSE key has no algorithm"); + } + + /** Converts a COSE_Key (RFC 9052/9053) into a Java public key. */ + public static PublicKey toPublicKey(Map coseKey) { + try { + long kty = (Long) coseKey.get(1L); + long alg = coseAlgorithm(coseKey); + if (kty == 2 && alg == ES256) { + if (!Long.valueOf(1).equals(coseKey.get(-1L))) { + throw new IllegalArgumentException("Only the P-256 curve is supported for ES256"); + } + BigInteger x = new BigInteger(1, (byte[]) coseKey.get(-2L)); + BigInteger y = new BigInteger(1, (byte[]) coseKey.get(-3L)); + AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(new ECGenParameterSpec("secp256r1")); + ECParameterSpec spec = parameters.getParameterSpec(ECParameterSpec.class); + // KeyFactory validates that the point is on the curve + return KeyFactory.getInstance("EC") + .generatePublic(new ECPublicKeySpec(new ECPoint(x, y), spec)); + } + if (kty == 1 && alg == EDDSA) { + if (!Long.valueOf(6).equals(coseKey.get(-1L))) { + throw new IllegalArgumentException("Only Ed25519 is supported for EdDSA"); + } + byte[] encoded = concat(ED25519_X509_PREFIX, (byte[]) coseKey.get(-2L)); + return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(encoded)); + } + if (kty == 3 && alg == RS256) { + BigInteger modulus = new BigInteger(1, (byte[]) coseKey.get(-1L)); + BigInteger exponent = new BigInteger(1, (byte[]) coseKey.get(-2L)); + return KeyFactory.getInstance("RSA") + .generatePublic(new RSAPublicKeySpec(modulus, exponent)); + } + throw new IllegalArgumentException( + "Unsupported COSE key (kty=%d, alg=%d)".formatted(kty, alg)); + } catch (GeneralSecurityException | ClassCastException | NullPointerException e) { + throw new IllegalArgumentException("Invalid COSE public key", e); + } + } + + public static boolean verifySignature( + long alg, PublicKey publicKey, byte[] signedData, byte[] signature) { + try { + Signature verifier = Signature.getInstance(javaAlgorithmName(alg)); + verifier.initVerify(publicKey); + verifier.update(signedData); + return verifier.verify(signature); + } catch (GeneralSecurityException e) { + return false; + } + } + + private static String javaAlgorithmName(long alg) { + if (alg == ES256) { + return "SHA256withECDSA"; + } + if (alg == EDDSA) { + return "Ed25519"; + } + if (alg == RS256) { + return "SHA256withRSA"; + } + throw new IllegalArgumentException("Unsupported algorithm " + alg); + } + + /** Result of checking an attestation statement. */ + public record AttestationResult(boolean valid, String type, String description) {} + + /** + * Verifies the attestation statement for the formats security keys typically produce ("none", + * "packed" and "fido-u2f"). The certificate chain is not validated against a trust anchor + * such as the FIDO Metadata Service: this only proves the attestation is internally consistent. + */ + public static AttestationResult verifyAttestation( + AttestationObject attestation, byte[] clientDataHash) { + Map attStmt = attestation.attStmt(); + AuthenticatorData authData = attestation.authData(); + byte[] signedData = concat(authData.raw(), clientDataHash); + try { + return switch (attestation.fmt()) { + case "none" -> + attStmt.isEmpty() + ? new AttestationResult(true, "None", "No attestation provided") + : new AttestationResult(false, "None", "attStmt must be empty for 'none'"); + case "packed" -> verifyPacked(attStmt, authData, signedData); + case "fido-u2f" -> verifyFidoU2f(attStmt, authData, clientDataHash); + default -> + new AttestationResult( + true, + "Unverified", + "Attestation format '%s' is not verified by WebGoat".formatted(attestation.fmt())); + }; + } catch (GeneralSecurityException | ClassCastException | NullPointerException e) { + return new AttestationResult(false, attestation.fmt(), "Invalid attestation: " + e); + } + } + + private static AttestationResult verifyPacked( + Map attStmt, AuthenticatorData authData, byte[] signedData) + throws GeneralSecurityException { + long alg = (Long) attStmt.get("alg"); + byte[] sig = (byte[]) attStmt.get("sig"); + if (attStmt.get("x5c") instanceof List x5c && !x5c.isEmpty()) { + X509Certificate certificate = certificate((byte[]) x5c.getFirst()); + if (certificate.getVersion() != 3 || certificate.getBasicConstraints() != -1) { + return new AttestationResult(false, "Basic", "Attestation certificate must be a v3 leaf"); + } + byte[] aaguidExtension = certificate.getExtensionValue(AAGUID_EXTENSION_OID); + if (aaguidExtension != null + && !Arrays.equals( + Arrays.copyOfRange(aaguidExtension, aaguidExtension.length - 16, aaguidExtension.length), + authData.aaguid())) { + return new AttestationResult(false, "Basic", "AAGUID in certificate does not match"); + } + boolean valid = verifySignature(alg, certificate.getPublicKey(), signedData, sig); + return new AttestationResult( + valid, "Basic", "Signed by " + certificate.getSubjectX500Principal().getName()); + } + // self attestation: signed with the credential private key itself + if (alg != coseAlgorithm(authData.credentialPublicKey())) { + return new AttestationResult(false, "Self", "Algorithm does not match credential key"); + } + boolean valid = + verifySignature(alg, toPublicKey(authData.credentialPublicKey()), signedData, sig); + return new AttestationResult(valid, "Self", "Self attestation with the credential key"); + } + + private static AttestationResult verifyFidoU2f( + Map attStmt, AuthenticatorData authData, byte[] clientDataHash) + throws GeneralSecurityException { + List x5c = (List) attStmt.get("x5c"); + if (x5c == null || x5c.size() != 1) { + return new AttestationResult(false, "Basic", "fido-u2f requires exactly one certificate"); + } + X509Certificate certificate = certificate((byte[]) x5c.getFirst()); + if (!(certificate.getPublicKey() instanceof ECPublicKey)) { + return new AttestationResult(false, "Basic", "fido-u2f certificate must use an EC key"); + } + Map coseKey = authData.credentialPublicKey(); + byte[] u2fPublicKey = + concat(new byte[] {0x04}, (byte[]) coseKey.get(-2L), (byte[]) coseKey.get(-3L)); + byte[] verificationData = + concat( + new byte[] {0x00}, + authData.rpIdHash(), + clientDataHash, + authData.credentialId(), + u2fPublicKey); + boolean valid = + verifySignature( + ES256, certificate.getPublicKey(), verificationData, (byte[]) attStmt.get("sig")); + return new AttestationResult( + valid, "Basic", "Signed by " + certificate.getSubjectX500Principal().getName()); + } + + private static X509Certificate certificate(byte[] der) throws GeneralSecurityException { + return (X509Certificate) + CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(der)); + } + + public static String toJson(Object value) { + try { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + /** Reads a nested string value such as {@code credential.response.clientDataJSON}. */ + public static String path(Map json, String... keys) { + Object current = json; + for (String key : keys) { + if (!(current instanceof Map map)) { + return null; + } + current = map.get(key); + } + return current instanceof String s ? s : null; + } +} diff --git a/src/main/resources/lessons/webauthn/css/webauthn.css b/src/main/resources/lessons/webauthn/css/webauthn.css new file mode 100644 index 00000000000..40da5fdf191 --- /dev/null +++ b/src/main/resources/lessons/webauthn/css/webauthn.css @@ -0,0 +1,31 @@ +.webauthn-bank, +.webauthn-ticket { + border: 1px solid #ccc; + border-radius: 6px; + padding: 10px 15px; + margin-bottom: 15px; +} + +.webauthn-bank-header { + font-weight: bold; + border-bottom: 1px solid #ddd; + padding-bottom: 6px; + margin-bottom: 10px; +} + +.webauthn-json { + font-family: monospace; + font-size: 12px; + max-height: 300px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} + +pre.webauthn-json:empty { + display: none; +} + +.webauthn-submit { + margin-top: 8px; +} diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_authentication.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_authentication.adoc new file mode 100644 index 00000000000..cb85c190f1e --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_authentication.adoc @@ -0,0 +1,45 @@ +== The authentication ceremony + +[source] +---- + Browser Server (relying party) Security key + | POST /authentication/options -->| | + | <-- challenge, rpId, | store challenge for this login | + | allowCredentials | | + | navigator.credentials.get() ----------- CTAP2 getAssertion ------->| + | | user touches the key | + | <---------------------------------- authenticatorData, signature --| + | POST /authentication/finish --> | verify, create session | +---- + +The response (an _assertion_) contains: + +* `clientDataJSON`: `{"type":"webauthn.get","challenge":"...","origin":"https://goatbank.example",...}` +* `authenticatorData`: `rpIdHash` | `flags` | `signCount` +* `signature`: made with the private key over `authenticatorData || SHA-256(clientDataJSON)` +* `userHandle`: the user handle stored with a discoverable credential (optional for non-discoverable ones) + +=== What the server must verify + +A simplified version of section 7.2 of the WebAuthn specification: + +. The credential ID belongs to a registered credential. If a user was identified first, the credential must belong + to that user; if `userHandle` is present it must match the owner of the credential. +. `clientDataJSON.type` is `webauthn.get`. +. `clientDataJSON.challenge` equals the challenge issued *for this login attempt*. It is used once and expires. +. `clientDataJSON.origin` is an expected origin. +. `rpIdHash` is the SHA-256 hash of the RP ID. +. The UP flag is set; the UV flag too when the credential is used without a password. +. The signature is valid for the stored public key. +. If the stored or the new signature counter is not zero, the new counter must be greater than the stored one. + Otherwise the authenticator may have been cloned. + +=== Why the challenge matters + +The signature proves that the private key signed _this exact_ `clientDataJSON` and `authenticatorData`. Nothing in +those values expires. The only thing that makes an assertion usable just once is the server insisting that it +contains a fresh, unpredictable challenge that it handed out a moment ago. Without that check, every assertion the +user ever produced is a reusable password. + +The signature counter is a weak second line of defence: synced passkeys and some security keys always report `0`, +and a replayed assertion only fails if the user has signed in again since it was captured. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_authentication_assignment.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_authentication_assignment.adoc new file mode 100644 index 00000000000..a55bcdff254 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_authentication_assignment.adoc @@ -0,0 +1,13 @@ +== Assignment: sign in as Jerry + +Enter a user name and click *Sign in with security key*. GoatBank returns the options, the simulated key signs the +challenge (it only holds Tom's credential), and the request body for `/WebAuthn/authentication/finish` appears in +the text area. Click *Send* to sign in as Tom. + +A while ago Jerry could not log in. A GoatBank engineer debugged the problem and attached the request from Jerry's +browser to the helpdesk ticket. Captures like these (HAR files, proxy logs, screenshots of DevTools) end up in +ticket systems, chat channels and log files all the time. + +GoatBank verifies the signature against Jerry's public key, and checks the origin, RP ID hash and flags. + +*Goal:* sign in to GoatBank as *Jerry*. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_concepts.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_concepts.adoc new file mode 100644 index 00000000000..ee2480ab646 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_concepts.adoc @@ -0,0 +1,58 @@ +== Concepts + +=== Relying party, RP ID and origin + +The *relying party* (RP) is the website that wants to authenticate users. Each credential is scoped to an +*RP ID*, a domain such as `goatbank.example`. The browser only allows a page to use an RP ID that is equal to, +or a registrable suffix of, its own host. A page on `https://login.goatbank.example` may use the RP ID +`goatbank.example`; a page on `https://goatbank.evil.example` may not. + +WebAuthn requires a *secure context*: the page must be served over `https://`, with `http://localhost` as the +only exception for development. IP addresses cannot be used as an RP ID. + +=== Authenticators + +[cols="1,3"] +|=== +|Platform authenticator |Built into the device: Touch ID / Face ID, Windows Hello, Android screen lock. +|Roaming (cross-platform) authenticator |A separate device that can be used with any computer: a *security key* +such as a YubiKey or Titan key connected over USB, NFC or Bluetooth, or a phone connected through the +_hybrid_ transport (scan a QR code, proximity checked over Bluetooth). +|=== + +=== Passkeys and security keys + +A *passkey* is a _discoverable_ credential: the authenticator stores the user account together with the key, +so the user can sign in without typing a user name. Passkeys managed by Apple, Google or a password manager are +*synced* between devices. A credential on a security key is *device-bound*: the private key cannot be exported, +so possession of that physical key is required. + +Security keys can also create _non-discoverable_ credentials. The key then stores nothing; the private key is +wrapped (encrypted) inside the credential ID that the server stores, and the server has to send the credential ID +back during sign in (`allowCredentials`). This is how security keys are used as a second factor. + +=== Flags + +Each response contains `authenticatorData` with flags the server should inspect: + +[cols="1,4"] +|=== +|UP (user present) |Someone touched the key or confirmed a prompt. +|UV (user verified) |The authenticator verified _who_ that is: PIN, fingerprint or face. Required when the +credential is the only factor. +|BE / BS (backup eligible / backed up) |The credential can be (and has been) synced to other devices. Always false +for a security key. +|AT (attested credential data) |A new public key is included (registration). +|=== + +=== What the server stores + +For every credential the relying party keeps a record: + +* the *credential ID*; +* the *public key* (in COSE format) and its algorithm, for example ES256 (`-7`); +* the *signature counter*, which many security keys increment on every use; +* the *user handle*: the account identifier the authenticator returns during sign in. The specification says this + must be an opaque random value of at most 64 bytes and must not contain personal information such as a user + name or e-mail address; +* optionally the *AAGUID* (authenticator model) and attestation information. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_intro.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_intro.adoc new file mode 100644 index 00000000000..d3aa57f12d9 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_intro.adoc @@ -0,0 +1,36 @@ +== Security keys, passkeys and WebAuthn + +Passwords can be guessed, reused, leaked and phished. One-time codes sent by SMS or generated by an app +improve this, but an attacker running a look-alike site can still ask the victim for the code and use it +immediately. + +*FIDO2* replaces shared secrets with public key cryptography. It consists of two specifications: + +* *WebAuthn* (W3C): the JavaScript API (`navigator.credentials.create()` / `navigator.credentials.get()`) + that a website uses to talk to the browser, and the data formats the server receives. +* *CTAP2* (FIDO Alliance): the protocol the browser or operating system uses to talk to an + authenticator, for example a security key over USB, NFC or Bluetooth. + +For every website the authenticator creates a new key pair. The private key never leaves the +authenticator; the website only stores the public key. Because the browser tells the authenticator +which site is asking, and puts the origin into the signed data, a credential for `goatbank.example` +cannot be used on `goatbank-login.example`. This makes WebAuthn _phishing resistant_. + +=== It is only as strong as the server + +The browser and the authenticator do their part, but *the relying party (the website) has to verify +everything it receives*. The specification lists more than twenty verification steps for registration +and authentication. Developers who build this themselves, or who use a library incorrectly, can easily +leave one of them out. A single missing check can turn "unphishable" authentication into an account +takeover. + +=== Lesson goals + +After this lesson you will: + +* understand authenticators, security keys and passkeys, and the data exchanged during the + *registration* and *authentication* ceremonies; +* exploit a registration flaw that binds your security key to someone else's account; +* exploit an authentication flaw that allows replaying a captured login; +* know which other implementation mistakes to look for, and how to prevent them; +* (bonus) register and sign in with a real roaming security key against a server that performs all checks. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_mitigation.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_mitigation.adoc new file mode 100644 index 00000000000..e2628b1dbae --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_mitigation.adoc @@ -0,0 +1,34 @@ +== Implementing WebAuthn securely + +=== Do not write the verification yourself + +Parsing CBOR, COSE keys and attestation statements, and implementing every verification step, is error prone. Use +a maintained library that implements the specification and is tested against the FIDO conformance tools: + +* Java: Spring Security WebAuthn support (built on WebAuthn4J), WebAuthn4J, Yubico `java-webauthn-server` +* JavaScript/Node.js: SimpleWebAuthn +* Python: `py_webauthn`; Go: `go-webauthn`; .NET: `fido2-net-lib` + +With Spring Security, passkey support is enabled with a fixed RP ID and allow list of origins: + +[source,java] +---- +http.webAuthn(webAuthn -> webAuthn + .rpName("GoatBank") + .rpId("goatbank.example") + .allowedOrigins("https://goatbank.example")); +---- + +=== Rules that a library cannot enforce for you + +* Keep ceremony state (challenge, account, requested options) on the server, bound to the session. Use every + challenge once and let it expire after a few minutes. +* Register credentials for the account that is signed in, never for an account named in the request. +* Generate user handles as 64 random bytes. Do not use user names, e-mail addresses or database IDs. +* Configure the RP ID and allowed origins; never derive them from request headers. +* Require user verification when the credential replaces the password. +* Require recent re-authentication before credentials are added or removed, and notify the user. +* Make recovery at least as strong as the credential: recovery codes, a second registered key, or verified + identity, not a single SMS or e-mail link. +* Allow users to see, name and remove their registered credentials. +* Do not log or attach complete WebAuthn requests in tickets; treat them like credentials. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_pitfalls.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_pitfalls.adoc new file mode 100644 index 00000000000..bd02d5db797 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_pitfalls.adoc @@ -0,0 +1,41 @@ +== Other implementation pitfalls + +The two assignments showed missing checks in hand-written verification code. These are other mistakes found in +real WebAuthn and passkey implementations. + +=== Registration + +* *Account taken from the client.* The account is identified by a user name, e-mail or user handle in the finish + request instead of the server-side ceremony state. (Assignment 1.) +* *Predictable or personal user handles.* A user handle such as the user name or database ID leaks information + to everyone who can read the authenticator, and makes it easy to target accounts. +* *No re-authentication before adding a key.* An attacker with a stolen session cookie, or a few seconds at an + unlocked computer, adds their own passkey and keeps access after the password is reset. Require a recent sign + in, and notify the user by e-mail when a credential is added. +* *Credential ID not unique.* A credential ID that is already registered for another account must be rejected. +* *Trusting client-reported details.* `authenticatorAttachment` and `getTransports()` are reported by the + client. A policy such as "only hardware security keys" requires verified attestation, checked against the + FIDO Metadata Service. +* *Unrequested algorithms or malformed keys accepted.* Hand-written CBOR/COSE parsing that accepts any algorithm + or does not validate the key can enable signature forgery. + +=== Authentication + +* *Challenge missing, not single use, or stored on the client* (for example in a cookie or an unsigned token). + Captured assertions can be replayed. (Assignment 2.) +* *Origin or RP ID hash not checked.* Without this, a phishing site that proxies the WebAuthn ceremony obtains an + assertion the real site accepts, which removes the main benefit of WebAuthn. Also beware of accepting any + subdomain or `http://` origins. +* *Credential not bound to the user.* The server verifies the signature with the credential identified by its + ID, but signs in the user name from the form. Any valid key then grants access to any account. +* *UV not enforced for passwordless sign in.* UP only proves _somebody_ touched the key. A lost or stolen key then + equals access to the account. +* *Signature counter ignored* for authenticators that implement one, which removes clone detection. +* *User enumeration.* Returning `allowCredentials` for any user name tells an attacker which accounts exist and + which credentials they have. Use discoverable credentials or return consistent fake data for unknown users. + +=== The rest of the account + +* *Weak recovery and fallbacks.* If an attacker can still sign in with a password, an SMS code or a "lost my key" + e-mail link, WebAuthn does not protect the account. The weakest sign in method decides the security level. +* *Session handling.* After a successful ceremony the usual rules apply: new session ID, secure cookies, timeouts. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_quiz.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_quiz.adoc new file mode 100644 index 00000000000..a14cbc97ca7 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_quiz.adoc @@ -0,0 +1,3 @@ +== Quiz + +Test your knowledge of the other WebAuthn implementation pitfalls. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_registration.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_registration.adoc new file mode 100644 index 00000000000..a7eeb9b3101 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_registration.adoc @@ -0,0 +1,62 @@ +== The registration ceremony + +Registration adds a new credential to an account the user is _already signed in to_ (or is creating right now). + +[source] +---- + Browser Server (relying party) Security key + | POST /registration/options --> | | + | <-- challenge, rp, user, algs | store challenge for this user | + | | | + | navigator.credentials.create() -------- CTAP2 makeCredential ------>| + | | user touches the key, | + | | new key pair is created | + | <---------------------------------------- attestationObject --------| + | POST /registration/finish ---> | verify, store public key | +---- + +=== Options sent by the server + +[source,json] +---- +{ + "challenge": "q3xY...random 32 bytes...", + "rp": { "id": "goatbank.example", "name": "GoatBank" }, + "user": { "id": "kJ8f...random user handle...", "name": "tom", "displayName": "Tom Cat" }, + "pubKeyCredParams": [ { "type": "public-key", "alg": -7 } ], + "excludeCredentials": [ { "type": "public-key", "id": "credential ID already registered" } ], + "attestation": "none" +} +---- + +=== Response returned by the browser + +* `clientDataJSON`, created by the browser: ++ +[source,json] +---- +{"type":"webauthn.create","challenge":"q3xY...","origin":"https://goatbank.example","crossOrigin":false} +---- +* `attestationObject`, a CBOR map created by the authenticator with: +** `fmt` and `attStmt`: the attestation statement (`none`, `packed`, `fido-u2f`, `tpm`, ...); +** `authData`: `rpIdHash` (32 bytes) | `flags` (1) | `signCount` (4) | `aaguid` (16) | credential ID length (2) | + credential ID | credential public key (COSE). + +=== What the server must verify + +A simplified version of the steps in section 7.1 of the WebAuthn specification: + +. The ceremony was started by an authenticated user; the challenge and the *account* are kept on the server. +. `clientDataJSON.type` is `webauthn.create`. +. `clientDataJSON.challenge` equals the challenge issued for this ceremony. A challenge is used once and expires. +. `clientDataJSON.origin` is one of the expected origins. +. `rpIdHash` is the SHA-256 hash of the RP ID. +. The UP flag is set, and UV if the policy requires user verification. +. The public key algorithm is one that was requested. +. The attestation statement is valid and acceptable according to policy. +. The credential ID is not registered yet, for any account. +. The credential is stored for *the account that started the ceremony*. + +Note what is *not* signed: during registration nothing proves which account the browser thinks it is registering +for. The `user` object is only sent _to_ the authenticator. Anything the client sends next to the attestation can +be changed. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_registration_assignment.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_registration_assignment.adoc new file mode 100644 index 00000000000..08a1b606143 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_registration_assignment.adoc @@ -0,0 +1,19 @@ +== Assignment: add a security key to GoatBank + +You are *Tom* and you are signed in to GoatBank. In the security settings you can add a security key. *Jerry*, +GoatBank's administrator, also uses a security key to sign in. + +You do not need a physical key: WebGoat simulates Tom's security key and the part of the browser that builds +`clientDataJSON`, just like the virtual authenticator in Chrome DevTools. + +. *Add a security key* requests the registration options from GoatBank. +. *Touch the security key* lets the simulated key create a new key pair. The body of the request that GoatBank's + JavaScript is about to send is shown in the text area. +. *Finish registration* sends that request to `/WebAuthn/registration/finish`. + +GoatBank verifies the challenge, origin, RP ID hash, flags and attestation correctly. + +*Goal:* get GoatBank to register _your_ new security key on *Jerry's* account. From then on you could sign in +as Jerry with a key you own. + +TIP: You can edit the request body in the text area, or intercept the request with a proxy such as ZAP. diff --git a/src/main/resources/lessons/webauthn/documentation/WebAuthn_securitykey.adoc b/src/main/resources/lessons/webauthn/documentation/WebAuthn_securitykey.adoc new file mode 100644 index 00000000000..f68562ac4d3 --- /dev/null +++ b/src/main/resources/lessons/webauthn/documentation/WebAuthn_securitykey.adoc @@ -0,0 +1,35 @@ +== Bonus: use a real security key + +This assignment uses the WebAuthn API of your browser with a *roaming authenticator*: a security key connected over +USB, NFC or Bluetooth, or a phone through the hybrid transport. The WebGoat server behind it performs all the +checks from the previous pages, including attestation verification for the `packed` and `fido-u2f` formats. + +The registration options ask for: + +* `authenticatorAttachment: "cross-platform"` and `hints: ["security-key"]`: the browser offers a security key + or phone instead of the built-in platform authenticator; +* `residentKey: "discouraged"`: a non-discoverable credential, so no storage slot on the key is used; +* `attestation: "direct"`: the key proves its make and model. Your browser may ask for permission to share this; +* `userVerification: "discouraged"`: the key is used as a second factor, so no PIN is needed. + +=== Requirements + +* Open WebGoat in a secure context: `https://` or *http://localhost:8080/WebGoat*. Using an IP address such as + `127.0.0.1` or the address of a Docker host does not work, because an IP address cannot be an RP ID. When you run + WebGoat in Docker, publish the port and browse to `localhost`. +* No security key? In Chrome or Edge open *DevTools > More tools > WebAuthn*, select _Enable virtual authenticator + environment_ and add an authenticator with protocol *ctap2* and transport *usb*. + +=== Steps + +. Click *Register security key* and touch your key. The server shows what it verified and stored: the AAGUID + (authenticator model), the attestation format and signer, and the flags. A security key reports `BE=false`: + the credential is device-bound. +. Click *Sign in with security key* and touch the key again. The assignment is solved when the server accepts the + assertion. + +Also try what you exploited in the GoatBank assignments: replay a request or change the user handle in a proxy, and +look at how this server responds. + +NOTE: WebGoat does not validate attestation certificates against the FIDO Metadata Service, so it cannot tell a +genuine security key from a well-made software emulation. A relying party that needs this guarantee must do so. diff --git a/src/main/resources/lessons/webauthn/html/WebAuthn.html b/src/main/resources/lessons/webauthn/html/WebAuthn.html new file mode 100644 index 00000000000..0b53cb07cbe --- /dev/null +++ b/src/main/resources/lessons/webauthn/html/WebAuthn.html @@ -0,0 +1,137 @@ + + + + + +
+
+
+ +
+
+
+ +
+
+
+ + +
+
+
+
+
+
GoatBank · Security settings · signed in as tom
+

+ + +

+ +

+            
+ + + +
+
+
+
+
+
+ +
+
+
+ + +
+
+
+
+
+
GoatBank · Sign in
+
+ + +
+ +

+            
+ + + +
+
+
+
GoatBank helpdesk · Ticket #4711 · "Jerry cannot log in with his YubiKey"
+

Status: resolved. "Attached the request from Jerry's browser (copied from DevTools → Network) for analysis."

+ +

+        
+
+
+
+
+ +
+
+
+ + +
+ + + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ + +
+
+
+
+ +

+ +

+

+        
+ + +
+
+
+
+
+ + + + diff --git a/src/main/resources/lessons/webauthn/i18n/WebGoatLabels.properties b/src/main/resources/lessons/webauthn/i18n/WebGoatLabels.properties new file mode 100644 index 00000000000..52709579d12 --- /dev/null +++ b/src/main/resources/lessons/webauthn/i18n/WebGoatLabels.properties @@ -0,0 +1,28 @@ +webauthn.title=Security Keys & WebAuthn + +# Registration assignment +webauthn.registration.success=Well done! GoatBank stored your security key on Jerry's account. You can now sign in as Jerry without knowing any of his secrets. +webauthn.registration.registered=The security key was registered for account {0}. That is what GoatBank intended, but can you get it registered on the account of Jerry? +webauthn.registration.unknown-account=GoatBank has no account named {0}. Have a look at how the user handle is constructed. +webauthn.registration.rejected=GoatBank rejected the registration: {0} +webauthn.registration.no-challenge=No registration in progress, start again with step 1. +webauthn.registration.hint1=Look at the request body of the finish request: which field could tell GoatBank which account the key is for? +webauthn.registration.hint2=The userHandle is base64url encoded. Decode it and see what it contains. +webauthn.registration.hint3=The userHandle is not covered by any signature, so GoatBank cannot tell if it was changed. +webauthn.registration.hint4=Base64url encode 'jerry' (amVycnk) and use it as the userHandle before finishing the registration. + +# Authentication assignment +webauthn.authentication.success=Congratulations, you are signed in as Jerry by replaying an old assertion! GoatBank never checked the challenge. +webauthn.authentication.signed-in=Signed in as {0}. Now try to become Jerry. +webauthn.authentication.rejected=GoatBank rejected the sign in: {0} +webauthn.authentication.hint1=You cannot produce a valid signature for Jerry's key, but maybe someone already did. +webauthn.authentication.hint2=Read the attachment of the support ticket. +webauthn.authentication.hint3=Compare the challenge in the decoded clientDataJSON of the captured request with the challenge from the options GoatBank just sent you. +webauthn.authentication.hint4=Send the captured request body unchanged to /WebAuthn/authentication/finish. + +# Bonus: security key +webauthn.securitykey.success=You signed in with your security key and the server verified the full assertion! +webauthn.securitykey.rejected=The security key sign in was rejected: {0} +webauthn.securitykey.hint1=WebAuthn only works in a secure context: use https:// or http://localhost, an IP address cannot be used as RP ID. +webauthn.securitykey.hint2=No security key? In Chrome open DevTools, More tools, WebAuthn and enable the virtual authenticator environment with protocol ctap2 and transport usb. +webauthn.securitykey.hint3=Register the key first, then sign in. Every step requests a fresh challenge, so resubmitting an old response will fail. diff --git a/src/main/resources/lessons/webauthn/js/questions_webauthn.json b/src/main/resources/lessons/webauthn/js/questions_webauthn.json new file mode 100644 index 00000000000..f2c13ffcb5c --- /dev/null +++ b/src/main/resources/lessons/webauthn/js/questions_webauthn.json @@ -0,0 +1,44 @@ +{ + "questions": [ + { + "text": "GoatBank returns the allowCredentials list for every user name typed on the sign in page, and an empty list for unknown users. What is the risk?", + "solutions": { + "1": "None, credential IDs are public values", + "2": "User enumeration: an attacker learns which accounts exist and use security keys", + "3": "An attacker can derive the private key from the credential ID" + } + }, + { + "text": "Why must the relying party compare the origin in clientDataJSON with its own origin?", + "solutions": { + "1": "To make CORS work for the WebAuthn API", + "2": "So an assertion obtained by a look-alike (phishing) site is rejected by the real site", + "3": "Because the origin is not covered by the authenticator signature" + } + }, + { + "text": "A security key is the only factor for signing in (passwordless). Which flag must the server require in authenticatorData?", + "solutions": { + "1": "UP (user present)", + "2": "UV (user verified)", + "3": "BE (backup eligible)" + } + }, + { + "text": "A security key that always increments its signature counter returns a counter lower than the stored value. What does this indicate?", + "solutions": { + "1": "The clock of the security key is out of sync", + "2": "The credential may have been cloned", + "3": "The user has registered the key twice" + } + }, + { + "text": "Why should adding a new passkey or security key require recent re-authentication?", + "solutions": { + "1": "Otherwise an attacker with a hijacked session can register their own key and keep access", + "2": "Because authenticators refuse to register while a session cookie exists", + "3": "Because the WebAuthn API only works on freshly loaded pages" + } + } + ] +} diff --git a/src/main/resources/lessons/webauthn/js/webauthn.js b/src/main/resources/lessons/webauthn/js/webauthn.js new file mode 100644 index 00000000000..5d781c4d2d0 --- /dev/null +++ b/src/main/resources/lessons/webauthn/js/webauthn.js @@ -0,0 +1,215 @@ +/* + * WebAuthn lesson: GoatBank flows use a security key simulated by WebGoat, the bonus assignment + * uses the browser WebAuthn API with a real (roaming) authenticator. + */ + +function webauthnToBuffer(base64url) { + var base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + var binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4)); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function webauthnToBase64Url(buffer) { + var bytes = new Uint8Array(buffer); + var binary = ''; + for (var i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function webauthnJson(value) { + return JSON.stringify(value, null, 2); +} + +// native promises only: the global jQuery of the lesson pages does not adopt native promises +function webauthnRequest(url, options) { + return fetch(url, Object.assign({credentials: 'same-origin'}, options)).then(function (response) { + return response.json().catch(function () { + return {}; + }).then(function (json) { + if (!response.ok) { + throw new Error(json.error || ('HTTP ' + response.status)); + } + return json; + }); + }); +} + +function webauthnPost(url, body) { + return webauthnRequest(url, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body || {}) + }); +} + +function webauthnError(error) { + if (!error || !error.message) { + return 'Request failed'; + } + // DOMExceptions from the WebAuthn API, e.g. NotAllowedError when the user cancels + return (error.name && error.name !== 'Error') ? error.name + ': ' + error.message : error.message; +} + +/* ---------- GoatBank registration (simulated security key) ---------- */ + +var webauthnRegistrationOptions = null; + +webgoat.customjs.webauthnRegistrationBody = function () { + return $('#webauthn-reg-body').val(); +}; + +function webauthnBeginRegistration() { + $('#webauthn-reg-body').val(''); + webauthnPost('WebAuthn/registration/options').then(function (options) { + webauthnRegistrationOptions = options; + $('#webauthn-reg-options').text(webauthnJson(options)); + $('#webauthn-reg-touch').prop('disabled', false); + }).catch(function (error) { + $('#webauthn-reg-options').text(webauthnError(error)); + }); +} + +function webauthnTouchRegistrationKey() { + if (!webauthnRegistrationOptions) { + return; + } + webauthnPost('WebAuthn/authenticator/create', webauthnRegistrationOptions).then(function (credential) { + // GoatBank's front end sends the user handle back so the server knows the account + $('#webauthn-reg-body').val(webauthnJson({ + userHandle: webauthnRegistrationOptions.user.id, + credential: credential + })); + $('#webauthn-reg-touch').prop('disabled', true); + }).catch(function (error) { + $('#webauthn-reg-body').val(webauthnError(error)); + }); +} + +/* ---------- GoatBank sign in (simulated security key) ---------- */ + +webgoat.customjs.webauthnAuthenticationBody = function () { + return $('#webauthn-login-body').val(); +}; + +function webauthnStartLogin() { + var username = $('#webauthn-login-username').val(); + $('#webauthn-login-body').val(''); + webauthnPost('WebAuthn/authentication/options', {username: username}).then(function (options) { + $('#webauthn-login-options').text(webauthnJson(options)); + return webauthnPost('WebAuthn/authenticator/get', options).then(function (credential) { + $('#webauthn-login-body').val(webauthnJson({username: username, credential: credential})); + }); + }).catch(function (error) { + $('#webauthn-login-body').val(webauthnError(error)); + }); +} + +function webauthnOpenTicket() { + webauthnRequest('WebAuthn/support-ticket').then(function (captured) { + $('#webauthn-ticket-content').text( + 'POST /WebGoat/WebAuthn/authentication/finish HTTP/1.1\n' + + 'Content-Type: application/json\n\n' + webauthnJson(captured)); + }); +} + +/* ---------- Bonus: real roaming authenticator ---------- */ + +webgoat.customjs.webauthnSecurityKeyBody = function () { + return $('#webauthn-sk-assertion').val(); +}; + +function webauthnSupported() { + if (!window.isSecureContext) { + return 'WebAuthn needs a secure context. Open WebGoat via https:// or http://localhost ' + + '(current origin: ' + window.location.origin + ').'; + } + if (!window.PublicKeyCredential || !navigator.credentials) { + return 'This browser does not support WebAuthn.'; + } + return null; +} + +function webauthnCredentialToJson(credential) { + var response = credential.response; + var json = { + id: credential.id, + rawId: webauthnToBase64Url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment || null, + clientExtensionResults: typeof credential.getClientExtensionResults === 'function' + ? credential.getClientExtensionResults() : {}, + response: {clientDataJSON: webauthnToBase64Url(response.clientDataJSON)} + }; + if (response.attestationObject) { + json.response.attestationObject = webauthnToBase64Url(response.attestationObject); + json.response.transports = response.getTransports ? response.getTransports() : []; + } else { + json.response.authenticatorData = webauthnToBase64Url(response.authenticatorData); + json.response.signature = webauthnToBase64Url(response.signature); + json.response.userHandle = response.userHandle ? webauthnToBase64Url(response.userHandle) : null; + } + return json; +} + +function webauthnDescriptors(descriptors) { + return (descriptors || []).map(function (d) { + return {type: d.type, id: webauthnToBuffer(d.id)}; + }); +} + +function webauthnRegisterSecurityKey() { + var output = $('#webauthn-sk-registration'); + output.text('Waiting for your security key, touch it when it blinks...'); + webauthnPost('WebAuthn/securitykey/registration/options').then(function (options) { + var publicKey = Object.assign({}, options, { + challenge: webauthnToBuffer(options.challenge), + user: Object.assign({}, options.user, {id: webauthnToBuffer(options.user.id)}), + excludeCredentials: webauthnDescriptors(options.excludeCredentials) + }); + return navigator.credentials.create({publicKey: publicKey}); + }).then(function (credential) { + return webauthnPost('WebAuthn/securitykey/registration/finish', webauthnCredentialToJson(credential)); + }).then(function (result) { + output.text(webauthnJson(result)); + }).catch(function (error) { + output.text('Registration failed: ' + webauthnError(error)); + }); +} + +function webauthnSignInWithSecurityKey() { + var output = $('#webauthn-sk-registration'); + webauthnPost('WebAuthn/securitykey/authentication/options').then(function (options) { + var publicKey = Object.assign({}, options, { + challenge: webauthnToBuffer(options.challenge), + allowCredentials: webauthnDescriptors(options.allowCredentials) + }); + return navigator.credentials.get({publicKey: publicKey}); + }).then(function (credential) { + $('#webauthn-sk-assertion').val(JSON.stringify(webauthnCredentialToJson(credential))); + // let WebGoat submit the assignment form so progress and feedback are handled + document.getElementById('webauthn-sk-form').requestSubmit(); + }).catch(function (error) { + output.text('Sign in failed: ' + webauthnError(error)); + }); +} + +$(document).ready(function () { + $('#webauthn-reg-begin').off('click').on('click', webauthnBeginRegistration); + $('#webauthn-reg-touch').off('click').on('click', webauthnTouchRegistrationKey); + $('#webauthn-login-start').off('click').on('click', webauthnStartLogin); + $('#webauthn-ticket-open').off('click').on('click', webauthnOpenTicket); + $('#webauthn-sk-register').off('click').on('click', webauthnRegisterSecurityKey); + $('#webauthn-sk-login').off('click').on('click', webauthnSignInWithSecurityKey); + + var unsupported = webauthnSupported(); + if (unsupported) { + $('#webauthn-sk-unsupported').text(unsupported).show(); + $('#webauthn-sk-register, #webauthn-sk-login').prop('disabled', true); + } +}); diff --git a/src/test/java/org/owasp/webgoat/lessons/webauthn/CborTest.java b/src/test/java/org/owasp/webgoat/lessons/webauthn/CborTest.java new file mode 100644 index 00000000000..3adb277a000 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/webauthn/CborTest.java @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CborTest { + + @Test + void roundTripsWebAuthnStructures() { + Map map = new LinkedHashMap<>(); + map.put("fmt", "none"); + map.put("attStmt", Map.of()); + map.put(1L, 2L); + map.put(-7L, new byte[] {1, 2, 3}); + map.put("list", List.of(true, false, 70000L, -300L)); + + Object decoded = Cbor.decode(Cbor.encode(map)); + + assertThat(decoded).isInstanceOf(Map.class); + Map result = (Map) decoded; + assertThat(result.get("fmt")).isEqualTo("none"); + assertThat(result.get(1L)).isEqualTo(2L); + assertThat((byte[]) result.get(-7L)).containsExactly(1, 2, 3); + assertThat(result.get("list")).isEqualTo(List.of(true, false, 70000L, -300L)); + } + + @Test + void decodesRfc8949Examples() { + assertThat(Cbor.decode(HexFormat.of().parseHex("1903e8"))).isEqualTo(1000L); + assertThat(Cbor.decode(HexFormat.of().parseHex("3903e7"))).isEqualTo(-1000L); + assertThat(Cbor.decode(HexFormat.of().parseHex("6449455446"))).isEqualTo("IETF"); + } + + @Test + void rejectsMalformedInput() { + // byte string claiming 16 bytes, only 1 present + assertThatThrownBy(() -> Cbor.decode(HexFormat.of().parseHex("5001"))) + .isInstanceOf(IllegalArgumentException.class); + // indefinite length array + assertThatThrownBy(() -> Cbor.decode(HexFormat.of().parseHex("9f01ff"))) + .isInstanceOf(IllegalArgumentException.class); + // trailing data + assertThatThrownBy(() -> Cbor.decode(HexFormat.of().parseHex("0101"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void virtualAuthenticatorProducesParsableAttestation() { + var authenticator = new VirtualAuthenticator(); + var credential = + authenticator.create("http://localhost", "localhost", "Y2hhbGxlbmdl", new byte[] {1}); + var response = (Map) credential.get("response"); + + var attestation = + WebAuthnSupport.parseAttestationObject((String) response.get("attestationObject")); + var clientData = WebAuthnSupport.parseClientData((String) response.get("clientDataJSON")); + + assertThat(attestation.fmt()).isEqualTo("none"); + assertThat(attestation.authData().userPresent()).isTrue(); + assertThat(attestation.authData().aaguidAsUuid()) + .isEqualTo(VirtualAuthenticator.AAGUID.toString()); + assertThat(WebAuthnSupport.base64Url(attestation.authData().credentialId())) + .isEqualTo(credential.get("id")); + assertThat(WebAuthnSupport.toPublicKey(attestation.authData().credentialPublicKey())) + .isNotNull(); + assertThat(clientData.type()).isEqualTo("webauthn.create"); + assertThat(WebAuthnSupport.verifyAttestation(attestation, clientData.hash()).valid()).isTrue(); + } +} diff --git a/src/test/java/org/owasp/webgoat/lessons/webauthn/WebAuthnLessonTest.java b/src/test/java/org/owasp/webgoat/lessons/webauthn/WebAuthnLessonTest.java new file mode 100644 index 00000000000..3bfa158dcd4 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/webauthn/WebAuthnLessonTest.java @@ -0,0 +1,257 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webauthn; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.plugins.LessonTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.ResultActions; + +class WebAuthnLessonTest extends LessonTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + // MockMvc requests use http://localhost + private static final String ORIGIN = "http://localhost"; + private static final String RP_ID = "localhost"; + + private MockHttpSession session; + + @BeforeEach + void setup() { + session = new MockHttpSession(); + } + + @Test + void registeringOwnKeyOnJerrysAccountSolvesAssignment() throws Exception { + Map options = postJson("/WebAuthn/registration/options", Map.of()); + Map credential = postJson("/WebAuthn/authenticator/create", options); + + finishRegistration(WebAuthnSupport.base64Url("jerry".getBytes(StandardCharsets.UTF_8)), credential) + .andExpect(jsonPath("$.lessonCompleted", is(true))); + } + + @Test + void honestRegistrationDoesNotSolveAssignment() throws Exception { + Map options = postJson("/WebAuthn/registration/options", Map.of()); + Map credential = postJson("/WebAuthn/authenticator/create", options); + + finishRegistration(userId(options), credential) + .andExpect(jsonPath("$.lessonCompleted", is(false))) + .andExpect(jsonPath("$.feedback", containsString("registered for account tom"))); + } + + @Test + void registrationWithForeignChallengeIsRejected() throws Exception { + postJson("/WebAuthn/registration/options", Map.of()); + var credential = + new VirtualAuthenticator() + .create(ORIGIN, RP_ID, WebAuthnSupport.randomChallenge(), new byte[] {1}); + + finishRegistration(WebAuthnSupport.base64Url("jerry".getBytes(StandardCharsets.UTF_8)), credential) + .andExpect(jsonPath("$.lessonCompleted", is(false))) + .andExpect(jsonPath("$.feedback", containsString("challenge does not match"))); + } + + @Test + void registrationFromOtherOriginIsRejected() throws Exception { + Map options = postJson("/WebAuthn/registration/options", Map.of()); + var credential = + new VirtualAuthenticator() + .create("https://goatbank.evil", RP_ID, (String) options.get("challenge"), new byte[] {1}); + + finishRegistration(userId(options), credential) + .andExpect(jsonPath("$.lessonCompleted", is(false))) + .andExpect(jsonPath("$.feedback", containsString("unexpected origin"))); + } + + @Test + void tomCanSignInWithHisOwnKey() throws Exception { + Map options = postJson("/WebAuthn/authentication/options", Map.of("username", "tom")); + Map credential = postJson("/WebAuthn/authenticator/get", options); + + finishAuthentication(Map.of("username", "tom", "credential", credential)) + .andExpect(jsonPath("$.lessonCompleted", is(false))) + .andExpect(jsonPath("$.feedback", containsString("Signed in as tom"))); + } + + @Test + void tomsKeyCannotSignForJerry() throws Exception { + Map options = + postJson("/WebAuthn/authentication/options", Map.of("username", "jerry")); + + mockMvc + .perform( + post("/WebAuthn/authenticator/get") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(options))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("NotAllowedError"))); + } + + @Test + void replayingCapturedLoginSolvesAssignment() throws Exception { + String captured = + mockMvc + .perform(get("/WebAuthn/support-ticket").session(session)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + postJson("/WebAuthn/authentication/options", Map.of("username", "jerry")); + + finishAuthentication(MAPPER.readValue(captured, Map.class)) + .andExpect(jsonPath("$.lessonCompleted", is(true))); + } + + @Test + @SuppressWarnings("unchecked") + void capturedCredentialCannotBeUsedForAnotherAccount() throws Exception { + Map captured = + MAPPER.readValue( + mockMvc + .perform(get("/WebAuthn/support-ticket").session(session)) + .andReturn() + .getResponse() + .getContentAsString(), + Map.class); + + finishAuthentication(Map.of("username", "tom", "credential", captured.get("credential"))) + .andExpect(jsonPath("$.lessonCompleted", is(false))) + .andExpect(jsonPath("$.feedback", containsString("unknown credential"))); + } + + @Test + void securityKeyRegistrationAndSignInSolvesBonus() throws Exception { + var securityKey = new VirtualAuthenticator(); + Map registrationOptions = + postJson("/WebAuthn/securitykey/registration/options", Map.of()); + var credential = + securityKey.create( + ORIGIN, + RP_ID, + (String) registrationOptions.get("challenge"), + WebAuthnSupport.fromBase64Url(userId(registrationOptions))); + Map registration = + postJson("/WebAuthn/securitykey/registration/finish", credential); + assertThat(registration.get("registered")).isEqualTo(true); + + var assertion = signIn(securityKey); + finishSecurityKey(assertion).andExpect(jsonPath("$.lessonCompleted", is(true))); + + // the challenge is single use: replaying the same assertion fails + postJson("/WebAuthn/securitykey/authentication/options", Map.of()); + finishSecurityKey(assertion) + .andExpect(jsonPath("$.lessonCompleted", is(false))) + .andExpect(jsonPath("$.feedback", containsString("challenge mismatch"))); + } + + @Test + void securityKeyRegistrationRejectsReplayedChallenge() throws Exception { + var securityKey = new VirtualAuthenticator(); + Map options = postJson("/WebAuthn/securitykey/registration/options", Map.of()); + var credential = + securityKey.create( + ORIGIN, RP_ID, (String) options.get("challenge"), WebAuthnSupport.randomBytes(32)); + postJson("/WebAuthn/securitykey/registration/finish", credential); + + mockMvc + .perform( + post("/WebAuthn/securitykey/registration/finish") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(credential))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("No registration in progress"))); + } + + @Test + void quizIsSolvedWithCorrectAnswers() throws Exception { + mockMvc + .perform( + post("/WebAuthn/quiz") + .param("question_0_solution", "Solution 2: User enumeration") + .param("question_1_solution", "Solution 2") + .param("question_2_solution", "Solution 2") + .param("question_3_solution", "Solution 2") + .param("question_4_solution", "Solution 1")) + .andExpect(jsonPath("$.lessonCompleted", is(true))); + } + + private Map signIn(VirtualAuthenticator securityKey) throws Exception { + Map options = postJson("/WebAuthn/securitykey/authentication/options", Map.of()); + List allowed = + ((List) options.get("allowCredentials")) + .stream().map(d -> (String) ((Map) d).get("id")).toList(); + return securityKey.get(ORIGIN, RP_ID, (String) options.get("challenge"), allowed); + } + + private static String userId(Map options) { + return (String) ((Map) options.get("user")).get("id"); + } + + private ResultActions finishRegistration(String userHandle, Map credential) + throws Exception { + return mockMvc + .perform( + post("/WebAuthn/registration/finish") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content( + MAPPER.writeValueAsString( + Map.of("userHandle", userHandle, "credential", credential)))) + .andExpect(status().isOk()); + } + + private ResultActions finishAuthentication(Map body) throws Exception { + return mockMvc + .perform( + post("/WebAuthn/authentication/finish") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(body))) + .andExpect(status().isOk()); + } + + private ResultActions finishSecurityKey(Map assertion) throws Exception { + return mockMvc + .perform( + post("/WebAuthn/securitykey/authentication/finish") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(assertion))) + .andExpect(status().isOk()); + } + + @SuppressWarnings("unchecked") + private Map postJson(String url, Object body) throws Exception { + String response = + mockMvc + .perform( + post(url) + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(body))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return MAPPER.readValue(response, Map.class); + } +}