8000 [CVE-2026-8830] Missing server-side WebAuthn validations during credential registration by mabartos · Pull Request #49344 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions services/src/main/java/org/keycloak/WebAuthnConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package org.keycloak;

import java.util.List;

public interface WebAuthnConstants {

// Interface binded by FreeMarker template between UA and RP
Expand All @@ -37,6 +39,9 @@ public interface WebAuthnConstants {
String RP_ID = "rpId";
String ATTESTATION_CONVEYANCE_PREFERENCE = "attestationConveyancePreference";
String AUTHENTICATOR_ATTACHMENT = "authenticatorAttachment";
String AUTHENTICATOR_ATTACHMENT_PLATFORM = "platform";
String AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM = "cross-platform";
List<String> SUPPORTED_AUTHENTICATOR_ATTACHMENTS = List.of(AUTHENTICATOR_ATTACHMENT_PLATFORM, AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM);
String REQUIRE_RESIDENT_KEY = "requireResidentKey";
String USER_VERIFICATION_REQUIREMENT = "userVerificationRequirement";
String CREATE_TIMEOUT = "createTimeout";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -61,10 +62,14 @@
import com.webauthn4j.converter.util.ObjectConverter;
import com.webauthn4j.data.AttestationConveyancePreference;
import com.webauthn4j.data.AuthenticatorTransport;
import com.webauthn4j.data.PublicKeyCredentialParameters;
import com.webauthn4j.data.PublicKeyCredentialType;
import com.webauthn4j.data.RegistrationData;
import com.webauthn4j.data.RegistrationParameters;
import com.webauthn4j.data.RegistrationRequest;
import com.webauthn4j.data.attestation.AttestationObject;
import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;
import com.webauthn4j.data.attestation.authenticator.AuthenticatorData;
import com.webauthn4j.data.attestation.statement.AttestationStatement;
import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier;
import com.webauthn4j.data.attestation.statement.NoneAttestationStatement;
Expand Down Expand Up @@ -253,6 +258,7 @@ public void processAction(RequiredActionContext context) {
boolean isUserVerificationRequired = policy.getUserVerificationRequirement().equals(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED);

final String transportsParam = params.getFirst(WebAuthnConstants.TRANSPORTS);
final String authenticatorAttachment = params.getFirst(WebAuthnConstants.AUTHENTICATOR_ATTACHMENT);

RegistrationRequest registrationRequest;

Expand All @@ -263,7 +269,7 @@ public void processAction(RequiredActionContext context) {
registrationRequest = new RegistrationRequest(attestationObject, clientDataJSON);
}

RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, isUserVerificationRequired);
RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, buildExpectedCredentialParameters(policy), isUserVerificationRequired);

if ("on".equals(params.getFirst("logout-sessions"))) {
AuthenticatorUtil.logoutOtherSessions(context);
Expand All @@ -278,11 +284,12 @@ public void processAction(RequiredActionContext context) {

showInfoAfterWebAuthnApiCreate(registrationData);

checkAcceptedAuthenticator(registrationData, policy);
// verify the registration response data against the WebAuthn policies
PolicyVerifier.verifyCompliance(registrationData, policy, authenticatorAttachment);

WebAuthnCredentialModelInput credential = new WebAuthnCredentialModelInput(getCredentialType());

credential.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData());
credential.setAttestedCredentialData(getAttestedCredentialData(registrationData));
credential.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount());
credential.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat());
credential.setTransports(registrationData.getTransports());
Expand Down Expand Up @@ -356,8 +363,8 @@ protected WebAuthnRegistrationManager createWebAuthnRegistrationManager(WebAuthn
*
* Returns the list of converted algorithm identifiers.
**/
private List<Long> convertSignatureAlgorithms(List<String> signatureAlgorithmsList) {
List<Long> algs = new ArrayList();
private static List<Long> convertSignatureAlgorithms(List<String> signatureAlgorithmsList) {
List<Long> algs = new ArrayList<>();
if (signatureAlgorithmsList == null || signatureAlgorithmsList.isEmpty()) return algs;

for (String s : signatureAlgorithmsList) {
Expand Down Expand Up @@ -394,8 +401,26 @@ private List<Long> convertSignatureAlgorithms(List<String> signatureAlgorithmsLi
return algs;
}

private void showInfoAfterWebAuthnApiCreate(RegistrationData response) {
AttestedCredentialData attestedCredentialData = response.getAttestationObject().getAuthenticatorData().getAttestedCredentialData();
private static List<PublicKeyCredentialParameters> buildExpectedCredentialParameters(WebAuthnPolicy policy) {
List<String> signatureAlgorithms = policy.getSignatureAlgorithm();
if (CollectionUtil.isEmpty(signatureAlgorithms)) {
signatureAlgorithms = List.of(Constants.DEFAULT_WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS.split(","));
}
return convertSignatureAlgorithms(signatureAlgorithms)
.stream()
.map(alg -> new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.create(alg)))
.toList();
}

private static AttestedCredentialData getAttestedCredentialData(RegistrationData registrationData) {
return Optional.ofNullable(registrationData.getAttestationObject())
.map(AttestationObject::getAuthenticatorData)
.map(AuthenticatorData::getAttestedCredentialData)
.orElseThrow(() -> new WebAuthnException("Cannot obtain attested credential data from the response"));
}

private static void showInfoAfterWebAuthnApiCreate(RegistrationData response) {
AttestedCredentialData attestedCredentialData = getAttestedCredentialData(response);
AttestationStatement attestationStatement = response.getAttestationObject().getAttestationStatement();
Set<AuthenticatorTransport> transports = response.getTransports();

Expand All @@ -410,18 +435,6 @@ private void showInfoAfterWebAuthnApiCreate(RegistrationData response) {
}
}

private void checkAcceptedAuthenticator(RegistrationData response, WebAuthnPolicy policy) throws Exception {
String aaguid = response.getAttestationObject().getAuthenticatorData().getAttestedCredentialData().getAaguid().toString();
List<String> acceptableAaguids = policy.getAcceptableAaguids();
if (acceptableAaguids != null && !acceptableAaguids.isEmpty()) {
if (NoneAttestationStatement.FORMAT.equals(response.getAttestationObject().getFormat())) {
throw new WebAuthnException("Acceptable AAGUIDs require an attestation format other than 'none'.");
} else if (acceptableAaguids.stream().noneMatch(acceptableAaguid -> aaguid.equals(acceptableAaguid))) {
throw new WebAuthnException("not acceptable aaguid = " + aaguid);
}
}
}

@Override
public void close() {
// NOP
Expand Down Expand Up @@ -473,4 +486,51 @@ private boolean isFormDataRequest(HttpRequest request) {
return mediaType != null && mediaType.isCompatible(MediaType.APPLICATION_FORM_URLENCODED_TYPE);
}

/**
* Policy compliance checks for WebAuthn registration.
* Signature algorithm, user verification, and user presence are validated by webauthn4j via {@link RegistrationParameters}.
* The checks below cover remaining policies.
*/
static class PolicyVerifier {

static void verifyCompliance(RegistrationData registrationData, WebAuthnPolicy policy, String authenticatorAttachment) {
verifyAcceptableAaguids(registrationData, policy);
verifyAuthenticatorAttachment(authenticatorAttachment, policy);
}

private static void verifyAcceptableAaguids(RegistrationData registrationData, WebAuthnPolicy policy) {
String aaguid = getAttestedCredentialData(registrationData).getAaguid().toString();
List<String> acceptableAaguids = policy.getAcceptableAaguids();
if (CollectionUtil.isNotEmpty(acceptableAaguids)) {
// AAGUID comes from the authenticator data itself; only real attestation cryptographically proves the authenticator model
if (NoneAttestationStatement.FORMAT.equals(registrationData.getAttestationObject().getFormat())) {
throw new WebAuthnException("Acceptable AAGUIDs require an attestation format other than 'none'.");
} else if (acceptableAaguids.stream().noneMatch(aaguid::equals)) {
logger.debugf("Rejected authenticator with AAGUID '%s'. Acceptable AAGUIDs: %s", aaguid, acceptableAaguids);
throw new WebAuthnException("Not acceptable authenticator model (based on the AAGUID).");
}
}
}

// Best-effort: authenticatorAttachment is client-reported (not in the attestation object), so not cryptographically verified
private static void verifyAuthenticatorAttachment(String authenticatorAttachment, WebAuthnPolicy policy) {
String requiredAttachment = policy.getAuthenticatorAttachment();
if (requiredAttachment == null || Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED.equals(requiredAttachment)) {
return;
}

// Browser may not provide authenticatorAttachment (not required by WebAuthn spec)
if (StringUtil.isBlank(authenticatorAttachment)) {
return;
}

if (!WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS.contains(authenticatorAttachment)) {
throw new WebAuthnException("Unexpected authenticator attachment value. Possible values are: " + String.join(", ", WebAuthnConstants.SUPPORTED_AUTHENTICATOR_ATTACHMENTS));
}

if (!requiredAttachment.equals(authenticatorAttachment)) {
throw new WebAuthnException("Policy requires '" + requiredAttachment + "' authenticator attachment but got '" + authenticatorAttachment + "'");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package org.keycloak.tests.webauthn;

import java.util.List;

import org.keycloak.common.util.SecretGenerator;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;

import org.junit.jupiter.api.Test;
import org.openqa.selenium.JavascriptExecutor;

import static org.keycloak.WebAuthnConstants.AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;

/**
* Tests that WebAuthn registration enforces realm policy server-side.
*
* Most policy constraints (signature algorithm, authenticator attachment, excludeCredentials)
* are enforced client-side by the browser before the credential reaches the server,
* so they cannot be tested with virtual authenticators via the normal flow.
*
* To test server-side attachment validation, we inject JS to tamper the form field
* before submission — simulating a malicious client that bypasses browser constraints.
*/
@KeycloakIntegrationTest
public class WebAuthnPolicyComplianceTest extends AbstractWebAuthnVirtualTest {

@Test
public void tamperedAuthenticatorAttachment() {
managedRealm.updateWithCleanup(r -> r
.webAuthnPolicyAuthenticatorAttachment(AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM));

registerAndExpectError("attach-tamper",
tamperFormField("authenticatorAttachment", "platform"),
"Policy requires 'cross-platform' authenticator attachment but got 'platform'");
}

@Test
public void invalidAuthenticatorAttachmentValue() {
managedRealm.updateWithCleanup(r -> r
.webAuthnPolicyAuthenticatorAttachment(AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM));

registerAndExpectError("attach-invalid",
tamperFormField("authenticatorAttachment", "not-a-real-value"),
"Unexpected authenticator attachment value");
}

@Test
public void tamperedSignatureAlgorithm() {
// Policy: ES512 only. Tamper pubKeyCredParams to ES256.
managedRealm.updateWithCleanup(r -> r
.webAuthnPolicySignatureAlgorithms(List.of("ES512")));

registerAndExpectError("alg-tamper",
tamperCreateOptions("opts.publicKey.pubKeyCredParams = [{type: 'public-key', alg: -7}];"),
"alg not listed in options.pubKeyCredParams is used.");
}

@Test
public void tamperedUserVerification() {
// Policy: UV required. Tamper to "discouraged" — default virtual authenticator has isUserVerified=false.
managedRealm.updateWithCleanup(r -> r
.webAuthnPolicyUserVerificationRequirement("required"));

registerAndExpectError("uv-tamper",
tamperCreateOptions(
"opts.publicKey.authenticatorSelection = opts.publicKey.authenticatorSelection || {};" +
"opts.publicKey.authenticatorSelection.userVerification = 'discouraged';"),
"Verifier is configured to check user verified, but UV flag in authenticatorData is not set.");
}

@Test
public void tamperedAttestationConveyance() {
// Policy: "direct" attestation. Tamper to "none" — server has no verifier for fmt:"none".
managedRealm.updateWithCleanup(r -> r
.webAuthnPolicyAttestationConveyancePreference("direct"));

registerAndExpectError("att-tamper",
tamperCreateOptions("opts.publicKey.attestation = 'none';"),
"AttestationVerifier is not configured to handle the supplied AttestationStatement format 'none'.");
}

@Test
public void replayedCredentialRegistration() {
// Capture form data from a successful registration, then replay it in a new session.
oAuthClient.openRegistrationForm();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", "replay@email", "policy-replay", PASSWORD);

webAuthnRegisterPage.assertCurrent();

((JavascriptExecutor) driver.driver()).executeScript(
"document.getElementById('register').addEventListener('submit', function() {" +
" ['clientDataJSON','attestationObject','publicKeyCredentialId','transports','authenticatorAttachment']" +
" .forEach(function(id) { localStorage.setItem('replay_' + id, document.getElementById(id).value); });" +
"});"
);

webAuthnRegisterPage.clickRegister();
webAuthnRegisterPage.registerWebAuthnCredential(SecretGenerator.getInstance().randomString(24));
logout();

// Replay the captured credential data in a new registration session
oAuthClient.openRegistrationForm();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", "replay-victim@email", "policy-replay-victim", PASSWORD);

webAuthnRegisterPage.assertCurrent();

((JavascriptExecutor) driver.driver()).executeScript(
"['clientDataJSON','attestationObject','publicKeyCredentialId','transports','authenticatorAttachment']" +
" .forEach(function(id) { document.getElementById(id).value = localStorage.getItem('replay_' + id) || ''; });" +
"document.getElementById('authenticatorLabel').value = 'replayed';" +
"document.getElementById('register').requestSubmit();"
);

webAuthnErrorPage.assertCurrent();
assertThat(webAuthnErrorPage.getError(),
containsString("The actual challenge does not match the expected challenge"));
}

@Test
public void acceptableAaguidWithNoneAttestation() {
managedRealm.updateWithCleanup(r -> r
.webAuthnPolicyAcceptableAaguids(List.of(ALL_ZERO_AAGUID))
.webAuthnPolicyAttestationConveyancePreference("none"));

registerAndExpectError("aaguid-none-attestation",
"Acceptable AAGUIDs require an attestation format other than 'none'.");
}

private void registerAndExpectError(String testId, String tamperScript, String expectedError) {
String username = "policy-" + testId;
String email = "policy-" + testId + "@email";

oAuthClient.openRegistrationForm();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", email, username, PASSWORD);

webAuthnRegisterPage.assertCurrent();

if (tamperScript != null) {
((JavascriptExecutor) driver.driver()).executeScript(tamperScript);
}

webAuthnRegisterPage.clickRegister();

if (webAuthnRegisterPage.isRegisterAlertPresent()) {
webAuthnRegisterPage.registerWebAuthnCredential(SecretGenerator.getInstance().randomString(24));
}

webAuthnErrorPage.assertCurrent();
assertThat(webAuthnErrorPage.getError(), containsString(expectedError));
}

private void registerAndExpectError(String testId, String expectedError) {
registerAndExpectError(testId, null, expectedError);
}

private static String tamperFormField(String fieldId, String value) {
return "document.getElementById('register').addEventListener('submit', function() {" +
" document.getElementById('" + fieldId + "').value = '" + value + "';" +
"});";
}

private static String tamperCreateOptions(String body) {
return "const origCreate = navigator.credentials.create.bind(navigator.credentials);" +
"navigator.credentials.create = function(opts) {" +
" " + body +
" return origCreate(opts);" +
"};";
}
}
Loading
Loading
0