From ec0841a47d7e6387a8536ee001bcf9f0e473340e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 7 Jul 2023 13:41:28 +0200 Subject: [PATCH 01/21] Introduce alternative CredentialRepositoryV2 interface and related types --- .../yubico/webauthn/AssertionResultV2.java | 237 ++++++ .../com/yubico/webauthn/CredentialRecord.java | 31 + .../yubico/webauthn/CredentialRepository.java | 2 +- .../CredentialRepositoryV1ToV2Adapter.java | 38 + .../webauthn/CredentialRepositoryV2.java | 77 ++ .../yubico/webauthn/FinishAssertionSteps.java | 298 +++++--- .../webauthn/FinishRegistrationSteps.java | 46 +- .../yubico/webauthn/RegisteredCredential.java | 2 +- .../com/yubico/webauthn/RelyingParty.java | 21 +- .../com/yubico/webauthn/RelyingPartyV2.java | 679 ++++++++++++++++++ .../yubico/webauthn/UsernameRepository.java | 44 ++ .../com/yubico/webauthn/RelyingPartyTest.java | 4 +- .../webauthn/RelyingPartyAssertionSpec.scala | 228 +++--- .../RelyingPartyUserIdentificationSpec.scala | 17 +- .../yubico/internal/util/OptionalUtil.java | 12 + 15 files changed, 1491 insertions(+), 245 deletions(-) create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java new file mode 100644 index 000000000..c347bbd06 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java @@ -0,0 +1,237 @@ +// 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 com.yubico.webauthn; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yubico.webauthn.data.AuthenticatorAssertionExtensionOutputs; +import com.yubico.webauthn.data.AuthenticatorAssertionResponse; +import com.yubico.webauthn.data.AuthenticatorAttachment; +import com.yubico.webauthn.data.AuthenticatorData; +import com.yubico.webauthn.data.AuthenticatorDataFlags; +import com.yubico.webauthn.data.AuthenticatorResponse; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.PublicKeyCredential; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.Value; + +/** The result of a call to {@link RelyingPartyV2#finishAssertion(FinishAssertionOptions)}. */ +@Value +public class AssertionResultV2 { + + /** true if the assertion was verified successfully. */ + private final boolean success; + + @JsonProperty + @Getter(AccessLevel.NONE) + private final PublicKeyCredential + credentialResponse; + + /** + * The {@link CredentialRecord} that was returned by {@link + * CredentialRepositoryV2#lookup(ByteArray, ByteArray)} and whose public key was used to + * successfully verify the assertion signature. + * + *

NOTE: The {@link CredentialRecord#getSignatureCount() signature count}, {@link + * CredentialRecord#isBackupEligible() backup eligibility} and {@link + * CredentialRecord#isBackedUp() backup state} properties in this object will reflect the state + * before the assertion operation, not the new state. When updating your database state, + * use the signature counter and backup state from {@link #getSignatureCount()}, {@link + * #isBackupEligible()} and {@link #isBackedUp()} instead. + */ + private final C credential; + + /** + * true if and only if at least one of the following is true: + * + *

    + *
  • The {@link AuthenticatorData#getSignatureCounter() signature counter value} in the + * assertion was strictly greater than {@link CredentialRecord#getSignatureCount() the + * stored one}. + *
  • The {@link AuthenticatorData#getSignatureCounter() signature counter value} in the + * assertion and {@link CredentialRecord#getSignatureCount() the stored one} were both zero. + *
+ * + * @see §6.1. + * Authenticator Data + * @see AuthenticatorData#getSignatureCounter() + * @see CredentialRecord#getSignatureCount() + * @see RelyingParty.RelyingPartyBuilder#validateSignatureCounter(boolean) + */ + private final boolean signatureCounterValid; + + @JsonCreator + AssertionResultV2( + @JsonProperty("success") boolean success, + @NonNull @JsonProperty("credentialResponse") + PublicKeyCredential + credentialResponse, + @NonNull @JsonProperty("credential") C credential, + @JsonProperty("signatureCounterValid") boolean signatureCounterValid) { + this.success = success; + this.credentialResponse = credentialResponse; + this.credential = credential; + this.signatureCounterValid = signatureCounterValid; + } + + /** + * Check whether the user + * verification as performed during the authentication ceremony. + * + *

This flag is also available via + * {@link PublicKeyCredential}.{@link PublicKeyCredential#getResponse() getResponse()}.{@link AuthenticatorResponse#getParsedAuthenticatorData() getParsedAuthenticatorData()}.{@link AuthenticatorData#getFlags() getFlags()}.{@link AuthenticatorDataFlags#UV UV} + * . + * + * @return true if and only if the authenticator claims to have performed user + * verification during the authentication ceremony. + * @see User Verification + * @see UV flag in §6.1. Authenticator + * Data + */ + @JsonIgnore + public boolean isUserVerified() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getFlags().UV; + } + + /** + * Check whether the asserted credential is backup eligible, using the BE flag in the authenticator data. + * + *

You SHOULD store this value in your representation of the corresponding {@link + * CredentialRecord} if no value is stored yet. {@link CredentialRepository} implementations + * SHOULD set this value when reconstructing that {@link CredentialRecord}. + * + * @return true if and only if the created credential is backup eligible. NOTE that + * this is only a hint and not a guarantee, unless backed by a trusted authenticator + * attestation. + * @see Backup Eligible in §4. + * Terminology + * @see BE flag in §6.1. Authenticator + * Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public boolean isBackupEligible() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getFlags().BE; + } + + /** + * Get the current backup state of the + * asserted credential, using the BS + * flag in the authenticator data. + * + *

You SHOULD update this value in your representation of a {@link CredentialRecord}. {@link + * CredentialRepository} implementations SHOULD set this value when reconstructing that {@link + * CredentialRecord}. + * + * @return true if and only if the created credential is believed to currently be + * backed up. NOTE that this is only a hint and not a guarantee, unless backed by a trusted + * authenticator attestation. + * @see Backup State in §4. Terminology + * @see BS flag in §6.1. Authenticator + * Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public boolean isBackedUp() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getFlags().BS; + } + + /** + * The authenticator + * attachment modality in effect at the time the asserted credential was used. + * + * @see PublicKeyCredential#getAuthenticatorAttachment() + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public Optional getAuthenticatorAttachment() { + return credentialResponse.getAuthenticatorAttachment(); + } + + /** + * The new signature + * count of the credential used for the assertion. + * + *

You should update this value in your database. + * + * @see AuthenticatorData#getSignatureCounter() + */ + @JsonIgnore + public long getSignatureCount() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getSignatureCounter(); + } + + /** + * The client + * extension outputs, if any. + * + *

This is present if and only if at least one extension output is present in the return value. + * + * @see §9.4. + * Client Extension Processing + * @see ClientAssertionExtensionOutputs + * @see #getAuthenticatorExtensionOutputs() () + */ + @JsonIgnore + public Optional getClientExtensionOutputs() { + return Optional.of(credentialResponse.getClientExtensionResults()) + .filter(ceo -> !ceo.getExtensionIds().isEmpty()); + } + + /** + * The authenticator + * extension outputs, if any. + * + *

This is present if and only if at least one extension output is present in the return value. + * + * @see §9.5. + * Authenticator Extension Processing + * @see AuthenticatorAssertionExtensionOutputs + * @see #getClientExtensionOutputs() + */ + @JsonIgnore + public Optional getAuthenticatorExtensionOutputs() { + return AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( + credentialResponse.getResponse().getParsedAuthenticatorData()); + } +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java new file mode 100644 index 000000000..36d5e5d8b --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java @@ -0,0 +1,31 @@ +package com.yubico.webauthn; + +import com.yubico.webauthn.data.ByteArray; +import java.util.Optional; +import lombok.NonNull; + +/** + * @see Credential Record + */ +public interface CredentialRecord { + + @NonNull + ByteArray getCredentialId(); + + @NonNull + ByteArray getUserHandle(); + + @NonNull + ByteArray getPublicKeyCose(); + + long getSignatureCount(); + + // @NonNull + // Set getTransports(); + + // boolean isUvInitialized(); + + Optional isBackupEligible(); + + Optional isBackedUp(); +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java index 990ba08c2..2eba3ba59 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java @@ -30,7 +30,7 @@ import java.util.Set; /** - * An abstraction of the database lookups needed by this library. + * An abstraction of the primary database lookups needed by this library. * *

This is used by {@link RelyingParty} to look up credentials, usernames and user handles from * usernames, user handles and credential IDs. diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java new file mode 100644 index 000000000..ea0b981a5 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java @@ -0,0 +1,38 @@ +package com.yubico.webauthn; + +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class CredentialRepositoryV1ToV2Adapter + implements CredentialRepositoryV2, UsernameRepository { + + private final CredentialRepository inner; + + @Override + public Set getCredentialIdsForUserHandle(ByteArray userHandle) { + return inner + .getUsernameForUserHandle(userHandle) + .map(inner::getCredentialIdsForUsername) + .orElseGet(Collections::emptySet); + } + + @Override + public Optional lookup(ByteArray credentialId, ByteArray userHandle) { + return inner.lookup(credentialId, userHandle); + } + + @Override + public boolean credentialIdExists(ByteArray credentialId) { + return !inner.lookupAll(credentialId).isEmpty(); + } + + @Override + public Optional getUserHandleForUsername(String username) { + return inner.getUserHandleForUsername(username); + } +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java new file mode 100644 index 000000000..9b1630bb3 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java @@ -0,0 +1,77 @@ +// 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 com.yubico.webauthn; + +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import java.util.Optional; +import java.util.Set; + +/** + * An abstraction of database lookups needed by this library. + * + *

This is used by {@link RelyingParty} to look up credentials and credential IDs. + * + *

Unlike {@link CredentialRepository}, this interface does not require support for usernames. + */ +public interface CredentialRepositoryV2 { + + /** + * Get the credential IDs of all credentials registered to the user with the given user handle. + * + *

After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method + * returns a value suitable for inclusion in this set. + * + * @return a {@link Set} containing one {@link PublicKeyCredentialDescriptor} for each credential + * registered to the given user. The set MUST NOT be null, but MAY be empty if the user does + * not exist or has no credentials. + */ + Set getCredentialIdsForUserHandle(ByteArray userHandle); + + /** + * Look up the public key, backup flags and current signature count for the given credential + * registered to the given user. + * + *

The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read + * directly from a database or assembled from other components. + * + * @return a {@link RegisteredCredential} describing the current state of the registered + * credential with credential ID credentialId, if any. If the credential does not + * exist or is registered to a different user handle than userHandle, return + * {@link Optional#empty()}. + */ + Optional lookup(ByteArray credentialId, ByteArray userHandle); + + /** + * Check whether any credential exists with the given credential ID, regardless of what user it is + * registered to. + * + *

This is used to refuse registration of duplicate credential IDs. + * + * @return true if and only if the credential database contains at least one + * credential with the given credential ID. + */ + boolean credentialIdExists(ByteArray credentialId); +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java index 7c6821007..505f198d5 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java @@ -43,11 +43,13 @@ import java.security.spec.InvalidKeySpecException; import java.util.Optional; import java.util.Set; +import lombok.AllArgsConstructor; import lombok.Value; import lombok.extern.slf4j.Slf4j; @Slf4j -final class FinishAssertionSteps { +@AllArgsConstructor +final class FinishAssertionSteps { private static final String CLIENT_DATA_TYPE = "webauthn.get"; private static final String SPC_CLIENT_DATA_TYPE = "payment.get"; @@ -58,23 +60,52 @@ final class FinishAssertionSteps { private final Optional callerTokenBindingId; private final Set origins; private final String rpId; - private final CredentialRepository credentialRepository; + private final Optional credentialRepository; + private final CredentialRepositoryV2 credentialRepositoryV2; + private final Optional usernameRepository; private final boolean allowOriginPort; private final boolean allowOriginSubdomain; private final boolean validateSignatureCounter; private final boolean isSecurePaymentConfirmation; - FinishAssertionSteps(RelyingParty rp, FinishAssertionOptions options) { - this.request = options.getRequest(); - this.response = options.getResponse(); - this.callerTokenBindingId = options.getCallerTokenBindingId(); - this.origins = rp.getOrigins(); - this.rpId = rp.getIdentity().getId(); - this.credentialRepository = rp.getCredentialRepository(); - this.allowOriginPort = rp.isAllowOriginPort(); - this.allowOriginSubdomain = rp.isAllowOriginSubdomain(); - this.validateSignatureCounter = rp.isValidateSignatureCounter(); - this.isSecurePaymentConfirmation = options.isSecurePaymentConfirmation(); + static FinishAssertionSteps fromV1( + RelyingParty rp, FinishAssertionOptions options) { + final CredentialRepository credRepo = rp.getCredentialRepository(); + final CredentialRepositoryV1ToV2Adapter credRepoV2 = + new CredentialRepositoryV1ToV2Adapter(credRepo); + return new FinishAssertionSteps<>( + options.getRequest(), + options.getResponse(), + options.getCallerTokenBindingId(), + rp.getOrigins(), + rp.getIdentity().getId(), + Optional.of(credRepo), + credRepoV2, + Optional.of(credRepoV2), + rp.isAllowOriginPort(), + rp.isAllowOriginSubdomain(), + rp.isValidateSignatureCounter(), + options.isSecurePaymentConfirmation()); + } + + FinishAssertionSteps(RelyingPartyV2 rp, FinishAssertionOptions options) { + this( + options.getRequest(), + options.getResponse(), + options.getCallerTokenBindingId(), + rp.getOrigins(), + rp.getIdentity().getId(), + Optional.empty(), + rp.getCredentialRepository(), + Optional.ofNullable(rp.getUsernameRepository()), + rp.isAllowOriginPort(), + rp.isAllowOriginSubdomain(), + rp.isValidateSignatureCounter(), + options.isSecurePaymentConfirmation()); + } + + private Optional getUsernameForUserHandle(final ByteArray userHandle) { + return credentialRepository.flatMap(credRepo -> credRepo.getUsernameForUserHandle(userHandle)); } public Step5 begin() { @@ -85,7 +116,11 @@ public AssertionResult run() throws InvalidSignatureCountException { return begin().run(); } - interface Step> { + public AssertionResultV2 runV2() throws InvalidSignatureCountException { + return begin().runV2(); + } + + interface Step> { Next nextStep(); void validate() throws InvalidSignatureCountException; @@ -94,6 +129,10 @@ default Optional result() { return Optional.empty(); } + default Optional> resultV2() { + return Optional.empty(); + } + default Next next() throws InvalidSignatureCountException { validate(); return nextStep(); @@ -106,12 +145,20 @@ default AssertionResult run() throws InvalidSignatureCountException { return next().run(); } } + + default AssertionResultV2 runV2() throws InvalidSignatureCountException { + if (resultV2().isPresent()) { + return resultV2().get(); + } else { + return next().runV2(); + } + } } // Steps 1 through 4 are to create the request and run the client-side part @Value - class Step5 implements Step { + class Step5 implements Step { @Override public Step6 nextStep() { return new Step6(); @@ -134,86 +181,101 @@ public void validate() { } @Value - class Step6 implements Step { + class Step6 implements Step { + + private final Optional requestedUserHandle; + private final Optional requestedUsername; + private final Optional responseUserHandle; + + private final Optional effectiveRequestUserHandle; + private final Optional effectiveRequestUsername; + private final boolean userHandleDerivedFromUsername; - private final Optional userHandle = - OptionalUtil.orElseOptional( - request.getUserHandle(), - () -> - OptionalUtil.orElseOptional( - response.getResponse().getUserHandle(), - () -> - request - .getUsername() - .flatMap(credentialRepository::getUserHandleForUsername))); + private final Optional finalUserHandle; + private final Optional finalUsername; + private final Optional registration; - private final Optional username = - OptionalUtil.orElseOptional( - request.getUsername(), - () -> userHandle.flatMap(credentialRepository::getUsernameForUserHandle)); + public Step6() { + requestedUserHandle = request.getUserHandle(); + requestedUsername = request.getUsername(); + responseUserHandle = response.getResponse().getUserHandle(); - private final Optional registration = - userHandle.flatMap(uh -> credentialRepository.lookup(response.getId(), uh)); + effectiveRequestUserHandle = + OptionalUtil.orElseOptional( + requestedUserHandle, + () -> + usernameRepository.flatMap( + unr -> requestedUsername.flatMap(unr::getUserHandleForUsername))); + + effectiveRequestUsername = + OptionalUtil.orElseOptional( + requestedUsername, + () -> + requestedUserHandle.flatMap(FinishAssertionSteps.this::getUsernameForUserHandle)); + + userHandleDerivedFromUsername = + !requestedUserHandle.isPresent() && effectiveRequestUserHandle.isPresent(); + + finalUserHandle = OptionalUtil.orOptional(effectiveRequestUserHandle, responseUserHandle); + finalUsername = + OptionalUtil.orElseOptional( + effectiveRequestUsername, + () -> finalUserHandle.flatMap(FinishAssertionSteps.this::getUsernameForUserHandle)); + + registration = + finalUserHandle.flatMap(uh -> credentialRepositoryV2.lookup(response.getId(), uh)); + } @Override public Step7 nextStep() { - return new Step7(username.get(), userHandle.get(), registration); + return new Step7(finalUsername, finalUserHandle.get(), registration); } @Override public void validate() { assertTrue( - request.getUsername().isPresent() - || request.getUserHandle().isPresent() - || response.getResponse().getUserHandle().isPresent(), - "At least one of username and user handle must be given; none was."); - if (request.getUserHandle().isPresent() - && response.getResponse().getUserHandle().isPresent()) { + finalUserHandle.isPresent(), + "Could not identify user to authenticate: none of requested username, requested user handle or response user handle are set."); + + if (requestedUserHandle.isPresent() && responseUserHandle.isPresent()) { assertTrue( - request.getUserHandle().get().equals(response.getResponse().getUserHandle().get()), + requestedUserHandle.get().equals(responseUserHandle.get()), "User handle set in request (%s) does not match user handle in response (%s).", - request.getUserHandle().get(), - response.getResponse().getUserHandle().get()); + requestedUserHandle.get(), + responseUserHandle.get()); } - assertTrue( - userHandle.isPresent(), - "User handle not found for username: %s", - request.getUsername(), - response.getResponse().getUserHandle()); - - assertTrue( - username.isPresent(), - "Username not found for userHandle: %s", - request.getUsername(), - response.getResponse().getUserHandle()); + if (userHandleDerivedFromUsername && responseUserHandle.isPresent()) { + assertTrue( + effectiveRequestUserHandle.get().equals(responseUserHandle.get()), + "User handle in request (%s) (derived from username: %s) does not match user handle in response (%s).", + effectiveRequestUserHandle.get(), + requestedUsername.get(), + responseUserHandle.get()); + } assertTrue(registration.isPresent(), "Unknown credential: %s", response.getId()); assertTrue( - userHandle.get().equals(registration.get().getUserHandle()), + finalUserHandle.get().equals(registration.get().getUserHandle()), "User handle %s does not own credential %s", - userHandle.get(), + finalUserHandle.get(), response.getId()); - final Optional usernameFromRequest = request.getUsername(); - final Optional userHandleFromResponse = response.getResponse().getUserHandle(); - if (usernameFromRequest.isPresent() && userHandleFromResponse.isPresent()) { + if (credentialRepository.isPresent()) { assertTrue( - userHandleFromResponse.equals( - credentialRepository.getUserHandleForUsername(usernameFromRequest.get())), - "User handle %s in response does not match username %s in request", - userHandleFromResponse, - usernameFromRequest); + finalUsername.isPresent(), + "Unknown username for user handle: %s", + finalUserHandle.get()); } } } @Value - class Step7 implements Step { - private final String username; + class Step7 implements Step { + private final Optional username; private final ByteArray userHandle; - private final Optional credential; + private final Optional credential; @Override public Step8 nextStep() { @@ -231,10 +293,10 @@ public void validate() { } @Value - class Step8 implements Step { + class Step8 implements Step { - private final String username; - private final RegisteredCredential credential; + private final Optional username; + private final C credential; @Override public void validate() { @@ -264,9 +326,9 @@ public ByteArray signature() { // Nothing to do for step 9 @Value - class Step10 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step10 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -284,9 +346,9 @@ public CollectedClientData clientData() { } @Value - class Step11 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step11 implements Step { + private final Optional username; + private final C credential; private final CollectedClientData clientData; @Override @@ -307,9 +369,9 @@ public Step12 nextStep() { } @Value - class Step12 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step12 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -328,9 +390,9 @@ public Step13 nextStep() { } @Value - class Step13 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step13 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -347,9 +409,9 @@ public Step14 nextStep() { } @Value - class Step14 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step14 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -364,9 +426,9 @@ public Step15 nextStep() { } @Value - class Step15 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step15 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -396,9 +458,9 @@ public Step16 nextStep() { } @Value - class Step16 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step16 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -414,9 +476,9 @@ public Step17 nextStep() { } @Value - class Step17 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step17 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -439,9 +501,9 @@ public PendingStep16 nextStep() { @Value // Step 16 in editor's draft as of 2022-11-09 https://w3c.github.io/webauthn/ // TODO: Finalize this when spec matures - class PendingStep16 implements Step { - private final String username; - private final RegisteredCredential credential; + class PendingStep16 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -462,9 +524,9 @@ public Step18 nextStep() { } @Value - class Step18 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step18 implements Step { + private final Optional username; + private final C credential; @Override public void validate() {} @@ -476,9 +538,9 @@ public Step19 nextStep() { } @Value - class Step19 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step19 implements Step { + private final Optional username; + private final C credential; @Override public void validate() { @@ -496,9 +558,9 @@ public ByteArray clientDataJsonHash() { } @Value - class Step20 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step20 implements Step { + private final Optional username; + private final C credential; private final ByteArray clientDataJsonHash; @Override @@ -541,13 +603,13 @@ public ByteArray signedBytes() { } @Value - class Step21 implements Step { - private final String username; - private final RegisteredCredential credential; + class Step21 implements Step { + private final Optional username; + private final C credential; private final long assertionSignatureCount; private final long storedSignatureCountBefore; - public Step21(String username, RegisteredCredential credential) { + public Step21(Optional username, C credential) { this.username = username; this.credential = credential; this.assertionSignatureCount = @@ -575,9 +637,9 @@ public Finished nextStep() { } @Value - class Finished implements Step { - private final RegisteredCredential credential; - private final String username; + class Finished implements Step { + private final C credential; + private final Optional username; private final long assertionSignatureCount; private final boolean signatureCounterValid; @@ -594,7 +656,17 @@ public Finished nextStep() { @Override public Optional result() { return Optional.of( - new AssertionResult(true, response, credential, username, signatureCounterValid)); + new AssertionResult( + true, + response, + (RegisteredCredential) credential, + username.get(), + signatureCounterValid)); + } + + public Optional> resultV2() { + return Optional.of( + new AssertionResultV2(true, response, credential, signatureCounterValid)); } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java index ad6094e8a..a7ba81d89 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java @@ -64,10 +64,12 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import lombok.AllArgsConstructor; import lombok.Value; import lombok.extern.slf4j.Slf4j; @Slf4j +@AllArgsConstructor final class FinishRegistrationSteps { private static final String CLIENT_DATA_TYPE = "webauthn.create"; @@ -83,23 +85,39 @@ final class FinishRegistrationSteps { private final String rpId; private final boolean allowUntrustedAttestation; private final Optional attestationTrustSource; - private final CredentialRepository credentialRepository; + private final CredentialRepositoryV2 credentialRepositoryV2; private final Clock clock; private final boolean allowOriginPort; private final boolean allowOriginSubdomain; - FinishRegistrationSteps(RelyingParty rp, FinishRegistrationOptions options) { - this.request = options.getRequest(); - this.response = options.getResponse(); - this.callerTokenBindingId = options.getCallerTokenBindingId(); - this.origins = rp.getOrigins(); - this.rpId = rp.getIdentity().getId(); - this.allowUntrustedAttestation = rp.isAllowUntrustedAttestation(); - this.attestationTrustSource = rp.getAttestationTrustSource(); - this.credentialRepository = rp.getCredentialRepository(); - this.clock = rp.getClock(); - this.allowOriginPort = rp.isAllowOriginPort(); - this.allowOriginSubdomain = rp.isAllowOriginSubdomain(); + static FinishRegistrationSteps fromV1(RelyingParty rp, FinishRegistrationOptions options) { + return new FinishRegistrationSteps( + options.getRequest(), + options.getResponse(), + options.getCallerTokenBindingId(), + rp.getOrigins(), + rp.getIdentity().getId(), + rp.isAllowUntrustedAttestation(), + rp.getAttestationTrustSource(), + new CredentialRepositoryV1ToV2Adapter(rp.getCredentialRepository()), + rp.getClock(), + rp.isAllowOriginPort(), + rp.isAllowOriginSubdomain()); + } + + FinishRegistrationSteps(RelyingPartyV2 rp, FinishRegistrationOptions options) { + this( + options.getRequest(), + options.getResponse(), + options.getCallerTokenBindingId(), + rp.getOrigins(), + rp.getIdentity().getId(), + rp.isAllowUntrustedAttestation(), + rp.getAttestationTrustSource(), + rp.getCredentialRepository(), + rp.getClock(), + rp.isAllowOriginPort(), + rp.isAllowOriginSubdomain()); } public Step6 begin() { @@ -627,7 +645,7 @@ class Step22 implements Step { @Override public void validate() { assertTrue( - credentialRepository.lookupAll(response.getId()).isEmpty(), + !credentialRepositoryV2.credentialIdExists(response.getId()), "Credential ID is already registered: %s", response.getId()); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java index 38abf25f6..17434ef57 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java @@ -56,7 +56,7 @@ */ @Value @Builder(toBuilder = true) -public final class RegisteredCredential { +public final class RegisteredCredential implements CredentialRecord { /** * The credential diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index b3b64691b..f2d23ff01 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java @@ -407,7 +407,7 @@ private static ByteArray generateChallenge() { * * @return a new {@link List} containing only the algorithms supported in the current JCA context. */ - private static List filterAvailableAlgorithms( + static List filterAvailableAlgorithms( List pubKeyCredParams) { return Collections.unmodifiableList( pubKeyCredParams.stream() @@ -507,7 +507,7 @@ public RegistrationResult finishRegistration(FinishRegistrationOptions finishReg * #finishRegistration(FinishRegistrationOptions)} instead of this method. */ FinishRegistrationSteps _finishRegistration(FinishRegistrationOptions options) { - return new FinishRegistrationSteps(this, options); + return FinishRegistrationSteps.fromV1(this, options); } public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptions) { @@ -564,8 +564,8 @@ public AssertionResult finishAssertion(FinishAssertionOptions finishAssertionOpt * a separate method to facilitate testing; users should call {@link * #finishAssertion(FinishAssertionOptions)} instead of this method. */ - FinishAssertionSteps _finishAssertion(FinishAssertionOptions options) { - return new FinishAssertionSteps(this, options); + FinishAssertionSteps _finishAssertion(FinishAssertionOptions options) { + return FinishAssertionSteps.fromV1(this, options); } public static RelyingPartyBuilder.MandatoryStages builder() { @@ -598,10 +598,23 @@ public class Step2 { * credentialRepository} is a required parameter. * * @see RelyingPartyBuilder#credentialRepository(CredentialRepository) + * @see #credentialRepository(CredentialRepositoryV2) */ public RelyingPartyBuilder credentialRepository(CredentialRepository credentialRepository) { return builder.credentialRepository(credentialRepository); } + + /** + * {@link RelyingPartyBuilder#credentialRepository(CredentialRepository) + * credentialRepository} is a required parameter. + * + * @see #credentialRepository(CredentialRepository) + */ + public + RelyingPartyV2.RelyingPartyV2Builder credentialRepository( + CredentialRepositoryV2 credentialRepository) { + return RelyingPartyV2.builder(builder.identity, credentialRepository); + } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java new file mode 100644 index 000000000..7d31eddc2 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java @@ -0,0 +1,679 @@ +// 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 com.yubico.webauthn; + +import com.yubico.internal.util.CollectionUtil; +import com.yubico.internal.util.OptionalUtil; +import com.yubico.webauthn.attestation.AttestationTrustSource; +import com.yubico.webauthn.data.AssertionExtensionInputs; +import com.yubico.webauthn.data.AttestationConveyancePreference; +import com.yubico.webauthn.data.AuthenticatorData; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.CollectedClientData; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions.PublicKeyCredentialCreationOptionsBuilder; +import com.yubico.webauthn.data.PublicKeyCredentialParameters; +import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; +import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions.PublicKeyCredentialRequestOptionsBuilder; +import com.yubico.webauthn.data.RegistrationExtensionInputs; +import com.yubico.webauthn.data.RelyingPartyIdentity; +import com.yubico.webauthn.exception.AssertionFailedException; +import com.yubico.webauthn.exception.InvalidSignatureCountException; +import com.yubico.webauthn.exception.RegistrationFailedException; +import com.yubico.webauthn.extension.appid.AppId; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.KeyFactory; +import java.security.SecureRandom; +import java.security.Signature; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +/** + * Encapsulates the four basic Web Authentication operations - start/finish registration, + * start/finish authentication - along with overall operational settings for them. + * + *

This class has no mutable state. An instance of this class may therefore be thought of as a + * container for specialized versions (function closures) of these four operations rather than a + * stateful object. + */ +@Slf4j +@Builder(toBuilder = true) +@Value +public class RelyingPartyV2 { + + private static final SecureRandom random = new SecureRandom(); + + /** + * The {@link RelyingPartyIdentity} that will be set as the {@link + * PublicKeyCredentialCreationOptions#getRp() rp} parameter when initiating registration + * operations, and which {@link AuthenticatorData#getRpIdHash()} will be compared against. This is + * a required parameter. + * + *

A successful registration or authentication operation requires {@link + * AuthenticatorData#getRpIdHash()} to exactly equal the SHA-256 hash of this member's {@link + * RelyingPartyIdentity#getId() id} member. Alternatively, it may instead equal the SHA-256 hash + * of {@link #getAppId() appId} if the latter is present. + * + * @see #startRegistration(StartRegistrationOptions) + * @see PublicKeyCredentialCreationOptions + */ + @NonNull private final RelyingPartyIdentity identity; + + /** + * The allowed origins that returned authenticator responses will be compared against. + * + *

The default is the set containing only the string + * "https://" + {@link #getIdentity()}.getId(). + * + *

If {@link RelyingPartyV2Builder#allowOriginPort(boolean) allowOriginPort} and {@link + * RelyingPartyV2Builder#allowOriginSubdomain(boolean) allowOriginSubdomain} are both false + * (the default), then a successful registration or authentication operation requires + * {@link CollectedClientData#getOrigin()} to exactly equal one of these values. + * + *

If {@link RelyingPartyV2Builder#allowOriginPort(boolean) allowOriginPort} is true + * , then the above rule is relaxed to allow any port number in {@link + * CollectedClientData#getOrigin()}, regardless of any port specified. + * + *

If {@link RelyingPartyV2Builder#allowOriginSubdomain(boolean) allowOriginSubdomain} is + * + * true, then the above rule is relaxed to allow any subdomain, of any depth, of any of + * these values. + * + *

For either of the above relaxations to take effect, both the allowed origin and the client + * data origin must be valid URLs. Origins that are not valid URLs are matched only by exact + * string equality. + * + * @see #getIdentity() + */ + @NonNull private final Set origins; + + /** + * An abstract database which can look up credentials, usernames and user handles from usernames, + * user handles and credential IDs. This is a required parameter. + * + *

This is used to look up: + * + *

+ */ + @NonNull private final CredentialRepositoryV2 credentialRepository; + + /** TODO */ + private final UsernameRepository usernameRepository; + + /** + * The extension input to set for the appid and appidExclude extensions. + * + *

You do not need this extension if you have not previously supported U2F. Its purpose is to + * make already-registered U2F credentials forward-compatible with the WebAuthn API. It is not + * needed for new registrations, even of U2F authenticators. + * + *

If this member is set, {@link #startAssertion(StartAssertionOptions) startAssertion} will + * automatically set the appid extension input, and {@link + * #finishAssertion(FinishAssertionOptions) finishAssertion} will adjust its verification logic to + * also accept this AppID as an alternative to the RP ID. Likewise, {@link + * #startRegistration(StartRegistrationOptions)} startRegistration} will automatically set the + * appidExclude extension input. + * + *

By default, this is not set. + * + * @see AssertionExtensionInputs#getAppid() + * @see RegistrationExtensionInputs#getAppidExclude() + * @see §10.1. + * FIDO AppID Extension (appid) + * @see §10.2. + * FIDO AppID Exclusion Extension (appidExclude) + */ + @NonNull private final Optional appId; + + /** + * The argument for the {@link PublicKeyCredentialCreationOptions#getAttestation() attestation} + * parameter in registration operations. + * + *

Unless your application has a concrete policy for authenticator attestation, it is + * recommended to leave this parameter undefined. + * + *

If you set this, you may want to explicitly set {@link + * RelyingPartyV2Builder#allowUntrustedAttestation(boolean) allowUntrustedAttestation} and {@link + * RelyingPartyV2Builder#attestationTrustSource(AttestationTrustSource) attestationTrustSource} + * too. + * + *

By default, this is not set. + * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + @NonNull private final Optional attestationConveyancePreference; + + /** + * An {@link AttestationTrustSource} instance to use for looking up trust roots for authenticator + * attestation. This matters only if {@link #getAttestationConveyancePreference()} is non-empty + * and not set to {@link AttestationConveyancePreference#NONE}. + * + *

By default, this is not set. + * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + @NonNull private final Optional attestationTrustSource; + + /** + * The argument for the {@link PublicKeyCredentialCreationOptions#getPubKeyCredParams() + * pubKeyCredParams} parameter in registration operations. + * + *

This is a list of acceptable public key algorithms and their parameters, ordered from most + * to least preferred. + * + *

The default is the following list, in order: + * + *

    + *
  1. {@link PublicKeyCredentialParameters#ES256 ES256} + *
  2. {@link PublicKeyCredentialParameters#EdDSA EdDSA} + *
  3. {@link PublicKeyCredentialParameters#ES256 ES384} + *
  4. {@link PublicKeyCredentialParameters#ES256 ES512} + *
  5. {@link PublicKeyCredentialParameters#RS256 RS256} + *
  6. {@link PublicKeyCredentialParameters#RS384 RS384} + *
  7. {@link PublicKeyCredentialParameters#RS512 RS512} + *
+ * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + @Builder.Default @NonNull + private final List preferredPubkeyParams = + Collections.unmodifiableList( + Arrays.asList( + PublicKeyCredentialParameters.ES256, + PublicKeyCredentialParameters.EdDSA, + PublicKeyCredentialParameters.ES384, + PublicKeyCredentialParameters.ES512, + PublicKeyCredentialParameters.RS256, + PublicKeyCredentialParameters.RS384, + PublicKeyCredentialParameters.RS512)); + + /** + * If true, the origin matching rule is relaxed to allow any port number. + * + *

The default is false. + * + *

Examples with + * origins: ["https://example.org", "https://accounts.example.org", "https://acme.com:8443"] + * + * + *

    + *
  • + *

    allowOriginPort: false + *

    Accepted: + *

      + *
    • https://example.org + *
    • https://accounts.example.org + *
    • https://acme.com:8443 + *
    + *

    Rejected: + *

      + *
    • https://example.org:8443 + *
    • https://shop.example.org + *
    • https://acme.com + *
    • https://acme.com:9000 + *
    + *
  • + *

    allowOriginPort: true + *

    Accepted: + *

      + *
    • https://example.org + *
    • https://example.org:8443 + *
    • https://accounts.example.org + *
    • https://acme.com + *
    • https://acme.com:8443 + *
    • https://acme.com:9000 + *
    + *

    Rejected: + *

      + *
    • https://shop.example.org + *
    + *
+ */ + @Builder.Default private final boolean allowOriginPort = false; + + /** + * If true, the origin matching rule is relaxed to allow any subdomain, of any depth, + * of the values of {@link RelyingPartyV2Builder#origins(Set) origins}. + * + *

The default is false. + * + *

Examples with origins: ["https://example.org", "https://acme.com:8443"] + * + *

    + *
  • + *

    allowOriginSubdomain: false + *

    Accepted: + *

      + *
    • https://example.org + *
    • https://acme.com:8443 + *
    + *

    Rejected: + *

      + *
    • https://example.org:8443 + *
    • https://accounts.example.org + *
    • https://acme.com + *
    • https://eu.shop.acme.com:8443 + *
    + *
  • + *

    allowOriginSubdomain: true + *

    Accepted: + *

      + *
    • https://example.org + *
    • https://accounts.example.org + *
    • https://acme.com:8443 + *
    • https://eu.shop.acme.com:8443 + *
    + *

    Rejected: + *

      + *
    • https://example.org:8443 + *
    • https://acme.com + *
    + *
+ */ + @Builder.Default private final boolean allowOriginSubdomain = false; + + /** + * If false, {@link #finishRegistration(FinishRegistrationOptions) + * finishRegistration} will only allow registrations where the attestation signature can be linked + * to a trusted attestation root. This excludes none attestation, and self attestation unless the + * self attestation key is explicitly trusted. + * + *

Regardless of the value of this option, invalid attestation statements of supported formats + * will always be rejected. For example, a "packed" attestation statement with an invalid + * signature will be rejected even if this option is set to true. + * + *

The default is true. + */ + @Builder.Default private final boolean allowUntrustedAttestation = true; + + /** + * If true, {@link #finishAssertion(FinishAssertionOptions) finishAssertion} will + * succeed only if the {@link AuthenticatorData#getSignatureCounter() signature counter value} in + * the response is strictly greater than the {@link RegisteredCredential#getSignatureCount() + * stored signature counter value}, or if both counters are exactly zero. + * + *

The default is true. + */ + @Builder.Default private final boolean validateSignatureCounter = true; + + /** + * A {@link Clock} which will be used to tell the current time while verifying attestation + * certificate chains. + * + *

This is intended primarily for testing, and relevant only if {@link + * RelyingPartyV2Builder#attestationTrustSource(AttestationTrustSource)} is set. + * + *

The default is Clock.systemUTC(). + */ + @Builder.Default @NonNull private final Clock clock = Clock.systemUTC(); + + @Builder + private RelyingPartyV2( + @NonNull RelyingPartyIdentity identity, + Set origins, + @NonNull CredentialRepositoryV2 credentialRepository, + UsernameRepository usernameRepository, + @NonNull Optional appId, + @NonNull Optional attestationConveyancePreference, + @NonNull Optional attestationTrustSource, + List preferredPubkeyParams, + boolean allowOriginPort, + boolean allowOriginSubdomain, + boolean allowUntrustedAttestation, + boolean validateSignatureCounter, + Clock clock) { + this.identity = identity; + this.origins = + origins != null + ? CollectionUtil.immutableSet(origins) + : Collections.singleton("https://" + identity.getId()); + + for (String origin : this.origins) { + try { + new URL(origin); + } catch (MalformedURLException e) { + log.warn( + "Allowed origin is not a valid URL, it will match only by exact string equality: {}", + origin); + } + } + + this.credentialRepository = credentialRepository; + this.usernameRepository = usernameRepository; + this.appId = appId; + this.attestationConveyancePreference = attestationConveyancePreference; + this.attestationTrustSource = attestationTrustSource; + this.preferredPubkeyParams = filterAvailableAlgorithms(preferredPubkeyParams); + this.allowOriginPort = allowOriginPort; + this.allowOriginSubdomain = allowOriginSubdomain; + this.allowUntrustedAttestation = allowUntrustedAttestation; + this.validateSignatureCounter = validateSignatureCounter; + this.clock = clock; + } + + private static ByteArray generateChallenge() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return new ByteArray(bytes); + } + + /** + * Filter pubKeyCredParams to only contain algorithms with a {@link KeyFactory} and a + * {@link Signature} available, and log a warning for every unsupported algorithm. + * + * @return a new {@link List} containing only the algorithms supported in the current JCA context. + */ + private static List filterAvailableAlgorithms( + List pubKeyCredParams) { + return RelyingParty.filterAvailableAlgorithms(pubKeyCredParams); + } + + public PublicKeyCredentialCreationOptions startRegistration( + StartRegistrationOptions startRegistrationOptions) { + PublicKeyCredentialCreationOptionsBuilder builder = + PublicKeyCredentialCreationOptions.builder() + .rp(identity) + .user(startRegistrationOptions.getUser()) + .challenge(generateChallenge()) + .pubKeyCredParams(preferredPubkeyParams) + .excludeCredentials( + credentialRepository.getCredentialIdsForUserHandle( + startRegistrationOptions.getUser().getId())) + .authenticatorSelection(startRegistrationOptions.getAuthenticatorSelection()) + .extensions( + startRegistrationOptions + .getExtensions() + .merge( + RegistrationExtensionInputs.builder() + .appidExclude(appId) + .credProps() + .build())) + .timeout(startRegistrationOptions.getTimeout()); + attestationConveyancePreference.ifPresent(builder::attestation); + return builder.build(); + } + + public RegistrationResult finishRegistration(FinishRegistrationOptions finishRegistrationOptions) + throws RegistrationFailedException { + try { + return _finishRegistration(finishRegistrationOptions).run(); + } catch (IllegalArgumentException e) { + throw new RegistrationFailedException(e); + } + } + + /** + * This method is NOT part of the public API. + * + *

This method is called internally by {@link #finishRegistration(FinishRegistrationOptions)}. + * It is a separate method to facilitate testing; users should call {@link + * #finishRegistration(FinishRegistrationOptions)} instead of this method. + */ + FinishRegistrationSteps _finishRegistration(FinishRegistrationOptions options) { + return new FinishRegistrationSteps(this, options); + } + + public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptions) { + PublicKeyCredentialRequestOptionsBuilder pkcro = + PublicKeyCredentialRequestOptions.builder() + .challenge(generateChallenge()) + .rpId(identity.getId()) + .allowCredentials( + OptionalUtil.orElseOptional( + startAssertionOptions.getUserHandle(), + () -> + Optional.ofNullable(usernameRepository) + .flatMap( + unr -> + startAssertionOptions + .getUsername() + .flatMap(unr::getUserHandleForUsername))) + .map(credentialRepository::getCredentialIdsForUserHandle) + .map(ArrayList::new)) + .extensions( + startAssertionOptions + .getExtensions() + .merge(startAssertionOptions.getExtensions().toBuilder().appid(appId).build())) + .timeout(startAssertionOptions.getTimeout()); + + startAssertionOptions.getUserVerification().ifPresent(pkcro::userVerification); + + return AssertionRequest.builder() + .publicKeyCredentialRequestOptions(pkcro.build()) + .username(startAssertionOptions.getUsername()) + .userHandle(startAssertionOptions.getUserHandle()) + .build(); + } + + /** + * @throws InvalidSignatureCountException if {@link + * RelyingPartyV2Builder#validateSignatureCounter(boolean) validateSignatureCounter} is + * true, the {@link AuthenticatorData#getSignatureCounter() signature count} in the + * response is less than or equal to the {@link RegisteredCredential#getSignatureCount() + * stored signature count}, and at least one of the signature count values is nonzero. + * @throws AssertionFailedException if validation fails for any other reason. + */ + public AssertionResultV2 finishAssertion(FinishAssertionOptions finishAssertionOptions) + throws AssertionFailedException { + try { + return _finishAssertion(finishAssertionOptions).runV2(); + } catch (IllegalArgumentException e) { + throw new AssertionFailedException(e); + } + } + + /** + * This method is NOT part of the public API. + * + *

This method is called internally by {@link #finishAssertion(FinishAssertionOptions)}. It is + * a separate method to facilitate testing; users should call {@link + * #finishAssertion(FinishAssertionOptions)} instead of this method. + */ + FinishAssertionSteps _finishAssertion(FinishAssertionOptions options) { + return new FinishAssertionSteps(this, options); + } + + static RelyingPartyV2Builder builder( + RelyingPartyIdentity identity, CredentialRepositoryV2 credentialRepository) { + return new RelyingPartyV2Builder() + .identity(identity) + .credentialRepository(credentialRepository); + } + + public static class RelyingPartyV2Builder { + private @NonNull Optional appId = Optional.empty(); + private @NonNull Optional attestationConveyancePreference = + Optional.empty(); + private @NonNull Optional attestationTrustSource = Optional.empty(); + + /** + * The extension input to set for the appid and appidExclude + * extensions. + * + *

You do not need this extension if you have not previously supported U2F. Its purpose is to + * make already-registered U2F credentials forward-compatible with the WebAuthn API. It is not + * needed for new registrations, even of U2F authenticators. + * + *

If this member is set, {@link #startAssertion(StartAssertionOptions) startAssertion} will + * automatically set the appid extension input, and {@link + * #finishAssertion(FinishAssertionOptions) finishAssertion} will adjust its verification logic + * to also accept this AppID as an alternative to the RP ID. Likewise, {@link + * #startRegistration(StartRegistrationOptions)} startRegistration} will automatically set the + * appidExclude extension input. + * + *

By default, this is not set. + * + * @see AssertionExtensionInputs#getAppid() + * @see RegistrationExtensionInputs#getAppidExclude() + * @see §10.1. + * FIDO AppID Extension (appid) + * @see §10.2. + * FIDO AppID Exclusion Extension (appidExclude) + */ + public RelyingPartyV2Builder appId(@NonNull Optional appId) { + this.appId = appId; + return this; + } + + /** + * The extension input to set for the appid and appidExclude + * extensions. + * + *

You do not need this extension if you have not previously supported U2F. Its purpose is to + * make already-registered U2F credentials forward-compatible with the WebAuthn API. It is not + * needed for new registrations, even of U2F authenticators. + * + *

If this member is set, {@link #startAssertion(StartAssertionOptions) startAssertion} will + * automatically set the appid extension input, and {@link + * #finishAssertion(FinishAssertionOptions) finishAssertion} will adjust its verification logic + * to also accept this AppID as an alternative to the RP ID. Likewise, {@link + * #startRegistration(StartRegistrationOptions)} startRegistration} will automatically set the + * appidExclude extension input. + * + *

By default, this is not set. + * + * @see AssertionExtensionInputs#getAppid() + * @see RegistrationExtensionInputs#getAppidExclude() + * @see §10.1. + * FIDO AppID Extension (appid) + * @see §10.2. + * FIDO AppID Exclusion Extension (appidExclude) + */ + public RelyingPartyV2Builder appId(@NonNull AppId appId) { + return this.appId(Optional.of(appId)); + } + + /** + * The argument for the {@link PublicKeyCredentialCreationOptions#getAttestation() attestation} + * parameter in registration operations. + * + *

Unless your application has a concrete policy for authenticator attestation, it is + * recommended to leave this parameter undefined. + * + *

If you set this, you may want to explicitly set {@link + * RelyingPartyV2Builder#allowUntrustedAttestation(boolean) allowUntrustedAttestation} and + * {@link RelyingPartyV2Builder#attestationTrustSource(AttestationTrustSource) + * attestationTrustSource} too. + * + *

By default, this is not set. + * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + public RelyingPartyV2Builder attestationConveyancePreference( + @NonNull Optional attestationConveyancePreference) { + this.attestationConveyancePreference = attestationConveyancePreference; + return this; + } + + /** + * The argument for the {@link PublicKeyCredentialCreationOptions#getAttestation() attestation} + * parameter in registration operations. + * + *

Unless your application has a concrete policy for authenticator attestation, it is + * recommended to leave this parameter undefined. + * + *

If you set this, you may want to explicitly set {@link + * RelyingPartyV2Builder#allowUntrustedAttestation(boolean) allowUntrustedAttestation} and + * {@link RelyingPartyV2Builder#attestationTrustSource(AttestationTrustSource) + * attestationTrustSource} too. + * + *

By default, this is not set. + * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + public RelyingPartyV2Builder attestationConveyancePreference( + @NonNull AttestationConveyancePreference attestationConveyancePreference) { + return this.attestationConveyancePreference(Optional.of(attestationConveyancePreference)); + } + + /** + * An {@link AttestationTrustSource} instance to use for looking up trust roots for + * authenticator attestation. This matters only if {@link #getAttestationConveyancePreference()} + * is non-empty and not set to {@link AttestationConveyancePreference#NONE}. + * + *

By default, this is not set. + * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + public RelyingPartyV2Builder attestationTrustSource( + @NonNull Optional attestationTrustSource) { + this.attestationTrustSource = attestationTrustSource; + return this; + } + + /** + * An {@link AttestationTrustSource} instance to use for looking up trust roots for + * authenticator attestation. This matters only if {@link #getAttestationConveyancePreference()} + * is non-empty and not set to {@link AttestationConveyancePreference#NONE}. + * + *

By default, this is not set. + * + * @see PublicKeyCredentialCreationOptions#getAttestation() + * @see §6.4. + * Attestation + */ + public RelyingPartyV2Builder attestationTrustSource( + @NonNull AttestationTrustSource attestationTrustSource) { + return this.attestationTrustSource(Optional.of(attestationTrustSource)); + } + } +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java new file mode 100644 index 000000000..0e342763c --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java @@ -0,0 +1,44 @@ +// 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 com.yubico.webauthn; + +import com.yubico.webauthn.data.ByteArray; +import java.util.Optional; + +/** + * An abstraction of optional database lookups needed by this library. + * + *

This is used by {@link RelyingParty} to look up usernames and user handles. + */ +public interface UsernameRepository { + + /** + * Get the user handle corresponding to the given username. + * + *

Used to look up the user handle based on the username, for authentication ceremonies where + * the username is already given. + */ + Optional getUserHandleForUsername(String username); +} diff --git a/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java b/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java index ed87a720a..c91d2a76d 100644 --- a/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java +++ b/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java @@ -64,9 +64,11 @@ public TrustRootsResult findTrustRoots( } }; + CredentialRepository credentialRepository = null; + RelyingParty.builder() .identity(null) - .credentialRepository(null) + .credentialRepository(credentialRepository) .origins(Collections.emptySet()) .appId(new AppId("https://example.com")) .appId(Optional.of(new AppId("https://example.com"))) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala index 3bcda28a5..115b75ee3 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala @@ -194,7 +194,7 @@ class RelyingPartyAssertionSpec userVerificationRequirement: UserVerificationRequirement = UserVerificationRequirement.PREFERRED, validateSignatureCounter: Boolean = true, - ): FinishAssertionSteps = { + ): FinishAssertionSteps[RegisteredCredential] = { val clientDataJsonBytes: ByteArray = if (clientDataJson == null) null else new ByteArray(clientDataJson.getBytes("UTF-8")) @@ -576,7 +576,8 @@ class RelyingPartyAssertionSpec ), credentialId = new ByteArray(Array(0, 1, 2, 3)), ) - val step: FinishAssertionSteps#Step5 = steps.begin + val step: FinishAssertionSteps[RegisteredCredential]#Step5 = + steps.begin step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -599,7 +600,8 @@ class RelyingPartyAssertionSpec ), credentialId = new ByteArray(Array(4, 5, 6, 7)), ) - val step: FinishAssertionSteps#Step5 = steps.begin + val step: FinishAssertionSteps[RegisteredCredential]#Step5 = + steps.begin step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -616,7 +618,8 @@ class RelyingPartyAssertionSpec allowCredentials = allowCredentials, credentialId = new ByteArray(Array(0, 1, 2, 3)), ) - val step: FinishAssertionSteps#Step5 = steps.begin + val step: FinishAssertionSteps[RegisteredCredential]#Step5 = + steps.begin step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -673,7 +676,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = Some(owner.username), ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -689,7 +693,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, userHandleForResponse = Some(owner.userHandle), ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -703,7 +708,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = Some(owner.username), ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -720,7 +726,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, userHandleForResponse = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -736,7 +743,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -753,7 +761,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -768,7 +777,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -783,7 +793,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -797,7 +808,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -811,7 +823,8 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps#Step6 = steps.begin.next + val step: FinishAssertionSteps[RegisteredCredential]#Step6 = + steps.begin.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -838,7 +851,7 @@ class RelyingPartyAssertionSpec ) ) val step: steps.Step7 = new steps.Step7( - Defaults.username, + Some(Defaults.username).toJava, Defaults.userHandle, None.toJava, ) @@ -863,7 +876,8 @@ class RelyingPartyAssertionSpec ) ) ) - val step: FinishAssertionSteps#Step7 = steps.begin.next.next + val step: FinishAssertionSteps[RegisteredCredential]#Step7 = + steps.begin.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -873,7 +887,8 @@ class RelyingPartyAssertionSpec describe("8. Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData, and signature respectively.") { it("Succeeds if all three are present.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Step8 = steps.begin.next.next.next + val step: FinishAssertionSteps[RegisteredCredential]#Step8 = + steps.begin.next.next.next step.validations shouldBe a[Success[_]] step.clientData should not be null @@ -927,7 +942,7 @@ class RelyingPartyAssertionSpec "type": "" }""" ) - val step: FinishAssertionSteps#Step10 = + val step: FinishAssertionSteps[RegisteredCredential]#Step10 = steps.begin.next.next.next.next step.validations shouldBe a[Success[_]] @@ -941,7 +956,7 @@ class RelyingPartyAssertionSpec ) { it("The default test case succeeds.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Step11 = + val step: FinishAssertionSteps[RegisteredCredential]#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -960,7 +975,7 @@ class RelyingPartyAssertionSpec ), isSecurePaymentConfirmation = isSecurePaymentConfirmation, ) - val step: FinishAssertionSteps#Step11 = + val step: FinishAssertionSteps[RegisteredCredential]#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -992,7 +1007,7 @@ class RelyingPartyAssertionSpec it("the default test case fails.") { val steps = finishAssertion(isSecurePaymentConfirmation = Some(true)) - val step: FinishAssertionSteps#Step11 = + val step: FinishAssertionSteps[RegisteredCredential]#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1010,7 +1025,7 @@ class RelyingPartyAssertionSpec .set[ObjectNode]("type", new TextNode("payment.get")) ), ) - val step: FinishAssertionSteps#Step11 = + val step: FinishAssertionSteps[RegisteredCredential]#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1054,7 +1069,7 @@ class RelyingPartyAssertionSpec it("12. Verify that the value of C.challenge equals the base64url encoding of options.challenge.") { val steps = finishAssertion(challenge = new ByteArray(Array.fill(16)(0))) - val step: FinishAssertionSteps#Step12 = + val step: FinishAssertionSteps[RegisteredCredential]#Step12 = steps.begin.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1079,7 +1094,7 @@ class RelyingPartyAssertionSpec allowOriginPort = allowOriginPort, allowOriginSubdomain = allowOriginSubdomain, ) - val step: FinishAssertionSteps#Step13 = + val step: FinishAssertionSteps[RegisteredCredential]#Step13 = steps.begin.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1102,7 +1117,7 @@ class RelyingPartyAssertionSpec allowOriginPort = allowOriginPort, allowOriginSubdomain = allowOriginSubdomain, ) - val step: FinishAssertionSteps#Step13 = + val step: FinishAssertionSteps[RegisteredCredential]#Step13 = steps.begin.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1280,7 +1295,7 @@ class RelyingPartyAssertionSpec describe("14. Verify that the value of C.tokenBinding.status matches the state of Token Binding for the TLS connection over which the attestation was obtained.") { it("Verification succeeds if neither side uses token binding ID.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1291,7 +1306,7 @@ class RelyingPartyAssertionSpec val clientDataJson = """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","type":"webauthn.get"}""" val steps = finishAssertion(clientDataJson = clientDataJson) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1302,7 +1317,7 @@ class RelyingPartyAssertionSpec val clientDataJson = """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"supported"},"type":"webauthn.get"}""" val steps = finishAssertion(clientDataJson = clientDataJson) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1317,7 +1332,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1332,7 +1347,7 @@ class RelyingPartyAssertionSpec callerTokenBindingId = None, clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1346,7 +1361,7 @@ class RelyingPartyAssertionSpec callerTokenBindingId = None, clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1363,7 +1378,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1378,7 +1393,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1394,7 +1409,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1410,7 +1425,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1426,7 +1441,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("ORANGESUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps#Step14 = + val step: FinishAssertionSteps[RegisteredCredential]#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1442,7 +1457,7 @@ class RelyingPartyAssertionSpec rpId = Defaults.rpId.toBuilder.id("root.evil").build(), origins = Some(Set("https://localhost")), ) - val step: FinishAssertionSteps#Step15 = + val step: FinishAssertionSteps[RegisteredCredential]#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1452,7 +1467,7 @@ class RelyingPartyAssertionSpec it("Succeeds if RP ID is the same.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Step15 = + val step: FinishAssertionSteps[RegisteredCredential]#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1474,7 +1489,7 @@ class RelyingPartyAssertionSpec .drop(32) ), ) - val step: FinishAssertionSteps#Step15 = + val step: FinishAssertionSteps[RegisteredCredential]#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1484,7 +1499,7 @@ class RelyingPartyAssertionSpec it("succeeds if RP ID is the SHA-256 hash of the standard RP ID.") { val steps = finishAssertion(requestedExtensions = extensions) - val step: FinishAssertionSteps#Step15 = + val step: FinishAssertionSteps[RegisteredCredential]#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1500,7 +1515,7 @@ class RelyingPartyAssertionSpec ).getBytes ++ Defaults.authenticatorData.getBytes.drop(32) ), ) - val step: FinishAssertionSteps#Step15 = + val step: FinishAssertionSteps[RegisteredCredential]#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1510,12 +1525,15 @@ class RelyingPartyAssertionSpec } { - def checks[Next <: FinishAssertionSteps.Step[ - _ - ], Step <: FinishAssertionSteps.Step[Next]]( - stepsToStep: FinishAssertionSteps => Step + def checks[ + Next <: FinishAssertionSteps.Step[RegisteredCredential, _], + Step <: FinishAssertionSteps.Step[RegisteredCredential, Next], + ]( + stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step ) = { - def check[Ret](stepsToStep: FinishAssertionSteps => Step)( + def check[Ret]( + stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step + )( chk: Step => Ret )(uvr: UserVerificationRequirement, authData: ByteArray): Ret = { val steps = finishAssertion( @@ -1525,7 +1543,7 @@ class RelyingPartyAssertionSpec chk(stepsToStep(steps)) } def checkFailsWith( - stepsToStep: FinishAssertionSteps => Step + stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step ): (UserVerificationRequirement, ByteArray) => Unit = check(stepsToStep) { step => step.validations shouldBe a[Failure[_]] @@ -1535,7 +1553,7 @@ class RelyingPartyAssertionSpec step.tryNext shouldBe a[Failure[_]] } def checkSucceedsWith( - stepsToStep: FinishAssertionSteps => Step + stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step ): (UserVerificationRequirement, ByteArray) => Unit = check(stepsToStep) { step => step.validations shouldBe a[Success[_]] @@ -1565,7 +1583,9 @@ class RelyingPartyAssertionSpec .toArray ) val (checkFails, checkSucceeds) = - checks[FinishAssertionSteps#Step17, FinishAssertionSteps#Step16]( + checks[FinishAssertionSteps[ + RegisteredCredential + ]#Step17, FinishAssertionSteps[RegisteredCredential]#Step16]( _.begin.next.next.next.next.next.next.next.next.next.next ) @@ -1615,8 +1635,8 @@ class RelyingPartyAssertionSpec ) val (checkFails, checkSucceeds) = checks[ - FinishAssertionSteps#PendingStep16, - FinishAssertionSteps#Step17, + FinishAssertionSteps[RegisteredCredential]#PendingStep16, + FinishAssertionSteps[RegisteredCredential]#Step17, ]( _.begin.next.next.next.next.next.next.next.next.next.next.next ) @@ -1658,22 +1678,26 @@ class RelyingPartyAssertionSpec backupFlagsGen = arbitrary[Boolean].map(bs => (true, bs)), ) ) { authData => - val step: FinishAssertionSteps#PendingStep16 = finishAssertion( - authenticatorData = authData, - credentialRepository = Some( - Helpers.CredentialRepository.withUser( - Defaults.user, - RegisteredCredential - .builder() - .credentialId(Defaults.credentialId) - .userHandle(Defaults.userHandle) - .publicKeyCose(getPublicKeyBytes(Defaults.credentialKey)) - .backupEligible(false) - .backupState(false) - .build(), - ) - ), - ).begin.next.next.next.next.next.next.next.next.next.next.next.next + val step + : FinishAssertionSteps[RegisteredCredential]#PendingStep16 = + finishAssertion( + authenticatorData = authData, + credentialRepository = Some( + Helpers.CredentialRepository.withUser( + Defaults.user, + RegisteredCredential + .builder() + .credentialId(Defaults.credentialId) + .userHandle(Defaults.userHandle) + .publicKeyCose( + getPublicKeyBytes(Defaults.credentialKey) + ) + .backupEligible(false) + .backupState(false) + .build(), + ) + ), + ).begin.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -1693,24 +1717,26 @@ class RelyingPartyAssertionSpec arbitrary[Boolean], ) { case (authData, storedBs) => - val step: FinishAssertionSteps#PendingStep16 = finishAssertion( - authenticatorData = authData, - credentialRepository = Some( - Helpers.CredentialRepository.withUser( - Defaults.user, - RegisteredCredential - .builder() - .credentialId(Defaults.credentialId) - .userHandle(Defaults.userHandle) - .publicKeyCose( - getPublicKeyBytes(Defaults.credentialKey) - ) - .backupEligible(true) - .backupState(storedBs) - .build(), - ) - ), - ).begin.next.next.next.next.next.next.next.next.next.next.next.next + val step + : FinishAssertionSteps[RegisteredCredential]#PendingStep16 = + finishAssertion( + authenticatorData = authData, + credentialRepository = Some( + Helpers.CredentialRepository.withUser( + Defaults.user, + RegisteredCredential + .builder() + .credentialId(Defaults.credentialId) + .userHandle(Defaults.userHandle) + .publicKeyCose( + getPublicKeyBytes(Defaults.credentialKey) + ) + .backupEligible(true) + .backupState(storedBs) + .build(), + ) + ), + ).begin.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[ @@ -1729,7 +1755,7 @@ class RelyingPartyAssertionSpec requestedExtensions = extensionInputs, clientExtensionResults = clientExtensionOutputs, ) - val step: FinishAssertionSteps#Step18 = + val step: FinishAssertionSteps[RegisteredCredential]#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1744,7 +1770,7 @@ class RelyingPartyAssertionSpec requestedExtensions = extensionInputs, clientExtensionResults = clientExtensionOutputs, ) - val step: FinishAssertionSteps#Step18 = + val step: FinishAssertionSteps[RegisteredCredential]#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1769,7 +1795,7 @@ class RelyingPartyAssertionSpec ) ), ) - val step: FinishAssertionSteps#Step18 = + val step: FinishAssertionSteps[RegisteredCredential]#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1794,7 +1820,7 @@ class RelyingPartyAssertionSpec ) ), ) - val step: FinishAssertionSteps#Step18 = + val step: FinishAssertionSteps[RegisteredCredential]#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1805,7 +1831,7 @@ class RelyingPartyAssertionSpec it("19. Let hash be the result of computing a hash over the cData using SHA-256.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Step19 = + val step: FinishAssertionSteps[RegisteredCredential]#Step19 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1822,7 +1848,7 @@ class RelyingPartyAssertionSpec describe("20. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData and hash.") { it("The default test case succeeds.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Step20 = + val step: FinishAssertionSteps[RegisteredCredential]#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1839,7 +1865,7 @@ class RelyingPartyAssertionSpec .set("foo", jsonFactory.textNode("bar")) ) ) - val step: FinishAssertionSteps#Step20 = + val step: FinishAssertionSteps[RegisteredCredential]#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1858,7 +1884,7 @@ class RelyingPartyAssertionSpec rpId = Defaults.rpId.toBuilder.id(rpId).build(), origins = Some(Set("https://localhost")), ) - val step: FinishAssertionSteps#Step20 = + val step: FinishAssertionSteps[RegisteredCredential]#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1878,7 +1904,7 @@ class RelyingPartyAssertionSpec .toArray ) ) - val step: FinishAssertionSteps#Step20 = + val step: FinishAssertionSteps[RegisteredCredential]#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1894,7 +1920,7 @@ class RelyingPartyAssertionSpec .toArray ) ) - val step: FinishAssertionSteps#Step20 = + val step: FinishAssertionSteps[RegisteredCredential]#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1941,7 +1967,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps#Step21 = + val step: FinishAssertionSteps[RegisteredCredential]#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1958,7 +1984,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps#Step21 = + val step: FinishAssertionSteps[RegisteredCredential]#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1980,7 +2006,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps#Step21 = + val step: FinishAssertionSteps[RegisteredCredential]#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -2000,7 +2026,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = false, ) - val step: FinishAssertionSteps#Step21 = + val step: FinishAssertionSteps[RegisteredCredential]#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -2014,7 +2040,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps#Step21 = + val step: FinishAssertionSteps[RegisteredCredential]#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next val result = Try(step.run()) @@ -2043,7 +2069,7 @@ class RelyingPartyAssertionSpec it("22. If all the above steps are successful, continue with the authentication ceremony as appropriate. Otherwise, fail the authentication ceremony.") { val steps = finishAssertion() - val step: FinishAssertionSteps#Finished = + val step: FinishAssertionSteps[RegisteredCredential]#Finished = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala index 034e2338d..d932ef85a 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala @@ -234,17 +234,14 @@ class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { userHandle = Some(Defaults.userHandle) ) - val result = Try( - rp.finishAssertion( - FinishAssertionOptions - .builder() - .request(deterministicRequest) - .response(response) - .build() - ) + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(response) + .build() ) - - result shouldBe a[Success[_]] + result.isSuccess should be(true) } it("fails for the default test case if no username was given and no userHandle returned.") { diff --git a/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java index 6afb76ea5..3f2f7f3c8 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java @@ -11,6 +11,18 @@ @UtilityClass public class OptionalUtil { + /** + * If primary is present, return it unchanged. Otherwise return + * secondary. + */ + public static Optional orOptional(Optional primary, Optional secondary) { + if (primary.isPresent()) { + return primary; + } else { + return secondary; + } + } + /** * If primary is present, return it unchanged. Otherwise return the result of * recover. From c2dde38c8f1ae0739afb8b7a49e5c62fe961a712 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 7 Jul 2023 17:58:34 +0200 Subject: [PATCH 02/21] Try out RelyingPartyV2 in demo --- .../webauthn/InMemoryRegistrationStorage.java | 91 +++++++++---------- .../java/demo/webauthn/WebAuthnServer.java | 14 +-- .../webauthn/data/CredentialRegistration.java | 35 ++++++- 3 files changed, 83 insertions(+), 57 deletions(-) diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java index 0cba71a9c..562e00ba5 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java @@ -26,10 +26,9 @@ import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; -import com.yubico.internal.util.CollectionUtil; -import com.yubico.webauthn.AssertionResult; -import com.yubico.webauthn.CredentialRepository; -import com.yubico.webauthn.RegisteredCredential; +import com.yubico.webauthn.AssertionResultV2; +import com.yubico.webauthn.CredentialRepositoryV2; +import com.yubico.webauthn.UsernameRepository; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import demo.webauthn.data.CredentialRegistration; @@ -44,7 +43,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class InMemoryRegistrationStorage implements CredentialRepository { +public class InMemoryRegistrationStorage + implements CredentialRepositoryV2, UsernameRepository { private final Cache> storage = CacheBuilder.newBuilder().maximumSize(1000).expireAfterAccess(1, TimeUnit.DAYS).build(); @@ -52,12 +52,12 @@ public class InMemoryRegistrationStorage implements CredentialRepository { private static final Logger logger = LoggerFactory.getLogger(InMemoryRegistrationStorage.class); //////////////////////////////////////////////////////////////////////////////// - // The following methods are required by the CredentialRepository interface. + // The following methods are required by the CredentialRepositoryV2 interface. //////////////////////////////////////////////////////////////////////////////// @Override - public Set getCredentialIdsForUsername(String username) { - return getRegistrationsByUsername(username).stream() + public Set getCredentialIdsForUserHandle(ByteArray userHandle) { + return getRegistrationsByUserHandle(userHandle).stream() .map( registration -> PublicKeyCredentialDescriptor.builder() @@ -68,25 +68,14 @@ public Set getCredentialIdsForUsername(String use } @Override - public Optional getUsernameForUserHandle(ByteArray userHandle) { - return getRegistrationsByUserHandle(userHandle).stream() - .findAny() - .map(CredentialRegistration::getUsername); - } - - @Override - public Optional getUserHandleForUsername(String username) { - return getRegistrationsByUsername(username).stream() - .findAny() - .map(reg -> reg.getUserIdentity().getId()); - } - - @Override - public Optional lookup(ByteArray credentialId, ByteArray userHandle) { + public Optional lookup(ByteArray credentialId, ByteArray userHandle) { Optional registrationMaybe = storage.asMap().values().stream() .flatMap(Collection::stream) - .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId())) + .filter( + credReg -> + credentialId.equals(credReg.getCredential().getCredentialId()) + && userHandle.equals(credReg.getUserHandle())) .findAny(); logger.debug( @@ -94,37 +83,38 @@ public Optional lookup(ByteArray credentialId, ByteArray u 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()); + + return registrationMaybe; } @Override - public Set lookupAll(ByteArray credentialId) { - return CollectionUtil.immutableSet( - 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())); + public boolean credentialIdExists(ByteArray credentialId) { + return storage.asMap().values().stream() + .flatMap(Collection::stream) + .anyMatch(reg -> reg.getCredential().getCredentialId().equals(credentialId)); + } + + //////////////////////////////////////////////////////////////////////////////// + // The following methods are required by the UsernameRepository interface. + //////////////////////////////////////////////////////////////////////////////// + + @Override + public Optional getUserHandleForUsername(String username) { + return getRegistrationsByUsername(username).stream() + .findAny() + .map(reg -> reg.getUserIdentity().getId()); } //////////////////////////////////////////////////////////////////////////////// // The following methods are specific to this demo application. //////////////////////////////////////////////////////////////////////////////// + public Optional getUsernameForUserHandle(ByteArray userHandle) { + return getRegistrationsByUserHandle(userHandle).stream() + .findAny() + .map(CredentialRegistration::getUsername); + } + public boolean addRegistrationByUsername(String username, CredentialRegistration reg) { try { return storage.get(username, HashSet::new).add(reg); @@ -152,18 +142,19 @@ public Collection getRegistrationsByUserHandle(ByteArray .collect(Collectors.toList()); } - public void updateSignatureCount(AssertionResult result) { + public void updateSignatureCount(AssertionResultV2 result) { CredentialRegistration registration = getRegistrationByUsernameAndCredentialId( - result.getUsername(), result.getCredential().getCredentialId()) + result.getCredential().getUsername(), result.getCredential().getCredentialId()) .orElseThrow( () -> new NoSuchElementException( String.format( "Credential \"%s\" is not registered to user \"%s\"", - result.getCredential().getCredentialId(), result.getUsername()))); + result.getCredential().getCredentialId(), + result.getCredential().getUsername()))); - Set regs = storage.getIfPresent(result.getUsername()); + Set regs = storage.getIfPresent(result.getCredential().getUsername()); regs.remove(registration); regs.add( registration.withCredential( diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java index d3fc6d4cd..bc7e8d2e1 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java @@ -39,12 +39,13 @@ import com.yubico.internal.util.CertificateParser; import com.yubico.internal.util.JacksonCodecs; import com.yubico.util.Either; -import com.yubico.webauthn.AssertionResult; +import com.yubico.webauthn.AssertionResultV2; import com.yubico.webauthn.FinishAssertionOptions; import com.yubico.webauthn.FinishRegistrationOptions; import com.yubico.webauthn.RegisteredCredential; import com.yubico.webauthn.RegistrationResult; import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.RelyingPartyV2; import com.yubico.webauthn.StartAssertionOptions; import com.yubico.webauthn.StartRegistrationOptions; import com.yubico.webauthn.attestation.YubicoJsonMetadataService; @@ -144,7 +145,7 @@ private static MetadataService getMetadataService() private final Clock clock = Clock.systemDefaultZone(); private final ObjectMapper jsonMapper = JacksonCodecs.json(); - private final RelyingParty rp; + private final RelyingPartyV2 rp; public WebAuthnServer() throws CertificateException, @@ -191,6 +192,7 @@ public WebAuthnServer( RelyingParty.builder() .identity(rpIdentity) .credentialRepository(this.userStorage) + .usernameRepository(this.userStorage) .origins(origins) .attestationConveyancePreference(Optional.of(AttestationConveyancePreference.DIRECT)) .attestationTrustSource(metadataService) @@ -488,7 +490,7 @@ public Either, SuccessfulAuthenticationResult> finishAuthentication return Either.left(Arrays.asList("Assertion failed!", "No such assertion in progress.")); } else { try { - AssertionResult result = + AssertionResultV2 result = rp.finishAssertion( FinishAssertionOptions.builder() .request(request.getRequest()) @@ -501,7 +503,7 @@ public Either, SuccessfulAuthenticationResult> finishAuthentication } catch (Exception e) { logger.error( "Failed to update signature count for user \"{}\", credential \"{}\"", - result.getUsername(), + result.getCredential().getUsername(), response.getCredential().getId(), e); } @@ -510,8 +512,8 @@ public Either, SuccessfulAuthenticationResult> finishAuthentication new SuccessfulAuthenticationResult( request, response, - userStorage.getRegistrationsByUsername(result.getUsername()), - result.getUsername(), + userStorage.getRegistrationsByUsername(result.getCredential().getUsername()), + result.getCredential().getUsername(), sessions.createSession(result.getCredential().getUserHandle()))); } else { return Either.left(Collections.singletonList("Assertion failed: Invalid assertion.")); diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java b/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java index ef5878821..8e9c5b75e 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java @@ -26,20 +26,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import com.yubico.webauthn.CredentialRecord; import com.yubico.webauthn.RegisteredCredential; import com.yubico.webauthn.data.AuthenticatorTransport; +import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.UserIdentity; import java.time.Instant; import java.util.Optional; import java.util.SortedSet; import lombok.Builder; +import lombok.NonNull; import lombok.Value; import lombok.With; @Value @Builder @With -public class CredentialRegistration { +public class CredentialRegistration implements CredentialRecord { UserIdentity userIdentity; Optional credentialNickname; @@ -58,4 +61,34 @@ public String getRegistrationTimestamp() { public String getUsername() { return userIdentity.getName(); } + + @Override + public @NonNull ByteArray getCredentialId() { + return credential.getCredentialId(); + } + + @Override + public @NonNull ByteArray getUserHandle() { + return userIdentity.getId(); + } + + @Override + public @NonNull ByteArray getPublicKeyCose() { + return credential.getPublicKeyCose(); + } + + @Override + public long getSignatureCount() { + return credential.getSignatureCount(); + } + + @Override + public Optional isBackupEligible() { + return credential.isBackupEligible(); + } + + @Override + public Optional isBackedUp() { + return credential.isBackedUp(); + } } From 29363d99a2a9f13f8aa5b4a5b38d07bad768703c Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 17 Oct 2023 21:13:59 +0200 Subject: [PATCH 03/21] Separate CredentialRepository and CredentialRepositoryV2 setters by name This prevents type ambiguity in the case of `credentialRepository(null)`, for example. --- .../src/main/java/com/yubico/webauthn/RelyingParty.java | 4 ++-- .../src/test/java/com/yubico/webauthn/RelyingPartyTest.java | 4 +--- .../src/main/java/demo/webauthn/WebAuthnServer.java | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index f2d23ff01..251bd643b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java @@ -598,7 +598,7 @@ public class Step2 { * credentialRepository} is a required parameter. * * @see RelyingPartyBuilder#credentialRepository(CredentialRepository) - * @see #credentialRepository(CredentialRepositoryV2) + * @see #credentialRepositoryV2(CredentialRepositoryV2) */ public RelyingPartyBuilder credentialRepository(CredentialRepository credentialRepository) { return builder.credentialRepository(credentialRepository); @@ -611,7 +611,7 @@ public RelyingPartyBuilder credentialRepository(CredentialRepository credentialR * @see #credentialRepository(CredentialRepository) */ public - RelyingPartyV2.RelyingPartyV2Builder credentialRepository( + RelyingPartyV2.RelyingPartyV2Builder credentialRepositoryV2( CredentialRepositoryV2 credentialRepository) { return RelyingPartyV2.builder(builder.identity, credentialRepository); } diff --git a/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java b/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java index c91d2a76d..ed87a720a 100644 --- a/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java +++ b/webauthn-server-core/src/test/java/com/yubico/webauthn/RelyingPartyTest.java @@ -64,11 +64,9 @@ public TrustRootsResult findTrustRoots( } }; - CredentialRepository credentialRepository = null; - RelyingParty.builder() .identity(null) - .credentialRepository(credentialRepository) + .credentialRepository(null) .origins(Collections.emptySet()) .appId(new AppId("https://example.com")) .appId(Optional.of(new AppId("https://example.com"))) diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java index bc7e8d2e1..b4f9165e1 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java @@ -191,7 +191,7 @@ public WebAuthnServer( rp = RelyingParty.builder() .identity(rpIdentity) - .credentialRepository(this.userStorage) + .credentialRepositoryV2(this.userStorage) .usernameRepository(this.userStorage) .origins(origins) .attestationConveyancePreference(Optional.of(AttestationConveyancePreference.DIRECT)) From 04a0783db9222f4dd02385e059b131ac038062b9 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 31 Oct 2023 15:57:56 +0100 Subject: [PATCH 04/21] Remove extraneous period --- .../src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala index 3fe9a73c5..5b54714e6 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala @@ -72,7 +72,7 @@ class JsonIoSpec def test[A](tpe: TypeReference[A])(implicit a: Arbitrary[A]): Unit = { val cn = tpe.getType.getTypeName describe(s"${cn}") { - it("is identical after multiple serialization round-trips..") { + it("is identical after multiple serialization round-trips.") { forAll(minSuccessful(10)) { value: A => val encoded: String = json.writeValueAsString(value) From f9a8d7350ed8aca640eef23e66e50ef615fcac31 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 20 Oct 2023 17:17:01 +0200 Subject: [PATCH 05/21] Make CredentialRepositoryV1ToV2Adapter package-private --- .../com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java index ea0b981a5..f378b9a4b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java @@ -8,7 +8,7 @@ import lombok.AllArgsConstructor; @AllArgsConstructor -public class CredentialRepositoryV1ToV2Adapter +class CredentialRepositoryV1ToV2Adapter implements CredentialRepositoryV2, UsernameRepository { private final CredentialRepository inner; From 515900a3a6ce4b39b4f902ee623b0e6fccf24c0b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 20 Oct 2023 17:31:13 +0200 Subject: [PATCH 06/21] Add method getUsernameForUserHandle to UsernameRepository --- .../webauthn/CredentialRepositoryV1ToV2Adapter.java | 5 +++++ .../com/yubico/webauthn/FinishAssertionSteps.java | 7 ++----- .../java/com/yubico/webauthn/UsernameRepository.java | 12 +++++++++++- .../demo/webauthn/InMemoryRegistrationStorage.java | 9 +++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java index f378b9a4b..49118cddc 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java @@ -35,4 +35,9 @@ public boolean credentialIdExists(ByteArray credentialId) { public Optional getUserHandleForUsername(String username) { return inner.getUserHandleForUsername(username); } + + @Override + public Optional getUsernameForUserHandle(ByteArray userHandle) { + return inner.getUsernameForUserHandle(userHandle); + } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java index 505f198d5..d8b287855 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java @@ -60,7 +60,6 @@ final class FinishAssertionSteps { private final Optional callerTokenBindingId; private final Set origins; private final String rpId; - private final Optional credentialRepository; private final CredentialRepositoryV2 credentialRepositoryV2; private final Optional usernameRepository; private final boolean allowOriginPort; @@ -79,7 +78,6 @@ static FinishAssertionSteps fromV1( options.getCallerTokenBindingId(), rp.getOrigins(), rp.getIdentity().getId(), - Optional.of(credRepo), credRepoV2, Optional.of(credRepoV2), rp.isAllowOriginPort(), @@ -95,7 +93,6 @@ static FinishAssertionSteps fromV1( options.getCallerTokenBindingId(), rp.getOrigins(), rp.getIdentity().getId(), - Optional.empty(), rp.getCredentialRepository(), Optional.ofNullable(rp.getUsernameRepository()), rp.isAllowOriginPort(), @@ -105,7 +102,7 @@ static FinishAssertionSteps fromV1( } private Optional getUsernameForUserHandle(final ByteArray userHandle) { - return credentialRepository.flatMap(credRepo -> credRepo.getUsernameForUserHandle(userHandle)); + return usernameRepository.flatMap(unameRepo -> unameRepo.getUsernameForUserHandle(userHandle)); } public Step5 begin() { @@ -262,7 +259,7 @@ public void validate() { finalUserHandle.get(), response.getId()); - if (credentialRepository.isPresent()) { + if (usernameRepository.isPresent()) { assertTrue( finalUsername.isPresent(), "Unknown username for user handle: %s", diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java index 0e342763c..b8429e963 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java @@ -35,10 +35,20 @@ public interface UsernameRepository { /** - * Get the user handle corresponding to the given username. + * Get the user handle corresponding to the given username - the inverse of {@link + * #getUsernameForUserHandle(ByteArray)}. * *

Used to look up the user handle based on the username, for authentication ceremonies where * the username is already given. */ Optional getUserHandleForUsername(String username); + + /** + * Get the username corresponding to the given user handle - the inverse of {@link + * #getUserHandleForUsername(String)}. + * + *

Used to look up the username based on the user handle, for username-less authentication + * ceremonies. + */ + Optional getUsernameForUserHandle(ByteArray userHandle); } diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java index 562e00ba5..75ae0bdfe 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java @@ -105,16 +105,17 @@ public Optional getUserHandleForUsername(String username) { .map(reg -> reg.getUserIdentity().getId()); } - //////////////////////////////////////////////////////////////////////////////// - // The following methods are specific to this demo application. - //////////////////////////////////////////////////////////////////////////////// - + @Override public Optional getUsernameForUserHandle(ByteArray userHandle) { return getRegistrationsByUserHandle(userHandle).stream() .findAny() .map(CredentialRegistration::getUsername); } + //////////////////////////////////////////////////////////////////////////////// + // The following methods are specific to this demo application. + //////////////////////////////////////////////////////////////////////////////// + public boolean addRegistrationByUsername(String username, CredentialRegistration reg) { try { return storage.get(username, HashSet::new).add(reg); From c59ce2fb91e707a734c6efcae739095be9d4f9fe Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 20 Oct 2023 18:11:36 +0200 Subject: [PATCH 07/21] Add tests for *V2 features --- .../com/yubico/webauthn/CredentialRecord.java | 8 +- .../yubico/webauthn/FinishAssertionSteps.java | 4 + .../yubico/webauthn/RegisteredCredential.java | 11 + .../com/yubico/webauthn/RelyingPartyV2.java | 5 + .../RelyingPartyStartOperationSpec.scala | 1912 +++++-- .../RelyingPartyUserIdentificationSpec.scala | 254 +- .../RelyingPartyV2AssertionSpec.scala | 2930 ++++++++++ .../RelyingPartyV2RegistrationSpec.scala | 4858 +++++++++++++++++ .../com/yubico/webauthn/test/Helpers.scala | 201 + .../webauthn/data/CredentialRegistration.java | 6 + 10 files changed, 9626 insertions(+), 563 deletions(-) create mode 100644 webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala create mode 100644 webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java index 36d5e5d8b..04776f34a 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java @@ -1,7 +1,9 @@ package com.yubico.webauthn; +import com.yubico.webauthn.data.AuthenticatorTransport; import com.yubico.webauthn.data.ByteArray; import java.util.Optional; +import java.util.Set; import lombok.NonNull; /** @@ -20,8 +22,10 @@ public interface CredentialRecord { long getSignatureCount(); - // @NonNull - // Set getTransports(); + @NonNull + default Optional> getTransports() { + return Optional.empty(); + } // boolean isUvInitialized(); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java index d8b287855..117cb17c1 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java @@ -230,6 +230,10 @@ public Step7 nextStep() { @Override public void validate() { + assertTrue( + !(request.getUsername().isPresent() && !usernameRepository.isPresent()), + "Cannot set request username when usernameRepository is not configured."); + assertTrue( finalUserHandle.isPresent(), "Could not identify user to authenticate: none of requested username, requested user handle or response user handle are set."); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java index 17434ef57..c3653364f 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java @@ -32,6 +32,7 @@ import com.yubico.webauthn.data.AttestedCredentialData; import com.yubico.webauthn.data.AuthenticatorAssertionResponse; import com.yubico.webauthn.data.AuthenticatorData; +import com.yubico.webauthn.data.AuthenticatorTransport; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.COSEAlgorithmIdentifier; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; @@ -41,6 +42,7 @@ import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.util.Optional; +import java.util.Set; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -118,6 +120,8 @@ public PublicKey getParsedPublicKey() */ @Builder.Default private final long signatureCount = 0; + @Builder.Default private final Set transports = null; + /** * The state of the BE flag when * this credential was registered, if known. @@ -172,16 +176,23 @@ private RegisteredCredential( @NonNull @JsonProperty("userHandle") ByteArray userHandle, @NonNull @JsonProperty("publicKeyCose") ByteArray publicKeyCose, @JsonProperty("signatureCount") long signatureCount, + @JsonProperty("transports") Set transports, @JsonProperty("backupEligible") Boolean backupEligible, @JsonProperty("backupState") @JsonAlias("backedUp") Boolean backupState) { this.credentialId = credentialId; this.userHandle = userHandle; this.publicKeyCose = publicKeyCose; this.signatureCount = signatureCount; + this.transports = transports; this.backupEligible = backupEligible; this.backupState = backupState; } + @Override + public Optional> getTransports() { + return Optional.ofNullable(transports); + } + /** * The state of the BE flag when * this credential was registered, if known. diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java index 7d31eddc2..38eca96e4 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java @@ -463,6 +463,11 @@ FinishRegistrationSteps _finishRegistration(FinishRegistrationOptions options) { } public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptions) { + if (startAssertionOptions.getUsername().isPresent() && usernameRepository == null) { + throw new IllegalArgumentException( + "StartAssertionOptions.username must not be set when usernameRepository is not configured."); + } + PublicKeyCredentialRequestOptionsBuilder pkcro = PublicKeyCredentialRequestOptions.builder() .challenge(generateChallenge()) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala index 7b491b189..fdec0b5c8 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala @@ -43,6 +43,7 @@ import com.yubico.webauthn.data.ResidentKeyRequirement import com.yubico.webauthn.data.UserIdentity import com.yubico.webauthn.extension.appid.AppId import com.yubico.webauthn.extension.appid.Generators._ +import com.yubico.webauthn.test.Helpers import org.junit.runner.RunWith import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen @@ -87,26 +88,6 @@ class RelyingPartyStartOperationSpec ): java.util.Set[RegisteredCredential] = ??? } - def relyingParty( - appId: Option[AppId] = None, - attestationConveyancePreference: Option[AttestationConveyancePreference] = - None, - credentials: Set[PublicKeyCredentialDescriptor] = Set.empty, - userId: UserIdentity, - ): RelyingParty = { - var builder = RelyingParty - .builder() - .identity(rpId) - .credentialRepository(credRepo(credentials, userId)) - .preferredPubkeyParams(List(PublicKeyCredentialParameters.ES256).asJava) - .origins(Set.empty.asJava) - appId.foreach { appid => builder = builder.appId(appid) } - attestationConveyancePreference.foreach { acp => - builder = builder.attestationConveyancePreference(acp) - } - builder.build() - } - val rpId = RelyingPartyIdentity .builder() .id("localhost") @@ -120,528 +101,1363 @@ class RelyingPartyStartOperationSpec .id(new ByteArray(Array(0, 1, 2, 3))) .build() - describe("RelyingParty.startRegistration") { + describe("RelyingParty") { + def relyingParty( + appId: Option[AppId] = None, + attestationConveyancePreference: Option[ + AttestationConveyancePreference + ] = None, + credentials: Set[PublicKeyCredentialDescriptor] = Set.empty, + userId: UserIdentity, + ): RelyingParty = { + var builder = RelyingParty + .builder() + .identity(rpId) + .credentialRepository(credRepo(credentials, userId)) + .preferredPubkeyParams(List(PublicKeyCredentialParameters.ES256).asJava) + .origins(Set.empty.asJava) + appId.foreach { appid => builder = builder.appId(appid) } + attestationConveyancePreference.foreach { acp => + builder = builder.attestationConveyancePreference(acp) + } + builder.build() + } - it("sets excludeCredentials automatically.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startRegistration( + describe("startRegistration") { + + it("sets excludeCredentials automatically.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + + result.getExcludeCredentials.toScala.map(_.asScala) should equal( + Some(credentials) + ) + } + } + + it("sets challenge randomly.") { + val rp = relyingParty(userId = userId) + + val request1 = rp.startRegistration( + StartRegistrationOptions.builder().user(userId).build() + ) + val request2 = rp.startRegistration( + StartRegistrationOptions.builder().user(userId).build() + ) + + request1.getChallenge should not equal request2.getChallenge + request1.getChallenge.size should be >= 32 + request2.getChallenge.size should be >= 32 + } + + it("allows setting authenticatorSelection.") { + val authnrSel = AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() + + val pkcco = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) + .authenticatorSelection(authnrSel) .build() ) + pkcco.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) + } + + it("allows setting authenticatorSelection with an Optional value.") { + val authnrSel = AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() - result.getExcludeCredentials.toScala.map(_.asScala) should equal( - Some(credentials) + val pkccoWith = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection(Optional.of(authnrSel)) + .build() + ) + val pkccoWithout = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + Optional.empty[AuthenticatorSelectionCriteria] + ) + .build() ) + pkccoWith.getAuthenticatorSelection.toScala should equal( + Some(authnrSel) + ) + pkccoWithout.getAuthenticatorSelection.toScala should equal(None) } - } - it("sets challenge randomly.") { - val rp = relyingParty(userId = userId) + it("uses the RelyingParty setting for attestationConveyancePreference.") { + forAll { acp: Option[AttestationConveyancePreference] => + val pkcco = + relyingParty(attestationConveyancePreference = acp, userId = userId) + .startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + pkcco.getAttestation should equal( + acp getOrElse AttestationConveyancePreference.NONE + ) + } + } - val request1 = rp.startRegistration( - StartRegistrationOptions.builder().user(userId).build() - ) - val request2 = rp.startRegistration( - StartRegistrationOptions.builder().user(userId).build() - ) + it("allows setting the timeout to empty.") { + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.empty[java.lang.Long]) + .build() + ) + pkcco.getTimeout.toScala shouldBe empty + } - request1.getChallenge should not equal request2.getChallenge - request1.getChallenge.size should be >= 32 - request2.getChallenge.size should be >= 32 - } + it("allows setting the timeout to a positive value.") { + val rp = relyingParty(userId = userId) - it("allows setting authenticatorSelection.") { - val authnrSel = AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) - .residentKey(ResidentKeyRequirement.REQUIRED) - .build() + forAll(Gen.posNum[Long]) { timeout: Long => + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .timeout(timeout) + .build() + ) - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection(authnrSel) - .build() - ) - pkcco.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) - } + pkcco.getTimeout.toScala should equal(Some(timeout)) + } + } - it("allows setting authenticatorSelection with an Optional value.") { - val authnrSel = AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) - .residentKey(ResidentKeyRequirement.REQUIRED) - .build() + it("does not allow setting the timeout to zero or negative.") { + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(0) + } - val pkccoWith = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection(Optional.of(authnrSel)) - .build() - ) - val pkccoWithout = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - Optional.empty[AuthenticatorSelectionCriteria] + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](0L)) + } + + forAll(Gen.negNum[Long]) { timeout: Long => + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(timeout) + } + + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } + + it( + "sets the appidExclude extension if the RP instance is given an AppId." + ) { + forAll { appId: AppId => + val rp = relyingParty(appId = Some(appId), userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() ) - .build() - ) - pkccoWith.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) - pkccoWithout.getAuthenticatorSelection.toScala should equal(None) - } - it("uses the RelyingParty setting for attestationConveyancePreference.") { - forAll { acp: Option[AttestationConveyancePreference] => - val pkcco = - relyingParty(attestationConveyancePreference = acp, userId = userId) - .startRegistration( + result.getExtensions.getAppidExclude.toScala should equal(Some(appId)) + } + } + + it("does not set the appidExclude extension if the RP instance is not given an AppId.") { + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal(None) + } + + it("does not override the appidExclude extension with an empty value if already non-null in StartRegistrationOptions.") { + forAll { requestAppId: AppId => + val rp = relyingParty(appId = None, userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions( + RegistrationExtensionInputs + .builder() + .appidExclude(requestAppId) + .build() + ) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal( + Some(requestAppId) + ) + } + } + + it("does not override the appidExclude extension if already non-null in StartRegistrationOptions.") { + forAll { (requestAppId: AppId, rpAppId: AppId) => + whenever(requestAppId != rpAppId) { + val rp = relyingParty(appId = Some(rpAppId), userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions( + RegistrationExtensionInputs + .builder() + .appidExclude(requestAppId) + .build() + ) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal( + Some(requestAppId) + ) + } + } + } + + it("by default sets the credProps extension.") { + forAll(registrationExtensionInputs(credPropsGen = None)) { + extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions) + .build() + ) + + result.getExtensions.getCredProps should be(true) + } + } + + it("does not override the credProps extension if explicitly set to false in StartRegistrationOptions.") { + forAll(registrationExtensionInputs(credPropsGen = Some(false))) { + extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) + .extensions(extensions) .build() ) - pkcco.getAttestation should equal( - acp getOrElse AttestationConveyancePreference.NONE + + result.getExtensions.getCredProps should be(false) + } + } + + it("by default does not set the uvm extension.") { + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() ) + result.getExtensions.getUvm should be(false) } - } - it("allows setting the timeout to empty.") { - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .timeout(Optional.empty[java.lang.Long]) - .build() - ) - pkcco.getTimeout.toScala shouldBe empty - } + it("sets the uvm extension if enabled in StartRegistrationOptions.") { + forAll { extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions.toBuilder.uvm().build()) + .build() + ) + + result.getExtensions.getUvm should be(true) + } + } + + it("respects the residentKey setting.") { + val rp = relyingParty(userId = userId) + + val pkccoDiscouraged = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.DISCOURAGED) + .build() + ) + .build() + ) + + val pkccoPreferred = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.PREFERRED) + .build() + ) + .build() + ) + + val pkccoRequired = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() + ) + .build() + ) + + val pkccoUnspecified = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria.builder().build() + ) + .build() + ) + + def jsonRequireResidentKey( + pkcco: PublicKeyCredentialCreationOptions + ): Option[Boolean] = + Option( + JacksonCodecs + .json() + .readTree(pkcco.toCredentialsCreateJson) + .get("publicKey") + .get("authenticatorSelection") + .get("requireResidentKey") + ).map(_.booleanValue) + + pkccoDiscouraged.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.DISCOURAGED) + ) + jsonRequireResidentKey(pkccoDiscouraged) should be(Some(false)) + + pkccoPreferred.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.PREFERRED) + ) + jsonRequireResidentKey(pkccoPreferred) should be(Some(false)) + + pkccoRequired.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.REQUIRED) + ) + jsonRequireResidentKey(pkccoRequired) should be(Some(true)) + + pkccoUnspecified.getAuthenticatorSelection.get.getResidentKey.toScala should be( + None + ) + jsonRequireResidentKey(pkccoUnspecified) should be(None) + } - it("allows setting the timeout to a positive value.") { - val rp = relyingParty(userId = userId) + it("respects the authenticatorAttachment parameter.") { + val rp = relyingParty(userId = userId) - forAll(Gen.posNum[Long]) { timeout: Long => val pkcco = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .timeout(timeout) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .build() + ) + .build() + ) + val pkccoWith = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment( + Optional.of(AuthenticatorAttachment.PLATFORM) + ) + .build() + ) + .build() + ) + val pkccoWithout = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment( + Optional.empty[AuthenticatorAttachment] + ) + .build() + ) .build() ) - pkcco.getTimeout.toScala should equal(Some(timeout)) + pkcco.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + Some(AuthenticatorAttachment.CROSS_PLATFORM) + ) + pkccoWith.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + Some(AuthenticatorAttachment.PLATFORM) + ) + pkccoWithout.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + None + ) } } - it("does not allow setting the timeout to zero or negative.") { - an[IllegalArgumentException] should be thrownBy { - StartRegistrationOptions - .builder() - .user(userId) - .timeout(0) + describe("startAssertion") { + + it("sets allowCredentials to empty if not given a username nor a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = + rp.startAssertion(StartAssertionOptions.builder().build()) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala shouldBe empty + } } - an[IllegalArgumentException] should be thrownBy { - StartRegistrationOptions - .builder() - .user(userId) - .timeout(Optional.of[java.lang.Long](0L)) + it("sets allowCredentials automatically if given a username.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } + } + + it("sets allowCredentials automatically if given a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(userId.getId) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } + } + + it("passes username through to AssertionRequest.") { + forAll { username: String => + val testCaseUserId = userId.toBuilder.name(username).build() + val rp = relyingParty(userId = testCaseUserId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(testCaseUserId.getName) + .build() + ) + result.getUsername.asScala should equal(Some(testCaseUserId.getName)) + } + } + + it("passes user handle through to AssertionRequest.") { + forAll { userHandle: ByteArray => + val testCaseUserId = userId.toBuilder.id(userHandle).build() + val rp = relyingParty(userId = testCaseUserId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(testCaseUserId.getId) + .build() + ) + result.getUserHandle.asScala should equal(Some(testCaseUserId.getId)) + } + } + + it("includes transports in allowCredentials when available.") { + forAll( + Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( + arbitrary[AuthenticatorTransport] + ), + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + ) { + ( + cred1Transports: Set[AuthenticatorTransport], + cred1: PublicKeyCredentialDescriptor, + cred2: PublicKeyCredentialDescriptor, + cred3: PublicKeyCredentialDescriptor, + ) => + val rp = relyingParty( + credentials = Set( + cred1.toBuilder.transports(cred1Transports.asJava).build(), + cred2.toBuilder + .transports( + Optional.of(Set.empty[AuthenticatorTransport].asJava) + ) + .build(), + cred3.toBuilder + .transports( + Optional.empty[java.util.Set[AuthenticatorTransport]] + ) + .build(), + ), + userId = userId, + ) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) + + val requestCreds = + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala + requestCreds.head.getTransports.toScala should equal( + Some(cred1Transports.asJava) + ) + requestCreds(1).getTransports.toScala should equal( + Some(Set.empty.asJava) + ) + requestCreds(2).getTransports.toScala should equal(None) + } + } + + it("sets challenge randomly.") { + val rp = relyingParty(userId = userId) + + val request1 = + rp.startAssertion(StartAssertionOptions.builder().build()) + val request2 = + rp.startAssertion(StartAssertionOptions.builder().build()) + + request1.getPublicKeyCredentialRequestOptions.getChallenge should not equal request2.getPublicKeyCredentialRequestOptions.getChallenge + request1.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 + request2.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 + } + + it("sets the appid extension if the RP instance is given an AppId.") { + forAll { appId: AppId => + val rp = relyingParty(appId = Some(appId), userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(appId) + ) + } + } + + it("does not set the appid extension if the RP instance is not given an AppId.") { + val rp = relyingParty(userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + None + ) + } + + it("does not override the appid extension with an empty value if already non-null in StartAssertionOptions.") { + forAll { requestAppId: AppId => + val rp = relyingParty(appId = None, userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .extensions( + AssertionExtensionInputs + .builder() + .appid(requestAppId) + .build() + ) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(requestAppId) + ) + } + } + + it("does not override the appid extension if already non-null in StartAssertionOptions.") { + forAll { (requestAppId: AppId, rpAppId: AppId) => + whenever(requestAppId != rpAppId) { + val rp = relyingParty(appId = Some(rpAppId), userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .extensions( + AssertionExtensionInputs + .builder() + .appid(requestAppId) + .build() + ) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(requestAppId) + ) + } + } + } + + it("allows setting the timeout to empty.") { + val req = relyingParty(userId = userId).startAssertion( + StartAssertionOptions + .builder() + .timeout(Optional.empty[java.lang.Long]) + .build() + ) + req.getPublicKeyCredentialRequestOptions.getTimeout.toScala shouldBe empty + } + + it("allows setting the timeout to a positive value.") { + val rp = relyingParty(userId = userId) + + forAll(Gen.posNum[Long]) { timeout: Long => + val req = rp.startAssertion( + StartAssertionOptions + .builder() + .timeout(timeout) + .build() + ) + + req.getPublicKeyCredentialRequestOptions.getTimeout.toScala should equal( + Some(timeout) + ) + } + } + + it("does not allow setting the timeout to zero or negative.") { + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(0) + } + + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(Optional.of[java.lang.Long](0L)) + } + + forAll(Gen.negNum[Long]) { timeout: Long => + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(timeout) + } + + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } + + it("by default does not set the uvm extension.") { + val rp = relyingParty(userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .build() + ) + result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( + false + ) + } + + it("sets the uvm extension if enabled in StartRegistrationOptions.") { + forAll { extensions: AssertionExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .extensions(extensions.toBuilder.uvm().build()) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( + true + ) + } + } + } + } + + describe("RelyingPartyV2") { + def relyingParty( + appId: Option[AppId] = None, + attestationConveyancePreference: Option[ + AttestationConveyancePreference + ] = None, + credentials: Set[PublicKeyCredentialDescriptor] = Set.empty, + userId: UserIdentity, + usernameRepository: Boolean = false, + ): RelyingPartyV2[CredentialRecord] = { + var builder = RelyingParty + .builder() + .identity(rpId) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUsers( + credentials + .map(c => + ( + userId, + Helpers.credentialRecord( + credentialId = c.getId, + userHandle = userId.getId, + publicKeyCose = ByteArray.fromHex(""), + transports = c.getTransports.map(_.asScala.toSet).toScala, + ), + ) + ) + .toList: _* + ) + ) + .preferredPubkeyParams(List(PublicKeyCredentialParameters.ES256).asJava) + .origins(Set.empty.asJava) + if (usernameRepository) { + builder.usernameRepository(Helpers.UsernameRepository.withUsers(userId)) + } + appId.foreach { appid => builder = builder.appId(appid) } + attestationConveyancePreference.foreach { acp => + builder = builder.attestationConveyancePreference(acp) + } + builder.build() + } + + describe("startRegistration") { + + it("sets excludeCredentials automatically.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + + result.getExcludeCredentials.toScala.map(_.asScala) should equal( + Some(credentials) + ) + } + } + + it("sets challenge randomly.") { + val rp = relyingParty(userId = userId) + + val request1 = rp.startRegistration( + StartRegistrationOptions.builder().user(userId).build() + ) + val request2 = rp.startRegistration( + StartRegistrationOptions.builder().user(userId).build() + ) + + request1.getChallenge should not equal request2.getChallenge + request1.getChallenge.size should be >= 32 + request2.getChallenge.size should be >= 32 + } + + it("allows setting authenticatorSelection.") { + val authnrSel = AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() + + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection(authnrSel) + .build() + ) + pkcco.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) + } + + it("allows setting authenticatorSelection with an Optional value.") { + val authnrSel = AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() + + val pkccoWith = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection(Optional.of(authnrSel)) + .build() + ) + val pkccoWithout = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + Optional.empty[AuthenticatorSelectionCriteria] + ) + .build() + ) + pkccoWith.getAuthenticatorSelection.toScala should equal( + Some(authnrSel) + ) + pkccoWithout.getAuthenticatorSelection.toScala should equal(None) + } + + it("uses the RelyingParty setting for attestationConveyancePreference.") { + forAll { acp: Option[AttestationConveyancePreference] => + val pkcco = + relyingParty(attestationConveyancePreference = acp, userId = userId) + .startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + pkcco.getAttestation should equal( + acp getOrElse AttestationConveyancePreference.NONE + ) + } + } + + it("allows setting the timeout to empty.") { + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.empty[java.lang.Long]) + .build() + ) + pkcco.getTimeout.toScala shouldBe empty + } + + it("allows setting the timeout to a positive value.") { + val rp = relyingParty(userId = userId) + + forAll(Gen.posNum[Long]) { timeout: Long => + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .timeout(timeout) + .build() + ) + + pkcco.getTimeout.toScala should equal(Some(timeout)) + } + } + + it("does not allow setting the timeout to zero or negative.") { + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(0) + } + + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](0L)) + } + + forAll(Gen.negNum[Long]) { timeout: Long => + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(timeout) + } + + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } + + it( + "sets the appidExclude extension if the RP instance is given an AppId." + ) { + forAll { appId: AppId => + val rp = relyingParty(appId = Some(appId), userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal(Some(appId)) + } + } + + it("does not set the appidExclude extension if the RP instance is not given an AppId.") { + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal(None) + } + + it("does not override the appidExclude extension with an empty value if already non-null in StartRegistrationOptions.") { + forAll { requestAppId: AppId => + val rp = relyingParty(appId = None, userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions( + RegistrationExtensionInputs + .builder() + .appidExclude(requestAppId) + .build() + ) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal( + Some(requestAppId) + ) + } + } + + it("does not override the appidExclude extension if already non-null in StartRegistrationOptions.") { + forAll { (requestAppId: AppId, rpAppId: AppId) => + whenever(requestAppId != rpAppId) { + val rp = relyingParty(appId = Some(rpAppId), userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions( + RegistrationExtensionInputs + .builder() + .appidExclude(requestAppId) + .build() + ) + .build() + ) + + result.getExtensions.getAppidExclude.toScala should equal( + Some(requestAppId) + ) + } + } + } + + it("by default sets the credProps extension.") { + forAll(registrationExtensionInputs(credPropsGen = None)) { + extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions) + .build() + ) + + result.getExtensions.getCredProps should be(true) + } + } + + it("does not override the credProps extension if explicitly set to false in StartRegistrationOptions.") { + forAll(registrationExtensionInputs(credPropsGen = Some(false))) { + extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions) + .build() + ) + + result.getExtensions.getCredProps should be(false) + } + } + + it("by default does not set the uvm extension.") { + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + result.getExtensions.getUvm should be(false) + } + + it("sets the uvm extension if enabled in StartRegistrationOptions.") { + forAll { extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions.toBuilder.uvm().build()) + .build() + ) + + result.getExtensions.getUvm should be(true) + } + } + + it("respects the residentKey setting.") { + val rp = relyingParty(userId = userId) + + val pkccoDiscouraged = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.DISCOURAGED) + .build() + ) + .build() + ) + + val pkccoPreferred = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.PREFERRED) + .build() + ) + .build() + ) + + val pkccoRequired = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() + ) + .build() + ) + + val pkccoUnspecified = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria.builder().build() + ) + .build() + ) + + def jsonRequireResidentKey( + pkcco: PublicKeyCredentialCreationOptions + ): Option[Boolean] = + Option( + JacksonCodecs + .json() + .readTree(pkcco.toCredentialsCreateJson) + .get("publicKey") + .get("authenticatorSelection") + .get("requireResidentKey") + ).map(_.booleanValue) + + pkccoDiscouraged.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.DISCOURAGED) + ) + jsonRequireResidentKey(pkccoDiscouraged) should be(Some(false)) + + pkccoPreferred.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.PREFERRED) + ) + jsonRequireResidentKey(pkccoPreferred) should be(Some(false)) + + pkccoRequired.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.REQUIRED) + ) + jsonRequireResidentKey(pkccoRequired) should be(Some(true)) + + pkccoUnspecified.getAuthenticatorSelection.get.getResidentKey.toScala should be( + None + ) + jsonRequireResidentKey(pkccoUnspecified) should be(None) } - forAll(Gen.negNum[Long]) { timeout: Long => - an[IllegalArgumentException] should be thrownBy { - StartRegistrationOptions - .builder() - .user(userId) - .timeout(timeout) - } + it("respects the authenticatorAttachment parameter.") { + val rp = relyingParty(userId = userId) - an[IllegalArgumentException] should be thrownBy { + val pkcco = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .timeout(Optional.of[java.lang.Long](timeout)) - } - } - } - - it( - "sets the appidExclude extension if the RP instance is given an AppId." - ) { - forAll { appId: AppId => - val rp = relyingParty(appId = Some(appId), userId = userId) - val result = rp.startRegistration( + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .build() + ) + .build() + ) + val pkccoWith = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment( + Optional.of(AuthenticatorAttachment.PLATFORM) + ) + .build() + ) .build() ) - - result.getExtensions.getAppidExclude.toScala should equal(Some(appId)) - } - } - - it("does not set the appidExclude extension if the RP instance is not given an AppId.") { - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) - - result.getExtensions.getAppidExclude.toScala should equal(None) - } - - it("does not override the appidExclude extension with an empty value if already non-null in StartRegistrationOptions.") { - forAll { requestAppId: AppId => - val rp = relyingParty(appId = None, userId = userId) - val result = rp.startRegistration( + val pkccoWithout = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .extensions( - RegistrationExtensionInputs + .authenticatorSelection( + AuthenticatorSelectionCriteria .builder() - .appidExclude(requestAppId) + .authenticatorAttachment( + Optional.empty[AuthenticatorAttachment] + ) .build() ) .build() ) - result.getExtensions.getAppidExclude.toScala should equal( - Some(requestAppId) + pkcco.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + Some(AuthenticatorAttachment.CROSS_PLATFORM) + ) + pkccoWith.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + Some(AuthenticatorAttachment.PLATFORM) + ) + pkccoWithout.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + None ) } } - it("does not override the appidExclude extension if already non-null in StartRegistrationOptions.") { - forAll { (requestAppId: AppId, rpAppId: AppId) => - whenever(requestAppId != rpAppId) { - val rp = relyingParty(appId = Some(rpAppId), userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .extensions( - RegistrationExtensionInputs - .builder() - .appidExclude(requestAppId) - .build() - ) - .build() - ) + describe("startAssertion") { - result.getExtensions.getAppidExclude.toScala should equal( - Some(requestAppId) - ) + it("sets allowCredentials to empty if not given a username nor a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = + rp.startAssertion(StartAssertionOptions.builder().build()) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala shouldBe empty } } - } - it("by default sets the credProps extension.") { - forAll(registrationExtensionInputs(credPropsGen = None)) { - extensions: RegistrationExtensionInputs => - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .extensions(extensions) - .build() + it("sets allowCredentials automatically if given a username.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty( + credentials = credentials, + userId = userId, + usernameRepository = true, ) - - result.getExtensions.getCredProps should be(true) - } - } - - it("does not override the credProps extension if explicitly set to false in StartRegistrationOptions.") { - forAll(registrationExtensionInputs(credPropsGen = Some(false))) { - extensions: RegistrationExtensionInputs => - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions + val result = rp.startAssertion( + StartAssertionOptions .builder() - .user(userId) - .extensions(extensions) + .username(userId.getName) .build() ) - result.getExtensions.getCredProps should be(false) - } - } - - it("by default does not set the uvm extension.") { - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) - result.getExtensions.getUvm should be(false) - } - - it("sets the uvm extension if enabled in StartRegistrationOptions.") { - forAll { extensions: RegistrationExtensionInputs => - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .extensions(extensions.toBuilder.uvm().build()) - .build() - ) - - result.getExtensions.getUvm should be(true) + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } } - } - - it("respects the residentKey setting.") { - val rp = relyingParty(userId = userId) - val pkccoDiscouraged = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria + it("sets allowCredentials automatically if given a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startAssertion( + StartAssertionOptions .builder() - .residentKey(ResidentKeyRequirement.DISCOURAGED) + .userHandle(userId.getId) .build() ) - .build() - ) - val pkccoPreferred = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .residentKey(ResidentKeyRequirement.PREFERRED) - .build() - ) - .build() - ) + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } + } - val pkccoRequired = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria + it("passes username through to AssertionRequest.") { + forAll { username: String => + val testCaseUserId = userId.toBuilder.name(username).build() + val rp = + relyingParty(userId = testCaseUserId, usernameRepository = true) + val result = rp.startAssertion( + StartAssertionOptions .builder() - .residentKey(ResidentKeyRequirement.REQUIRED) + .username(testCaseUserId.getName) .build() ) - .build() - ) - - val pkccoUnspecified = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria.builder().build() - ) - .build() - ) - - def jsonRequireResidentKey( - pkcco: PublicKeyCredentialCreationOptions - ): Option[Boolean] = - Option( - JacksonCodecs - .json() - .readTree(pkcco.toCredentialsCreateJson) - .get("publicKey") - .get("authenticatorSelection") - .get("requireResidentKey") - ).map(_.booleanValue) - - pkccoDiscouraged.getAuthenticatorSelection.get.getResidentKey.toScala should be( - Some(ResidentKeyRequirement.DISCOURAGED) - ) - jsonRequireResidentKey(pkccoDiscouraged) should be(Some(false)) - - pkccoPreferred.getAuthenticatorSelection.get.getResidentKey.toScala should be( - Some(ResidentKeyRequirement.PREFERRED) - ) - jsonRequireResidentKey(pkccoPreferred) should be(Some(false)) - - pkccoRequired.getAuthenticatorSelection.get.getResidentKey.toScala should be( - Some(ResidentKeyRequirement.REQUIRED) - ) - jsonRequireResidentKey(pkccoRequired) should be(Some(true)) - - pkccoUnspecified.getAuthenticatorSelection.get.getResidentKey.toScala should be( - None - ) - jsonRequireResidentKey(pkccoUnspecified) should be(None) - } - - it("respects the authenticatorAttachment parameter.") { - val rp = relyingParty(userId = userId) + result.getUsername.asScala should equal(Some(testCaseUserId.getName)) + } + } - val pkcco = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) - .build() - ) - .build() - ) - val pkccoWith = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment( - Optional.of(AuthenticatorAttachment.PLATFORM) - ) - .build() - ) - .build() - ) - val pkccoWithout = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria + it("passes user handle through to AssertionRequest.") { + forAll { userHandle: ByteArray => + val testCaseUserId = userId.toBuilder.id(userHandle).build() + val rp = relyingParty(userId = testCaseUserId) + val result = rp.startAssertion( + StartAssertionOptions .builder() - .authenticatorAttachment(Optional.empty[AuthenticatorAttachment]) + .userHandle(testCaseUserId.getId) .build() ) - .build() - ) - - pkcco.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( - Some(AuthenticatorAttachment.CROSS_PLATFORM) - ) - pkccoWith.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( - Some(AuthenticatorAttachment.PLATFORM) - ) - pkccoWithout.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( - None - ) - } - } - - describe("RelyingParty.startAssertion") { - - it("sets allowCredentials to empty if not given a username nor a user handle.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startAssertion(StartAssertionOptions.builder().build()) - - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala shouldBe empty + result.getUserHandle.asScala should equal(Some(testCaseUserId.getId)) + } } - } - it("sets allowCredentials automatically if given a username.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(userId.getName) - .build() - ) + it("includes transports in allowCredentials when available.") { + forAll( + Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( + arbitrary[AuthenticatorTransport] + ), + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + ) { + ( + cred1Transports: Set[AuthenticatorTransport], + cred1: PublicKeyCredentialDescriptor, + cred2: PublicKeyCredentialDescriptor, + cred3: PublicKeyCredentialDescriptor, + ) => + val rp = relyingParty( + credentials = Set( + cred1.toBuilder.transports(cred1Transports.asJava).build(), + cred2.toBuilder + .transports( + Optional.of(Set.empty[AuthenticatorTransport].asJava) + ) + .build(), + cred3.toBuilder + .transports( + Optional.empty[java.util.Set[AuthenticatorTransport]] + ) + .build(), + ), + userId = userId, + usernameRepository = true, + ) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala - .map(_.asScala.toSet) should equal(Some(credentials)) + val requestCreds = + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala + requestCreds.head.getTransports.toScala should equal( + Some(cred1Transports.asJava) + ) + requestCreds(1).getTransports.toScala should equal( + Some(Set.empty.asJava) + ) + requestCreds(2).getTransports.toScala should equal(None) + } } - } - - it("sets allowCredentials automatically if given a user handle.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .userHandle(userId.getId) - .build() - ) - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala - .map(_.asScala.toSet) should equal(Some(credentials)) - } - } + it("sets challenge randomly.") { + val rp = relyingParty(userId = userId) - it("passes username through to AssertionRequest.") { - forAll { username: String => - val testCaseUserId = userId.toBuilder.name(username).build() - val rp = relyingParty(userId = testCaseUserId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(testCaseUserId.getName) - .build() - ) - result.getUsername.asScala should equal(Some(testCaseUserId.getName)) - } - } + val request1 = + rp.startAssertion(StartAssertionOptions.builder().build()) + val request2 = + rp.startAssertion(StartAssertionOptions.builder().build()) - it("passes user handle through to AssertionRequest.") { - forAll { userHandle: ByteArray => - val testCaseUserId = userId.toBuilder.id(userHandle).build() - val rp = relyingParty(userId = testCaseUserId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .userHandle(testCaseUserId.getId) - .build() - ) - result.getUserHandle.asScala should equal(Some(testCaseUserId.getId)) + request1.getPublicKeyCredentialRequestOptions.getChallenge should not equal request2.getPublicKeyCredentialRequestOptions.getChallenge + request1.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 + request2.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 } - } - it("includes transports in allowCredentials when available.") { - forAll( - Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( - arbitrary[AuthenticatorTransport] - ), - arbitrary[PublicKeyCredentialDescriptor], - arbitrary[PublicKeyCredentialDescriptor], - arbitrary[PublicKeyCredentialDescriptor], - ) { - ( - cred1Transports: Set[AuthenticatorTransport], - cred1: PublicKeyCredentialDescriptor, - cred2: PublicKeyCredentialDescriptor, - cred3: PublicKeyCredentialDescriptor, - ) => + it("sets the appid extension if the RP instance is given an AppId.") { + forAll { appId: AppId => val rp = relyingParty( - credentials = Set( - cred1.toBuilder.transports(cred1Transports.asJava).build(), - cred2.toBuilder - .transports( - Optional.of(Set.empty[AuthenticatorTransport].asJava) - ) - .build(), - cred3.toBuilder - .transports( - Optional.empty[java.util.Set[AuthenticatorTransport]] - ) - .build(), - ), + appId = Some(appId), userId = userId, + usernameRepository = true, ) val result = rp.startAssertion( StartAssertionOptions @@ -650,85 +1466,33 @@ class RelyingPartyStartOperationSpec .build() ) - val requestCreds = - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala - requestCreds.head.getTransports.toScala should equal( - Some(cred1Transports.asJava) - ) - requestCreds(1).getTransports.toScala should equal( - Some(Set.empty.asJava) + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(appId) ) - requestCreds(2).getTransports.toScala should equal(None) - } - } - - it("sets challenge randomly.") { - val rp = relyingParty(userId = userId) - - val request1 = rp.startAssertion(StartAssertionOptions.builder().build()) - val request2 = rp.startAssertion(StartAssertionOptions.builder().build()) - - request1.getPublicKeyCredentialRequestOptions.getChallenge should not equal request2.getPublicKeyCredentialRequestOptions.getChallenge - request1.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 - request2.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 - } - - it("sets the appid extension if the RP instance is given an AppId.") { - forAll { appId: AppId => - val rp = relyingParty(appId = Some(appId), userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(userId.getName) - .build() - ) - - result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - Some(appId) - ) + } } - } - - it("does not set the appid extension if the RP instance is not given an AppId.") { - val rp = relyingParty(userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(userId.getName) - .build() - ) - - result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - None - ) - } - it("does not override the appid extension with an empty value if already non-null in StartAssertionOptions.") { - forAll { requestAppId: AppId => - val rp = relyingParty(appId = None, userId = userId) + it("does not set the appid extension if the RP instance is not given an AppId.") { + val rp = relyingParty(userId = userId, usernameRepository = true) val result = rp.startAssertion( StartAssertionOptions .builder() .username(userId.getName) - .extensions( - AssertionExtensionInputs - .builder() - .appid(requestAppId) - .build() - ) .build() ) result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - Some(requestAppId) + None ) } - } - it("does not override the appid extension if already non-null in StartAssertionOptions.") { - forAll { (requestAppId: AppId, rpAppId: AppId) => - whenever(requestAppId != rpAppId) { - val rp = relyingParty(appId = Some(rpAppId), userId = userId) + it("does not override the appid extension with an empty value if already non-null in StartAssertionOptions.") { + forAll { requestAppId: AppId => + val rp = relyingParty( + appId = None, + userId = userId, + usernameRepository = true, + ) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -747,89 +1511,117 @@ class RelyingPartyStartOperationSpec ) } } - } - it("allows setting the timeout to empty.") { - val req = relyingParty(userId = userId).startAssertion( - StartAssertionOptions - .builder() - .timeout(Optional.empty[java.lang.Long]) - .build() - ) - req.getPublicKeyCredentialRequestOptions.getTimeout.toScala shouldBe empty - } + it("does not override the appid extension if already non-null in StartAssertionOptions.") { + forAll { (requestAppId: AppId, rpAppId: AppId) => + whenever(requestAppId != rpAppId) { + val rp = relyingParty( + appId = Some(rpAppId), + userId = userId, + usernameRepository = true, + ) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .extensions( + AssertionExtensionInputs + .builder() + .appid(requestAppId) + .build() + ) + .build() + ) - it("allows setting the timeout to a positive value.") { - val rp = relyingParty(userId = userId) + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(requestAppId) + ) + } + } + } - forAll(Gen.posNum[Long]) { timeout: Long => - val req = rp.startAssertion( + it("allows setting the timeout to empty.") { + val req = relyingParty(userId = userId).startAssertion( StartAssertionOptions .builder() - .timeout(timeout) + .timeout(Optional.empty[java.lang.Long]) .build() ) - - req.getPublicKeyCredentialRequestOptions.getTimeout.toScala should equal( - Some(timeout) - ) + req.getPublicKeyCredentialRequestOptions.getTimeout.toScala shouldBe empty } - } - it("does not allow setting the timeout to zero or negative.") { - an[IllegalArgumentException] should be thrownBy { - StartAssertionOptions - .builder() - .timeout(0) - } + it("allows setting the timeout to a positive value.") { + val rp = relyingParty(userId = userId) - an[IllegalArgumentException] should be thrownBy { - StartAssertionOptions - .builder() - .timeout(Optional.of[java.lang.Long](0L)) + forAll(Gen.posNum[Long]) { timeout: Long => + val req = rp.startAssertion( + StartAssertionOptions + .builder() + .timeout(timeout) + .build() + ) + + req.getPublicKeyCredentialRequestOptions.getTimeout.toScala should equal( + Some(timeout) + ) + } } - forAll(Gen.negNum[Long]) { timeout: Long => + it("does not allow setting the timeout to zero or negative.") { an[IllegalArgumentException] should be thrownBy { StartAssertionOptions .builder() - .timeout(timeout) + .timeout(0) } an[IllegalArgumentException] should be thrownBy { StartAssertionOptions .builder() - .timeout(Optional.of[java.lang.Long](timeout)) + .timeout(Optional.of[java.lang.Long](0L)) } - } - } - it("by default does not set the uvm extension.") { - val rp = relyingParty(userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .build() - ) - result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( - false - ) - } + forAll(Gen.negNum[Long]) { timeout: Long => + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(timeout) + } + + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } - it("sets the uvm extension if enabled in StartRegistrationOptions.") { - forAll { extensions: AssertionExtensionInputs => + it("by default does not set the uvm extension.") { val rp = relyingParty(userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() - .extensions(extensions.toBuilder.uvm().build()) .build() ) - result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( - true + false ) } + + it("sets the uvm extension if enabled in StartRegistrationOptions.") { + forAll { extensions: AssertionExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .extensions(extensions.toBuilder.uvm().build()) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( + true + ) + } + } } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala index d932ef85a..4fd6222d5 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala @@ -32,6 +32,8 @@ import com.yubico.webauthn.data.CollectedClientData import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.RelyingPartyIdentity +import com.yubico.webauthn.data.UserIdentity +import com.yubico.webauthn.test.Helpers import org.junit.runner.RunWith import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers @@ -130,7 +132,7 @@ class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { .build() } - describe("The assertion ceremony") { + describe("The assertion ceremony with RelyingParty") { val rp = RelyingParty .builder() @@ -270,4 +272,254 @@ class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { } + describe("The assertion ceremony with RelyingPartyV2") { + + describe("with usernameRepository set") { + val user = UserIdentity + .builder() + .name(Defaults.username) + .displayName("") + .id(Defaults.userHandle) + .build() + val rp = RelyingParty + .builder() + .identity(Defaults.rpId) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + user, + credentialId = Defaults.credentialId, + publicKeyCose = WebAuthnTestCodecs.ecPublicKeyToCose( + Defaults.credentialKey.getPublic.asInstanceOf[ECPublicKey] + ), + signatureCount = 0, + ) + ) + .usernameRepository(Helpers.UsernameRepository.withUsers(user)) + .preferredPubkeyParams(Nil.asJava) + .origins(Set(Defaults.rpId.getId).asJava) + .allowUntrustedAttestation(false) + .validateSignatureCounter(true) + .build() + + it("succeeds for the default test case if a username was given.") { + val request = rp.startAssertion( + StartAssertionOptions + .builder() + .username(Defaults.username) + .build() + ) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val result = Try( + rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(Defaults.publicKeyCredential) + .build() + ) + ) + + result shouldBe a[Success[_]] + } + + it("succeeds for the default test case if a user handle was given.") { + val request = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(Defaults.userHandle) + .build() + ) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val result = Try( + rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(Defaults.publicKeyCredential) + .build() + ) + ) + + result shouldBe a[Success[_]] + } + + it("succeeds if username or user handle was not given but userHandle was returned.") { + val request = rp.startAssertion(StartAssertionOptions.builder().build()) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val response: PublicKeyCredential[ + AuthenticatorAssertionResponse, + ClientAssertionExtensionOutputs, + ] = Defaults.defaultPublicKeyCredential( + userHandle = Some(Defaults.userHandle) + ) + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(response) + .build() + ) + result.isSuccess should be(true) + } + + it("fails for the default test case if no username or user handle was given and no userHandle returned.") { + val request = rp.startAssertion(StartAssertionOptions.builder().build()) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val result = Try( + rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(Defaults.publicKeyCredential) + .build() + ) + ) + + result shouldBe a[Failure[_]] + } + } + + describe("with no usernameRepository set") { + val user = UserIdentity + .builder() + .name(Defaults.username) + .displayName("") + .id(Defaults.userHandle) + .build() + val rp = RelyingParty + .builder() + .identity(Defaults.rpId) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + user, + credentialId = Defaults.credentialId, + publicKeyCose = WebAuthnTestCodecs.ecPublicKeyToCose( + Defaults.credentialKey.getPublic.asInstanceOf[ECPublicKey] + ), + signatureCount = 0, + ) + ) + .preferredPubkeyParams(Nil.asJava) + .origins(Set(Defaults.rpId.getId).asJava) + .allowUntrustedAttestation(false) + .validateSignatureCounter(true) + .build() + + it("succeeds for the default test case if a userhandle was given.") { + val request = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(Defaults.userHandle) + .build() + ) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val result = Try( + rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(Defaults.publicKeyCredential) + .build() + ) + ) + + result shouldBe a[Success[_]] + } + + it("succeeds if user handle was not given but userHandle was returned.") { + val request = rp.startAssertion(StartAssertionOptions.builder().build()) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val response: PublicKeyCredential[ + AuthenticatorAssertionResponse, + ClientAssertionExtensionOutputs, + ] = Defaults.defaultPublicKeyCredential( + userHandle = Some(Defaults.userHandle) + ) + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(response) + .build() + ) + result.isSuccess should be(true) + } + + it("fails for the default test case if no user handle was given and no userHandle returned.") { + val request = rp.startAssertion(StartAssertionOptions.builder().build()) + val deterministicRequest = + request.toBuilder + .publicKeyCredentialRequestOptions( + request.getPublicKeyCredentialRequestOptions.toBuilder + .challenge(Defaults.challenge) + .build() + ) + .build() + + val result = Try( + rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(Defaults.publicKeyCredential) + .build() + ) + ) + + result shouldBe a[Failure[_]] + } + } + + } + } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala new file mode 100644 index 000000000..491b7a143 --- /dev/null +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala @@ -0,0 +1,2930 @@ +// Copyright (c) 2023, 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 com.yubico.webauthn + +import com.fasterxml.jackson.core.`type`.TypeReference +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode +import com.upokecenter.cbor.CBORObject +import com.yubico.internal.util.JacksonCodecs +import com.yubico.webauthn.data.AssertionExtensionInputs +import com.yubico.webauthn.data.AuthenticatorAssertionResponse +import com.yubico.webauthn.data.AuthenticatorAttachment +import com.yubico.webauthn.data.AuthenticatorDataFlags +import com.yubico.webauthn.data.AuthenticatorTransport +import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.ClientAssertionExtensionOutputs +import com.yubico.webauthn.data.CollectedClientData +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput +import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry +import com.yubico.webauthn.data.Generators._ +import com.yubico.webauthn.data.PublicKeyCredential +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor +import com.yubico.webauthn.data.PublicKeyCredentialParameters +import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions +import com.yubico.webauthn.data.ReexportHelpers +import com.yubico.webauthn.data.RelyingPartyIdentity +import com.yubico.webauthn.data.UserIdentity +import com.yubico.webauthn.data.UserVerificationRequirement +import com.yubico.webauthn.exception.InvalidSignatureCountException +import com.yubico.webauthn.extension.appid.AppId +import com.yubico.webauthn.extension.uvm.KeyProtectionType +import com.yubico.webauthn.extension.uvm.MatcherProtectionType +import com.yubico.webauthn.extension.uvm.UserVerificationMethod +import com.yubico.webauthn.test.Helpers +import com.yubico.webauthn.test.RealExamples +import com.yubico.webauthn.test.Util.toStepWithUtilities +import org.junit.runner.RunWith +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.Gen +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.junit.JUnitRunner +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +import java.io.IOException +import java.nio.charset.Charset +import java.security.KeyPair +import java.security.MessageDigest +import java.util.Optional +import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters.RichOption +import scala.jdk.OptionConverters.RichOptional +import scala.util.Failure +import scala.util.Success +import scala.util.Try + +@RunWith(classOf[JUnitRunner]) +class RelyingPartyV2AssertionSpec + extends AnyFunSpec + with Matchers + with ScalaCheckDrivenPropertyChecks + with TestWithEachProvider { + + private def jsonFactory: JsonNodeFactory = JsonNodeFactory.instance + + private def sha256(bytes: ByteArray): ByteArray = Crypto.sha256(bytes) + private def sha256(data: String): ByteArray = + sha256(new ByteArray(data.getBytes(Charset.forName("UTF-8")))) + + private object Defaults { + + val rpId = + RelyingPartyIdentity.builder().id("localhost").name("Test party").build() + + // These values were generated using TestAuthenticator.makeAssertionExample() + val authenticatorData: ByteArray = + ByteArray.fromHex("49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630100000539") + val clientDataJson: String = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","type":"webauthn.get","tokenBinding":{"status":"supported"},"clientExtensions":{}}""" + val credentialId: ByteArray = + ByteArray.fromBase64Url("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8") + val credentialKey: KeyPair = TestAuthenticator.importEcKeypair( + privateBytes = + ByteArray.fromHex("308193020100301306072a8648ce3d020106082a8648ce3d030107047930770201010420449d91b8a2a508b2927cd5cf4dde32db8e58f237fc155e395d3aad127e115f5aa00a06082a8648ce3d030107a1440342000446c68a2eb75057b1f19b6d06dd3733381063d021391b3637889b0b432c54aaa2b184b35e44d433c70e63a9dd82568dd1ec02c5daba3e66b90a3a881c0c1f4c1a"), + publicBytes = + ByteArray.fromHex("3059301306072a8648ce3d020106082a8648ce3d0301070342000446c68a2eb75057b1f19b6d06dd3733381063d021391b3637889b0b432c54aaa2b184b35e44d433c70e63a9dd82568dd1ec02c5daba3e66b90a3a881c0c1f4c1a"), + ) + val signature: ByteArray = + ByteArray.fromHex("304502201dfef99d44222410686605e23227853f19e9bf89cbab181fdb52b7f40d79f0d5022100c167309d699a03416887af363de0628d7d77f678a01d135da996f0ecbed7e8a5") + + // These values are not signed over + val username: String = "foo-user" + val userHandle: ByteArray = + ByteArray.fromHex("6d8972d9603ce4f3fa5d520ce6d024bf") + val user: UserIdentity = UserIdentity + .builder() + .name(username) + .displayName("Test user") + .id(userHandle) + .build() + + // These values are defined by the attestationObject and clientDataJson above + val credentialPublicKeyCose: ByteArray = + WebAuthnTestCodecs.publicKeyToCose(credentialKey.getPublic) + val clientDataJsonBytes: ByteArray = new ByteArray( + clientDataJson.getBytes("UTF-8") + ) + val clientData = new CollectedClientData(clientDataJsonBytes) + val challenge: ByteArray = clientData.getChallenge + val requestedExtensions = AssertionExtensionInputs.builder().build() + val clientExtensionResults: ClientAssertionExtensionOutputs = + ClientAssertionExtensionOutputs.builder().build() + + } + + def finishAssertion[C <: CredentialRecord]( + credentialRepository: CredentialRepositoryV2[C], + allowCredentials: Option[java.util.List[PublicKeyCredentialDescriptor]] = + Some( + List( + PublicKeyCredentialDescriptor + .builder() + .id(Defaults.credentialId) + .build() + ).asJava + ), + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + authenticatorData: ByteArray = Defaults.authenticatorData, + callerTokenBindingId: Option[ByteArray] = None, + challenge: ByteArray = Defaults.challenge, + clientDataJson: String = Defaults.clientDataJson, + clientExtensionResults: ClientAssertionExtensionOutputs = + Defaults.clientExtensionResults, + credentialId: ByteArray = Defaults.credentialId, + isSecurePaymentConfirmation: Option[Boolean] = None, + origins: Option[Set[String]] = None, + requestedExtensions: AssertionExtensionInputs = + Defaults.requestedExtensions, + rpId: RelyingPartyIdentity = Defaults.rpId, + signature: ByteArray = Defaults.signature, + userHandleForResponse: Option[ByteArray] = Some(Defaults.userHandle), + userHandleForRequest: Option[ByteArray] = None, + usernameForRequest: Option[String] = None, + usernameRepository: Option[UsernameRepository] = None, + userVerificationRequirement: UserVerificationRequirement = + UserVerificationRequirement.PREFERRED, + validateSignatureCounter: Boolean = true, + ): FinishAssertionSteps[C] = { + val clientDataJsonBytes: ByteArray = + if (clientDataJson == null) null + else new ByteArray(clientDataJson.getBytes("UTF-8")) + + val request = AssertionRequest + .builder() + .publicKeyCredentialRequestOptions( + PublicKeyCredentialRequestOptions + .builder() + .challenge(challenge) + .rpId(rpId.getId) + .allowCredentials(allowCredentials.toJava) + .userVerification(userVerificationRequirement) + .extensions(requestedExtensions) + .build() + ) + .username(usernameForRequest.toJava) + .userHandle(userHandleForRequest.toJava) + .build() + + val response = PublicKeyCredential + .builder() + .id(credentialId) + .response( + AuthenticatorAssertionResponse + .builder() + .authenticatorData( + if (authenticatorData == null) null else authenticatorData + ) + .clientDataJSON( + if (clientDataJsonBytes == null) null else clientDataJsonBytes + ) + .signature(if (signature == null) null else signature) + .userHandle(userHandleForResponse.toJava) + .build() + ) + .clientExtensionResults(clientExtensionResults) + .build() + + val builder = RelyingParty + .builder() + .identity(rpId) + .credentialRepositoryV2(credentialRepository) + .preferredPubkeyParams(Nil.asJava) + .allowOriginPort(allowOriginPort) + .allowOriginSubdomain(allowOriginSubdomain) + .allowUntrustedAttestation(false) + .validateSignatureCounter(validateSignatureCounter) + + usernameRepository.foreach(builder.usernameRepository) + origins.map(_.asJava).foreach(builder.origins) + + val fao = FinishAssertionOptions + .builder() + .request(request) + .response(response) + .callerTokenBindingId(callerTokenBindingId.toJava) + + isSecurePaymentConfirmation foreach { isSpc => + fao.isSecurePaymentConfirmation(isSpc) + } + + builder + .build() + ._finishAssertion(fao.build()) + } + + testWithEachProvider { it => + describe("RelyingParty.startAssertion") { + + describe( + "respects the userVerification parameter in StartAssertionOptions." + ) { + it(s"If the parameter is not set, or set to empty, it is also empty in the result.") { + val rp = RelyingParty + .builder() + .identity(Defaults.rpId) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + val request1 = + rp.startAssertion(StartAssertionOptions.builder().build()) + val request2 = rp.startAssertion( + StartAssertionOptions + .builder() + .userVerification(Optional.empty[UserVerificationRequirement]) + .build() + ) + + request1.getPublicKeyCredentialRequestOptions.getUserVerification.toScala should be( + None + ) + request2.getPublicKeyCredentialRequestOptions.getUserVerification.toScala should be( + None + ) + } + + it(s"If the parameter is set, that value is used.") { + val rp = RelyingParty + .builder() + .identity(Defaults.rpId) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + + forAll { uv: Option[UserVerificationRequirement] => + val request = rp.startAssertion( + StartAssertionOptions + .builder() + .userVerification(uv.toJava) + .build() + ) + + request.getPublicKeyCredentialRequestOptions.getUserVerification.toScala should equal( + uv + ) + } + } + } + + } + + describe("RelyingParty.finishAssertion") { + + it("does not make redundant calls to CredentialRepositoryV2.lookup().") { + val registrationTestData = + RegistrationTestData.Packed.BasicAttestationEdDsa + val testData = registrationTestData.assertion.get + + val credRepo = new Helpers.CredentialRepositoryV2.CountingCalls( + Helpers.CredentialRepositoryV2.withUsers( + ( + registrationTestData.userId, + Helpers.toCredentialRecord(registrationTestData), + ) + ) + ) + val usernameRepo = + Helpers.UsernameRepository.withUsers(registrationTestData.userId) + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity.builder().id("localhost").name("Test RP").build() + ) + .credentialRepositoryV2(credRepo) + .usernameRepository(usernameRepo) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(testData.request) + .response(testData.response) + .build() + ) + + result.isSuccess should be(true) + result.getCredential.getUserHandle should equal( + registrationTestData.userId.getId + ) + result.getCredential.getCredentialId should equal( + registrationTestData.response.getId + ) + result.getCredential.getCredentialId should equal( + testData.response.getId + ) + credRepo.lookupCount should equal(1) + } + + describe("§7.2. Verifying an authentication assertion: When verifying a given PublicKeyCredential structure (credential) and an AuthenticationExtensionsClientOutputs structure clientExtensionResults, as part of an authentication ceremony, the Relying Party MUST proceed as follows:") { + + describe("1. Let options be a new PublicKeyCredentialRequestOptions structure configured to the Relying Party's needs for the ceremony.") { + it("If options.allowCredentials is present, the transports member of each item SHOULD be set to the value returned by credential.response.getTransports() when the corresponding credential was registered.") { + forAll( + Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( + arbitrary[AuthenticatorTransport] + ), + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + ) { + ( + cred1Transports: Set[AuthenticatorTransport], + cred1: PublicKeyCredentialDescriptor, + cred2: PublicKeyCredentialDescriptor, + cred3: PublicKeyCredentialDescriptor, + ) => + val credRepo = new CredentialRepositoryV2[CredentialRecord] { + override def getCredentialIdsForUserHandle( + userHandle: ByteArray + ): java.util.Set[PublicKeyCredentialDescriptor] = + Set( + cred1.toBuilder + .transports(cred1Transports.asJava) + .build(), + cred2.toBuilder + .transports( + Optional.of( + Set.empty[AuthenticatorTransport].asJava + ) + ) + .build(), + cred3.toBuilder + .transports( + Optional + .empty[java.util.Set[AuthenticatorTransport]] + ) + .build(), + ).asJava + + override def lookup( + credentialId: ByteArray, + userHandle: ByteArray, + ): Optional[CredentialRecord] = ??? + + override def credentialIdExists( + credentialId: ByteArray + ): Boolean = ??? + } + + { + val rp = RelyingParty + .builder() + .identity(Defaults.rpId) + .credentialRepositoryV2( + credRepo + ) + .preferredPubkeyParams( + List(PublicKeyCredentialParameters.ES256).asJava + ) + .build() + + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(Defaults.userHandle) + .build() + ) + + val requestCreds = + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala + requestCreds.head.getTransports.toScala should equal( + Some(cred1Transports.asJava) + ) + requestCreds(1).getTransports.toScala should equal( + Some(Set.empty.asJava) + ) + requestCreds(2).getTransports.toScala should equal(None) + + } + + { + val usernameRepo = Helpers.UsernameRepository.withUsers( + UserIdentity + .builder() + .name(Defaults.username) + .displayName(Defaults.username) + .id(Defaults.userHandle) + .build() + ) + val rp = RelyingParty + .builder() + .identity(Defaults.rpId) + .credentialRepositoryV2( + credRepo + ) + .usernameRepository(usernameRepo) + .preferredPubkeyParams( + List(PublicKeyCredentialParameters.ES256).asJava + ) + .build() + + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(Defaults.username) + .build() + ) + + val requestCreds = + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala + requestCreds.head.getTransports.toScala should equal( + Some(cred1Transports.asJava) + ) + requestCreds(1).getTransports.toScala should equal( + Some(Set.empty.asJava) + ) + requestCreds(2).getTransports.toScala should equal(None) + + } + } + } + } + + describe("2. Call navigator.credentials.get() and pass options as the publicKey option. Let credential be the result of the successfully resolved promise. If the promise is rejected, abort the ceremony with a user-visible error, or otherwise guide the user experience as might be determinable from the context available in the rejected promise. For information on different error contexts and the circumstances leading to them, see § 6.3.3 The authenticatorGetAssertion Operation.") { + it("Nothing to test: applicable only to client side.") {} + } + + it("3. Let response be credential.response. If response is not an instance of AuthenticatorAssertionResponse, abort the ceremony with a user-visible error.") { + val testData = + RegistrationTestData.Packed.BasicAttestationEdDsa.assertion.get + val faob = FinishAssertionOptions + .builder() + .request(testData.request) + "faob.response(testData.request)" shouldNot compile + faob.response(testData.response).build() should not be null + } + + describe("4. Let clientExtensionResults be the result of calling credential.getClientExtensionResults().") { + it( + "The PublicKeyCredential class has a clientExtensionResults field" + ) { + val pkc = PublicKeyCredential.parseAssertionResponseJson("""{ + "type": "public-key", + "id": "", + "response": { + "authenticatorData": "xGzvgq0bVGR3WR0Aiwh1nsPm0uy085R0v-ppaZJdA7cBAAAABQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaHZGN1AxNGwxTjZUcEhnZXVBMjhDdnJaTE1yVjRSMjdZd2JrY2FSYlRPZyIsIm9yaWdpbiI6Imh0dHBzOi8vZGVtby55dWJpY28uY29tIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ==", + "signature": "MEYCIQCi7u0ErVIGZIWOQbc_y7IYcNXBniczTgzHH_yE0WfzcQIhALDsITBJDPQMBFxB6pKd608lRVPcNeNnrX3olAxA3AmX" + }, + "clientExtensionResults": { + "appid": true, + "org.example.foo": "bar" + } + }""") + pkc.getClientExtensionResults.getExtensionIds should contain( + "appid" + ) + } + } + + describe("5. If options.allowCredentials is not empty, verify that credential.id identifies one of the public key credentials listed in options.allowCredentials.") { + it("Fails if returned credential ID is not a requested one.") { + val steps = finishAssertion[CredentialRecord]( + credentialRepository = + Helpers.CredentialRepositoryV2.unimplemented[CredentialRecord], + allowCredentials = Some( + List( + PublicKeyCredentialDescriptor + .builder() + .id(new ByteArray(Array(3, 2, 1, 0))) + .build() + ).asJava + ), + credentialId = new ByteArray(Array(0, 1, 2, 3)), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step5 = + steps.begin + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Succeeds if returned credential ID is a requested one.") { + val steps = finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.unimplemented[CredentialRecord], + allowCredentials = Some( + List( + PublicKeyCredentialDescriptor + .builder() + .id(new ByteArray(Array(0, 1, 2, 3))) + .build(), + PublicKeyCredentialDescriptor + .builder() + .id(new ByteArray(Array(4, 5, 6, 7))) + .build(), + ).asJava + ), + credentialId = new ByteArray(Array(4, 5, 6, 7)), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step5 = + steps.begin + + step.validations shouldBe a[Success[_]] + } + + it("Succeeds if no credential IDs were requested.") { + for { + allowCredentials <- List( + None, + Some(List.empty[PublicKeyCredentialDescriptor].asJava), + ) + } { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2 + .unimplemented[CredentialRecord], + allowCredentials = allowCredentials, + credentialId = new ByteArray(Array(0, 1, 2, 3)), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step5 = + steps.begin + + step.validations shouldBe a[Success[_]] + } + } + } + + describe("6. Identify the user being authenticated and verify that this user is the owner of the public key credential source credentialSource identified by credential.id:") { + val owner = UserIdentity + .builder() + .name("owner") + .displayName("") + .id(new ByteArray(Array(4, 5, 6, 7))) + .build() + val nonOwner = UserIdentity + .builder() + .name("non-owner") + .displayName("") + .id(new ByteArray(Array(8, 9, 10, 11))) + .build() + + val credentialOwnedByOwner = Helpers.CredentialRepositoryV2.withUsers( + ( + owner, + Helpers.credentialRecord( + credentialId = Defaults.credentialId, + userHandle = owner.getId, + publicKeyCose = null, + ), + ) + ) + + val credentialOwnedByNonOwner = + Helpers.CredentialRepositoryV2.withUsers( + ( + nonOwner, + Helpers.credentialRecord( + credentialId = new ByteArray(Array(12, 13, 14, 15)), + userHandle = nonOwner.getId, + publicKeyCose = null, + ), + ) + ) + + describe("If the user was identified before the authentication ceremony was initiated, e.g., via a username or cookie, verify that the identified user is the owner of credentialSource. If response.userHandle is present, let userHandle be its value. Verify that userHandle also maps to the same user.") { + def checks(usernameRepository: Option[UsernameRepository]) = { + it( + "Fails if credential ID is not owned by the requested user handle." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByNonOwner, + usernameRepository = usernameRepository, + userHandleForRequest = Some(owner.getId), + userHandleForResponse = None, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + it( + "Fails if response.userHandle does not identify the same user as request.userHandle." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + userHandleForRequest = Some(nonOwner.getId), + userHandleForResponse = Some(owner.getId), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + it("Succeeds if credential ID is owned by the requested user handle.") { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + userHandleForRequest = Some(owner.getId), + userHandleForResponse = None, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Succeeds if credential ID is owned by the requested and returned user handle.") { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + userHandleForRequest = Some(owner.getId), + userHandleForResponse = Some(owner.getId), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + describe("When a UsernameRepository is set:") { + val usernameRepository = + Some(Helpers.UsernameRepository.withUsers(owner, nonOwner)) + checks(usernameRepository) + + it( + "Fails if credential ID is not owned by the requested username." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByNonOwner, + usernameRepository = usernameRepository, + usernameForRequest = Some(owner.getName), + userHandleForResponse = None, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + it( + "Fails if response.userHandle does not identify the same user as request.username." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + usernameForRequest = Some(nonOwner.getName), + userHandleForResponse = Some(owner.getId), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + it( + "Succeeds if credential ID is owned by the requested username." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + usernameForRequest = Some(owner.getName), + userHandleForResponse = None, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Succeeds if credential ID is owned by the requested username and returned user handle.") { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + usernameForRequest = Some(owner.getName), + userHandleForResponse = Some(owner.getId), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + describe("When a UsernameRepository is not set:") { + checks(None) + } + } + + describe("If the user was not identified before the authentication ceremony was initiated, verify that response.userHandle is present, and that the user identified by this value is the owner of credentialSource.") { + def checks(usernameRepository: Option[UsernameRepository]) = { + it( + "Fails if response.userHandle is not present." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + usernameForRequest = None, + userHandleForRequest = None, + userHandleForResponse = None, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + it( + "Fails if credential ID is not owned by the user handle in the response." + ) { + val steps = finishAssertion( + credentialRepository = credentialOwnedByNonOwner, + usernameRepository = usernameRepository, + usernameForRequest = None, + userHandleForRequest = None, + userHandleForResponse = Some(owner.getId), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + it("Succeeds if credential ID is owned by the user handle in the response.") { + val steps = finishAssertion( + credentialRepository = credentialOwnedByOwner, + usernameRepository = usernameRepository, + usernameForRequest = None, + userHandleForRequest = None, + userHandleForResponse = Some(owner.getId), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step6 = + steps.begin.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + val usernameRepository = + Helpers.UsernameRepository.withUsers(owner, nonOwner) + describe("When a UsernameRepository is set:") { + checks(Some(usernameRepository)) + } + + describe("When a UsernameRepository is not set:") { + checks(None) + } + } + } + + describe("7. Using credential.id (or credential.rawId, if base64url encoding is inappropriate for your use case), look up the corresponding credential public key and let credentialPublicKey be that credential public key.") { + it("Fails if the credential ID is unknown.") { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + ) + val step: steps.Step7 = new steps.Step7( + Some(Defaults.username).toJava, + Defaults.userHandle, + None.toJava, + ) + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Succeeds if the credential ID is known.") { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + ) + val step: FinishAssertionSteps[CredentialRecord]#Step7 = + steps.begin.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + describe("8. Let cData, authData and sig denote the value of response’s clientDataJSON, authenticatorData, and signature respectively.") { + it("Succeeds if all three are present.") { + val steps = finishAssertion(credentialRepository = + Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + ) + val step: FinishAssertionSteps[CredentialRecord]#Step8 = + steps.begin.next.next.next + + step.validations shouldBe a[Success[_]] + step.clientData should not be null + step.authenticatorData should not be null + step.signature should not be null + step.tryNext shouldBe a[Success[_]] + } + + it("Fails if clientDataJSON is missing.") { + a[NullPointerException] should be thrownBy finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.unimplemented[CredentialRecord], + clientDataJson = null, + ) + } + + it("Fails if authenticatorData is missing.") { + a[NullPointerException] should be thrownBy finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.unimplemented[CredentialRecord], + authenticatorData = null, + ) + } + + it("Fails if signature is missing.") { + a[NullPointerException] should be thrownBy finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.unimplemented[CredentialRecord], + signature = null, + ) + } + } + + describe("9. Let JSONtext be the result of running UTF-8 decode on the value of cData.") { + it("Fails if clientDataJSON is not valid UTF-8.") { + an[IOException] should be thrownBy new CollectedClientData( + new ByteArray(Array(-128)) + ) + } + } + + describe("10. Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext.") { + it("Fails if cData is not valid JSON.") { + an[IOException] should be thrownBy new CollectedClientData( + new ByteArray("{".getBytes(Charset.forName("UTF-8"))) + ) + an[IOException] should be thrownBy finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.unimplemented[CredentialRecord], + clientDataJson = "{", + ) + } + + it("Succeeds if cData is valid JSON.") { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + clientDataJson = """{ + "challenge": "", + "origin": "", + "type": "" + }""", + ) + val step: FinishAssertionSteps[CredentialRecord]#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.clientData should not be null + step.tryNext shouldBe a[Success[_]] + } + } + + describe( + "11. Verify that the value of C.type is the string webauthn.get." + ) { + it("The default test case succeeds.") { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + ) + val step: FinishAssertionSteps[CredentialRecord]#Step11 = + steps.begin.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + } + + def assertFails( + typeString: String, + isSecurePaymentConfirmation: Option[Boolean] = None, + ): Unit = { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + clientDataJson = JacksonCodecs.json.writeValueAsString( + JacksonCodecs.json + .readTree(Defaults.clientDataJson) + .asInstanceOf[ObjectNode] + .set("type", jsonFactory.textNode(typeString)) + ), + isSecurePaymentConfirmation = isSecurePaymentConfirmation, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step11 = + steps.begin.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + } + + it("""Any value other than "webauthn.get" fails.""") { + forAll { (typeString: String) => + whenever(typeString != "webauthn.get") { + assertFails(typeString) + } + } + forAll(Gen.alphaNumStr) { (typeString: String) => + whenever(typeString != "webauthn.get") { + assertFails(typeString) + } + } + } + + it("""The string "webauthn.create" fails.""") { + assertFails("webauthn.create") + } + + it("""The string "payment.get" fails.""") { + assertFails("payment.get") + } + + describe("If the isSecurePaymentConfirmation option is set,") { + it("the default test case fails.") { + val steps = + finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + isSecurePaymentConfirmation = Some(true), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step11 = + steps.begin.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + } + + it("""the default test case succeeds if type is overwritten with the value "payment.get".""") { + val json = JacksonCodecs.json() + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + isSecurePaymentConfirmation = Some(true), + clientDataJson = json.writeValueAsString( + json + .readTree(Defaults.clientDataJson) + .asInstanceOf[ObjectNode] + .set[ObjectNode]("type", new TextNode("payment.get")) + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step11 = + steps.begin.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + } + + it("""any value other than "payment.get" fails.""") { + forAll { (typeString: String) => + whenever(typeString != "payment.get") { + assertFails( + typeString, + isSecurePaymentConfirmation = Some(true), + ) + } + } + forAll(Gen.alphaNumStr) { (typeString: String) => + whenever(typeString != "payment.get") { + assertFails( + typeString, + isSecurePaymentConfirmation = Some(true), + ) + } + } + } + + it("""the string "webauthn.create" fails.""") { + assertFails( + "webauthn.create", + isSecurePaymentConfirmation = Some(true), + ) + } + + it("""the string "webauthn.get" fails.""") { + assertFails( + "webauthn.get", + isSecurePaymentConfirmation = Some(true), + ) + } + } + } + + it("12. Verify that the value of C.challenge equals the base64url encoding of options.challenge.") { + val steps = + finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + challenge = new ByteArray(Array.fill(16)(0)), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step12 = + steps.begin.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + describe("13. Verify that the value of C.origin matches the Relying Party's origin.") { + def checkAccepted( + origin: String, + origins: Option[Set[String]] = None, + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + ): Unit = { + val clientDataJson: String = Defaults.clientDataJson.replace( + "\"https://localhost\"", + "\"" + origin + "\"", + ) + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + clientDataJson = clientDataJson, + origins = origins, + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step13 = + steps.begin.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + def checkRejected( + origin: String, + origins: Option[Set[String]] = None, + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + ): Unit = { + val clientDataJson: String = Defaults.clientDataJson.replace( + "\"https://localhost\"", + "\"" + origin + "\"", + ) + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ), + clientDataJson = clientDataJson, + origins = origins, + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step13 = + steps.begin.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Fails if origin is different.") { + checkRejected(origin = "https://root.evil") + } + + describe("Explicit ports are") { + val origin = "https://localhost:8080" + + it("by default not allowed.") { + checkRejected(origin = origin) + } + + it("allowed if RP opts in to it.") { + checkAccepted(origin = origin, allowOriginPort = true) + } + } + + describe("Subdomains are") { + val origin = "https://foo.localhost" + + it("by default not allowed.") { + checkRejected(origin = origin) + } + + it("allowed if RP opts in to it.") { + checkAccepted(origin = origin, allowOriginSubdomain = true) + } + } + + describe("Subdomains and explicit ports at the same time are") { + val origin = "https://foo.localhost:8080" + + it("by default not allowed.") { + checkRejected(origin = origin) + } + + it("not allowed if only subdomains are allowed.") { + checkRejected(origin = origin, allowOriginSubdomain = true) + } + + it("not allowed if only explicit ports are allowed.") { + checkRejected(origin = origin, allowOriginPort = true) + } + + it("allowed if RP opts in to both.") { + checkAccepted( + origin = origin, + allowOriginPort = true, + allowOriginSubdomain = true, + ) + } + } + + describe("The examples in JavaDoc are correct:") { + def check( + origins: Set[String], + acceptOrigins: Iterable[String], + rejectOrigins: Iterable[String], + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + ): Unit = { + for { origin <- acceptOrigins } { + it(s"${origin} is accepted.") { + checkAccepted( + origin = origin, + origins = Some(origins), + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + } + } + + for { origin <- rejectOrigins } { + it(s"${origin} is rejected.") { + checkRejected( + origin = origin, + origins = Some(origins), + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + } + } + } + + describe("For allowOriginPort:") { + val origins = Set( + "https://example.org", + "https://accounts.example.org", + "https://acme.com:8443", + ) + + describe("false,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://accounts.example.org", + "https://acme.com:8443", + ), + rejectOrigins = List( + "https://example.org:8443", + "https://shop.example.org", + "https://acme.com", + "https://acme.com:9000", + ), + allowOriginPort = false, + ) + } + + describe("true,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://example.org:8443", + "https://accounts.example.org", + "https://acme.com", + "https://acme.com:8443", + "https://acme.com:9000", + ), + rejectOrigins = List( + "https://shop.example.org" + ), + allowOriginPort = true, + ) + } + } + + describe("For allowOriginSubdomain:") { + val origins = Set("https://example.org", "https://acme.com:8443") + + describe("false,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://acme.com:8443", + ), + rejectOrigins = List( + "https://example.org:8443", + "https://accounts.example.org", + "https://acme.com", + "https://shop.acme.com:8443", + ), + allowOriginSubdomain = false, + ) + } + + describe("true,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://accounts.example.org", + "https://acme.com:8443", + "https://shop.acme.com:8443", + ), + rejectOrigins = List( + "https://example.org:8443", + "https://acme.com", + ), + allowOriginSubdomain = true, + ) + } + } + } + } + + describe("14. Verify that the value of C.tokenBinding.status matches the state of Token Binding for the TLS connection over which the attestation was obtained.") { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + + it("Verification succeeds if neither side uses token binding ID.") { + val steps = finishAssertion( + credentialRepository = credentialRepository + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification succeeds if client data specifies token binding is unsupported, and RP does not use it.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification succeeds if client data specifies token binding is supported, and RP does not use it.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"supported"},"type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification fails if client data does not specify token binding status and RP specifies token binding ID.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification succeeds if client data does not specify token binding status and RP does not specify token binding ID.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = None, + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification fails if client data specifies token binding ID but RP does not.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"present","id":"YELLOWSUBMARINE"},"type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = None, + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + describe("If Token Binding was used on that TLS connection, also verify that C.tokenBinding.id matches the base64url encoding of the Token Binding ID for the connection.") { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + + it("Verification succeeds if both sides specify the same token binding ID.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"present","id":"YELLOWSUBMARINE"},"type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification fails if ID is missing from tokenBinding in client data.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"present"},"type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification fails if RP specifies token binding ID but client does not support it.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification fails if RP specifies token binding ID but client does not use it.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"supported"},"type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification fails if client data and RP specify different token binding IDs.") { + val clientDataJson = + """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","tokenBinding":{"status":"present","id":"YELLOWSUBMARINE"},"type":"webauthn.get"}""" + val steps = finishAssertion( + credentialRepository = credentialRepository, + callerTokenBindingId = + Some(ByteArray.fromBase64Url("ORANGESUBMARINE")), + clientDataJson = clientDataJson, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step14 = + steps.begin.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + describe("15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.") { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + + it("Fails if RP ID is different.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + rpId = Defaults.rpId.toBuilder.id("root.evil").build(), + origins = Some(Set("https://localhost")), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step15 = + steps.begin.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Succeeds if RP ID is the same.") { + val steps = finishAssertion( + credentialRepository = credentialRepository + ) + val step: FinishAssertionSteps[CredentialRecord]#Step15 = + steps.begin.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + describe("When using the appid extension, it") { + val appid = new AppId("https://test.example.org/foo") + val extensions = AssertionExtensionInputs + .builder() + .appid(Some(appid).toJava) + .build() + + it("fails if RP ID is different.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensions, + authenticatorData = new ByteArray( + Array.fill[Byte](32)(0) ++ Defaults.authenticatorData.getBytes + .drop(32) + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step15 = + steps.begin.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("succeeds if RP ID is the SHA-256 hash of the standard RP ID.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensions, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step15 = + steps.begin.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("succeeds if RP ID is the SHA-256 hash of the appid.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensions, + authenticatorData = new ByteArray( + sha256( + appid.getId + ).getBytes ++ Defaults.authenticatorData.getBytes.drop(32) + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step15 = + steps.begin.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + } + + { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + + def checks[ + Next <: FinishAssertionSteps.Step[CredentialRecord, _], + Step <: FinishAssertionSteps.Step[CredentialRecord, Next], + ]( + stepsToStep: FinishAssertionSteps[CredentialRecord] => Step + ) = { + def check[Ret]( + stepsToStep: FinishAssertionSteps[CredentialRecord] => Step + )( + chk: Step => Ret + )(uvr: UserVerificationRequirement, authData: ByteArray): Ret = { + val steps = finishAssertion( + credentialRepository = credentialRepository, + userVerificationRequirement = uvr, + authenticatorData = authData, + ) + chk(stepsToStep(steps)) + } + def checkFailsWith( + stepsToStep: FinishAssertionSteps[CredentialRecord] => Step + ): (UserVerificationRequirement, ByteArray) => Unit = + check(stepsToStep) { step => + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + def checkSucceedsWith( + stepsToStep: FinishAssertionSteps[CredentialRecord] => Step + ): (UserVerificationRequirement, ByteArray) => Unit = + check(stepsToStep) { step => + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + (checkFailsWith(stepsToStep), checkSucceedsWith(stepsToStep)) + } + + describe("16. Verify that the User Present bit of the flags in authData is set.") { + val flagOn: ByteArray = new ByteArray( + Defaults.authenticatorData.getBytes.toVector + .updated( + 32, + (Defaults.authenticatorData.getBytes + .toVector(32) | 0x04 | 0x01).toByte, + ) + .toArray + ) + val flagOff: ByteArray = new ByteArray( + Defaults.authenticatorData.getBytes.toVector + .updated( + 32, + ((Defaults.authenticatorData.getBytes + .toVector(32) | 0x04) & 0xfe).toByte, + ) + .toArray + ) + val (checkFails, checkSucceeds) = + checks[FinishAssertionSteps[ + CredentialRecord + ]#Step17, FinishAssertionSteps[CredentialRecord]#Step16]( + _.begin.next.next.next.next.next.next.next.next.next.next + ) + + it("Fails if UV is discouraged and flag is not set.") { + checkFails(UserVerificationRequirement.DISCOURAGED, flagOff) + } + + it("Succeeds if UV is discouraged and flag is set.") { + checkSucceeds(UserVerificationRequirement.DISCOURAGED, flagOn) + } + + it("Fails if UV is preferred and flag is not set.") { + checkFails(UserVerificationRequirement.PREFERRED, flagOff) + } + + it("Succeeds if UV is preferred and flag is set.") { + checkSucceeds(UserVerificationRequirement.PREFERRED, flagOn) + } + + it("Fails if UV is required and flag is not set.") { + checkFails(UserVerificationRequirement.REQUIRED, flagOff) + } + + it("Succeeds if UV is required and flag is set.") { + checkSucceeds(UserVerificationRequirement.REQUIRED, flagOn) + } + } + + describe("17. If user verification is required for this assertion, verify that the User Verified bit of the flags in authData is set.") { + val flagOn: ByteArray = new ByteArray( + Defaults.authenticatorData.getBytes.toVector + .updated( + 32, + (Defaults.authenticatorData.getBytes + .toVector(32) | 0x04).toByte, + ) + .toArray + ) + val flagOff: ByteArray = new ByteArray( + Defaults.authenticatorData.getBytes.toVector + .updated( + 32, + (Defaults.authenticatorData.getBytes + .toVector(32) & 0xfb).toByte, + ) + .toArray + ) + val (checkFails, checkSucceeds) = + checks[FinishAssertionSteps[ + CredentialRecord + ]#PendingStep16, FinishAssertionSteps[CredentialRecord]#Step17]( + _.begin.next.next.next.next.next.next.next.next.next.next.next + ) + + it("Succeeds if UV is discouraged and flag is not set.") { + checkSucceeds(UserVerificationRequirement.DISCOURAGED, flagOff) + } + + it("Succeeds if UV is discouraged and flag is set.") { + checkSucceeds(UserVerificationRequirement.DISCOURAGED, flagOn) + } + + it("Succeeds if UV is preferred and flag is not set.") { + checkSucceeds(UserVerificationRequirement.PREFERRED, flagOff) + } + + it("Succeeds if UV is preferred and flag is set.") { + checkSucceeds(UserVerificationRequirement.PREFERRED, flagOn) + } + + it("Fails if UV is required and flag is not set.") { + checkFails(UserVerificationRequirement.REQUIRED, flagOff) + } + + it("Succeeds if UV is required and flag is set.") { + checkSucceeds(UserVerificationRequirement.REQUIRED, flagOn) + } + } + } + + describe("(NOT YET MATURE) 16. If the credential backup state is used as part of Relying Party business logic or policy, let currentBe and currentBs be the values of the BE and BS bits, respectively, of the flags in authData. Compare currentBe and currentBs with credentialRecord.BE and credentialRecord.BS and apply Relying Party policy, if any.") { + it( + "Fails if BE=0 in the stored credential and BE=1 in the assertion." + ) { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + be = Some(false), + bs = Some(false), + ) + forAll( + authenticatorDataBytes( + Gen.option(Extensions.authenticatorAssertionExtensionOutputs()), + rpIdHashGen = Gen.const(sha256(Defaults.rpId.getId)), + backupFlagsGen = arbitrary[Boolean].map(bs => (true, bs)), + ) + ) { authData => + val step: FinishAssertionSteps[CredentialRecord]#PendingStep16 = + finishAssertion( + credentialRepository = credentialRepository, + authenticatorData = authData, + ).begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + } + + it( + "Fails if BE=1 in the stored credential and BE=0 in the assertion." + ) { + forAll( + authenticatorDataBytes( + Gen.option( + Extensions.authenticatorAssertionExtensionOutputs() + ), + rpIdHashGen = Gen.const(sha256(Defaults.rpId.getId)), + backupFlagsGen = Gen.const((false, false)), + ), + arbitrary[Boolean], + ) { + case (authData, storedBs) => + val step: FinishAssertionSteps[CredentialRecord]#PendingStep16 = + finishAssertion( + credentialRepository = + Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + be = Some(true), + bs = Some(storedBs), + ), + authenticatorData = authData, + ).begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + describe("18. Verify that the values of the client extension outputs in clientExtensionResults and the authenticator extension outputs in the extensions in authData are as expected, considering the client extension input values that were given in options.extensions and any specific policy of the Relying Party regarding unsolicited extensions, i.e., those that were not specified as part of options.extensions. In the general case, the meaning of \"are as expected\" is specific to the Relying Party and which extensions are in use.") { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + + it("Succeeds if clientExtensionResults is not a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.unrequestedClientAssertionExtensions) { + case (extensionInputs, clientExtensionOutputs, _) => + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensionInputs, + clientExtensionResults = clientExtensionOutputs, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step18 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + it("Succeeds if clientExtensionResults is a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.subsetAssertionExtensions) { + case (extensionInputs, clientExtensionOutputs, _) => + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensionInputs, + clientExtensionResults = clientExtensionOutputs, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step18 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + it("Succeeds if authenticator extensions is not a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.unrequestedAuthenticatorAssertionExtensions) { + case ( + extensionInputs: AssertionExtensionInputs, + _, + authenticatorExtensionOutputs: CBORObject, + ) => + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensionInputs, + authenticatorData = TestAuthenticator.makeAuthDataBytes( + extensionsCborBytes = Some( + new ByteArray( + authenticatorExtensionOutputs.EncodeToBytes() + ) + ) + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step18 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + it("Succeeds if authenticator extensions is a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.subsetAssertionExtensions) { + case ( + extensionInputs: AssertionExtensionInputs, + _, + authenticatorExtensionOutputs: CBORObject, + ) => + val steps = finishAssertion( + credentialRepository = credentialRepository, + requestedExtensions = extensionInputs, + authenticatorData = TestAuthenticator.makeAuthDataBytes( + extensionsCborBytes = Some( + new ByteArray( + authenticatorExtensionOutputs.EncodeToBytes() + ) + ) + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step18 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + } + + it("19. Let hash be the result of computing a hash over the cData using SHA-256.") { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = ByteArray.fromHex(""), + ) + ) + val step: FinishAssertionSteps[CredentialRecord]#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.clientDataJsonHash should equal( + new ByteArray( + MessageDigest + .getInstance("SHA-256") + .digest(Defaults.clientDataJsonBytes.getBytes) + ) + ) + } + + describe("20. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData and hash.") { + val credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = Defaults.credentialPublicKeyCose, + ) + + it("The default test case succeeds.") { + val steps = finishAssertion( + credentialRepository = credentialRepository + ) + val step: FinishAssertionSteps[CredentialRecord]#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.signedBytes should not be null + } + + it("A mutated clientDataJSON fails verification.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + clientDataJson = JacksonCodecs.json.writeValueAsString( + JacksonCodecs.json + .readTree(Defaults.clientDataJson) + .asInstanceOf[ObjectNode] + .set("foo", jsonFactory.textNode("bar")) + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("A test case with a different signed RP ID hash fails.") { + val rpId = "ARGHABLARGHLER" + val rpIdHash: ByteArray = Crypto.sha256(rpId) + val steps = finishAssertion( + credentialRepository = credentialRepository, + authenticatorData = new ByteArray( + (rpIdHash.getBytes.toVector ++ Defaults.authenticatorData.getBytes.toVector + .drop(32)).toArray + ), + rpId = Defaults.rpId.toBuilder.id(rpId).build(), + origins = Some(Set("https://localhost")), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("A test case with a different signed flags field fails.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + authenticatorData = new ByteArray( + Defaults.authenticatorData.getBytes.toVector + .updated( + 32, + (Defaults.authenticatorData.getBytes + .toVector(32) | 0x02).toByte, + ) + .toArray + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("A test case with a different signed signature counter fails.") { + val steps = finishAssertion( + credentialRepository = credentialRepository, + authenticatorData = new ByteArray( + Defaults.authenticatorData.getBytes.toVector + .updated(33, 42.toByte) + .toArray + ), + ) + val step: FinishAssertionSteps[CredentialRecord]#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("21. Let storedSignCount be the stored signature counter value associated with credential.id. If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:") { + describe("If authData.signCount is") { + def credentialRepository(signatureCount: Long) = + Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = Defaults.credentialPublicKeyCose, + signatureCount = signatureCount, + ) + + describe( + "zero, then the stored signature counter value must also be zero." + ) { + val authenticatorData = new ByteArray( + Defaults.authenticatorData.getBytes + .updated(33, 0: Byte) + .updated(34, 0: Byte) + .updated(35, 0: Byte) + .updated(36, 0: Byte) + ) + val signature = TestAuthenticator.makeAssertionSignature( + authenticatorData, + Crypto.sha256(Defaults.clientDataJsonBytes), + Defaults.credentialKey.getPrivate, + ) + + it("Succeeds if the stored signature counter value is zero.") { + val cr = credentialRepository(0) + val steps = finishAssertion( + credentialRepository = cr, + authenticatorData = authenticatorData, + signature = signature, + validateSignatureCounter = true, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.next.resultV2.get.isSignatureCounterValid should be(true) + step.next.resultV2.get.getSignatureCount should be(0) + } + + it("Fails if the stored signature counter value is nonzero.") { + val cr = credentialRepository(1) + val steps = finishAssertion( + credentialRepository = cr, + authenticatorData = authenticatorData, + signature = signature, + validateSignatureCounter = true, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.tryNext.failed.get shouldBe an[ + InvalidSignatureCountException + ] + } + } + + describe("greater than storedSignCount:") { + val cr = credentialRepository(1336) + + describe( + "Update storedSignCount to be the value of authData.signCount." + ) { + it("An increasing signature counter always succeeds.") { + val steps = finishAssertion( + credentialRepository = cr, + validateSignatureCounter = true, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.next.resultV2.get.isSignatureCounterValid should be(true) + step.next.resultV2.get.getSignatureCount should be(1337) + } + } + } + + describe("less than or equal to storedSignCount:") { + val cr = credentialRepository(1337) + + describe("This is a signal that the authenticator may be cloned, i.e. at least two copies of the credential private key may exist and are being used in parallel. Relying Parties should incorporate this information into their risk scoring. Whether the Relying Party updates storedSignCount in this case, or not, or fails the authentication ceremony or not, is Relying Party-specific.") { + it("If signature counter validation is disabled, a nonincreasing signature counter succeeds.") { + val steps = finishAssertion( + credentialRepository = cr, + validateSignatureCounter = false, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.next.resultV2.get.isSignatureCounterValid should be( + false + ) + step.next.resultV2.get.getSignatureCount should be(1337) + } + + it("If signature counter validation is enabled, a nonincreasing signature counter fails.") { + val steps = finishAssertion( + credentialRepository = cr, + validateSignatureCounter = true, + ) + val step: FinishAssertionSteps[CredentialRecord]#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + val result = Try(step.run()) + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + InvalidSignatureCountException + ] + step.tryNext shouldBe a[Failure[_]] + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[InvalidSignatureCountException] + result.failed.get + .asInstanceOf[InvalidSignatureCountException] + .getExpectedMinimum should equal(1338) + result.failed.get + .asInstanceOf[InvalidSignatureCountException] + .getReceived should equal(1337) + result.failed.get + .asInstanceOf[InvalidSignatureCountException] + .getCredentialId should equal(Defaults.credentialId) + } + } + } + } + } + + it("22. If all the above steps are successful, continue with the authentication ceremony as appropriate. Otherwise, fail the authentication ceremony.") { + val steps = finishAssertion( + credentialRepository = Helpers.CredentialRepositoryV2.withUser( + Defaults.user, + credentialId = Defaults.credentialId, + publicKeyCose = Defaults.credentialPublicKeyCose, + ) + ) + val step: FinishAssertionSteps[CredentialRecord]#Finished = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + Try(steps.runV2) shouldBe a[Success[_]] + + step.resultV2.get.isSuccess should be(true) + step.resultV2.get.getCredential.getCredentialId should equal( + Defaults.credentialId + ) + step.resultV2.get.getCredential.getUserHandle should equal( + Defaults.userHandle + ) + step.resultV2.get.getCredential.getCredentialId should equal( + step.resultV2.get.getCredential.getCredentialId + ) + step.resultV2.get.getCredential.getUserHandle should equal( + step.resultV2.get.getCredential.getUserHandle + ) + step.resultV2.get.getCredential.getPublicKeyCose should not be null + } + } + } + + describe("RelyingParty supports authenticating") { + it("a real RSA key.") { + val testData = RegistrationTestData.Packed.BasicAttestationRsaReal + + val credData = + testData.response.getResponse.getAttestation.getAuthenticatorData.getAttestedCredentialData.get + val credId: ByteArray = credData.getCredentialId + val publicKeyBytes: ByteArray = credData.getCredentialPublicKey + + val request: AssertionRequest = AssertionRequest + .builder() + .publicKeyCredentialRequestOptions( + JacksonCodecs.json.readValue( + """{ + "challenge": "drdVqKT0T-9PyQfkceSE94Q8ruW2I-w1gsamBisjuMw", + "rpId": "demo3.yubico.test", + "userVerification": "preferred", + "extensions": { + "appid": "https://demo3.yubico.test:8443" + } + }""", + classOf[PublicKeyCredentialRequestOptions], + ) + ) + .username(testData.userId.getName) + .build() + + val response: PublicKeyCredential[ + AuthenticatorAssertionResponse, + ClientAssertionExtensionOutputs, + ] = JacksonCodecs.json.readValue( + """{ + "type": "public-key", + "id": "ClvGfsNH8ulYnrKNd4fEgQ", + "response": { + "authenticatorData": "AU4Ai_91hLmkf2mxjxj_SJrA3qTIOjr6tw1rluqSp_4FAAAABA", + "clientDataJSON": "ew0KCSJ0eXBlIiA6ICJ3ZWJhdXRobi5nZXQiLA0KCSJjaGFsbGVuZ2UiIDogImRyZFZxS1QwVC05UHlRZmtjZVNFOTRROHJ1VzJJLXcxZ3NhbUJpc2p1TXciLA0KCSJvcmlnaW4iIDogImh0dHBzOi8vZGVtbzMueXViaWNvLnRlc3Q6ODQ0MyIsDQoJInRva2VuQmluZGluZyIgOiANCgl7DQoJCSJzdGF0dXMiIDogInN1cHBvcnRlZCINCgl9DQp9", + "signature": "1YYgnM1Nau6FQV2YK1qZDaoF6CHkFSxhaWac00dJNQemQueU_a1wE0hYy-g0O-ZwKn_MTtmfnwgjHxTRZx6v51eiuBpy-FlfkMmQHkz26MKKnQOK0Mc4kVjugvM0XlQ7E0hvsrdvVlmrwYc-U2IVfgRUw5rD-SbUctA_ZXc248LjyrgD_vhDWLR6I4nzmH_pe2tgKAQgohmzD4kVpVzS_T_M4Bn0Vcc5oUwNU4m57DiWDWCAR5BohKdajRgt8DUqBp9jvn9mgStIhEq1EIjhGdEE47WxVJaQb5IdHRaCNJ186x_ilsQvGT2Iy4s5C8IOkuffw07GesdpmJ8awtiA4A", + "userHandle": "NiBJtVMh4AmSpZYuJ--jnEWgFzZHHVbS6zx7HFgAjAc" + }, + "clientExtensionResults": { + "appid": false + } + }""", + new TypeReference[PublicKeyCredential[ + AuthenticatorAssertionResponse, + ClientAssertionExtensionOutputs, + ]]() {}, + ) + + val credRepo = Helpers.CredentialRepositoryV2.withUser( + testData.userId, + credentialId = testData.response.getId, + publicKeyCose = publicKeyBytes, + ) + val usernameRepo = Helpers.UsernameRepository.withUsers(testData.userId) + + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("demo3.yubico.test") + .name("Yubico WebAuthn demo") + .build() + ) + .credentialRepositoryV2(credRepo) + .usernameRepository(usernameRepo) + .origins(Set("https://demo3.yubico.test:8443").asJava) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(response) + .build() + ) + + result.isSuccess should be(true) + result.getCredential.getUserHandle should equal(testData.userId.getId) + result.getCredential.getCredentialId should equal(credId) + } + + it("an Ed25519 key.") { + val registrationRequest = JacksonCodecs + .json() + .readValue( + """ + |{ + | "rp": { + | "name": "Yubico WebAuthn demo", + | "id": "demo3.yubico.test" + | }, + | "user": { + | "name": "foo", + | "displayName": "Foo Bar", + | "id": "a2jHKZU9PDuGzwGaRQ5fVc8b_B3cfIOMZEiesm0Z-g0" + | }, + | "challenge": "FFDZDypegliApKZXF8XCHCn2SlMy4BVupeOFXDSr1uE", + | "pubKeyCredParams": [ + | { + | "alg": -8, + | "type": "public-key" + | } + | ], + | "excludeCredentials": [], + | "authenticatorSelection": { + | "requireResidentKey": false, + | "userVerification": "preferred" + | }, + | "attestation": "direct", + | "extensions": {} + |} + """.stripMargin, + classOf[PublicKeyCredentialCreationOptions], + ) + val registrationResponse = + PublicKeyCredential.parseRegistrationResponseJson(""" + |{ + | "type": "public-key", + | "id": "PMEuc5FHylmDzH9BgG0lf_YqsOKKspino-b5ybq8CD0mpwU3Q4S4oUMQd_CgQsJOR3qyv3HirclQM2lNIiyi3dytZ6p-zbfBxDCH637qWTTZTZfKPxKBsdEOVPMBPopU_9uNXKh9dTxqe4mpSuznjxV-cEMF3BU3CSnJDU1BOCM", + | "response": { + | "attestationObject": "o2NmbXRmcGFja2VkaGF1dGhEYXRhWOEBTgCL_3WEuaR_abGPGP9ImsDepMg6Ovq3DWuW6pKn_kUAAAAC-KAR84wKTRWABhcRH57cfQCAPMEuc5FHylmDzH9BgG0lf_YqsOKKspino-b5ybq8CD0mpwU3Q4S4oUMQd_CgQsJOR3qyv3HirclQM2lNIiyi3dytZ6p-zbfBxDCH637qWTTZTZfKPxKBsdEOVPMBPopU_9uNXKh9dTxqe4mpSuznjxV-cEMF3BU3CSnJDU1BOCOkAQEDJyAGIVggSRLgxGS7m40dHlC9RGF4pzIj4V03KEVLj1iZ8-4zpgFnYXR0U3RtdKNjYWxnJmNzaWdYRzBFAiA6fyJf8gJc5N0fUJtpKckvc6jg0SJitLYVbzA3bl5uBgIhAI11DQDK7c0nhJGh5ElJzhTOcvvTovCAd31CZ_6ZsdrJY3g1Y4FZAmgwggJkMIIBTKADAgECAgQHL7bPMA0GCSqGSIb3DQEBCwUAMA8xDTALBgNVBAMMBHRlc3QwHhcNMTkwNDI0MTExMDAyWhcNMjAwNDIzMTExMDAyWjBuMQswCQYDVQQGEwJTRTESMBAGA1UECgwJWXViaWNvIEFCMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMScwJQYDVQQDDB5ZdWJpY28gVTJGIEVFIFNlcmlhbCAxMjA1Njc1MDMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATFcdVF_m2S3VTnMBABD0ZO8b4dvbqdr7a9zxLi9VBkR5YPakd2coJoFiuEcEuRhNJwSXlJlDX8q3Y-dY_Qp1XYozQwMjAiBgkrBgEEAYLECgIEFTEuMy42LjEuNC4xLjQxNDgyLjEuMjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBm6U8jEfxKn5WqNe1r7LNlq80RVYQraj1V90Z-a1BFKEEDtRzmoNEGlaUVbmYrdv5u4lWd1abiSq7hWc4H7uTklC8wUt9F1qnSjDWkK45cYjwMpTtRavAQtX00R-8g1orIdSMAVsJ1RG-gqlvJhQWvlWQk8fHRBQ74MzVgUhutu74CgL8_-QjH1_2yEkAndj6slsTyNOCv2n60jJNzT9dk6oYE9HyvOuhYTc0IBAR5XsWQj1XXOof9CnARaC7C0P2Tn1yW0wjeP5St4i2aKuoL5tsaaSVk11hZ6XF2kjKjjqjow9uTyVIrn1NH-kwHf0cZSkPExkHLIl1JDtpMCE5R", + | "clientDataJSON": "ew0KCSJ0eXBlIiA6ICJ3ZWJhdXRobi5jcmVhdGUiLA0KCSJjaGFsbGVuZ2UiIDogIkZGRFpEeXBlZ2xpQXBLWlhGOFhDSENuMlNsTXk0QlZ1cGVPRlhEU3IxdUUiLA0KCSJvcmlnaW4iIDogImh0dHBzOi8vZGVtbzMueXViaWNvLnRlc3Q6ODQ0MyIsDQoJInRva2VuQmluZGluZyIgOiANCgl7DQoJCSJzdGF0dXMiIDogInN1cHBvcnRlZCINCgl9DQp9" + | }, + | "clientExtensionResults": {} + |} + | + """.stripMargin) + + val assertionRequest = JacksonCodecs + .json() + .readValue( + """{ + | "challenge": "YK17iD3fpOQKPSU6bxIU-TFBj1HNVSrX5bX5Pzj-SHQ", + | "rpId": "demo3.yubico.test", + | "allowCredentials": [ + | { + | "type": "public-key", + | "id": "PMEuc5FHylmDzH9BgG0lf_YqsOKKspino-b5ybq8CD0mpwU3Q4S4oUMQd_CgQsJOR3qyv3HirclQM2lNIiyi3dytZ6p-zbfBxDCH637qWTTZTZfKPxKBsdEOVPMBPopU_9uNXKh9dTxqe4mpSuznjxV-cEMF3BU3CSnJDU1BOCM" + | } + | ], + | "userVerification": "preferred", + | "extensions": { + | "appid": "https://demo3.yubico.test:8443" + | } + |} + |""".stripMargin, + classOf[PublicKeyCredentialRequestOptions], + ) + val assertionResponse = PublicKeyCredential.parseAssertionResponseJson( + """ + |{ + | "type": "public-key", + | "id": "PMEuc5FHylmDzH9BgG0lf_YqsOKKspino-b5ybq8CD0mpwU3Q4S4oUMQd_CgQsJOR3qyv3HirclQM2lNIiyi3dytZ6p-zbfBxDCH637qWTTZTZfKPxKBsdEOVPMBPopU_9uNXKh9dTxqe4mpSuznjxV-cEMF3BU3CSnJDU1BOCM", + | "response": { + | "authenticatorData": "AU4Ai_91hLmkf2mxjxj_SJrA3qTIOjr6tw1rluqSp_4FAAAACA", + | "clientDataJSON": "ew0KCSJ0eXBlIiA6ICJ3ZWJhdXRobi5nZXQiLA0KCSJjaGFsbGVuZ2UiIDogIllLMTdpRDNmcE9RS1BTVTZieElVLVRGQmoxSE5WU3JYNWJYNVB6ai1TSFEiLA0KCSJvcmlnaW4iIDogImh0dHBzOi8vZGVtbzMueXViaWNvLnRlc3Q6ODQ0MyIsDQoJInRva2VuQmluZGluZyIgOiANCgl7DQoJCSJzdGF0dXMiIDogInN1cHBvcnRlZCINCgl9DQp9", + | "signature": "YWVfTS-0-j6mRFG_fYBN9ApkhgjH89hyOVGaOuqxazXv1jA3YBQjoTurN43PebHPXDC6gNxjATUGxMvCq2t5Dg", + | "userHandle": null + | }, + | "clientExtensionResults": { + | "appid": false + | } + |} + """.stripMargin + ) + + val credData = + registrationResponse.getResponse.getAttestation.getAuthenticatorData.getAttestedCredentialData.get + val credId: ByteArray = credData.getCredentialId + val publicKeyBytes: ByteArray = credData.getCredentialPublicKey + + val credRepo = Helpers.CredentialRepositoryV2.withUser( + registrationRequest.getUser, + credentialId = registrationResponse.getId, + publicKeyCose = publicKeyBytes, + ) + val usernameRepo = + Helpers.UsernameRepository.withUsers(registrationRequest.getUser) + + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("demo3.yubico.test") + .name("Yubico WebAuthn demo") + .build() + ) + .credentialRepositoryV2(credRepo) + .usernameRepository(usernameRepo) + .origins(Set("https://demo3.yubico.test:8443").asJava) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request( + AssertionRequest + .builder() + .publicKeyCredentialRequestOptions(assertionRequest) + .username(registrationRequest.getUser.getName) + .build() + ) + .response(assertionResponse) + .build() + ) + + result.isSuccess should be(true) + result.getCredential.getUserHandle should equal( + registrationRequest.getUser.getId + ) + result.getCredential.getCredentialId should equal(credId) + } + + it("a generated Ed25519 key.") { + val registrationTestData = + RegistrationTestData.Packed.BasicAttestationEdDsa + val testData = registrationTestData.assertion.get + + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity.builder().id("localhost").name("Test RP").build() + ) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + registrationTestData.userId, + credentialId = registrationTestData.response.getId, + publicKeyCose = + registrationTestData.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey, + ) + ) + .usernameRepository( + Helpers.UsernameRepository.withUsers(registrationTestData.userId) + ) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(testData.request) + .response(testData.response) + .build() + ) + + result.isSuccess should be(true) + result.getCredential.getUserHandle should equal( + registrationTestData.userId.getId + ) + result.getCredential.getCredentialId should equal( + registrationTestData.response.getId + ) + result.getCredential.getCredentialId should equal( + testData.response.getId + ) + } + + describe("an RS1 key") { + def test(registrationTestData: RegistrationTestData): Unit = { + val testData = registrationTestData.assertion.get + + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Test RP") + .build() + ) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + registrationTestData.userId, + credentialId = registrationTestData.response.getId, + publicKeyCose = + registrationTestData.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey, + ) + ) + .usernameRepository( + Helpers.UsernameRepository.withUsers(registrationTestData.userId) + ) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(testData.request) + .response(testData.response) + .build() + ) + + result.isSuccess should be(true) + result.getCredential.getUserHandle should equal( + registrationTestData.userId.getId + ) + result.getCredential.getCredentialId should equal( + registrationTestData.response.getId + ) + result.getCredential.getCredentialId should equal( + testData.response.getId + ) + } + + it("with basic attestation.") { + test(RegistrationTestData.Packed.BasicAttestationRs1) + } + it("with self attestation.") { + test(RegistrationTestData.Packed.SelfAttestationRs1) + } + } + + it("a U2F-formatted public key.") { + val testData = RealExamples.YubiKeyNeo.asRegistrationTestData + val x = ByteArray.fromHex( + "39C94FBBDDC694A925E6F8657C66916CFE84CD0222EDFCF281B21F5CDC347923" + ) + val y = ByteArray.fromHex( + "D6B0D2021CFE1724A6FE81E3568C4FFAE339298216A30AFC18C0B975F2E2A891" + ) + val u2fPubkey = ByteArray.fromHex("04").concat(x).concat(y) + + val rp = RelyingParty + .builder() + .identity(testData.rpId) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + testData.userId, + credentialId = testData.assertion.get.response.getId, + publicKeyCose = WebAuthnCodecs.rawEcKeyToCose(u2fPubkey), + ) + ) + .usernameRepository( + Helpers.UsernameRepository.withUsers(testData.userId) + ) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(testData.assertion.get.request) + .response(testData.assertion.get.response) + .build() + ) + + result.isSuccess should be(true) + result.getCredential.getUserHandle should equal(testData.userId.getId) + result.getCredential.getCredentialId should equal( + testData.response.getId + ) + } + } + + describe("The default RelyingParty settings") { + val testDataBase = RegistrationTestData.Packed.BasicAttestationEdDsa + val rp = RelyingParty + .builder() + .identity(testDataBase.rpId) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + testDataBase.userId, + credentialId = testDataBase.response.getId, + publicKeyCose = + testDataBase.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey, + ) + ) + .build() + + describe("support the largeBlob extension") { + it("for writing a blob.") { + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request( + testDataBase.assertion.get.request.toBuilder + .publicKeyCredentialRequestOptions( + testDataBase.assertion.get.request.getPublicKeyCredentialRequestOptions.toBuilder + .extensions( + AssertionExtensionInputs + .builder() + .largeBlob( + LargeBlobAuthenticationInput + .write(ByteArray.fromHex("00010203")) + ) + .build() + ) + .build() + ) + .userHandle(testDataBase.userId.getId) + .build() + ) + .response( + testDataBase.assertion.get.response.toBuilder + .clientExtensionResults( + ClientAssertionExtensionOutputs + .builder() + .largeBlob( + ReexportHelpers + .newLargeBlobAuthenticationOutput(None, Some(true)) + ) + .build() + ) + .build() + ) + .build() + ) + + result.getClientExtensionOutputs.get.getLargeBlob.get.getWritten.toScala should be( + Some(true) + ) + result.getClientExtensionOutputs.get.getLargeBlob.get.getBlob.toScala should be( + None + ) + } + + it("for reading a blob.") { + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request( + testDataBase.assertion.get.request.toBuilder + .publicKeyCredentialRequestOptions( + testDataBase.assertion.get.request.getPublicKeyCredentialRequestOptions.toBuilder + .extensions( + AssertionExtensionInputs + .builder() + .largeBlob(LargeBlobAuthenticationInput.read()) + .build() + ) + .build() + ) + .userHandle(testDataBase.userId.getId) + .build() + ) + .response( + testDataBase.assertion.get.response.toBuilder + .clientExtensionResults( + ClientAssertionExtensionOutputs + .builder() + .largeBlob( + ReexportHelpers.newLargeBlobAuthenticationOutput( + Some(ByteArray.fromHex("00010203")), + None, + ) + ) + .build() + ) + .build() + ) + .build() + ) + + result.getClientExtensionOutputs.get.getLargeBlob.get.getBlob.toScala should be( + Some(ByteArray.fromHex("00010203")) + ) + result.getClientExtensionOutputs.get.getLargeBlob.get.getWritten.toScala should be( + None + ) + } + } + + describe("support the uvm extension") { + it("at authentication time.") { + + // Example from spec: https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-uvm-extension + // A1 -- extension: CBOR map of one element + // 63 -- Key 1: CBOR text string of 3 bytes + // 75 76 6d -- "uvm" [=UTF-8 encoded=] string + // 82 -- Value 1: CBOR array of length 2 indicating two factor usage + // 83 -- Item 1: CBOR array of length 3 + // 02 -- Subitem 1: CBOR integer for User Verification Method Fingerprint + // 04 -- Subitem 2: CBOR short for Key Protection Type TEE + // 02 -- Subitem 3: CBOR short for Matcher Protection Type TEE + // 83 -- Item 2: CBOR array of length 3 + // 04 -- Subitem 1: CBOR integer for User Verification Method Passcode + // 01 -- Subitem 2: CBOR short for Key Protection Type Software + // 01 -- Subitem 3: CBOR short for Matcher Protection Type Software + val uvmCborExample = ByteArray.fromHex("A16375766d828302040283040101") + + val cred = TestAuthenticator.createAssertionFromTestData( + testDataBase, + testDataBase.assertion.get.request.getPublicKeyCredentialRequestOptions, + authenticatorExtensions = + Some(JacksonCodecs.cbor().readTree(uvmCborExample.getBytes)), + ) + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request( + testDataBase.assertion.get.request.toBuilder + .publicKeyCredentialRequestOptions( + testDataBase.assertion.get.request.getPublicKeyCredentialRequestOptions.toBuilder + .extensions( + AssertionExtensionInputs + .builder() + .uvm() + .build() + ) + .build() + ) + .userHandle(testDataBase.userId.getId) + .build() + ) + .response(cred) + .build() + ) + + result.getAuthenticatorExtensionOutputs.get.getUvm.toScala should equal( + Some( + List( + new UvmEntry( + UserVerificationMethod.USER_VERIFY_FINGERPRINT_INTERNAL, + KeyProtectionType.KEY_PROTECTION_TEE, + MatcherProtectionType.MATCHER_PROTECTION_TEE, + ), + new UvmEntry( + UserVerificationMethod.USER_VERIFY_PASSCODE_INTERNAL, + KeyProtectionType.KEY_PROTECTION_SOFTWARE, + MatcherProtectionType.MATCHER_PROTECTION_SOFTWARE, + ), + ).asJava + ) + ) + } + } + + describe("returns AssertionResponse which") { + { + val user = UserIdentity.builder + .name("foo") + .displayName("Foo User") + .id(new ByteArray(Array(0, 1, 2, 3))) + .build() + val (credential, credentialKeypair, _) = + TestAuthenticator.createUnattestedCredential() + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Example RP") + .build() + ) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.withUser( + user, + credentialId = credential.getId, + publicKeyCose = + credential.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey, + ) + ) + .usernameRepository(Helpers.UsernameRepository.withUsers(user)) + .build() + + val request = AssertionRequest + .builder() + .publicKeyCredentialRequestOptions( + PublicKeyCredentialRequestOptions + .builder() + .challenge(ByteArray.fromBase64Url("Y2hhbGxlbmdl")) + .rpId("localhost") + .build() + ) + .username(user.getName) + .build() + + it("exposes isUserVerified() with the UV flag value in authenticator data.") { + val pkcWithoutUv = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithUv = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x04.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + val resultWithoutUv = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithoutUv) + .build() + ) + val resultWithUv = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithUv) + .build() + ) + + resultWithoutUv.isUserVerified should be(false) + resultWithUv.isUserVerified should be(true) + } + + it("exposes isBackupEligible() with the BE flag value in authenticator data.") { + val pkcWithoutBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + val resultWithoutBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + val resultWithBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + + resultWithoutBackup.isBackupEligible should be(false) + resultWithBackup.isBackupEligible should be(true) + } + + it( + "exposes isBackedUp() with the BS flag value in authenticator data." + ) { + val pkcWithoutBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithBeOnly = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x18.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + val resultWithBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + val resultWithBeOnly = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithBeOnly) + .build() + ) + val resultWithoutBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + + resultWithoutBackup.isBackedUp should be(false) + resultWithBeOnly.isBackedUp should be(false) + resultWithBackup.isBackedUp should be(true) + } + + it( + "exposes getAuthenticatorAttachment() with the authenticatorAttachment value from the PublicKeyCredential." + ) { + val pkcTemplate = + TestAuthenticator.createAssertion( + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + forAll { authenticatorAttachment: Option[AuthenticatorAttachment] => + val pkc = pkcTemplate.toBuilder + .authenticatorAttachment(authenticatorAttachment.orNull) + .build() + + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkc) + .build() + ) + + result.getAuthenticatorAttachment should equal( + pkc.getAuthenticatorAttachment + ) + } + } + } + } + } + } + +} diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala new file mode 100644 index 000000000..45414b447 --- /dev/null +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala @@ -0,0 +1,4858 @@ +// Copyright (c) 2023, 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 com.yubico.webauthn + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.upokecenter.cbor.CBORObject +import com.yubico.internal.util.BinaryUtil +import com.yubico.internal.util.CertificateParser +import com.yubico.internal.util.JacksonCodecs +import com.yubico.webauthn.TestAuthenticator.AttestationCert +import com.yubico.webauthn.TestAuthenticator.AttestationMaker +import com.yubico.webauthn.TestAuthenticator.AttestationSigner +import com.yubico.webauthn.TpmAttestationStatementVerifier.Attributes +import com.yubico.webauthn.TpmAttestationStatementVerifier.TPM_ALG_NULL +import com.yubico.webauthn.TpmAttestationStatementVerifier.TpmRsaScheme +import com.yubico.webauthn.attestation.AttestationTrustSource +import com.yubico.webauthn.attestation.AttestationTrustSource.TrustRootsResult +import com.yubico.webauthn.data.AttestationObject +import com.yubico.webauthn.data.AttestationType +import com.yubico.webauthn.data.AuthenticatorAttachment +import com.yubico.webauthn.data.AuthenticatorAttestationResponse +import com.yubico.webauthn.data.AuthenticatorData +import com.yubico.webauthn.data.AuthenticatorDataFlags +import com.yubico.webauthn.data.AuthenticatorSelectionCriteria +import com.yubico.webauthn.data.AuthenticatorTransport +import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.COSEAlgorithmIdentifier +import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs +import com.yubico.webauthn.data.CollectedClientData +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationInput.LargeBlobSupport +import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry +import com.yubico.webauthn.data.Generators._ +import com.yubico.webauthn.data.PublicKeyCredential +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions +import com.yubico.webauthn.data.PublicKeyCredentialParameters +import com.yubico.webauthn.data.ReexportHelpers +import com.yubico.webauthn.data.ReexportHelpers.newCredentialPropertiesOutput +import com.yubico.webauthn.data.RegistrationExtensionInputs +import com.yubico.webauthn.data.RelyingPartyIdentity +import com.yubico.webauthn.data.UserIdentity +import com.yubico.webauthn.data.UserVerificationRequirement +import com.yubico.webauthn.exception.RegistrationFailedException +import com.yubico.webauthn.extension.uvm.KeyProtectionType +import com.yubico.webauthn.extension.uvm.MatcherProtectionType +import com.yubico.webauthn.extension.uvm.UserVerificationMethod +import com.yubico.webauthn.test.Helpers +import com.yubico.webauthn.test.RealExamples +import com.yubico.webauthn.test.Util.toStepWithUtilities +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERUTF8String +import org.bouncycastle.asn1.x500.AttributeTypeAndValue +import org.bouncycastle.asn1.x500.RDN +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNamesBuilder +import org.bouncycastle.cert.jcajce.JcaX500NameUtil +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.Gen +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.junit.JUnitRunner +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +import java.io.IOException +import java.math.BigInteger +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPair +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.Security +import java.security.SignatureException +import java.security.cert.CRL +import java.security.cert.CertStore +import java.security.cert.CollectionCertStoreParameters +import java.security.cert.PolicyNode +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util +import java.util.Collections +import java.util.Optional +import java.util.function.Predicate +import javax.security.auth.x500.X500Principal +import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters.RichOption +import scala.jdk.OptionConverters.RichOptional +import scala.util.Failure +import scala.util.Success +import scala.util.Try + +@RunWith(classOf[JUnitRunner]) +class RelyingPartyV2RegistrationSpec + extends AnyFunSpec + with Matchers + with ScalaCheckDrivenPropertyChecks + with TestWithEachProvider { + + private def jsonFactory: JsonNodeFactory = JsonNodeFactory.instance + private def toJsonObject(obj: Map[String, JsonNode]): JsonNode = + jsonFactory.objectNode().setAll(obj.asJava) + private def toJson(obj: Map[String, String]): JsonNode = + toJsonObject(obj.view.mapValues(jsonFactory.textNode).toMap) + + private def sha256(bytes: ByteArray): ByteArray = Crypto.sha256(bytes) + + def flipByte(index: Int, bytes: ByteArray): ByteArray = + editByte(bytes, index, b => (0xff ^ b).toByte) + def editByte(bytes: ByteArray, index: Int, updater: Byte => Byte): ByteArray = + new ByteArray( + bytes.getBytes.updated(index, updater(bytes.getBytes()(index))) + ) + + private def finishRegistration[C <: CredentialRecord]( + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + allowUntrustedAttestation: Boolean = false, + callerTokenBindingId: Option[ByteArray] = None, + credentialRepository: CredentialRepositoryV2[C] = + Helpers.CredentialRepositoryV2.unimplemented, + attestationTrustSource: Option[AttestationTrustSource] = None, + origins: Option[Set[String]] = None, + pubkeyCredParams: Option[List[PublicKeyCredentialParameters]] = None, + testData: RegistrationTestData, + clock: Clock = Clock.systemUTC(), + ): FinishRegistrationSteps = { + var builder = RelyingParty + .builder() + .identity(testData.rpId) + .credentialRepositoryV2(credentialRepository) + .allowOriginPort(allowOriginPort) + .allowOriginSubdomain(allowOriginSubdomain) + .allowUntrustedAttestation(allowUntrustedAttestation) + .clock(clock) + + attestationTrustSource.foreach { ats => + builder = builder.attestationTrustSource(ats) + } + + origins.map(_.asJava).foreach(builder.origins _) + + val fro = FinishRegistrationOptions + .builder() + .request( + pubkeyCredParams + .map(pkcp => + testData.request.toBuilder.pubKeyCredParams(pkcp.asJava).build() + ) + .getOrElse(testData.request) + ) + .response(testData.response) + .callerTokenBindingId(callerTokenBindingId.toJava) + .build() + + builder + .build() + ._finishRegistration(fro) + } + + val emptyTrustSource = new AttestationTrustSource { + override def findTrustRoots( + attestationCertificateChain: util.List[X509Certificate], + aaguid: Optional[ByteArray], + ): TrustRootsResult = + TrustRootsResult.builder().trustRoots(Collections.emptySet()).build() + } + def trustSourceWith( + trustedCert: X509Certificate, + crls: Option[Set[CRL]] = None, + enableRevocationChecking: Boolean = true, + policyTreeValidator: Option[Predicate[PolicyNode]] = None, + ): AttestationTrustSource = + (_: util.List[X509Certificate], _: Optional[ByteArray]) => { + TrustRootsResult + .builder() + .trustRoots(Collections.singleton(trustedCert)) + .certStore( + crls + .map(crls => + CertStore.getInstance( + "Collection", + new CollectionCertStoreParameters(crls.asJava), + ) + ) + .orNull + ) + .enableRevocationChecking(enableRevocationChecking) + .policyTreeValidator(policyTreeValidator.orNull) + .build() + } + + testWithEachProvider { it => + describe("§7.1. Registering a new credential") { + + describe("In order to perform a registration ceremony, the Relying Party MUST proceed as follows:") { + + describe("1. Let options be a new PublicKeyCredentialCreationOptions structure configured to the Relying Party's needs for the ceremony.") { + it("Nothing to test: applicable only to client side.") {} + } + + describe("2. Call navigator.credentials.create() and pass options as the publicKey option. Let credential be the result of the successfully resolved promise. If the promise is rejected, abort the ceremony with a user-visible error, or otherwise guide the user experience as might be determinable from the context available in the rejected promise. For example if the promise is rejected with an error code equivalent to \"InvalidStateError\", the user might be instructed to use a different authenticator. For information on different error contexts and the circumstances leading to them, see §6.3.2 The authenticatorMakeCredential Operation.") { + it("Nothing to test: applicable only to client side.") {} + } + + describe("3. Let response be credential.response.") { + it("If response is not an instance of AuthenticatorAttestationResponse, abort the ceremony with a user-visible error.") { + val testData = RegistrationTestData.Packed.BasicAttestationEdDsa + val frob = FinishRegistrationOptions + .builder() + .request(testData.request) + "frob.response(testData.response)" should compile + "frob.response(testData.assertion.get.response)" shouldNot compile + frob.response(testData.response).build() should not be null + } + } + + describe("4. Let clientExtensionResults be the result of calling credential.getClientExtensionResults().") { + it( + "The PublicKeyCredential class has a clientExtensionResults field" + ) { + val pkc = PublicKeyCredential.parseRegistrationResponseJson("""{ + "type": "public-key", + "id": "", + "response": { + "attestationObject": "o2NmbXRmcGFja2VkaGF1dGhEYXRhWQFXAU4Ai_91hLmkf2mxjxj_SJrA3qTIOjr6tw1rluqSp_5FAAAAAG1Eupv27C5JuTAMj-kgy3MAEApbxn7DR_LpWJ6yjXeHxIGkAQMDOQEAIFkBAPm_XOU-DioXdG6YXFo5gpHPNxJDimlbnXCro2D_hvzBsxoY4oEzNyRDgK_PoDedZ4tJyk12_I8qJ8g5HqbpT6YUekYegcP4ugL1Omr31gGqTwsF45fIITcSWXcoJbqPnwotbaM98Hu15mSIT8NeXDce0MVNYJ6PULRm6xiiWXHk1cxwrHd9xPCjww6CjRKDc06hP--noBbToW3xx43eh7kGlisWPeU1naIMe7CZAjIMhNlu_uxQssaPAhEXNzDENpK99ieUg290Ym4YNAGbWdW4irkeTt7h_yC-ARrJUu4ygwwGaqCTl9QIMrwZGuiQD11LC0uKraIA2YHaGa2UGKshQwEAAWdhdHRTdG10o2NhbGcmY3NpZ1hHMEUCIQDLKMt6O4aKJkl71VhyIcuI6lqyFTHMDuCO5Y4Jdq2_xQIgPm2_1GF0ivkR816opfVQMWq0s-Hx0uJjcX5l5tm9ZgFjeDVjgVkCwTCCAr0wggGloAMCAQICBCrnYmMwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG4xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJzAlBgNVBAMMHll1YmljbyBVMkYgRUUgU2VyaWFsIDcxOTgwNzA3NTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCoDhl5gQ9meEf8QqiVUV4S_Ca-Oax47MhcpIW9VEhqM2RDTmd3HaL3-SnvH49q8YubSRp_1Z1uP-okMynSGnj-jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBguUcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEG1Eupv27C5JuTAMj-kgy3MwDAYDVR0TAQH_BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAclfQPNzD4RVphJDW-A75W1MHI3PZ5kcyYysR3Nx3iuxr1ZJtB-F7nFQweI3jL05HtFh2_4xVIgKb6Th4eVcjMecncBaCinEbOcdP1sEli9Hk2eVm1XB5A0faUjXAPw_-QLFCjgXG6ReZ5HVUcWkB7riLsFeJNYitiKrTDXFPLy-sNtVNutcQnFsCerDKuM81TvEAigkIbKCGlq8M_NvBg5j83wIxbCYiyV7mIr3RwApHieShzLdJo1S6XydgQjC-_64G5r8C-8AVvNFR3zXXCpio5C3KRIj88HEEIYjf6h1fdLfqeIsq-cUUqbq5T-c4nNoZUZCysTB9v5EY4akp-A", + "clientDataJSON": "ew0KCSJ0eXBlIiA6ICJ3ZWJhdXRobi5jcmVhdGUiLA0KCSJjaGFsbGVuZ2UiIDogImxaMllKbUZ2YWkteGhYMElteG9fQlk1SkpVdmREa3JXd1ZGZllmcHQtNmciLA0KCSJvcmlnaW4iIDogImh0dHBzOi8vZGVtbzMueXViaWNvLnRlc3Q6ODQ0MyIsDQoJInRva2VuQmluZGluZyIgOiANCgl7DQoJCSJzdGF0dXMiIDogInN1cHBvcnRlZCINCgl9DQp9" + }, + "clientExtensionResults": { + "appidExclude": true, + "org.example.foo": "bar" + } + }""") + pkc.getClientExtensionResults.getExtensionIds should contain( + "appidExclude" + ) + } + } + + describe("5. Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON.") { + it("Fails if clientDataJSON is not valid UTF-8.") { + an[IOException] should be thrownBy new CollectedClientData( + new ByteArray(Array(-128)) + ) + } + } + + describe("6. Let C, the client data claimed as collected during the credential creation, be the result of running an implementation-specific JSON parser on JSONtext.") { + + it("Fails if clientDataJson is not valid JSON.") { + an[IOException] should be thrownBy new CollectedClientData( + new ByteArray("{".getBytes(Charset.forName("UTF-8"))) + ) + an[IOException] should be thrownBy finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation + .copy(clientDataJson = "{") + ) + } + + it("Succeeds if clientDataJson is valid JSON.") { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation.copy( + clientDataJson = """{ + "challenge": "", + "origin": "", + "type": "" + }""", + overrideRequest = + Some(RegistrationTestData.FidoU2f.BasicAttestation.request), + ) + ) + val step: FinishRegistrationSteps#Step6 = steps.begin + + step.validations shouldBe a[Success[_]] + step.clientData should not be null + step.tryNext shouldBe a[Success[_]] + } + } + + describe("7. Verify that the value of C.type is webauthn.create.") { + it("The default test case succeeds.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step7 = steps.begin.next + + step.validations shouldBe a[Success[_]] + } + + def assertFails(typeString: String): Unit = { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editClientData("type", typeString) + ) + val step: FinishRegistrationSteps#Step7 = steps.begin.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + } + + it("""Any value other than "webauthn.create" fails.""") { + forAll { (typeString: String) => + whenever(typeString != "webauthn.create") { + assertFails(typeString) + } + } + forAll(Gen.alphaNumStr) { (typeString: String) => + whenever(typeString != "webauthn.create") { + assertFails(typeString) + } + } + } + + it("""The string "webauthn.get" fails.""") { + assertFails("webauthn.get") + } + } + + it("8. Verify that the value of C.challenge equals the base64url encoding of options.challenge.") { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation.copy( + overrideRequest = Some( + RegistrationTestData.FidoU2f.BasicAttestation.request.toBuilder + .challenge(new ByteArray(Array.fill(16)(0))) + .build() + ) + ) + ) + val step: FinishRegistrationSteps#Step8 = steps.begin.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + describe("9. Verify that the value of C.origin matches the Relying Party's origin.") { + + def checkAccepted( + origin: String, + origins: Option[Set[String]] = None, + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + ): Unit = { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editClientData("origin", origin), + origins = origins, + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + val step: FinishRegistrationSteps#Step9 = steps.begin.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + def checkRejected( + origin: String, + origins: Option[Set[String]] = None, + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + ): Unit = { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editClientData("origin", origin), + origins = origins, + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + val step: FinishRegistrationSteps#Step9 = steps.begin.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Fails if origin is different.") { + checkRejected(origin = "https://root.evil") + } + + describe("Explicit ports are") { + val origin = "https://localhost:8080" + it("by default not allowed.") { + checkRejected(origin = origin) + } + + it("allowed if RP opts in to it.") { + checkAccepted(origin = origin, allowOriginPort = true) + } + } + + describe("Subdomains are") { + val origin = "https://foo.localhost" + + it("by default not allowed.") { + checkRejected(origin = origin) + } + + it("allowed if RP opts in to it.") { + checkAccepted(origin = origin, allowOriginSubdomain = true) + } + } + + describe("Subdomains and explicit ports at the same time are") { + val origin = "https://foo.localhost:8080" + + it("by default not allowed.") { + checkRejected(origin = origin) + } + + it("not allowed if only subdomains are allowed.") { + checkRejected( + origin = origin, + allowOriginPort = false, + allowOriginSubdomain = true, + ) + } + + it("not allowed if only explicit ports are allowed.") { + checkRejected( + origin = origin, + allowOriginPort = true, + allowOriginSubdomain = false, + ) + } + + it("allowed if RP opts in to both.") { + checkAccepted( + origin = origin, + allowOriginPort = true, + allowOriginSubdomain = true, + ) + } + } + + describe("The examples in JavaDoc are correct:") { + def check( + origins: Set[String], + acceptOrigins: Iterable[String], + rejectOrigins: Iterable[String], + allowOriginPort: Boolean = false, + allowOriginSubdomain: Boolean = false, + ): Unit = { + for { origin <- acceptOrigins } { + it(s"${origin} is accepted.") { + checkAccepted( + origin = origin, + origins = Some(origins), + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + } + } + + for { origin <- rejectOrigins } { + it(s"${origin} is rejected.") { + checkRejected( + origin = origin, + origins = Some(origins), + allowOriginPort = allowOriginPort, + allowOriginSubdomain = allowOriginSubdomain, + ) + } + } + } + + describe("For allowOriginPort:") { + val origins = Set( + "https://example.org", + "https://accounts.example.org", + "https://acme.com:8443", + ) + + describe("false,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://accounts.example.org", + "https://acme.com:8443", + ), + rejectOrigins = List( + "https://example.org:8443", + "https://shop.example.org", + "https://acme.com", + "https://acme.com:9000", + ), + allowOriginPort = false, + ) + } + + describe("true,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://example.org:8443", + "https://accounts.example.org", + "https://acme.com", + "https://acme.com:8443", + "https://acme.com:9000", + ), + rejectOrigins = List( + "https://shop.example.org" + ), + allowOriginPort = true, + ) + } + } + + describe("For allowOriginSubdomain:") { + val origins = Set("https://example.org", "https://acme.com:8443") + + describe("false,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://acme.com:8443", + ), + rejectOrigins = List( + "https://example.org:8443", + "https://accounts.example.org", + "https://acme.com", + "https://shop.acme.com:8443", + ), + allowOriginSubdomain = false, + ) + } + + describe("true,") { + check( + origins = origins, + acceptOrigins = List( + "https://example.org", + "https://accounts.example.org", + "https://acme.com:8443", + "https://shop.acme.com:8443", + ), + rejectOrigins = List( + "https://example.org:8443", + "https://acme.com", + ), + allowOriginSubdomain = true, + ) + } + } + } + } + + describe("10. Verify that the value of C.tokenBinding.status matches the state of Token Binding for the TLS connection over which the assertion was obtained.") { + it("Verification succeeds if neither side uses token binding ID.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification succeeds if client data specifies token binding is unsupported, and RP does not use it.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + .editClientData(_.without[ObjectNode]("tokenBinding")) + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification succeeds if client data specifies token binding is supported, and RP does not use it.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + .editClientData( + "tokenBinding", + toJson(Map("status" -> "supported")), + ) + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification fails if client data does not specify token binding status and RP specifies token binding ID.") { + val steps = finishRegistration( + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editClientData(_.without[ObjectNode]("tokenBinding")), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification succeeds if client data does not specify token binding status and RP does not specify token binding ID.") { + val steps = finishRegistration( + callerTokenBindingId = None, + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editClientData(_.without[ObjectNode]("tokenBinding")), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification fails if client data specifies token binding ID but RP does not.") { + val steps = finishRegistration( + callerTokenBindingId = None, + testData = + RegistrationTestData.FidoU2f.BasicAttestation.editClientData( + "tokenBinding", + toJson(Map("status" -> "present", "id" -> "YELLOWSUBMARINE")), + ), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + describe("If Token Binding was used on that TLS connection, also verify that C.tokenBinding.id matches the base64url encoding of the Token Binding ID for the connection.") { + it("Verification succeeds if both sides specify the same token binding ID.") { + val steps = finishRegistration( + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + testData = + RegistrationTestData.FidoU2f.BasicAttestation.editClientData( + "tokenBinding", + toJson( + Map("status" -> "present", "id" -> "YELLOWSUBMARINE") + ), + ), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Verification fails if ID is missing from tokenBinding in client data.") { + val steps = finishRegistration( + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + testData = + RegistrationTestData.FidoU2f.BasicAttestation.editClientData( + "tokenBinding", + toJson(Map("status" -> "present")), + ), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification fails if RP specifies token binding ID but client does not support it.") { + val steps = finishRegistration( + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editClientData(_.without[ObjectNode]("tokenBinding")), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification fails if RP specifies token binding ID but client does not use it.") { + val steps = finishRegistration( + callerTokenBindingId = + Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), + testData = + RegistrationTestData.FidoU2f.BasicAttestation.editClientData( + "tokenBinding", + toJson(Map("status" -> "supported")), + ), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verification fails if client data and RP specify different token binding IDs.") { + val steps = finishRegistration( + callerTokenBindingId = + Some(ByteArray.fromBase64Url("ORANGESUBMARINE")), + testData = + RegistrationTestData.FidoU2f.BasicAttestation.editClientData( + "tokenBinding", + toJson( + Map("status" -> "supported", "id" -> "YELLOWSUBMARINE") + ), + ), + ) + val step: FinishRegistrationSteps#Step10 = + steps.begin.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("11. Let hash be the result of computing a hash over response.clientDataJSON using SHA-256.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step11 = + steps.begin.next.next.next.next.next + val digest = MessageDigest.getInstance("SHA-256") + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.clientDataJsonHash should equal( + new ByteArray( + digest.digest( + RegistrationTestData.FidoU2f.BasicAttestation.clientDataJsonBytes.getBytes + ) + ) + ) + } + + it("12. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse structure to obtain the attestation statement format fmt, the authenticator data authData, and the attestation statement attStmt.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step12 = + steps.begin.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestation.getFormat should equal("fido-u2f") + step.attestation.getAuthenticatorData should not be null + step.attestation.getAttestationStatement should not be null + } + + describe("13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.") { + it("Fails if RP ID is different.") { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation + .editAuthenticatorData { authData: ByteArray => + new ByteArray( + Array.fill[Byte](32)(0) ++ authData.getBytes.drop(32) + ) + } + ) + val step: FinishRegistrationSteps#Step13 = + steps.begin.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("Succeeds if RP ID is the same.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step13 = + steps.begin.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + { + val testData = RegistrationTestData.Packed.BasicAttestation + + def upOn(authData: ByteArray): ByteArray = + new ByteArray( + authData.getBytes + .updated(32, (authData.getBytes()(32) | 0x01).toByte) + ) + + def upOff(authData: ByteArray): ByteArray = + new ByteArray( + authData.getBytes + .updated(32, (authData.getBytes()(32) & 0xfe).toByte) + ) + + def uvOn(authData: ByteArray): ByteArray = + new ByteArray( + authData.getBytes + .updated(32, (authData.getBytes()(32) | 0x04).toByte) + ) + + def uvOff(authData: ByteArray): ByteArray = + new ByteArray( + authData.getBytes + .updated(32, (authData.getBytes()(32) & 0xfb).toByte) + ) + + def checks[Next <: FinishRegistrationSteps.Step[ + _ + ], Step <: FinishRegistrationSteps.Step[Next]]( + stepsToStep: FinishRegistrationSteps => Step + ) = { + def check[B]( + stepsToStep: FinishRegistrationSteps => Step + )(chk: Step => B)( + uvr: UserVerificationRequirement, + authDataEdit: ByteArray => ByteArray, + ): B = { + val steps = finishRegistration( + testData = testData + .copy( + authenticatorSelection = Some( + AuthenticatorSelectionCriteria + .builder() + .userVerification(uvr) + .build() + ) + ) + .editAuthenticatorData(authDataEdit) + ) + chk(stepsToStep(steps)) + } + + def checkFailsWith( + stepsToStep: FinishRegistrationSteps => Step + ): (UserVerificationRequirement, ByteArray => ByteArray) => Unit = + check(stepsToStep) { step => + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + + def checkSucceedsWith( + stepsToStep: FinishRegistrationSteps => Step + ): (UserVerificationRequirement, ByteArray => ByteArray) => Unit = + check(stepsToStep) { step => + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + (checkFailsWith(stepsToStep), checkSucceedsWith(stepsToStep)) + } + + describe("14. Verify that the User Present bit of the flags in authData is set.") { + val (checkFails, checkSucceeds) = checks[ + FinishRegistrationSteps#Step15, + FinishRegistrationSteps#Step14, + ](_.begin.next.next.next.next.next.next.next.next) + + it("Fails if UV is discouraged and flag is not set.") { + checkFails(UserVerificationRequirement.DISCOURAGED, upOff) + } + + it("Succeeds if UV is discouraged and flag is set.") { + checkSucceeds(UserVerificationRequirement.DISCOURAGED, upOn) + } + + it("Fails if UV is preferred and flag is not set.") { + checkFails(UserVerificationRequirement.PREFERRED, upOff) + } + + it("Succeeds if UV is preferred and flag is set.") { + checkSucceeds(UserVerificationRequirement.PREFERRED, upOn) + } + + it("Fails if UV is required and flag is not set.") { + checkFails( + UserVerificationRequirement.REQUIRED, + upOff _ andThen uvOn, + ) + } + + it("Succeeds if UV is required and flag is set.") { + checkSucceeds( + UserVerificationRequirement.REQUIRED, + upOn _ andThen uvOn, + ) + } + } + + describe("15. If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set.") { + val (checkFails, checkSucceeds) = checks[ + FinishRegistrationSteps#Step16, + FinishRegistrationSteps#Step15, + ](_.begin.next.next.next.next.next.next.next.next.next) + + it("Succeeds if UV is discouraged and flag is not set.") { + checkSucceeds(UserVerificationRequirement.DISCOURAGED, uvOff) + } + + it("Succeeds if UV is discouraged and flag is set.") { + checkSucceeds(UserVerificationRequirement.DISCOURAGED, uvOn) + } + + it("Succeeds if UV is preferred and flag is not set.") { + checkSucceeds(UserVerificationRequirement.PREFERRED, uvOff) + } + + it("Succeeds if UV is preferred and flag is set.") { + checkSucceeds(UserVerificationRequirement.PREFERRED, uvOn) + } + + it("Fails if UV is required and flag is not set.") { + checkFails(UserVerificationRequirement.REQUIRED, uvOff) + } + + it("Succeeds if UV is required and flag is set.") { + checkSucceeds(UserVerificationRequirement.REQUIRED, uvOn) + } + } + } + + describe("16. Verify that the \"alg\" parameter in the credential public key in authData matches the alg attribute of one of the items in options.pubKeyCredParams.") { + it("An ES256 key succeeds if ES256 was a requested algorithm.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + val result = finishRegistration( + testData = testData, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + allowUntrustedAttestation = true, + ).run + + result should not be null + result.getPublicKeyCose should not be null + } + + it("An ES256 key fails if only RSA and EdDSA are allowed.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + val result = Try( + finishRegistration( + testData = testData.copy( + overrideRequest = Some( + testData.request.toBuilder + .pubKeyCredParams( + List( + PublicKeyCredentialParameters.EdDSA, + PublicKeyCredentialParameters.RS256, + ).asJava + ) + .build() + ) + ), + credentialRepository = Helpers.CredentialRepositoryV2.empty, + allowUntrustedAttestation = true, + ).run + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + } + + describe("17. Verify that the values of the client extension outputs in clientExtensionResults and the authenticator extension outputs in the extensions in authData are as expected, considering the client extension input values that were given in options.extensions and any specific policy of the Relying Party regarding unsolicited extensions, i.e., those that were not specified as part of options.extensions. In the general case, the meaning of \"are as expected\" is specific to the Relying Party and which extensions are in use.") { + it("Succeeds if clientExtensionResults is a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.subsetRegistrationExtensions) { + case (extensionInputs, clientExtensionOutputs, _) => + val steps = finishRegistration( + testData = RegistrationTestData.Packed.BasicAttestation.copy( + requestedExtensions = extensionInputs, + clientExtensionResults = clientExtensionOutputs, + ) + ) + val stepAfter: Try[FinishRegistrationSteps#Step18] = + steps.begin.next.next.next.next.next.next.next.next.next.next.tryNext + + stepAfter shouldBe a[Success[_]] + } + } + + it("Succeeds if clientExtensionResults is not a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.unrequestedClientRegistrationExtensions) { + case (extensionInputs, clientExtensionOutputs, _) => + val steps = finishRegistration( + testData = RegistrationTestData.Packed.BasicAttestation.copy( + requestedExtensions = extensionInputs, + clientExtensionResults = clientExtensionOutputs, + ) + ) + val stepAfter: Try[FinishRegistrationSteps#Step18] = + steps.begin.next.next.next.next.next.next.next.next.next.next.tryNext + + stepAfter shouldBe a[Success[_]] + } + } + + it("Succeeds if authenticator extensions is a subset of the extensions requested by the Relying Party.") { + forAll(Extensions.subsetRegistrationExtensions) { + case ( + extensionInputs: RegistrationExtensionInputs, + _, + authenticatorExtensionOutputs: CBORObject, + ) => + val steps = finishRegistration( + testData = RegistrationTestData.Packed.BasicAttestation + .copy( + requestedExtensions = extensionInputs + ) + .editAuthenticatorData(authData => + new ByteArray( + authData.getBytes.updated( + 32, + (authData.getBytes()(32) | 0x80).toByte, + ) ++ authenticatorExtensionOutputs.EncodeToBytes() + ) + ) + ) + val stepAfter: Try[FinishRegistrationSteps#Step18] = + steps.begin.next.next.next.next.next.next.next.next.next.next.tryNext + + stepAfter shouldBe a[Success[_]] + } + } + + it("Succeeds if authenticator extensions is not a subset of the extensions requested by the Relying Party.") { + forAll( + Extensions.unrequestedAuthenticatorRegistrationExtensions + ) { + case ( + extensionInputs: RegistrationExtensionInputs, + _, + authenticatorExtensionOutputs: CBORObject, + ) => + val steps = finishRegistration( + testData = RegistrationTestData.Packed.BasicAttestation + .copy( + requestedExtensions = extensionInputs + ) + .editAuthenticatorData(authData => + new ByteArray( + authData.getBytes.updated( + 32, + (authData.getBytes()(32) | 0x80).toByte, + ) ++ authenticatorExtensionOutputs.EncodeToBytes() + ) + ) + ) + val stepAfter: Try[FinishRegistrationSteps#Step18] = + steps.begin.next.next.next.next.next.next.next.next.next.next.tryNext + + stepAfter shouldBe a[Success[_]] + } + } + } + + describe("18. Determine the attestation statement format by performing a USASCII case-sensitive match on fmt against the set of supported WebAuthn Attestation Statement Format Identifier values. An up-to-date list of registered WebAuthn Attestation Statement Format Identifier values is maintained in the IANA \"WebAuthn Attestation Statement Format Identifiers\" registry established by RFC8809.") { + def setup(format: String): FinishRegistrationSteps = { + finishRegistration( + testData = RegistrationTestData.FidoU2f.BasicAttestation + .setAttestationStatementFormat(format) + ) + } + + def checkUnknown(format: String): Unit = { + it(s"""Returns no known attestation statement verifier if fmt is "${format}".""") { + val steps = setup(format) + val step: FinishRegistrationSteps#Step18 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.format should equal(format) + step.attestationStatementVerifier.toScala shouldBe empty + } + } + + def checkKnown(format: String): Unit = { + it(s"""Returns a known attestation statement verifier if fmt is "${format}".""") { + val steps = setup(format) + val step: FinishRegistrationSteps#Step18 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.format should equal(format) + step.attestationStatementVerifier.toScala should not be empty + } + } + + checkKnown("android-safetynet") + checkKnown("fido-u2f") + checkKnown("none") + checkKnown("packed") + checkKnown("tpm") + + checkUnknown("android-key") + + checkUnknown("FIDO-U2F") + checkUnknown("Fido-U2F") + checkUnknown("bleurgh") + } + + describe("19. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using the attestation statement format fmt’s verification procedure given attStmt, authData and hash.") { + + describe("If allowUntrustedAttestation is set,") { + it("a fido-u2f attestation is still rejected if invalid.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + .updateAttestationObject( + "attStmt", + { attStmtNode: JsonNode => + attStmtNode + .asInstanceOf[ObjectNode] + .set[ObjectNode]( + "sig", + jsonFactory.binaryNode(Array(0, 0, 0, 0)), + ) + }, + ) + val steps = finishRegistration( + testData = testData, + allowUntrustedAttestation = true, + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get.getCause shouldBe a[ + SignatureException + ] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("For the fido-u2f statement format,") { + it("the default test case is a valid basic attestation.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationType should equal(AttestationType.BASIC) + step.tryNext shouldBe a[Success[_]] + } + + it("a test case with self attestation is valid.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.SelfAttestation + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationType should equal( + AttestationType.SELF_ATTESTATION + ) + step.tryNext shouldBe a[Success[_]] + } + + it("a test case with different signed client data is not valid.") { + val testData = RegistrationTestData.FidoU2f.SelfAttestation + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + ) + val step: FinishRegistrationSteps#Step19 = new steps.Step19( + Crypto.sha256( + new ByteArray( + testData.clientDataJsonBytes.getBytes.updated( + 20, + (testData.clientDataJsonBytes.getBytes()(20) + 1).toByte, + ) + ) + ), + new AttestationObject(testData.attestationObject), + Optional.of(new FidoU2fAttestationStatementVerifier), + ) + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + def checkByteFlipFails(index: Int): Unit = { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + .editAuthenticatorData { + flipByte(index, _) + } + + val steps = finishRegistration(testData = testData) + val step: FinishRegistrationSteps#Step19 = new steps.Step19( + Crypto.sha256(testData.clientDataJsonBytes), + new AttestationObject(testData.attestationObject), + Optional.of(new FidoU2fAttestationStatementVerifier), + ) + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + it("a test case with a different signed RP ID hash is not valid.") { + checkByteFlipFails(0) + } + + it( + "a test case with a different signed credential ID is not valid." + ) { + checkByteFlipFails(32 + 1 + 4 + 16 + 2 + 1) + } + + it("a test case with a different signed credential public key is not valid.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + .editAuthenticatorData { authenticatorData => + val decoded = new AuthenticatorData(authenticatorData) + val L = + decoded.getAttestedCredentialData.get.getCredentialId.getBytes.length + val evilPublicKey: ByteArray = + WebAuthnTestCodecs.publicKeyToCose( + TestAuthenticator + .generateKeypair( + COSEAlgorithmIdentifier + .fromPublicKey( + decoded.getAttestedCredentialData.get.getCredentialPublicKey + ) + .get + ) + .getPublic + ) + + new ByteArray( + authenticatorData.getBytes.take( + 32 + 1 + 4 + 16 + 2 + L + ) ++ evilPublicKey.getBytes + ) + } + val steps = finishRegistration(testData = testData) + val step: FinishRegistrationSteps#Step19 = new steps.Step19( + Crypto.sha256(testData.clientDataJsonBytes), + new AttestationObject(testData.attestationObject), + Optional.of(new FidoU2fAttestationStatementVerifier), + ) + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + + describe("if x5c is not a certificate for an ECDSA public key over the P-256 curve, stop verification and return an error.") { + val testAuthenticator = TestAuthenticator + + def checkRejected( + attestationAlg: COSEAlgorithmIdentifier, + keypair: KeyPair, + ): Unit = { + val (credential, _, _) = testAuthenticator + .createBasicAttestedCredential(attestationMaker = + AttestationMaker.fidoU2f( + new AttestationCert( + attestationAlg, + testAuthenticator.generateAttestationCertificate( + attestationAlg, + Some(keypair), + ), + ) + ) + ) + + val steps = finishRegistration( + testData = RegistrationTestData( + alg = COSEAlgorithmIdentifier.ES256, + attestationObject = + credential.getResponse.getAttestationObject, + clientDataJson = new String( + credential.getResponse.getClientDataJSON.getBytes, + "UTF-8", + ), + ) + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + val standaloneVerification = Try { + new FidoU2fAttestationStatementVerifier() + .verifyAttestationSignature( + credential.getResponse.getAttestation, + Crypto.sha256(credential.getResponse.getClientDataJSON), + ) + } + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + + standaloneVerification shouldBe a[Failure[_]] + standaloneVerification.failed.get shouldBe an[ + IllegalArgumentException + ] + } + + def checkAccepted( + attestationAlg: COSEAlgorithmIdentifier, + keypair: KeyPair, + ): Unit = { + val (credential, _, _) = testAuthenticator + .createBasicAttestedCredential(attestationMaker = + AttestationMaker.fidoU2f( + new AttestationCert( + attestationAlg, + testAuthenticator.generateAttestationCertificate( + attestationAlg, + Some(keypair), + ), + ) + ) + ) + + val steps = finishRegistration( + testData = RegistrationTestData( + alg = COSEAlgorithmIdentifier.ES256, + attestationObject = + credential.getResponse.getAttestationObject, + clientDataJson = new String( + credential.getResponse.getClientDataJSON.getBytes, + "UTF-8", + ), + ) + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + val standaloneVerification = Try { + new FidoU2fAttestationStatementVerifier() + .verifyAttestationSignature( + credential.getResponse.getAttestation, + Crypto.sha256(credential.getResponse.getClientDataJSON), + ) + } + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + + standaloneVerification should equal(Success(true)) + } + + it("An RSA attestation certificate is rejected.") { + checkRejected( + COSEAlgorithmIdentifier.RS256, + testAuthenticator.generateRsaKeypair(), + ) + } + + it("A secp256r1 attestation certificate is accepted.") { + checkAccepted( + COSEAlgorithmIdentifier.ES256, + testAuthenticator.generateEcKeypair(curve = "secp256r1"), + ) + } + + it("A secp256k1 attestation certificate is rejected.") { + checkRejected( + COSEAlgorithmIdentifier.ES256, + testAuthenticator.generateEcKeypair(curve = "secp256k1"), + ) + } + } + } + + describe("For the none statement format,") { + def flipByte(index: Int, bytes: ByteArray): ByteArray = + new ByteArray( + bytes.getBytes + .updated(index, (0xff ^ bytes.getBytes()(index)).toByte) + ) + + def checkByteFlipSucceeds( + mutationDescription: String, + index: Int, + ): Unit = { + it(s"the default test case with mutated ${mutationDescription} is accepted.") { + val testData = RegistrationTestData.NoneAttestation.Default + .editAuthenticatorData { + flipByte(index, _) + } + + val steps = finishRegistration(testData = testData) + val step: FinishRegistrationSteps#Step19 = new steps.Step19( + Crypto.sha256(testData.clientDataJsonBytes), + new AttestationObject(testData.attestationObject), + Optional.of(new NoneAttestationStatementVerifier), + ) + + step.validations shouldBe a[Success[_]] + step.attestationType should equal(AttestationType.NONE) + step.tryNext shouldBe a[Success[_]] + } + } + + it("the default test case is accepted.") { + val steps = finishRegistration(testData = + RegistrationTestData.NoneAttestation.Default + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationType should equal(AttestationType.NONE) + step.tryNext shouldBe a[Success[_]] + } + + checkByteFlipSucceeds("signature counter", 32 + 1) + checkByteFlipSucceeds("AAGUID", 32 + 1 + 4) + checkByteFlipSucceeds("credential ID", 32 + 1 + 4 + 16 + 2) + } + + describe("For the packed statement format") { + val verifier = new PackedAttestationStatementVerifier + + it("the attestation statement verifier implementation is PackedAttestationStatementVerifier.") { + val steps = finishRegistration(testData = + RegistrationTestData.Packed.BasicAttestation + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.getAttestationStatementVerifier.get shouldBe a[ + PackedAttestationStatementVerifier + ] + } + + describe("the verification procedure is:") { + describe("1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract the contained fields.") { + + it("Fails if attStmt.sig is a text value.") { + val testData = RegistrationTestData.Packed.BasicAttestation + .editAttestationObject( + "attStmt", + jsonFactory + .objectNode() + .set("sig", jsonFactory.textNode("foo")), + ) + + val result: Try[Boolean] = Try( + verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + + it("Fails if attStmt.sig is missing.") { + val testData = RegistrationTestData.Packed.BasicAttestation + .editAttestationObject( + "attStmt", + jsonFactory + .objectNode() + .set("x5c", jsonFactory.arrayNode()), + ) + + val result: Try[Boolean] = Try( + verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + } + + describe("2. If x5c is present:") { + it("The attestation type is identified as Basic.") { + val steps = finishRegistration(testData = + RegistrationTestData.Packed.BasicAttestation + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType should be(AttestationType.BASIC) + } + + describe("1. Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash using the attestation public key in attestnCert with the algorithm specified in alg.") { + it("Succeeds for the default test case.") { + val testData = RegistrationTestData.Packed.BasicAttestation + val result: Try[Boolean] = Try( + verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + ) + result should equal(Success(true)) + } + + it("Succeeds for an RS1 test case.") { + val testData = + RegistrationTestData.Packed.BasicAttestationRs1 + + val result = verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + result should equal(true) + } + + it("Fail if the default test case is mutated.") { + val testData = RegistrationTestData.Packed.BasicAttestation + + val result: Try[Boolean] = Try( + verifier.verifyAttestationSignature( + new AttestationObject( + testData + .editAuthenticatorData({ authData: ByteArray => + new ByteArray( + authData.getBytes.updated( + 16, + if (authData.getBytes()(16) == 0) 1: Byte + else 0: Byte, + ) + ) + }) + .attestationObject + ), + testData.clientDataJsonHash, + ) + ) + result should equal(Success(false)) + } + } + + describe("2. Verify that attestnCert meets the requirements in § 8.2.1 Packed Attestation Statement Certificate Requirements.") { + it("Fails for an attestation signature with an invalid country code.") { + val authenticator = TestAuthenticator + val alg = COSEAlgorithmIdentifier.ES256 + val (badCert, key): (X509Certificate, PrivateKey) = + authenticator.generateAttestationCertificate( + alg = alg, + name = new X500Name( + "O=Yubico, C=AA, OU=Authenticator Attestation" + ), + ) + val (credential, _, _) = + authenticator.createBasicAttestedCredential( + attestationMaker = AttestationMaker.packed( + new AttestationCert(alg, (badCert, key)) + ) + ) + val result = Try( + verifier.verifyAttestationSignature( + credential.getResponse.getAttestation, + sha256(credential.getResponse.getClientDataJSON), + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + + it("succeeds for the default test case.") { + val testData = RegistrationTestData.Packed.BasicAttestation + val result = verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + result should equal(true) + } + } + + describe("3. If attestnCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) verify that the value of this extension matches the aaguid in authenticatorData.") { + it("Succeeds for the default test case.") { + val testData = RegistrationTestData.Packed.BasicAttestation + val result = verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + + testData.packedAttestationCert.getNonCriticalExtensionOIDs.asScala should equal( + Set("1.3.6.1.4.1.45724.1.1.4") + ) + result should equal(true) + } + + it("Succeeds if the attestation certificate does not have the extension.") { + val testData = + RegistrationTestData.Packed.BasicAttestationWithoutAaguidExtension + + val result = verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + + testData.packedAttestationCert.getNonCriticalExtensionOIDs shouldBe null + result should equal(true) + } + + it("Fails if the attestation certificate has the extension and it does not match the AAGUID.") { + val testData = + RegistrationTestData.Packed.BasicAttestationWithWrongAaguidExtension + + val result = Try( + verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + ) + + testData.packedAttestationCert.getNonCriticalExtensionOIDs should not be empty + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + } + + describe("4. Optionally, inspect x5c and consult externally provided knowledge to determine whether attStmt conveys a Basic or AttCA attestation.") { + it("Nothing to test.") {} + } + + it("5. If successful, return implementation-specific values representing attestation type Basic, AttCA or uncertainty, and attestation trust path x5c.") { + val testData = RegistrationTestData.Packed.BasicAttestation + val steps = finishRegistration(testData = testData) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType should be(AttestationType.BASIC) + step.attestationTrustPath.toScala should not be empty + step.attestationTrustPath.get.asScala should be( + List(testData.packedAttestationCert) + ) + } + } + + describe( + "3. If x5c is not present, self attestation is in use." + ) { + val testDataBase = RegistrationTestData.Packed.SelfAttestation + + it("The attestation type is identified as SelfAttestation.") { + val steps = finishRegistration(testData = testDataBase) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType should be( + AttestationType.SELF_ATTESTATION + ) + } + + describe("1. Validate that alg matches the algorithm of the credentialPublicKey in authenticatorData.") { + it("Succeeds for the default test case.") { + val result = verifier.verifyAttestationSignature( + new AttestationObject(testDataBase.attestationObject), + testDataBase.clientDataJsonHash, + ) + + CBORObject + .DecodeFromBytes( + new AttestationObject( + testDataBase.attestationObject + ).getAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey.getBytes + ) + .get(CBORObject.FromObject(3)) + .AsInt64Value should equal(-7) + new AttestationObject( + testDataBase.attestationObject + ).getAttestationStatement.get("alg").longValue should equal( + -7 + ) + result should equal(true) + } + + it("Fails if the alg is a different value.") { + def modifyAuthdataPubkeyAlg(authDataBytes: Array[Byte]) + : Array[Byte] = { + val authData = + new AuthenticatorData(new ByteArray(authDataBytes)) + val key = WebAuthnCodecs + .importCosePublicKey( + authData.getAttestedCredentialData.get.getCredentialPublicKey + ) + .asInstanceOf[RSAPublicKey] + val reencodedKey = WebAuthnTestCodecs.rsaPublicKeyToCose( + key, + COSEAlgorithmIdentifier.RS256, + ) + new ByteArray( + java.util.Arrays.copyOfRange( + authDataBytes, + 0, + 32 + 1 + 4 + 16 + 2, + ) + ) + .concat( + authData.getAttestedCredentialData.get.getCredentialId + ) + .concat(reencodedKey) + .getBytes + } + + def modifyAttobjPubkeyAlg(attObjBytes: ByteArray) + : ByteArray = { + val attObj = + JacksonCodecs.cbor.readTree(attObjBytes.getBytes) + new ByteArray( + JacksonCodecs.cbor.writeValueAsBytes( + attObj + .asInstanceOf[ObjectNode] + .set( + "authData", + jsonFactory.binaryNode( + modifyAuthdataPubkeyAlg( + attObj.get("authData").binaryValue() + ) + ), + ) + ) + ) + } + + val testData = + RegistrationTestData.Packed.SelfAttestationRs1 + val attObj = new AttestationObject( + modifyAttobjPubkeyAlg( + testData.response.getResponse.getAttestationObject + ) + ) + + val result = Try( + verifier.verifyAttestationSignature( + attObj, + testData.clientDataJsonHash, + ) + ) + + CBORObject + .DecodeFromBytes( + attObj.getAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey.getBytes + ) + .get(CBORObject.FromObject(3)) + .AsInt64Value should equal(-257) + attObj.getAttestationStatement + .get("alg") + .longValue should equal(-65535) + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + } + + describe("2. Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash using the credential public key with alg.") { + it("Succeeds for the default test case.") { + val result = verifier.verifyAttestationSignature( + new AttestationObject(testDataBase.attestationObject), + testDataBase.clientDataJsonHash, + ) + result should equal(true) + } + + it("Succeeds for an RS1 test case.") { + val testData = + RegistrationTestData.Packed.SelfAttestationRs1 + val alg = COSEAlgorithmIdentifier + .fromPublicKey( + testData.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey + ) + .get + alg should be(COSEAlgorithmIdentifier.RS1) + + val result = verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + result should equal(true) + } + + it("Fails if the attestation object is mutated.") { + val testData = testDataBase.editAuthenticatorData { + authData: ByteArray => + new ByteArray( + authData.getBytes.updated( + 16, + if (authData.getBytes()(16) == 0) 1: Byte + else 0: Byte, + ) + ) + } + val result = verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + result should equal(false) + } + + it("Fails if the client data is mutated.") { + val result = verifier.verifyAttestationSignature( + new AttestationObject(testDataBase.attestationObject), + sha256( + new ByteArray( + testDataBase.clientDataJson + .updated(4, 'ä') + .getBytes("UTF-8") + ) + ), + ) + result should equal(false) + } + + it("Fails if the client data hash is mutated.") { + val result = verifier.verifyAttestationSignature( + new AttestationObject(testDataBase.attestationObject), + new ByteArray( + testDataBase.clientDataJsonHash.getBytes.updated( + 7, + if ( + testDataBase.clientDataJsonHash.getBytes()(7) == 0 + ) 1: Byte + else 0: Byte, + ) + ), + ) + result should equal(false) + } + } + + it("3. If successful, return implementation-specific values representing attestation type Self and an empty attestation trust path.") { + val testData = RegistrationTestData.Packed.SelfAttestation + val steps = finishRegistration(testData = testData) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType should be( + AttestationType.SELF_ATTESTATION + ) + step.attestationTrustPath.toScala shouldBe empty + } + } + } + + describe( + "8.2.1. Packed Attestation Statement Certificate Requirements" + ) { + val testDataBase = RegistrationTestData.Packed.BasicAttestation + + describe("The attestation certificate MUST have the following fields/extensions:") { + it("Version MUST be set to 3 (which is indicated by an ASN.1 INTEGER with value 2).") { + val badCert = Mockito.mock(classOf[X509Certificate]) + val principal = new X500Principal( + "O=Yubico, C=SE, OU=Authenticator Attestation" + ) + Mockito.when(badCert.getVersion) thenReturn 2 + Mockito.when( + badCert.getSubjectX500Principal + ) thenReturn principal + Mockito.when(badCert.getBasicConstraints) thenReturn -1 + val result = Try( + verifier.verifyX5cRequirements(badCert, testDataBase.aaguid) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + + verifier.verifyX5cRequirements( + testDataBase.packedAttestationCert, + testDataBase.aaguid, + ) should equal(true) + } + + describe("Subject field MUST be set to:") { + it("Subject-C: ISO 3166 code specifying the country where the Authenticator vendor is incorporated (PrintableString)") { + val badCert: X509Certificate = TestAuthenticator + .generateAttestationCertificate( + name = new X500Name( + "O=Yubico, C=AA, OU=Authenticator Attestation" + ) + ) + ._1 + val result = Try( + verifier.verifyX5cRequirements( + badCert, + testDataBase.aaguid, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + + verifier.verifyX5cRequirements( + testDataBase.packedAttestationCert, + testDataBase.aaguid, + ) should equal(true) + } + + it("Subject-O: Legal name of the Authenticator vendor (UTF8String)") { + val badCert: X509Certificate = TestAuthenticator + .generateAttestationCertificate( + name = + new X500Name("C=SE, OU=Authenticator Attestation") + ) + ._1 + val result = Try( + verifier.verifyX5cRequirements( + badCert, + testDataBase.aaguid, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + + verifier.verifyX5cRequirements( + testDataBase.packedAttestationCert, + testDataBase.aaguid, + ) should equal(true) + } + + it("""Subject-OU: Literal string "Authenticator Attestation" (UTF8String)""") { + val badCert: X509Certificate = TestAuthenticator + .generateAttestationCertificate( + name = new X500Name("O=Yubico, C=SE, OU=Foo") + ) + ._1 + val result = Try( + verifier.verifyX5cRequirements( + badCert, + testDataBase.aaguid, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + + verifier.verifyX5cRequirements( + testDataBase.packedAttestationCert, + testDataBase.aaguid, + ) should equal(true) + } + + describe( + "Subject-CN: A UTF8String of the vendor’s choosing" + ) { + it("Nothing to test") {} + } + } + + it("If the related attestation root certificate is used for multiple authenticator models, the Extension OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) MUST be present, containing the AAGUID as a 16-byte OCTET STRING. The extension MUST NOT be marked as critical.") { + val idFidoGenCeAaguid = "1.3.6.1.4.1.45724.1.1.4" + + val badCert: X509Certificate = TestAuthenticator + .generateAttestationCertificate( + name = new X500Name( + "O=Yubico, C=SE, OU=Authenticator Attestation" + ), + extensions = List( + ( + idFidoGenCeAaguid, + false, + new DEROctetString(Array[Byte](0, 1, 2, 3)), + ) + ), + ) + ._1 + val result = Try( + verifier.verifyX5cRequirements(badCert, testDataBase.aaguid) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + + val badCertCritical: X509Certificate = TestAuthenticator + .generateAttestationCertificate( + name = new X500Name( + "O=Yubico, C=SE, OU=Authenticator Attestation" + ), + extensions = List( + ( + idFidoGenCeAaguid, + true, + new DEROctetString(testDataBase.aaguid.getBytes), + ) + ), + ) + ._1 + val resultCritical = Try( + verifier.verifyX5cRequirements( + badCertCritical, + testDataBase.aaguid, + ) + ) + + resultCritical shouldBe a[Failure[_]] + resultCritical.failed.get shouldBe an[ + IllegalArgumentException + ] + + val goodResult = Try( + verifier.verifyX5cRequirements(badCert, testDataBase.aaguid) + ) + + goodResult shouldBe a[Failure[_]] + goodResult.failed.get shouldBe an[IllegalArgumentException] + + verifier.verifyX5cRequirements( + testDataBase.packedAttestationCert, + testDataBase.aaguid, + ) should equal(true) + } + + it("The Basic Constraints extension MUST have the CA component set to false.") { + val result = Try( + verifier.verifyX5cRequirements( + testDataBase.attestationCertChain.last._1, + testDataBase.aaguid, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + + verifier.verifyX5cRequirements( + testDataBase.packedAttestationCert, + testDataBase.aaguid, + ) should equal(true) + } + + describe("An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL Distribution Point extension [RFC5280] are both OPTIONAL as the status of many attestation certificates is available through authenticator metadata services. See, for example, the FIDO Metadata Service [FIDOMetadataService].") { + it("Nothing to test.") {} + } + } + } + } + + describe("The tpm statement format") { + + it("is supported.") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + val steps = + finishRegistration( + testData = testData, + origins = + Some(Set("https://dev.d2urpypvrhb05x.amplifyapp.com")), + credentialRepository = Helpers.CredentialRepositoryV2.empty, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.get, + enableRevocationChecking = false, + policyTreeValidator = Some(_ => true), + ) + ), + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.run.getAttestationType should be( + AttestationType.ATTESTATION_CA + ) + } + + describe("is supported and accepts test-generated values:") { + + val emptySubject = new X500Name(Array.empty[RDN]) + val tcgAtTpmManufacturer = new AttributeTypeAndValue( + new ASN1ObjectIdentifier("2.23.133.2.1"), + new DERUTF8String("id:00000000"), + ) + val tcgAtTpmModel = new AttributeTypeAndValue( + new ASN1ObjectIdentifier("2.23.133.2.2"), + new DERUTF8String("TEST_Yubico_java-webauthn-server"), + ) + val tcgAtTpmVersion = new AttributeTypeAndValue( + new ASN1ObjectIdentifier("2.23.133.2.3"), + new DERUTF8String("id:00000000"), + ) + val tcgKpAikCertificate = new ASN1ObjectIdentifier("2.23.133.8.3") + + def makeCred( + authDataAndKeypair: Option[(ByteArray, KeyPair)] = None, + credKeyAlgorithm: COSEAlgorithmIdentifier = + TestAuthenticator.Defaults.keyAlgorithm, + clientDataJson: Option[String] = None, + subject: X500Name = emptySubject, + rdn: Array[AttributeTypeAndValue] = + Array(tcgAtTpmManufacturer, tcgAtTpmModel, tcgAtTpmVersion), + extendedKeyUsage: Array[ASN1Encodable] = + Array(tcgKpAikCertificate), + ver: Option[String] = Some("2.0"), + magic: ByteArray = + TpmAttestationStatementVerifier.TPM_GENERATED_VALUE, + `type`: ByteArray = + TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY, + modifyAttestedName: ByteArray => ByteArray = an => an, + overrideCosePubkey: Option[ByteArray] = None, + aaguidInCert: Option[ByteArray] = None, + attributes: Option[Long] = None, + symmetric: Option[Int] = None, + scheme: Option[Int] = None, + ): ( + PublicKeyCredential[ + AuthenticatorAttestationResponse, + ClientRegistrationExtensionOutputs, + ], + KeyPair, + List[(X509Certificate, PrivateKey)], + ) = { + val (authData, credentialKeypair) = + authDataAndKeypair.getOrElse( + TestAuthenticator.createAuthenticatorData( + credentialKeypair = Some( + TestAuthenticator.Defaults.defaultKeypair( + credKeyAlgorithm + ) + ), + keyAlgorithm = credKeyAlgorithm, + ) + ) + + TestAuthenticator.createCredential( + authDataBytes = authData, + credentialKeypair = credentialKeypair, + clientDataJson = clientDataJson, + attestationMaker = AttestationMaker.tpm( + cert = AttestationSigner.ca( + alg = COSEAlgorithmIdentifier.ES256, + certSubject = subject, + aaguid = aaguidInCert, + certExtensions = List( + ( + Extension.subjectAlternativeName.getId, + true, + new GeneralNamesBuilder() + .addName( + new GeneralName(new X500Name(Array(new RDN(rdn)))) + ) + .build(), + ), + ( + Extension.extendedKeyUsage.getId, + true, + new DERSequence(extendedKeyUsage), + ), + ), + validFrom = Instant.now(), + validTo = Instant.now().plusSeconds(600), + ), + ver = ver, + magic = magic, + `type` = `type`, + modifyAttestedName = modifyAttestedName, + overrideCosePubkey = overrideCosePubkey, + attributes = attributes, + symmetric = symmetric, + scheme = scheme, + ), + ) + } + + def init( + testData: RegistrationTestData + ): FinishRegistrationSteps#Step19 = { + val steps = + finishRegistration( + credentialRepository = Helpers.CredentialRepositoryV2.empty, + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationCertChain.last._1, + enableRevocationChecking = false, + ) + ), + ) + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + } + + def check( + testData: RegistrationTestData, + pubKeyCredParams: Option[ + List[PublicKeyCredentialParameters] + ] = None, + ) = { + val steps = + finishRegistration( + testData = testData, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.getOrElse( + testData.attestationCertChain.last._1 + ), + enableRevocationChecking = false, + ) + ), + pubkeyCredParams = pubKeyCredParams, + clock = Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.run.getAttestationType should be( + AttestationType.ATTESTATION_CA + ) + } + + it("ES256.") { + check(RegistrationTestData.Tpm.ValidEs256) + } + it("ES384.") { + check(RegistrationTestData.Tpm.ValidEs384) + } + it("ES512.") { + check(RegistrationTestData.Tpm.ValidEs512) + } + it("RS256.") { + check(RegistrationTestData.Tpm.ValidRs256) + } + it("RS1.") { + check( + RegistrationTestData.Tpm.ValidRs1, + pubKeyCredParams = + Some(List(PublicKeyCredentialParameters.RS1)), + ) + } + + it("Default cert generator settings.") { + val testData = (RegistrationTestData.from _).tupled(makeCred()) + val step = init(testData) + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.run.getAttestationType should be( + AttestationType.ATTESTATION_CA + ) + } + + describe("Verify that the public key specified by the parameters and unique fields of pubArea is identical to the credentialPublicKey in the attestedCredentialData in authenticatorData.") { + it("Fails when EC key is unrelated but on the same curve.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + overrideCosePubkey = Some( + WebAuthnTestCodecs.ecPublicKeyToCose( + TestAuthenticator + .generateEcKeypair() + .getPublic + .asInstanceOf[ECPublicKey] + ) + ) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.validations.failed.get.getMessage should include( + "EC X coordinate differs" + ) + } + + it("Fails when EC key is on a different curve.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + overrideCosePubkey = Some( + WebAuthnTestCodecs.ecPublicKeyToCose( + TestAuthenticator + .generateEcKeypair("secp384r1") + .getPublic + .asInstanceOf[ECPublicKey] + ) + ) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.validations.failed.get.getMessage should include( + "elliptic curve differs" + ) + } + + it("Fails when EC key has an inverted Y coordinate.") { + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.ES256 + ) + + val cose = CBORObject.DecodeFromBytes( + WebAuthnTestCodecs + .ecPublicKeyToCose( + keypair.getPublic.asInstanceOf[ECPublicKey] + ) + .getBytes + ) + val yneg = TestAuthenticator.Es256PrimeModulus + .subtract( + new BigInteger(1, cose.get(-3).GetByteString()) + ) + val ynegBytes = yneg.toByteArray.dropWhile(_ == 0) + cose.Set( + -3, + Array.fill[Byte](32 - ynegBytes.length)(0) ++ ynegBytes, + ) + + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + overrideCosePubkey = + Some(new ByteArray(cose.EncodeToBytes())), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.validations.failed.get.getMessage should include( + "EC Y coordinate differs" + ) + } + + it("Fails when RSA key is unrelated.") { + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.RS256 + ) + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + overrideCosePubkey = Some( + WebAuthnTestCodecs.rsaPublicKeyToCose( + TestAuthenticator + .generateRsaKeypair() + .getPublic + .asInstanceOf[RSAPublicKey], + COSEAlgorithmIdentifier.RS256, + ) + ), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("""The "ver" property must equal "2.0".""") { + forAll( + Gen.option( + Gen.oneOf( + Gen.numStr, + for { + major <- arbitrary[Int] + minor <- arbitrary[Int] + } yield s"${major}.${minor}", + arbitrary[String], + ) + ) + ) { ver: Option[String] => + whenever(!ver.contains("2.0")) { + val testData = + (RegistrationTestData.from _).tupled(makeCred(ver = ver)) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""Verify that magic is set to TPM_GENERATED_VALUE.""") { + forAll(byteArray(4)) { magic => + whenever( + magic != TpmAttestationStatementVerifier.TPM_GENERATED_VALUE + ) { + val testData = (RegistrationTestData.from _).tupled( + makeCred(magic = magic) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""Verify that type is set to TPM_ST_ATTEST_CERTIFY.""") { + forAll( + Gen.oneOf( + byteArray(2), + flipOneBit( + TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY + ), + ) + ) { `type` => + whenever( + `type` != TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY + ) { + val testData = (RegistrationTestData.from _).tupled( + makeCred(`type` = `type`) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""Verify that extraData is set to the hash of attToBeSigned using the hash algorithm employed in "alg".""") { + val testData = (RegistrationTestData.from _).tupled(makeCred()) + val json = JacksonCodecs.json() + val clientData = json + .readTree(testData.clientDataJson) + .asInstanceOf[ObjectNode] + clientData.set( + "challenge", + jsonFactory.textNode( + Crypto + .sha256( + ByteArray.fromBase64Url( + clientData.get("challenge").textValue + ) + ) + .getBase64Url + ), + ) + val mutatedTestData = testData.copy(clientDataJson = + json.writeValueAsString(clientData) + ) + val step = init(mutatedTestData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verify that attested contains a TPMS_CERTIFY_INFO structure as specified in [TPMv2-Part2] section 10.12.3, whose name field contains a valid Name for pubArea, as computed using the algorithm in the nameAlg field of pubArea using the procedure specified in [TPMv2-Part1] section 16.") { + forAll( + Gen.oneOf( + for { + flipBitIndex: Int <- + Gen.oneOf(Gen.const(0), Gen.posNum[Int]) + } yield (an: ByteArray) => + flipBit(flipBitIndex % (8 * an.size()))(an), + for { + attestedName <- arbitrary[ByteArray] + } yield (_: ByteArray) => attestedName, + ) + ) { (modifyAttestedName: ByteArray => ByteArray) => + val testData = (RegistrationTestData.from _).tupled( + makeCred(modifyAttestedName = modifyAttestedName) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("Verify the sig is a valid signature over certInfo using the attestation public key in aikCert with the algorithm specified in alg.") { + val testData = (RegistrationTestData.from _).tupled(makeCred()) + forAll( + flipOneBit( + new ByteArray( + new AttestationObject( + testData.attestationObject + ).getAttestationStatement.get("sig").binaryValue() + ) + ) + ) { sig => + val mutatedTestData = testData.updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .set[ObjectNode]( + "sig", + jsonFactory.binaryNode(sig.getBytes), + ), + ) + val step = init(mutatedTestData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("Verify that aikCert meets the requirements in §8.3.1 TPM Attestation Statement Certificate Requirements.") { + it("Version MUST be set to 3.") { + val testData = + (RegistrationTestData.from _).tupled(makeCred()) + forAll(arbitrary[Byte] suchThat { _ != 2 }) { version => + val mutatedTestData = testData.updateAttestationObject( + "attStmt", + attStmt => { + val origAikCert = attStmt + .get("x5c") + .get(0) + .binaryValue + + val x509VerOffset = 12 + attStmt + .get("x5c") + .asInstanceOf[ArrayNode] + .set(0, origAikCert.updated(x509VerOffset, version)) + attStmt + }, + ) + val step = init(mutatedTestData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("Subject field MUST be set to empty.") { + it("Fails if a subject is set.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(subject = + new X500Name( + Array( + new RDN( + Array( + tcgAtTpmManufacturer, + tcgAtTpmModel, + tcgAtTpmVersion, + ) + ) + ) + ) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("The Subject Alternative Name extension MUST be set as defined in [TPMv2-EK-Profile] section 3.2.9.") { + it("Fails when manufacturer is absent.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(rdn = Array(tcgAtTpmModel, tcgAtTpmVersion)) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("Fails when model is absent.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(rdn = + Array(tcgAtTpmManufacturer, tcgAtTpmVersion) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("Fails when version is absent.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(rdn = Array(tcgAtTpmManufacturer, tcgAtTpmModel)) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("The Extended Key Usage extension MUST contain the OID 2.23.133.8.3 (\"joint-iso-itu-t(2) internationalorganizations(23) 133 tcg-kp(8) tcg-kp-AIKCertificate(3)\").") { + it("Fails when extended key usage is empty.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(extendedKeyUsage = Array.empty) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("""Fails when extended key usage contains only "serverAuth".""") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(extendedKeyUsage = + Array(new ASN1ObjectIdentifier("1.3.6.1.5.5.7.3.1")) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("The Basic Constraints extension MUST have the CA component set to false.") { + it( + "Fails when the attestation cert is a self-signed CA cert." + ) { + val testData = (RegistrationTestData.from _).tupled( + TestAuthenticator.createBasicAttestedCredential( + keyAlgorithm = COSEAlgorithmIdentifier.ES256, + attestationMaker = AttestationMaker.tpm( + AttestationSigner.selfsigned( + alg = COSEAlgorithmIdentifier.ES256, + certSubject = emptySubject, + issuerSubject = + Some(TestAuthenticator.Defaults.caCertSubject), + certExtensions = List( + ( + Extension.subjectAlternativeName.getId, + true, + new GeneralNamesBuilder() + .addName( + new GeneralName( + new X500Name( + Array( + new RDN( + Array( + tcgAtTpmManufacturer, + tcgAtTpmModel, + tcgAtTpmVersion, + ) + ) + ) + ) + ) + ) + .build(), + ), + ( + Extension.extendedKeyUsage.getId, + true, + new DERSequence(tcgKpAikCertificate), + ), + ), + validFrom = Instant.now(), + validTo = Instant.now().plusSeconds(600), + isCa = true, + ) + ), + ) + ) + val step = init(testData) + testData.attestationCertChain.head._1.getBasicConstraints should not be (-1) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL Distribution Point extension [RFC5280] are both OPTIONAL as the status of many attestation certificates is available through metadata services. See, for example, the FIDO Metadata Service [FIDOMetadataService].") { + it("Nothing to test.") {} + } + } + + describe("If aikCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) verify that the value of this extension matches the aaguid in authenticatorData.") { + it("Succeeds if the cert does not have the extension.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(aaguidInCert = None) + ) + val step = init(testData) + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it( + "Succeeds if the cert has the extension with the right value." + ) { + forAll(byteArray(16)) { aaguid => + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData( + aaguid = aaguid, + credentialKeypair = Some( + TestAuthenticator.Defaults.defaultKeypair( + COSEAlgorithmIdentifier.ES256 + ) + ), + ) + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + aaguidInCert = Some(aaguid), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + it( + "Fails if the cert has the extension with the wrong value." + ) { + forAll(byteArray(16), byteArray(16)) { + (aaguidInCred, aaguidInCert) => + whenever(aaguidInCred != aaguidInCert) { + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData( + aaguid = aaguidInCred, + credentialKeypair = Some( + TestAuthenticator.Defaults.defaultKeypair( + COSEAlgorithmIdentifier.ES256 + ) + ), + ) + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + aaguidInCert = Some(aaguidInCert), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + } + + describe("Other requirements:") { + it("RSA keys must have the SIGN_ENCRYPT attribute.") { + forAll( + Gen.chooseNum(0, Int.MaxValue.toLong * 2 + 1), + minSuccessful(5), + ) { attributes: Long => + val testData = (RegistrationTestData.from _).tupled( + makeCred( + credKeyAlgorithm = COSEAlgorithmIdentifier.RS256, + attributes = Some(attributes & ~Attributes.SIGN_ENCRYPT), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.RS256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("""RSA keys must have "symmetric" set to TPM_ALG_NULL""") { + forAll( + Gen.chooseNum(0, Short.MaxValue * 2 + 1), + minSuccessful(5), + ) { symmetric: Int => + whenever(symmetric != TPM_ALG_NULL) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + credKeyAlgorithm = COSEAlgorithmIdentifier.RS256, + symmetric = Some(symmetric), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.RS256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""RSA keys must have "scheme" set to TPM_ALG_RSASSA or TPM_ALG_NULL""") { + forAll( + Gen.chooseNum(0, Short.MaxValue * 2 + 1), + minSuccessful(5), + ) { scheme: Int => + whenever( + scheme != TpmRsaScheme.RSASSA && scheme != TPM_ALG_NULL + ) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + credKeyAlgorithm = COSEAlgorithmIdentifier.RS256, + scheme = Some(scheme), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.RS256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("ECC keys must have the SIGN_ENCRYPT attribute.") { + forAll( + Gen.chooseNum(0, Int.MaxValue.toLong * 2 + 1), + minSuccessful(5), + ) { attributes: Long => + val testData = (RegistrationTestData.from _).tupled( + makeCred( + credKeyAlgorithm = COSEAlgorithmIdentifier.ES256, + attributes = Some(attributes & ~Attributes.SIGN_ENCRYPT), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.ES256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("""ECC keys must have "symmetric" set to TPM_ALG_NULL""") { + forAll( + Gen.chooseNum(0, Short.MaxValue * 2 + 1), + minSuccessful(5), + ) { symmetric: Int => + whenever(symmetric != TPM_ALG_NULL) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + credKeyAlgorithm = COSEAlgorithmIdentifier.ES256, + symmetric = Some(symmetric), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.ES256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""ECC keys must have "scheme" set to TPM_ALG_NULL""") { + forAll( + Gen.chooseNum(0, Short.MaxValue * 2 + 1), + minSuccessful(5), + ) { scheme: Int => + whenever(scheme != TPM_ALG_NULL) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + credKeyAlgorithm = COSEAlgorithmIdentifier.ES256, + scheme = Some(scheme), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.ES256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + } + } + } + + ignore("The android-key statement format is supported.") { + val steps = finishRegistration(testData = + RegistrationTestData.AndroidKey.BasicAttestation + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + describe("For the android-safetynet attestation statement format") { + val verifier = new AndroidSafetynetAttestationStatementVerifier + val testDataContainer = RegistrationTestData.AndroidSafetynet + val defaultTestData = testDataContainer.BasicAttestation + + it("the attestation statement verifier implementation is AndroidSafetynetAttestationStatementVerifier.") { + val steps = finishRegistration( + testData = defaultTestData, + allowUntrustedAttestation = false, + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.getAttestationStatementVerifier.get shouldBe an[ + AndroidSafetynetAttestationStatementVerifier + ] + } + + describe("the verification procedure is:") { + def checkFails(testData: RegistrationTestData): Unit = { + val result: Try[Boolean] = Try( + verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + ) + + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + + describe("1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract the contained fields.") { + it("Fails if attStmt.ver is a number value.") { + val testData = defaultTestData + .updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .set[ObjectNode]("ver", jsonFactory.numberNode(123)), + ) + checkFails(testData) + } + + it("Fails if attStmt.ver is missing.") { + val testData = defaultTestData + .updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .without[ObjectNode]("ver"), + ) + checkFails(testData) + } + + it("Fails if attStmt.response is a text value.") { + val testData = defaultTestData + .updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .set[ObjectNode]( + "response", + jsonFactory.textNode( + new ByteArray( + attStmt.get("response").binaryValue() + ).getBase64Url + ), + ), + ) + checkFails(testData) + } + + it("Fails if attStmt.response is missing.") { + val testData = defaultTestData + .updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .without[ObjectNode]("response"), + ) + checkFails(testData) + } + } + + describe("2. Verify that response is a valid SafetyNet response of version ver by following the steps indicated by the SafetyNet online documentation. As of this writing, there is only one format of the SafetyNet response and ver is reserved for future use.") { + it("Fails if there's a difference in the signature.") { + val testData = defaultTestData + .updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .set[ObjectNode]( + "response", + jsonFactory.binaryNode( + editByte( + new ByteArray( + attStmt.get("response").binaryValue() + ), + 2000, + b => ((b + 1) % 26 + 0x41).toByte, + ).getBytes + ), + ), + ) + + val result: Try[Boolean] = Try( + verifier.verifyAttestationSignature( + new AttestationObject(testData.attestationObject), + testData.clientDataJsonHash, + ) + ) + + result shouldBe a[Success[_]] + result.get should be(false) + } + } + + describe("3. Verify that the nonce attribute in the payload of response is identical to the Base64 encoding of the SHA-256 hash of the concatenation of authenticatorData and clientDataHash.") { + it( + "Fails if an additional property is added to the client data." + ) { + val testData = defaultTestData.editClientData("foo", "bar") + checkFails(testData) + } + } + + describe("4. Verify that the SafetyNet response actually came from the SafetyNet service by following the steps in the SafetyNet online documentation.") { + it("Verify that attestationCert is issued to the hostname \"attest.android.com\".") { + checkFails(testDataContainer.WrongHostname) + } + + it("Verify that the ctsProfileMatch attribute in the payload of response is true.") { + checkFails(testDataContainer.FalseCtsProfileMatch) + } + } + + describe("5. If successful, return implementation-specific values representing attestation type Basic and attestation trust path x5c.") { + it("The real example succeeds.") { + val steps = finishRegistration( + testData = testDataContainer.RealExample + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType() should be(AttestationType.BASIC) + step.attestationTrustPath().get should not be empty + step.attestationTrustPath().get.size should be(2) + } + + it("The default test case succeeds.") { + val steps = finishRegistration(testData = + testDataContainer.BasicAttestation + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType() should be(AttestationType.BASIC) + step.attestationTrustPath().get should not be empty + step.attestationTrustPath().get.size should be(1) + } + } + } + } + + it("The android-safetynet statement format is supported.") { + val steps = finishRegistration( + testData = RegistrationTestData.AndroidSafetynet.RealExample + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("The apple statement format is supported.") { + val steps = finishRegistration( + testData = RealExamples.AppleAttestationIos.asRegistrationTestData + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it("Unknown attestation statement formats are identified as such.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + .setAttestationStatementFormat("urgel") + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.attestationType should be(AttestationType.UNKNOWN) + step.attestationTrustPath.toScala shouldBe empty + } + + it("(Deleted) If verification of the attestation statement failed, the Relying Party MUST fail the registration ceremony.") { + val steps = finishRegistration(testData = + RegistrationTestData.FidoU2f.BasicAttestation + .editClientData("foo", "bar") + ) + val step14: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step14.validations shouldBe a[Failure[_]] + Try(step14.next) shouldBe a[Failure[_]] + + Try(steps.run) shouldBe a[Failure[_]] + Try(steps.run).failed.get shouldBe an[IllegalArgumentException] + } + } + + describe("20. If validation is successful, obtain a list of acceptable trust anchors (i.e. attestation root certificates) for that attestation type and attestation statement format fmt, from a trusted source or from policy. For example, the FIDO Metadata Service [FIDOMetadataService] provides one way to obtain such information, using the aaguid in the attestedCredentialData in authData.") { + + val testData = RegistrationTestData.Packed.BasicAttestation + val (attestationRootCert, _) = + TestAuthenticator.generateAttestationCertificate() + + it("If an attestation trust source is set, it is used to get trust anchors.") { + val attestationTrustSource = new AttestationTrustSource { + override def findTrustRoots( + attestationCertificateChain: util.List[X509Certificate], + aaguid: Optional[ByteArray], + ): TrustRootsResult = + TrustRootsResult + .builder() + .trustRoots( + if ( + attestationCertificateChain + .get(0) + .equals( + CertificateParser.parseDer( + new AttestationObject( + testData.attestationObject + ).getAttestationStatement + .get("x5c") + .get(0) + .binaryValue() + ) + ) + ) { + Set(attestationRootCert).asJava + } else { + Set.empty[X509Certificate].asJava + } + ) + .build() + } + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some(attestationTrustSource), + ) + val step: FinishRegistrationSteps#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.getTrustRoots.toScala.map( + _.getTrustRoots.asScala + ) should equal( + Some(Set(attestationRootCert)) + ) + step.tryNext shouldBe a[Success[_]] + } + + it("When the AAGUID in authenticator data is zero, the AAGUID in the attestation certificate is used instead, if possible.") { + val example = RealExamples.SecurityKeyNfc + val testData = example.asRegistrationTestData + testData.aaguid should equal( + ByteArray.fromHex("00000000000000000000000000000000") + ) + val certAaguid = new ByteArray( + CertificateParser + .parseFidoAaguidExtension( + CertificateParser.parseDer(example.attestationCert.getBytes) + ) + .get + ) + + val attestationTrustSource = new AttestationTrustSource { + override def findTrustRoots( + attestationCertificateChain: util.List[X509Certificate], + aaguid: Optional[ByteArray], + ): TrustRootsResult = { + TrustRootsResult + .builder() + .trustRoots( + if (aaguid == Optional.of(certAaguid)) { + Set(attestationRootCert).asJava + } else { + Set.empty[X509Certificate].asJava + } + ) + .build() + } + } + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some(attestationTrustSource), + ) + val step: FinishRegistrationSteps#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.getTrustRoots.toScala.map( + _.getTrustRoots.asScala + ) should equal( + Some(Set(attestationRootCert)) + ) + step.tryNext shouldBe a[Success[_]] + } + + it( + "If an attestation trust source is not set, no trust anchors are returned." + ) { + val steps = finishRegistration( + testData = testData, + attestationTrustSource = None, + ) + val step: FinishRegistrationSteps#Step20 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.getTrustRoots.toScala shouldBe empty + step.tryNext shouldBe a[Success[_]] + } + } + + describe("21. Assess the attestation trustworthiness using the outputs of the verification procedure in step 19, as follows:") { + + describe("If no attestation was provided, verify that None attestation is acceptable under Relying Party policy.") { + describe("The default test case") { + it("is rejected if untrusted attestation is not allowed.") { + val steps = finishRegistration( + testData = RegistrationTestData.NoneAttestation.Default, + allowUntrustedAttestation = false, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + + it("is accepted if untrusted attestation is allowed.") { + val steps = finishRegistration( + testData = RegistrationTestData.NoneAttestation.Default, + allowUntrustedAttestation = true, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Success[_]] + } + } + } + + describe("(Not in spec:) If an unknown attestation statement format was used, check if no attestation is acceptable under Relying Party policy.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + .setAttestationStatementFormat("urgel") + + describe("The default test case") { + it("is rejected if untrusted attestation is not allowed.") { + val steps = finishRegistration( + testData = testData, + allowUntrustedAttestation = false, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + + it("is accepted if untrusted attestation is allowed.") { + val steps = finishRegistration( + testData = testData, + allowUntrustedAttestation = true, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Success[_]] + } + } + } + + describe("If self attestation was used, verify that self attestation is acceptable under Relying Party policy.") { + + describe("The default test case, with self attestation,") { + it("is rejected if untrusted attestation is not allowed.") { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.SelfAttestation, + allowUntrustedAttestation = false, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + + it("is accepted if untrusted attestation is allowed.") { + val steps = finishRegistration( + testData = RegistrationTestData.FidoU2f.SelfAttestation, + allowUntrustedAttestation = true, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Success[_]] + } + + it("is accepted if untrusted attestation is not allowed, but the self attestation key is a trust anchor.") { + val testData = RegistrationTestData.FidoU2f.SelfAttestation + val selfAttestationCert = CertificateParser.parseDer( + new AttestationObject( + testData.attestationObject + ).getAttestationStatement.get("x5c").get(0).binaryValue() + ) + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + selfAttestationCert, + crls = Some( + Set( + TestAuthenticator.buildCrl( + JcaX500NameUtil.getX500Name( + selfAttestationCert.getSubjectX500Principal + ), + WebAuthnTestCodecs.importPrivateKey( + testData.privateKey.get, + testData.alg, + ), + "SHA256withECDSA", + currentTime = + TestAuthenticator.Defaults.certValidFrom, + nextUpdate = TestAuthenticator.Defaults.certValidTo, + ) + ) + ), + ) + ), + allowUntrustedAttestation = false, + clock = Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(true) + step.tryNext shouldBe a[Success[_]] + } + } + } + + describe("Otherwise, use the X.509 certificates returned as the attestation trust path from the verification procedure to verify that the attestation public key either correctly chains up to an acceptable root certificate, or is itself an acceptable certificate (i.e., it and the root certificate obtained in Step 20 may be the same).") { + + def generateTests( + testData: RegistrationTestData, + clock: Clock, + trustedRootCert: Option[X509Certificate] = None, + enableRevocationChecking: Boolean = true, + origins: Option[Set[String]] = None, + policyTreeValidator: Option[Predicate[PolicyNode]] = None, + ): Unit = { + it("is rejected if untrusted attestation is not allowed and the trust source does not trust it.") { + val steps = finishRegistration( + allowUntrustedAttestation = false, + testData = testData, + attestationTrustSource = Some(emptyTrustSource), + clock = clock, + origins = origins, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + + it("is accepted if untrusted attestation is allowed and the trust source does not trust it.") { + val steps = finishRegistration( + allowUntrustedAttestation = true, + testData = testData, + attestationTrustSource = Some(emptyTrustSource), + clock = clock, + origins = origins, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Success[_]] + } + + it("is accepted if the trust source trusts it.") { + val attestationTrustSource: Option[AttestationTrustSource] = + trustedRootCert + .orElse(testData.attestationCertChain.lastOption.map(_._1)) + .map( + trustSourceWith( + _, + crls = testData.attestationCertChain.lastOption + .map({ + case (cert, key) => + Set( + TestAuthenticator.buildCrl( + JcaX500NameUtil.getSubject(cert), + key, + "SHA256withECDSA", + clock.instant(), + clock.instant().plusSeconds(3600 * 24), + ) + ) + }), + enableRevocationChecking = enableRevocationChecking, + policyTreeValidator = policyTreeValidator, + ) + ) + val steps = finishRegistration( + testData = testData, + attestationTrustSource = attestationTrustSource, + clock = clock, + origins = origins, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(true) + step.tryNext shouldBe a[Success[_]] + } + + it("is rejected if the attestation root cert appears in getCertStore but not in findTrustRoots.") { + val rootCert = trustedRootCert.getOrElse( + testData.attestationCertChain.last._1 + ) + val crl: Option[CRL] = + testData.attestationCertChain.lastOption + .map({ + case (cert, key) => + TestAuthenticator.buildCrl( + JcaX500NameUtil.getSubject(cert), + key, + "SHA256withECDSA", + clock.instant(), + clock.instant().plusSeconds(3600 * 24), + ) + }) + val certStore = CertStore.getInstance( + "Collection", + new CollectionCertStoreParameters( + (List(rootCert) ++ crl).asJava + ), + ) + + { + // First, check that the attestation is not trusted if the root cert appears only in getCertStore. + val attestationTrustSource = new AttestationTrustSource { + override def findTrustRoots( + attestationCertificateChain: util.List[X509Certificate], + aaguid: Optional[ByteArray], + ): TrustRootsResult = + TrustRootsResult + .builder() + .trustRoots(Collections.emptySet()) + .certStore(certStore) + .enableRevocationChecking(enableRevocationChecking) + .policyTreeValidator(policyTreeValidator.orNull) + .build() + } + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some(attestationTrustSource), + clock = clock, + origins = origins, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + + { + // Since the above assertions would also pass if the cert chain happens to be broken, or CRL resolution fails, etc, make sure that the attestation is indeed trusted if the root cert appears in findTrustRoots. + val attestationTrustSource = new AttestationTrustSource { + override def findTrustRoots( + attestationCertificateChain: util.List[X509Certificate], + aaguid: Optional[ByteArray], + ): TrustRootsResult = + TrustRootsResult + .builder() + .trustRoots(Collections.singleton(rootCert)) + .certStore(certStore) + .enableRevocationChecking(enableRevocationChecking) + .policyTreeValidator(policyTreeValidator.orNull) + .build() + } + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some(attestationTrustSource), + clock = clock, + origins = origins, + ) + val step: FinishRegistrationSteps#Step21 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(true) + step.tryNext shouldBe a[Success[_]] + } + } + } + + describe("An android-key basic attestation") { + ignore("fails for now.") { + fail("Test not implemented.") + } + } + + describe("An android-safetynet basic attestation") { + generateTests( + testData = RegistrationTestData.AndroidSafetynet.RealExample, + Clock + .fixed(Instant.parse("2019-01-01T00:00:00Z"), ZoneOffset.UTC), + trustedRootCert = Some( + CertificateParser.parsePem( + new String( + BinaryUtil.readAll( + getClass() + .getResourceAsStream("/globalsign-root-r2.pem") + ), + StandardCharsets.UTF_8, + ) + ) + ), + enableRevocationChecking = + false, // CRLs for this example are no longer available + ) + } + + describe("A fido-u2f basic attestation") { + generateTests( + testData = RegistrationTestData.FidoU2f.BasicAttestation, + Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + } + + describe("A packed basic attestation") { + generateTests( + testData = RegistrationTestData.Packed.BasicAttestation, + Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + } + + describe("A tpm attestation") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + generateTests( + testData = testData, + clock = Clock.fixed( + Instant.parse("2022-08-25T16:00:00Z"), + ZoneOffset.UTC, + ), + origins = Some(Set(testData.clientData.getOrigin)), + trustedRootCert = Some(testData.attestationRootCertificate.get), + enableRevocationChecking = false, + policyTreeValidator = Some(_ => true), + ) + } + + describe("Critical certificate policy extensions") { + def init( + policyTreeValidator: Option[Predicate[PolicyNode]] + ): FinishRegistrationSteps#Step21 = { + val testData = + RealExamples.WindowsHelloTpm.asRegistrationTestData + val clock = Clock.fixed( + Instant.parse("2022-08-25T16:00:00Z"), + ZoneOffset.UTC, + ) + val steps = finishRegistration( + allowUntrustedAttestation = false, + origins = Some(Set(testData.clientData.getOrigin)), + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.get, + enableRevocationChecking = false, + policyTreeValidator = policyTreeValidator, + ) + ), + clock = clock, + ) + + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + } + + it("are rejected if no policy tree validator is set.") { + // BouncyCastle provider does not reject critical policy extensions + // TODO Mark test as ignored instead of just skipping (assume() and cancel() currently break pitest) + if ( + !Security.getProviders + .exists(p => p.isInstanceOf[BouncyCastleProvider]) + ) { + val step = init(policyTreeValidator = None) + + step.validations shouldBe a[Failure[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + } + + it("are accepted if a policy tree validator is set and accepts the policy tree.") { + val step = init(policyTreeValidator = Some(_ => true)) + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(true) + step.tryNext shouldBe a[Success[_]] + } + + it("are rejected if a policy tree validator is set and does not accept the policy tree.") { + val step = init(policyTreeValidator = Some(_ => false)) + + step.validations shouldBe a[Failure[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + } + } + } + + describe("22. Check that the credentialId is not yet registered to any other user. If registration is requested for a credential that is already registered to a different user, the Relying Party SHOULD fail this registration ceremony, or it MAY decide to accept the registration, e.g. while deleting the older registration.") { + + val testData = RegistrationTestData.FidoU2f.SelfAttestation + + it("Registration is aborted if the given credential ID is already registered.") { + val credentialRepository = + Helpers.CredentialRepositoryV2.withUser( + testData.userId, + credentialId = testData.response.getId, + publicKeyCose = + testData.response.getResponse.getAttestation.getAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey, + signatureCount = 1337, + ) + + val steps = finishRegistration( + allowUntrustedAttestation = true, + testData = testData, + credentialRepository = credentialRepository, + ) + val step: FinishRegistrationSteps#Step22 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe an[Failure[_]] + } + + it("Registration proceeds if the given credential ID is not already registered.") { + val steps = finishRegistration( + allowUntrustedAttestation = true, + testData = testData, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + ) + val step: FinishRegistrationSteps#Step22 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + describe("23. If the attestation statement attStmt verified successfully and is found to be trustworthy, then register the new credential with the account that was denoted in options.user:") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationCertChain.last._1, + crls = Some( + testData.attestationCertChain.tail + .map({ + case (cert, key) => + TestAuthenticator.buildCrl( + JcaX500NameUtil.getSubject(cert), + key, + "SHA256withECDSA", + TestAuthenticator.Defaults.certValidFrom, + TestAuthenticator.Defaults.certValidTo, + ) + }) + .toSet + ), + ) + ), + credentialRepository = Helpers.CredentialRepositoryV2.empty, + clock = Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + val result = steps.run() + result.isAttestationTrusted should be(true) + + it("Associate the user’s account with the credentialId and credentialPublicKey in authData.attestedCredentialData, as appropriate for the Relying Party's system.") { + result.getKeyId.getId should be(testData.response.getId) + result.getPublicKeyCose should be( + testData.response.getResponse.getAttestation.getAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey + ) + } + + it("Associate the credentialId with a new stored signature counter value initialized to the value of authData.signCount.") { + result.getSignatureCount should be( + testData.response.getResponse.getAttestation.getAuthenticatorData.getSignatureCounter + ) + } + + describe("It is RECOMMENDED to also:") { + it("Associate the credentialId with the transport hints returned by calling credential.response.getTransports(). This value SHOULD NOT be modified before or after storing it. It is RECOMMENDED to use this value to populate the transports of the allowCredentials option in future get() calls to help the client know how to find a suitable authenticator.") { + result.getKeyId.getTransports.toScala should equal( + Some( + testData.response.getResponse.getTransports + ) + ) + } + } + } + + describe("24. If the attestation statement attStmt successfully verified but is not trustworthy per step 21 above, the Relying Party SHOULD fail the registration ceremony.") { + it("The test case with self attestation succeeds, but reports attestation is not trusted.") { + val testData = RegistrationTestData.Packed.SelfAttestation + val steps = finishRegistration( + testData = testData, + allowUntrustedAttestation = true, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + attestationTrustSource = Some(emptyTrustSource), + ) + steps.run.getKeyId.getId should be(testData.response.getId) + steps.run.isAttestationTrusted should be(false) + } + + describe("The test case with unknown attestation") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + .setAttestationStatementFormat("urgel") + + it("passes if the RP allows untrusted attestation.") { + val steps = finishRegistration( + testData = testData, + allowUntrustedAttestation = true, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + ) + val result = Try(steps.run) + result shouldBe a[Success[_]] + result.get.isAttestationTrusted should be(false) + result.get.getAttestationType should be(AttestationType.UNKNOWN) + } + + it("fails if the RP required trusted attestation.") { + val steps = finishRegistration( + testData = testData, + allowUntrustedAttestation = false, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + ) + val result = Try(steps.run) + result shouldBe a[Failure[_]] + result.failed.get shouldBe an[IllegalArgumentException] + } + } + + def testUntrusted(testData: RegistrationTestData): Unit = { + val fmt = + new AttestationObject(testData.attestationObject).getFormat + it(s"""A test case with good "${fmt}" attestation but no attestation trust source succeeds, but reports attestation as not trusted.""") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + val steps = finishRegistration( + testData = testData, + attestationTrustSource = None, + allowUntrustedAttestation = true, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + ) + steps.run.getKeyId.getId should be(testData.response.getId) + steps.run.isAttestationTrusted should be(false) + } + } + + testUntrusted(RegistrationTestData.AndroidKey.BasicAttestation) + testUntrusted(RegistrationTestData.AndroidSafetynet.BasicAttestation) + testUntrusted(RegistrationTestData.FidoU2f.BasicAttestation) + testUntrusted(RegistrationTestData.NoneAttestation.Default) + testUntrusted(RealExamples.WindowsHelloTpm.asRegistrationTestData) + } + } + } + + describe("The default RelyingParty settings") { + + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Test party") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + + val request = rp + .startRegistration( + StartRegistrationOptions + .builder() + .user( + UserIdentity + .builder() + .name("test") + .displayName("Test Testsson") + .id(new ByteArray(Array())) + .build() + ) + .build() + ) + .toBuilder() + .challenge( + RegistrationTestData.NoneAttestation.Default.clientData.getChallenge + ) + .build() + + it("accept registrations with no attestation.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(RegistrationTestData.NoneAttestation.Default.response) + .build() + ) + + result.isAttestationTrusted should be(false) + result.getAttestationType should be(AttestationType.NONE) + result.getKeyId.getId should equal( + RegistrationTestData.NoneAttestation.Default.response.getId + ) + } + + it( + "accept registrations with unknown attestation statement format." + ) { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + .setAttestationStatementFormat("urgel") + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(testData.response) + .build() + ) + + result.isAttestationTrusted should be(false) + result.getAttestationType should be(AttestationType.UNKNOWN) + result.getKeyId.getId should equal(testData.response.getId) + } + + it("accept android-key attestations but report they're untrusted.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response( + RegistrationTestData.AndroidKey.BasicAttestation.response + ) + .build() + ) + + result.isAttestationTrusted should be(false) + result.getKeyId.getId should equal( + RegistrationTestData.AndroidKey.BasicAttestation.response.getId + ) + } + + it("accept TPM attestations but report they're untrusted.") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + val result = rp.toBuilder + .identity(testData.rpId) + .origins(Set("https://dev.d2urpypvrhb05x.amplifyapp.com").asJava) + .build() + .finishRegistration( + FinishRegistrationOptions + .builder() + .request( + request.toBuilder.challenge(testData.responseChallenge).build() + ) + .response(testData.response) + .build() + ) + + result.isAttestationTrusted should be(false) + result.getKeyId.getId should equal( + RealExamples.WindowsHelloTpm.asRegistrationTestData.response.getId + ) + } + + describe("accept apple attestations but report they're untrusted:") { + it("iOS") { + val result = rp + .toBuilder() + .identity(RealExamples.AppleAttestationIos.rp) + .origins( + Set( + RealExamples.AppleAttestationIos.attestation.collectedClientData.getOrigin + ).asJava + ) + .build() + .finishRegistration( + FinishRegistrationOptions + .builder() + .request( + request + .toBuilder() + .challenge( + RealExamples.AppleAttestationIos.attestation.collectedClientData.getChallenge + ) + .build() + ) + .response( + RealExamples.AppleAttestationIos.attestation.credential + ) + .build() + ) + + result.isAttestationTrusted should be(false) + RealExamples.AppleAttestationIos.attestation.credential.getResponse.getAttestation.getFormat should be( + "apple" + ) + result.getAttestationType should be( + AttestationType.ANONYMIZATION_CA + ) + result.getKeyId.getId should equal( + RealExamples.AppleAttestationIos.attestation.credential.getId + ) + } + + it("MacOS") { + val result = rp + .toBuilder() + .identity(RealExamples.AppleAttestationMacos.rp) + .origins( + Set( + RealExamples.AppleAttestationMacos.attestation.collectedClientData.getOrigin + ).asJava + ) + .build() + .finishRegistration( + FinishRegistrationOptions + .builder() + .request( + request + .toBuilder() + .challenge( + RealExamples.AppleAttestationMacos.attestation.collectedClientData.getChallenge + ) + .build() + ) + .response( + RealExamples.AppleAttestationMacos.attestation.credential + ) + .build() + ) + + result.isAttestationTrusted should be(false) + RealExamples.AppleAttestationMacos.attestation.credential.getResponse.getAttestation.getFormat should be( + "apple" + ) + result.getAttestationType should be( + AttestationType.ANONYMIZATION_CA + ) + result.getKeyId.getId should equal( + RealExamples.AppleAttestationMacos.attestation.credential.getId + ) + } + } + + describe("accept all test examples in the validExamples list.") { + RegistrationTestData.defaultSettingsValidExamples.zipWithIndex + .foreach { + case (testData, i) => + it(s"Succeeds for example index ${i} (${testData.alg}, ${testData.attestationStatementFormat}).") { + val rp = RelyingParty + .builder() + .identity(testData.rpId) + .credentialRepositoryV2( + Helpers.CredentialRepositoryV2.empty + ) + .origins(Set(testData.clientData.getOrigin).asJava) + .build() + + val request = rp + .startRegistration( + StartRegistrationOptions + .builder() + .user(testData.userId) + .build() + ) + .toBuilder + .challenge(testData.request.getChallenge) + .build() + + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(testData.response) + .build() + ) + + result.getKeyId.getId should equal(testData.response.getId) + } + } + } + + describe("generate pubKeyCredParams which") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user( + UserIdentity + .builder() + .name("foo") + .displayName("Foo") + .id(ByteArray.fromHex("aabbccdd")) + .build() + ) + .build() + ) + + val pubKeyCredParams = pkcco.getPubKeyCredParams.asScala + + describe("include") { + it("ES256.") { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.ES256 + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.ES256 + ) + } + + it("ES384.") { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.ES384 + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.ES384 + ) + } + + it("ES512.") { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.ES512 + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.ES512 + ) + } + + it("EdDSA, when available.") { + // The RelyingParty constructor call needs to be here inside the `it` call in order to have the right JCA provider environment + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Test party") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user( + UserIdentity + .builder() + .name("foo") + .displayName("Foo") + .id(ByteArray.fromHex("aabbccdd")) + .build() + ) + .build() + ) + val pubKeyCredParams = pkcco.getPubKeyCredParams.asScala + + if (Try(KeyFactory.getInstance("EdDSA")).isSuccess) { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.EdDSA + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.EdDSA + ) + } else { + pubKeyCredParams should not contain ( + PublicKeyCredentialParameters.EdDSA + ) + pubKeyCredParams map (_.getAlg) should not contain ( + COSEAlgorithmIdentifier.EdDSA + ) + } + } + + it("RS256.") { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.RS256 + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.RS256 + ) + } + + it("RS384.") { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.RS384 + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.RS384 + ) + } + + it("RS512.") { + pubKeyCredParams should contain( + PublicKeyCredentialParameters.RS512 + ) + pubKeyCredParams map (_.getAlg) should contain( + COSEAlgorithmIdentifier.RS512 + ) + } + } + + describe("do not include") { + it("RS1.") { + pubKeyCredParams should not contain PublicKeyCredentialParameters.RS1 + pubKeyCredParams map (_.getAlg) should not contain COSEAlgorithmIdentifier.RS1 + } + } + } + + describe("expose the credProps extension output as RegistrationResult.isDiscoverable()") { + val testDataBase = RegistrationTestData.Packed.BasicAttestation + val testData = testDataBase.copy(requestedExtensions = + testDataBase.request.getExtensions.toBuilder.credProps().build() + ) + + it("when set to true.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.request) + .response( + testData.response.toBuilder + .clientExtensionResults( + ClientRegistrationExtensionOutputs + .builder() + .credProps( + newCredentialPropertiesOutput(true) + ) + .build() + ) + .build() + ) + .build() + ) + + result.isDiscoverable.toScala should equal(Some(true)) + } + + it("when set to false.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.request) + .response( + testData.response.toBuilder + .clientExtensionResults( + ClientRegistrationExtensionOutputs + .builder() + .credProps( + newCredentialPropertiesOutput(false) + ) + .build() + ) + .build() + ) + .build() + ) + + result.isDiscoverable.toScala should equal(Some(false)) + } + + it("when not available.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.request) + .response(testData.response) + .build() + ) + + result.isDiscoverable.toScala should equal(None) + } + } + + describe("support the largeBlob extension") { + it("being enabled at registration time.") { + val testData = RegistrationTestData.Packed.BasicAttestation + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request( + testData.request.toBuilder + .extensions( + RegistrationExtensionInputs + .builder() + .largeBlob(LargeBlobSupport.REQUIRED) + .build() + ) + .build() + ) + .response( + testData.response.toBuilder + .clientExtensionResults( + ClientRegistrationExtensionOutputs + .builder() + .largeBlob( + ReexportHelpers.newLargeBlobRegistrationOutput(true) + ) + .build() + ) + .build() + ) + .build() + ) + + result.getClientExtensionOutputs.get.getLargeBlob.get.isSupported should be( + true + ) + } + } + + describe("support the uvm extension") { + it("at registration time.") { + + // Example from spec: https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-uvm-extension + // A1 -- extension: CBOR map of one element + // 63 -- Key 1: CBOR text string of 3 bytes + // 75 76 6d -- "uvm" [=UTF-8 encoded=] string + // 82 -- Value 1: CBOR array of length 2 indicating two factor usage + // 83 -- Item 1: CBOR array of length 3 + // 02 -- Subitem 1: CBOR integer for User Verification Method Fingerprint + // 04 -- Subitem 2: CBOR short for Key Protection Type TEE + // 02 -- Subitem 3: CBOR short for Matcher Protection Type TEE + // 83 -- Item 2: CBOR array of length 3 + // 04 -- Subitem 1: CBOR integer for User Verification Method Passcode + // 01 -- Subitem 2: CBOR short for Key Protection Type Software + // 01 -- Subitem 3: CBOR short for Matcher Protection Type Software + val uvmCborExample = ByteArray.fromHex("A16375766d828302040283040101") + + val challenge = TestAuthenticator.Defaults.challenge + val (cred, _, _) = TestAuthenticator.createUnattestedCredential( + authenticatorExtensions = + Some(JacksonCodecs.cbor().readTree(uvmCborExample.getBytes)), + challenge = challenge, + ) + + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request( + PublicKeyCredentialCreationOptions + .builder() + .rp( + RelyingPartyIdentity + .builder() + .id(TestAuthenticator.Defaults.rpId) + .name("Test RP") + .build() + ) + .user( + UserIdentity + .builder() + .name("foo") + .displayName("Foo User") + .id(ByteArray.fromHex("00010203")) + .build() + ) + .challenge(challenge) + .pubKeyCredParams( + List(PublicKeyCredentialParameters.ES256).asJava + ) + .extensions( + RegistrationExtensionInputs + .builder() + .uvm() + .build() + ) + .build() + ) + .response(cred) + .build() + ) + + result.getAuthenticatorExtensionOutputs.get.getUvm.toScala should equal( + Some( + List( + new UvmEntry( + UserVerificationMethod.USER_VERIFY_FINGERPRINT_INTERNAL, + KeyProtectionType.KEY_PROTECTION_TEE, + MatcherProtectionType.MATCHER_PROTECTION_TEE, + ), + new UvmEntry( + UserVerificationMethod.USER_VERIFY_PASSCODE_INTERNAL, + KeyProtectionType.KEY_PROTECTION_SOFTWARE, + MatcherProtectionType.MATCHER_PROTECTION_SOFTWARE, + ), + ).asJava + ) + ) + } + } + } + + describe("RelyingParty supports registering") { + it("a real packed attestation with an RSA key.") { + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("demo3.yubico.test") + .name("Yubico WebAuthn demo") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .origins(Set("https://demo3.yubico.test:8443").asJava) + .build() + + val testData = RegistrationTestData.Packed.BasicAttestationRsaReal + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.request) + .response(testData.response) + .build() + ) + + result.isAttestationTrusted should be(false) + result.getKeyId.getId should equal(testData.response.getId) + } + } + + describe("The RegistrationResult") { + describe("exposes getTransports() which") { + + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("example.com") + .name("Example RP") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + val user = UserIdentity.builder + .name("foo") + .displayName("Foo User") + .id(new ByteArray(Array(0, 1, 2, 3))) + .build() + + val request = PublicKeyCredentialCreationOptions + .builder() + .rp(rp.getIdentity) + .user(user) + .challenge(ByteArray.fromBase64Url("Y2hhbGxlbmdl")) + .pubKeyCredParams(List(PublicKeyCredentialParameters.ES256).asJava) + .build() + + it("contains the returned transports when available.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(PublicKeyCredential.parseRegistrationResponseJson("""{ + "type": "public-key", + "id": "LbYHDfeoEJ-ItG8lq6fjNVnhg6kgbebGjYWEf32ZpyChibGv4gJU1OGM0nOQQY5G", + "response": { + "clientDataJSON": "eyJ0eXBlIjogIndlYmF1dGhuLmNyZWF0ZSIsICJjbGllbnRFeHRlbnNpb25zIjoge30sICJjaGFsbGVuZ2UiOiAiWTJoaGJHeGxibWRsIiwgIm9yaWdpbiI6ICJodHRwczovL2V4YW1wbGUuY29tIn0", + "attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIhAJKRPuYlfW8dZZlsJrJiwA-BvAyOvIe1TScv5qlek1SQAiAnglgs-nRjA7kpc61PewQ4VULjdlzLmReI7-MJT1TLrGN4NWOBWQLBMIICvTCCAaWgAwIBAgIEMAIspTANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgODA1NDQ4ODY5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE-66HSEytO3plXno3zPhH1k-zFwWxESIdrTbQp4HSEuzFum1Mwpy8itoOosBQksnIrefLHkTRNUtV8jIrFKAvbaNsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBlZXnJy-X3fJfNdlIdIQlFpO5-A5uM41jJ2XgdRag_8rSxXCz98t_jyoWth5FQF9As96Ags3p-Lyaqb1bpEc9RfmkxiiqwDzDI56Sj4HKlANF2tddm-ew29H9yaNbpU5y6aleCeH2rR4t1cFgcBRAV84IndIH0cYASRnyrFbHjI80vlPNR0z4j-_W9vYEWBpLeS_wrdKPVW7C7wyuc4bobauCyhElBPZUwblR_Ll0iovmfazD17VLCBMA4p_SVVTwSXpKyZjMiCotj8mDhQ1ymhvCepkK82EwnrBMJIzCi_joxAXqxLPMs6yJrz_hFUkZaloa1ZS6f7aGAmAKhRNO2aGF1dGhEYXRhWMSjeab27q-5pV43jBGANOJ1Hmgvq58tMKsT0hJVhs4ZR0EAAAAAAAAAAAAAAAAAAAAAAAAAAABAJT086Ym5LhLsK6MRwYRSdjVn9jVYVtwiGwgq_bDPpVuI3aaOW7UQfqGWdos-kVwHnQccbDRnQDvQmCDqy6QdSaUBAgMmIAEhWCCRGd2Bo0vIj-suQxM-cOCXovv1Ag6azqHn8PE31Fcu4iJYIOiLha_PR9JwOhCw4SC2Xq7cOackGAMsq4UUJ_IRCCcq", + "transports": ["nfc", "usb"] + }, + "clientExtensionResults": {} + }""")) + .build() + ) + + result.getKeyId.getTransports.toScala.map(_.asScala) should equal( + Some(Set(AuthenticatorTransport.USB, AuthenticatorTransport.NFC)) + ) + } + + it( + "returns present but empty when transport hints are not available." + ) { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(PublicKeyCredential.parseRegistrationResponseJson("""{ + "type": "public-key", + "id": "LbYHDfeoEJ-ItG8lq6fjNVnhg6kgbebGjYWEf32ZpyChibGv4gJU1OGM0nOQQY5G", + "response": { + "clientDataJSON": "eyJ0eXBlIjogIndlYmF1dGhuLmNyZWF0ZSIsICJjbGllbnRFeHRlbnNpb25zIjoge30sICJjaGFsbGVuZ2UiOiAiWTJoaGJHeGxibWRsIiwgIm9yaWdpbiI6ICJodHRwczovL2V4YW1wbGUuY29tIn0", + "attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIhAJKRPuYlfW8dZZlsJrJiwA-BvAyOvIe1TScv5qlek1SQAiAnglgs-nRjA7kpc61PewQ4VULjdlzLmReI7-MJT1TLrGN4NWOBWQLBMIICvTCCAaWgAwIBAgIEMAIspTANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgODA1NDQ4ODY5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE-66HSEytO3plXno3zPhH1k-zFwWxESIdrTbQp4HSEuzFum1Mwpy8itoOosBQksnIrefLHkTRNUtV8jIrFKAvbaNsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBlZXnJy-X3fJfNdlIdIQlFpO5-A5uM41jJ2XgdRag_8rSxXCz98t_jyoWth5FQF9As96Ags3p-Lyaqb1bpEc9RfmkxiiqwDzDI56Sj4HKlANF2tddm-ew29H9yaNbpU5y6aleCeH2rR4t1cFgcBRAV84IndIH0cYASRnyrFbHjI80vlPNR0z4j-_W9vYEWBpLeS_wrdKPVW7C7wyuc4bobauCyhElBPZUwblR_Ll0iovmfazD17VLCBMA4p_SVVTwSXpKyZjMiCotj8mDhQ1ymhvCepkK82EwnrBMJIzCi_joxAXqxLPMs6yJrz_hFUkZaloa1ZS6f7aGAmAKhRNO2aGF1dGhEYXRhWMSjeab27q-5pV43jBGANOJ1Hmgvq58tMKsT0hJVhs4ZR0EAAAAAAAAAAAAAAAAAAAAAAAAAAABAJT086Ym5LhLsK6MRwYRSdjVn9jVYVtwiGwgq_bDPpVuI3aaOW7UQfqGWdos-kVwHnQccbDRnQDvQmCDqy6QdSaUBAgMmIAEhWCCRGd2Bo0vIj-suQxM-cOCXovv1Ag6azqHn8PE31Fcu4iJYIOiLha_PR9JwOhCw4SC2Xq7cOackGAMsq4UUJ_IRCCcq" + }, + "clientExtensionResults": {} + }""")) + .build() + ) + + result.getKeyId.getTransports.toScala.map(_.asScala) should equal( + Some(Set.empty) + ) + } + + it("returns present but empty when transport hints are empty.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(PublicKeyCredential.parseRegistrationResponseJson("""{ + "type": "public-key", + "id": "LbYHDfeoEJ-ItG8lq6fjNVnhg6kgbebGjYWEf32ZpyChibGv4gJU1OGM0nOQQY5G", + "response": { + "clientDataJSON": "eyJ0eXBlIjogIndlYmF1dGhuLmNyZWF0ZSIsICJjbGllbnRFeHRlbnNpb25zIjoge30sICJjaGFsbGVuZ2UiOiAiWTJoaGJHeGxibWRsIiwgIm9yaWdpbiI6ICJodHRwczovL2V4YW1wbGUuY29tIn0", + "attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIhAJKRPuYlfW8dZZlsJrJiwA-BvAyOvIe1TScv5qlek1SQAiAnglgs-nRjA7kpc61PewQ4VULjdlzLmReI7-MJT1TLrGN4NWOBWQLBMIICvTCCAaWgAwIBAgIEMAIspTANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgODA1NDQ4ODY5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE-66HSEytO3plXno3zPhH1k-zFwWxESIdrTbQp4HSEuzFum1Mwpy8itoOosBQksnIrefLHkTRNUtV8jIrFKAvbaNsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBlZXnJy-X3fJfNdlIdIQlFpO5-A5uM41jJ2XgdRag_8rSxXCz98t_jyoWth5FQF9As96Ags3p-Lyaqb1bpEc9RfmkxiiqwDzDI56Sj4HKlANF2tddm-ew29H9yaNbpU5y6aleCeH2rR4t1cFgcBRAV84IndIH0cYASRnyrFbHjI80vlPNR0z4j-_W9vYEWBpLeS_wrdKPVW7C7wyuc4bobauCyhElBPZUwblR_Ll0iovmfazD17VLCBMA4p_SVVTwSXpKyZjMiCotj8mDhQ1ymhvCepkK82EwnrBMJIzCi_joxAXqxLPMs6yJrz_hFUkZaloa1ZS6f7aGAmAKhRNO2aGF1dGhEYXRhWMSjeab27q-5pV43jBGANOJ1Hmgvq58tMKsT0hJVhs4ZR0EAAAAAAAAAAAAAAAAAAAAAAAAAAABAJT086Ym5LhLsK6MRwYRSdjVn9jVYVtwiGwgq_bDPpVuI3aaOW7UQfqGWdos-kVwHnQccbDRnQDvQmCDqy6QdSaUBAgMmIAEhWCCRGd2Bo0vIj-suQxM-cOCXovv1Ag6azqHn8PE31Fcu4iJYIOiLha_PR9JwOhCw4SC2Xq7cOackGAMsq4UUJ_IRCCcq", + "transports": [] + }, + "clientExtensionResults": {} + }""")) + .build() + ) + + result.getKeyId.getTransports.toScala.map(_.asScala) should equal( + Some(Set.empty) + ) + } + } + + describe( + "exposes getAttestationTrustPath() with the attestation trust path" + ) { + it("for a fido-u2f attestation.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationCertChain.last._1, + crls = Some( + testData.attestationCertChain + .map({ + case (cert, key) => + TestAuthenticator.buildCrl( + JcaX500NameUtil.getSubject(cert), + key, + "SHA256withECDSA", + TestAuthenticator.Defaults.certValidFrom, + TestAuthenticator.Defaults.certValidTo, + ) + }) + .toSet + ), + ) + ), + credentialRepository = Helpers.CredentialRepositoryV2.empty, + clock = Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + val result = steps.run() + result.isAttestationTrusted should be(true) + result.getAttestationTrustPath.toScala.map(_.asScala) should equal( + Some(testData.attestationCertChain.init.map(_._1)) + ) + } + + it("for a tpm attestation.") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + val steps = finishRegistration( + testData = testData, + origins = Some(Set("https://dev.d2urpypvrhb05x.amplifyapp.com")), + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.get, + enableRevocationChecking = false, + policyTreeValidator = Some(_ => true), + ) + ), + credentialRepository = Helpers.CredentialRepositoryV2.empty, + clock = Clock.fixed( + Instant.parse("2022-05-11T12:34:50Z"), + ZoneOffset.UTC, + ), + ) + val result = steps.run() + result.isAttestationTrusted should be(true) + } + } + + it("exposes getAaguid() with the authenticator AAGUID.") { + val testData = RegistrationTestData.Packed.BasicAttestation + val steps = finishRegistration( + testData = testData, + credentialRepository = Helpers.CredentialRepositoryV2.empty, + allowUntrustedAttestation = true, + ) + val result = steps.run() + result.getAaguid should equal( + testData.response.getResponse.getAttestation.getAuthenticatorData.getAttestedCredentialData.get.getAaguid + ) + } + + { + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Example RP") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + val user = UserIdentity.builder + .name("foo") + .displayName("Foo User") + .id(new ByteArray(Array(0, 1, 2, 3))) + .build() + + val request = PublicKeyCredentialCreationOptions + .builder() + .rp(rp.getIdentity) + .user(user) + .challenge(ByteArray.fromBase64Url("Y2hhbGxlbmdl")) + .pubKeyCredParams(List(PublicKeyCredentialParameters.ES256).asJava) + .build() + + it("exposes isUserVerified() with the UV flag value in authenticator data.") { + val (pkcWithoutUv, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithUv, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x04.toByte)), + challenge = request.getChallenge, + ) + + val resultWithoutUv = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithoutUv) + .build() + ) + val resultWithUv = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithUv) + .build() + ) + + resultWithoutUv.isUserVerified should be(false) + resultWithUv.isUserVerified should be(true) + } + + it("exposes isBackupEligible() with the BE flag value in authenticator data.") { + val (pkcWithoutBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = request.getChallenge, + ) + + val resultWithoutBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + val resultWithBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + + resultWithoutBackup.isBackupEligible should be(false) + resultWithBackup.isBackupEligible should be(true) + } + + it( + "exposes isBackedUp() with the BS flag value in authenticator data." + ) { + val (pkcWithoutBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithBeOnly, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x18.toByte)), + challenge = request.getChallenge, + ) + + val resultWithBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + val resultWithBeOnly = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithBeOnly) + .build() + ) + val resultWithoutBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + + resultWithoutBackup.isBackedUp should be(false) + resultWithBeOnly.isBackedUp should be(false) + resultWithBackup.isBackedUp should be(true) + } + + it( + "exposes getAuthenticatorAttachment() with the authenticatorAttachment value from the PublicKeyCredential." + ) { + val (pkcTemplate, _, _) = + TestAuthenticator.createUnattestedCredential(challenge = + request.getChallenge + ) + + forAll { authenticatorAttachment: Option[AuthenticatorAttachment] => + val pkc = pkcTemplate.toBuilder + .authenticatorAttachment(authenticatorAttachment.orNull) + .build() + + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkc) + .build() + ) + + result.getAuthenticatorAttachment should equal( + pkc.getAuthenticatorAttachment + ) + } + } + } + } + + } + + describe("RelyingParty.finishRegistration") { + it("supports 1023 bytes long credential IDs.") { + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Test party") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user( + UserIdentity + .builder() + .name("test") + .displayName("Test Testsson") + .id(new ByteArray(Array())) + .build() + ) + .build() + ) + + forAll(byteArray(1023)) { credId => + val credential = TestAuthenticator + .createUnattestedCredential(challenge = pkcco.getChallenge) + ._1 + .toBuilder() + .id(credId) + .build() + + val result = Try( + rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(pkcco) + .response(credential) + .build() + ) + ) + result shouldBe a[Success[_]] + result.get.getKeyId.getId should equal(credId) + result.get.getKeyId.getId.size should be(1023) + } + } + + it("throws RegistrationFailedException in case of errors.") { + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Test party") + .build() + ) + .credentialRepositoryV2(Helpers.CredentialRepositoryV2.empty) + .build() + + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user( + UserIdentity + .builder() + .name("test") + .displayName("Test Testsson") + .id(new ByteArray(Array())) + .build() + ) + .build() + ) + + val result = Try( + rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(pkcco) + .response(RegistrationTestData.NoneAttestation.Default.response) + .build() + ) + ) + result shouldBe a[Failure[_]] + result.failed.get shouldBe a[RegistrationFailedException] + result.failed.get.getMessage should include("Incorrect challenge") + } + } + +} diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala index 2a9395fe9..efb1791a0 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala @@ -1,8 +1,13 @@ package com.yubico.webauthn.test +import com.yubico.webauthn.CredentialRecord import com.yubico.webauthn.CredentialRepository +import com.yubico.webauthn.CredentialRepositoryV2 import com.yubico.webauthn.RegisteredCredential import com.yubico.webauthn.RegistrationResult +import com.yubico.webauthn.RegistrationTestData +import com.yubico.webauthn.UsernameRepository +import com.yubico.webauthn.data.AuthenticatorTransport import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.UserIdentity @@ -13,6 +18,9 @@ import scala.jdk.OptionConverters.RichOption object Helpers { + def toJava(o: Option[scala.Boolean]): Optional[java.lang.Boolean] = + o.toJava.map((b: scala.Boolean) => b) + object CredentialRepository { val empty = new CredentialRepository { override def getCredentialIdsForUsername( @@ -97,6 +105,161 @@ object Helpers { } } + object CredentialRepositoryV2 { + def empty[C <: CredentialRecord] = + new CredentialRepositoryV2[C] { + override def getCredentialIdsForUserHandle( + userHandle: ByteArray + ): java.util.Set[PublicKeyCredentialDescriptor] = Set.empty.asJava + override def lookup( + credentialId: ByteArray, + userHandle: ByteArray, + ): Optional[C] = None.toJava + override def credentialIdExists( + credentialId: ByteArray + ): Boolean = false + } + def unimplemented[C <: CredentialRecord] = + new CredentialRepositoryV2[C] { + override def getCredentialIdsForUserHandle( + userHandle: ByteArray + ): java.util.Set[PublicKeyCredentialDescriptor] = ??? + override def lookup( + credentialId: ByteArray, + userHandle: ByteArray, + ): Optional[C] = ??? + override def credentialIdExists( + credentialId: ByteArray + ): Boolean = ??? + } + + class CountingCalls[C <: CredentialRecord](inner: CredentialRepositoryV2[C]) + extends CredentialRepositoryV2[C] { + var getCredentialIdsCount = 0 + var lookupCount = 0 + var credentialIdExistsCount = 0 + + override def getCredentialIdsForUserHandle( + userHandle: ByteArray + ): java.util.Set[PublicKeyCredentialDescriptor] = { + getCredentialIdsCount += 1 + inner.getCredentialIdsForUserHandle(userHandle) + } + + override def lookup( + credentialId: ByteArray, + userHandle: ByteArray, + ): Optional[C] = { + lookupCount += 1 + inner.lookup(credentialId, userHandle) + } + + override def credentialIdExists(credentialId: ByteArray) = { + credentialIdExistsCount += 1 + inner.credentialIdExists(credentialId) + } + } + + def withUsers[C <: CredentialRecord]( + users: (UserIdentity, C)* + ): CredentialRepositoryV2[C] = { + new CredentialRepositoryV2[C] { + override def getCredentialIdsForUserHandle( + userHandle: ByteArray + ): java.util.Set[PublicKeyCredentialDescriptor] = + users + .filter({ + case (u, c) => + u.getId == userHandle && c.getUserHandle == userHandle + }) + .map({ + case (_, credential) => + PublicKeyCredentialDescriptor + .builder() + .id(credential.getCredentialId) + .transports(credential.getTransports) + .build() + }) + .toSet + .asJava + + override def lookup( + credentialId: ByteArray, + userHandle: ByteArray, + ): Optional[C] = + users + .find(_._1.getId == userHandle) + .map(_._2) + .filter(cred => + cred.getUserHandle == userHandle && cred.getCredentialId == credentialId + ) + .toJava + + override def credentialIdExists( + credentialId: ByteArray + ): Boolean = + users.exists(_._2.getCredentialId == credentialId) + } + } + + def withUser( + user: UserIdentity, + credentialId: ByteArray, + publicKeyCose: ByteArray, + signatureCount: Long = 0, + be: Option[Boolean] = None, + bs: Option[Boolean] = None, + ): CredentialRepositoryV2[CredentialRecord] = { + withUsers( + ( + user, + credentialRecord( + credentialId = credentialId, + userHandle = user.getId, + publicKeyCose = publicKeyCose, + signatureCount = signatureCount, + be = be, + bs = bs, + ), + ) + ) + } + } + + object UsernameRepository { + val empty = + new UsernameRepository { + override def getUserHandleForUsername( + username: String + ): Optional[ByteArray] = None.toJava + override def getUsernameForUserHandle( + userHandle: ByteArray + ): Optional[String] = None.toJava + } + def unimplemented[C <: CredentialRecord] = + new UsernameRepository { + override def getUserHandleForUsername( + username: String + ): Optional[ByteArray] = ??? + override def getUsernameForUserHandle( + userHandle: ByteArray + ): Optional[String] = ??? + } + + def withUsers(users: UserIdentity*): UsernameRepository = + new UsernameRepository { + override def getUserHandleForUsername( + username: String + ): Optional[ByteArray] = + users.find(_.getName == username).map(_.getId).toJava + + override def getUsernameForUserHandle( + userHandle: ByteArray + ): Optional[String] = + users.find(_.getId == userHandle).map(_.getName).toJava + } + } + def toRegisteredCredential( user: UserIdentity, result: RegistrationResult, @@ -108,4 +271,42 @@ object Helpers { .publicKeyCose(result.getPublicKeyCose) .build() + def credentialRecord( + credentialId: ByteArray, + userHandle: ByteArray, + publicKeyCose: ByteArray, + signatureCount: Long = 0, + transports: Option[Set[AuthenticatorTransport]] = None, + be: Option[Boolean] = None, + bs: Option[Boolean] = None, + ): CredentialRecord = { + new CredentialRecord { + override def getCredentialId: ByteArray = credentialId + override def getUserHandle: ByteArray = userHandle + override def getPublicKeyCose: ByteArray = publicKeyCose + override def getSignatureCount: Long = signatureCount + override def getTransports + : Optional[java.util.Set[AuthenticatorTransport]] = + transports.toJava.map(_.asJava) + override def isBackupEligible: Optional[java.lang.Boolean] = toJava(be) + override def isBackedUp: Optional[java.lang.Boolean] = toJava(bs) + } + } + + def toCredentialRecord( + testData: RegistrationTestData, + signatureCount: Long = 0, + be: Option[Boolean] = None, + bs: Option[Boolean] = None, + ): CredentialRecord = + new CredentialRecord { + override def getCredentialId: ByteArray = testData.response.getId + override def getUserHandle: ByteArray = testData.userId.getId + override def getPublicKeyCose: ByteArray = + testData.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey + override def getSignatureCount: Long = signatureCount + override def isBackupEligible: Optional[java.lang.Boolean] = toJava(be) + override def isBackedUp: Optional[java.lang.Boolean] = toJava(bs) + } + } diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java b/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java index 8e9c5b75e..138352671 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/data/CredentialRegistration.java @@ -33,6 +33,7 @@ import com.yubico.webauthn.data.UserIdentity; import java.time.Instant; import java.util.Optional; +import java.util.Set; import java.util.SortedSet; import lombok.Builder; import lombok.NonNull; @@ -82,6 +83,11 @@ public long getSignatureCount() { return credential.getSignatureCount(); } + @Override + public Optional> getTransports() { + return Optional.ofNullable(transports); + } + @Override public Optional isBackupEligible() { return credential.isBackupEligible(); From 4070bad999468db474f7e1cfd9b10a8c1256bde0 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 31 Oct 2023 17:38:23 +0100 Subject: [PATCH 08/21] Add experimental feature annotations --- .../yubico/webauthn/AssertionResultV2.java | 13 ++++++- .../com/yubico/webauthn/CredentialRecord.java | 38 +++++++++++++++++++ .../webauthn/CredentialRepositoryV2.java | 13 +++++++ .../com/yubico/webauthn/RelyingParty.java | 3 ++ .../com/yubico/webauthn/RelyingPartyV2.java | 9 ++++- .../yubico/webauthn/UsernameRepository.java | 14 ++++++- 6 files changed, 85 insertions(+), 5 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java index c347bbd06..5b027ffbc 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java @@ -42,7 +42,13 @@ import lombok.NonNull; import lombok.Value; -/** The result of a call to {@link RelyingPartyV2#finishAssertion(FinishAssertionOptions)}. */ +/** + * The result of a call to {@link RelyingPartyV2#finishAssertion(FinishAssertionOptions)}. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ +@Deprecated @Value public class AssertionResultV2 { @@ -65,8 +71,11 @@ public class AssertionResultV2 { * before the assertion operation, not the new state. When updating your database state, * use the signature counter and backup state from {@link #getSignatureCount()}, {@link * #isBackupEligible()} and {@link #isBackedUp()} instead. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ - private final C credential; + @Deprecated private final C credential; /** * true if and only if at least one of the following is true: diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java index 04776f34a..0d04ea6c7 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java @@ -8,20 +8,48 @@ /** * @see Credential Record + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ +@Deprecated public interface CredentialRecord { + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated @NonNull ByteArray getCredentialId(); + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated @NonNull ByteArray getUserHandle(); + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated @NonNull ByteArray getPublicKeyCose(); + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated long getSignatureCount(); + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated @NonNull default Optional> getTransports() { return Optional.empty(); @@ -29,7 +57,17 @@ default Optional> getTransports() { // boolean isUvInitialized(); + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated Optional isBackupEligible(); + /** + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated Optional isBackedUp(); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java index 9b1630bb3..af6a1a036 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java @@ -35,7 +35,11 @@ *

This is used by {@link RelyingParty} to look up credentials and credential IDs. * *

Unlike {@link CredentialRepository}, this interface does not require support for usernames. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ +@Deprecated public interface CredentialRepositoryV2 { /** @@ -47,7 +51,10 @@ public interface CredentialRepositoryV2 { * @return a {@link Set} containing one {@link PublicKeyCredentialDescriptor} for each credential * registered to the given user. The set MUST NOT be null, but MAY be empty if the user does * not exist or has no credentials. + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ + @Deprecated Set getCredentialIdsForUserHandle(ByteArray userHandle); /** @@ -61,7 +68,10 @@ public interface CredentialRepositoryV2 { * credential with credential ID credentialId, if any. If the credential does not * exist or is registered to a different user handle than userHandle, return * {@link Optional#empty()}. + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ + @Deprecated Optional lookup(ByteArray credentialId, ByteArray userHandle); /** @@ -72,6 +82,9 @@ public interface CredentialRepositoryV2 { * * @return true if and only if the credential database contains at least one * credential with the given credential ID. + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ + @Deprecated boolean credentialIdExists(ByteArray credentialId); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index 251bd643b..2553d3ab8 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java @@ -609,7 +609,10 @@ public RelyingPartyBuilder credentialRepository(CredentialRepository credentialR * credentialRepository} is a required parameter. * * @see #credentialRepository(CredentialRepository) + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be + * deleted before reaching a mature release. */ + @Deprecated public RelyingPartyV2.RelyingPartyV2Builder credentialRepositoryV2( CredentialRepositoryV2 credentialRepository) { diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java index 38eca96e4..5eb526786 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java @@ -139,8 +139,13 @@ public class RelyingPartyV2 { */ @NonNull private final CredentialRepositoryV2 credentialRepository; - /** TODO */ - private final UsernameRepository usernameRepository; + /** + * TODO + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated private final UsernameRepository usernameRepository; /** * The extension input to set for the appid and appidExclude extensions. diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java index b8429e963..101937f69 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java @@ -30,8 +30,12 @@ /** * An abstraction of optional database lookups needed by this library. * - *

This is used by {@link RelyingParty} to look up usernames and user handles. + *

This is used by {@link RelyingPartyV2} to look up usernames and user handles. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ +@Deprecated public interface UsernameRepository { /** @@ -40,7 +44,11 @@ public interface UsernameRepository { * *

Used to look up the user handle based on the username, for authentication ceremonies where * the username is already given. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ + @Deprecated Optional getUserHandleForUsername(String username); /** @@ -49,6 +57,10 @@ public interface UsernameRepository { * *

Used to look up the username based on the user handle, for username-less authentication * ceremonies. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ + @Deprecated Optional getUsernameForUserHandle(ByteArray userHandle); } From e4e7ff419b878517fc1a3de3e012aa5198aa13b9 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 13:23:58 +0100 Subject: [PATCH 09/21] Rename CredentialRepositoryV2.get{CredentialIds => CredentialDescriptors}ForUserHandle --- .../webauthn/CredentialRepositoryV1ToV2Adapter.java | 3 ++- .../com/yubico/webauthn/CredentialRepositoryV2.java | 2 +- .../main/java/com/yubico/webauthn/RelyingPartyV2.java | 4 ++-- .../yubico/webauthn/RelyingPartyV2AssertionSpec.scala | 2 +- .../test/scala/com/yubico/webauthn/test/Helpers.scala | 10 +++++----- .../demo/webauthn/InMemoryRegistrationStorage.java | 3 ++- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java index 49118cddc..5cea0fad7 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java @@ -14,7 +14,8 @@ class CredentialRepositoryV1ToV2Adapter private final CredentialRepository inner; @Override - public Set getCredentialIdsForUserHandle(ByteArray userHandle) { + public Set getCredentialDescriptorsForUserHandle( + ByteArray userHandle) { return inner .getUsernameForUserHandle(userHandle) .map(inner::getCredentialIdsForUsername) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java index af6a1a036..bade4b815 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java @@ -55,7 +55,7 @@ public interface CredentialRepositoryV2 { * before reaching a mature release. */ @Deprecated - Set getCredentialIdsForUserHandle(ByteArray userHandle); + Set getCredentialDescriptorsForUserHandle(ByteArray userHandle); /** * Look up the public key, backup flags and current signature count for the given credential diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java index 5eb526786..428118b37 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java @@ -431,7 +431,7 @@ public PublicKeyCredentialCreationOptions startRegistration( .challenge(generateChallenge()) .pubKeyCredParams(preferredPubkeyParams) .excludeCredentials( - credentialRepository.getCredentialIdsForUserHandle( + credentialRepository.getCredentialDescriptorsForUserHandle( startRegistrationOptions.getUser().getId())) .authenticatorSelection(startRegistrationOptions.getAuthenticatorSelection()) .extensions( @@ -487,7 +487,7 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio startAssertionOptions .getUsername() .flatMap(unr::getUserHandleForUsername))) - .map(credentialRepository::getCredentialIdsForUserHandle) + .map(credentialRepository::getCredentialDescriptorsForUserHandle) .map(ArrayList::new)) .extensions( startAssertionOptions diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala index 491b7a143..369c8e91b 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala @@ -357,7 +357,7 @@ class RelyingPartyV2AssertionSpec cred3: PublicKeyCredentialDescriptor, ) => val credRepo = new CredentialRepositoryV2[CredentialRecord] { - override def getCredentialIdsForUserHandle( + override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray ): java.util.Set[PublicKeyCredentialDescriptor] = Set( diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala index efb1791a0..3597c30dd 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala @@ -108,7 +108,7 @@ object Helpers { object CredentialRepositoryV2 { def empty[C <: CredentialRecord] = new CredentialRepositoryV2[C] { - override def getCredentialIdsForUserHandle( + override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray ): java.util.Set[PublicKeyCredentialDescriptor] = Set.empty.asJava override def lookup( @@ -121,7 +121,7 @@ object Helpers { } def unimplemented[C <: CredentialRecord] = new CredentialRepositoryV2[C] { - override def getCredentialIdsForUserHandle( + override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray ): java.util.Set[PublicKeyCredentialDescriptor] = ??? override def lookup( @@ -139,11 +139,11 @@ object Helpers { var lookupCount = 0 var credentialIdExistsCount = 0 - override def getCredentialIdsForUserHandle( + override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray ): java.util.Set[PublicKeyCredentialDescriptor] = { getCredentialIdsCount += 1 - inner.getCredentialIdsForUserHandle(userHandle) + inner.getCredentialDescriptorsForUserHandle(userHandle) } override def lookup( @@ -164,7 +164,7 @@ object Helpers { users: (UserIdentity, C)* ): CredentialRepositoryV2[C] = { new CredentialRepositoryV2[C] { - override def getCredentialIdsForUserHandle( + override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray ): java.util.Set[PublicKeyCredentialDescriptor] = users diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java index 75ae0bdfe..047cf3fe2 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java @@ -56,7 +56,8 @@ public class InMemoryRegistrationStorage //////////////////////////////////////////////////////////////////////////////// @Override - public Set getCredentialIdsForUserHandle(ByteArray userHandle) { + public Set getCredentialDescriptorsForUserHandle( + ByteArray userHandle) { return getRegistrationsByUserHandle(userHandle).stream() .map( registration -> From 736226ec2b5b4234e80f0913a5a9de348c8a52b4 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 13:28:11 +0100 Subject: [PATCH 10/21] Fix copy-paste errors in CredentialRepositoryV2 JavaDoc --- .../com/yubico/webauthn/CredentialRepositoryV2.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java index bade4b815..0b9fc8299 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java @@ -32,7 +32,7 @@ /** * An abstraction of database lookups needed by this library. * - *

This is used by {@link RelyingParty} to look up credentials and credential IDs. + *

This is used by {@link RelyingPartyV2} to look up credentials and credential IDs. * *

Unlike {@link CredentialRepository}, this interface does not require support for usernames. * @@ -61,13 +61,13 @@ public interface CredentialRepositoryV2 { * Look up the public key, backup flags and current signature count for the given credential * registered to the given user. * - *

The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read + *

The returned {@link CredentialRecord} is not expected to be long-lived. It may be read * directly from a database or assembled from other components. * - * @return a {@link RegisteredCredential} describing the current state of the registered - * credential with credential ID credentialId, if any. If the credential does not - * exist or is registered to a different user handle than userHandle, return - * {@link Optional#empty()}. + * @return a {@link CredentialRecord} describing the current state of the registered credential + * with credential ID credentialId, if any. If the credential does not exist or + * is registered to a different user handle than userHandle, return {@link + * Optional#empty()}. * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ From e60d332070e97dabe37937db86e17980056ee2b1 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 13:43:33 +0100 Subject: [PATCH 11/21] Introduce interface ToPublicKeyCredentialDescriptor --- .../com/yubico/webauthn/CredentialRecord.java | 28 ++++++++++++--- .../CredentialRepositoryV1ToV2Adapter.java | 3 +- .../webauthn/CredentialRepositoryV2.java | 18 +++++++--- .../com/yubico/webauthn/RelyingPartyV2.java | 18 +++++++--- .../ToPublicKeyCredentialDescriptor.java | 34 +++++++++++++++++++ .../data/PublicKeyCredentialDescriptor.java | 16 ++++++++- .../com/yubico/webauthn/test/Helpers.scala | 16 ++++----- .../webauthn/InMemoryRegistrationStorage.java | 17 +++------- 8 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java index 0d04ea6c7..4b3c520bd 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java @@ -2,6 +2,7 @@ import com.yubico.webauthn.data.AuthenticatorTransport; import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import java.util.Optional; import java.util.Set; import lombok.NonNull; @@ -12,7 +13,7 @@ * before reaching a mature release. */ @Deprecated -public interface CredentialRecord { +public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { /** * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted @@ -50,10 +51,7 @@ public interface CredentialRecord { * before reaching a mature release. */ @Deprecated - @NonNull - default Optional> getTransports() { - return Optional.empty(); - } + Optional> getTransports(); // boolean isUvInitialized(); @@ -70,4 +68,24 @@ default Optional> getTransports() { */ @Deprecated Optional isBackedUp(); + + /** + * This default implementation of {@link + * ToPublicKeyCredentialDescriptor#toPublicKeyCredentialDescriptor()} sets the {@link + * PublicKeyCredentialDescriptor.PublicKeyCredentialDescriptorBuilder#id(ByteArray) id} field to + * the return value of {@link #getCredentialId()} and the {@link + * PublicKeyCredentialDescriptor.PublicKeyCredentialDescriptorBuilder#transports(Optional) + * transports} field to the return value of {@link #getTransports()}. + * + * @see credential + * descriptor for a credential record in Web Authentication Level 3 (Editor's Draft) + */ + @Override + default PublicKeyCredentialDescriptor toPublicKeyCredentialDescriptor() { + return PublicKeyCredentialDescriptor.builder() + .id(getCredentialId()) + .transports(getTransports()) + .build(); + } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java index 5cea0fad7..41e02e876 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java @@ -1,7 +1,6 @@ package com.yubico.webauthn; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import java.util.Collections; import java.util.Optional; import java.util.Set; @@ -14,7 +13,7 @@ class CredentialRepositoryV1ToV2Adapter private final CredentialRepository inner; @Override - public Set getCredentialDescriptorsForUserHandle( + public Set getCredentialDescriptorsForUserHandle( ByteArray userHandle) { return inner .getUsernameForUserHandle(userHandle) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java index 0b9fc8299..f9a75e9cd 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java @@ -48,14 +48,24 @@ public interface CredentialRepositoryV2 { *

After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method * returns a value suitable for inclusion in this set. * - * @return a {@link Set} containing one {@link PublicKeyCredentialDescriptor} for each credential - * registered to the given user. The set MUST NOT be null, but MAY be empty if the user does - * not exist or has no credentials. + *

Note that the {@link CredentialRecord} interface extends from the expected {@link + * ToPublicKeyCredentialDescriptor} return type, so this method MAY return a {@link Set} of the + * same item type as the value returned by the {@link #lookup(ByteArray, ByteArray)} method. + * + *

Implementations MUST NOT return null. The returned {@link Set} MUST NOT contain null. + * + * @return a {@link Set} containing one {@link PublicKeyCredentialDescriptor} (or value that + * implements {@link ToPublicKeyCredentialDescriptor}, for example {@link CredentialRecord}) + * for each credential registered to the given user. The set MUST NOT be null, but MAY be + * empty if the user does not exist or has no credentials. + * @see ToPublicKeyCredentialDescriptor + * @see CredentialRecord * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @Deprecated - Set getCredentialDescriptorsForUserHandle(ByteArray userHandle); + Set getCredentialDescriptorsForUserHandle( + ByteArray userHandle); /** * Look up the public key, backup flags and current signature count for the given credential diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java index 428118b37..b81d47e36 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java @@ -49,12 +49,12 @@ import java.security.SecureRandom; import java.security.Signature; import java.time.Clock; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import lombok.Builder; import lombok.NonNull; import lombok.Value; @@ -431,8 +431,12 @@ public PublicKeyCredentialCreationOptions startRegistration( .challenge(generateChallenge()) .pubKeyCredParams(preferredPubkeyParams) .excludeCredentials( - credentialRepository.getCredentialDescriptorsForUserHandle( - startRegistrationOptions.getUser().getId())) + credentialRepository + .getCredentialDescriptorsForUserHandle( + startRegistrationOptions.getUser().getId()) + .stream() + .map(ToPublicKeyCredentialDescriptor::toPublicKeyCredentialDescriptor) + .collect(Collectors.toSet())) .authenticatorSelection(startRegistrationOptions.getAuthenticatorSelection()) .extensions( startRegistrationOptions @@ -488,7 +492,13 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio .getUsername() .flatMap(unr::getUserHandleForUsername))) .map(credentialRepository::getCredentialDescriptorsForUserHandle) - .map(ArrayList::new)) + .map( + descriptors -> + descriptors.stream() + .map( + ToPublicKeyCredentialDescriptor + ::toPublicKeyCredentialDescriptor) + .collect(Collectors.toList()))) .extensions( startAssertionOptions .getExtensions() diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java new file mode 100644 index 000000000..92a6f2f35 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java @@ -0,0 +1,34 @@ +package com.yubico.webauthn; + +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; + +/** + * A type that can be converted into a {@link PublicKeyCredentialDescriptor} value. + * + * @see PublicKeyCredentialDescriptor + * @see §5.10.3. + * Credential Descriptor (dictionary PublicKeyCredentialDescriptor) + * @see CredentialRecord + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ +@Deprecated +public interface ToPublicKeyCredentialDescriptor { + + /** + * Convert this value to a {@link PublicKeyCredentialDescriptor} value. + * + *

Implementations MUST NOT return null. + * + * @see PublicKeyCredentialDescriptor + * @see §5.10.3. + * Credential Descriptor (dictionary PublicKeyCredentialDescriptor) + * @see CredentialRecord + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + */ + @Deprecated + PublicKeyCredentialDescriptor toPublicKeyCredentialDescriptor(); +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java index b2487b5c1..be1810f2d 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java @@ -29,6 +29,7 @@ import com.yubico.internal.util.CollectionUtil; import com.yubico.internal.util.ComparableUtil; import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.ToPublicKeyCredentialDescriptor; import java.util.Optional; import java.util.Set; import java.util.SortedSet; @@ -49,7 +50,8 @@ */ @Value @Builder(toBuilder = true) -public class PublicKeyCredentialDescriptor implements Comparable { +public class PublicKeyCredentialDescriptor + implements Comparable, ToPublicKeyCredentialDescriptor { /** The type of the credential the caller is referring to. */ @NonNull @Builder.Default @@ -108,6 +110,18 @@ public static PublicKeyCredentialDescriptorBuilder.MandatoryStages builder() { return new PublicKeyCredentialDescriptorBuilder.MandatoryStages(); } + /** + * This implementation of {@link + * ToPublicKeyCredentialDescriptor#toPublicKeyCredentialDescriptor()} is a no-op which returns + * this unchanged. + * + * @return this. + */ + @Override + public PublicKeyCredentialDescriptor toPublicKeyCredentialDescriptor() { + return this; + } + public static class PublicKeyCredentialDescriptorBuilder { private Set transports = null; diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala index 3597c30dd..60d7c540e 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala @@ -6,6 +6,7 @@ import com.yubico.webauthn.CredentialRepositoryV2 import com.yubico.webauthn.RegisteredCredential import com.yubico.webauthn.RegistrationResult import com.yubico.webauthn.RegistrationTestData +import com.yubico.webauthn.ToPublicKeyCredentialDescriptor import com.yubico.webauthn.UsernameRepository import com.yubico.webauthn.data.AuthenticatorTransport import com.yubico.webauthn.data.ByteArray @@ -141,7 +142,7 @@ object Helpers { override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray - ): java.util.Set[PublicKeyCredentialDescriptor] = { + ): java.util.Set[_ <: ToPublicKeyCredentialDescriptor] = { getCredentialIdsCount += 1 inner.getCredentialDescriptorsForUserHandle(userHandle) } @@ -166,19 +167,14 @@ object Helpers { new CredentialRepositoryV2[C] { override def getCredentialDescriptorsForUserHandle( userHandle: ByteArray - ): java.util.Set[PublicKeyCredentialDescriptor] = + ): java.util.Set[_ <: ToPublicKeyCredentialDescriptor] = users .filter({ case (u, c) => u.getId == userHandle && c.getUserHandle == userHandle }) .map({ - case (_, credential) => - PublicKeyCredentialDescriptor - .builder() - .id(credential.getCredentialId) - .transports(credential.getTransports) - .build() + case (_, credential) => credential }) .toSet .asJava @@ -305,6 +301,10 @@ object Helpers { override def getPublicKeyCose: ByteArray = testData.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey override def getSignatureCount: Long = signatureCount + + override def getTransports + : Optional[java.util.Set[AuthenticatorTransport]] = + Optional.of(testData.response.getResponse.getTransports) override def isBackupEligible: Optional[java.lang.Boolean] = toJava(be) override def isBackedUp: Optional[java.lang.Boolean] = toJava(bs) } diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java index 047cf3fe2..1a4add942 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java @@ -30,7 +30,6 @@ import com.yubico.webauthn.CredentialRepositoryV2; import com.yubico.webauthn.UsernameRepository; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import demo.webauthn.data.CredentialRegistration; import java.util.Collection; import java.util.HashSet; @@ -56,16 +55,8 @@ public class InMemoryRegistrationStorage //////////////////////////////////////////////////////////////////////////////// @Override - public Set getCredentialDescriptorsForUserHandle( - ByteArray userHandle) { - return getRegistrationsByUserHandle(userHandle).stream() - .map( - registration -> - PublicKeyCredentialDescriptor.builder() - .id(registration.getCredential().getCredentialId()) - .transports(registration.getTransports()) - .build()) - .collect(Collectors.toSet()); + public Set getCredentialDescriptorsForUserHandle(ByteArray userHandle) { + return getRegistrationsByUserHandle(userHandle); } @Override @@ -135,13 +126,13 @@ public Collection getRegistrationsByUsername(String user } } - public Collection getRegistrationsByUserHandle(ByteArray userHandle) { + public Set getRegistrationsByUserHandle(ByteArray userHandle) { return storage.asMap().values().stream() .flatMap(Collection::stream) .filter( credentialRegistration -> userHandle.equals(credentialRegistration.getUserIdentity().getId())) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); } public void updateSignatureCount(AssertionResultV2 result) { From abb6b073215ca8c94bc9a06c4d6da878df444028 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 14:02:38 +0100 Subject: [PATCH 12/21] Add missing JavaDoc to CredentialRecord and RegisteredCredential --- .../com/yubico/webauthn/CredentialRecord.java | 115 +++++++++++++++++- .../yubico/webauthn/RegisteredCredential.java | 57 +++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java index 4b3c520bd..9db9abd9f 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java @@ -1,14 +1,24 @@ package com.yubico.webauthn; +import com.yubico.webauthn.data.AttestedCredentialData; +import com.yubico.webauthn.data.AuthenticatorAssertionResponse; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; +import com.yubico.webauthn.data.AuthenticatorData; import com.yubico.webauthn.data.AuthenticatorTransport; import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; +import com.yubico.webauthn.data.UserIdentity; import java.util.Optional; import java.util.Set; import lombok.NonNull; /** - * @see Credential Record + * An abstraction of properties of a stored WebAuthn credential. + * + * @see Credential Record in Web + * Authentication Level 3 (Editor's Draft) * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @@ -16,6 +26,15 @@ public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { /** + * The credential + * ID of the credential. + * + *

Implementations MUST NOT return null. + * + * @see Credential + * ID + * @see RegistrationResult#getKeyId() + * @see PublicKeyCredentialDescriptor#getId() * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @@ -24,6 +43,13 @@ public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { ByteArray getCredentialId(); /** + * The user handle + * of the user the credential is registered to. + * + *

Implementations MUST NOT return null. + * + * @see User Handle + * @see UserIdentity#getId() * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @@ -32,6 +58,19 @@ public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { ByteArray getUserHandle(); /** + * The credential public key encoded in COSE_Key format, as defined in Section 7 of RFC 8152. + * + *

This is used to verify the {@link AuthenticatorAssertionResponse#getSignature() signature} + * in authentication assertions. + * + *

If your database has credentials encoded in U2F (raw) format, you may need to use {@link + * #cosePublicKeyFromEs256Raw(ByteArray)} to convert them before returning them in this method. + * + *

Implementations MUST NOT return null. + * + * @see AttestedCredentialData#getCredentialPublicKey() + * @see RegistrationResult#getPublicKeyCose() * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @@ -40,6 +79,17 @@ public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { ByteArray getPublicKeyCose(); /** + * The stored signature + * count of the credential. + * + *

This is used to validate the {@link AuthenticatorData#getSignatureCounter() signature + * counter} in authentication assertions. + * + * @see §6.1. + * Authenticator Data + * @see AuthenticatorData#getSignatureCounter() + * @see AssertionResult#getSignatureCount() * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @@ -47,6 +97,30 @@ public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { long getSignatureCount(); /** + * Transport hints as to how the client might communicate with the authenticator this credential + * is bound to. + * + *

Implementations SHOULD return the value returned by {@link + * AuthenticatorAttestationResponse#getTransports()} when the credential was created. That value + * SHOULD NOT be modified. + * + *

Implementations MUST NOT return null. + * + *

This is used to set {@link PublicKeyCredentialDescriptor#getTransports()} in {@link + * PublicKeyCredentialCreationOptions#getExcludeCredentials() excludeCredentials} in {@link + * RelyingParty#startRegistration(StartRegistrationOptions)} and and {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials() allowCredentials} in {@link + * RelyingParty#startAssertion(StartAssertionOptions)}. + * + * @see getTransports() + * in 5.2.1. Information About Public Key Credential (interface + * AuthenticatorAttestationResponse) + * @see transports + * in 5.8.3. Credential Descriptor (dictionary PublicKeyCredentialDescriptor) + * @see AuthenticatorAttestationResponse#getTransports() + * @see PublicKeyCredentialDescriptor#getTransports() * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. */ @@ -56,15 +130,50 @@ public interface CredentialRecord extends ToPublicKeyCredentialDescriptor { // boolean isUvInitialized(); /** + * The state of the BE flag when + * this credential was registered, if known. + * + *

If absent, it is not known whether or not this credential is backup eligible. + * + *

If present and true, the credential is backup eligible: it can be backed up in + * some way, most commonly by syncing the private key to a cloud account. + * + *

If present and false, the credential is not backup eligible: it cannot be + * backed up in any way. + * + *

{@link CredentialRecord} implementations SHOULD return the first known value returned by + * {@link RegistrationResult#isBackupEligible()} or {@link AssertionResult#isBackupEligible()}, if + * known. If unknown, {@link CredentialRecord} implementations SHOULD return + * Optional.empty(). + * + *

Implementations MUST NOT return null. + * * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted - * before reaching a mature release. + * before reaching a mature release. EXPERIMENTAL: This feature is from a not yet mature + * standard; it could change as the standard matures. */ @Deprecated Optional isBackupEligible(); /** + * The last known state of the BS + * flag for this credential, if known. + * + *

If absent, the backup state of the credential is not known. + * + *

If present and true, the credential is believed to be currently backed up. + * + *

If present and false, the credential is believed to not be currently backed up. + * + *

{@link CredentialRecord} implementations SHOULD return the most recent value returned by + * {@link AssertionResult#isBackedUp()} or {@link RegistrationResult#isBackedUp()}, if known. If + * unknown, {@link CredentialRecord} implementations SHOULD return Optional.empty(). + * + *

Implementations MUST NOT return null. + * * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted - * before reaching a mature release. + * before reaching a mature release. EXPERIMENTAL: This feature is from a not yet mature + * standard; it could change as the standard matures. */ @Deprecated Optional isBackedUp(); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java index c3653364f..cdb86a69e 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java @@ -31,11 +31,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.yubico.webauthn.data.AttestedCredentialData; import com.yubico.webauthn.data.AuthenticatorAssertionResponse; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; import com.yubico.webauthn.data.AuthenticatorData; import com.yubico.webauthn.data.AuthenticatorTransport; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.COSEAlgorithmIdentifier; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserIdentity; import java.io.IOException; import java.security.NoSuchAlgorithmException; @@ -120,6 +123,33 @@ public PublicKey getParsedPublicKey() */ @Builder.Default private final long signatureCount = 0; + /** + * Transport hints as to how the client might communicate with the authenticator this credential + * is bound to. + * + *

This SHOULD be set to the value returned by {@link + * AuthenticatorAttestationResponse#getTransports()} when the credential was created. That value + * SHOULD NOT be modified. + * + *

This is only used if the {@link RelyingParty} is configured with a {@link + * CredentialRepositoryV2}, in which case this is used to set {@link + * PublicKeyCredentialDescriptor#getTransports()} in {@link + * PublicKeyCredentialCreationOptions#getExcludeCredentials() excludeCredentials} in {@link + * RelyingParty#startRegistration(StartRegistrationOptions)} and {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials() allowCredentials} in {@link + * RelyingParty#startAssertion(StartAssertionOptions)}. This is not used if the {@link + * RelyingParty} is configured with a {@link CredentialRepository}. + * + * @see getTransports() + * in 5.2.1. Information About Public Key Credential (interface + * AuthenticatorAttestationResponse) + * @see transports + * in 5.8.3. Credential Descriptor (dictionary PublicKeyCredentialDescriptor) + * @see AuthenticatorAttestationResponse#getTransports() + * @see PublicKeyCredentialDescriptor#getTransports() + */ @Builder.Default private final Set transports = null; /** @@ -188,6 +218,33 @@ private RegisteredCredential( this.backupState = backupState; } + /** + * Transport hints as to how the client might communicate with the authenticator this credential + * is bound to. + * + *

This SHOULD be set to the value returned by {@link + * AuthenticatorAttestationResponse#getTransports()} when the credential was created. That value + * SHOULD NOT be modified. + * + *

This is only used if the {@link RelyingParty} is configured with a {@link + * CredentialRepositoryV2}, in which case this is used to set {@link + * PublicKeyCredentialDescriptor#getTransports()} in {@link + * PublicKeyCredentialCreationOptions#getExcludeCredentials() excludeCredentials} in {@link + * RelyingParty#startRegistration(StartRegistrationOptions)} and {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials() allowCredentials} in {@link + * RelyingParty#startAssertion(StartAssertionOptions)}. This is not used if the {@link + * RelyingParty} is configured with a {@link CredentialRepository}. + * + * @see getTransports() + * in 5.2.1. Information About Public Key Credential (interface + * AuthenticatorAttestationResponse) + * @see transports + * in 5.8.3. Credential Descriptor (dictionary PublicKeyCredentialDescriptor) + * @see AuthenticatorAttestationResponse#getTransports() + * @see PublicKeyCredentialDescriptor#getTransports() + */ @Override public Optional> getTransports() { return Optional.ofNullable(transports); From cff0ba21fa048d6774ddf8731ea8c71085603c35 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 14:03:06 +0100 Subject: [PATCH 13/21] Reword CredentialRepository JavaDoc slightly --- .../src/main/java/com/yubico/webauthn/CredentialRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java index 2eba3ba59..f88776abc 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java @@ -30,7 +30,7 @@ import java.util.Set; /** - * An abstraction of the primary database lookups needed by this library. + * An abstraction of database lookups needed by this library. * *

This is used by {@link RelyingParty} to look up credentials, usernames and user handles from * usernames, user handles and credential IDs. From 57c977013fcf701424d65ac5c32a698b614cd0e9 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 14:32:58 +0100 Subject: [PATCH 14/21] Add function CredentialRecord.cosePublicKeyFromEs256Raw(ByteArray) --- .../com/yubico/webauthn/CredentialRecord.java | 25 +++++++++++++++++++ .../RelyingPartyV2AssertionSpec.scala | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java index 9db9abd9f..0b06f1b30 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java @@ -197,4 +197,29 @@ default PublicKeyCredentialDescriptor toPublicKeyCredentialDescriptor() { .transports(getTransports()) .build(); } + + /** + * Convert a credential public key from U2F format to COSE_Key format. + * + *

The U2F JavaScript API encoded credential public keys in ALG_KEY_ECC_X962_RAW + * format as specified in FIDO + * Registry §3.6.2 Public Key Representation Formats. If your database has credential public + * keys stored in this format, those public keys need to be converted to COSE_Key format before + * they can be used by a {@link CredentialRecord} instance. This function performs the conversion. + * + *

If your application has only used the navigator.credentials.create() API to + * register credentials, you likely do not need this function. + * + * @param es256RawKey a credential public key in ALG_KEY_ECC_X962_RAW format as + * specified in FIDO + * Registry §3.6.2 Public Key Representation Formats. + * @return a credential public key in COSE_Key format, suitable to be returned by {@link + * CredentialRecord#getPublicKeyCose()}. + * @see RegisteredCredential.RegisteredCredentialBuilder#publicKeyEs256Raw(ByteArray) + */ + static ByteArray cosePublicKeyFromEs256Raw(final ByteArray es256RawKey) { + return WebAuthnCodecs.rawEcKeyToCose(es256RawKey); + } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala index 369c8e91b..c91be8959 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala @@ -2525,7 +2525,8 @@ class RelyingPartyV2AssertionSpec Helpers.CredentialRepositoryV2.withUser( testData.userId, credentialId = testData.assertion.get.response.getId, - publicKeyCose = WebAuthnCodecs.rawEcKeyToCose(u2fPubkey), + publicKeyCose = + CredentialRecord.cosePublicKeyFromEs256Raw(u2fPubkey), ) ) .usernameRepository( From 68b6988821083e9ea175349bb5324e29721c02fe Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 16:46:28 +0100 Subject: [PATCH 15/21] Add *V2 features to NEWS --- NEWS | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/NEWS b/NEWS index 0b3d1203e..88f4c01fd 100644 --- a/NEWS +++ b/NEWS @@ -10,6 +10,34 @@ New features: `FinishAssertionOptions`. When set, `RelyingParty.finishAssertion()` will adapt the validation logic for a Secure Payment Confirmation (SPC) response instead of an ordinary WebAuthn response. See the JavaDoc for details. + ** NOTE: Experimental features may receive breaking changes without a major + version increase. +* (Experimental) Added a new suite of interfaces, starting with + `CredentialRepositoryV2`. `RelyingParty` can now be configured with a + `CredentialRepositoryV2` instance instead of a `CredentialRepository` + instance. This changes the result of the `RelyingParty` builder to + `RelyingPartyV2`. `CredentialRepositoryV2` and `RelyingPartyV2` enable a suite + of new features: + ** `CredentialRepositoryV2` does not assume that the application has usernames, + instead username support is modular. In addition to the + `CredentialRepositoryV2`, `RelyingPartyV2` can be optionally configured with + a `UsernameRepository` as well. If a `UsernameRepository` is not set, then + `RelyingPartyV2.startAssertion(StartAssertionOptions)` will fail at runtime + if `StartAssertionOptions.username` is set. + ** `CredentialRepositoryV2` uses a new interface `CredentialRecord` to + represent registered credentials, instead of the concrete + `RegisteredCredential` class (although `RegisteredCredential` also + implements `CredentialRecord`). This provides implementations greater + flexibility while also automating the type conversion to + `PublicKeyCredentialDescriptor` needed in `startRegistration()` and + `startAssertion()`. + ** `RelyingPartyV2.finishAssertion()` returns a new type `AssertionResultV2` + with a new method `getCredential()`, which returns the `CredentialRecord` + that was verified. The return type of `getCredential()` is generic and + preserves the concrete type of `CredentialRecord` returned by the + `CredentialRepositoryV2` implementation. + ** NOTE: Experimental features may receive breaking changes without a major + version increase. == Version 2.5.0 == From a269d19bd8b25c9dfb43fd22bf749a24d7775a1d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 14:52:24 +0100 Subject: [PATCH 16/21] Add public builder to CredentialPropertiesOutput --- NEWS | 1 + .../src/main/java/com/yubico/webauthn/data/Extensions.java | 4 +++- .../com/yubico/webauthn/RelyingPartyRegistrationSpec.scala | 6 +++--- .../yubico/webauthn/RelyingPartyV2RegistrationSpec.scala | 6 +++--- .../test/scala/com/yubico/webauthn/data/Generators.scala | 2 +- .../scala/com/yubico/webauthn/data/ReexportHelpers.scala | 4 ---- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/NEWS b/NEWS index 88f4c01fd..b3062d70b 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,7 @@ New features: `RegistrationResult` and `RegisteredCredential`. ** Thanks to Jakob Heher (A-SIT) for the contribution, see https://github.com/Yubico/java-webauthn-server/pull/299 +* Added public builder to `CredentialPropertiesOutput`. * (Experimental) Added option `isSecurePaymentConfirmation(boolean)` to `FinishAssertionOptions`. When set, `RelyingParty.finishAssertion()` will adapt the validation logic for a Secure Payment Confirmation (SPC) response diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java index f9b02cdd5..9cb3aa615 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java @@ -14,6 +14,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.Builder; import lombok.NonNull; import lombok.Value; import lombok.experimental.UtilityClass; @@ -63,12 +64,13 @@ public static class CredentialProperties { * Credential Properties Extension (credProps) */ @Value + @Builder public static class CredentialPropertiesOutput { @JsonProperty("rk") private final Boolean rk; @JsonCreator - CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { + private CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { this.rk = rk; } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala index 50967b5f7..89b59e01b 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala @@ -52,6 +52,7 @@ import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.COSEAlgorithmIdentifier import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.CollectedClientData +import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationInput.LargeBlobSupport import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ @@ -59,7 +60,6 @@ import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.ReexportHelpers -import com.yubico.webauthn.data.ReexportHelpers.newCredentialPropertiesOutput import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity @@ -4232,7 +4232,7 @@ class RelyingPartyRegistrationSpec ClientRegistrationExtensionOutputs .builder() .credProps( - newCredentialPropertiesOutput(true) + CredentialPropertiesOutput.builder().rk(true).build() ) .build() ) @@ -4255,7 +4255,7 @@ class RelyingPartyRegistrationSpec ClientRegistrationExtensionOutputs .builder() .credProps( - newCredentialPropertiesOutput(false) + CredentialPropertiesOutput.builder().rk(false).build() ) .build() ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala index 45414b447..a2fc2dd32 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala @@ -52,6 +52,7 @@ import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.COSEAlgorithmIdentifier import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.CollectedClientData +import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationInput.LargeBlobSupport import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ @@ -59,7 +60,6 @@ import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.ReexportHelpers -import com.yubico.webauthn.data.ReexportHelpers.newCredentialPropertiesOutput import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity @@ -4227,7 +4227,7 @@ class RelyingPartyV2RegistrationSpec ClientRegistrationExtensionOutputs .builder() .credProps( - newCredentialPropertiesOutput(true) + CredentialPropertiesOutput.builder().rk(true).build() ) .build() ) @@ -4250,7 +4250,7 @@ class RelyingPartyV2RegistrationSpec ClientRegistrationExtensionOutputs .builder() .credProps( - newCredentialPropertiesOutput(false) + CredentialPropertiesOutput.builder().rk(false).build() ) .build() ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala index a9609f9b7..aa2e1fe70 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala @@ -868,7 +868,7 @@ object Generators { def credentialPropertiesOutput: Gen[CredentialPropertiesOutput] = for { rk <- arbitrary[Boolean] - } yield new CredentialPropertiesOutput(rk) + } yield CredentialPropertiesOutput.builder().rk(rk).build() } object LargeBlob { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala index ce67d8b72..8e69e2469 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala @@ -1,6 +1,5 @@ package com.yubico.webauthn.data -import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput @@ -10,9 +9,6 @@ import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput */ object ReexportHelpers { - def newCredentialPropertiesOutput(rk: Boolean): CredentialPropertiesOutput = - new CredentialPropertiesOutput(rk) - def newLargeBlobRegistrationOutput( supported: Boolean ): LargeBlobRegistrationOutput = new LargeBlobRegistrationOutput(supported) From e369465a641491b6331567beb81e4a04d47eb353 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 14:53:14 +0100 Subject: [PATCH 17/21] Add public factory function LargeBlobRegistrationOutput.supported(boolean) --- NEWS | 2 ++ .../com/yubico/webauthn/data/Extensions.java | 19 ++++++++++++++++++- .../RelyingPartyRegistrationSpec.scala | 4 ++-- .../RelyingPartyV2RegistrationSpec.scala | 4 ++-- .../yubico/webauthn/data/ExtensionsSpec.scala | 4 ++-- .../com/yubico/webauthn/data/Generators.scala | 2 +- .../webauthn/data/ReexportHelpers.scala | 4 ---- 7 files changed, 27 insertions(+), 12 deletions(-) diff --git a/NEWS b/NEWS index b3062d70b..5a2df224f 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,8 @@ New features: ** Thanks to Jakob Heher (A-SIT) for the contribution, see https://github.com/Yubico/java-webauthn-server/pull/299 * Added public builder to `CredentialPropertiesOutput`. +* Added public factory function + `LargeBlobRegistrationOutput.supported(boolean)`. * (Experimental) Added option `isSecurePaymentConfirmation(boolean)` to `FinishAssertionOptions`. When set, `RelyingParty.finishAssertion()` will adapt the validation logic for a Secure Payment Confirmation (SPC) response diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java index 9cb3aa615..1cd3348d0 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java @@ -216,6 +216,9 @@ public static Set values() { * Extension inputs for the Large blob storage extension (largeBlob) in * authentication ceremonies. * + *

Use the {@link #read()} and {@link #write(ByteArray)} factory functions to construct this + * type. + * * @see §10.5. * Large blob storage extension (largeBlob) @@ -311,6 +314,8 @@ public Optional getWrite() { * Extension outputs for the Large blob storage extension (largeBlob) in * registration ceremonies. * + *

Use the {@link #supported(boolean)} factory function to construct this type. + * * @see §10.5. * Large blob storage extension (largeBlob) @@ -328,9 +333,21 @@ public static class LargeBlobRegistrationOutput { @JsonProperty private final boolean supported; @JsonCreator - LargeBlobRegistrationOutput(@JsonProperty("supported") boolean supported) { + private LargeBlobRegistrationOutput(@JsonProperty("supported") boolean supported) { this.supported = supported; } + + /** + * Create a Large blob storage extension output with the supported output set to + * the given value. + * + * @see + * dictionary AuthenticationExtensionsLargeBlobOutputs + */ + public static LargeBlobRegistrationOutput supported(boolean supported) { + return new LargeBlobRegistrationOutput(supported); + } } /** diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala index 89b59e01b..ca42a1019 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala @@ -54,12 +54,12 @@ import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.CollectedClientData import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationInput.LargeBlobSupport +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialParameters -import com.yubico.webauthn.data.ReexportHelpers import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity @@ -4302,7 +4302,7 @@ class RelyingPartyRegistrationSpec ClientRegistrationExtensionOutputs .builder() .largeBlob( - ReexportHelpers.newLargeBlobRegistrationOutput(true) + LargeBlobRegistrationOutput.supported(true) ) .build() ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala index a2fc2dd32..be6274d6b 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala @@ -54,12 +54,12 @@ import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.CollectedClientData import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationInput.LargeBlobSupport +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialParameters -import com.yubico.webauthn.data.ReexportHelpers import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity @@ -4297,7 +4297,7 @@ class RelyingPartyV2RegistrationSpec ClientRegistrationExtensionOutputs .builder() .largeBlob( - ReexportHelpers.newLargeBlobRegistrationOutput(true) + LargeBlobRegistrationOutput.supported(true) ) .build() ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala index 6ded9bce3..e3989a314 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala @@ -327,7 +327,7 @@ class ExtensionsSpec Set("largeBlob") ) registrationCred.getClientExtensionResults.getLargeBlob.toScala should equal( - Some(new LargeBlobRegistrationOutput(true)) + Some(LargeBlobRegistrationOutput.supported(true)) ) assertionCred.getClientExtensionResults.getExtensionIds.asScala should equal( @@ -347,7 +347,7 @@ class ExtensionsSpec Set("largeBlob") ) registrationCred.getClientExtensionResults.getLargeBlob.toScala should equal( - Some(new LargeBlobRegistrationOutput(true)) + Some(LargeBlobRegistrationOutput.supported(true)) ) assertionCred.getClientExtensionResults.getExtensionIds.asScala should equal( diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala index aa2e1fe70..4828fcabc 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala @@ -883,7 +883,7 @@ object Generators { def largeBlobRegistrationOutput: Gen[LargeBlobRegistrationOutput] = for { supported <- arbitrary[Boolean] - } yield new LargeBlobRegistrationOutput(supported) + } yield LargeBlobRegistrationOutput.supported(supported) def largeBlobAuthenticationInput: Gen[LargeBlobAuthenticationInput] = halfsized( diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala index 8e69e2469..c5d1cb373 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala @@ -1,7 +1,6 @@ package com.yubico.webauthn.data import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput -import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput /** Public re-exports of things in the com.yubico.webauthn.data package, so that * tests can access them but dependent projects cannot (unless they do this @@ -9,9 +8,6 @@ import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput */ object ReexportHelpers { - def newLargeBlobRegistrationOutput( - supported: Boolean - ): LargeBlobRegistrationOutput = new LargeBlobRegistrationOutput(supported) def newLargeBlobAuthenticationOutput( blob: Option[ByteArray], written: Option[Boolean], From e0668eee93fc0015cb65c50f425db4e4d2758840 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 8 Nov 2023 14:59:48 +0100 Subject: [PATCH 18/21] Add public factory functions to LargeBlobAuthenticationOutput --- NEWS | 1 + .../com/yubico/webauthn/data/Extensions.java | 33 ++++++++++++++++++- .../webauthn/RelyingPartyAssertionSpec.scala | 11 +++---- .../RelyingPartyV2AssertionSpec.scala | 11 +++---- .../yubico/webauthn/data/ExtensionsSpec.scala | 7 ++-- .../com/yubico/webauthn/data/Generators.scala | 4 +-- .../webauthn/data/ReexportHelpers.scala | 19 ----------- 7 files changed, 46 insertions(+), 40 deletions(-) delete mode 100644 webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala diff --git a/NEWS b/NEWS index 5a2df224f..02b98efa9 100644 --- a/NEWS +++ b/NEWS @@ -9,6 +9,7 @@ New features: * Added public builder to `CredentialPropertiesOutput`. * Added public factory function `LargeBlobRegistrationOutput.supported(boolean)`. +* Added public factory functions to `LargeBlobAuthenticationOutput`. * (Experimental) Added option `isSecurePaymentConfirmation(boolean)` to `FinishAssertionOptions`. When set, `RelyingParty.finishAssertion()` will adapt the validation logic for a Secure Payment Confirmation (SPC) response diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java index 1cd3348d0..8a819367f 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java @@ -364,12 +364,43 @@ public static class LargeBlobAuthenticationOutput { @JsonProperty private final Boolean written; @JsonCreator - LargeBlobAuthenticationOutput( + private LargeBlobAuthenticationOutput( @JsonProperty("blob") ByteArray blob, @JsonProperty("written") Boolean written) { this.blob = blob; this.written = written; } + /** + * Create a Large blob storage extension output with the blob output set to the + * given value. + * + *

This corresponds to the extension input {@link LargeBlobAuthenticationInput#read() + * LargeBlobAuthenticationInput.read()}. + * + * @see + * dictionary AuthenticationExtensionsLargeBlobOutputs + */ + public static LargeBlobAuthenticationOutput read(final ByteArray blob) { + return new LargeBlobAuthenticationOutput(blob, null); + } + + /** + * Create a Large blob storage extension output with the written output set to + * the given value. + * + *

This corresponds to the extension input {@link + * LargeBlobAuthenticationInput#write(ByteArray) + * LargeBlobAuthenticationInput.write(ByteArray)}. + * + * @see + * dictionary AuthenticationExtensionsLargeBlobOutputs + */ + public static LargeBlobAuthenticationOutput write(final boolean write) { + return new LargeBlobAuthenticationOutput(null, write); + } + /** * The opaque byte string that was associated with the credential identified by {@link * PublicKeyCredential#getId()}. Only valid if {@link LargeBlobAuthenticationInput#getRead()} diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala index 115b75ee3..6d4c711f2 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala @@ -39,6 +39,7 @@ import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.CollectedClientData import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential @@ -46,7 +47,6 @@ import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions -import com.yubico.webauthn.data.ReexportHelpers import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity import com.yubico.webauthn.data.UserVerificationRequirement @@ -2517,8 +2517,7 @@ class RelyingPartyAssertionSpec ClientAssertionExtensionOutputs .builder() .largeBlob( - ReexportHelpers - .newLargeBlobAuthenticationOutput(None, Some(true)) + LargeBlobAuthenticationOutput.write(true) ) .build() ) @@ -2559,10 +2558,8 @@ class RelyingPartyAssertionSpec ClientAssertionExtensionOutputs .builder() .largeBlob( - ReexportHelpers.newLargeBlobAuthenticationOutput( - Some(ByteArray.fromHex("00010203")), - None, - ) + LargeBlobAuthenticationOutput + .read(ByteArray.fromHex("00010203")) ) .build() ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala index c91be8959..402f2f1d7 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala @@ -39,6 +39,7 @@ import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.CollectedClientData import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential @@ -46,7 +47,6 @@ import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions -import com.yubico.webauthn.data.ReexportHelpers import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity import com.yubico.webauthn.data.UserVerificationRequirement @@ -2594,8 +2594,7 @@ class RelyingPartyV2AssertionSpec ClientAssertionExtensionOutputs .builder() .largeBlob( - ReexportHelpers - .newLargeBlobAuthenticationOutput(None, Some(true)) + LargeBlobAuthenticationOutput.write(true) ) .build() ) @@ -2637,10 +2636,8 @@ class RelyingPartyV2AssertionSpec ClientAssertionExtensionOutputs .builder() .largeBlob( - ReexportHelpers.newLargeBlobAuthenticationOutput( - Some(ByteArray.fromHex("00010203")), - None, - ) + LargeBlobAuthenticationOutput + .read(ByteArray.fromHex("00010203")) ) .build() ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala index e3989a314..30080c42c 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala @@ -334,7 +334,7 @@ class ExtensionsSpec Set("appid", "largeBlob") ) assertionCred.getClientExtensionResults.getLargeBlob.toScala should equal( - Some(new LargeBlobAuthenticationOutput(null, true)) + Some(LargeBlobAuthenticationOutput.write(true)) ) } @@ -355,9 +355,8 @@ class ExtensionsSpec ) assertionCred.getClientExtensionResults.getLargeBlob.toScala should equal( Some( - new LargeBlobAuthenticationOutput( - new ByteArray("Hello, World!".getBytes(StandardCharsets.UTF_8)), - null, + LargeBlobAuthenticationOutput.read( + new ByteArray("Hello, World!".getBytes(StandardCharsets.UTF_8)) ) ) ) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala index 4828fcabc..2a7ce9df3 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala @@ -898,8 +898,8 @@ object Generators { blob <- arbitrary[ByteArray] written <- arbitrary[Boolean] result <- Gen.oneOf( - new LargeBlobAuthenticationOutput(blob, null), - new LargeBlobAuthenticationOutput(null, written), + LargeBlobAuthenticationOutput.read(blob), + LargeBlobAuthenticationOutput.write(written), ) } yield result) } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala deleted file mode 100644 index c5d1cb373..000000000 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala +++ /dev/null @@ -1,19 +0,0 @@ -package com.yubico.webauthn.data - -import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput - -/** Public re-exports of things in the com.yubico.webauthn.data package, so that - * tests can access them but dependent projects cannot (unless they do this - * same workaround hack). - */ -object ReexportHelpers { - - def newLargeBlobAuthenticationOutput( - blob: Option[ByteArray], - written: Option[Boolean], - ): LargeBlobAuthenticationOutput = - new LargeBlobAuthenticationOutput( - blob.orNull, - written.map(java.lang.Boolean.valueOf).orNull, - ) -} From 379c5fbac004728a903cb39c2b6b00f0fa791ad6 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 9 Nov 2023 19:10:58 +0100 Subject: [PATCH 19/21] Fill out JavaDoc for credentialRepositoryV2 and usernameRepository setters --- .../src/main/java/com/yubico/webauthn/RelyingParty.java | 7 ++++++- .../src/main/java/com/yubico/webauthn/RelyingPartyV2.java | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index 2553d3ab8..52bb2528a 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java @@ -606,7 +606,12 @@ public RelyingPartyBuilder credentialRepository(CredentialRepository credentialR /** * {@link RelyingPartyBuilder#credentialRepository(CredentialRepository) - * credentialRepository} is a required parameter. + * credentialRepository} is a required parameter. This setter differs from {@link + * #credentialRepository(CredentialRepository)} in that it takes an instance of {@link + * CredentialRepositoryV2} and converts the builder's return type to {@link RelyingPartyV2}. + * {@link CredentialRepositoryV2} does not require the application to support usernames, + * unless {@link RelyingPartyV2.RelyingPartyV2Builder#usernameRepository(UsernameRepository) + * usernameRepository} is also set in a subsequent builder step. * * @see #credentialRepository(CredentialRepository) * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java index b81d47e36..23a71c5bf 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java @@ -140,7 +140,13 @@ public class RelyingPartyV2 { @NonNull private final CredentialRepositoryV2 credentialRepository; /** - * TODO + * Enable support for identifying users by username. + * + *

If set, then {@link #startAssertion(StartAssertionOptions)} allows setting the {@link + * StartAssertionOptions.StartAssertionOptionsBuilder#username(String) username} parameter when + * starting an assertion. + * + *

By default, this is not set. * * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted * before reaching a mature release. From d8de44ec098a9641013467cc182aa92aefe2b006 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 9 Nov 2023 19:15:53 +0100 Subject: [PATCH 20/21] Mark RegisteredCredential.transports as an experimental feature --- NEWS | 3 +++ .../java/com/yubico/webauthn/RegisteredCredential.java | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 02b98efa9..729268799 100644 --- a/NEWS +++ b/NEWS @@ -42,6 +42,9 @@ New features: `CredentialRepositoryV2` implementation. ** NOTE: Experimental features may receive breaking changes without a major version increase. +* (Experimental) Added property `RegisteredCredential.transports`. + ** NOTE: Experimental features may receive breaking changes without a major + version increase. == Version 2.5.0 == diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java index cdb86a69e..2a5fa30cf 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java @@ -149,8 +149,10 @@ public PublicKey getParsedPublicKey() * in 5.8.3. Credential Descriptor (dictionary PublicKeyCredentialDescriptor) * @see AuthenticatorAttestationResponse#getTransports() * @see PublicKeyCredentialDescriptor#getTransports() + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ - @Builder.Default private final Set transports = null; + @Deprecated @Builder.Default private final Set transports = null; /** * The state of the BE flag when @@ -244,7 +246,10 @@ private RegisteredCredential( * in 5.8.3. Credential Descriptor (dictionary PublicKeyCredentialDescriptor) * @see AuthenticatorAttestationResponse#getTransports() * @see PublicKeyCredentialDescriptor#getTransports() + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. */ + @Deprecated @Override public Optional> getTransports() { return Optional.ofNullable(transports); From 85de45bda99107c5465046e1c990f01d72cc85e1 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 9 Nov 2023 19:20:03 +0100 Subject: [PATCH 21/21] Bump JDK version in signature verification workflow --- .github/workflows/release-verify-signatures.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index e2ff48b54..604a22350 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -39,7 +39,7 @@ jobs: strategy: matrix: - java: ["17.0.7"] + java: ["17.0.9"] distribution: [temurin, zulu, microsoft] steps: