[java-idp-plugin-webauthn] branch main updated: Add basic public key credential validation using Yubico client
Phil Smart
philip.smart at jisc.ac.uk
Thu Nov 16 17:47:28 UTC 2023
This is an automated email from the git hooks/post-receive script.
philsmart pushed a commit to branch main
in repository java-idp-plugin-webauthn.
View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-webauthn.git;a=commit;h=85fd85542052626643aab715ecab22334f4473ad
The following commit(s) were added to refs/heads/main by this push:
new 85fd855 Add basic public key credential validation using Yubico client
85fd855 is described below
commit 85fd85542052626643aab715ecab22334f4473ad
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Thu Nov 16 17:47:21 2023 +0000
Add basic public key credential validation using Yubico client
---
.../AbstractWebAuthnAuthenticationAction.java | 35 ++
.../plugin/authn/webauthn/PublicKeyCredential.java | 9 +-
.../webauthn/WebAuthnAuthenticationClient.java | 18 +-
.../context/WebAuthnAuthenticationContext.java | 27 +-
webauthn-impl/pom.xml | 11 +-
.../impl/YubicoWebauthnAuthenticationClient.java | 27 +-
.../client/impl/YubicoWebauthnClientFactory.java | 5 +-
.../CreatePublicKeyCredentialCreationOptions.java | 2 +-
.../CreatePublicKeyCredentialRequestOptions.java | 2 +-
.../PopulateWebauthnAuthenticationContext.java | 26 --
.../webauthn/impl/ValidatePublicKeyCredential.java | 43 ++-
.../storage/impl/CredentialRegistration.java | 74 +++++
.../storage/impl/DummyCredentialRepository.java | 66 ----
.../storage/impl/InMemoryRegistrationStorage.java | 207 ++++++++++++
.../YubicoWebauthnAuthenticationClientTest.java | 145 +++++++++
.../authn/webauthn/impl/AbstractWebAuthnTest.java | 133 ++++++++
.../authn/webauthn/impl/MockAuthenticator.java | 357 +++++++++++++++++++++
.../impl/ValidatePublicKeyCredentialTest.java | 115 +++++++
18 files changed, 1170 insertions(+), 132 deletions(-)
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/AbstractWebAuthnAuthenticationAction.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/AbstractWebAuthnAuthenticationAction.java
index 3d8a1c3..a9448cc 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/AbstractWebAuthnAuthenticationAction.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/AbstractWebAuthnAuthenticationAction.java
@@ -32,6 +32,7 @@ import net.shibboleth.idp.authn.context.AuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
@@ -59,6 +60,30 @@ public abstract class AbstractWebAuthnAuthenticationAction extends AbstractAuthe
/** The Duo authentication Context.*/
@NonnullBeforeExec private WebAuthnAuthenticationContext webauthnContext;
+
+ /** The WebAuthn client to use.*/
+ @NonnullBeforeExec private WebAuthnAuthenticationClient webAuthnClient;
+
+
+ /**
+ * Set the WebAuthn client used to handle registration and authentication ceremonies.
+ *
+ * @param webAuthnClient The webauthnClient to set.
+ */
+ public void setWebAuthnClient(@Nonnull final WebAuthnAuthenticationClient client) {
+ checkSetterPreconditions();
+ webAuthnClient = Constraint.isNotNull(client, "WebAuthn client can not be null");
+ }
+
+ /**
+ * Get the WebAuthn client used to handle registration and authentication ceremonies.
+ *
+ * @return the webAuthnClient.
+ */
+ @NonnullBeforeExec public WebAuthnAuthenticationClient getWebAuthnClient() {
+ checkComponentActive();
+ return webAuthnClient;
+ }
/** Constructor.*/
@@ -82,6 +107,16 @@ public abstract class AbstractWebAuthnAuthenticationAction extends AbstractAuthe
Constraint.isNotNull(strategy, "WebauthnContextLookuplookup strategy cannot be null");
}
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (webAuthnClient == null) {
+ throw new ComponentInitializationException("WebAuthn Client can not be null");
+ }
+ }
+
/** {@inheritDoc} */
@Override
protected final boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/PublicKeyCredential.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/PublicKeyCredential.java
index 0bd7cbb..4f42dba 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/PublicKeyCredential.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/PublicKeyCredential.java
@@ -12,6 +12,7 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
@ThreadSafe
@JsonDeserialize(builder=PublicKeyCredential.Builder.class)
@JsonIgnoreProperties(ignoreUnknown = true)
+ at Deprecated
public final class PublicKeyCredential {
@Nonnull private final String type;
@@ -23,7 +24,7 @@ public final class PublicKeyCredential {
//no clientExtensionResults?
- private PublicKeyCredential(Builder builder) {
+ private PublicKeyCredential(final Builder builder) {
this.type = builder.type;
this.id = builder.id;
this.response = builder.response;
@@ -53,17 +54,17 @@ public final class PublicKeyCredential {
private Builder() {
}
- public Builder withType(String type) {
+ public Builder withType(final String type) {
this.type = type;
return this;
}
- public Builder withId(String id) {
+ public Builder withId(final String id) {
this.id = id;
return this;
}
- public Builder withResponse(Response response) {
+ public Builder withResponse(final Response response) {
this.response = response;
return this;
}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/WebAuthnAuthenticationClient.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/WebAuthnAuthenticationClient.java
index 345243e..f4e5ff1 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/WebAuthnAuthenticationClient.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/WebAuthnAuthenticationClient.java
@@ -3,6 +3,10 @@ package net.shibboleth.idp.plugin.authn.webauthn;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
+import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
+import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+
/**
* A client that manages the entire webauthn authentication and registration ceremony.
*
@@ -48,6 +52,18 @@ public interface WebAuthnAuthenticationClient {
*
* @return true if the assertion was verified successfully, false otherwise.
*/
- boolean validateAuthenticatorAssertionResponse(@Nullable final String jsonAssertionResponse);
+ boolean validateAuthenticatorAssertionResponse(@Nullable final String jsonAssertionResponse);
+
+
+ /**
+ * Validate a registration request.
+ *
+ * @param request
+ * @param response
+ * @return
+ */
+ //TODO use other validation method?
+ boolean validateRegistration(String request,
+ PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> response);
}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
index c976eb8..9eb0209 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/WebAuthnAuthenticationContext.java
@@ -11,7 +11,6 @@ import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
import com.yubico.webauthn.data.PublicKeyCredential;
-import net.shibboleth.idp.plugin.authn.webauthn.WebAuthnAuthenticationClient;
import net.shibboleth.shared.codec.Base64Support;
import net.shibboleth.shared.codec.EncodingException;
import net.shibboleth.shared.logic.Constraint;
@@ -42,16 +41,14 @@ public final class WebAuthnAuthenticationContext extends BaseContext {
/** In a new context? TODO. The existing credential public key encoded in COSE_Key format if found.*/
@Nullable private byte[] existingPublicKey;
+ //TODO would need to be bytes if Yubico agnostic (if we wanted that)
/** An assertion response that is the result of an authentication.*/
@Nullable
private PublicKeyCredential<AuthenticatorAssertionResponse,ClientRegistrationExtensionOutputs> assertionResponse;
/** The public key credential creation options for registration.*/
@Nullable private String publicKeyCredentialCreationOptions;
-
- /** The WebAuthn client to use.*/
- //TODO is the context the correct place for this type of client
- @Nullable private WebAuthnAuthenticationClient webAuthnClient;
+
/**
* Set the server challenge which the client authenticator needs to sign.
@@ -195,6 +192,7 @@ public final class WebAuthnAuthenticationContext extends BaseContext {
return publicKeyCredential;
}
+ // From creation of a credential
public void setPublicKeyCredential(
@Nonnull final PublicKeyCredential<AuthenticatorAttestationResponse,ClientRegistrationExtensionOutputs> credential) {
publicKeyCredential = Constraint.isNotNull(credential, "Public Key Credential can not be null or empty");
@@ -205,24 +203,7 @@ public final class WebAuthnAuthenticationContext extends BaseContext {
@Nullable public String getServerChallengeBase64() throws EncodingException {
return Base64Support.encode(serverChallenge, false);
}
-
- /**
- * Set the WebAuthn client used to handle registration and authentication ceremonies.
- *
- * @param webAuthnClient The webauthnClient to set.
- */
- public void setWebAuthnClient(@Nullable final WebAuthnAuthenticationClient client) {
- webAuthnClient = client;
- }
-
- /**
- * Get the WebAuthn client used to handle registration and authentication ceremonies.
- *
- * @return the webAuthnClient.
- */
- @Nullable public WebAuthnAuthenticationClient getWebAuthnClient() {
- return webAuthnClient;
- }
+
/**
* Set the options used to create public key credentials.
diff --git a/webauthn-impl/pom.xml b/webauthn-impl/pom.xml
index e9375cd..05f633b 100644
--- a/webauthn-impl/pom.xml
+++ b/webauthn-impl/pom.xml
@@ -31,7 +31,8 @@
<artifactId>webauthn-server-core</artifactId>
<scope>compile</scope>
</dependency>
- <dependency> <!-- note when we move to more yubico libs this will need to be runtime -->
+ <dependency> <!-- note when we move to more yubico libs this will need to
+ be runtime -->
<groupId>com.upokecenter</groupId>
<artifactId>cbor</artifactId>
<scope>compile</scope>
@@ -41,7 +42,7 @@
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-cbor</artifactId>
<scope>runtime</scope>
- </dependency>
+ </dependency>
<!-- Service API and Plugin Description dependencies -->
<dependency>
<groupId>${idp.groupId}</groupId>
@@ -84,6 +85,12 @@
<artifactId>jackson-datatype-jsr310</artifactId>
<scope>provided</scope>
</dependency>
+ <!-- TODO we should not need this for the cache long term if we switch out the storage-->
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <scope>provided</scope>
+ </dependency>
<!-- Test dependencies -->
<dependency>
<groupId>${idp.groupId}</groupId>
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClient.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClient.java
index 3c41103..beaa84c 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClient.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClient.java
@@ -28,13 +28,18 @@ import org.slf4j.Logger;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yubico.webauthn.AssertionRequest;
+import com.yubico.webauthn.FinishRegistrationOptions;
import com.yubico.webauthn.RelyingParty;
import com.yubico.webauthn.StartAssertionOptions;
+import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
import com.yubico.webauthn.data.PublicKeyCredentialParameters;
import com.yubico.webauthn.data.UserIdentity;
import com.yubico.webauthn.data.UserVerificationRequirement;
+import com.yubico.webauthn.exception.RegistrationFailedException;
import net.shibboleth.idp.plugin.authn.webauthn.WebAuthnAuthenticationClient;
import net.shibboleth.shared.logic.Constraint;
@@ -147,7 +152,7 @@ public class YubicoWebauthnAuthenticationClient implements WebAuthnAuthenticatio
@Override
public boolean validateAuthenticatorAssertionResponse(@Nullable final String jsonAssertionResponse) {
-
+
if (jsonAssertionResponse == null || jsonAssertionResponse.isEmpty()) {
log.warn("JSON Assertion Response is either null or empty, authentication can not be validated");
return false;
@@ -155,6 +160,26 @@ public class YubicoWebauthnAuthenticationClient implements WebAuthnAuthenticatio
return true;
}
+ /** {@inheritDoc} */
+ @Override
+ public boolean validateRegistration(final String request,
+ final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> response) {
+
+ try {
+ final PublicKeyCredentialCreationOptions requestOptions =
+ PublicKeyCredentialCreationOptions.fromJson(request);
+ rp.finishRegistration(FinishRegistrationOptions.builder()
+ .request(requestOptions)
+ .response(response)
+ .build());
+ } catch (final RegistrationFailedException | JsonProcessingException e) {
+ log.error("Public key credential can not be registered", e);
+ // Should throw this?
+ return false;
+ }
+ return true;
+ }
+
}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java
index b50a7b3..3481d49 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnClientFactory.java
@@ -26,7 +26,7 @@ import com.yubico.webauthn.RelyingParty;
import com.yubico.webauthn.data.RelyingPartyIdentity;
import net.shibboleth.idp.plugin.authn.webauthn.WebAuthnAuthenticationClient;
-import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.DummyCredentialRepository;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.InMemoryRegistrationStorage;
import net.shibboleth.shared.component.AbstractInitializableComponent;
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
@@ -83,7 +83,8 @@ public class YubicoWebauthnClientFactory extends AbstractInitializableComponent
.builder()
.id(getRelyingPartyId())
.name(getRelyingPartyName())
- .build()).credentialRepository(new DummyCredentialRepository())
+ //Use inmemory for now
+ .build()).credentialRepository(new InMemoryRegistrationStorage())
.allowOriginPort(isAllowOriginPort())
.allowOriginSubdomain(isAllowOriginSubdomain())
.build();
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialCreationOptions.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialCreationOptions.java
index 03bffdc..8bec478 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialCreationOptions.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialCreationOptions.java
@@ -44,7 +44,7 @@ public class CreatePublicKeyCredentialCreationOptions extends AbstractWebAuthnAu
@Nonnull final AuthenticationContext authenticationContext,
@Nonnull final WebAuthnAuthenticationContext context) {
- final WebAuthnAuthenticationClient client = context.getWebAuthnClient();
+ final WebAuthnAuthenticationClient client = getWebAuthnClient();
if (client == null) {
log.error("{} WebAuthn client is null, has the context been created correctly?",getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.AUTHN_EXCEPTION);
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialRequestOptions.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialRequestOptions.java
index 749f756..1068f35 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialRequestOptions.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CreatePublicKeyCredentialRequestOptions.java
@@ -41,7 +41,7 @@ public class CreatePublicKeyCredentialRequestOptions extends AbstractWebAuthnAut
@Nonnull final AuthenticationContext authenticationContext,
@Nonnull final WebAuthnAuthenticationContext context) {
- final WebAuthnAuthenticationClient client = context.getWebAuthnClient();
+ final WebAuthnAuthenticationClient client = getWebAuthnClient();
if (client == null) {
log.error("{} WebAuthn client is null, has the context been created correctly?",getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.AUTHN_EXCEPTION);
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/PopulateWebauthnAuthenticationContext.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/PopulateWebauthnAuthenticationContext.java
index b7eed7b..9bccc42 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/PopulateWebauthnAuthenticationContext.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/PopulateWebauthnAuthenticationContext.java
@@ -28,11 +28,8 @@ import org.slf4j.Logger;
import net.shibboleth.idp.authn.AbstractAuthenticationAction;
import net.shibboleth.idp.authn.context.AuthenticationContext;
-import net.shibboleth.idp.plugin.authn.webauthn.WebAuthnAuthenticationClient;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.idp.session.context.navigate.CanonicalUsernameLookupStrategy;
-import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
-import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.logic.PredicateSupport;
import net.shibboleth.shared.primitive.LoggerFactory;
@@ -62,9 +59,6 @@ public class PopulateWebauthnAuthenticationContext extends AbstractAuthenticatio
/** Is the username required?*/
private Predicate<ProfileRequestContext> usernameRequiredPredicate;
- /** The Webauthn client.*/
- @NonnullAfterInit private WebAuthnAuthenticationClient webauthnClient;
-
/** Constructor.*/
public PopulateWebauthnAuthenticationContext() {
@@ -76,26 +70,7 @@ public class PopulateWebauthnAuthenticationContext extends AbstractAuthenticatio
usernameLookupStrategy = new CanonicalUsernameLookupStrategy();
usernameRequiredPredicate = PredicateSupport.alwaysTrue();
}
-
- @Override protected void doInitialize() throws ComponentInitializationException {
- super.doInitialize();
-
- if (webauthnClient == null) {
- throw new ComponentInitializationException("Webauthn client cannot be null");
- }
- }
-
- /**
- * Set the WebAuthn client to use.
- *
- * @param client the client.
- */
- public void setWebauthnClient(@Nonnull final WebAuthnAuthenticationClient client) {
- checkSetterPreconditions();
- webauthnClient = Constraint.isNotNull(client, "WebAuthn Client can not be null");
- }
-
/**
* @param flag The usernameRequired to set.
*/
@@ -144,7 +119,6 @@ public class PopulateWebauthnAuthenticationContext extends AbstractAuthenticatio
return;
}
context.setUsername(usernameLookupStrategy.apply(profileRequestContext));
- context.setWebAuthnClient(webauthnClient);
log.debug("Created Webauthn authentication context");
}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredential.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredential.java
index 7c86f77..0b7258e 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredential.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredential.java
@@ -32,7 +32,10 @@ import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.authn.context.AuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.AbstractWebAuthnAuthenticationAction;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
/**
* Validate the public key registration attempt.
@@ -42,22 +45,52 @@ public class ValidatePublicKeyCredential extends AbstractWebAuthnAuthenticationA
/** Class logger. */
@Nonnull
private final Logger log = LoggerFactory.getLogger(ValidatePublicKeyCredential.class);
+
+ /** The stashed public key credential creation options used to create a new credential.*/
+ @NonnullBeforeExec @NotEmpty private String pkCredCreationOptions;
+
+ /** The stashed authenticator response.*/
+ @NonnullBeforeExec
+ private PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkCred;
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final AuthenticationContext authenticationContext,
+ @Nonnull final WebAuthnAuthenticationContext context) {
+
+ pkCred = context.getPublicKeyCredential();
+ if (pkCred == null) {
+ log.error("{} public key credential was null", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ return false;
+ }
+
+ pkCredCreationOptions = context.getPublicKeyCredentialCreationOptions();
+ if (StringSupport.trimOrNull(pkCredCreationOptions) == null) {
+ log.error("{} public key credential creation options was null", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ return false;
+ }
+
+ return super.doPreExecute(profileRequestContext, authenticationContext, context);
+ }
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
@Nonnull final AuthenticationContext authenticationContext,
@Nonnull final WebAuthnAuthenticationContext context) {
- final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkCred =
- context.getPublicKeyCredential();
+ // TODO this should throw the error?
+ final boolean publicKeyCredentialIsValid =
+ getWebAuthnClient().validateRegistration(context.getPublicKeyCredentialCreationOptions(), pkCred);
- if (pkCred == null) {
- log.error("{} public key credential was null", getLogPrefix());
+ if (!publicKeyCredentialIsValid) {
+ log.error("{} public key credential creation options was invalid", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
return;
}
- // Validate the registration
// If valid. Add back to context
log.info("Public Key Registration was valid");
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistration.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistration.java
new file mode 100644
index 0000000..09c0baf
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistration.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements. See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.webauthn.storage.impl;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.SortedSet;
+
+import com.yubico.webauthn.RegisteredCredential;
+import com.yubico.webauthn.data.AuthenticatorTransport;
+import com.yubico.webauthn.data.UserIdentity;
+
+/**
+ * Influenced by the CredentialRegistration class in the Yubico demo libraries.
+ */
+public class CredentialRegistration {
+
+ UserIdentity userIdentity;
+ Optional<String> credentialNickname;
+ SortedSet<AuthenticatorTransport> transports;
+
+ Instant registrationTime;
+ RegisteredCredential credential;
+
+ Optional<Object> attestationMetadata;
+
+ public String getRegistrationTimestamp() {
+ return registrationTime.toString();
+ }
+
+ public String getUsername() {
+ return userIdentity.getName();
+ }
+
+ public UserIdentity getUserIdentity() {
+ return userIdentity;
+ }
+
+ public RegisteredCredential getCredential() {
+ return credential;
+ }
+
+ public SortedSet<AuthenticatorTransport> getTransports() {
+ return transports;
+ }
+
+ public CredentialRegistration withCredential(final RegisteredCredential newRegCred) {
+ final CredentialRegistration newReg = new CredentialRegistration();
+ newReg.attestationMetadata = attestationMetadata;
+ newReg.userIdentity = userIdentity;
+ newReg.registrationTime = registrationTime;
+ newReg.transports = transports;
+ newReg.credentialNickname = credentialNickname;
+ // With the new credential
+ newReg.credential = newRegCred;
+ return newReg;
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/DummyCredentialRepository.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/DummyCredentialRepository.java
deleted file mode 100644
index 9b60af7..0000000
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/DummyCredentialRepository.java
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package net.shibboleth.idp.plugin.authn.webauthn.storage.impl;
-
-import java.util.Optional;
-import java.util.Set;
-
-import com.yubico.webauthn.CredentialRepository;
-import com.yubico.webauthn.RegisteredCredential;
-import com.yubico.webauthn.data.ByteArray;
-import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
-
-/** A dummy CredentialRepository for testing.*/
-public class DummyCredentialRepository implements CredentialRepository{
-
- @Override
- public Set<RegisteredCredential> lookupAll(final ByteArray credentialId) {
- return Set.of(RegisteredCredential.builder()
- .credentialId(new ByteArray("credid".getBytes()))
- .userHandle(new ByteArray("userhandle".getBytes()))
- .publicKeyCose(new ByteArray("publickeycose".getBytes()))
- .signatureCount(0)
- .build());
- }
-
- @Override
- public Optional<RegisteredCredential> lookup(final ByteArray credentialId, final ByteArray userHandle) {
- return Optional.of(RegisteredCredential.builder()
- .credentialId(new ByteArray("credid".getBytes()))
- .userHandle(new ByteArray("userhandle".getBytes()))
- .publicKeyCose(new ByteArray("publickeycose".getBytes()))
- .signatureCount(0)
- .build());
- }
-
- @Override
- public Optional<String> getUsernameForUserHandle(final ByteArray userHandle) {
- return Optional.of("jdoe");
- }
-
- @Override
- public Optional<ByteArray> getUserHandleForUsername(final String username) {
- return Optional.of(new ByteArray("credid".getBytes()));
- }
-
- @Override
- public Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(final String username) {
- return Set.of(
- PublicKeyCredentialDescriptor.builder()
- .id(new ByteArray("credid".getBytes()))
- .build());
- }
-
-}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
new file mode 100644
index 0000000..aa58bec
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/InMemoryRegistrationStorage.java
@@ -0,0 +1,207 @@
+// Copyright (c) 2018, Yubico AB
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+package net.shibboleth.idp.plugin.authn.webauthn.storage.impl;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.yubico.webauthn.AssertionResult;
+import com.yubico.webauthn.CredentialRepository;
+import com.yubico.webauthn.RegisteredCredential;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
+
+
+public class InMemoryRegistrationStorage implements CredentialRepository {
+
+ private final Cache<String, Set<CredentialRegistration>> storage =
+ CacheBuilder.newBuilder().maximumSize(1000).expireAfterAccess(1, TimeUnit.DAYS).build();
+
+ private static final Logger logger = LoggerFactory.getLogger(InMemoryRegistrationStorage.class);
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // The following methods are required by the CredentialRepository interface.
+ ////////////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(final String username) {
+ return getRegistrationsByUsername(username).stream()
+ .map(
+ registration ->
+ PublicKeyCredentialDescriptor.builder()
+ .id(registration.getCredential().getCredentialId())
+ .transports(registration.getTransports())
+ .build())
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public Optional<String> getUsernameForUserHandle(final ByteArray userHandle) {
+ return getRegistrationsByUserHandle(userHandle).stream()
+ .findAny()
+ .map(CredentialRegistration::getUsername);
+ }
+
+ @Override
+ public Optional<ByteArray> getUserHandleForUsername(final String username) {
+ return getRegistrationsByUsername(username).stream()
+ .findAny()
+ .map(reg -> reg.getUserIdentity().getId());
+ }
+
+ @Override
+ public Optional<RegisteredCredential> lookup(final ByteArray credentialId, final ByteArray userHandle) {
+ final Optional<CredentialRegistration> registrationMaybe =
+ storage.asMap().values().stream()
+ .flatMap(Collection::stream)
+ .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId()))
+ .findAny();
+
+ logger.debug(
+ "lookup credential ID: {}, user handle: {}; result: {}",
+ credentialId,
+ userHandle,
+ registrationMaybe);
+ return registrationMaybe.map(
+ registration ->
+ RegisteredCredential.builder()
+ .credentialId(registration.getCredential().getCredentialId())
+ .userHandle(registration.getUserIdentity().getId())
+ .publicKeyCose(registration.getCredential().getPublicKeyCose())
+ .signatureCount(registration.getCredential().getSignatureCount())
+ .build());
+ }
+
+ @Override
+ public Set<RegisteredCredential> lookupAll(final ByteArray credentialId) {
+ return Collections.unmodifiableSet(
+ storage.asMap().values().stream()
+ .flatMap(Collection::stream)
+ .filter(reg -> reg.getCredential().getCredentialId().equals(credentialId))
+ .map(
+ reg ->
+ RegisteredCredential.builder()
+ .credentialId(reg.getCredential().getCredentialId())
+ .userHandle(reg.getUserIdentity().getId())
+ .publicKeyCose(reg.getCredential().getPublicKeyCose())
+ .signatureCount(reg.getCredential().getSignatureCount())
+ .build())
+ .collect(Collectors.toSet()));
+ }
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // The following methods are specific to this demo application.
+ ////////////////////////////////////////////////////////////////////////////////
+
+ public boolean addRegistrationByUsername(final String username, final CredentialRegistration reg) {
+ try {
+ return storage.get(username, HashSet::new).add(reg);
+ } catch (final ExecutionException e) {
+ logger.error("Failed to add registration", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public Collection<CredentialRegistration> getRegistrationsByUsername(final String username) {
+ try {
+ return storage.get(username, HashSet::new);
+ } catch (final ExecutionException e) {
+ logger.error("Registration lookup failed", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public Collection<CredentialRegistration> getRegistrationsByUserHandle(final ByteArray userHandle) {
+ return storage.asMap().values().stream()
+ .flatMap(Collection::stream)
+ .filter(
+ credentialRegistration ->
+ userHandle.equals(credentialRegistration.getUserIdentity().getId()))
+ .collect(Collectors.toList());
+ }
+
+ public void updateSignatureCount(final AssertionResult result) {
+ final CredentialRegistration registration =
+ getRegistrationByUsernameAndCredentialId(
+ result.getUsername(), result.getCredential().getCredentialId())
+ .orElseThrow(
+ () ->
+ new NoSuchElementException(
+ String.format(
+ "Credential \"%s\" is not registered to user \"%s\"",
+ result.getCredential().getCredentialId(), result.getUsername())));
+
+ final Set<CredentialRegistration> regs = storage.getIfPresent(result.getUsername());
+ regs.remove(registration);
+ regs.add(
+ registration.withCredential(
+ registration.getCredential().toBuilder()
+ .signatureCount(result.getSignatureCount())
+ .build()));
+ }
+
+ public Optional<CredentialRegistration> getRegistrationByUsernameAndCredentialId(
+ final String username, final ByteArray id) {
+ try {
+ return storage.get(username, HashSet::new).stream()
+ .filter(credReg -> id.equals(credReg.getCredential().getCredentialId()))
+ .findFirst();
+ } catch (final ExecutionException e) {
+ logger.error("Registration lookup failed", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public boolean removeRegistrationByUsername(
+ final String username, final CredentialRegistration credentialRegistration) {
+ try {
+ return storage.get(username, HashSet::new).remove(credentialRegistration);
+ } catch (final ExecutionException e) {
+ logger.error("Failed to remove registration", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public boolean removeAllRegistrations(final String username) {
+ storage.invalidate(username);
+ return true;
+ }
+
+ public boolean userExists(final String username) {
+ return !getRegistrationsByUsername(username).isEmpty();
+ }
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java
new file mode 100644
index 0000000..c05bbda
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.webauthn.client.impl;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+import java.util.Map;
+import java.util.Optional;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yubico.webauthn.RelyingParty;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
+import com.yubico.webauthn.data.RelyingPartyIdentity;
+import com.yubico.webauthn.data.UserIdentity;
+
+import net.shibboleth.idp.plugin.authn.webauthn.impl.AbstractWebAuthnTest;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.MockAuthenticator;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.MockAuthenticator.Attestation;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.InMemoryRegistrationStorage;
+import net.shibboleth.shared.codec.Base64Support;
+
+/**
+ *
+ */
+public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest{
+
+ private YubicoWebauthnAuthenticationClient client;
+
+ private final static String CHALLENGE_B64 = "dGhpc2lzYWNoYWxsZW5nZQ==";
+
+ private final static String USER_HANDLE_B64 = "dGhpc2lzYWNoYWxsZW5nZQ==";
+
+ private String credentialCreationOptions;
+
+ private final static String ORIGIN = "https://idp.example.com";
+
+ private final static String RPID = "idp.example.com";
+
+
+ @Override
+ @BeforeMethod
+ public void setup() throws Exception {
+ super.setup();
+
+ final RelyingParty rp = RelyingParty.builder().identity(
+ RelyingPartyIdentity
+ .builder()
+ .id(RPID)
+ .name("Demo IdP as a WebAuthn RP")
+ .build()).credentialRepository(new InMemoryRegistrationStorage())
+ .allowOriginPort(true)
+ .allowOriginSubdomain(true)
+ .build();
+ client = new YubicoWebauthnAuthenticationClient(rp,jsonMapper);
+
+ final UserIdentity identity =
+ UserIdentity.builder().name("test-user").displayName("test user")
+ .id(new ByteArray(Base64Support.decode(USER_HANDLE_B64))).build();
+
+ final PublicKeyCredentialCreationOptions options =
+ PublicKeyCredentialCreationOptions.builder()
+ .rp(rp.getIdentity())
+ .user(identity)
+ .challenge(new ByteArray(Base64Support.decode(CHALLENGE_B64)))
+ .pubKeyCredParams(preferredPublickeyParams)
+ .excludeCredentials(Optional.empty())
+ .timeout(Optional.empty()).build();
+ credentialCreationOptions =
+ jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(options);
+
+ }
+
+
+ @Test
+ public void testSuccesss() throws Exception {
+
+ mockAuthenticator = new MockAuthenticator(ORIGIN, RPID);
+
+ final Map<String, String> clientDataJson =
+ mockAuthenticator.createClientDataJson("webauthn.create", CHALLENGE_B64);
+ final String clientDataJsonString = new ObjectMapper().writeValueAsString(clientDataJson);
+
+ final Attestation attestationObject = mockAuthenticator.createCredentialAttestationObject();
+
+ final boolean registrationIsValid = client.validateRegistration(credentialCreationOptions,
+ createPublicKeyCredential(attestationObject, clientDataJsonString));
+
+ assertTrue(registrationIsValid);
+
+ }
+
+ @Test
+ public void testFail_BadRpId() throws Exception {
+
+ mockAuthenticator = new MockAuthenticator(ORIGIN, "wrong-rpid.example.com");
+
+ final Map<String, String> clientDataJson =
+ mockAuthenticator.createClientDataJson("webauthn.create", CHALLENGE_B64);
+ final String clientDataJsonString = new ObjectMapper().writeValueAsString(clientDataJson);
+
+ final Attestation attestationObject = mockAuthenticator.createCredentialAttestationObject();
+
+ final boolean registrationIsValid = client.validateRegistration(credentialCreationOptions,
+ createPublicKeyCredential(attestationObject, clientDataJsonString));
+
+ assertFalse(registrationIsValid);
+ }
+
+ @Test
+ public void testFail_BadOrigin() throws Exception {
+
+ mockAuthenticator = new MockAuthenticator("wrong-origin", RPID);
+
+ final Map<String, String> clientDataJson =
+ mockAuthenticator.createClientDataJson("webauthn.create", CHALLENGE_B64);
+ final String clientDataJsonString = new ObjectMapper().writeValueAsString(clientDataJson);
+
+ final Attestation attestationObject = mockAuthenticator.createCredentialAttestationObject();
+
+ final boolean registrationIsValid = client.validateRegistration(credentialCreationOptions,
+ createPublicKeyCredential(attestationObject, clientDataJsonString));
+
+ assertFalse(registrationIsValid);
+ }
+
+ // Needs to do crypto checks
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
new file mode 100644
index 0000000..280feeb
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.webauthn.impl;
+
+import static org.testng.Assert.assertNotNull;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.webflow.execution.RequestContext;
+
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.core.Base64Variants;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+import com.yubico.webauthn.data.PublicKeyCredentialParameters;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.MockAuthenticator.Attestation;
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+
+/** Abstract class for tests that require context setup.*/
+public abstract class AbstractWebAuthnTest {
+
+ /** The profile request context to use.*/
+ protected ProfileRequestContext prc;
+
+ /** The request context to use.*/
+ protected RequestContext src;
+
+ /** The webauthn authentication context.*/
+ protected WebAuthnAuthenticationContext webAuthnContext;
+
+ /** The authentication context.*/
+ protected AuthenticationContext ac;
+
+ /** A mock authenticator to use for creating Authenticator Attestations etc.*/
+ protected MockAuthenticator mockAuthenticator;
+
+ /** The CBOR friendly json mapper.*/
+ protected ObjectMapper jsonMapper;
+
+ /** List of acceptable public key algorithms.*/
+ protected final List<PublicKeyCredentialParameters> preferredPublickeyParams =
+ Collections.unmodifiableList(
+ Arrays.asList(
+ PublicKeyCredentialParameters.ES256,
+ PublicKeyCredentialParameters.EdDSA,
+ PublicKeyCredentialParameters.ES384,
+ PublicKeyCredentialParameters.ES512,
+ PublicKeyCredentialParameters.RS256,
+ PublicKeyCredentialParameters.RS384,
+ PublicKeyCredentialParameters.RS512));
+
+
+ /**
+ * Setup the various contexts.
+ *
+ * @throws Exception on error
+ */
+ public void setup() throws Exception {
+ jsonMapper = JsonMapper.builder()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
+ .serializationInclusion(Include.NON_ABSENT)
+ .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL)
+ .addModule(new Jdk8Module())
+ .addModule(new JavaTimeModule())
+ .build();
+
+
+ src = new RequestContextBuilder().buildRequestContext();
+ prc = new WebflowRequestContextProfileRequestContextLookup().apply(src);
+ webAuthnContext = new WebAuthnAuthenticationContext();
+ assert null != prc;
+ ac = new AuthenticationContext();
+ ac.setAuthenticatingAuthority("https://idp.example.com");
+ assert null != ac;
+ prc.addSubcontext(ac);
+ assert null != webAuthnContext;
+ ac.addSubcontext(webAuthnContext);
+ }
+
+
+ @Nonnull protected PublicKeyCredential<AuthenticatorAttestationResponse,ClientRegistrationExtensionOutputs>
+ createPublicKeyCredential(final Attestation attestationObject, final String clientDataJsonString)
+ throws Exception{
+
+ final var response = AuthenticatorAttestationResponse.builder()
+ .attestationObject(new ByteArray(attestationObject.getAttestationObjectCose()))
+ .clientDataJSON(new ByteArray(clientDataJsonString.getBytes()))
+ .build();
+
+ final ClientRegistrationExtensionOutputs extOutputs =ClientRegistrationExtensionOutputs.builder().build();
+ final PublicKeyCredential<AuthenticatorAttestationResponse,ClientRegistrationExtensionOutputs> credential =
+ PublicKeyCredential.<AuthenticatorAttestationResponse,ClientRegistrationExtensionOutputs>builder()
+ .id(new ByteArray(attestationObject.getCredentialIdBytes()))
+ .response(response)
+ .clientExtensionResults(extOutputs)
+ .build();
+ assertNotNull(credential);
+ assert credential != null;
+ return credential;
+ }
+
+
+
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/MockAuthenticator.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/MockAuthenticator.java
new file mode 100644
index 0000000..86e38ab
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/MockAuthenticator.java
@@ -0,0 +1,357 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.webauthn.impl;
+
+import static org.testng.Assert.assertEquals;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.Base64Variants;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
+import com.yubico.webauthn.data.AuthenticatorData;
+import com.yubico.webauthn.data.ByteArray;
+
+import COSE.AlgorithmID;
+import COSE.OneKey;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Mock Authenticator, to create public key credentials and attestations for a given (fixed) relying party.
+ *
+ * <p> Does not support the attestation statement 'attStmt' (which provides data provenance information for the public
+ * key of the attesting authority and authenticator).</p>
+ */
+public class MockAuthenticator {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(MockAuthenticator.class);
+
+ /** The attestation statement format, default is 'none' as that is the only currently supported. */
+ @Nonnull @NotEmpty private final String fmt;
+
+ /**
+ * The relying party identifier. A valid domain string. The origin's effective domain
+ * (the host's domain name, no scheme and no port).
+ */
+ @Nonnull @NotEmpty private final String rpId;
+
+ /** The full RP Origin.*/
+ @Nonnull @NotEmpty private final String origin;
+
+ /** CBOR Mapper.*/
+ @Nonnull private final ObjectMapper cborMapper;
+
+
+ /**
+ *
+ * Constructor.
+ *
+ * <p>Creates a new public/private key pair per instantiation.</p>
+ *
+ * @param the relying party's origin.
+ * @param relyingPartyId the relying party identifier, a valid domain string. The origin's effective domain
+ * (the host's domain name, no scheme and no port).
+ * @throws Exception on error creating this authenticator.
+ */
+ public MockAuthenticator(@Nonnull @NotEmpty final String rpOrigin,
+ @Nonnull @NotEmpty final String relyingPartyId) throws Exception {
+ rpId = Constraint.isNotEmpty(relyingPartyId, "relyingPartyId can not be null");
+ origin = Constraint.isNotEmpty(rpOrigin, "rpOrigin can not be null");;
+ fmt = "none";
+ cborMapper = new ObjectMapper(new CBORFactory()).setBase64Variant(Base64Variants.MODIFIED_FOR_URL);
+
+ }
+
+ /**
+ * Create a WebAuthn public key credential attestation object. A new attestation is created each time.
+ *
+ * @throws Exception on error.
+ */
+ public Attestation createCredentialAttestationObject() throws Exception {
+
+ final OneKey createdKey = OneKey.generateKey(AlgorithmID.ECDSA_256);
+ assert createdKey != null;
+
+ final String aaguidHex = generateRandomIdentifierHex(32);
+ final String credentialIdHex = generateRandomIdentifierHex(32);
+
+ final byte[] attestationObject = createAttestationObject(createdKey, aaguidHex, credentialIdHex);
+ return new Attestation(createdKey, aaguidHex, credentialIdHex, attestationObject);
+
+ }
+
+
+ /**
+ * Create a client data JSON object as a Java Map.
+ *
+ * @param challenge the challenge
+ * @param type the type
+ * @return the client JSON map.
+ */
+ public Map<String, String> createClientDataJson(@Nonnull @NotEmpty final String type,
+ @Nonnull @NotEmpty final String challenge){
+ final HashMap<String, String> obj = new HashMap<>();
+ obj.put("challenge",challenge);
+ obj.put("origin", origin);
+ obj.put("type", type);
+ return obj;
+ }
+
+
+ /**
+ * Create the CBOR encoded version of the JSON attestation object.
+ * See https://www.w3.org/TR/webauthn-2/#sctn-attestation.
+ *
+ *
+ * @param createdKey the key to use as the basis of this attestation object (and the new key to register).
+ * @param credentialId the credential identifier
+ * @param aaguidHex the Authenticator Attestation GUID
+ *
+ * @return the attestation object CBOR encoded.
+ *
+ * @throws Exception on error
+ */
+ private byte[] createAttestationObject(final OneKey createdKey, final String aaguidHex,
+ final String credentialId) throws Exception{
+ final HashMap<String, Object> attObj = new HashMap<>();
+ attObj.put("authData", createAuthData(createdKey, aaguidHex, credentialId).getBytes().getBytes());
+ attObj.put("fmt", fmt);
+ final HashMap<String, String> attStmt = new HashMap<>();
+// attStmt.put("sig", createSignature());
+// attStmt.put("x5c", createX5c());
+ attObj.put("attStmt", attStmt);
+ return cborMapper.writeValueAsBytes(attObj);
+ }
+
+
+ /**
+ * Create the authenticator data part of the attestation object.
+ * See https://www.w3.org/TR/webauthn-2/#sctn-attested-credential-data.
+ *
+ * @param createdKey the key to use as the basis of this attestation object (and the new key to register).
+ * @param credentialId
+ * @param aaguidHex
+ *
+ * @return the authData
+ * @throws NoSuchAlgorithmException on error.
+ */
+ private AuthenticatorData createAuthData(final OneKey createdKey, final String aaguidHex,
+ final String credentialId) throws Exception {
+ final byte[] rpIdHash = createRpIdHash();
+ final byte[] flags = createFlags(true, true, true, false);
+ final byte[] signCount = createSignCount(1);
+ final byte[] attestedCredentialData = createAttestedCredentialData(createdKey, aaguidHex, credentialId);
+
+ final byte[] authDataCombined = new byte[rpIdHash.length + flags.length + signCount.length
+ + attestedCredentialData.length];
+
+ System.arraycopy(rpIdHash, 0, authDataCombined, 0, rpIdHash.length);
+ System.arraycopy(flags, 0, authDataCombined, rpIdHash.length, flags.length);
+ System.arraycopy(signCount, 0, authDataCombined, rpIdHash.length + flags.length, signCount.length);
+ System.arraycopy(attestedCredentialData, 0, authDataCombined, rpIdHash.length + flags.length + signCount.length,
+ attestedCredentialData.length);
+
+
+ final ByteArray authData = new ByteArray(authDataCombined);
+ final AuthenticatorData data = new AuthenticatorData(authData);
+ log.debug("Created Authenticator Data for RP, '{}' RPHash '{}', SignCounter '{}': '{}'", rpId,
+ data.getRpIdHash().getHex(), data.getSignatureCounter(), data);
+ return data;
+ }
+
+ /**
+ * Create the authenticator flags set. See https://www.w3.org/TR/webauthn-2/#authenticator-data.
+ *
+ * @param up User present flag
+ * @param uv User verified flag
+ * @param atIncluded attested credential data included flag (should be true for create credentials)
+ * @param extDataIncluded extension data included flag
+ *
+ * @return a byte of the flags. Should only be one byte.
+ */
+ private byte[] createFlags(final boolean up, final boolean uv, final boolean atIncluded,
+ final boolean extDataIncluded) {
+ final BitSet bits = new BitSet();
+
+ if (up) bits.set(0);
+ if (uv) bits.set(2);
+ if (atIncluded) bits.set(6);
+ if (extDataIncluded) bits.set(7);
+
+ return bits.toByteArray();
+ }
+
+ private byte[] createAttestedCredentialData(final OneKey createdKey,
+ final String aaguidHex, final String credentialId) throws Exception {
+ final byte[] aaguid = createAaguid(aaguidHex);
+ final byte[] credentialIdAndLength = createCredentialId(credentialId);
+ // OneKey is from the COSE-JAVA lib that Yubico use, but is not perhaps well maintained
+ log.debug("Key is type {}", createdKey);
+ final byte[] coseKeyAsCborBytes = createdKey.AsCBOR().EncodeToBytes();
+
+ final byte[] attestedCredentialDataCombined = new byte[aaguid.length + credentialIdAndLength.length
+ + coseKeyAsCborBytes.length];
+
+ System.arraycopy(aaguid, 0, attestedCredentialDataCombined, 0, aaguid.length);
+ System.arraycopy(credentialIdAndLength, 0, attestedCredentialDataCombined, aaguid.length,
+ credentialIdAndLength.length);
+ System.arraycopy(coseKeyAsCborBytes, 0, attestedCredentialDataCombined,
+ aaguid.length + credentialIdAndLength.length, coseKeyAsCborBytes.length);
+
+
+ return attestedCredentialDataCombined;
+ }
+
+ private byte[] createAaguid(final String aaguidHex) throws DecoderException {
+ assertEquals(aaguidHex.length(), 32);
+ final byte[] aaguidBytes = Hex.decodeHex(aaguidHex.toCharArray());
+ assertEquals(aaguidBytes.length, 16);
+ return aaguidBytes;
+
+ }
+
+ private byte[] createSignCount(final int count){
+ return ByteBuffer.allocate(4).putInt(count).array();
+ }
+
+ private byte[] createCredentialId(final String credentialId) throws DecoderException {
+ final byte[] credentialIdBytes = Hex.decodeHex(credentialId.toCharArray());
+ final int credentialIdLength = credentialIdBytes.length;
+
+ final byte[] credentialIdLengthBytes = new byte[2];
+ credentialIdLengthBytes[0] = (byte) ((credentialIdLength >>> 8) & 0xFF);
+ credentialIdLengthBytes[1] = (byte) (credentialIdLength & 0xFF);
+ assertEquals(credentialIdLengthBytes.length, 2);
+
+ final byte[] credentialIdCombined = new byte[credentialIdBytes.length + credentialIdLengthBytes.length];
+
+ System.arraycopy(credentialIdLengthBytes, 0, credentialIdCombined, 0, credentialIdLengthBytes.length);
+ System.arraycopy(credentialIdBytes, 0, credentialIdCombined, credentialIdLengthBytes.length,
+ credentialIdBytes.length);
+ return credentialIdCombined;
+
+ }
+
+
+ /**
+ * Create a SHA-256 hash of the relying party ID.
+ *
+ * @return the SHA-25 hash of the relying party ID.
+ *
+ * @throws NoSuchAlgorithmException on error
+ */
+ private byte[] createRpIdHash() throws NoSuchAlgorithmException {
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ final byte[] encodedhash = digest.digest(rpId.getBytes(StandardCharsets.UTF_8));
+ return encodedhash;
+ }
+
+ /**
+ * Generates a random identifier in Hex format.
+ *
+ * @param length the length of the parameter.
+ *
+ * @return the randomly generated value.
+ */
+ @Nonnull static String generateRandomIdentifierHex(@Nonnull final Integer length) {
+ final SecureRandom secureRandom = new SecureRandom();
+ final StringBuilder sb = new StringBuilder();
+ while(sb.length() < length){
+ sb.append(Integer.toHexString(secureRandom.nextInt()));
+ }
+ return sb.toString().substring(0, length);
+ }
+
+ /** Simple attestion return type so the caller can access the underlying values easily.*/
+ public class Attestation {
+
+ private final OneKey key;
+ private final String credentialIdHex;
+ private final String aaguidHex;
+ private final byte[] attestationObjectCose;
+
+ /**
+ * Constructor.
+ *
+ * @param key the key
+ * @param credentialIdHex the credential Id
+ * @param aaguidHex the aaguid
+ * @param attestationObjectCose the attestation object
+ */
+ public Attestation(final OneKey key, final String credentialIdHex, final String aaguidHex,
+ final byte[] attestationObjectCose) {
+ super();
+ this.key = key;
+ this.credentialIdHex = credentialIdHex;
+ this.aaguidHex = aaguidHex;
+ this.attestationObjectCose = attestationObjectCose;
+ }
+
+ /**
+ * @return Returns the key.
+ */
+ public OneKey getKey() {
+ return key;
+ }
+
+ /**
+ * @return Returns the credentialIdHex.
+ */
+ public String getCredentialIdHex() {
+ return credentialIdHex;
+ }
+
+ public byte[] getCredentialIdBytes() throws DecoderException {
+ return Hex.decodeHex(credentialIdHex.toCharArray());
+ }
+
+ public byte[] getAaguidBytes() throws DecoderException {
+ return Hex.decodeHex(aaguidHex.toCharArray());
+ }
+
+ /**
+ * @return Returns the aaguidHex.
+ */
+ public String getAaguidHex() {
+ return aaguidHex;
+ }
+
+ /**
+ * @return Returns the attestationObjectCose.
+ */
+ public byte[] getAttestationObjectCose() {
+ return attestationObjectCose;
+ }
+
+
+ }
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredentialTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredentialTest.java
new file mode 100644
index 0000000..a435aab
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidatePublicKeyCredentialTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.idp.plugin.authn.webauthn.impl;
+
+import static org.testng.Assert.assertNull;
+
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.webflow.execution.Event;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yubico.webauthn.RelyingParty;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
+import com.yubico.webauthn.data.RelyingPartyIdentity;
+import com.yubico.webauthn.data.UserIdentity;
+
+import net.shibboleth.idp.plugin.authn.webauthn.WebAuthnAuthenticationClient;
+import net.shibboleth.idp.plugin.authn.webauthn.client.impl.YubicoWebauthnAuthenticationClient;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.MockAuthenticator.Attestation;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.InMemoryRegistrationStorage;
+import net.shibboleth.shared.codec.Base64Support;
+
+
+/**
+ * Tests for {@link ValidatePublicKeyCredential}.
+ */
+public class ValidatePublicKeyCredentialTest extends AbstractWebAuthnTest{
+
+ private ValidatePublicKeyCredential validator;
+
+ private final static String CHALLENGE_B64 = "dGhpc2lzYWNoYWxsZW5nZQ==";
+
+ private final static String USER_HANDLE_B64 = "dGhpc2lzYWNoYWxsZW5nZQ==";
+
+ @Override
+ @BeforeMethod
+ public void setup() throws Exception {
+ super.setup();
+ validator = new ValidatePublicKeyCredential();
+ //Move this test to one of the client, this should use a mock and less specific data types
+ final RelyingParty rp = RelyingParty.builder().identity(
+ RelyingPartyIdentity
+ .builder()
+ .id("idp.example.com")
+ .name("Demo IdP as a WebAuthn RP")
+ .build()).credentialRepository(new InMemoryRegistrationStorage())
+ .allowOriginPort(true)
+ .allowOriginSubdomain(true)
+ .build();
+
+ final UserIdentity identity =
+ UserIdentity.builder().name("test-user").displayName("test user")
+ .id(new ByteArray(Base64Support.decode(USER_HANDLE_B64))).build();
+
+ final PublicKeyCredentialCreationOptions options =
+ PublicKeyCredentialCreationOptions.builder()
+ .rp(rp.getIdentity())
+ .user(identity)
+ .challenge(new ByteArray(Base64Support.decode(CHALLENGE_B64)))
+ .pubKeyCredParams(preferredPublickeyParams)
+ .excludeCredentials(Optional.empty())
+ .timeout(Optional.empty()).build();
+ final String credentialCreationOptions =
+ jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(options);
+ webAuthnContext.setPublicKeyCredentialCreationOptions(credentialCreationOptions);
+
+ final WebAuthnAuthenticationClient client = new YubicoWebauthnAuthenticationClient(rp, jsonMapper);
+ validator.setWebAuthnClient(client);
+ validator.initialize();
+ }
+
+ @Test
+ public void testSuccesss() throws Exception {
+
+ mockAuthenticator = new MockAuthenticator("https://idp.example.com", "idp.example.com");
+
+ final Map<String, String> clientDataJson =
+ mockAuthenticator.createClientDataJson("webauthn.create", CHALLENGE_B64);
+ final String clientDataJsonString = new ObjectMapper().writeValueAsString(clientDataJson);
+
+ final Attestation attestationObject = mockAuthenticator.createCredentialAttestationObject();
+ webAuthnContext.setPublicKeyCredential(createPublicKeyCredential(attestationObject, clientDataJsonString));
+
+ final Event event = validator.execute(src);
+ assertNull(event);
+ }
+
+
+
+ // Helper method to convert a byte array to a hex string
+ public static String bytesToHex(final byte[] bytes) {
+ final StringBuilder sb = new StringBuilder();
+ for (final byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list