From 08367041a61b2679daa16a7f41884d3d23c02e63 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 21 Jun 2023 11:09:12 +0200 Subject: [PATCH 001/132] expose credential public key as a PublicKey object --- .gitignore | 3 +++ .../yubico/webauthn/RegisteredCredential.java | 17 +++++++++++++++++ .../com/yubico/webauthn/RegistrationResult.java | 16 ++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/.gitignore b/.gitignore index 896796d4c..2ad24a54e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ out/ *.iws .attach_pid* +# VS Code +.vscode/ + # Mac .DS_Store 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 eeba1d362..72e3086b1 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 @@ -24,6 +24,7 @@ package com.yubico.webauthn; +import COSE.CoseException; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.yubico.webauthn.data.AttestedCredentialData; @@ -33,6 +34,10 @@ import com.yubico.webauthn.data.COSEAlgorithmIdentifier; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.UserIdentity; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; import java.util.Optional; import lombok.AccessLevel; import lombok.Builder; @@ -83,6 +88,18 @@ public final class RegisteredCredential { */ @NonNull private final ByteArray publicKeyCose; + /** + * The credential public key, as a {@link java.security.PublicKey} object. + * + *

Provided for convenience. + */ + @NonNull + @JsonIgnore + public PublicKey getPublicKeyAsPublicKey() + throws InvalidKeySpecException, NoSuchAlgorithmException, CoseException, IOException { + return WebAuthnCodecs.importCosePublicKey(getPublicKeyCose()); + } + /** * The stored signature * count of the credential. diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index 2189ee81c..1ea23a93a 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -24,6 +24,7 @@ package com.yubico.webauthn; +import COSE.CoseException; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; @@ -38,9 +39,13 @@ import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -263,6 +268,17 @@ public ByteArray getPublicKeyCose() { .getCredentialPublicKey(); } + /** + * The public key of the created credential, as a {@link java.security.PublicKey} object. + *

Provided for convenience. + */ + @NonNull + @JsonIgnore + public PublicKey getPublicKeyAsPublicKey() + throws InvalidKeySpecException, NoSuchAlgorithmException, CoseException, IOException { + return WebAuthnCodecs.importCosePublicKey(getPublicKeyCose()); + } + /** * The client From de39170c2f5ffaafdc396fa2579becd131de9e1d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 22 Jun 2023 18:23:44 +0200 Subject: [PATCH 002/132] Add missing import and run spotlessApply --- .../src/main/java/com/yubico/webauthn/RegisteredCredential.java | 1 + .../src/main/java/com/yubico/webauthn/RegistrationResult.java | 1 + 2 files changed, 2 insertions(+) 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 72e3086b1..3b2cd348c 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 @@ -26,6 +26,7 @@ import COSE.CoseException; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.yubico.webauthn.data.AttestedCredentialData; import com.yubico.webauthn.data.AuthenticatorAssertionResponse; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index 1ea23a93a..5aa4129a0 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -270,6 +270,7 @@ public ByteArray getPublicKeyCose() { /** * The public key of the created credential, as a {@link java.security.PublicKey} object. + * *

Provided for convenience. */ @NonNull From db3f868c36c198280e9032d67b533ffe9b9cf58e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 22 Jun 2023 18:35:29 +0200 Subject: [PATCH 003/132] Rename getPublicKeyAsPublicKey to getParsedPublicKey, refine JavaDoc --- .../java/com/yubico/webauthn/RegisteredCredential.java | 7 ++++--- .../main/java/com/yubico/webauthn/RegistrationResult.java | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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 3b2cd348c..ed6aa5255 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 @@ -90,13 +90,14 @@ public final class RegisteredCredential { @NonNull private final ByteArray publicKeyCose; /** - * The credential public key, as a {@link java.security.PublicKey} object. + * The public key of the credential, parsed as a {@link PublicKey} object. * - *

Provided for convenience. + * @see #getPublicKeyCose() + * @see RegistrationResult#getParsedPublicKey() */ @NonNull @JsonIgnore - public PublicKey getPublicKeyAsPublicKey() + public PublicKey getParsedPublicKey() throws InvalidKeySpecException, NoSuchAlgorithmException, CoseException, IOException { return WebAuthnCodecs.importCosePublicKey(getPublicKeyCose()); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index 5aa4129a0..f86ff70d3 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -269,13 +269,14 @@ public ByteArray getPublicKeyCose() { } /** - * The public key of the created credential, as a {@link java.security.PublicKey} object. + * The public key of the created credential, parsed as a {@link PublicKey} object. * - *

Provided for convenience. + * @see #getPublicKeyCose() + * @see RegisteredCredential#getParsedPublicKey() */ @NonNull @JsonIgnore - public PublicKey getPublicKeyAsPublicKey() + public PublicKey getParsedPublicKey() throws InvalidKeySpecException, NoSuchAlgorithmException, CoseException, IOException { return WebAuthnCodecs.importCosePublicKey(getPublicKeyCose()); } From a5d88f7ece1c3fd8b5e1258b33bad8bcc6ae41fb Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 28 Jun 2023 10:30:03 +0200 Subject: [PATCH 004/132] Update NEWS --- NEWS | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/NEWS b/NEWS index c3088166b..f1c909e04 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,11 @@ +== Version 2.6.0 (unreleased) == + +* Added method `getParsedPublicKey(): java.security.PublicKey` to + `RegistrationResult` and `RegisteredCredential`. + ** Thanks to Jakob Heher (A-SIT) for the contribution, see + https://github.com/Yubico/java-webauthn-server/pull/299 + + == Version 2.5.0 (unreleased) == `webauthn-server-core`: From 869fcc5cc66020d3f4b70802441559f5ff6c79c0 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 5 Jul 2023 21:03:31 +0200 Subject: [PATCH 005/132] Delete unused fields --- .../src/main/java/com/yubico/webauthn/FinishAssertionSteps.java | 1 - .../main/java/com/yubico/webauthn/FinishRegistrationSteps.java | 1 - 2 files changed, 2 deletions(-) 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 88b792435..b5e688f2d 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 @@ -63,7 +63,6 @@ final class FinishAssertionSteps { @Builder.Default private final boolean allowOriginPort = false; @Builder.Default private final boolean allowOriginSubdomain = false; - @Builder.Default private final boolean allowUnrequestedExtensions = false; @Builder.Default private final boolean validateSignatureCounter = true; public Step5 begin() { 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 172da13dd..cd7e85f9d 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 @@ -90,7 +90,6 @@ final class FinishRegistrationSteps { @Builder.Default private final boolean allowOriginPort = false; @Builder.Default private final boolean allowOriginSubdomain = false; - @Builder.Default private final boolean allowUnrequestedExtensions = false; public Step6 begin() { return new Step6(); From 4a794e515b9d2fd77b905412011b76dddf9e9b82 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 5 Jul 2023 21:21:53 +0200 Subject: [PATCH 006/132] Don't use @Builder in FinishAssertionSteps and FinishRegistrationSteps --- .../yubico/webauthn/FinishAssertionSteps.java | 21 +++++-- .../webauthn/FinishRegistrationSteps.java | 21 +++++-- .../com/yubico/webauthn/RelyingParty.java | 55 ++----------------- .../webauthn/RelyingPartyAssertionSpec.scala | 8 ++- .../RelyingPartyRegistrationSpec.scala | 17 ++++-- 5 files changed, 55 insertions(+), 67 deletions(-) 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 b5e688f2d..7bfaaa500 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,9 @@ import java.security.spec.InvalidKeySpecException; import java.util.Optional; import java.util.Set; -import lombok.Builder; import lombok.Value; import lombok.extern.slf4j.Slf4j; -@Builder @Slf4j final class FinishAssertionSteps { @@ -60,10 +58,21 @@ final class FinishAssertionSteps { private final Set origins; private final String rpId; private final CredentialRepository credentialRepository; - - @Builder.Default private final boolean allowOriginPort = false; - @Builder.Default private final boolean allowOriginSubdomain = false; - @Builder.Default private final boolean validateSignatureCounter = true; + private final boolean allowOriginPort; + private final boolean allowOriginSubdomain; + private final boolean validateSignatureCounter; + + 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(); + } public Step5 begin() { return new Step5(); 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 cd7e85f9d..ad6094e8a 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,11 +64,9 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import lombok.Builder; import lombok.Value; import lombok.extern.slf4j.Slf4j; -@Builder @Slf4j final class FinishRegistrationSteps { @@ -87,9 +85,22 @@ final class FinishRegistrationSteps { private final Optional attestationTrustSource; private final CredentialRepository credentialRepository; private final Clock clock; - - @Builder.Default private final boolean allowOriginPort = false; - @Builder.Default private final boolean allowOriginSubdomain = false; + 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(); + } public Step6 begin() { return new Step6(); 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 7c0daf12d..b3b64691b 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 @@ -29,14 +29,9 @@ import com.yubico.webauthn.attestation.AttestationTrustSource; import com.yubico.webauthn.data.AssertionExtensionInputs; import com.yubico.webauthn.data.AttestationConveyancePreference; -import com.yubico.webauthn.data.AuthenticatorAssertionResponse; -import com.yubico.webauthn.data.AuthenticatorAttestationResponse; import com.yubico.webauthn.data.AuthenticatorData; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; -import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.CollectedClientData; -import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions.PublicKeyCredentialCreationOptionsBuilder; import com.yubico.webauthn.data.PublicKeyCredentialParameters; @@ -498,11 +493,7 @@ public PublicKeyCredentialCreationOptions startRegistration( public RegistrationResult finishRegistration(FinishRegistrationOptions finishRegistrationOptions) throws RegistrationFailedException { try { - return _finishRegistration( - finishRegistrationOptions.getRequest(), - finishRegistrationOptions.getResponse(), - finishRegistrationOptions.getCallerTokenBindingId()) - .run(); + return _finishRegistration(finishRegistrationOptions).run(); } catch (IllegalArgumentException e) { throw new RegistrationFailedException(e); } @@ -515,24 +506,8 @@ public RegistrationResult finishRegistration(FinishRegistrationOptions finishReg * It is a separate method to facilitate testing; users should call {@link * #finishRegistration(FinishRegistrationOptions)} instead of this method. */ - FinishRegistrationSteps _finishRegistration( - PublicKeyCredentialCreationOptions request, - PublicKeyCredential - response, - Optional callerTokenBindingId) { - return FinishRegistrationSteps.builder() - .request(request) - .response(response) - .callerTokenBindingId(callerTokenBindingId) - .credentialRepository(credentialRepository) - .origins(origins) - .rpId(identity.getId()) - .allowOriginPort(allowOriginPort) - .allowOriginSubdomain(allowOriginSubdomain) - .allowUntrustedAttestation(allowUntrustedAttestation) - .attestationTrustSource(attestationTrustSource) - .clock(clock) - .build(); + FinishRegistrationSteps _finishRegistration(FinishRegistrationOptions options) { + return new FinishRegistrationSteps(this, options); } public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptions) { @@ -576,11 +551,7 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio public AssertionResult finishAssertion(FinishAssertionOptions finishAssertionOptions) throws AssertionFailedException { try { - return _finishAssertion( - finishAssertionOptions.getRequest(), - finishAssertionOptions.getResponse(), - finishAssertionOptions.getCallerTokenBindingId()) - .run(); + return _finishAssertion(finishAssertionOptions).run(); } catch (IllegalArgumentException e) { throw new AssertionFailedException(e); } @@ -593,22 +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( - AssertionRequest request, - PublicKeyCredential response, - Optional callerTokenBindingId // = None.asJava - ) { - return FinishAssertionSteps.builder() - .request(request) - .response(response) - .callerTokenBindingId(callerTokenBindingId) - .origins(origins) - .rpId(identity.getId()) - .credentialRepository(credentialRepository) - .allowOriginPort(allowOriginPort) - .allowOriginSubdomain(allowOriginSubdomain) - .validateSignatureCounter(validateSignatureCounter) - .build(); + FinishAssertionSteps _finishAssertion(FinishAssertionOptions options) { + return new FinishAssertionSteps(this, options); } public static RelyingPartyBuilder.MandatoryStages builder() { 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 07b0b7301..a3111908f 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 @@ -277,9 +277,15 @@ class RelyingPartyAssertionSpec origins.map(_.asJava).foreach(builder.origins _) + val fao = FinishAssertionOptions + .builder() + .request(request) + .response(response) + .callerTokenBindingId(callerTokenBindingId.toJava) + builder .build() - ._finishAssertion(request, response, callerTokenBindingId.toJava) + ._finishAssertion(fao.build()) } testWithEachProvider { it => 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 39f20a1b2..50967b5f7 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 @@ -175,17 +175,22 @@ class RelyingPartyRegistrationSpec origins.map(_.asJava).foreach(builder.origins _) - builder - .build() - ._finishRegistration( + val fro = FinishRegistrationOptions + .builder() + .request( pubkeyCredParams .map(pkcp => testData.request.toBuilder.pubKeyCredParams(pkcp.asJava).build() ) - .getOrElse(testData.request), - testData.response, - callerTokenBindingId.toJava, + .getOrElse(testData.request) ) + .response(testData.response) + .callerTokenBindingId(callerTokenBindingId.toJava) + .build() + + builder + .build() + ._finishRegistration(fro) } val emptyTrustSource = new AttestationTrustSource { From b62ee4398c0c0cfa4ec9009031e38a7c34eb7e42 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 5 Jul 2023 21:45:15 +0200 Subject: [PATCH 007/132] Implement experimental support for SPC response type --- NEWS | 6 ++ .../webauthn/FinishAssertionOptions.java | 37 +++++++++ .../yubico/webauthn/FinishAssertionSteps.java | 9 ++- .../webauthn/RelyingPartyAssertionSpec.scala | 80 ++++++++++++++++++- 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/NEWS b/NEWS index b11942255..0b3d1203e 100644 --- a/NEWS +++ b/NEWS @@ -1,9 +1,15 @@ == Version 2.6.0 (unreleased) == +New features: + * Added method `getParsedPublicKey(): java.security.PublicKey` to `RegistrationResult` and `RegisteredCredential`. ** Thanks to Jakob Heher (A-SIT) for the contribution, see https://github.com/Yubico/java-webauthn-server/pull/299 +* (Experimental) Added option `isSecurePaymentConfirmation(boolean)` to + `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. == Version 2.5.0 == diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java index fa126741b..04a8f3b65 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java @@ -27,8 +27,10 @@ import com.yubico.webauthn.data.AuthenticatorAssertionResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.CollectedClientData; import com.yubico.webauthn.data.PublicKeyCredential; import java.util.Optional; +import java.util.Set; import lombok.Builder; import lombok.NonNull; import lombok.Value; @@ -59,6 +61,41 @@ public class FinishAssertionOptions { */ private final ByteArray callerTokenBindingId; + /** + * EXPERIMENTAL FEATURE: + * + *

If set to false (the default), the "type" property in the collected + * client data of the assertion will be verified to equal "webauthn.get". + * + *

If set to true, it will instead be verified to equal "payment.get" + * . + * + *

NOTE: If you're using Secure Payment + * Confirmation (SPC), you likely also need to relax the origin validation logic. Right now + * this library only supports matching against a finite {@link Set} of acceptable origins. If + * necessary, your application may validate the origin externally (see {@link + * PublicKeyCredential#getResponse()}, {@link AuthenticatorAssertionResponse#getClientData()} and + * {@link CollectedClientData#getOrigin()}) and construct a new {@link RelyingParty} instance for + * each SPC response, setting the {@link RelyingParty.RelyingPartyBuilder#origins(Set) origins} + * setting on that instance to contain the pre-validated origin value. + * + *

Better support for relaxing origin validation may be added as the feature matures. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + * @see Secure + * Payment Confirmation + * @see 5.8.1. + * Client Data Used in WebAuthn Signatures (dictionary CollectedClientData) + * @see RelyingParty.RelyingPartyBuilder#origins(Set) + * @see CollectedClientData + * @see CollectedClientData#getOrigin() + */ + @Deprecated @Builder.Default private final boolean isSecurePaymentConfirmation = false; + /** * The token binding ID of the * connection to the client, if any. 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 7bfaaa500..7c6821007 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 @@ -50,6 +50,7 @@ final class FinishAssertionSteps { private static final String CLIENT_DATA_TYPE = "webauthn.get"; + private static final String SPC_CLIENT_DATA_TYPE = "payment.get"; private final AssertionRequest request; private final PublicKeyCredential @@ -61,6 +62,7 @@ final class FinishAssertionSteps { 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(); @@ -72,6 +74,7 @@ final class FinishAssertionSteps { this.allowOriginPort = rp.isAllowOriginPort(); this.allowOriginSubdomain = rp.isAllowOriginSubdomain(); this.validateSignatureCounter = rp.isValidateSignatureCounter(); + this.isSecurePaymentConfirmation = options.isSecurePaymentConfirmation(); } public Step5 begin() { @@ -288,10 +291,12 @@ class Step11 implements Step { @Override public void validate() { + final String expectedType = + isSecurePaymentConfirmation ? SPC_CLIENT_DATA_TYPE : CLIENT_DATA_TYPE; assertTrue( - CLIENT_DATA_TYPE.equals(clientData.getType()), + expectedType.equals(clientData.getType()), "The \"type\" in the client data must be exactly \"%s\", was: %s", - CLIENT_DATA_TYPE, + expectedType, clientData.getType()); } 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 a3111908f..3bcda28a5 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 @@ -27,6 +27,7 @@ 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 @@ -179,6 +180,7 @@ class RelyingPartyAssertionSpec credentialId: ByteArray = Defaults.credentialId, credentialKey: KeyPair = Defaults.credentialKey, credentialRepository: Option[CredentialRepository] = None, + isSecurePaymentConfirmation: Option[Boolean] = None, origins: Option[Set[String]] = None, requestedExtensions: AssertionExtensionInputs = Defaults.requestedExtensions, @@ -283,6 +285,10 @@ class RelyingPartyAssertionSpec .response(response) .callerTokenBindingId(callerTokenBindingId.toJava) + isSecurePaymentConfirmation foreach { isSpc => + fao.isSecurePaymentConfirmation(isSpc) + } + builder .build() ._finishAssertion(fao.build()) @@ -941,14 +947,18 @@ class RelyingPartyAssertionSpec step.validations shouldBe a[Success[_]] } - def assertFails(typeString: String): Unit = { + def assertFails( + typeString: String, + isSecurePaymentConfirmation: Option[Boolean] = None, + ): Unit = { val steps = finishAssertion( clientDataJson = JacksonCodecs.json.writeValueAsString( JacksonCodecs.json .readTree(Defaults.clientDataJson) .asInstanceOf[ObjectNode] .set("type", jsonFactory.textNode(typeString)) - ) + ), + isSecurePaymentConfirmation = isSecurePaymentConfirmation, ) val step: FinishAssertionSteps#Step11 = steps.begin.next.next.next.next.next @@ -973,6 +983,72 @@ class RelyingPartyAssertionSpec 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(isSecurePaymentConfirmation = Some(true)) + val step: FinishAssertionSteps#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( + isSecurePaymentConfirmation = Some(true), + clientDataJson = json.writeValueAsString( + json + .readTree(Defaults.clientDataJson) + .asInstanceOf[ObjectNode] + .set[ObjectNode]("type", new TextNode("payment.get")) + ), + ) + val step: FinishAssertionSteps#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.") { From ec0841a47d7e6387a8536ee001bcf9f0e473340e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 7 Jul 2023 13:41:28 +0200 Subject: [PATCH 008/132] 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: + * + *

+ * + * @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 009/132] 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 31fbde89adb328ec38c2c1b341c7d76733efa3b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 13:01:14 +0000 Subject: [PATCH 010/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.19.0 to 6.20.0 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.19.0 to 6.20.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.19.0...gradle/6.20.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 8e2ebbf9b..793be1c88 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.19.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.20.0") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.14") } } From 18699f196efc170132b39e586e5ab3d7c5906ee4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:56:54 +0000 Subject: [PATCH 011/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.20.0 to 6.21.0 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.20.0 to 6.21.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.20.0...gradle/6.21.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 793be1c88..e76660a25 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.20.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.21.0") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.14") } } From 7ac022cab7d483ea360085d65fb5c20cce9bea7b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Aug 2023 12:07:35 +0200 Subject: [PATCH 012/132] Use absolute URLs to sources --- webauthn-server-demo/README | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index 6735d1b79..a204dee34 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -7,9 +7,9 @@ one can perform auxiliary actions such as adding an additional authenticator or deregistering a credential. The central part is the -link:src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`] +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`] class, and the -link:src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`] +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`] class which provides the REST API on top of it. @@ -32,21 +32,21 @@ link:../webauthn-server-core/[`webauthn-server-core`] library: - The front end interacts with the server via a *REST API*, implemented in - link:src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`]. + link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`]. + This layer manages translation between JSON request/response payloads and domain objects, and most methods simply call into analogous methods in the server layer. - The REST API then delegates to the *server layer*, implemented in - link:src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`]. + link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`]. + This layer manages the general architecture of the system, and is where most business logic and integration code would go. The demo server implements the "persistent" storage of users and credential registrations - the link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] integration point - as the -link:src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] class, which simply keeps them stored in memory for a limited time. The transient storage of pending challenges is also kept in memory, but for a shorter duration. @@ -103,7 +103,7 @@ To build it, run === Standalone Java executable The standalone Java executable has the main class -link:src/main/java/demo/webauthn/EmbeddedServer.java[`demo.webauthn.EmbeddedServer`]. +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java[`demo.webauthn.EmbeddedServer`]. This server also serves the REST API at `/api/v1/`, and static resources for the GUI under `/`. From e73085879c835b179c6152ba6a72e14d1a41eb9d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Aug 2023 12:15:45 +0200 Subject: [PATCH 013/132] Fix incorrect description of webauthn-server-attestation in demo README --- webauthn-server-demo/README | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index a204dee34..af3f18bed 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -77,8 +77,8 @@ link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server- interface to enable identification and validation of authenticator models. This instance is then used to look up trusted attestation root certificates. The link:../webauthn-server-attestation/[`webauthn-server-attestation`] -sibling library provides implementations of this interface that are pre-seeded -with Yubico device metadata. +sibling library provides an implementation of this interface that integrates +with the https://fidoalliance.org/metadata/[FIDO Metadata Service]. == Usage From 5fd272d3749ca511a3cd4c3719efcbfcdbebbae5 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Aug 2023 12:18:22 +0200 Subject: [PATCH 014/132] Describe what interface implementations are used in the demo app --- webauthn-server-demo/README | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index af3f18bed..4eaf49dc6 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -72,6 +72,9 @@ notable integration points are: link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] interface to use for looking up stored public keys, user handles and signature counters. +The example app does this via the +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] +class. ** The library user can optionally provide an instance of the link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`] interface to enable identification and validation of authenticator models. This @@ -79,6 +82,14 @@ instance is then used to look up trusted attestation root certificates. The link:../webauthn-server-attestation/[`webauthn-server-attestation`] sibling library provides an implementation of this interface that integrates with the https://fidoalliance.org/metadata/[FIDO Metadata Service]. ++ +For this the example app uses the +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/com/yubico/webauthn/attestation/YubicoJsonMetadataService.java[`YubicoJsonMetadataService`] +class, which reads attestation data from a bundled JSON file. If enabled by +configuration, this is also combined with the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +implementation from the +link:../webauthn-server-attestation/[`webauthn-server-attestation`] module. == Usage From 86f6cc3d901df292a416cf2f4f180bbe4655108e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Aug 2023 12:19:51 +0200 Subject: [PATCH 015/132] Fix line break in environment variable example --- webauthn-server-demo/README | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index 4eaf49dc6..3123622f6 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -165,8 +165,7 @@ correct environment. - `YUBICO_WEBAUTHN_RP_NAME`: The human-readable https://www.w3.org/TR/webauthn/#dom-publickeycredentialentity-name[RP name] - the server will report. Example: `YUBICO_WEBAUTHN_RP_ID='Yubico Web - Authentication demo'` + the server will report. Example: `YUBICO_WEBAUTHN_RP_ID='Yubico Web Authentication demo'` - `YUBICO_WEBAUTHN_USE_FIDO_MDS`: If set to `true` (case-insensitive), use https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] From d685dd460acee4c301af935eae1997c69f39991b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Aug 2023 12:21:13 +0200 Subject: [PATCH 016/132] Fix paragraph continuation in demo README --- webauthn-server-demo/README | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index 3123622f6..fd240816d 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -152,9 +152,9 @@ correct environment. - `YUBICO_WEBAUTHN_PORT`: Port number to run the server on. Example: `YUBICO_WEBAUTHN_PORT=8081` - - This is ignored when running as a `.war` artifact, since the port is - controlled by the parent web server. ++ +This is ignored when running as a `.war` artifact, since the port is +controlled by the parent web server. - `YUBICO_WEBAUTHN_ALLOWED_ORIGINS`: Comma-separated list of origins the server will accept requests for. Example: From c254c39405bc14a3adda44ae7eee93b7d624e4f7 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Aug 2023 12:21:59 +0200 Subject: [PATCH 017/132] Fix a word in demo README --- webauthn-server-demo/README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index fd240816d..9076d0e14 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -157,7 +157,7 @@ This is ignored when running as a `.war` artifact, since the port is controlled by the parent web server. - `YUBICO_WEBAUTHN_ALLOWED_ORIGINS`: Comma-separated list of origins the - server will accept requests for. Example: + server will accept requests from. Example: `YUBICO_WEBAUTHN_ALLOWED_ORIGINS=http://demo.yubico.com:8080` - `YUBICO_WEBAUTHN_RP_ID`: The https://www.w3.org/TR/webauthn/#rp-id[RP ID] From ac091881fd053e4db3b8c97f40972d206fcbf788 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 13:44:42 +0000 Subject: [PATCH 018/132] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/code-formatting.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/coverage.yml | 4 ++-- .github/workflows/release-verify-signatures.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 782041a8a..72bdf237d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v3 diff --git a/.github/workflows/code-formatting.yml b/.github/workflows/code-formatting.yml index 749569623..6ee8f92fe 100644 --- a/.github/workflows/code-formatting.yml +++ b/.github/workflows/code-formatting.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK uses: actions/setup-java@v3 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a48696100..7cd898691 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: actions/setup-java@v3 with: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e2bbe639e..00969a2a9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK uses: actions/setup-java@v3 @@ -52,7 +52,7 @@ jobs: output-file: build/gh-pages/coverage-badge.json - name: Check out GitHub Pages branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: gh-pages clean: false diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index e2ff48b54..7f45f2878 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -44,7 +44,7 @@ jobs: steps: - name: check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} From 640c89bac1bf2b3fbf469ac0817cbac6d7e26869 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 13:05:53 +0000 Subject: [PATCH 019/132] Bump info.solidsoft.gradle.pitest:gradle-pitest-plugin Bumps info.solidsoft.gradle.pitest:gradle-pitest-plugin from 1.9.11 to 1.15.0. --- updated-dependencies: - dependency-name: info.solidsoft.gradle.pitest:gradle-pitest-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index e76660a25..ec92d8a63 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,7 +10,7 @@ repositories { } dependencies { - implementation("info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.9.11") + implementation("info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.15.0") implementation("io.franzbecker:gradle-lombok:5.0.0") // Spotless dropped Java 8 support in version 2.33.0 From d2161780a3e27c628f9dfc27bb938739e94aec2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:16:10 +0000 Subject: [PATCH 020/132] Bump io.github.cosmicsilence:gradle-scalafix from 0.1.14 to 0.1.15 Bumps [io.github.cosmicsilence:gradle-scalafix](https://github.com/cosmicsilence/gradle-scalafix) from 0.1.14 to 0.1.15. - [Release notes](https://github.com/cosmicsilence/gradle-scalafix/releases) - [Commits](https://github.com/cosmicsilence/gradle-scalafix/compare/0.1.14...0.1.15) --- updated-dependencies: - dependency-name: io.github.cosmicsilence:gradle-scalafix dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index e76660a25..930ced371 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -16,6 +16,6 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { implementation("com.diffplug.spotless:spotless-plugin-gradle:6.21.0") - implementation("io.github.cosmicsilence:gradle-scalafix:0.1.14") + implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") } } From 29363d99a2a9f13f8aa5b4a5b38d07bad768703c Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 17 Oct 2023 21:13:59 +0200 Subject: [PATCH 021/132] 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 40e82696f72f8675a0d5ad215252e21b96a5264e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 19:42:16 +0000 Subject: [PATCH 022/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.21.0 to 6.22.0 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.21.0 to 6.22.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.21.0...gradle/6.22.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 930ced371..61f28d568 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.21.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.22.0") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") } } From 3d20dac8eaf650abeb8d70a35be62b088275a0c0 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 18 Oct 2023 14:57:12 +0200 Subject: [PATCH 023/132] Bump pitestVersion to match plugin version --- buildSrc/src/main/kotlin/project-convention-pitest.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/project-convention-pitest.gradle.kts b/buildSrc/src/main/kotlin/project-convention-pitest.gradle.kts index 4bd829dfb..0bd7a5104 100644 --- a/buildSrc/src/main/kotlin/project-convention-pitest.gradle.kts +++ b/buildSrc/src/main/kotlin/project-convention-pitest.gradle.kts @@ -4,7 +4,7 @@ plugins { } pitest { - pitestVersion.set("1.9.5") + pitestVersion.set("1.15.0") timestampedReports.set(false) outputFormats.set(listOf("XML", "HTML")) From 42f776b614396e7f2e8a9a00b52f971f4fd4dce5 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 18 Oct 2023 15:00:24 +0200 Subject: [PATCH 024/132] Run pitest workflow on dependabot-pitest branches, but don't overwrite reports --- .github/workflows/coverage.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 00969a2a9..69ce6fa81 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,7 +3,9 @@ name: Test coverage on: push: - branches: [main] + branches: + - main + - dependabot/gradle/info.solidsoft.gradle.pitest-gradle-pitest-plugin-* jobs: test: @@ -48,6 +50,7 @@ jobs: # This creates a file that defines a [Shields.io endpoint badge](https://shields.io/endpoint) # which we can then include in the project README. uses: ./.github/actions/pit-results-badge + if: github.ref_name == 'main' with: output-file: build/gh-pages/coverage-badge.json @@ -69,6 +72,7 @@ jobs: prev-mutations-file: prev-mutations.xml - name: Push to GitHub Pages + if: github.ref_name == 'main' run: | git config user.name github-actions git config user.email github-actions@github.com From 5d447bf07ff546154d9d658803603684878d0ccc Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 18 Oct 2023 15:01:28 +0200 Subject: [PATCH 025/132] Move both main-only steps last in coverage.yml --- .github/workflows/coverage.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 69ce6fa81..6d61b5b4d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -46,14 +46,6 @@ jobs: done sed "s/{shortcommit}/${GITHUB_SHA:0:8}/g;s/{commit}/${GITHUB_SHA}/g;s#{repo}#${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}#g" .github/workflows/coverage/index.html.template > build/gh-pages/index.html - - name: Create coverage badge - # This creates a file that defines a [Shields.io endpoint badge](https://shields.io/endpoint) - # which we can then include in the project README. - uses: ./.github/actions/pit-results-badge - if: github.ref_name == 'main' - with: - output-file: build/gh-pages/coverage-badge.json - - name: Check out GitHub Pages branch uses: actions/checkout@v4 with: @@ -71,6 +63,14 @@ jobs: prev-commit: ${{ env.PREV_COMMIT }} prev-mutations-file: prev-mutations.xml + - name: Create coverage badge + # This creates a file that defines a [Shields.io endpoint badge](https://shields.io/endpoint) + # which we can then include in the project README. + uses: ./.github/actions/pit-results-badge + if: github.ref_name == 'main' + with: + output-file: build/gh-pages/coverage-badge.json + - name: Push to GitHub Pages if: github.ref_name == 'main' run: | From 04a0783db9222f4dd02385e059b131ac038062b9 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 31 Oct 2023 15:57:56 +0100 Subject: [PATCH 026/132] 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 027/132] 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 028/132] 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 029/132] 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 030/132] 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 031/132] 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 032/132] 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 033/132] 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 034/132] 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 035/132] 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 036/132] 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 037/132] 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 038/132] 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 039/132] 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 040/132] 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 2e6e921c0f544a6cc5c0e3d491f71c45076e5b6e Mon Sep 17 00:00:00 2001 From: Ashok Goli Date: Wed, 8 Nov 2023 22:44:13 -0500 Subject: [PATCH 041/132] Updated YUBICO_WEBAUTHN_RP_ID to YUBICO_WEBAUTHN_RP_NAME in README.md YUBICO_WEBAUTHN_RP_ID used in example instead of YUBICO_WEBAUTHN_RP_NAME. Corrected it. --- webauthn-server-demo/README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index 9076d0e14..fbd589c38 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -165,7 +165,7 @@ controlled by the parent web server. - `YUBICO_WEBAUTHN_RP_NAME`: The human-readable https://www.w3.org/TR/webauthn/#dom-publickeycredentialentity-name[RP name] - the server will report. Example: `YUBICO_WEBAUTHN_RP_ID='Yubico Web Authentication demo'` + the server will report. Example: `YUBICO_WEBAUTHN_RP_NAME='Yubico Web Authentication demo'` - `YUBICO_WEBAUTHN_USE_FIDO_MDS`: If set to `true` (case-insensitive), use https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] From 379c5fbac004728a903cb39c2b6b00f0fa791ad6 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 9 Nov 2023 19:10:58 +0100 Subject: [PATCH 042/132] 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 043/132] 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 044/132] 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: From 2246f5d0bafe9ae583e729411959fa56b4b4c275 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 17 Oct 2023 16:24:43 +0200 Subject: [PATCH 045/132] Fix typo in JavaDoc --- .../webauthn/data/PublicKeyCredentialCreationOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java index a5f252c31..3d2b6033f 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java @@ -200,7 +200,7 @@ public String toJson() throws JsonProcessingException { } /** - * Decode an {@link PublicKeyCredentialCreationOptions} from JSON. The inverse of {@link + * Decode a {@link PublicKeyCredentialCreationOptions} from JSON. The inverse of {@link * #toJson()}. * *

If the JSON was generated by the {@link #toJson()} method, then {@link #fromJson(String)} in From e69d48f8cb59bee1b644bddb29dbb3e762e6aa2c Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 17 Oct 2023 20:51:59 +0200 Subject: [PATCH 046/132] Add enum parsing functions --- NEWS | 6 ++++++ .../webauthn/data/AuthenticatorAttachment.java | 18 +++++++++++++++++- .../webauthn/data/PublicKeyCredentialType.java | 15 +++++++++++++-- .../webauthn/data/ResidentKeyRequirement.java | 15 +++++++++++++-- .../webauthn/data/TokenBindingStatus.java | 15 +++++++++++++-- .../data/UserVerificationRequirement.java | 15 +++++++++++++-- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/NEWS b/NEWS index 0b3d1203e..52e2ef901 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,12 @@ 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 enum parsing functions: + ** `AuthenticatorAttachment.fromValue(String): Optional` + ** `PublicKeyCredentialType.fromId(String): Optional` + ** `ResidentKeyRequirement.fromValue(String): Optional` + ** `TokenBindingStatus.fromValue(String): Optional` + ** `UserVerificationRequirement.fromValue(String): Optional` * (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/AuthenticatorAttachment.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java index d5d338b42..ad223ed04 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Optional; import java.util.stream.Stream; import lombok.AllArgsConstructor; import lombok.Getter; @@ -72,8 +73,23 @@ public enum AuthenticatorAttachment { @JsonValue @Getter @NonNull private final String value; + /** + * Attempt to parse a string as an {@link AuthenticatorAttachment}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * AuthenticatorAttachment} + * @return The {@link AuthenticatorAttachment} instance whose {@link #getValue() value} equals + * value, if any. + * @see §5.4.5. + * Authenticator Attachment Enumeration (enum AuthenticatorAttachment) + */ + public static Optional fromValue(@NonNull String value) { + return Stream.of(values()).filter(v -> v.value.equals(value)).findAny(); + } + @JsonCreator private static AuthenticatorAttachment fromJsonString(@NonNull String value) { - return Stream.of(values()).filter(v -> v.value.equals(value)).findAny().orElse(null); + return fromValue(value).orElse(null); } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java index 7a480eea2..7e08a0dba 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java @@ -51,13 +51,24 @@ public enum PublicKeyCredentialType { @JsonValue @Getter @NonNull private final String id; - private static Optional fromString(@NonNull String id) { + /** + * Attempt to parse a string as a {@link PublicKeyCredentialType}. + * + * @param id a {@link String} equal to the {@link #getId() id} of a constant in {@link + * PublicKeyCredentialType} + * @return The {@link AuthenticatorAttachment} instance whose {@link #getId() id} equals id + * , if any. + * @see §5.10.2. + * Credential Type Enumeration (enum PublicKeyCredentialType) + */ + public static Optional fromId(@NonNull String id) { return Stream.of(values()).filter(v -> v.id.equals(id)).findAny(); } @JsonCreator private static PublicKeyCredentialType fromJsonString(@NonNull String id) { - return fromString(id) + return fromId(id) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java index b27912d25..a2664854e 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java @@ -105,13 +105,24 @@ public enum ResidentKeyRequirement { @JsonValue @Getter @NonNull private final String value; - private static Optional fromString(@NonNull String value) { + /** + * Attempt to parse a string as a {@link ResidentKeyRequirement}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * ResidentKeyRequirement} + * @return The {@link ResidentKeyRequirement} instance whose {@link #getValue() value} equals + * value, if any. + * @see §5.4.6. + * Resident Key Requirement Enumeration (enum ResidentKeyRequirement) + */ + public static Optional fromValue(@NonNull String value) { return Stream.of(values()).filter(v -> v.value.equals(value)).findAny(); } @JsonCreator private static ResidentKeyRequirement fromJsonString(@NonNull String value) { - return fromString(value) + return fromValue(value) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java index 1e499751b..a9feec1d6 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java @@ -58,13 +58,24 @@ public enum TokenBindingStatus { @JsonValue @Getter @NonNull private final String value; - private static Optional fromString(@NonNull String value) { + /** + * Attempt to parse a string as a {@link TokenBindingStatus}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * TokenBindingStatus} + * @return The {@link TokenBindingStatus} instance whose {@link #getValue() value} equals + * value, if any. + * @see enum + * TokenBindingStatus + */ + public static Optional fromValue(@NonNull String value) { return Arrays.stream(values()).filter(v -> v.value.equals(value)).findAny(); } @JsonCreator static TokenBindingStatus fromJsonString(@NonNull String value) { - return fromString(value) + return fromValue(value) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java index 642f71bf3..e975fed0b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java @@ -66,13 +66,24 @@ public enum UserVerificationRequirement { @JsonValue @Getter @NonNull private final String value; - private static Optional fromString(@NonNull String value) { + /** + * Attempt to parse a string as a {@link UserVerificationRequirement}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * UserVerificationRequirement} + * @return The {@link UserVerificationRequirement} instance whose {@link #getValue() value} equals + * value, if any. + * @see §5.10.6. + * User Verification Requirement Enumeration (enum UserVerificationRequirement) + */ + public static Optional fromValue(@NonNull String value) { return Stream.of(values()).filter(v -> v.value.equals(value)).findAny(); } @JsonCreator private static UserVerificationRequirement fromJsonString(@NonNull String value) { - return fromString(value) + return fromValue(value) .orElseThrow( () -> new IllegalArgumentException( From d386583ed1e200ac69f46ea87756b80fcc0ed057 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 17 Oct 2023 20:59:27 +0200 Subject: [PATCH 047/132] Clarify that CredentialRepository implementations MUST NOT return null --- .../java/com/yubico/webauthn/CredentialRepository.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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..9fa2871bb 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 @@ -42,6 +42,8 @@ public interface CredentialRepository { * *

After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method * returns a value suitable for inclusion in this set. + * + *

Implementations of this method MUST NOT return null. */ Set getCredentialIdsForUsername(String username); @@ -51,6 +53,8 @@ public interface CredentialRepository { * *

Used to look up the user handle based on the username, for authentication ceremonies where * the username is already given. + * + *

Implementations of this method MUST NOT return null. */ Optional getUserHandleForUsername(String username); @@ -60,6 +64,8 @@ public interface CredentialRepository { * *

Used to look up the username based on the user handle, for username-less authentication * ceremonies. + * + *

Implementations of this method MUST NOT return null. */ Optional getUsernameForUserHandle(ByteArray userHandle); @@ -69,6 +75,8 @@ public interface CredentialRepository { * *

The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read * directly from a database or assembled from other components. + * + *

Implementations of this method MUST NOT return null. */ Optional lookup(ByteArray credentialId, ByteArray userHandle); @@ -79,6 +87,8 @@ public interface CredentialRepository { *

This is used to refuse registration of duplicate credential IDs. Therefore, under normal * circumstances this method should only return zero or one credential (this is an expected * consequence, not an interface requirement). + * + *

Implementations of this method MUST NOT return null. */ Set lookupAll(ByteArray credentialId); } From e0233d7fe7f1760d719f161fede6ddc76d5da627 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 9 Nov 2023 20:27:51 +0100 Subject: [PATCH 048/132] Revert "Bump JDK version in signature verification workflow" This reverts commit 85de45bda99107c5465046e1c990f01d72cc85e1. --- .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 eddc2a078..7f45f2878 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.9"] + java: ["17.0.7"] distribution: [temurin, zulu, microsoft] steps: From 3c74bb57f4abe94488a854fdaaf3acd5b469603f Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 9 Nov 2023 20:31:27 +0100 Subject: [PATCH 049/132] Add releasing step to check that JDK version is available in GHA --- doc/releasing.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/releasing.md b/doc/releasing.md index 88083b50c..557e2fbe5 100644 --- a/doc/releasing.md +++ b/doc/releasing.md @@ -32,7 +32,8 @@ Release candidate versions java: ["17.0.7"] ``` - Commit this change, if any. + Check that this version is available in GitHub Actions. Commit this change, + if any. 4. Tag the head commit with an `X.Y.Z-RCN` tag: @@ -134,6 +135,8 @@ Release versions java: ["17.0.7"] ``` + Check that this version is available in GitHub Actions. + 7. Amend these changes into the merge commit: ``` From 9fbac37c7170e142bb8c1e561a467dc16c2ed699 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Nov 2023 13:47:57 +0000 Subject: [PATCH 050/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.22.0 to 6.23.0 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.22.0 to 6.23.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.22.0...gradle/6.23.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 06c9e10d0..4c3cadccf 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.22.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.23.0") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") } } From 20fddc91fa1e637b16aad820462eb0d426f5ed7b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 27 Nov 2023 16:00:41 +0100 Subject: [PATCH 051/132] Refer to RelyingParty.origins setting in origin mismatch error message --- .../main/java/com/yubico/webauthn/FinishAssertionSteps.java | 3 ++- .../main/java/com/yubico/webauthn/FinishRegistrationSteps.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 117cb17c1..ea01a9da1 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 @@ -400,7 +400,8 @@ public void validate() { final String responseOrigin = response.getResponse().getClientData().getOrigin(); assertTrue( OriginMatcher.isAllowed(responseOrigin, origins, allowOriginPort, allowOriginSubdomain), - "Incorrect origin: " + responseOrigin); + "Incorrect origin, please see the RelyingParty.origins setting: %s", + responseOrigin); } @Override 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 a7ba81d89..58ce2d6dc 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 @@ -215,7 +215,8 @@ public void validate() { final String responseOrigin = clientData.getOrigin(); assertTrue( OriginMatcher.isAllowed(responseOrigin, origins, allowOriginPort, allowOriginSubdomain), - "Incorrect origin: " + responseOrigin); + "Incorrect origin, please see the RelyingParty.origins setting: %s", + responseOrigin); } @Override From fadd7ababa630fb5c60ebea9436c2750388215a3 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 27 Nov 2023 16:01:03 +0100 Subject: [PATCH 052/132] Refer to code injections security consideration in allowOriginSubdomain JavaDoc --- .../src/main/java/com/yubico/webauthn/RelyingParty.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 52bb2528a..f8e588eb6 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 @@ -281,6 +281,11 @@ public class RelyingParty { * If true, the origin matching rule is relaxed to allow any subdomain, of any depth, * of the values of {@link RelyingPartyBuilder#origins(Set) origins}. * + *

Please see Security + * Considerations: Code injection attacks for discussion of the risks in setting this to + * true. + * *

The default is false. * *

Examples with origins: ["https://example.org", "https://acme.com:8443"] @@ -315,6 +320,9 @@ public class RelyingParty { *

  • https://acme.com * * + * + * @see §13.4.8. + * Code injection attacks */ @Builder.Default private final boolean allowOriginSubdomain = false; From 5bbeeb877aef2202ab6213cb83dbfc2740bb080c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:15:39 +0000 Subject: [PATCH 053/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.23.0 to 6.23.2 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.23.0 to 6.23.2. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/commits) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 4c3cadccf..6e688815a 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.23.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.23.2") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") } } From 0897c0626563be3730c3b3cdcf96a3d5c950adc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:52:39 +0000 Subject: [PATCH 054/132] Bump actions/setup-java from 3 to 4 Bumps [actions/setup-java](https://github.com/actions/setup-java) from 3 to 4. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/setup-java dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 4 ++-- .github/workflows/code-formatting.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/release-verify-signatures.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72bdf237d..cacb292f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: ${{ matrix.distribution }} @@ -45,7 +45,7 @@ jobs: run: ./gradlew clean testClasses - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: ${{ matrix.distribution }} diff --git a/.github/workflows/code-formatting.yml b/.github/workflows/code-formatting.yml index 6ee8f92fe..5c2e8174b 100644 --- a/.github/workflows/code-formatting.yml +++ b/.github/workflows/code-formatting.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: ${{ matrix.distribution }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7cd898691..721893c6f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6d61b5b4d..d6310dcb6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index 7f45f2878..545dc3b6e 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -49,7 +49,7 @@ jobs: ref: ${{ github.ref_name }} - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: ${{ matrix.distribution }} From 9d08a9d6dc0c50db79567b0aa2e39dbc467022d7 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 4 Dec 2023 14:31:38 +0100 Subject: [PATCH 055/132] Make dependabot ignore more patch updates of spotless-plugin-gradle --- .github/dependabot.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 031f7e8a5..36029b6e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,8 @@ updates: # Spotless patch updates are too noisy - dependency-name: "spotless-plugin-gradle" update-types: ["version-update:semver-patch"] + - dependency-name: "com.diffplug.spotless:spotless-plugin-gradle" + update-types: ["version-update:semver-patch"] - package-ecosystem: "github-actions" directory: "/" From d78c77c9f4b73c6554d15dd45737508b169bdfa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 13:27:19 +0000 Subject: [PATCH 056/132] Bump actions/upload-artifact from 3 to 4 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 6 +++--- .github/workflows/coverage.yml | 2 +- .github/workflows/release-verify-signatures.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cacb292f2..fa0711fb7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,7 +55,7 @@ jobs: - name: Archive HTML test report on failure if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-java17-java${{ matrix.java }}-${{ matrix.distribution }}-html path: "*/build/reports/**" @@ -68,14 +68,14 @@ jobs: - name: Archive HTML test report if: ${{ always() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-java${{ matrix.java }}-${{ matrix.distribution }}-html path: "*/build/reports/**" - name: Archive JUnit test report if: ${{ always() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-java${{ matrix.java }}-${{ matrix.distribution }}-xml path: "*/build/test-results/**/*.xml" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index d6310dcb6..9b9ce3c9c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -30,7 +30,7 @@ jobs: run: ./gradlew pitestMerge - name: Archive test reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pitest-reports-${{ github.sha }} path: "*/build/reports/pitest/**" diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index 545dc3b6e..694f3cbda 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -24,7 +24,7 @@ jobs: until wget https://repo1.maven.org/maven2/com/yubico/webauthn-server-core/${{ github.ref_name }}/webauthn-server-core-${{ github.ref_name }}.jar.asc; do sleep 180; done - name: Store keyring and signatures as artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: keyring-and-signatures retention-days: 1 From cfacabe7caaa8c9ac12b37d084005dc8bc970e55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 13:27:23 +0000 Subject: [PATCH 057/132] Bump actions/download-artifact from 3 to 4 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/release-verify-signatures.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cacb292f2..b23121151 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,7 +100,7 @@ jobs: steps: - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: test-reports-java${{ needs.test.outputs.report-java }}-${{ needs.test.outputs.report-dist }}-xml diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index 545dc3b6e..b41fd9e16 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -68,7 +68,7 @@ jobs: done - name: Retrieve keyring and signatures - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: keyring-and-signatures @@ -87,7 +87,7 @@ jobs: steps: - name: Retrieve signatures - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: keyring-and-signatures From a9a106acf34fc62c12fa466114f5136daf8f1087 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 13:27:27 +0000 Subject: [PATCH 058/132] Bump github/codeql-action from 2 to 3 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 721893c6f..328cf31ec 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -30,7 +30,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: java @@ -39,4 +39,4 @@ jobs: ./gradlew jar - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 From 5bd021b8d7ef4802d34a32db09556726665ce31f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:32:39 +0000 Subject: [PATCH 059/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.23.2 to 6.24.0 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.23.2 to 6.24.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/commits/gradle/6.24.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 6e688815a..1e76a9aab 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.23.2") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.24.0") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") } } From e944c4f635f0b03ff4beb0baaaa3ea4a31323ee0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jan 2024 13:09:31 +0000 Subject: [PATCH 060/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.24.0 to 6.25.0 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.24.0 to 6.25.0. - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.24.0...gradle/6.25.0) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 1e76a9aab..ac67ae261 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.24.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.25.0") implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") } } From a62d4c8ce5196caf697d1c79cce70f1cec1de39c Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 3 Jun 2024 14:58:03 +0200 Subject: [PATCH 061/132] Bump org.scalatestplus:junit dependency version Fixes this build failure that suddenly occurred with no code changes: ``` $ ./gradlew :webauthn-server-core:compileTestScala Execution failed for task ':webauthn-server-core:compileTestScala'. > bad constant pool index: 0 at pos: 89856 while compiling: ./webauthn-server-core/src/test/scala/com/yubico/scalacheck/gen/JacksonGenerators.scala during phase: globalPhase=typer, enteringPhase=namer library version: version 2.13.8 compiler version: version 2.13.8 reconstructed args: [9876 characters redacted for brevity] last tree to typer: Ident(com) tree position: line 35 of ./webauthn-server-core/src/test/scala/com/yubico/scalacheck/gen/JacksonGenerators.scala tree tpe: com.type symbol: final package com symbol definition: final package com (a ModuleSymbol) symbol package: symbol owners: package com call site: package gen in package scalacheck in package scalacheck == Source file context for tree position == 32 import com.fasterxml.jackson.databind.node.NumericNode 33 import com.fasterxml.jackson.databind.node.ObjectNode 34 import com.fasterxml.jackson.databind.node.TextNode 35 import com.upokecenter.cbor.CBORObject 36 import com.yubico.internal.util.JacksonCodecs 37 import org.scalacheck.Arbitrary 38 import org.scalacheck.Arbitrary.arbitrary ``` --- test-platform/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-platform/build.gradle.kts b/test-platform/build.gradle.kts index c3ee96679..e6224e144 100644 --- a/test-platform/build.gradle.kts +++ b/test-platform/build.gradle.kts @@ -12,7 +12,7 @@ dependencies { api("org.mockito:mockito-core:4.7.0") api("org.scalacheck:scalacheck_2.13:1.16.0") api("org.scalatest:scalatest_2.13:3.2.13") - api("org.scalatestplus:junit-4-13_2.13:3.2.13.0") + api("org.scalatestplus:junit-4-13_2.13:3.2.17.0") api("org.scalatestplus:scalacheck-1-16_2.13:3.2.13.0") api("org.slf4j:slf4j-nop:2.0.3") api("uk.org.lidalia:slf4j-test:1.2.0") From f242370b8c1ab1c430847a72ca0a486d005adb45 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 3 Jun 2024 15:19:39 +0200 Subject: [PATCH 062/132] Bump test dependency versions --- test-platform/build.gradle.kts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test-platform/build.gradle.kts b/test-platform/build.gradle.kts index e6224e144..763737ab2 100644 --- a/test-platform/build.gradle.kts +++ b/test-platform/build.gradle.kts @@ -9,12 +9,12 @@ dependencies { api("junit:junit:4.13.2") api("org.bouncycastle:bcpkix-jdk18on:[1.62,2)") api("org.bouncycastle:bcprov-jdk18on:[1.62,2)") - api("org.mockito:mockito-core:4.7.0") - api("org.scalacheck:scalacheck_2.13:1.16.0") - api("org.scalatest:scalatest_2.13:3.2.13") - api("org.scalatestplus:junit-4-13_2.13:3.2.17.0") - api("org.scalatestplus:scalacheck-1-16_2.13:3.2.13.0") - api("org.slf4j:slf4j-nop:2.0.3") + api("org.mockito:mockito-core:4.11.0") + api("org.scalacheck:scalacheck_2.13:1.18.0") + api("org.scalatest:scalatest_2.13:3.2.18") + api("org.scalatestplus:junit-4-13_2.13:3.2.18.0") + api("org.scalatestplus:scalacheck-1-16_2.13:3.2.14.0") + api("org.slf4j:slf4j-nop:2.0.13") api("uk.org.lidalia:slf4j-test:1.2.0") } } From 3a67ec4a1b2d6588e5161fc24de6ae28544ddd88 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 3 Jun 2024 15:42:40 +0200 Subject: [PATCH 063/132] Fix scalafix errors The `ignoreSourceSets` config is needed to fix errors like this: ``` Execution failed for task ':yubico-util:configSemanticDBMain'. > Failed to calculate the value of task ':yubico-util:configSemanticDBMain' property 'scalaVersion'. > Unable to detect the Scala version for the 'main' source set. Please ensure it declares dependency to scala-library or consider adding it to 'ignoreSourceSets' ``` The change to `project-convention-code-formatting-internal.gradle` is needed to fix this error: ``` Execution failed for task ':yubico-util-scala:compileScala'. > Could not resolve all files for configuration ':yubico-util-scala:detachedConfiguration1'. > Could not find org.scalameta:semanticdb-scalac_2.13.13:4.5.5. Searched in the following locations: - https://repo.maven.apache.org/maven2/org/scalameta/semanticdb-scalac_2.13.13/4.5.5/semanticdb-scalac_2.13.13-4.5.5.pom If the artifact you are trying to retrieve can be found in the repository but without metadata in 'Maven POM' format, you need to adjust the 'metadataSources { ... }' of the re pository declaration. Required by: project :yubico-util-scala ``` --- buildSrc/build.gradle.kts | 2 +- .../groovy/project-convention-code-formatting-internal.gradle | 4 ---- webauthn-server-attestation/build.gradle.kts | 4 ++++ webauthn-server-core/build.gradle.kts | 4 ++++ webauthn-server-demo/build.gradle.kts | 4 ++++ yubico-util/build.gradle.kts | 4 ++++ 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index ac67ae261..2efaa001c 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -16,6 +16,6 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { implementation("com.diffplug.spotless:spotless-plugin-gradle:6.25.0") - implementation("io.github.cosmicsilence:gradle-scalafix:0.1.15") + implementation("io.github.cosmicsilence:gradle-scalafix:0.2.2") } } diff --git a/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle b/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle index 29e7800f9..5da3528c4 100644 --- a/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle +++ b/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle @@ -12,10 +12,6 @@ spotless { scalafix { configFile.set(project.rootProject.file("scalafix.conf")) - - // Work around dependency resolution issues in April 2022 - semanticdb.autoConfigure.set(true) - semanticdb.version.set("4.5.5") } project.dependencies.scalafix("com.github.liancheng:organize-imports_2.13:0.6.0") diff --git a/webauthn-server-attestation/build.gradle.kts b/webauthn-server-attestation/build.gradle.kts index 1748835d2..1b331539e 100644 --- a/webauthn-server-attestation/build.gradle.kts +++ b/webauthn-server-attestation/build.gradle.kts @@ -86,3 +86,7 @@ tasks.javadoc.configure { // Use this instead for local testing //(options as StandardJavadocDocletOptions).linksOffline("file://${coreJavadoc.destinationDir}", "${coreJavadoc.destinationDir}") } + +scalafix { + ignoreSourceSets.add("main") +} diff --git a/webauthn-server-core/build.gradle.kts b/webauthn-server-core/build.gradle.kts index 9bbd2bda8..cc1d311e3 100644 --- a/webauthn-server-core/build.gradle.kts +++ b/webauthn-server-core/build.gradle.kts @@ -68,3 +68,7 @@ tasks.withType(Jar::class) { )) } } + +scalafix { + ignoreSourceSets.add("main") +} diff --git a/webauthn-server-demo/build.gradle.kts b/webauthn-server-demo/build.gradle.kts index 82830c46e..c0fb5f773 100644 --- a/webauthn-server-demo/build.gradle.kts +++ b/webauthn-server-demo/build.gradle.kts @@ -71,3 +71,7 @@ for (task in listOf(tasks.installDist, tasks.distZip, tasks.distTar)) { } } } + +scalafix { + ignoreSourceSets.add("main") +} diff --git a/yubico-util/build.gradle.kts b/yubico-util/build.gradle.kts index e8319c120..4d503d950 100644 --- a/yubico-util/build.gradle.kts +++ b/yubico-util/build.gradle.kts @@ -49,3 +49,7 @@ tasks.jar { )) } } + +scalafix { + ignoreSourceSets.add("main") +} From 48d9688f7a85d10da927effb8e36c296ecec8feb Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 3 Jun 2024 15:56:16 +0200 Subject: [PATCH 064/132] Apply scalafix settings conditionally The scalafix plugin is not applied when running in Java 8, so its settings must also be applied conditionally in order to not break the JDK 8 build. --- .../groovy/project-convention-code-formatting-internal.gradle | 4 ++++ webauthn-server-attestation/build.gradle.kts | 4 ---- webauthn-server-core/build.gradle.kts | 4 ---- webauthn-server-demo/build.gradle.kts | 4 ---- yubico-util/build.gradle.kts | 4 ---- 5 files changed, 4 insertions(+), 16 deletions(-) diff --git a/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle b/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle index 5da3528c4..c875ed7c2 100644 --- a/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle +++ b/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle @@ -12,6 +12,10 @@ spotless { scalafix { configFile.set(project.rootProject.file("scalafix.conf")) + + if (project.name != "yubico-util-scala") { + ignoreSourceSets.add("main") + } } project.dependencies.scalafix("com.github.liancheng:organize-imports_2.13:0.6.0") diff --git a/webauthn-server-attestation/build.gradle.kts b/webauthn-server-attestation/build.gradle.kts index 1b331539e..1748835d2 100644 --- a/webauthn-server-attestation/build.gradle.kts +++ b/webauthn-server-attestation/build.gradle.kts @@ -86,7 +86,3 @@ tasks.javadoc.configure { // Use this instead for local testing //(options as StandardJavadocDocletOptions).linksOffline("file://${coreJavadoc.destinationDir}", "${coreJavadoc.destinationDir}") } - -scalafix { - ignoreSourceSets.add("main") -} diff --git a/webauthn-server-core/build.gradle.kts b/webauthn-server-core/build.gradle.kts index cc1d311e3..9bbd2bda8 100644 --- a/webauthn-server-core/build.gradle.kts +++ b/webauthn-server-core/build.gradle.kts @@ -68,7 +68,3 @@ tasks.withType(Jar::class) { )) } } - -scalafix { - ignoreSourceSets.add("main") -} diff --git a/webauthn-server-demo/build.gradle.kts b/webauthn-server-demo/build.gradle.kts index c0fb5f773..82830c46e 100644 --- a/webauthn-server-demo/build.gradle.kts +++ b/webauthn-server-demo/build.gradle.kts @@ -71,7 +71,3 @@ for (task in listOf(tasks.installDist, tasks.distZip, tasks.distTar)) { } } } - -scalafix { - ignoreSourceSets.add("main") -} diff --git a/yubico-util/build.gradle.kts b/yubico-util/build.gradle.kts index 4d503d950..e8319c120 100644 --- a/yubico-util/build.gradle.kts +++ b/yubico-util/build.gradle.kts @@ -49,7 +49,3 @@ tasks.jar { )) } } - -scalafix { - ignoreSourceSets.add("main") -} From 0d5b5ef4fd07ff057f84d75088a32576b1197646 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 3 Jun 2024 17:42:58 +0200 Subject: [PATCH 065/132] Add comment why yubico-util-scala is the exception to scalafix.ignoreSourceSets --- .../groovy/project-convention-code-formatting-internal.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle b/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle index c875ed7c2..d468bf0c7 100644 --- a/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle +++ b/buildSrc/src/main/groovy/project-convention-code-formatting-internal.gradle @@ -14,6 +14,7 @@ scalafix { configFile.set(project.rootProject.file("scalafix.conf")) if (project.name != "yubico-util-scala") { + // yubico-util-scala is the only subproject with Scala sources in the "main" source set ignoreSourceSets.add("main") } } From 9de37b29171f3cf8063829aebd851ae681b7f856 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 4 Jun 2024 14:20:40 +0200 Subject: [PATCH 066/132] Document JDK version support philosohpy --- doc/development.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/doc/development.md b/doc/development.md index 0acb00b5d..a6b562e85 100644 --- a/doc/development.md +++ b/doc/development.md @@ -2,6 +2,27 @@ Developer docs === +JDK versions +--- + +The project's official build JDK version is the latest LTS JDK version, +although the project may lag behind the true latest release for a while +until we can upgrade the build definition to match this target. + +The official build JDK version currently in effect is encoded in the +["Reproducible binary"](https://github.com/Yubico/java-webauthn-server/blob/main/.github/workflows/release-verify-signatures.yml) +workflow, +as the JDK version is crucial for successfully reproducing released binaries. +This version is also enforced in the release process in +[`build.gradle`](https://github.com/Yubico/java-webauthn-server/blob/main/build.gradle). + +The [primary build workflow](https://github.com/Yubico/java-webauthn-server/blob/main/.github/workflows/build.yml) +should run on all currently maintaned LTS JDK versions, +and ideally also the latest non-LTS JDK version if Gradle and other build dependencies are compatible. + +A list of JDK versions and maintenance status can be found [here](https://en.wikipedia.org/wiki/Java_version_history). + + Setup for publishing --- From 6c70489979558649883c09f8913b322034505045 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 4 Jun 2024 14:21:54 +0200 Subject: [PATCH 067/132] Move section "Code formatting" to before "Setup for publishing" --- doc/development.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/development.md b/doc/development.md index a6b562e85..7a4a07336 100644 --- a/doc/development.md +++ b/doc/development.md @@ -23,6 +23,14 @@ and ideally also the latest non-LTS JDK version if Gradle and other build depend A list of JDK versions and maintenance status can be found [here](https://en.wikipedia.org/wiki/Java_version_history). +Code formatting +--- + +Use `./gradlew spotlessApply` to run the automatic code formatter. +You can also run it in continuous mode as `./gradlew --continuous spotlessApply` +to reformat whenever a file changes. + + Setup for publishing --- @@ -35,11 +43,3 @@ yubicoPublish=true ossrhUsername=8pnmjKQP ossrhPassword=bmjuyWSIik8P3Nq/ZM2G0Xs0sHEKBg+4q4zTZ8JDDRCr ``` - - -Code formatting ---- - -Use `./gradlew spotlessApply` to run the automatic code formatter. -You can also run it in continuous mode as `./gradlew --continuous spotlessApply` -to reformat whenever a file changes. From 0da81b952996c4e31ac650f34b02f8200dc80381 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 4 Jun 2024 14:23:30 +0200 Subject: [PATCH 068/132] Add section "Publishing a release" to development.md --- doc/development.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/development.md b/doc/development.md index 7a4a07336..04d2de910 100644 --- a/doc/development.md +++ b/doc/development.md @@ -43,3 +43,9 @@ yubicoPublish=true ossrhUsername=8pnmjKQP ossrhPassword=bmjuyWSIik8P3Nq/ZM2G0Xs0sHEKBg+4q4zTZ8JDDRCr ``` + + +Publishing a release +--- + +See the [release checklist](./releasing.md). From 45a2bcf9dd2e0ab48ffcef275ac17b3cf3fd622a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 4 Jun 2024 14:24:55 +0200 Subject: [PATCH 069/132] Link to developer docs from main README --- README | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README b/README index 176303870..e2c0ff4c9 100644 --- a/README +++ b/README @@ -849,3 +849,9 @@ built artifacts. Official Yubico software signing keys are listed on the https://developers.yubico.com/Software_Projects/Software_Signing.html[Yubico Developers site]. + + +[#development] +=== Development + +See the link:https://github.com/Yubico/java-webauthn-server/blob/main/doc/development.md[developer docs]. From 37f32c3a74e6147394f4c08dc2f480a5e74f655d Mon Sep 17 00:00:00 2001 From: Dennis Fokin Date: Thu, 13 Jun 2024 14:40:38 +0200 Subject: [PATCH 070/132] Added credProps.authenticatorDisplayName --- NEWS | 3 ++ .../yubico/webauthn/RegistrationResult.java | 36 ++++++++++----- .../com/yubico/webauthn/data/Extensions.java | 27 ++++++++++- .../RelyingPartyRegistrationSpec.scala | 45 +++++++++++++++++++ .../com/yubico/webauthn/data/Generators.scala | 10 ++++- 5 files changed, 107 insertions(+), 14 deletions(-) diff --git a/NEWS b/NEWS index 39f984f47..d233009f3 100644 --- a/NEWS +++ b/NEWS @@ -51,6 +51,9 @@ New features: * (Experimental) Added property `RegisteredCredential.transports`. ** NOTE: Experimental features may receive breaking changes without a major version increase. +* (Experimental) Added property `credProps.authenticatorDisplayName`. + ** NOTE: Experimental features may receive breaking changes without a major + version increase. == Version 2.5.2 == diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index 499003730..f3f3ece18 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -30,17 +30,7 @@ import com.yubico.internal.util.CertificateParser; import com.yubico.webauthn.RelyingParty.RelyingPartyBuilder; import com.yubico.webauthn.attestation.AttestationTrustSource; -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.AuthenticatorRegistrationExtensionOutputs; -import com.yubico.webauthn.data.AuthenticatorResponse; -import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; -import com.yubico.webauthn.data.PublicKeyCredential; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.*; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; @@ -367,6 +357,30 @@ public Optional isDiscoverable() { .flatMap(credProps -> credProps.getRk()); } + /** + * Retrieve a suitable nickname for this credential, if one is available. + * + *

    This returns the authenticatorDisplayName output from the + * credProps extension. + * + * @return A user-chosen or vendor-default display name for the credential, if available. + * Otherwise empty. + * @see §10.1.3. + * Credential Properties Extension (credProps), "authenticatorDisplayName" output + * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @JsonIgnore + @Deprecated + public Optional getAuthenticatorDisplayName() { + return getClientExtensionOutputs() + .flatMap(outputs -> outputs.getCredProps()) + .flatMap(credProps -> credProps.getAuthenticatorDisplayName()); + } + /** * The attestation 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 d25d0f901..2d066cfc7 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 @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonValue; import com.upokecenter.cbor.CBORObject; import com.upokecenter.cbor.CBORType; +import com.yubico.webauthn.RegistrationResult; import com.yubico.webauthn.StartRegistrationOptions; import com.yubico.webauthn.extension.uvm.KeyProtectionType; import com.yubico.webauthn.extension.uvm.MatcherProtectionType; @@ -71,9 +72,15 @@ public static class CredentialPropertiesOutput { @JsonProperty("rk") private final Boolean rk; + @JsonProperty("authenticatorDisplayName") + private final String authenticatorDisplayName; + @JsonCreator - private CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { + private CredentialPropertiesOutput( + @JsonProperty("rk") Boolean rk, + @JsonProperty("authenticatorDisplayName") String authenticatorDisplayName) { this.rk = rk; + this.authenticatorDisplayName = authenticatorDisplayName; } /** @@ -105,6 +112,24 @@ private CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { public Optional getRk() { return Optional.ofNullable(rk); } + + /** + * This OPTIONAL property is a human-palatable description of the credential's managing + * authenticator, chosen by the user. + * + *

    If the RP includes an [$credential record/authenticatorDisplayName$] + * [=struct/item=] in [=credential records=], the [=[RP]=] MAY offer this value, if present, + * as a default value for the [$credential record/authenticatorDisplayName$] of + * the new [=credential record=]. + * + * @see RegistrationResult#getAuthenticatorDisplayName() + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change + * as the standard matures. + */ + @Deprecated + public Optional getAuthenticatorDisplayName() { + return Optional.ofNullable(authenticatorDisplayName); + } } } 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 4f16eb1fd..49ee87a4b 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 @@ -4289,6 +4289,51 @@ class RelyingPartyRegistrationSpec } } + describe("expose the credProps.authenticatorDisplayName extension output as RegistrationResult.getAuthenticatorDisplayName()") { + val testDataBase = RegistrationTestData.Packed.BasicAttestation + val testData = testDataBase.copy(requestedExtensions = + testDataBase.request.getExtensions.toBuilder.credProps().build() + ) + + it("""when set to "hej".""") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.request) + .response( + testData.response.toBuilder + .clientExtensionResults( + ClientRegistrationExtensionOutputs + .builder() + .credProps( + CredentialPropertiesOutput + .builder() + .authenticatorDisplayName("hej") + .build() + ) + .build() + ) + .build() + ) + .build() + ) + + result.getAuthenticatorDisplayName.toScala should equal(Some("hej")) + } + + it("when not available.") { + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.request) + .response(testData.response) + .build() + ) + + result.getAuthenticatorDisplayName.toScala should equal(None) + } + } + describe("support the largeBlob extension") { it("being enabled at registration time.") { val testData = RegistrationTestData.Packed.BasicAttestation 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 2a7ce9df3..e1a32f6e6 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 @@ -867,8 +867,14 @@ object Generators { object CredProps { def credentialPropertiesOutput: Gen[CredentialPropertiesOutput] = for { - rk <- arbitrary[Boolean] - } yield CredentialPropertiesOutput.builder().rk(rk).build() + rk <- arbitrary[Option[Boolean]] + authenticatorDisplayName <- arbitrary[Option[String]] + } yield { + val b = CredentialPropertiesOutput.builder() + rk.foreach(b.rk(_)) + authenticatorDisplayName.foreach(b.authenticatorDisplayName) + b.build() + } } object LargeBlob { From 563e5c34d047673dd70f4b849047b2c948a14766 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 1 Jul 2024 12:09:26 +0200 Subject: [PATCH 071/132] Fix comment outdated since commit 113f08637aa99501743d73aad7da2a90a7cafb7e --- .../src/main/java/demo/webauthn/EmbeddedServer.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java b/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java index 77626b03f..2418100e2 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java @@ -40,10 +40,7 @@ import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.servlet.ServletContainer; -/** - * Standalone Java application launcher that runs the demo server with the API but no static - * resources (i.e., no web GUI) - */ +/** Standalone Java application launcher that runs the demo application. */ public class EmbeddedServer { public static void main(String[] args) throws Exception { From 4b3bdd836decb7c67d89b783ead8c1bf6a23ef3d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 14:13:21 +0200 Subject: [PATCH 072/132] Expand wildcard import --- .../com/yubico/webauthn/RegistrationResult.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index f3f3ece18..b31fe5b74 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -30,7 +30,18 @@ import com.yubico.internal.util.CertificateParser; import com.yubico.webauthn.RelyingParty.RelyingPartyBuilder; import com.yubico.webauthn.attestation.AttestationTrustSource; -import com.yubico.webauthn.data.*; +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.AuthenticatorRegistrationExtensionOutputs; +import com.yubico.webauthn.data.AuthenticatorResponse; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; +import com.yubico.webauthn.data.Extensions; +import com.yubico.webauthn.data.PublicKeyCredential; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; From 89698cebcc3bd71293426598a3faf1de6dacdebd Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 14:14:03 +0200 Subject: [PATCH 073/132] Reference Google style guide in developer docs --- doc/development.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/development.md b/doc/development.md index 04d2de910..f6cef87c8 100644 --- a/doc/development.md +++ b/doc/development.md @@ -30,6 +30,16 @@ Use `./gradlew spotlessApply` to run the automatic code formatter. You can also run it in continuous mode as `./gradlew --continuous spotlessApply` to reformat whenever a file changes. +We mean to follow the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html), +but do not enforce it comprehensively (apart from what the automatic formatter does). +Take particular note of the rules: + +- [3.3.1 No wildcard imports](https://google.github.io/styleguide/javaguide.html#s3.3.1-wildcard-imports) +- [5.3 Camel case: defined](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case) + (`XmlHttpRequest` and `requestId`, not `XMLHTTPRequest` and `requestID`) + +In case of disagreement on code style, defer to the style guide. + Setup for publishing --- From 2fc247043ad0f047aecc23dbc650852b252ee212 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 14:18:11 +0200 Subject: [PATCH 074/132] Add link to Sonatype Nexus user token documentation --- doc/development.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/development.md b/doc/development.md index f6cef87c8..5be1218bf 100644 --- a/doc/development.md +++ b/doc/development.md @@ -44,8 +44,9 @@ In case of disagreement on code style, defer to the style guide. Setup for publishing --- -To enable publishing to Maven Central via Sonatype Nexus, set -`yubicoPublish=true` in `$HOME/.gradle/gradle.properties` and add your Sonatype +To enable publishing to Maven Central via Sonatype Nexus, +[generate a user token](https://central.sonatype.org/publish/generate-token/). +Set `yubicoPublish=true` in `$HOME/.gradle/gradle.properties` and add your token username and password. Example: ```properties From 02a10f6387fc478b76dba6a20ad315a75dee4c1b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 14:34:44 +0200 Subject: [PATCH 075/132] Tweak JavaDoc of authenticatorDisplayName --- .../com/yubico/webauthn/RegistrationResult.java | 7 ++++--- .../com/yubico/webauthn/data/Extensions.java | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index b31fe5b74..8b5b75f40 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -372,14 +372,15 @@ public Optional isDiscoverable() { * Retrieve a suitable nickname for this credential, if one is available. * *

    This returns the authenticatorDisplayName output from the + * href="https://w3c.github.io/webauthn/#sctn-authenticator-credential-properties-extension"> * credProps extension. * * @return A user-chosen or vendor-default display name for the credential, if available. * Otherwise empty. * @see §10.1.3. - * Credential Properties Extension (credProps), "authenticatorDisplayName" output + * href="https://w3c.github.io/webauthn/#dom-credentialpropertiesoutput-authenticatordisplayname"> + * authenticatorDisplayName in §10.1.3. Credential Properties Extension + * (credProps) * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as * the standard matures. 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 2d066cfc7..6052e5b98 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 @@ -117,11 +117,19 @@ public Optional getRk() { * This OPTIONAL property is a human-palatable description of the credential's managing * authenticator, chosen by the user. * - *

    If the RP includes an [$credential record/authenticatorDisplayName$] - * [=struct/item=] in [=credential records=], the [=[RP]=] MAY offer this value, if present, - * as a default value for the [$credential record/authenticatorDisplayName$] of - * the new [=credential record=]. + *

    If the application supports setting "nicknames" for registered credentials, then this + * value may be a suitable default value for such a nickname. * + *

    In an authentication ceremony, if this value is different from the stored nickname, then + * the application may want to offer the user to update the stored nickname to match this + * value. + * + * @return A user-chosen or vendor-default display name for the credential, if available. + * Otherwise empty. + * @see + * authenticatorDisplayName in §10.1.3. Credential Properties Extension + * (credProps) * @see RegistrationResult#getAuthenticatorDisplayName() * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change * as the standard matures. From a1a3e2f4062d776243f631174448859175142ef3 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 15:31:48 +0200 Subject: [PATCH 076/132] Add credProps and authenticatorDisplayName to assertion extension outputs --- NEWS | 1 + .../com/yubico/webauthn/AssertionResult.java | 30 +++++++++++ .../yubico/webauthn/AssertionResultV2.java | 30 +++++++++++ .../yubico/webauthn/RegistrationResult.java | 2 + .../data/ClientAssertionExtensionOutputs.java | 26 ++++++++++ .../com/yubico/webauthn/data/Extensions.java | 4 ++ .../webauthn/RelyingPartyAssertionSpec.scala | 50 +++++++++++++++++++ .../RelyingPartyV2AssertionSpec.scala | 50 +++++++++++++++++++ 8 files changed, 193 insertions(+) diff --git a/NEWS b/NEWS index d233009f3..677142541 100644 --- a/NEWS +++ b/NEWS @@ -54,6 +54,7 @@ New features: * (Experimental) Added property `credProps.authenticatorDisplayName`. ** NOTE: Experimental features may receive breaking changes without a major version increase. +* (Experimental) Added `credProps` extension to assertion extension outputs. == Version 2.5.2 == diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java index 5763af7af..50ec81975 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java @@ -35,6 +35,7 @@ import com.yubico.webauthn.data.AuthenticatorResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.Extensions; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserIdentity; @@ -281,4 +282,33 @@ public Optional getAuthenticatorExtensio return AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( credentialResponse.getResponse().getParsedAuthenticatorData()); } + + /** + * Retrieve a suitable nickname for this credential, if one is available. This MAY differ from + * {@link RegistrationResult#getAuthenticatorDisplayName() the value returned during + * registration}, if any. In that case the application may want to offer the user to update the + * previously stored value, if any. + * + *

    This returns the authenticatorDisplayName output from the + * credProps extension. + * + * @return A user-chosen or vendor-default display name for the credential, if available. + * Otherwise empty. + * @see + * authenticatorDisplayName in §10.1.3. Credential Properties Extension + * (credProps) + * @see RegistrationResult#getAuthenticatorDisplayName() + * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @JsonIgnore + @Deprecated + public Optional getAuthenticatorDisplayName() { + return getClientExtensionOutputs() + .flatMap(outputs -> outputs.getCredProps()) + .flatMap(credProps -> credProps.getAuthenticatorDisplayName()); + } } 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 5b027ffbc..27df1a515 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 @@ -35,6 +35,7 @@ import com.yubico.webauthn.data.AuthenticatorResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.Extensions; import com.yubico.webauthn.data.PublicKeyCredential; import java.util.Optional; import lombok.AccessLevel; @@ -243,4 +244,33 @@ public Optional getAuthenticatorExtensio return AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( credentialResponse.getResponse().getParsedAuthenticatorData()); } + + /** + * Retrieve a suitable nickname for this credential, if one is available. This MAY differ from + * {@link RegistrationResult#getAuthenticatorDisplayName() the value returned during + * registration}, if any. In that case the application may want to offer the user to update the + * previously stored value, if any. + * + *

    This returns the authenticatorDisplayName output from the + * credProps extension. + * + * @return A user-chosen or vendor-default display name for the credential, if available. + * Otherwise empty. + * @see + * authenticatorDisplayName in §10.1.3. Credential Properties Extension + * (credProps) + * @see RegistrationResult#getAuthenticatorDisplayName() + * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @JsonIgnore + @Deprecated + public Optional getAuthenticatorDisplayName() { + return getClientExtensionOutputs() + .flatMap(outputs -> outputs.getCredProps()) + .flatMap(credProps -> credProps.getAuthenticatorDisplayName()); + } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index 8b5b75f40..c1b8b9c66 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -381,6 +381,8 @@ public Optional isDiscoverable() { * href="https://w3c.github.io/webauthn/#dom-credentialpropertiesoutput-authenticatordisplayname"> * authenticatorDisplayName in §10.1.3. Credential Properties Extension * (credProps) + * @see AssertionResult#getAuthenticatorDisplayName() + * @see AssertionResultV2#getAuthenticatorDisplayName() * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as * the standard matures. diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java index 3c6579d66..81c9af07d 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java @@ -64,13 +64,18 @@ public class ClientAssertionExtensionOutputs implements ClientExtensionOutputs { */ private final Boolean appid; + private final Extensions.CredentialProperties.CredentialPropertiesOutput credProps; + private final Extensions.LargeBlob.LargeBlobAuthenticationOutput largeBlob; @JsonCreator private ClientAssertionExtensionOutputs( @JsonProperty("appid") Boolean appid, + @JsonProperty("credProps") + Extensions.CredentialProperties.CredentialPropertiesOutput credProps, @JsonProperty("largeBlob") Extensions.LargeBlob.LargeBlobAuthenticationOutput largeBlob) { this.appid = appid; + this.credProps = credProps; this.largeBlob = largeBlob; } @@ -81,6 +86,9 @@ public Set getExtensionIds() { if (appid != null) { ids.add(Extensions.Appid.EXTENSION_ID); } + if (credProps != null) { + ids.add(Extensions.CredentialProperties.EXTENSION_ID); + } if (largeBlob != null) { ids.add(Extensions.LargeBlob.EXTENSION_ID); } @@ -100,6 +108,24 @@ public Optional getAppid() { return Optional.ofNullable(appid); } + /** + * The extension output for the Credential Properties Extension (credProps), if any. + * + *

    This value MAY be present but have all members empty if the extension was successfully + * processed but no credential properties could be determined. + * + * @see com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput + * @see §10.4. + * Credential Properties Extension (credProps) + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + public Optional getCredProps() { + return Optional.ofNullable(credProps); + } + /** * The extension output for the Large blob 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 6052e5b98..0f762fb1f 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 @@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonValue; import com.upokecenter.cbor.CBORObject; import com.upokecenter.cbor.CBORType; +import com.yubico.webauthn.AssertionResult; +import com.yubico.webauthn.AssertionResultV2; import com.yubico.webauthn.RegistrationResult; import com.yubico.webauthn.StartRegistrationOptions; import com.yubico.webauthn.extension.uvm.KeyProtectionType; @@ -131,6 +133,8 @@ public Optional getRk() { * authenticatorDisplayName in §10.1.3. Credential Properties Extension * (credProps) * @see RegistrationResult#getAuthenticatorDisplayName() + * @see AssertionResult#getAuthenticatorDisplayName() + * @see AssertionResultV2#getAuthenticatorDisplayName() * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change * as the standard matures. */ 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 6d4c711f2..ef809d116 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 @@ -38,6 +38,7 @@ 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.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry @@ -2845,6 +2846,55 @@ class RelyingPartyAssertionSpec ) } } + + describe("exposes the credProps.authenticatorDisplayName extension output as getAuthenticatorDisplayName()") { + val pkcTemplate = + TestAuthenticator.createAssertion( + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + it("""when set to "hej".""") { + val pkc = pkcTemplate.toBuilder + .clientExtensionResults( + pkcTemplate.getClientExtensionResults.toBuilder + .credProps( + CredentialPropertiesOutput + .builder() + .authenticatorDisplayName("hej") + .build() + ) + .build() + ) + .build() + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkc) + .build() + ) + + result.getAuthenticatorDisplayName.toScala should equal( + Some("hej") + ) + } + + it("when not available.") { + val pkc = pkcTemplate + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkc) + .build() + ) + + result.getAuthenticatorDisplayName.toScala should equal(None) + } + } } } } 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 402f2f1d7..794db38fb 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 @@ -38,6 +38,7 @@ 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.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry @@ -2920,6 +2921,55 @@ class RelyingPartyV2AssertionSpec ) } } + + describe("exposes the credProps.authenticatorDisplayName extension output as getAuthenticatorDisplayName()") { + val pkcTemplate = + TestAuthenticator.createAssertion( + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + it("""when set to "hej".""") { + val pkc = pkcTemplate.toBuilder + .clientExtensionResults( + pkcTemplate.getClientExtensionResults.toBuilder + .credProps( + CredentialPropertiesOutput + .builder() + .authenticatorDisplayName("hej") + .build() + ) + .build() + ) + .build() + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkc) + .build() + ) + + result.getAuthenticatorDisplayName.toScala should equal( + Some("hej") + ) + } + + it("when not available.") { + val pkc = pkcTemplate + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkc) + .build() + ) + + result.getAuthenticatorDisplayName.toScala should equal(None) + } + } } } } From f923fbfba4ad770b2f2f2b3bf744d6ae83e018b2 Mon Sep 17 00:00:00 2001 From: "E. Luke Walker" Date: Fri, 19 Jul 2024 14:17:46 -0700 Subject: [PATCH 077/132] Update Migrating_from_v1.adoc fix broken link on dyc --- doc/Migrating_from_v1.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/Migrating_from_v1.adoc b/doc/Migrating_from_v1.adoc index b8f04803b..27c49bd7b 100644 --- a/doc/Migrating_from_v1.adoc +++ b/doc/Migrating_from_v1.adoc @@ -11,7 +11,7 @@ link:https://github.com/Yubico/java-webauthn-server/issues/new[let us know!] This is the migration guide for the core library. The `webauthn-server-attestation` module has -link:../webauthn-server-attestation/doc/Migrating_from_v1.adoc[its own migration guide]. +link:/java-webauthn-server/webauthn-server-attestation/doc/Migrating_from_v1.adoc[its own migration guide]. Here is a high-level outline of what needs to be updated: From 28cd549d8315adee78a9c8a28116300d7aa2fa8b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 22 Jul 2024 12:41:33 +0200 Subject: [PATCH 078/132] Use absolute migration guide URL to dev.y.c --- doc/Migrating_from_v1.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/Migrating_from_v1.adoc b/doc/Migrating_from_v1.adoc index 27c49bd7b..1e6bba66b 100644 --- a/doc/Migrating_from_v1.adoc +++ b/doc/Migrating_from_v1.adoc @@ -11,7 +11,7 @@ link:https://github.com/Yubico/java-webauthn-server/issues/new[let us know!] This is the migration guide for the core library. The `webauthn-server-attestation` module has -link:/java-webauthn-server/webauthn-server-attestation/doc/Migrating_from_v1.adoc[its own migration guide]. +link:https://developers.yubico.com/java-webauthn-server/webauthn-server-attestation/doc/Migrating_from_v1.html[its own migration guide]. Here is a high-level outline of what needs to be updated: From 12424fe0e70f4c214269e9ebcc1c59cd3b8aaa0d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 22 Jul 2024 12:44:40 +0200 Subject: [PATCH 079/132] Set :idprefix: and :idseparator: in adoc files Currently, the "getting started" links in the project README successfully link to the `#getting-started` section on GitHub, but fail to link on developers.yubico.com because the heading ID there is `#_getting_started` instead. These settings should resolve this. --- README | 2 ++ doc/Migrating_from_v1.adoc | 2 ++ webauthn-server-attestation/README.adoc | 2 ++ webauthn-server-attestation/doc/Migrating_from_v1.adoc | 2 ++ webauthn-server-core/README | 2 ++ webauthn-server-demo/README | 2 ++ 6 files changed, 12 insertions(+) diff --git a/README b/README index e2c0ff4c9..ee0e987f4 100644 --- a/README +++ b/README @@ -3,6 +3,8 @@ java-webauthn-server :toc: :toc-placement: macro :toc-title: +:idprefix: +:idseparator: - image:https://github.com/Yubico/java-webauthn-server/workflows/build/badge.svg["Build Status", link="https://github.com/Yubico/java-webauthn-server/actions"] image:https://img.shields.io/endpoint?url=https%3A%2F%2FYubico.github.io%2Fjava-webauthn-server%2Fcoverage-badge.json["Mutation test coverage", link="https://Yubico.github.io/java-webauthn-server/"] diff --git a/doc/Migrating_from_v1.adoc b/doc/Migrating_from_v1.adoc index 1e6bba66b..a33051903 100644 --- a/doc/Migrating_from_v1.adoc +++ b/doc/Migrating_from_v1.adoc @@ -1,4 +1,6 @@ = v1.x to v2.0 migration guide +:idprefix: +:idseparator: - The `2.0` release of the `webauthn-server-core` module removes some deprecated features diff --git a/webauthn-server-attestation/README.adoc b/webauthn-server-attestation/README.adoc index d591e3cec..f0a86db7a 100644 --- a/webauthn-server-attestation/README.adoc +++ b/webauthn-server-attestation/README.adoc @@ -2,6 +2,8 @@ :toc: :toc-placement: macro :toc-title: +:idprefix: +:idseparator: - An optional module which extends link:../[`webauthn-server-core`] with a trust root source for verifying diff --git a/webauthn-server-attestation/doc/Migrating_from_v1.adoc b/webauthn-server-attestation/doc/Migrating_from_v1.adoc index add3d7e9c..850f4a6e1 100644 --- a/webauthn-server-attestation/doc/Migrating_from_v1.adoc +++ b/webauthn-server-attestation/doc/Migrating_from_v1.adoc @@ -1,4 +1,6 @@ = v1.x to v2.1 migration guide +:idprefix: +:idseparator: - The `2.0` release of the `webauthn-server-attestation` module makes lots of breaking changes compared to the `1.x` versions. diff --git a/webauthn-server-core/README b/webauthn-server-core/README index 4da98cd32..6af518e66 100644 --- a/webauthn-server-core/README +++ b/webauthn-server-core/README @@ -1,4 +1,6 @@ = Web Authentication server library +:idprefix: +:idseparator: - Implementation of a Web Authentication Relying Party (RP). diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index c9f125590..48f71b67c 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -1,4 +1,6 @@ = webauthn-server-demo +:idprefix: +:idseparator: - A simple self-contained demo server supporting multiple authenticators per user. It illustrates how to use the required integration points, the most important of From 28bcbb018aeca4830a9fa975d6bd4cf5d0c7b881 Mon Sep 17 00:00:00 2001 From: Dennis Fokin Date: Fri, 6 Sep 2024 08:53:38 +0200 Subject: [PATCH 080/132] Add support for PublicKeyCredentialHint --- .../com/yubico/webauthn/RelyingParty.java | 3 +- .../com/yubico/webauthn/RelyingPartyV2.java | 6 +- .../webauthn/StartAssertionOptions.java | 38 +++++- .../webauthn/StartRegistrationOptions.java | 36 +++++- .../PublicKeyCredentialCreationOptions.java | 21 ++++ .../data/PublicKeyCredentialHint.java | 110 ++++++++++++++++++ .../PublicKeyCredentialRequestOptions.java | 22 ++++ .../com/yubico/webauthn/Generators.scala | 11 ++ .../RelyingPartyStartOperationSpec.scala | 34 ++++++ .../com/yubico/webauthn/data/Generators.scala | 48 ++++++-- 10 files changed, 312 insertions(+), 17 deletions(-) create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java 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 f8e588eb6..be6a2dc22 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 @@ -493,7 +493,8 @@ public PublicKeyCredentialCreationOptions startRegistration( .appidExclude(appId) .credProps() .build())) - .timeout(startRegistrationOptions.getTimeout()); + .timeout(startRegistrationOptions.getTimeout()) + .hints(startRegistrationOptions.getHints()); attestationConveyancePreference.ifPresent(builder::attestation); return builder.build(); } 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 23a71c5bf..9ce2883c3 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 @@ -452,7 +452,8 @@ public PublicKeyCredentialCreationOptions startRegistration( .appidExclude(appId) .credProps() .build())) - .timeout(startRegistrationOptions.getTimeout()); + .timeout(startRegistrationOptions.getTimeout()) + .hints(startRegistrationOptions.getHints()); attestationConveyancePreference.ifPresent(builder::attestation); return builder.build(); } @@ -509,7 +510,8 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio startAssertionOptions .getExtensions() .merge(startAssertionOptions.getExtensions().toBuilder().appid(appId).build())) - .timeout(startAssertionOptions.getTimeout()); + .timeout(startAssertionOptions.getTimeout()) + .hints(startAssertionOptions.getHints()); startAssertionOptions.getUserVerification().ifPresent(pkcro::userVerification); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java index 461f31228..df119e0fa 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java @@ -26,8 +26,12 @@ import com.yubico.webauthn.data.AssertionExtensionInputs; import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialHint; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserVerificationRequirement; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Optional; import lombok.Builder; import lombok.NonNull; @@ -36,7 +40,7 @@ /** Parameters for {@link RelyingParty#startAssertion(StartAssertionOptions)}. */ @Value @Builder(toBuilder = true) -public class StartAssertionOptions { +public final class StartAssertionOptions { private final String username; @@ -79,6 +83,23 @@ public class StartAssertionOptions { */ private final Long timeout; + private final List hints; + + private StartAssertionOptions( + String username, + ByteArray userHandle, + @NonNull AssertionExtensionInputs extensions, + UserVerificationRequirement userVerification, + Long timeout, + List hints) { + this.username = username; + this.userHandle = userHandle; + this.extensions = extensions; + this.userVerification = userVerification; + this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); + } + /** * The username of the user to authenticate, if the user has already been identified. * @@ -370,5 +391,20 @@ public StartAssertionOptionsBuilder timeout(long timeout) { private StartAssertionOptionsBuilder timeout(Long timeout) { return this.timeout(Optional.ofNullable(timeout)); } + + public StartAssertionOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + public StartAssertionOptionsBuilder hints(@NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + public StartAssertionOptionsBuilder hints(@NonNull List hints) { + this.hints = hints; + return this; + } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java index e78184fb5..a8d51d766 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java @@ -26,8 +26,12 @@ import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.PublicKeyCredentialHint; import com.yubico.webauthn.data.RegistrationExtensionInputs; import com.yubico.webauthn.data.UserIdentity; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Optional; import lombok.Builder; import lombok.NonNull; @@ -36,7 +40,7 @@ /** Parameters for {@link RelyingParty#startRegistration(StartRegistrationOptions)}. */ @Value @Builder(toBuilder = true) -public class StartRegistrationOptions { +public final class StartRegistrationOptions { /** Identifiers for the user creating a credential. */ @NonNull private final UserIdentity user; @@ -64,6 +68,21 @@ public class StartRegistrationOptions { */ private final Long timeout; + private final List hints; + + private StartRegistrationOptions( + @NonNull UserIdentity user, + AuthenticatorSelectionCriteria authenticatorSelection, + @NonNull RegistrationExtensionInputs extensions, + Long timeout, + List hints) { + this.user = user; + this.authenticatorSelection = authenticatorSelection; + this.extensions = extensions; + this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); + } + /** * Constraints on what kind of authenticator the user is allowed to use to create the credential, * and on features that authenticator must or should support. @@ -157,5 +176,20 @@ public StartRegistrationOptionsBuilder timeout(@NonNull Optional timeout) public StartRegistrationOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + + public StartRegistrationOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + public StartRegistrationOptionsBuilder hints(@NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + public StartRegistrationOptionsBuilder hints(@NonNull List hints) { + this.hints = hints; + return this; + } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java index 3d2b6033f..105586a41 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java @@ -36,6 +36,7 @@ import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.Signature; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -94,6 +95,8 @@ public class PublicKeyCredentialCreationOptions { */ private final Long timeout; + private final List hints; + /** * Intended for use by Relying Parties that wish to limit the creation of multiple credentials for * the same account on a single authenticator. The client is requested to return an error if the @@ -136,6 +139,7 @@ private PublicKeyCredentialCreationOptions( @NonNull @JsonProperty("pubKeyCredParams") List pubKeyCredParams, @JsonProperty("timeout") Long timeout, + @JsonProperty("hints") List hints, @JsonProperty("excludeCredentials") Set excludeCredentials, @JsonProperty("authenticatorSelection") AuthenticatorSelectionCriteria authenticatorSelection, @JsonProperty("attestation") AttestationConveyancePreference attestation, @@ -145,6 +149,7 @@ private PublicKeyCredentialCreationOptions( this.challenge = challenge; this.pubKeyCredParams = filterAvailableAlgorithms(pubKeyCredParams); this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); this.excludeCredentials = excludeCredentials == null ? null @@ -317,6 +322,22 @@ public PublicKeyCredentialCreationOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + public PublicKeyCredentialCreationOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + public PublicKeyCredentialCreationOptionsBuilder hints( + @NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + public PublicKeyCredentialCreationOptionsBuilder hints(List hints) { + this.hints = hints; + return this; + } + /** * Intended for use by Relying Parties that wish to limit the creation of multiple credentials * for the same account on a single authenticator. The client is requested to return an error if diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java new file mode 100644 index 000000000..86cfe359a --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java @@ -0,0 +1,110 @@ +// 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.data; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import lombok.Value; + +/** + * Authenticators may communicate with Clients using a variety of transports. This enumeration + * defines a hint as to how Clients might communicate with a particular Authenticator in order to + * obtain an assertion for a specific credential. Note that these hints represent the Relying + * Party's best belief as to how an Authenticator may be reached. A Relying Party may obtain a list + * of transports hints from some attestation statement formats or via some out-of-band mechanism; it + * is outside the scope of this specification to define that mechanism. + * + *

    Authenticators may implement various transports for communicating with clients. This + * enumeration defines hints as to how clients might communicate with a particular authenticator in + * order to obtain an assertion for a specific credential. Note that these hints represent the + * WebAuthn Relying Party's best belief as to how an authenticator may be reached. A Relying Party + * may obtain a list of transports hints from some attestation statement formats or via some + * out-of-band mechanism; it is outside the scope of the Web Authentication specification to define + * that mechanism. + * + * @see §5.10.4. + * Authenticator Transport Enumeration (enum AuthenticatorTransport) + */ +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PublicKeyCredentialHint { + + @JsonValue @NonNull private final String value; + + public static final PublicKeyCredentialHint SECURITY_KEY = + new PublicKeyCredentialHint("security-key"); + + public static final PublicKeyCredentialHint CLIENT_DEVICE = + new PublicKeyCredentialHint("client-device"); + + public static final PublicKeyCredentialHint HYBRID = new PublicKeyCredentialHint("hybrid"); + + /** + * @return An array containing all predefined values of {@link PublicKeyCredentialHint} known by + * this implementation. + */ + public static PublicKeyCredentialHint[] values() { + return new PublicKeyCredentialHint[] {SECURITY_KEY, CLIENT_DEVICE, HYBRID}; + } + + /** + * @return If value is the same as that of any of {@link #SECURITY_KEY}, {@link + * #CLIENT_DEVICE} or {@link #HYBRID}, returns that constant instance. Otherwise returns a new + * instance containing value. + * @see #valueOf(String) + */ + @JsonCreator + public static PublicKeyCredentialHint of(@NonNull String value) { + return Stream.of(values()) + .filter(v -> v.getValue().equals(value)) + .findAny() + .orElseGet(() -> new PublicKeyCredentialHint(value)); + } + + /** + * @return If name equals "SECURITY_KEY", "CLIENT_DEVICE" + * or "HYBRID", returns the constant by that name. + * @throws IllegalArgumentException if name is anything else. + * @see #of(String) + */ + public static PublicKeyCredentialHint valueOf(String name) { + switch (name) { + case "SECURITY_KEY": + return SECURITY_KEY; + case "CLIENT_DEVICE": + return CLIENT_DEVICE; + case "HYBRID": + return HYBRID; + default: + throw new IllegalArgumentException( + "No constant com.yubico.webauthn.data.PublicKeyCredentialHint." + name); + } + } +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java index 4834d81a4..61c7c46d9 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java @@ -31,6 +31,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.yubico.internal.util.CollectionUtil; import com.yubico.internal.util.JacksonCodecs; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Optional; import lombok.Builder; @@ -66,6 +68,8 @@ public class PublicKeyCredentialRequestOptions { */ private final Long timeout; + private final List hints; + /** * Specifies the relying party identifier claimed by the caller. * @@ -112,12 +116,14 @@ public class PublicKeyCredentialRequestOptions { private PublicKeyCredentialRequestOptions( @NonNull @JsonProperty("challenge") ByteArray challenge, @JsonProperty("timeout") Long timeout, + @JsonProperty("hints") List hints, @JsonProperty("rpId") String rpId, @JsonProperty("allowCredentials") List allowCredentials, @JsonProperty("userVerification") UserVerificationRequirement userVerification, @NonNull @JsonProperty("extensions") AssertionExtensionInputs extensions) { this.challenge = challenge; this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); this.rpId = rpId; this.allowCredentials = allowCredentials == null ? null : CollectionUtil.immutableList(allowCredentials); @@ -213,6 +219,22 @@ public PublicKeyCredentialRequestOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + public PublicKeyCredentialRequestOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + public PublicKeyCredentialRequestOptionsBuilder hints( + @NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + public PublicKeyCredentialRequestOptionsBuilder hints(List hints) { + this.hints = hints; + return this; + } + /** * Specifies the relying party identifier claimed by the caller. * diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala index bcad72216..c9655c7db 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala @@ -10,6 +10,7 @@ import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential +import com.yubico.webauthn.data.PublicKeyCredentialHint import com.yubico.webauthn.data.UserVerificationRequirement import org.bouncycastle.asn1.x500.X500Name import org.scalacheck.Arbitrary @@ -97,12 +98,22 @@ object Generators { for { extensions <- arbitrary[Option[AssertionExtensionInputs]] timeout <- Gen.option(Gen.posNum[Long]) + hints <- + arbitrary[Option[Either[List[String], List[PublicKeyCredentialHint]]]] usernameOrUserHandle <- arbitrary[Option[Either[String, ByteArray]]] userVerification <- arbitrary[Option[UserVerificationRequirement]] } yield { val b = StartAssertionOptions.builder() extensions.foreach(b.extensions) timeout.foreach(b.timeout) + hints.foreach { + case Left(h) => { + b.hints(h.asJava) + } + case Right(h) => { + b.hints(h: _*) + } + } usernameOrUserHandle.foreach { case Left(username) => b.username(username) case Right(userHandle) => b.userHandle(userHandle) 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 fdec0b5c8..52aa8d7b7 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 @@ -36,6 +36,7 @@ import com.yubico.webauthn.data.Generators.Extensions.registrationExtensionInput import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialDescriptor +import com.yubico.webauthn.data.PublicKeyCredentialHint import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity @@ -981,6 +982,39 @@ class RelyingPartyStartOperationSpec } } + it("allows setting the hints to a value not in the spec.") { + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints("hej") + .build() + ) + pkcco.getHints.asScala should equal(List("hej")) + } + + it("allows setting the hints to a value in the spec.") { + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints(PublicKeyCredentialHint.SECURITY_KEY) + .build() + ) + pkcco.getHints.asScala should equal(List("security-key")) + } + + it("allows setting the hints to empty") { + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints("") + .build() + ) + pkcco.getHints.asScala should equal(List("")) + } + it("allows setting the timeout to empty.") { val pkcco = relyingParty(userId = userId).startRegistration( StartRegistrationOptions 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 e1a32f6e6..0baea9816 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 @@ -1071,19 +1071,33 @@ object Generators { arbitrary[java.util.List[PublicKeyCredentialParameters]] rp <- arbitrary[RelyingPartyIdentity] timeout <- arbitrary[Optional[java.lang.Long]] + hints <- + arbitrary[Option[Either[List[String], List[PublicKeyCredentialHint]]]] user <- arbitrary[UserIdentity] - } yield PublicKeyCredentialCreationOptions - .builder() - .rp(rp) - .user(user) - .challenge(challenge) - .pubKeyCredParams(pubKeyCredParams) - .attestation(attestation) - .authenticatorSelection(authenticatorSelection) - .excludeCredentials(excludeCredentials) - .extensions(extensions) - .timeout(timeout) - .build() + } yield { + val b = PublicKeyCredentialCreationOptions + .builder() + .rp(rp) + .user(user) + .challenge(challenge) + .pubKeyCredParams(pubKeyCredParams) + .attestation(attestation) + .authenticatorSelection(authenticatorSelection) + .excludeCredentials(excludeCredentials) + .extensions(extensions) + .timeout(timeout) + + hints.foreach { + case Left(h) => { + b.hints(h.asJava) + } + case Right(h) => { + b.hints(h: _*) + } + } + + b.build() + } ) ) @@ -1103,6 +1117,14 @@ object Generators { ) ) + implicit val arbitraryPublicKeyCredentialHint + : Arbitrary[PublicKeyCredentialHint] = Arbitrary( + Gen.oneOf( + Gen.oneOf(PublicKeyCredentialHint.values()), + Gen.alphaNumStr.map(PublicKeyCredentialHint.of), + ) + ) + implicit val arbitraryPublicKeyCredentialParameters : Arbitrary[PublicKeyCredentialParameters] = Arbitrary( halfsized( @@ -1127,6 +1149,7 @@ object Generators { extensions <- arbitrary[AssertionExtensionInputs] rpId <- arbitrary[Optional[String]] timeout <- arbitrary[Optional[java.lang.Long]] + hints <- arbitrary[Option[List[String]]] userVerification <- arbitrary[UserVerificationRequirement] } yield PublicKeyCredentialRequestOptions .builder() @@ -1135,6 +1158,7 @@ object Generators { .extensions(extensions) .rpId(rpId) .timeout(timeout) + .hints(hints.map(_.asJava).orNull) .userVerification(userVerification) .build() ) From d2f301eae99256fde5c7f5d61a6184a6bb51cc9c Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:01:56 +0200 Subject: [PATCH 081/132] Remove spurious final class modifiers These are already implied by the `@Value` Lombok annotation - the explicit modifier probably appeared when we used delombok to generate the new constructors. (Most) other classes in the repo don't have the explicit `final` modifier, so let's leave it out here too so it doesn't seem like these classes are different. --- .../main/java/com/yubico/webauthn/StartAssertionOptions.java | 2 +- .../main/java/com/yubico/webauthn/StartRegistrationOptions.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java index df119e0fa..4cf49eebb 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java @@ -40,7 +40,7 @@ /** Parameters for {@link RelyingParty#startAssertion(StartAssertionOptions)}. */ @Value @Builder(toBuilder = true) -public final class StartAssertionOptions { +public class StartAssertionOptions { private final String username; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java index a8d51d766..a9c21e3b5 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java @@ -40,7 +40,7 @@ /** Parameters for {@link RelyingParty#startRegistration(StartRegistrationOptions)}. */ @Value @Builder(toBuilder = true) -public final class StartRegistrationOptions { +public class StartRegistrationOptions { /** Identifiers for the user creating a credential. */ @NonNull private final UserIdentity user; From 7a82286221459fc8954202dbb2af041f0260da52 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:22:02 +0200 Subject: [PATCH 082/132] Remove redundant braces --- .../test/scala/com/yubico/webauthn/data/Generators.scala | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 0baea9816..5da241018 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 @@ -1088,12 +1088,8 @@ object Generators { .timeout(timeout) hints.foreach { - case Left(h) => { - b.hints(h.asJava) - } - case Right(h) => { - b.hints(h: _*) - } + case Left(h) => b.hints(h.asJava) + case Right(h) => b.hints(h: _*) } b.build() From a5c0d1c429f228028eff77a9d8e8be1af8379faf Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:22:23 +0200 Subject: [PATCH 083/132] Use arbitrary strings in PublicKeyCredentialHint generator --- .../src/test/scala/com/yubico/webauthn/data/Generators.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5da241018..aa6354eee 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 @@ -1117,7 +1117,7 @@ object Generators { : Arbitrary[PublicKeyCredentialHint] = Arbitrary( Gen.oneOf( Gen.oneOf(PublicKeyCredentialHint.values()), - Gen.alphaNumStr.map(PublicKeyCredentialHint.of), + arbitrary[String].map(PublicKeyCredentialHint.of), ) ) From 89e78ffa5c902467d9f46de2c2320e19366d3011 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:26:44 +0200 Subject: [PATCH 084/132] Generate PublicKeyCredentialRequestOptions using both List and PublicKeyCredentialHint... --- .../com/yubico/webauthn/data/Generators.scala | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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 aa6354eee..e6a49fab4 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 @@ -1145,18 +1145,26 @@ object Generators { extensions <- arbitrary[AssertionExtensionInputs] rpId <- arbitrary[Optional[String]] timeout <- arbitrary[Optional[java.lang.Long]] - hints <- arbitrary[Option[List[String]]] + hints <- + arbitrary[Option[Either[List[String], List[PublicKeyCredentialHint]]]] userVerification <- arbitrary[UserVerificationRequirement] - } yield PublicKeyCredentialRequestOptions - .builder() - .challenge(challenge) - .allowCredentials(allowCredentials) - .extensions(extensions) - .rpId(rpId) - .timeout(timeout) - .hints(hints.map(_.asJava).orNull) - .userVerification(userVerification) - .build() + } yield { + val b = PublicKeyCredentialRequestOptions + .builder() + .challenge(challenge) + .allowCredentials(allowCredentials) + .extensions(extensions) + .rpId(rpId) + .timeout(timeout) + .userVerification(userVerification) + + hints.foreach { + case Left(h) => b.hints(h.asJava) + case Right(h) => b.hints(h: _*) + } + + b.build() + } ) ) From 8834c470edd92c143984dc184b452655e489fb8a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:29:23 +0200 Subject: [PATCH 085/132] Generate PublicKeyCredentialHints using all three setters --- .../com/yubico/webauthn/data/Generators.scala | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 e6a49fab4..3d0c1d7c0 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 @@ -1072,7 +1072,9 @@ object Generators { rp <- arbitrary[RelyingPartyIdentity] timeout <- arbitrary[Optional[java.lang.Long]] hints <- - arbitrary[Option[Either[List[String], List[PublicKeyCredentialHint]]]] + arbitrary[Option[Either[Either[List[String], Array[String]], List[ + PublicKeyCredentialHint + ]]]] user <- arbitrary[UserIdentity] } yield { val b = PublicKeyCredentialCreationOptions @@ -1088,8 +1090,9 @@ object Generators { .timeout(timeout) hints.foreach { - case Left(h) => b.hints(h.asJava) - case Right(h) => b.hints(h: _*) + case Left(Left(h: List[String])) => b.hints(h.asJava) + case Left(Right(h: Array[String])) => b.hints(h: _*) + case Right(h: List[PublicKeyCredentialHint]) => b.hints(h: _*) } b.build() @@ -1146,7 +1149,9 @@ object Generators { rpId <- arbitrary[Optional[String]] timeout <- arbitrary[Optional[java.lang.Long]] hints <- - arbitrary[Option[Either[List[String], List[PublicKeyCredentialHint]]]] + arbitrary[Option[Either[Either[List[String], Array[String]], List[ + PublicKeyCredentialHint + ]]]] userVerification <- arbitrary[UserVerificationRequirement] } yield { val b = PublicKeyCredentialRequestOptions @@ -1159,8 +1164,9 @@ object Generators { .userVerification(userVerification) hints.foreach { - case Left(h) => b.hints(h.asJava) - case Right(h) => b.hints(h: _*) + case Left(Left(h: List[String])) => b.hints(h.asJava) + case Left(Right(h: Array[String])) => b.hints(h: _*) + case Right(h: List[PublicKeyCredentialHint]) => b.hints(h: _*) } b.build() From a97acb62def8514df6559e6393a33901b9f59016 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:35:08 +0200 Subject: [PATCH 086/132] Make hints @NonNull The reason we couldn't do this previously is that we were setting `hints` to `null` in the `PublicKeyCredentialRequestOptions` generator. We no longer do since commit 89e78ffa5c902467d9f46de2c2320e19366d3011, so we can now require this to be non-null. --- .../webauthn/data/PublicKeyCredentialCreationOptions.java | 2 +- .../yubico/webauthn/data/PublicKeyCredentialRequestOptions.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java index 105586a41..cdfd3a7df 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java @@ -333,7 +333,7 @@ public PublicKeyCredentialCreationOptionsBuilder hints( Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); } - public PublicKeyCredentialCreationOptionsBuilder hints(List hints) { + public PublicKeyCredentialCreationOptionsBuilder hints(@NonNull List hints) { this.hints = hints; return this; } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java index 61c7c46d9..8ffe1d0ce 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java @@ -230,7 +230,7 @@ public PublicKeyCredentialRequestOptionsBuilder hints( Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); } - public PublicKeyCredentialRequestOptionsBuilder hints(List hints) { + public PublicKeyCredentialRequestOptionsBuilder hints(@NonNull List hints) { this.hints = hints; return this; } From bed7091a5db4264d716c423086ebcbc7040d965d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 12:58:45 +0200 Subject: [PATCH 087/132] Extract describe parent from hints tests in RelyingPartyStartOperationSpec --- .../RelyingPartyStartOperationSpec.scala | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) 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 52aa8d7b7..27c34d58a 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 @@ -982,37 +982,41 @@ class RelyingPartyStartOperationSpec } } - it("allows setting the hints to a value not in the spec.") { - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .hints("hej") - .build() - ) - pkcco.getHints.asScala should equal(List("hej")) - } + describe("allows setting the hints") { + val rp = relyingParty(userId = userId) - it("allows setting the hints to a value in the spec.") { - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .hints(PublicKeyCredentialHint.SECURITY_KEY) - .build() - ) - pkcco.getHints.asScala should equal(List("security-key")) - } + it("to a value not in the spec.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints("hej") + .build() + ) + pkcco.getHints.asScala should equal(List("hej")) + } - it("allows setting the hints to empty") { - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .hints("") - .build() - ) - pkcco.getHints.asScala should equal(List("")) + it("to a value in the spec.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints(PublicKeyCredentialHint.SECURITY_KEY) + .build() + ) + pkcco.getHints.asScala should equal(List("security-key")) + } + + it("to empty") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints("") + .build() + ) + pkcco.getHints.asScala should equal(List("")) + } } it("allows setting the timeout to empty.") { From dd31b535aebb8f72f062d581ef17b3ebe080428c Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 13:12:57 +0200 Subject: [PATCH 088/132] Test setting hints to empty list instead of empty string --- .../webauthn/RelyingPartyStartOperationSpec.scala | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 27c34d58a..3645626ef 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 @@ -1007,15 +1007,11 @@ class RelyingPartyStartOperationSpec pkcco.getHints.asScala should equal(List("security-key")) } - it("to empty") { + it("or not, defaulting to the empty list.") { val pkcco = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .hints("") - .build() + StartRegistrationOptions.builder().user(userId).build() ) - pkcco.getHints.asScala should equal(List("")) + pkcco.getHints.asScala should equal(List()) } } From 8a7ce197730f77e5577c2e8cf49391d98da8631f Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 13:18:11 +0200 Subject: [PATCH 089/132] Expand test cases of setting hints --- .../RelyingPartyStartOperationSpec.scala | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) 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 3645626ef..e03ea6142 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 @@ -985,26 +985,48 @@ class RelyingPartyStartOperationSpec describe("allows setting the hints") { val rp = relyingParty(userId = userId) - it("to a value not in the spec.") { + it("to string values in the spec or not.") { val pkcco = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .hints("hej") + .hints("hej", "security-key", "hoj", "client-device", "hybrid") .build() ) - pkcco.getHints.asScala should equal(List("hej")) + pkcco.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + PublicKeyCredentialHint.HYBRID.getValue, + ) + ) } - it("to a value in the spec.") { + it("to PublicKeyCredentialHint values in the spec or not.") { val pkcco = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .hints(PublicKeyCredentialHint.SECURITY_KEY) + .hints( + PublicKeyCredentialHint.of("hej"), + PublicKeyCredentialHint.HYBRID, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.of("hoj"), + PublicKeyCredentialHint.CLIENT_DEVICE, + ) .build() ) - pkcco.getHints.asScala should equal(List("security-key")) + pkcco.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.HYBRID.getValue, + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + ) + ) } it("or not, defaulting to the empty list.") { From a67ac4ab9af76488f19776d07d34af565c6a7abc Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 13:25:10 +0200 Subject: [PATCH 090/132] Add tests of hints in RelyingPartyV2.startAssertion --- .../RelyingPartyStartOperationSpec.scala | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 e03ea6142..8967f59eb 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 @@ -1596,6 +1596,59 @@ class RelyingPartyStartOperationSpec } } + describe("allows setting the hints") { + val rp = relyingParty(userId = userId) + + it("to string values in the spec or not.") { + val pkcro = rp.startAssertion( + StartAssertionOptions + .builder() + .hints("hej", "security-key", "hoj", "client-device", "hybrid") + .build() + ) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + PublicKeyCredentialHint.HYBRID.getValue, + ) + ) + } + + it("to PublicKeyCredentialHint values in the spec or not.") { + val pkcro = rp.startAssertion( + StartAssertionOptions + .builder() + .hints( + PublicKeyCredentialHint.of("hej"), + PublicKeyCredentialHint.HYBRID, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.of("hoj"), + PublicKeyCredentialHint.CLIENT_DEVICE, + ) + .build() + ) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.HYBRID.getValue, + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + ) + ) + } + + it("or not, defaulting to the empty list.") { + val pkcro = rp.startAssertion(StartAssertionOptions.builder().build()) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List() + ) + } + } + it("allows setting the timeout to empty.") { val req = relyingParty(userId = userId).startAssertion( StartAssertionOptions From 4c3bde44a005134e00bb8cdc5eb58c8d4ed565bd Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 13:54:52 +0200 Subject: [PATCH 091/132] Copy tests of startRegistration hints from RelyingPartyV2 to RelyingParty --- .../RelyingPartyStartOperationSpec.scala | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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 8967f59eb..34ef3693b 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 @@ -219,6 +219,64 @@ class RelyingPartyStartOperationSpec } } + describe("allows setting the hints") { + val rp = relyingParty(userId = userId) + + it("to string values in the spec or not.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints("hej", "security-key", "hoj", "client-device", "hybrid") + .build() + ) + pkcco.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + PublicKeyCredentialHint.HYBRID.getValue, + ) + ) + } + + it("to PublicKeyCredentialHint values in the spec or not.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints( + PublicKeyCredentialHint.of("hej"), + PublicKeyCredentialHint.HYBRID, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.of("hoj"), + PublicKeyCredentialHint.CLIENT_DEVICE, + ) + .build() + ) + pkcco.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.HYBRID.getValue, + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + ) + ) + } + + it("or not, defaulting to the empty list.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + pkcco.getHints.asScala should equal(List()) + } + } + it("allows setting the timeout to empty.") { val pkcco = relyingParty(userId = userId).startRegistration( StartRegistrationOptions From d220ef20b5fce1a2e2b36526c7a0cfcafc5c7870 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 6 Sep 2024 13:55:12 +0200 Subject: [PATCH 092/132] Pass hints through in RelyingParty.startAssertion --- .../com/yubico/webauthn/RelyingParty.java | 3 +- .../RelyingPartyStartOperationSpec.scala | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) 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 be6a2dc22..b8f0bafcb 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 @@ -538,7 +538,8 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio startAssertionOptions .getExtensions() .merge(startAssertionOptions.getExtensions().toBuilder().appid(appId).build())) - .timeout(startAssertionOptions.getTimeout()); + .timeout(startAssertionOptions.getTimeout()) + .hints(startAssertionOptions.getHints()); startAssertionOptions.getUserVerification().ifPresent(pkcro::userVerification); 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 34ef3693b..4afd9a8f6 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 @@ -818,6 +818,59 @@ class RelyingPartyStartOperationSpec } } + describe("allows setting the hints") { + val rp = relyingParty(userId = userId) + + it("to string values in the spec or not.") { + val pkcro = rp.startAssertion( + StartAssertionOptions + .builder() + .hints("hej", "security-key", "hoj", "client-device", "hybrid") + .build() + ) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + PublicKeyCredentialHint.HYBRID.getValue, + ) + ) + } + + it("to PublicKeyCredentialHint values in the spec or not.") { + val pkcro = rp.startAssertion( + StartAssertionOptions + .builder() + .hints( + PublicKeyCredentialHint.of("hej"), + PublicKeyCredentialHint.HYBRID, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.of("hoj"), + PublicKeyCredentialHint.CLIENT_DEVICE, + ) + .build() + ) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.HYBRID.getValue, + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + ) + ) + } + + it("or not, defaulting to the empty list.") { + val pkcro = rp.startAssertion(StartAssertionOptions.builder().build()) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List() + ) + } + } + it("allows setting the timeout to empty.") { val req = relyingParty(userId = userId).startAssertion( StartAssertionOptions From 76948865b1b3815cf1fb2c0dcf1ba6629a6e6cc0 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 13 Sep 2024 13:19:21 +0200 Subject: [PATCH 093/132] Add JavaDoc for hints and PublicKeyCredentialHint --- .../webauthn/StartAssertionOptions.java | 135 ++++++++++++++++++ .../webauthn/StartRegistrationOptions.java | 130 +++++++++++++++++ .../PublicKeyCredentialCreationOptions.java | 129 +++++++++++++++++ .../data/PublicKeyCredentialHint.java | 105 ++++++++++++-- .../PublicKeyCredentialRequestOptions.java | 135 ++++++++++++++++++ 5 files changed, 619 insertions(+), 15 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java index 4cf49eebb..02f2cdba9 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java @@ -26,6 +26,7 @@ import com.yubico.webauthn.data.AssertionExtensionInputs; import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.PublicKeyCredentialHint; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserVerificationRequirement; @@ -83,6 +84,38 @@ public class StartAssertionOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument to + * navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(List) + * @see StartAssertionOptionsBuilder#hints(String...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ private final List hints; private StartAssertionOptions( @@ -392,16 +425,118 @@ private StartAssertionOptionsBuilder timeout(Long timeout) { return this.timeout(Optional.ofNullable(timeout)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument + * to navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(List) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public StartAssertionOptionsBuilder hints(@NonNull String... hints) { this.hints = Arrays.asList(hints); return this; } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument + * to navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(List) + * @see StartAssertionOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public StartAssertionOptionsBuilder hints(@NonNull PublicKeyCredentialHint... hints) { return this.hints( Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument + * to navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(String...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public StartAssertionOptionsBuilder hints(@NonNull List hints) { this.hints = hints; return this; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java index a9c21e3b5..7d9d18ab9 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java @@ -68,6 +68,37 @@ public class StartRegistrationOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this occurs, + * the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptionsBuilder#hints(List) + * @see StartRegistrationOptionsBuilder#hints(String...) + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ private final List hints; private StartRegistrationOptions( @@ -177,16 +208,115 @@ public StartRegistrationOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see StartRegistrationOptionsBuilder#hints(List) + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public StartRegistrationOptionsBuilder hints(@NonNull String... hints) { this.hints = Arrays.asList(hints); return this; } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see StartRegistrationOptionsBuilder#hints(List) + * @see StartRegistrationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public StartRegistrationOptionsBuilder hints(@NonNull PublicKeyCredentialHint... hints) { return this.hints( Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see StartRegistrationOptionsBuilder#hints(String...) + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public StartRegistrationOptionsBuilder hints(@NonNull List hints) { this.hints = hints; return this; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java index cdfd3a7df..2ed1c22de 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java @@ -33,6 +33,7 @@ import com.yubico.internal.util.JacksonCodecs; import com.yubico.webauthn.FinishRegistrationOptions; import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.StartRegistrationOptions; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.Signature; @@ -95,6 +96,38 @@ public class PublicKeyCredentialCreationOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this occurs, + * the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(List) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ private final List hints; /** @@ -322,17 +355,113 @@ public PublicKeyCredentialCreationOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(List) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public PublicKeyCredentialCreationOptionsBuilder hints(@NonNull String... hints) { this.hints = Arrays.asList(hints); return this; } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(List) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public PublicKeyCredentialCreationOptionsBuilder hints( @NonNull PublicKeyCredentialHint... hints) { return this.hints( Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public PublicKeyCredentialCreationOptionsBuilder hints(@NonNull List hints) { this.hints = hints; return this; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java index 86cfe359a..c063bc0d3 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java @@ -26,6 +26,12 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import com.yubico.webauthn.RelyingParty.RelyingPartyBuilder; +import com.yubico.webauthn.StartAssertionOptions; +import com.yubico.webauthn.StartAssertionOptions.StartAssertionOptionsBuilder; +import com.yubico.webauthn.StartRegistrationOptions; +import com.yubico.webauthn.StartRegistrationOptions.StartRegistrationOptionsBuilder; +import com.yubico.webauthn.attestation.AttestationTrustSource; import java.util.stream.Stream; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -33,24 +39,25 @@ import lombok.Value; /** - * Authenticators may communicate with Clients using a variety of transports. This enumeration - * defines a hint as to how Clients might communicate with a particular Authenticator in order to - * obtain an assertion for a specific credential. Note that these hints represent the Relying - * Party's best belief as to how an Authenticator may be reached. A Relying Party may obtain a list - * of transports hints from some attestation statement formats or via some out-of-band mechanism; it - * is outside the scope of this specification to define that mechanism. + * Hints to guide the user agent in interacting with the user. * - *

    Authenticators may implement various transports for communicating with clients. This - * enumeration defines hints as to how clients might communicate with a particular authenticator in - * order to obtain an assertion for a specific credential. Note that these hints represent the - * WebAuthn Relying Party's best belief as to how an authenticator may be reached. A Relying Party - * may obtain a list of transports hints from some attestation statement formats or via some - * out-of-band mechanism; it is outside the scope of the Web Authentication specification to define - * that mechanism. + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of using an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the option + * of using a built-in passkey provider. * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + * @see StartRegistrationOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialCreationOptions.hints + * @see PublicKeyCredentialRequestOptions.hints * @see §5.10.4. - * Authenticator Transport Enumeration (enum AuthenticatorTransport) + * href="https://www.w3.org/TR/2023/WD-webauthn-3-20230927/#enumdef-publickeycredentialhints">§5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) */ @Value @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -58,12 +65,80 @@ public class PublicKeyCredentialHint { @JsonValue @NonNull private final String value; + /** + * Indicates that the application believes that users will satisfy this request with a physical + * security key. + * + *

    For example, an enterprise application may set this hint if they have issued security keys + * to their employees and will only accept those authenticators for registration and + * authentication. In that case, the application should probably also set {@link + * RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource) attestationTrustSource} and + * set {@link RelyingPartyBuilder#allowUntrustedAttestation(boolean) allowUntrustedAttestation} to + * false. See also the + * webauthn-server-attestation module. + * + *

    For compatibility with older user agents, when this hint is used in {@link + * StartRegistrationOptions}, the + * {@link StartRegistrationOptionsBuilder#authenticatorSelection(AuthenticatorSelectionCriteria) authenticatorSelection}.{@link AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder#authenticatorAttachment(AuthenticatorAttachment) authenticatorAttachment} + * parameter SHOULD be set to {@link AuthenticatorAttachment#CROSS_PLATFORM}. + * + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see + * security-key in §5.8.7. User-agent Hints Enumeration (enum + * PublicKeyCredentialHints) + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public static final PublicKeyCredentialHint SECURITY_KEY = new PublicKeyCredentialHint("security-key"); + /** + * Indicates that the application believes that users will satisfy this request with an + * authenticator built into the client device. + * + *

    For compatibility with older user agents, when this hint is used in {@link + * StartRegistrationOptions}, the + * {@link StartRegistrationOptionsBuilder#authenticatorSelection(AuthenticatorSelectionCriteria) authenticatorSelection}.{@link AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder#authenticatorAttachment(AuthenticatorAttachment) authenticatorAttachment} + * parameter SHOULD be set to {@link AuthenticatorAttachment#PLATFORM}. + * + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see + * client-device in §5.8.7. User-agent Hints Enumeration (enum + * PublicKeyCredentialHints) + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public static final PublicKeyCredentialHint CLIENT_DEVICE = new PublicKeyCredentialHint("client-device"); + /** + * Indicates that the application believes that users will satisfy this request with + * general-purpose authenticators such as smartphones. For example, a consumer application may + * believe that only a small fraction of their customers possesses dedicated security keys. This + * option also implies that the local platform authenticator should not be promoted in the UI. + * + *

    For compatibility with older user agents, when this hint is used in {@link + * StartRegistrationOptions}, the + * {@link StartRegistrationOptionsBuilder#authenticatorSelection(AuthenticatorSelectionCriteria) authenticatorSelection}.{@link AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder#authenticatorAttachment(AuthenticatorAttachment) authenticatorAttachment} + * parameter SHOULD be set to {@link AuthenticatorAttachment#CROSS_PLATFORM}. + * + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see + * hybrid in §5.8.7. User-agent Hints Enumeration (enum PublicKeyCredentialHints) + * + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public static final PublicKeyCredentialHint HYBRID = new PublicKeyCredentialHint("hybrid"); /** diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java index 8ffe1d0ce..da37870cc 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java @@ -31,6 +31,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.yubico.internal.util.CollectionUtil; import com.yubico.internal.util.JacksonCodecs; +import com.yubico.webauthn.StartAssertionOptions; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -68,6 +69,38 @@ public class PublicKeyCredentialRequestOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on the + * client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(List) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ private final List hints; /** @@ -219,17 +252,119 @@ public PublicKeyCredentialRequestOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(List) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public PublicKeyCredentialRequestOptionsBuilder hints(@NonNull String... hints) { this.hints = Arrays.asList(hints); return this; } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(List) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public PublicKeyCredentialRequestOptionsBuilder hints( @NonNull PublicKeyCredentialHint... hints) { return this.hints( Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ public PublicKeyCredentialRequestOptionsBuilder hints(@NonNull List hints) { this.hints = hints; return this; From 80c52dee4e9ac355d4953446b0e2cccebc79d1cc Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 24 Sep 2024 14:19:41 +0200 Subject: [PATCH 094/132] Add hints feature to NEWS --- NEWS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NEWS b/NEWS index 34df46cdf..a2dbd4a08 100644 --- a/NEWS +++ b/NEWS @@ -16,6 +16,11 @@ New features: * Added public factory function `LargeBlobRegistrationOutput.supported(boolean)`. * Added public factory functions to `LargeBlobAuthenticationOutput`. +* Added `hints` property to `StartRegistrationOptions`, `StartAssertionOptions`, + `PublicKeyCredentialCreationOptions` and `PublicKeyCredentialRequestOptions`, + and class `PublicKeyCredentialHint` to support them, to support the `hints` + parameter introduced in WebAuthn L3: + https://www.w3.org/TR/2023/WD-webauthn-3-20230927/#dom-publickeycredentialcreationoptions-hints * (Experimental) Added option `isSecurePaymentConfirmation(boolean)` to `FinishAssertionOptions`. When set, `RelyingParty.finishAssertion()` will adapt the validation logic for a Secure Payment Confirmation (SPC) response From 339eb6d69c249425790cdb3e8b03728708b2a413 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 12 Dec 2024 18:56:54 +0100 Subject: [PATCH 095/132] Remove credProps.authenticatorDisplayName --- NEWS | 1 - .../com/yubico/webauthn/AssertionResult.java | 30 ----------- .../yubico/webauthn/AssertionResultV2.java | 30 ----------- .../yubico/webauthn/RegistrationResult.java | 28 ----------- .../com/yubico/webauthn/data/Extensions.java | 39 +-------------- .../webauthn/RelyingPartyAssertionSpec.scala | 50 ------------------- .../RelyingPartyRegistrationSpec.scala | 46 ----------------- .../RelyingPartyV2AssertionSpec.scala | 50 ------------------- .../com/yubico/webauthn/data/Generators.scala | 2 - 9 files changed, 1 insertion(+), 275 deletions(-) diff --git a/NEWS b/NEWS index 341090cb6..e2b2c0ec1 100644 --- a/NEWS +++ b/NEWS @@ -56,7 +56,6 @@ New features: * (Experimental) Added property `RegisteredCredential.transports`. ** NOTE: Experimental features may receive breaking changes without a major version increase. -* (Experimental) Added property `credProps.authenticatorDisplayName`. ** NOTE: Experimental features may receive breaking changes without a major version increase. * (Experimental) Added `credProps` extension to assertion extension outputs. diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java index 50ec81975..5763af7af 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java @@ -35,7 +35,6 @@ import com.yubico.webauthn.data.AuthenticatorResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; -import com.yubico.webauthn.data.Extensions; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserIdentity; @@ -282,33 +281,4 @@ public Optional getAuthenticatorExtensio return AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( credentialResponse.getResponse().getParsedAuthenticatorData()); } - - /** - * Retrieve a suitable nickname for this credential, if one is available. This MAY differ from - * {@link RegistrationResult#getAuthenticatorDisplayName() the value returned during - * registration}, if any. In that case the application may want to offer the user to update the - * previously stored value, if any. - * - *

    This returns the authenticatorDisplayName output from the - * credProps extension. - * - * @return A user-chosen or vendor-default display name for the credential, if available. - * Otherwise empty. - * @see - * authenticatorDisplayName in §10.1.3. Credential Properties Extension - * (credProps) - * @see RegistrationResult#getAuthenticatorDisplayName() - * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() - * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as - * the standard matures. - */ - @JsonIgnore - @Deprecated - public Optional getAuthenticatorDisplayName() { - return getClientExtensionOutputs() - .flatMap(outputs -> outputs.getCredProps()) - .flatMap(credProps -> credProps.getAuthenticatorDisplayName()); - } } 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 27df1a515..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 @@ -35,7 +35,6 @@ import com.yubico.webauthn.data.AuthenticatorResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; -import com.yubico.webauthn.data.Extensions; import com.yubico.webauthn.data.PublicKeyCredential; import java.util.Optional; import lombok.AccessLevel; @@ -244,33 +243,4 @@ public Optional getAuthenticatorExtensio return AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( credentialResponse.getResponse().getParsedAuthenticatorData()); } - - /** - * Retrieve a suitable nickname for this credential, if one is available. This MAY differ from - * {@link RegistrationResult#getAuthenticatorDisplayName() the value returned during - * registration}, if any. In that case the application may want to offer the user to update the - * previously stored value, if any. - * - *

    This returns the authenticatorDisplayName output from the - * credProps extension. - * - * @return A user-chosen or vendor-default display name for the credential, if available. - * Otherwise empty. - * @see - * authenticatorDisplayName in §10.1.3. Credential Properties Extension - * (credProps) - * @see RegistrationResult#getAuthenticatorDisplayName() - * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() - * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as - * the standard matures. - */ - @JsonIgnore - @Deprecated - public Optional getAuthenticatorDisplayName() { - return getClientExtensionOutputs() - .flatMap(outputs -> outputs.getCredProps()) - .flatMap(credProps -> credProps.getAuthenticatorDisplayName()); - } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index c1b8b9c66..499003730 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -39,7 +39,6 @@ import com.yubico.webauthn.data.AuthenticatorResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; -import com.yubico.webauthn.data.Extensions; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import java.io.IOException; @@ -368,33 +367,6 @@ public Optional isDiscoverable() { .flatMap(credProps -> credProps.getRk()); } - /** - * Retrieve a suitable nickname for this credential, if one is available. - * - *

    This returns the authenticatorDisplayName output from the - * credProps extension. - * - * @return A user-chosen or vendor-default display name for the credential, if available. - * Otherwise empty. - * @see - * authenticatorDisplayName in §10.1.3. Credential Properties Extension - * (credProps) - * @see AssertionResult#getAuthenticatorDisplayName() - * @see AssertionResultV2#getAuthenticatorDisplayName() - * @see Extensions.CredentialProperties.CredentialPropertiesOutput#getAuthenticatorDisplayName() - * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as - * the standard matures. - */ - @JsonIgnore - @Deprecated - public Optional getAuthenticatorDisplayName() { - return getClientExtensionOutputs() - .flatMap(outputs -> outputs.getCredProps()) - .flatMap(credProps -> credProps.getAuthenticatorDisplayName()); - } - /** * The attestation 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 0f762fb1f..d25d0f901 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 @@ -6,9 +6,6 @@ import com.fasterxml.jackson.annotation.JsonValue; import com.upokecenter.cbor.CBORObject; import com.upokecenter.cbor.CBORType; -import com.yubico.webauthn.AssertionResult; -import com.yubico.webauthn.AssertionResultV2; -import com.yubico.webauthn.RegistrationResult; import com.yubico.webauthn.StartRegistrationOptions; import com.yubico.webauthn.extension.uvm.KeyProtectionType; import com.yubico.webauthn.extension.uvm.MatcherProtectionType; @@ -74,15 +71,9 @@ public static class CredentialPropertiesOutput { @JsonProperty("rk") private final Boolean rk; - @JsonProperty("authenticatorDisplayName") - private final String authenticatorDisplayName; - @JsonCreator - private CredentialPropertiesOutput( - @JsonProperty("rk") Boolean rk, - @JsonProperty("authenticatorDisplayName") String authenticatorDisplayName) { + private CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { this.rk = rk; - this.authenticatorDisplayName = authenticatorDisplayName; } /** @@ -114,34 +105,6 @@ private CredentialPropertiesOutput( public Optional getRk() { return Optional.ofNullable(rk); } - - /** - * This OPTIONAL property is a human-palatable description of the credential's managing - * authenticator, chosen by the user. - * - *

    If the application supports setting "nicknames" for registered credentials, then this - * value may be a suitable default value for such a nickname. - * - *

    In an authentication ceremony, if this value is different from the stored nickname, then - * the application may want to offer the user to update the stored nickname to match this - * value. - * - * @return A user-chosen or vendor-default display name for the credential, if available. - * Otherwise empty. - * @see - * authenticatorDisplayName in §10.1.3. Credential Properties Extension - * (credProps) - * @see RegistrationResult#getAuthenticatorDisplayName() - * @see AssertionResult#getAuthenticatorDisplayName() - * @see AssertionResultV2#getAuthenticatorDisplayName() - * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change - * as the standard matures. - */ - @Deprecated - public Optional getAuthenticatorDisplayName() { - return Optional.ofNullable(authenticatorDisplayName); - } } } 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 ef809d116..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 @@ -38,7 +38,6 @@ 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.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry @@ -2846,55 +2845,6 @@ class RelyingPartyAssertionSpec ) } } - - describe("exposes the credProps.authenticatorDisplayName extension output as getAuthenticatorDisplayName()") { - val pkcTemplate = - TestAuthenticator.createAssertion( - challenge = - request.getPublicKeyCredentialRequestOptions.getChallenge, - credentialKey = credentialKeypair, - credentialId = credential.getId, - ) - - it("""when set to "hej".""") { - val pkc = pkcTemplate.toBuilder - .clientExtensionResults( - pkcTemplate.getClientExtensionResults.toBuilder - .credProps( - CredentialPropertiesOutput - .builder() - .authenticatorDisplayName("hej") - .build() - ) - .build() - ) - .build() - val result = rp.finishAssertion( - FinishAssertionOptions - .builder() - .request(request) - .response(pkc) - .build() - ) - - result.getAuthenticatorDisplayName.toScala should equal( - Some("hej") - ) - } - - it("when not available.") { - val pkc = pkcTemplate - val result = rp.finishAssertion( - FinishAssertionOptions - .builder() - .request(request) - .response(pkc) - .build() - ) - - result.getAuthenticatorDisplayName.toScala should equal(None) - } - } } } } 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 49ee87a4b..ecac50d92 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 @@ -266,7 +266,6 @@ class RelyingPartyRegistrationSpec "org.example.foo": "bar", "credProps": { "rk": false, - "authenticatorDisplayName": "My passkey", "unknownProperty": ["unknown-value"] } } @@ -4289,51 +4288,6 @@ class RelyingPartyRegistrationSpec } } - describe("expose the credProps.authenticatorDisplayName extension output as RegistrationResult.getAuthenticatorDisplayName()") { - val testDataBase = RegistrationTestData.Packed.BasicAttestation - val testData = testDataBase.copy(requestedExtensions = - testDataBase.request.getExtensions.toBuilder.credProps().build() - ) - - it("""when set to "hej".""") { - val result = rp.finishRegistration( - FinishRegistrationOptions - .builder() - .request(testData.request) - .response( - testData.response.toBuilder - .clientExtensionResults( - ClientRegistrationExtensionOutputs - .builder() - .credProps( - CredentialPropertiesOutput - .builder() - .authenticatorDisplayName("hej") - .build() - ) - .build() - ) - .build() - ) - .build() - ) - - result.getAuthenticatorDisplayName.toScala should equal(Some("hej")) - } - - it("when not available.") { - val result = rp.finishRegistration( - FinishRegistrationOptions - .builder() - .request(testData.request) - .response(testData.response) - .build() - ) - - result.getAuthenticatorDisplayName.toScala should equal(None) - } - } - describe("support the largeBlob extension") { it("being enabled at registration time.") { val testData = RegistrationTestData.Packed.BasicAttestation 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 794db38fb..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 @@ -38,7 +38,6 @@ 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.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry @@ -2921,55 +2920,6 @@ class RelyingPartyV2AssertionSpec ) } } - - describe("exposes the credProps.authenticatorDisplayName extension output as getAuthenticatorDisplayName()") { - val pkcTemplate = - TestAuthenticator.createAssertion( - challenge = - request.getPublicKeyCredentialRequestOptions.getChallenge, - credentialKey = credentialKeypair, - credentialId = credential.getId, - ) - - it("""when set to "hej".""") { - val pkc = pkcTemplate.toBuilder - .clientExtensionResults( - pkcTemplate.getClientExtensionResults.toBuilder - .credProps( - CredentialPropertiesOutput - .builder() - .authenticatorDisplayName("hej") - .build() - ) - .build() - ) - .build() - val result = rp.finishAssertion( - FinishAssertionOptions - .builder() - .request(request) - .response(pkc) - .build() - ) - - result.getAuthenticatorDisplayName.toScala should equal( - Some("hej") - ) - } - - it("when not available.") { - val pkc = pkcTemplate - val result = rp.finishAssertion( - FinishAssertionOptions - .builder() - .request(request) - .response(pkc) - .build() - ) - - result.getAuthenticatorDisplayName.toScala should equal(None) - } - } } } } 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 3d0c1d7c0..8a98c17b4 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,11 +868,9 @@ object Generators { def credentialPropertiesOutput: Gen[CredentialPropertiesOutput] = for { rk <- arbitrary[Option[Boolean]] - authenticatorDisplayName <- arbitrary[Option[String]] } yield { val b = CredentialPropertiesOutput.builder() rk.foreach(b.rk(_)) - authenticatorDisplayName.foreach(b.authenticatorDisplayName) b.build() } } From 3de9992bec96b6af30dbd4492d2a01c3d45f01a7 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 12 Dec 2024 19:05:47 +0100 Subject: [PATCH 096/132] Remove credProps from assertion extension outputs --- NEWS | 1 - .../data/ClientAssertionExtensionOutputs.java | 26 ------------------- 2 files changed, 27 deletions(-) diff --git a/NEWS b/NEWS index e2b2c0ec1..b9fc48e37 100644 --- a/NEWS +++ b/NEWS @@ -58,7 +58,6 @@ New features: version increase. ** NOTE: Experimental features may receive breaking changes without a major version increase. -* (Experimental) Added `credProps` extension to assertion extension outputs. == Version 2.5.4 == diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java index 81c9af07d..3c6579d66 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ClientAssertionExtensionOutputs.java @@ -64,18 +64,13 @@ public class ClientAssertionExtensionOutputs implements ClientExtensionOutputs { */ private final Boolean appid; - private final Extensions.CredentialProperties.CredentialPropertiesOutput credProps; - private final Extensions.LargeBlob.LargeBlobAuthenticationOutput largeBlob; @JsonCreator private ClientAssertionExtensionOutputs( @JsonProperty("appid") Boolean appid, - @JsonProperty("credProps") - Extensions.CredentialProperties.CredentialPropertiesOutput credProps, @JsonProperty("largeBlob") Extensions.LargeBlob.LargeBlobAuthenticationOutput largeBlob) { this.appid = appid; - this.credProps = credProps; this.largeBlob = largeBlob; } @@ -86,9 +81,6 @@ public Set getExtensionIds() { if (appid != null) { ids.add(Extensions.Appid.EXTENSION_ID); } - if (credProps != null) { - ids.add(Extensions.CredentialProperties.EXTENSION_ID); - } if (largeBlob != null) { ids.add(Extensions.LargeBlob.EXTENSION_ID); } @@ -108,24 +100,6 @@ public Optional getAppid() { return Optional.ofNullable(appid); } - /** - * The extension output for the Credential Properties Extension (credProps), if any. - * - *

    This value MAY be present but have all members empty if the extension was successfully - * processed but no credential properties could be determined. - * - * @see com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput - * @see §10.4. - * Credential Properties Extension (credProps) - * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as - * the standard matures. - */ - @Deprecated - public Optional getCredProps() { - return Optional.ofNullable(credProps); - } - /** * The extension output for the Large blob From 55171498712af76a9f4f5262f674925adb18208e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 18:16:23 +0200 Subject: [PATCH 097/132] Add function WebAuthnCodecs.parseDerLength and expand encodeDerLength domain --- .../com/yubico/webauthn/WebAuthnCodecs.java | 92 +++++++++++++++++-- .../yubico/webauthn/WebAuthnCodecsSpec.scala | 56 ++++++++++- 2 files changed, 139 insertions(+), 9 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java index 24dffeb4c..2d90aba8c 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java @@ -41,6 +41,8 @@ import java.util.HashMap; import java.util.Map; import java.util.stream.Stream; +import lombok.AllArgsConstructor; +import lombok.NonNull; final class WebAuthnCodecs { @@ -209,17 +211,91 @@ private static PublicKey importCoseEcdsaPublicKey(CBORObject cose) return kFact.generatePublic(new X509EncodedKeySpec(x509Key.getBytes())); } - private static ByteArray encodeDerLength(final int length) { - if (length <= 127) { - return new ByteArray(new byte[] {(byte) length}); + static ByteArray encodeDerLength(final int length) { + if (length < 0) { + throw new IllegalArgumentException("Length is negative: " + length); + } else if (length <= 0x7f) { + return new ByteArray(new byte[] {(byte) (length & 0xff)}); + } else if (length <= 0xff) { + return new ByteArray(new byte[] {(byte) (0x80 | 0x01), (byte) (length & 0xff)}); } else if (length <= 0xffff) { - if (length <= 255) { - return new ByteArray(new byte[] {-127, (byte) length}); + return new ByteArray( + new byte[] {(byte) (0x80 | 0x02), (byte) ((length >> 8) & 0xff), (byte) (length & 0xff)}); + } else if (length <= 0xffffff) { + return new ByteArray( + new byte[] { + (byte) (0x80 | 0x03), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }); + } else { + return new ByteArray( + new byte[] { + (byte) (0x80 | 0x04), + (byte) ((length >> 24) & 0xff), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }); + } + } + + @AllArgsConstructor + static class ParseDerResult { + final T result; + final int nextOffset; + } + + static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { + final int len = der.length - offset; + if (len == 0) { + throw new IllegalArgumentException("Empty input"); + } else if ((der[offset] & 0x80) == 0) { + return new ParseDerResult<>(der[offset] & 0xff, offset + 1); + } else { + final int longLen = der[offset] & 0x7f; + if (len >= longLen) { + switch (longLen) { + case 0: + throw new IllegalArgumentException( + String.format( + "Empty length encoding at offset %d: %s", offset, new ByteArray(der))); + case 1: + return new ParseDerResult<>(der[offset + 1] & 0xff, offset + 2); + case 2: + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 8) | (der[offset + 2] & 0xff), offset + 3); + case 3: + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 16) + | ((der[offset + 2] & 0xff) << 8) + | (der[offset + 3] & 0xff), + offset + 4); + case 4: + if ((der[offset + 1] & 0x80) == 0) { + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 24) + | ((der[offset + 2] & 0xff) << 16) + | ((der[offset + 3] & 0xff) << 8) + | (der[offset + 4] & 0xff), + offset + 5); + } else { + throw new UnsupportedOperationException( + String.format( + "Length out of range of int: 0x%02x%02x%02x%02x", + der[offset + 1], der[offset + 2], der[offset + 3], der[offset + 4])); + } + default: + throw new UnsupportedOperationException( + String.format("Length is too long for int: %d octets", longLen)); + } } else { - return new ByteArray(new byte[] {-126, (byte) (length >> 8), (byte) (length & 0x00ff)}); + throw new IllegalArgumentException( + String.format( + "Length encoding needs %d octets but only %s remain at index %d: %s", + longLen, len - (offset + 1), offset + 1, new ByteArray(der))); } - } else { - throw new UnsupportedOperationException("Too long: " + length); } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala index c1efa2775..3613cc900 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala @@ -25,6 +25,7 @@ package com.yubico.webauthn import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.Generators.arbitraryByteArray import com.yubico.webauthn.test.Util import org.junit.runner.RunWith import org.scalacheck.Arbitrary @@ -125,6 +126,59 @@ class WebAuthnCodecsSpec } } - } + describe("DER parsing and encoding:") { + it("encodeDerLength and parseDerLength are each other's inverse.") { + forAll(Gen.chooseNum(0, Int.MaxValue), arbitraryByteArray.arbitrary) { + (len: Int, prefix: ByteArray) => + val encoded = WebAuthnCodecs.encodeDerLength(len) + val decoded = WebAuthnCodecs.parseDerLength(encoded.getBytes, 0) + val decodedWithPrefix = WebAuthnCodecs.parseDerLength( + prefix.concat(encoded).getBytes, + prefix.size, + ) + + decoded.result should equal(len) + decoded.nextOffset should equal(encoded.size) + decodedWithPrefix.result should equal(len) + decodedWithPrefix.nextOffset should equal( + prefix.size + encoded.size + ) + + val recoded = WebAuthnCodecs.encodeDerLength(decoded.result) + recoded should equal(encoded) + } + } + + it("parseDerLength tolerates unnecessarily long encodings.") { + WebAuthnCodecs + .parseDerLength(Array(0x81, 0).map(_.toByte), 0) + .result should equal(0) + WebAuthnCodecs + .parseDerLength(Array(0x82, 0, 0).map(_.toByte), 0) + .result should equal(0) + WebAuthnCodecs + .parseDerLength(Array(0x83, 0, 0, 0).map(_.toByte), 0) + .result should equal(0) + WebAuthnCodecs + .parseDerLength(Array(0x84, 0, 0, 0, 0).map(_.toByte), 0) + .result should equal(0) + WebAuthnCodecs + .parseDerLength(Array(0x81, 7).map(_.toByte), 0) + .result should equal(7) + WebAuthnCodecs + .parseDerLength(Array(0x82, 0, 7).map(_.toByte), 0) + .result should equal(7) + WebAuthnCodecs + .parseDerLength(Array(0x83, 0, 0, 7).map(_.toByte), 0) + .result should equal(7) + WebAuthnCodecs + .parseDerLength(Array(0x84, 0, 0, 4, 2).map(_.toByte), 0) + .result should equal(1026) + WebAuthnCodecs + .parseDerLength(Array(0x84, 0, 1, 33, 7).map(_.toByte), 0) + .result should equal(73991) + } + } + } } From c5a23e4cdbdc0354671355b839d1aed45efd9115 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 19:18:50 +0200 Subject: [PATCH 098/132] Add function WebAuthnCodecs.parseDerSequence --- .../com/yubico/webauthn/WebAuthnCodecs.java | 19 +++++++- .../yubico/webauthn/WebAuthnCodecsSpec.scala | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java index 2d90aba8c..9e329e32e 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java @@ -299,6 +299,23 @@ static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { } } + /** Parse a SEQUENCE and return a copy of the content octets. */ + static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { + final int len = der.length - offset; + if (len == 0) { + throw new IllegalArgumentException( + String.format("Empty input at offset %d: %s", offset, new ByteArray(der))); + } else if ((der[offset] & 0xff) == 0x30) { + final ParseDerResult contentLen = parseDerLength(der, offset + 1); + final int contentEnd = contentLen.nextOffset + contentLen.result; + return new ParseDerResult<>( + new ByteArray(Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd)), contentEnd); + } else { + throw new IllegalArgumentException( + String.format("Not a SEQUENCE tag (0x30) at offset %d: %s", offset, new ByteArray(der))); + } + } + private static ByteArray encodeDerObjectId(final ByteArray oid) { return new ByteArray(new byte[] {0x06, (byte) oid.size()}).concat(oid); } @@ -310,7 +327,7 @@ private static ByteArray encodeDerBitStringWithZeroUnused(final ByteArray conten .concat(content); } - private static ByteArray encodeDerSequence(final ByteArray... items) { + static ByteArray encodeDerSequence(final ByteArray... items) { final ByteArray content = Stream.of(items).reduce(ByteArray::concat).orElseGet(() -> new ByteArray(new byte[0])); return new ByteArray(new byte[] {0x30}).concat(encodeDerLength(content.size())).concat(content); diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala index 3613cc900..799c0ede0 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala @@ -179,6 +179,50 @@ class WebAuthnCodecsSpec .parseDerLength(Array(0x84, 0, 1, 33, 7).map(_.toByte), 0) .result should equal(73991) } + + it("encodeDerSequence and parseDerSequenceEnd are (almost) each other's inverse.") { + forAll { (data: Array[ByteArray], prefix: ByteArray) => + val encoded = WebAuthnCodecs.encodeDerSequence(data: _*) + val decoded = WebAuthnCodecs.parseDerSequence(encoded.getBytes, 0) + val encodedWithPrefix = prefix.concat(encoded) + val decodedWithPrefix = WebAuthnCodecs.parseDerSequence( + encodedWithPrefix.getBytes, + prefix.size, + ) + + val expectedContent: ByteArray = + data.fold(new ByteArray(Array.empty))((a, b) => a.concat(b)) + decoded.result should equal(expectedContent) + decodedWithPrefix.result should equal(expectedContent) + decoded.nextOffset should equal(encoded.size) + decodedWithPrefix.nextOffset should equal(prefix.size + encoded.size) + } + } + + it("parseDerSequence fails if the first byte is not 0x30.") { + forAll { (tag: Byte, data: Array[ByteArray]) => + whenever(tag != 0x30) { + val encoded = WebAuthnCodecs.encodeDerSequence(data: _*) + an[IllegalArgumentException] shouldBe thrownBy { + WebAuthnCodecs.parseDerSequence( + encoded.getBytes.updated(0, tag), + 0, + ) + } + } + } + } + + it("parseDerSequence fails on empty input.") { + an[IllegalArgumentException] shouldBe thrownBy { + WebAuthnCodecs.parseDerSequence(Array.empty, 0) + } + forAll { data: Array[Byte] => + an[IllegalArgumentException] shouldBe thrownBy { + WebAuthnCodecs.parseDerSequence(data, data.length) + } + } + } } } } From 726a63b2bb0a86e91b7853b801e05f336a9cae0e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 12 Jul 2024 12:31:59 +0200 Subject: [PATCH 099/132] Add function WebAuthnCodecs.parseDerExplicitlyTaggedContextSpecificConstructed --- .../com/yubico/webauthn/WebAuthnCodecs.java | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java index 9e329e32e..371c8fb8b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java @@ -299,20 +299,45 @@ static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { } } - /** Parse a SEQUENCE and return a copy of the content octets. */ - static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { + private static ParseDerResult parseDerTagged( + @NonNull byte[] der, int offset, byte expectTag) { final int len = der.length - offset; if (len == 0) { throw new IllegalArgumentException( String.format("Empty input at offset %d: %s", offset, new ByteArray(der))); - } else if ((der[offset] & 0xff) == 0x30) { - final ParseDerResult contentLen = parseDerLength(der, offset + 1); - final int contentEnd = contentLen.nextOffset + contentLen.result; - return new ParseDerResult<>( - new ByteArray(Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd)), contentEnd); } else { - throw new IllegalArgumentException( - String.format("Not a SEQUENCE tag (0x30) at offset %d: %s", offset, new ByteArray(der))); + final byte tag = der[offset]; + if (tag == expectTag) { + final ParseDerResult contentLen = parseDerLength(der, offset + 1); + final int contentEnd = contentLen.nextOffset + contentLen.result; + return new ParseDerResult<>( + new ByteArray(Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd)), contentEnd); + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect tag: 0x%02x (expected 0x%02x) at offset %d: %s", + tag, expectTag, offset, new ByteArray(der))); + } + } + } + + /** Parse a SEQUENCE and return a copy of the content octets. */ + static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { + return parseDerTagged(der, offset, (byte) 0x30); + } + + /** + * Parse an explicitly tagged value of class "context-specific" (bits 8-7 are 0b10), in + * "constructed" encoding (bit 6 is 1), with a prescribed tag value, and return a copy of the + * content octets. + */ + static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( + @NonNull byte[] der, int offset, byte tagNumber) { + if (tagNumber <= 30 && tagNumber >= 0) { + return parseDerTagged(der, offset, (byte) ((tagNumber & 0x1f) | 0xa0)); + } else { + throw new UnsupportedOperationException( + String.format("Tag number out of range: %d (expected 0 to 30, inclusive)", tagNumber)); } } From 75146630eae79f5ac43c1fd3075025aabf9ce282 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 20:29:54 +0200 Subject: [PATCH 100/132] Add functions BinaryUtil.copyInto and .concat --- .../com/yubico/internal/util/BinaryUtil.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java index 2f47aee3f..8c8eb55b3 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java @@ -36,6 +36,37 @@ public static byte[] copy(byte[] bytes) { return Arrays.copyOf(bytes, bytes.length); } + /** + * Copy src into dest beginning at the offset destFrom, + * then return the modified dest. + */ + public static byte[] copyInto(byte[] src, byte[] dest, int destFrom) { + if (dest.length - destFrom < src.length) { + throw new IllegalArgumentException("Source array will not fit in destination array"); + } + if (destFrom < 0) { + throw new IllegalArgumentException("Invalid destination range"); + } + + for (int i = 0; i < src.length; ++i) { + dest[destFrom + i] = src[i]; + } + + return dest; + } + + /** Return a new array containing the concatenation of the argument arrays. */ + public static byte[] concat(byte[]... arrays) { + final int len = Arrays.stream(arrays).map(a -> a.length).reduce(0, Integer::sum); + byte[] result = new byte[len]; + int i = 0; + for (byte[] src : arrays) { + copyInto(src, result, i); + i += src.length; + } + return result; + } + /** * @param bytes Bytes to encode */ From 1b547a4b1c10a24a0cd4b7c2e277e2626e7409bb Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 20:30:30 +0200 Subject: [PATCH 101/132] Use byte[] instead of ByteArray in DER encoding and parsing functions --- .../com/yubico/webauthn/WebAuthnCodecs.java | 121 +++++++++--------- .../yubico/webauthn/WebAuthnCodecsSpec.scala | 63 ++++----- 2 files changed, 94 insertions(+), 90 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java index 371c8fb8b..74d910b3e 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java @@ -26,6 +26,7 @@ import com.google.common.primitives.Bytes; import com.upokecenter.cbor.CBORObject; +import com.yubico.internal.util.BinaryUtil; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.COSEAlgorithmIdentifier; import java.io.IOException; @@ -40,7 +41,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.stream.Stream; import lombok.AllArgsConstructor; import lombok.NonNull; @@ -175,69 +175,70 @@ private static PublicKey importCoseRsaPublicKey(CBORObject cose) private static PublicKey importCoseEcdsaPublicKey(CBORObject cose) throws NoSuchAlgorithmException, InvalidKeySpecException { final int crv = cose.get(CBORObject.FromObject(-1)).AsInt32Value(); - final ByteArray x = new ByteArray(cose.get(CBORObject.FromObject(-2)).GetByteString()); - final ByteArray y = new ByteArray(cose.get(CBORObject.FromObject(-3)).GetByteString()); + final byte[] x = cose.get(CBORObject.FromObject(-2)).GetByteString(); + final byte[] y = cose.get(CBORObject.FromObject(-3)).GetByteString(); - final ByteArray curveOid; + final byte[] curveOid; switch (crv) { case 1: - curveOid = P256_CURVE_OID; + curveOid = P256_CURVE_OID.getBytes(); break; case 2: - curveOid = P384_CURVE_OID; + curveOid = P384_CURVE_OID.getBytes(); break; case 3: - curveOid = P512_CURVE_OID; + curveOid = P512_CURVE_OID.getBytes(); break; default: throw new IllegalArgumentException("Unknown COSE EC2 curve: " + crv); } - final ByteArray algId = - encodeDerSequence(encodeDerObjectId(EC_PUBLIC_KEY_OID), encodeDerObjectId(curveOid)); + final byte[] algId = + encodeDerSequence( + encodeDerObjectId(EC_PUBLIC_KEY_OID.getBytes()), encodeDerObjectId(curveOid)); - final ByteArray rawKey = + final byte[] rawKey = encodeDerBitStringWithZeroUnused( - new ByteArray(new byte[] {0x04}) // Raw EC public key with x and y - .concat(x) - .concat(y)); + BinaryUtil.concat( + new byte[] {0x04}, // Raw EC public key with x and y + x, + y)); - final ByteArray x509Key = encodeDerSequence(algId, rawKey); + final byte[] x509Key = encodeDerSequence(algId, rawKey); KeyFactory kFact = KeyFactory.getInstance("EC"); - return kFact.generatePublic(new X509EncodedKeySpec(x509Key.getBytes())); + return kFact.generatePublic(new X509EncodedKeySpec(x509Key)); } - static ByteArray encodeDerLength(final int length) { + static byte[] encodeDerLength(final int length) { if (length < 0) { throw new IllegalArgumentException("Length is negative: " + length); } else if (length <= 0x7f) { - return new ByteArray(new byte[] {(byte) (length & 0xff)}); + return new byte[] {(byte) (length & 0xff)}; } else if (length <= 0xff) { - return new ByteArray(new byte[] {(byte) (0x80 | 0x01), (byte) (length & 0xff)}); + return new byte[] {(byte) (0x80 | 0x01), (byte) (length & 0xff)}; } else if (length <= 0xffff) { - return new ByteArray( - new byte[] {(byte) (0x80 | 0x02), (byte) ((length >> 8) & 0xff), (byte) (length & 0xff)}); + return new byte[] { + (byte) (0x80 | 0x02), (byte) ((length >> 8) & 0xff), (byte) (length & 0xff) + }; } else if (length <= 0xffffff) { - return new ByteArray( - new byte[] { - (byte) (0x80 | 0x03), - (byte) ((length >> 16) & 0xff), - (byte) ((length >> 8) & 0xff), - (byte) (length & 0xff) - }); + return new byte[] { + (byte) (0x80 | 0x03), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }; } else { - return new ByteArray( - new byte[] { - (byte) (0x80 | 0x04), - (byte) ((length >> 24) & 0xff), - (byte) ((length >> 16) & 0xff), - (byte) ((length >> 8) & 0xff), - (byte) (length & 0xff) - }); + return new byte[] { + (byte) (0x80 | 0x04), + (byte) ((length >> 24) & 0xff), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }; } } @@ -260,7 +261,7 @@ static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { case 0: throw new IllegalArgumentException( String.format( - "Empty length encoding at offset %d: %s", offset, new ByteArray(der))); + "Empty length encoding at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); case 1: return new ParseDerResult<>(der[offset + 1] & 0xff, offset + 2); case 2: @@ -293,36 +294,36 @@ static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { } else { throw new IllegalArgumentException( String.format( - "Length encoding needs %d octets but only %s remain at index %d: %s", - longLen, len - (offset + 1), offset + 1, new ByteArray(der))); + "Length encoding needs %d octets but only %s remain at index %d: 0x%s", + longLen, len - (offset + 1), offset + 1, BinaryUtil.toHex(der))); } } } - private static ParseDerResult parseDerTagged( + private static ParseDerResult parseDerTagged( @NonNull byte[] der, int offset, byte expectTag) { final int len = der.length - offset; if (len == 0) { throw new IllegalArgumentException( - String.format("Empty input at offset %d: %s", offset, new ByteArray(der))); + String.format("Empty input at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); } else { final byte tag = der[offset]; if (tag == expectTag) { final ParseDerResult contentLen = parseDerLength(der, offset + 1); final int contentEnd = contentLen.nextOffset + contentLen.result; return new ParseDerResult<>( - new ByteArray(Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd)), contentEnd); + Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd), contentEnd); } else { throw new IllegalArgumentException( String.format( - "Incorrect tag: 0x%02x (expected 0x%02x) at offset %d: %s", - tag, expectTag, offset, new ByteArray(der))); + "Incorrect tag: 0x%02x (expected 0x%02x) at offset %d: 0x%s", + tag, expectTag, offset, BinaryUtil.toHex(der))); } } } /** Parse a SEQUENCE and return a copy of the content octets. */ - static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { + static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { return parseDerTagged(der, offset, (byte) 0x30); } @@ -331,7 +332,7 @@ static ParseDerResult parseDerSequence(@NonNull byte[] der, int offse * "constructed" encoding (bit 6 is 1), with a prescribed tag value, and return a copy of the * content octets. */ - static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( + static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( @NonNull byte[] der, int offset, byte tagNumber) { if (tagNumber <= 30 && tagNumber >= 0) { return parseDerTagged(der, offset, (byte) ((tagNumber & 0x1f) | 0xa0)); @@ -341,21 +342,21 @@ static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstruc } } - private static ByteArray encodeDerObjectId(final ByteArray oid) { - return new ByteArray(new byte[] {0x06, (byte) oid.size()}).concat(oid); + private static byte[] encodeDerObjectId(@NonNull byte[] oid) { + byte[] result = new byte[2 + oid.length]; + result[0] = 0x06; + result[1] = (byte) oid.length; + return BinaryUtil.copyInto(oid, result, 2); } - private static ByteArray encodeDerBitStringWithZeroUnused(final ByteArray content) { - return new ByteArray(new byte[] {0x03}) - .concat(encodeDerLength(1 + content.size())) - .concat(new ByteArray(new byte[] {0})) - .concat(content); + private static byte[] encodeDerBitStringWithZeroUnused(@NonNull byte[] content) { + return BinaryUtil.concat( + new byte[] {0x03}, encodeDerLength(1 + content.length), new byte[] {0}, content); } - static ByteArray encodeDerSequence(final ByteArray... items) { - final ByteArray content = - Stream.of(items).reduce(ByteArray::concat).orElseGet(() -> new ByteArray(new byte[0])); - return new ByteArray(new byte[] {0x30}).concat(encodeDerLength(content.size())).concat(content); + static byte[] encodeDerSequence(final byte[]... items) { + byte[] content = BinaryUtil.concat(items); + return BinaryUtil.concat(new byte[] {0x30}, encodeDerLength(content.length), content); } private static PublicKey importCoseEdDsaPublicKey(CBORObject cose) @@ -371,12 +372,12 @@ private static PublicKey importCoseEdDsaPublicKey(CBORObject cose) private static PublicKey importCoseEd25519PublicKey(CBORObject cose) throws InvalidKeySpecException, NoSuchAlgorithmException { - final ByteArray rawKey = new ByteArray(cose.get(CBORObject.FromObject(-2)).GetByteString()); - final ByteArray x509Key = - encodeDerSequence(ED25519_ALG_ID, encodeDerBitStringWithZeroUnused(rawKey)); + final byte[] rawKey = cose.get(CBORObject.FromObject(-2)).GetByteString(); + final byte[] x509Key = + encodeDerSequence(ED25519_ALG_ID.getBytes(), encodeDerBitStringWithZeroUnused(rawKey)); KeyFactory kFact = KeyFactory.getInstance("EdDSA"); - return kFact.generatePublic(new X509EncodedKeySpec(x509Key.getBytes())); + return kFact.generatePublic(new X509EncodedKeySpec(x509Key)); } static String getJavaAlgorithmName(COSEAlgorithmIdentifier alg) { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala index 799c0ede0..6c055b69c 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala @@ -24,8 +24,8 @@ package com.yubico.webauthn +import com.yubico.internal.util.BinaryUtil import com.yubico.webauthn.data.ByteArray -import com.yubico.webauthn.data.Generators.arbitraryByteArray import com.yubico.webauthn.test.Util import org.junit.runner.RunWith import org.scalacheck.Arbitrary @@ -129,24 +129,26 @@ class WebAuthnCodecsSpec describe("DER parsing and encoding:") { it("encodeDerLength and parseDerLength are each other's inverse.") { - forAll(Gen.chooseNum(0, Int.MaxValue), arbitraryByteArray.arbitrary) { - (len: Int, prefix: ByteArray) => - val encoded = WebAuthnCodecs.encodeDerLength(len) - val decoded = WebAuthnCodecs.parseDerLength(encoded.getBytes, 0) - val decodedWithPrefix = WebAuthnCodecs.parseDerLength( - prefix.concat(encoded).getBytes, - prefix.size, - ) - - decoded.result should equal(len) - decoded.nextOffset should equal(encoded.size) - decodedWithPrefix.result should equal(len) - decodedWithPrefix.nextOffset should equal( - prefix.size + encoded.size - ) - - val recoded = WebAuthnCodecs.encodeDerLength(decoded.result) - recoded should equal(encoded) + forAll( + Gen.chooseNum(0, Int.MaxValue), + Arbitrary.arbitrary[Array[Byte]], + ) { (len: Int, prefix: Array[Byte]) => + val encoded = WebAuthnCodecs.encodeDerLength(len) + val decoded = WebAuthnCodecs.parseDerLength(encoded, 0) + val decodedWithPrefix = WebAuthnCodecs.parseDerLength( + BinaryUtil.concat(prefix, encoded), + prefix.length, + ) + + decoded.result should equal(len) + decoded.nextOffset should equal(encoded.length) + decodedWithPrefix.result should equal(len) + decodedWithPrefix.nextOffset should equal( + prefix.length + encoded.length + ) + + val recoded = WebAuthnCodecs.encodeDerLength(decoded.result) + recoded should equal(encoded) } } @@ -181,31 +183,32 @@ class WebAuthnCodecsSpec } it("encodeDerSequence and parseDerSequenceEnd are (almost) each other's inverse.") { - forAll { (data: Array[ByteArray], prefix: ByteArray) => + forAll { (data: Array[Array[Byte]], prefix: Array[Byte]) => val encoded = WebAuthnCodecs.encodeDerSequence(data: _*) - val decoded = WebAuthnCodecs.parseDerSequence(encoded.getBytes, 0) - val encodedWithPrefix = prefix.concat(encoded) + val decoded = WebAuthnCodecs.parseDerSequence(encoded, 0) + val encodedWithPrefix = BinaryUtil.concat(prefix, encoded) val decodedWithPrefix = WebAuthnCodecs.parseDerSequence( - encodedWithPrefix.getBytes, - prefix.size, + encodedWithPrefix, + prefix.length, ) - val expectedContent: ByteArray = - data.fold(new ByteArray(Array.empty))((a, b) => a.concat(b)) + val expectedContent: Array[Byte] = BinaryUtil.concat(data: _*) decoded.result should equal(expectedContent) decodedWithPrefix.result should equal(expectedContent) - decoded.nextOffset should equal(encoded.size) - decodedWithPrefix.nextOffset should equal(prefix.size + encoded.size) + decoded.nextOffset should equal(encoded.length) + decodedWithPrefix.nextOffset should equal( + prefix.length + encoded.length + ) } } it("parseDerSequence fails if the first byte is not 0x30.") { - forAll { (tag: Byte, data: Array[ByteArray]) => + forAll { (tag: Byte, data: Array[Array[Byte]]) => whenever(tag != 0x30) { val encoded = WebAuthnCodecs.encodeDerSequence(data: _*) an[IllegalArgumentException] shouldBe thrownBy { WebAuthnCodecs.parseDerSequence( - encoded.getBytes.updated(0, tag), + encoded.updated(0, tag), 0, ) } From 6dfbe9a0c796acd5bbba45b7f68b4b4b111c6ce7 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 21:40:41 +0200 Subject: [PATCH 102/132] Move DER encoding/parsing functions to BinaryUtil --- .../com/yubico/webauthn/WebAuthnCodecs.java | 160 +----------------- .../yubico/webauthn/WebAuthnCodecsSpec.scala | 101 ----------- .../com/yubico/internal/util/BinaryUtil.java | 148 ++++++++++++++++ .../yubico/internal/util/BinaryUtilSpec.scala | 102 +++++++++++ 4 files changed, 257 insertions(+), 254 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java index 74d910b3e..df223757b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java @@ -41,8 +41,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.NonNull; final class WebAuthnCodecs { @@ -197,168 +195,23 @@ private static PublicKey importCoseEcdsaPublicKey(CBORObject cose) } final byte[] algId = - encodeDerSequence( - encodeDerObjectId(EC_PUBLIC_KEY_OID.getBytes()), encodeDerObjectId(curveOid)); + BinaryUtil.encodeDerSequence( + BinaryUtil.encodeDerObjectId(EC_PUBLIC_KEY_OID.getBytes()), + BinaryUtil.encodeDerObjectId(curveOid)); final byte[] rawKey = - encodeDerBitStringWithZeroUnused( + BinaryUtil.encodeDerBitStringWithZeroUnused( BinaryUtil.concat( new byte[] {0x04}, // Raw EC public key with x and y x, y)); - final byte[] x509Key = encodeDerSequence(algId, rawKey); + final byte[] x509Key = BinaryUtil.encodeDerSequence(algId, rawKey); KeyFactory kFact = KeyFactory.getInstance("EC"); return kFact.generatePublic(new X509EncodedKeySpec(x509Key)); } - static byte[] encodeDerLength(final int length) { - if (length < 0) { - throw new IllegalArgumentException("Length is negative: " + length); - } else if (length <= 0x7f) { - return new byte[] {(byte) (length & 0xff)}; - } else if (length <= 0xff) { - return new byte[] {(byte) (0x80 | 0x01), (byte) (length & 0xff)}; - } else if (length <= 0xffff) { - return new byte[] { - (byte) (0x80 | 0x02), (byte) ((length >> 8) & 0xff), (byte) (length & 0xff) - }; - } else if (length <= 0xffffff) { - return new byte[] { - (byte) (0x80 | 0x03), - (byte) ((length >> 16) & 0xff), - (byte) ((length >> 8) & 0xff), - (byte) (length & 0xff) - }; - } else { - return new byte[] { - (byte) (0x80 | 0x04), - (byte) ((length >> 24) & 0xff), - (byte) ((length >> 16) & 0xff), - (byte) ((length >> 8) & 0xff), - (byte) (length & 0xff) - }; - } - } - - @AllArgsConstructor - static class ParseDerResult { - final T result; - final int nextOffset; - } - - static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { - final int len = der.length - offset; - if (len == 0) { - throw new IllegalArgumentException("Empty input"); - } else if ((der[offset] & 0x80) == 0) { - return new ParseDerResult<>(der[offset] & 0xff, offset + 1); - } else { - final int longLen = der[offset] & 0x7f; - if (len >= longLen) { - switch (longLen) { - case 0: - throw new IllegalArgumentException( - String.format( - "Empty length encoding at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); - case 1: - return new ParseDerResult<>(der[offset + 1] & 0xff, offset + 2); - case 2: - return new ParseDerResult<>( - ((der[offset + 1] & 0xff) << 8) | (der[offset + 2] & 0xff), offset + 3); - case 3: - return new ParseDerResult<>( - ((der[offset + 1] & 0xff) << 16) - | ((der[offset + 2] & 0xff) << 8) - | (der[offset + 3] & 0xff), - offset + 4); - case 4: - if ((der[offset + 1] & 0x80) == 0) { - return new ParseDerResult<>( - ((der[offset + 1] & 0xff) << 24) - | ((der[offset + 2] & 0xff) << 16) - | ((der[offset + 3] & 0xff) << 8) - | (der[offset + 4] & 0xff), - offset + 5); - } else { - throw new UnsupportedOperationException( - String.format( - "Length out of range of int: 0x%02x%02x%02x%02x", - der[offset + 1], der[offset + 2], der[offset + 3], der[offset + 4])); - } - default: - throw new UnsupportedOperationException( - String.format("Length is too long for int: %d octets", longLen)); - } - } else { - throw new IllegalArgumentException( - String.format( - "Length encoding needs %d octets but only %s remain at index %d: 0x%s", - longLen, len - (offset + 1), offset + 1, BinaryUtil.toHex(der))); - } - } - } - - private static ParseDerResult parseDerTagged( - @NonNull byte[] der, int offset, byte expectTag) { - final int len = der.length - offset; - if (len == 0) { - throw new IllegalArgumentException( - String.format("Empty input at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); - } else { - final byte tag = der[offset]; - if (tag == expectTag) { - final ParseDerResult contentLen = parseDerLength(der, offset + 1); - final int contentEnd = contentLen.nextOffset + contentLen.result; - return new ParseDerResult<>( - Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd), contentEnd); - } else { - throw new IllegalArgumentException( - String.format( - "Incorrect tag: 0x%02x (expected 0x%02x) at offset %d: 0x%s", - tag, expectTag, offset, BinaryUtil.toHex(der))); - } - } - } - - /** Parse a SEQUENCE and return a copy of the content octets. */ - static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { - return parseDerTagged(der, offset, (byte) 0x30); - } - - /** - * Parse an explicitly tagged value of class "context-specific" (bits 8-7 are 0b10), in - * "constructed" encoding (bit 6 is 1), with a prescribed tag value, and return a copy of the - * content octets. - */ - static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( - @NonNull byte[] der, int offset, byte tagNumber) { - if (tagNumber <= 30 && tagNumber >= 0) { - return parseDerTagged(der, offset, (byte) ((tagNumber & 0x1f) | 0xa0)); - } else { - throw new UnsupportedOperationException( - String.format("Tag number out of range: %d (expected 0 to 30, inclusive)", tagNumber)); - } - } - - private static byte[] encodeDerObjectId(@NonNull byte[] oid) { - byte[] result = new byte[2 + oid.length]; - result[0] = 0x06; - result[1] = (byte) oid.length; - return BinaryUtil.copyInto(oid, result, 2); - } - - private static byte[] encodeDerBitStringWithZeroUnused(@NonNull byte[] content) { - return BinaryUtil.concat( - new byte[] {0x03}, encodeDerLength(1 + content.length), new byte[] {0}, content); - } - - static byte[] encodeDerSequence(final byte[]... items) { - byte[] content = BinaryUtil.concat(items); - return BinaryUtil.concat(new byte[] {0x30}, encodeDerLength(content.length), content); - } - private static PublicKey importCoseEdDsaPublicKey(CBORObject cose) throws InvalidKeySpecException, NoSuchAlgorithmException { final int curveId = cose.get(CBORObject.FromObject(-1)).AsInt32(); @@ -374,7 +227,8 @@ private static PublicKey importCoseEd25519PublicKey(CBORObject cose) throws InvalidKeySpecException, NoSuchAlgorithmException { final byte[] rawKey = cose.get(CBORObject.FromObject(-2)).GetByteString(); final byte[] x509Key = - encodeDerSequence(ED25519_ALG_ID.getBytes(), encodeDerBitStringWithZeroUnused(rawKey)); + BinaryUtil.encodeDerSequence( + ED25519_ALG_ID.getBytes(), BinaryUtil.encodeDerBitStringWithZeroUnused(rawKey)); KeyFactory kFact = KeyFactory.getInstance("EdDSA"); return kFact.generatePublic(new X509EncodedKeySpec(x509Key)); diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala index 6c055b69c..a22d19e54 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala @@ -24,7 +24,6 @@ package com.yubico.webauthn -import com.yubico.internal.util.BinaryUtil import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.test.Util import org.junit.runner.RunWith @@ -127,105 +126,5 @@ class WebAuthnCodecsSpec } - describe("DER parsing and encoding:") { - it("encodeDerLength and parseDerLength are each other's inverse.") { - forAll( - Gen.chooseNum(0, Int.MaxValue), - Arbitrary.arbitrary[Array[Byte]], - ) { (len: Int, prefix: Array[Byte]) => - val encoded = WebAuthnCodecs.encodeDerLength(len) - val decoded = WebAuthnCodecs.parseDerLength(encoded, 0) - val decodedWithPrefix = WebAuthnCodecs.parseDerLength( - BinaryUtil.concat(prefix, encoded), - prefix.length, - ) - - decoded.result should equal(len) - decoded.nextOffset should equal(encoded.length) - decodedWithPrefix.result should equal(len) - decodedWithPrefix.nextOffset should equal( - prefix.length + encoded.length - ) - - val recoded = WebAuthnCodecs.encodeDerLength(decoded.result) - recoded should equal(encoded) - } - } - - it("parseDerLength tolerates unnecessarily long encodings.") { - WebAuthnCodecs - .parseDerLength(Array(0x81, 0).map(_.toByte), 0) - .result should equal(0) - WebAuthnCodecs - .parseDerLength(Array(0x82, 0, 0).map(_.toByte), 0) - .result should equal(0) - WebAuthnCodecs - .parseDerLength(Array(0x83, 0, 0, 0).map(_.toByte), 0) - .result should equal(0) - WebAuthnCodecs - .parseDerLength(Array(0x84, 0, 0, 0, 0).map(_.toByte), 0) - .result should equal(0) - WebAuthnCodecs - .parseDerLength(Array(0x81, 7).map(_.toByte), 0) - .result should equal(7) - WebAuthnCodecs - .parseDerLength(Array(0x82, 0, 7).map(_.toByte), 0) - .result should equal(7) - WebAuthnCodecs - .parseDerLength(Array(0x83, 0, 0, 7).map(_.toByte), 0) - .result should equal(7) - WebAuthnCodecs - .parseDerLength(Array(0x84, 0, 0, 4, 2).map(_.toByte), 0) - .result should equal(1026) - WebAuthnCodecs - .parseDerLength(Array(0x84, 0, 1, 33, 7).map(_.toByte), 0) - .result should equal(73991) - } - - it("encodeDerSequence and parseDerSequenceEnd are (almost) each other's inverse.") { - forAll { (data: Array[Array[Byte]], prefix: Array[Byte]) => - val encoded = WebAuthnCodecs.encodeDerSequence(data: _*) - val decoded = WebAuthnCodecs.parseDerSequence(encoded, 0) - val encodedWithPrefix = BinaryUtil.concat(prefix, encoded) - val decodedWithPrefix = WebAuthnCodecs.parseDerSequence( - encodedWithPrefix, - prefix.length, - ) - - val expectedContent: Array[Byte] = BinaryUtil.concat(data: _*) - decoded.result should equal(expectedContent) - decodedWithPrefix.result should equal(expectedContent) - decoded.nextOffset should equal(encoded.length) - decodedWithPrefix.nextOffset should equal( - prefix.length + encoded.length - ) - } - } - - it("parseDerSequence fails if the first byte is not 0x30.") { - forAll { (tag: Byte, data: Array[Array[Byte]]) => - whenever(tag != 0x30) { - val encoded = WebAuthnCodecs.encodeDerSequence(data: _*) - an[IllegalArgumentException] shouldBe thrownBy { - WebAuthnCodecs.parseDerSequence( - encoded.updated(0, tag), - 0, - ) - } - } - } - } - - it("parseDerSequence fails on empty input.") { - an[IllegalArgumentException] shouldBe thrownBy { - WebAuthnCodecs.parseDerSequence(Array.empty, 0) - } - forAll { data: Array[Byte] => - an[IllegalArgumentException] shouldBe thrownBy { - WebAuthnCodecs.parseDerSequence(data, data.length) - } - } - } - } } } diff --git a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java index 8c8eb55b3..0223cf1a8 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java @@ -29,6 +29,8 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.NonNull; public class BinaryUtil { @@ -197,4 +199,150 @@ public static byte[] readAll(InputStream is) throws IOException { } } } + + public static byte[] encodeDerLength(final int length) { + if (length < 0) { + throw new IllegalArgumentException("Length is negative: " + length); + } else if (length <= 0x7f) { + return new byte[] {(byte) (length & 0xff)}; + } else if (length <= 0xff) { + return new byte[] {(byte) (0x80 | 0x01), (byte) (length & 0xff)}; + } else if (length <= 0xffff) { + return new byte[] { + (byte) (0x80 | 0x02), (byte) ((length >> 8) & 0xff), (byte) (length & 0xff) + }; + } else if (length <= 0xffffff) { + return new byte[] { + (byte) (0x80 | 0x03), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }; + } else { + return new byte[] { + (byte) (0x80 | 0x04), + (byte) ((length >> 24) & 0xff), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }; + } + } + + @AllArgsConstructor + public static class ParseDerResult { + public final T result; + public final int nextOffset; + } + + public static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { + final int len = der.length - offset; + if (len == 0) { + throw new IllegalArgumentException("Empty input"); + } else if ((der[offset] & 0x80) == 0) { + return new ParseDerResult<>(der[offset] & 0xff, offset + 1); + } else { + final int longLen = der[offset] & 0x7f; + if (len >= longLen) { + switch (longLen) { + case 0: + throw new IllegalArgumentException( + String.format( + "Empty length encoding at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); + case 1: + return new ParseDerResult<>(der[offset + 1] & 0xff, offset + 2); + case 2: + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 8) | (der[offset + 2] & 0xff), offset + 3); + case 3: + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 16) + | ((der[offset + 2] & 0xff) << 8) + | (der[offset + 3] & 0xff), + offset + 4); + case 4: + if ((der[offset + 1] & 0x80) == 0) { + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 24) + | ((der[offset + 2] & 0xff) << 16) + | ((der[offset + 3] & 0xff) << 8) + | (der[offset + 4] & 0xff), + offset + 5); + } else { + throw new UnsupportedOperationException( + String.format( + "Length out of range of int: 0x%02x%02x%02x%02x", + der[offset + 1], der[offset + 2], der[offset + 3], der[offset + 4])); + } + default: + throw new UnsupportedOperationException( + String.format("Length is too long for int: %d octets", longLen)); + } + } else { + throw new IllegalArgumentException( + String.format( + "Length encoding needs %d octets but only %s remain at index %d: 0x%s", + longLen, len - (offset + 1), offset + 1, BinaryUtil.toHex(der))); + } + } + } + + private static ParseDerResult parseDerTagged( + @NonNull byte[] der, int offset, byte expectTag) { + final int len = der.length - offset; + if (len == 0) { + throw new IllegalArgumentException( + String.format("Empty input at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); + } else { + final byte tag = der[offset]; + if (tag == expectTag) { + final ParseDerResult contentLen = parseDerLength(der, offset + 1); + final int contentEnd = contentLen.nextOffset + contentLen.result; + return new ParseDerResult<>( + Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd), contentEnd); + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect tag: 0x%02x (expected 0x%02x) at offset %d: 0x%s", + tag, expectTag, offset, BinaryUtil.toHex(der))); + } + } + } + + /** Parse a SEQUENCE and return a copy of the content octets. */ + public static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { + return parseDerTagged(der, offset, (byte) 0x30); + } + + /** + * Parse an explicitly tagged value of class "context-specific" (bits 8-7 are 0b10), in + * "constructed" encoding (bit 6 is 1), with a prescribed tag value, and return a copy of the + * content octets. + */ + public static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( + @NonNull byte[] der, int offset, byte tagNumber) { + if (tagNumber <= 30 && tagNumber >= 0) { + return parseDerTagged(der, offset, (byte) ((tagNumber & 0x1f) | 0xa0)); + } else { + throw new UnsupportedOperationException( + String.format("Tag number out of range: %d (expected 0 to 30, inclusive)", tagNumber)); + } + } + + public static byte[] encodeDerObjectId(@NonNull byte[] oid) { + byte[] result = new byte[2 + oid.length]; + result[0] = 0x06; + result[1] = (byte) oid.length; + return BinaryUtil.copyInto(oid, result, 2); + } + + public static byte[] encodeDerBitStringWithZeroUnused(@NonNull byte[] content) { + return BinaryUtil.concat( + new byte[] {0x03}, encodeDerLength(1 + content.length), new byte[] {0}, content); + } + + public static byte[] encodeDerSequence(final byte[]... items) { + byte[] content = BinaryUtil.concat(items); + return BinaryUtil.concat(new byte[] {0x30}, encodeDerLength(content.length), content); + } } diff --git a/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala b/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala index b834f95b7..53b9d8835 100644 --- a/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala +++ b/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala @@ -25,6 +25,7 @@ package com.yubico.internal.util import org.junit.runner.RunWith +import org.scalacheck.Arbitrary import org.scalacheck.Gen import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers @@ -149,4 +150,105 @@ class BinaryUtilSpec } } + describe("DER parsing and encoding:") { + it("encodeDerLength and parseDerLength are each other's inverse.") { + forAll( + Gen.chooseNum(0, Int.MaxValue), + Arbitrary.arbitrary[Array[Byte]], + ) { (len: Int, prefix: Array[Byte]) => + val encoded = BinaryUtil.encodeDerLength(len) + val decoded = BinaryUtil.parseDerLength(encoded, 0) + val decodedWithPrefix = BinaryUtil.parseDerLength( + BinaryUtil.concat(prefix, encoded), + prefix.length, + ) + + decoded.result should equal(len) + decoded.nextOffset should equal(encoded.length) + decodedWithPrefix.result should equal(len) + decodedWithPrefix.nextOffset should equal( + prefix.length + encoded.length + ) + + val recoded = BinaryUtil.encodeDerLength(decoded.result) + recoded should equal(encoded) + } + } + + it("parseDerLength tolerates unnecessarily long encodings.") { + BinaryUtil + .parseDerLength(Array(0x81, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x82, 0, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x83, 0, 0, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x84, 0, 0, 0, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x81, 7).map(_.toByte), 0) + .result should equal(7) + BinaryUtil + .parseDerLength(Array(0x82, 0, 7).map(_.toByte), 0) + .result should equal(7) + BinaryUtil + .parseDerLength(Array(0x83, 0, 0, 7).map(_.toByte), 0) + .result should equal(7) + BinaryUtil + .parseDerLength(Array(0x84, 0, 0, 4, 2).map(_.toByte), 0) + .result should equal(1026) + BinaryUtil + .parseDerLength(Array(0x84, 0, 1, 33, 7).map(_.toByte), 0) + .result should equal(73991) + } + + it("encodeDerSequence and parseDerSequenceEnd are (almost) each other's inverse.") { + forAll { (data: Array[Array[Byte]], prefix: Array[Byte]) => + val encoded = BinaryUtil.encodeDerSequence(data: _*) + val decoded = BinaryUtil.parseDerSequence(encoded, 0) + val encodedWithPrefix = BinaryUtil.concat(prefix, encoded) + val decodedWithPrefix = BinaryUtil.parseDerSequence( + encodedWithPrefix, + prefix.length, + ) + + val expectedContent: Array[Byte] = BinaryUtil.concat(data: _*) + decoded.result should equal(expectedContent) + decodedWithPrefix.result should equal(expectedContent) + decoded.nextOffset should equal(encoded.length) + decodedWithPrefix.nextOffset should equal( + prefix.length + encoded.length + ) + } + } + + it("parseDerSequence fails if the first byte is not 0x30.") { + forAll { (tag: Byte, data: Array[Array[Byte]]) => + whenever(tag != 0x30) { + val encoded = BinaryUtil.encodeDerSequence(data: _*) + an[IllegalArgumentException] shouldBe thrownBy { + BinaryUtil.parseDerSequence( + encoded.updated(0, tag), + 0, + ) + } + } + } + } + + it("parseDerSequence fails on empty input.") { + an[IllegalArgumentException] shouldBe thrownBy { + BinaryUtil.parseDerSequence(Array.empty, 0) + } + forAll { data: Array[Byte] => + an[IllegalArgumentException] shouldBe thrownBy { + BinaryUtil.parseDerSequence(data, data.length) + } + } + } + } + } From e60862a51909756eb0aa530c5390b6e1a58dfa50 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 11 Jul 2024 22:00:27 +0200 Subject: [PATCH 103/132] Use BinaryUtil.concat instead of ByteArray.concat where appropriate --- .../java/com/yubico/fido/metadata/AAGUID.java | 16 +- .../webauthn/RelyingPartyAssertionSpec.scala | 8 +- .../RelyingPartyRegistrationSpec.scala | 11 +- .../RelyingPartyV2AssertionSpec.scala | 8 +- .../RelyingPartyV2RegistrationSpec.scala | 11 +- .../yubico/webauthn/TestAuthenticator.scala | 153 ++++++++---------- 6 files changed, 97 insertions(+), 110 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java index e5adeff30..e11dac599 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import com.yubico.internal.util.BinaryUtil; import com.yubico.internal.util.ExceptionUtil; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.exception.HexException; import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.AccessLevel; @@ -105,12 +105,14 @@ private static ByteArray parse(String value) { Matcher matcher = AAGUID_PATTERN.matcher(value); if (matcher.find()) { try { - return ByteArray.fromHex(matcher.group(1)) - .concat(ByteArray.fromHex(matcher.group(2))) - .concat(ByteArray.fromHex(matcher.group(3))) - .concat(ByteArray.fromHex(matcher.group(4))) - .concat(ByteArray.fromHex(matcher.group(5))); - } catch (HexException e) { + return new ByteArray( + BinaryUtil.concat( + BinaryUtil.fromHex(matcher.group(1)), + BinaryUtil.fromHex(matcher.group(2)), + BinaryUtil.fromHex(matcher.group(3)), + BinaryUtil.fromHex(matcher.group(4)), + BinaryUtil.fromHex(matcher.group(5)))); + } catch (Exception e) { throw new RuntimeException( "This exception should be impossible, please file a bug report.", e); } 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 ef809d116..ca9ee43c2 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 @@ -29,6 +29,7 @@ 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.BinaryUtil import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AuthenticatorAssertionResponse @@ -2419,13 +2420,14 @@ class RelyingPartyAssertionSpec it("a U2F-formatted public key.") { val testData = RealExamples.YubiKeyNeo.asRegistrationTestData - val x = ByteArray.fromHex( + val x = BinaryUtil.fromHex( "39C94FBBDDC694A925E6F8657C66916CFE84CD0222EDFCF281B21F5CDC347923" ) - val y = ByteArray.fromHex( + val y = BinaryUtil.fromHex( "D6B0D2021CFE1724A6FE81E3568C4FFAE339298216A30AFC18C0B975F2E2A891" ) - val u2fPubkey = ByteArray.fromHex("04").concat(x).concat(y) + val u2fPubkey = + new ByteArray(BinaryUtil.concat(BinaryUtil.fromHex("04"), x, y)) val cred1 = RegisteredCredential .builder() 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 49ee87a4b..de55d059e 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 @@ -1745,18 +1745,15 @@ class RelyingPartyRegistrationSpec key, COSEAlgorithmIdentifier.RS256, ) - new ByteArray( + BinaryUtil.concat( java.util.Arrays.copyOfRange( authDataBytes, 0, 32 + 1 + 4 + 16 + 2, - ) + ), + authData.getAttestedCredentialData.get.getCredentialId.getBytes, + reencodedKey.getBytes, ) - .concat( - authData.getAttestedCredentialData.get.getCredentialId - ) - .concat(reencodedKey) - .getBytes } def modifyAttobjPubkeyAlg(attObjBytes: ByteArray) 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 794db38fb..8d2579d8a 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 @@ -29,6 +29,7 @@ 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.BinaryUtil import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AuthenticatorAssertionResponse @@ -2511,13 +2512,14 @@ class RelyingPartyV2AssertionSpec it("a U2F-formatted public key.") { val testData = RealExamples.YubiKeyNeo.asRegistrationTestData - val x = ByteArray.fromHex( + val x = BinaryUtil.fromHex( "39C94FBBDDC694A925E6F8657C66916CFE84CD0222EDFCF281B21F5CDC347923" ) - val y = ByteArray.fromHex( + val y = BinaryUtil.fromHex( "D6B0D2021CFE1724A6FE81E3568C4FFAE339298216A30AFC18C0B975F2E2A891" ) - val u2fPubkey = ByteArray.fromHex("04").concat(x).concat(y) + val u2fPubkey = + new ByteArray(BinaryUtil.concat(BinaryUtil.fromHex("04"), x, y)) val rp = RelyingParty .builder() 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 be6274d6b..f623ac2ef 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 @@ -1736,18 +1736,15 @@ class RelyingPartyV2RegistrationSpec key, COSEAlgorithmIdentifier.RS256, ) - new ByteArray( + BinaryUtil.concat( java.util.Arrays.copyOfRange( authDataBytes, 0, 32 + 1 + 4 + 16 + 2, - ) + ), + authData.getAttestedCredentialData.get.getCredentialId.getBytes, + reencodedKey.getBytes, ) - .concat( - authData.getAttestedCredentialData.get.getCredentialId - ) - .concat(reencodedKey) - .getBytes } def modifyAttobjPubkeyAlg(attObjBytes: ByteArray) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala index 83f3b5606..5c5f115d2 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala @@ -930,26 +930,21 @@ object TestAuthenticator { case 3 => { // RSA val cose = CBORObject.DecodeFromBytes(cosePubkey.getBytes) ( - new ByteArray(BinaryUtil.encodeUint16(symmetric getOrElse 0x0010)) - .concat( - new ByteArray( - BinaryUtil.encodeUint16(scheme getOrElse TpmRsaScheme.RSASSA) - ) - ) - .concat( - new ByteArray(BinaryUtil.encodeUint16(RsaKeySizeBits)) - ) // key_bits - .concat( - new ByteArray( - BinaryUtil.encodeUint32( - new BigInteger(1, cose.get(-2).GetByteString()).longValue() - ) - ) - ) // exponent - , - new ByteArray( - BinaryUtil.encodeUint16(cose.get(-1).GetByteString().length) - ).concat(new ByteArray(cose.get(-1).GetByteString())), // modulus + BinaryUtil.concat( + BinaryUtil.encodeUint16(symmetric getOrElse 0x0010), + BinaryUtil.encodeUint16(scheme getOrElse TpmRsaScheme.RSASSA), + // key_bits + BinaryUtil.encodeUint16(RsaKeySizeBits), + // exponent + BinaryUtil.encodeUint32( + new BigInteger(1, cose.get(-2).GetByteString()).longValue() + ), + ), + BinaryUtil.concat( + BinaryUtil.encodeUint16(cose.get(-1).GetByteString().length), + // modulus + cose.get(-1).GetByteString(), + ), ) } case 2 => { // EC @@ -957,78 +952,70 @@ object TestAuthenticator { .importCosePublicKey(cosePubkey) .asInstanceOf[ECPublicKey] ( - new ByteArray(BinaryUtil.encodeUint16(symmetric getOrElse 0x0010)) - .concat( - new ByteArray(BinaryUtil.encodeUint16(scheme getOrElse 0x0010)) - ) - .concat( - new ByteArray(BinaryUtil.encodeUint16(coseKeyAlg match { - case COSEAlgorithmIdentifier.ES256 => 0x0003 - case COSEAlgorithmIdentifier.ES384 => 0x0004 - case COSEAlgorithmIdentifier.ES512 => 0x0005 - case COSEAlgorithmIdentifier.RS1 | - COSEAlgorithmIdentifier.RS256 | - COSEAlgorithmIdentifier.RS384 | - COSEAlgorithmIdentifier.RS512 | - COSEAlgorithmIdentifier.EdDSA => - ??? - })) - ) - .concat( - new ByteArray(BinaryUtil.encodeUint16(0x0010)) - ) // kdf_scheme: ??? (unused?) - , - new ByteArray( - BinaryUtil.encodeUint16(pubkey.getW.getAffineX.toByteArray.length) - ) - .concat(new ByteArray(pubkey.getW.getAffineX.toByteArray)) - .concat( - new ByteArray( - BinaryUtil.encodeUint16( - pubkey.getW.getAffineY.toByteArray.length - ) - ) - ) - .concat(new ByteArray(pubkey.getW.getAffineY.toByteArray)), + BinaryUtil.concat( + BinaryUtil.encodeUint16(symmetric getOrElse 0x0010), + BinaryUtil.encodeUint16(scheme getOrElse 0x0010), + BinaryUtil.encodeUint16(coseKeyAlg match { + case COSEAlgorithmIdentifier.ES256 => 0x0003 + case COSEAlgorithmIdentifier.ES384 => 0x0004 + case COSEAlgorithmIdentifier.ES512 => 0x0005 + case COSEAlgorithmIdentifier.RS1 | COSEAlgorithmIdentifier.RS256 | + COSEAlgorithmIdentifier.RS384 | + COSEAlgorithmIdentifier.RS512 | + COSEAlgorithmIdentifier.EdDSA => + ??? + }), + // kdf_scheme: ??? (unused?) + BinaryUtil.encodeUint16(0x0010), + ), + BinaryUtil.concat( + BinaryUtil.encodeUint16(pubkey.getW.getAffineX.toByteArray.length), + pubkey.getW.getAffineX.toByteArray, + BinaryUtil.encodeUint16( + pubkey.getW.getAffineY.toByteArray.length + ), + pubkey.getW.getAffineY.toByteArray, + ), ) } } - val pubArea = new ByteArray(BinaryUtil.encodeUint16(signAlg)) - .concat(new ByteArray(BinaryUtil.encodeUint16(hashId))) - .concat( - new ByteArray( - BinaryUtil.encodeUint32(attributes getOrElse Attributes.SIGN_ENCRYPT) - ) + val pubArea = new ByteArray( + BinaryUtil.concat( + BinaryUtil.encodeUint16(signAlg), + BinaryUtil.encodeUint16(hashId), + BinaryUtil.encodeUint32(attributes getOrElse Attributes.SIGN_ENCRYPT), + // authPolicy is ignored by TpmAttestationStatementVerifier + BinaryUtil.encodeUint16(0), + parameters, + unique, ) - .concat( - new ByteArray(BinaryUtil.encodeUint16(0)) - ) // authPolicy is ignored by TpmAttestationStatementVerifier - .concat(parameters) - .concat(unique) - - val qualifiedSigner = ByteArray.fromHex("") - val clockInfo = ByteArray.fromHex("0000000000000000111111112222222233") - val firmwareVersion = ByteArray.fromHex("0000000000000000") + ) + + val qualifiedSigner = BinaryUtil.fromHex("") + val clockInfo = BinaryUtil.fromHex("0000000000000000111111112222222233") + val firmwareVersion = BinaryUtil.fromHex("0000000000000000") val attestedName = modifyAttestedName( new ByteArray(BinaryUtil.encodeUint16(hashId)).concat(hashFunc(pubArea)) ) - val attestedQualifiedName = ByteArray.fromHex("") - - val certInfo = magic - .concat(`type`) - .concat(new ByteArray(BinaryUtil.encodeUint16(qualifiedSigner.size))) - .concat(qualifiedSigner) - .concat(new ByteArray(BinaryUtil.encodeUint16(extraData.size))) - .concat(extraData) - .concat(clockInfo) - .concat(firmwareVersion) - .concat(new ByteArray(BinaryUtil.encodeUint16(attestedName.size))) - .concat(attestedName) - .concat( - new ByteArray(BinaryUtil.encodeUint16(attestedQualifiedName.size)) + val attestedQualifiedName = BinaryUtil.fromHex("") + + val certInfo = new ByteArray( + BinaryUtil.concat( + magic.getBytes, + `type`.getBytes, + BinaryUtil.encodeUint16(qualifiedSigner.length), + qualifiedSigner, + BinaryUtil.encodeUint16(extraData.size), + extraData.getBytes, + clockInfo, + firmwareVersion, + BinaryUtil.encodeUint16(attestedName.size), + attestedName.getBytes, + BinaryUtil.encodeUint16(attestedQualifiedName.length), + attestedQualifiedName, ) - .concat(attestedQualifiedName) + ) val sig = sign(certInfo, cert.key, cert.alg) From 7c7de8f3659958da01fcba5a33cd4334901751ff Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 15 Jul 2024 10:58:25 +0200 Subject: [PATCH 104/132] Make parseDerExplicitlyTaggedContextSpecificConstructed private --- .../src/main/java/com/yubico/internal/util/BinaryUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java index 0223cf1a8..ecf68d43c 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java @@ -319,7 +319,7 @@ public static ParseDerResult parseDerSequence(@NonNull byte[] der, int o * "constructed" encoding (bit 6 is 1), with a prescribed tag value, and return a copy of the * content octets. */ - public static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( + private static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( @NonNull byte[] der, int offset, byte tagNumber) { if (tagNumber <= 30 && tagNumber >= 0) { return parseDerTagged(der, offset, (byte) ((tagNumber & 0x1f) | 0xa0)); From aa2605b09a94c0186d3b4869c8186169e2a6131a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 15 Jul 2024 11:00:11 +0200 Subject: [PATCH 105/132] Parse X.509 CRLDistributionPoints extension Co-authored by: Dennis Fokin --- .../com/yubico/internal/util/BinaryUtil.java | 211 +++++++++++++++--- .../internal/util/CertificateParser.java | 151 +++++++++++++ .../yubico/internal/util/BinaryUtilSpec.scala | 46 ---- 3 files changed, 336 insertions(+), 72 deletions(-) diff --git a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java index ecf68d43c..037be9dec 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java @@ -28,9 +28,13 @@ import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.ArrayList; import java.util.Arrays; -import lombok.AllArgsConstructor; +import java.util.List; +import java.util.Optional; import lombok.NonNull; +import lombok.ToString; +import lombok.Value; public class BinaryUtil { @@ -229,10 +233,48 @@ public static byte[] encodeDerLength(final int length) { } } - @AllArgsConstructor + @ToString + public enum DerTagClass { + UNIVERSAL, + APPLICATION, + CONTEXT_SPECIFIC, + PRIVATE; + + public static DerTagClass parse(byte tag) { + switch ((tag >> 6) & 0x03) { + case 0x0: + return DerTagClass.UNIVERSAL; + case 0x1: + return DerTagClass.APPLICATION; + case 0x2: + return DerTagClass.CONTEXT_SPECIFIC; + case 0x3: + return DerTagClass.PRIVATE; + default: + throw new RuntimeException("This should be impossible"); + } + } + } + + @Value + private static class ParseDerAnyResult { + DerTagClass tagClass; + boolean constructed; + byte tagValue; + byte[] content; + int nextOffset; + } + + @Value public static class ParseDerResult { - public final T result; - public final int nextOffset; + /** The parsed value, excluding the tag-and-length header. */ + public T result; + + /** + * The offset of the first octet past the end of the parsed value. In other words, the offset to + * continue reading from. + */ + public int nextOffset; } public static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { @@ -287,46 +329,163 @@ public static ParseDerResult parseDerLength(@NonNull byte[] der, int of } } - private static ParseDerResult parseDerTagged( - @NonNull byte[] der, int offset, byte expectTag) { + private static ParseDerAnyResult parseDerAny(@NonNull byte[] der, int offset) { final int len = der.length - offset; if (len == 0) { throw new IllegalArgumentException( String.format("Empty input at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); } else { final byte tag = der[offset]; - if (tag == expectTag) { - final ParseDerResult contentLen = parseDerLength(der, offset + 1); - final int contentEnd = contentLen.nextOffset + contentLen.result; - return new ParseDerResult<>( - Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd), contentEnd); + final ParseDerResult contentLen = parseDerLength(der, offset + 1); + final int contentEnd = contentLen.nextOffset + contentLen.result; + return new ParseDerAnyResult( + DerTagClass.parse(tag), + (tag & 0x20) != 0, + (byte) (tag & 0x1f), + Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd), + contentEnd); + } + } + + /** + * Parse a DER header with the given tag value, constructed bit and tag class, and return a copy + * of the value octets. If any of the three criteria do not match, return empty instead. + * + * @param der DER source to read from. + * @param offset The offset in der from which to start reading. + * @param expectTag The expected tag value, excluding the constructed bit and tag class. This is + * the 5 least significant bits of the tag octet. + * @param constructed The expected "constructed" bit. This is bit 6 (the third-most significant + * bit) of the tag octet. + * @param expectTagClass The expected tag class. This is the 2 most significant bits of the tag + * octet. + * @return A copy of the value octets, if the parsed tag matches expectTag, + * constructed and expectTagClass, otherwise empty. {@link + * ParseDerResult#nextOffset} is always returned. + */ + public static ParseDerResult> parseDerTaggedOrSkip( + @NonNull byte[] der, + int offset, + byte expectTag, + boolean constructed, + DerTagClass expectTagClass) { + final ParseDerAnyResult result = parseDerAny(der, offset); + if (result.tagValue == expectTag + && result.constructed == constructed + && result.tagClass == expectTagClass) { + return new ParseDerResult<>(Optional.of(result.content), result.nextOffset); + } else { + return new ParseDerResult<>(Optional.empty(), result.nextOffset); + } + } + + /** + * Parse a DER header with the given tag value, constructed bit and tag class, and return a copy + * of the value octets. If any of the three criteria do not match, throw an {@link + * IllegalArgumentException}. + * + * @param der DER source to read from. + * @param offset The offset in der from which to start reading. + * @param expectTag The expected tag value, excluding the constructed bit and tag class. This is + * the 5 least significant bits of the tag octet. + * @param constructed The expected "constructed" bit. This is bit 6 (the third-most significant + * bit) of the tag octet. + * @param expectTagClass The expected tag class. This is the 2 most significant bits of the tag + * octet. + * @return A copy of the value octets, if the parsed tag matches expectTag, + * constructed and expectTagClass, otherwise empty. {@link + * ParseDerResult#nextOffset} is always returned. + */ + private static ParseDerResult parseDerTagged( + @NonNull byte[] der, + int offset, + byte expectTag, + boolean constructed, + DerTagClass expectTagClass) { + final ParseDerAnyResult result = parseDerAny(der, offset); + if (result.tagValue == expectTag) { + if (result.constructed == constructed) { + if (result.tagClass == expectTagClass) { + return new ParseDerResult<>(result.content, result.nextOffset); + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect tag class: expected %s, found %s at offset %d: 0x%s", + expectTagClass, result.tagClass, offset, BinaryUtil.toHex(der))); + } } else { throw new IllegalArgumentException( String.format( - "Incorrect tag: 0x%02x (expected 0x%02x) at offset %d: 0x%s", - tag, expectTag, offset, BinaryUtil.toHex(der))); + "Incorrect constructed bit: expected %s, found %s at offset %d: 0x%s", + constructed, result.constructed, offset, BinaryUtil.toHex(der))); } + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect tag: expected 0x%02x, found 0x%02x at offset %d: 0x%s", + expectTag, result.tagValue, offset, BinaryUtil.toHex(der))); } } - /** Parse a SEQUENCE and return a copy of the content octets. */ - public static ParseDerResult parseDerSequence(@NonNull byte[] der, int offset) { - return parseDerTagged(der, offset, (byte) 0x30); + /** Function to parse an element of a DER SEQUENCE. */ + @FunctionalInterface + public interface ParseDerSequenceElementFunction { + /** + * Parse an element of a DER SEQUENCE. + * + * @param sequenceDer The content octets of the parent SEQUENCE. This includes ALL elements in + * the sequence. + * @param elementOffset The offset into sequenceDer from where to parse the + * element. + * @return A {@link ParseDerResult} whose {@link ParseDerResult#result} is the parsed element + * and {@link ParseDerResult#nextOffset} is the offset of the first octet past the end of + * the parsed element. + */ + ParseDerResult parse(@NonNull byte[] sequenceDer, int elementOffset); } /** - * Parse an explicitly tagged value of class "context-specific" (bits 8-7 are 0b10), in - * "constructed" encoding (bit 6 is 1), with a prescribed tag value, and return a copy of the - * content octets. + * Parse the elements of a SEQUENCE using the given element parsing function. + * + * @param der DER source array to read from + * @param offset Offset from which to begin reading the first element + * @param endOffset Offset of the first octet past the end of the sequence + * @param parseElement Function to use to parse each element in the sequence. */ - private static ParseDerResult parseDerExplicitlyTaggedContextSpecificConstructed( - @NonNull byte[] der, int offset, byte tagNumber) { - if (tagNumber <= 30 && tagNumber >= 0) { - return parseDerTagged(der, offset, (byte) ((tagNumber & 0x1f) | 0xa0)); - } else { - throw new UnsupportedOperationException( - String.format("Tag number out of range: %d (expected 0 to 30, inclusive)", tagNumber)); + public static ParseDerResult> parseDerSequenceContents( + @NonNull byte[] der, + int offset, + int endOffset, + @NonNull ParseDerSequenceElementFunction parseElement) { + List result = new ArrayList<>(); + int seqOffset = offset; + while (seqOffset < endOffset) { + ParseDerResult elementResult = parseElement.parse(der, seqOffset); + result.add(elementResult.result); + seqOffset = elementResult.nextOffset; } + return new ParseDerResult<>(result, endOffset); + } + + /** + * Parse a SEQUENCE using the given element parsing function. + * + * @param der DER source array to read from + * @param offset Offset from which to begin reading the SEQUENCE + * @param parseElement Function to use to parse each element in the sequence. + */ + public static ParseDerResult> parseDerSequence( + @NonNull byte[] der, int offset, @NonNull ParseDerSequenceElementFunction parseElement) { + final ParseDerResult seq = + parseDerTagged(der, offset, (byte) 0x10, true, DerTagClass.UNIVERSAL); + final ParseDerResult> res = + parseDerSequenceContents(seq.result, 0, seq.result.length, parseElement); + return new ParseDerResult<>(res.result, seq.nextOffset); + } + + /** Parse an Octet String. */ + public static ParseDerResult parseDerOctetString(@NonNull byte[] der, int offset) { + return parseDerTagged(der, offset, (byte) 0x04, false, DerTagClass.UNIVERSAL); } public static byte[] encodeDerObjectId(@NonNull byte[] oid) { diff --git a/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java b/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java index 1e1c72bfe..9703a773c 100755 --- a/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java @@ -26,20 +26,28 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Optional; +import lombok.Value; public class CertificateParser { public static final String ID_FIDO_GEN_CE_AAGUID = "1.3.6.1.4.1.45724.1.1.4"; + public static final String OID_CRL_DISTRIBUTION_POINTS = "2.5.29.31"; private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder(); private static final List FIXSIG = @@ -164,4 +172,147 @@ public static Optional parseFidoAaguidExtension(X509Certificate cert) { }); return result; } + + @Value + public static class ParseCrlDistributionPointsExtensionResult { + /** + * The successfully parsed distribution point URLs. If the CRLDistributionPoints extension is + * not present, this will be an empty list. + */ + Collection distributionPoints; + + /** + * True if and only if the CRLDistributionPoints extension is present and contains anything that + * is not a distributionPoint [0] DistributionPointName containing a + * fullName [0] GeneralNames containing exactly one + * uniformResourceIdentifier [6] IA5String + */ + boolean anyDistributionPointUnsupported; + } + + public static ParseCrlDistributionPointsExtensionResult parseCrlDistributionPointsExtension( + X509Certificate cert) { + final byte[] crldpExtension = cert.getExtensionValue(OID_CRL_DISTRIBUTION_POINTS); + if (crldpExtension != null) { + BinaryUtil.ParseDerResult octetString = + BinaryUtil.parseDerOctetString(crldpExtension, 0); + try { + BinaryUtil.ParseDerResult>>>> distributionPoints = + BinaryUtil.parseDerSequence( + octetString.result, + 0, + (outerSequenceDer, distributionPointOffset) -> + BinaryUtil.parseDerSequence( + outerSequenceDer, + distributionPointOffset, + (innerSequenceDer, distributionPointChoiceOffset) -> { + // DistributionPoint ::= SEQUENCE { + // distributionPoint [0] DistributionPointName OPTIONAL, + final BinaryUtil.ParseDerResult> dpElement = + BinaryUtil.parseDerTaggedOrSkip( + innerSequenceDer, + distributionPointChoiceOffset, + (byte) 0, + true, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + if (dpElement.result.isPresent()) { + + // DistributionPointName ::= CHOICE { + // fullName [0] GeneralNames, + final BinaryUtil.ParseDerResult> dpNameElement = + BinaryUtil.parseDerTaggedOrSkip( + dpElement.result.get(), + 0, + (byte) 0, + true, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + + if (dpNameElement.result.isPresent()) { + return BinaryUtil.parseDerSequenceContents( + dpNameElement.result.get(), + 0, + dpNameElement.result.get().length, + (generalNamesDer, generalNamesElementOffset) -> { + // fullName [0] GeneralNames, + // GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName + // GeneralName ::= CHOICE { + // uniformResourceIdentifier [6] IA5String, + // + // GeneralNames is defined in RFC 5280 appendix 2 which uses + // IMPLICIT tagging + // https://datatracker.ietf.org/doc/html/rfc5280#appendix-A.2 + // so the SEQUENCE tag in GeneralNames is implicit. + // The IA5String tag is also implicit from the CHOICE tag. + final BinaryUtil.ParseDerResult> generalName = + BinaryUtil.parseDerTaggedOrSkip( + generalNamesDer, + generalNamesElementOffset, + (byte) 6, + false, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + if (generalName.result.isPresent()) { + String uriString = + new String( + generalName.result.get(), StandardCharsets.US_ASCII); + try { + return new BinaryUtil.ParseDerResult<>( + Optional.of(new URL(uriString)), + generalName.nextOffset); + } catch (MalformedURLException e) { + throw new IllegalArgumentException( + String.format( + "Invalid URL in CRLDistributionPoints: %s", + uriString), + e); + } + } else { + return new BinaryUtil.ParseDerResult<>( + Optional.empty(), generalName.nextOffset); + } + }); + } + } + + // Ignore all other forms of distribution points + return new BinaryUtil.ParseDerResult<>( + Collections.emptyList(), dpElement.nextOffset); + })); + + return distributionPoints.result.stream() + .flatMap(Collection::stream) + .flatMap(Collection::stream) + .reduce( + new ParseCrlDistributionPointsExtensionResult(new ArrayList<>(), false), + (result, next) -> { + if (next.isPresent()) { + List dp = new ArrayList<>(result.distributionPoints); + dp.add(next.get()); + return new ParseCrlDistributionPointsExtensionResult( + dp, result.anyDistributionPointUnsupported); + } else { + return new ParseCrlDistributionPointsExtensionResult( + result.distributionPoints, true); + } + }, + (resultA, resultB) -> { + List dp = new ArrayList<>(resultA.distributionPoints); + dp.addAll(resultB.distributionPoints); + return new ParseCrlDistributionPointsExtensionResult( + dp, + resultA.anyDistributionPointUnsupported + || resultB.anyDistributionPointUnsupported); + }); + + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + String.format( + "X.509 extension %s (id-ce-cRLDistributionPoints) is incorrectly encoded.", + OID_CRL_DISTRIBUTION_POINTS), + e); + } + + } else { + return new ParseCrlDistributionPointsExtensionResult(Collections.emptySet(), false); + } + } } diff --git a/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala b/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala index 53b9d8835..190286649 100644 --- a/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala +++ b/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala @@ -204,51 +204,5 @@ class BinaryUtilSpec .parseDerLength(Array(0x84, 0, 1, 33, 7).map(_.toByte), 0) .result should equal(73991) } - - it("encodeDerSequence and parseDerSequenceEnd are (almost) each other's inverse.") { - forAll { (data: Array[Array[Byte]], prefix: Array[Byte]) => - val encoded = BinaryUtil.encodeDerSequence(data: _*) - val decoded = BinaryUtil.parseDerSequence(encoded, 0) - val encodedWithPrefix = BinaryUtil.concat(prefix, encoded) - val decodedWithPrefix = BinaryUtil.parseDerSequence( - encodedWithPrefix, - prefix.length, - ) - - val expectedContent: Array[Byte] = BinaryUtil.concat(data: _*) - decoded.result should equal(expectedContent) - decodedWithPrefix.result should equal(expectedContent) - decoded.nextOffset should equal(encoded.length) - decodedWithPrefix.nextOffset should equal( - prefix.length + encoded.length - ) - } - } - - it("parseDerSequence fails if the first byte is not 0x30.") { - forAll { (tag: Byte, data: Array[Array[Byte]]) => - whenever(tag != 0x30) { - val encoded = BinaryUtil.encodeDerSequence(data: _*) - an[IllegalArgumentException] shouldBe thrownBy { - BinaryUtil.parseDerSequence( - encoded.updated(0, tag), - 0, - ) - } - } - } - } - - it("parseDerSequence fails on empty input.") { - an[IllegalArgumentException] shouldBe thrownBy { - BinaryUtil.parseDerSequence(Array.empty, 0) - } - forAll { data: Array[Byte] => - an[IllegalArgumentException] shouldBe thrownBy { - BinaryUtil.parseDerSequence(data, data.length) - } - } - } } - } From 5b8c7582894962536e13658dcbd2aecccf5ce4af Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Oct 2024 21:00:51 +0100 Subject: [PATCH 106/132] Extract method FidoMetadataDownloader.fetchHeaderCertChain --- .../fido/metadata/FidoMetadataDownloader.java | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index cf3dfd5cd..0dffab20f 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -1097,34 +1097,7 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo InvalidAlgorithmParameterException, FidoMetadataDownloaderException { final MetadataBLOBHeader header = parseResult.blob.getHeader(); - - final List certChain; - if (header.getX5u().isPresent()) { - final URL x5u = header.getX5u().get(); - if (blobUrl != null - && (!(x5u.getHost().equals(blobUrl.getHost()) - && x5u.getProtocol().equals(blobUrl.getProtocol()) - && x5u.getPort() == blobUrl.getPort()))) { - throw new IllegalArgumentException( - String.format( - "x5u in BLOB header must have same origin as the URL the BLOB was downloaded from. Expected origin of: %s ; found: %s", - blobUrl, x5u)); - } - List certs = new ArrayList<>(); - for (String pem : - new String(download(x5u).getBytes(), StandardCharsets.UTF_8) - .trim() - .split("\\n+-----END CERTIFICATE-----\\n+-----BEGIN CERTIFICATE-----\\n+")) { - X509Certificate x509Certificate = CertificateParser.parsePem(pem); - certs.add(x509Certificate); - } - certChain = certs; - } else if (header.getX5c().isPresent()) { - certChain = header.getX5c().get(); - } else { - certChain = Collections.singletonList(trustRootCertificate); - } - + final List certChain = fetchHeaderCertChain(trustRootCertificate, header); final X509Certificate leafCert = certChain.get(0); final Signature signature; @@ -1209,4 +1182,35 @@ private static class ParseResult { private ByteArray jwtPayload; private ByteArray jwtSignature; } + + /** Parse the header cert chain and download any certificates as necessary. */ + List fetchHeaderCertChain( + X509Certificate trustRootCertificate, MetadataBLOBHeader header) + throws IOException, CertificateException { + if (header.getX5u().isPresent()) { + final URL x5u = header.getX5u().get(); + if (blobUrl != null + && (!(x5u.getHost().equals(blobUrl.getHost()) + && x5u.getProtocol().equals(blobUrl.getProtocol()) + && x5u.getPort() == blobUrl.getPort()))) { + throw new IllegalArgumentException( + String.format( + "x5u in BLOB header must have same origin as the URL the BLOB was downloaded from. Expected origin of: %s ; found: %s", + blobUrl, x5u)); + } + List certs = new ArrayList<>(); + for (String pem : + new String(download(x5u).getBytes(), StandardCharsets.UTF_8) + .trim() + .split("\\n+-----END CERTIFICATE-----\\n+-----BEGIN CERTIFICATE-----\\n+")) { + X509Certificate x509Certificate = CertificateParser.parsePem(pem); + certs.add(x509Certificate); + } + return certs; + } else if (header.getX5c().isPresent()) { + return header.getX5c().get(); + } else { + return Collections.singletonList(trustRootCertificate); + } + } } From efa4b009d88c3788fa19acdae407a816aeb6746a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Oct 2024 21:01:50 +0100 Subject: [PATCH 107/132] Fetch CRLDistributionPoints in FidoMetadataDownloader --- webauthn-server-attestation/build.gradle.kts | 10 ++- ...idoMetadataDownloaderIntegrationTest.scala | 43 ++++++++++- .../fido/metadata/FidoMetadataDownloader.java | 75 ++++++++++++++++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/webauthn-server-attestation/build.gradle.kts b/webauthn-server-attestation/build.gradle.kts index 1748835d2..590e42ad7 100644 --- a/webauthn-server-attestation/build.gradle.kts +++ b/webauthn-server-attestation/build.gradle.kts @@ -48,7 +48,12 @@ dependencies { testImplementation("org.scalatestplus:junit-4-13_2.13") testImplementation("org.scalatestplus:scalacheck-1-16_2.13") - testImplementation("org.slf4j:slf4j-api") + testImplementation("org.slf4j:slf4j-api") { + version { + strictly("[1.7.25,1.8-a)") // Pre-1.8 version required by slf4j-test + } + } + testRuntimeOnly("uk.org.lidalia:slf4j-test") } val integrationTest = task("integrationTest") { @@ -58,9 +63,6 @@ val integrationTest = task("integrationTest") { testClassesDirs = sourceSets["integrationTest"].output.classesDirs classpath = sourceSets["integrationTest"].runtimeClasspath shouldRunAfter(tasks.test) - - // Required for processing CRL distribution points extension - systemProperty("com.sun.security.enableCRLDP", "true") } tasks["check"].dependsOn(integrationTest) diff --git a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala index 937a0db8c..a2d01fc09 100644 --- a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala +++ b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala @@ -1,5 +1,7 @@ package com.yubico.fido.metadata +import com.yubico.internal.util.CertificateParser +import com.yubico.webauthn.data.ByteArray import org.junit.runner.RunWith import org.scalatest.BeforeAndAfter import org.scalatest.funspec.AnyFunSpec @@ -8,7 +10,8 @@ import org.scalatest.tags.Network import org.scalatest.tags.Slow import org.scalatestplus.junit.JUnitRunner -import java.util.Optional +import scala.jdk.CollectionConverters.ListHasAsScala +import scala.jdk.OptionConverters.RichOption import scala.util.Success import scala.util.Try @@ -21,6 +24,9 @@ class FidoMetadataDownloaderIntegrationTest with BeforeAndAfter { describe("FidoMetadataDownloader with default settings") { + // Cache downloaded items to avoid cause unnecessary load on remote servers + var trustRootCache: Option[ByteArray] = None + var blobCache: Option[ByteArray] = None val downloader = FidoMetadataDownloader .builder() @@ -28,17 +34,46 @@ class FidoMetadataDownloaderIntegrationTest "Retrieval and use of this BLOB indicates acceptance of the appropriate agreement located at https://fidoalliance.org/metadata/metadata-legal-terms/" ) .useDefaultTrustRoot() - .useTrustRootCache(() => Optional.empty(), _ => {}) + .useTrustRootCache( + () => trustRootCache.toJava, + trustRoot => { trustRootCache = Some(trustRoot) }, + ) .useDefaultBlob() - .useBlobCache(() => Optional.empty(), _ => {}) + .useBlobCache( + () => blobCache.toJava, + blob => { blobCache = Some(blob) }, + ) .build() it("downloads and verifies the root cert and BLOB successfully.") { - // This test requires the system property com.sun.security.enableCRLDP=true val blob = Try(downloader.loadCachedBlob) blob shouldBe a[Success[_]] blob.get should not be null } + + it( + "does not encounter any CRLDistributionPoints entries in unknown format." + ) { + val blob = Try(downloader.loadCachedBlob) + blob shouldBe a[Success[_]] + val trustRootCert = + CertificateParser.parseDer(trustRootCache.get.getBytes) + val certChain = downloader + .fetchHeaderCertChain( + trustRootCert, + FidoMetadataDownloader.parseBlob(blobCache.get).getBlob.getHeader, + ) + .asScala :+ trustRootCert + for { cert <- certChain } { + withClue( + s"Unknown CRLDistributionPoints structure in cert [${cert.getSubjectX500Principal}] : ${new ByteArray(cert.getEncoded)}" + ) { + CertificateParser + .parseCrlDistributionPointsExtension(cert) + .isAnyDistributionPointUnsupported should be(false) + } + } + } } } diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index 0dffab20f..90d550285 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -29,6 +29,7 @@ import com.yubico.fido.metadata.FidoMetadataDownloaderException.Reason; import com.yubico.internal.util.BinaryUtil; import com.yubico.internal.util.CertificateParser; +import com.yubico.internal.util.OptionalUtil; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.exception.Base64UrlException; import com.yubico.webauthn.data.exception.HexException; @@ -54,6 +55,7 @@ import java.security.Signature; import java.security.SignatureException; import java.security.cert.CRL; +import java.security.cert.CRLException; import java.security.cert.CertPath; import java.security.cert.CertPathValidator; import java.security.cert.CertPathValidatorException; @@ -1131,13 +1133,18 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo if (certStore != null) { pathParams.addCertStore(certStore); } + + // Parse CRLDistributionPoints ourselves so users don't have to set the + // `com.sun.security.enableCRLDP=true` system property + fetchCrlDistributionPoints(certChain, certFactory).ifPresent(pathParams::addCertStore); + pathParams.setDate(Date.from(clock.instant())); cpv.validate(blobCertPath, pathParams); return parseResult.blob; } - private static ParseResult parseBlob(ByteArray jwt) throws IOException, Base64UrlException { + static ParseResult parseBlob(ByteArray jwt) throws IOException, Base64UrlException { Scanner s = new Scanner(new ByteArrayInputStream(jwt.getBytes())).useDelimiter("\\."); final ByteArray jwtHeader = ByteArray.fromBase64Url(s.next()); final ByteArray jwtPayload = ByteArray.fromBase64Url(s.next()); @@ -1176,7 +1183,7 @@ private static ByteArray verifyHash(ByteArray contents, Set acceptedC } @Value - private static class ParseResult { + static class ParseResult { private MetadataBLOB blob; private ByteArray jwtHeader; private ByteArray jwtPayload; @@ -1213,4 +1220,68 @@ List fetchHeaderCertChain( return Collections.singletonList(trustRootCertificate); } } + + /** + * Parse the CRLDistributionPoints extension of each certificate, fetch each distribution point + * and assemble them into a {@link CertStore} ready to be injected into {@link + * PKIXParameters#addCertStore(CertStore)} to provide CRLs for the verification procedure. + * + *

    We do this ourselves so that users don't have to set the `com.sun.security.enableCRLDP=true` + * system property. This is required by the default SUN provider in order to enable + * CRLDistributionPoints resolution. + * + *

    Any CRLDistributionPoints entries in unknown format are ignored and log a warning. + */ + private Optional fetchCrlDistributionPoints( + List certChain, CertificateFactory certFactory) + throws InvalidAlgorithmParameterException, NoSuchAlgorithmException { + final List crlDistributionPointUrls = + certChain.stream() + .flatMap( + cert -> { + log.debug( + "Attempting to parse CRLDistributionPoints extension of cert: {}", + cert.getSubjectX500Principal()); + try { + return CertificateParser.parseCrlDistributionPointsExtension(cert) + .getDistributionPoints() + .stream(); + } catch (Exception e) { + log.warn( + "Failed to parse CRLDistributionPoints extension of cert: {}", + cert.getSubjectX500Principal(), + e); + return Stream.empty(); + } + }) + .collect(Collectors.toList()); + + if (crlDistributionPointUrls.isEmpty()) { + return Optional.empty(); + + } else { + final List crldpCrls = + crlDistributionPointUrls.stream() + .map( + crldpUrl -> { + log.debug("Attempting to download CRL distribution point: {}", crldpUrl); + try { + return Optional.of( + certFactory.generateCRL( + new ByteArrayInputStream(download(crldpUrl).getBytes()))); + } catch (CRLException e) { + log.warn("Failed to import CRL from distribution point: {}", crldpUrl, e); + return Optional.empty(); + } catch (Exception e) { + log.warn("Failed to download CRL distribution point: {}", crldpUrl, e); + return Optional.empty(); + } + }) + .flatMap(OptionalUtil::stream) + .collect(Collectors.toList()); + + return Optional.of( + CertStore.getInstance("Collection", new CollectionCertStoreParameters(crldpCrls))); + } + } } From 811cd63f5b5e6cbb8ffb84ec282dc3e8c7ae41b2 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 31 Oct 2024 21:30:58 +0100 Subject: [PATCH 108/132] Return offsets instead of byte array copies during DER parsing --- .../com/yubico/internal/util/BinaryUtil.java | 42 ++++++++------ .../internal/util/CertificateParser.java | 56 ++++++++++--------- 2 files changed, 55 insertions(+), 43 deletions(-) diff --git a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java index 037be9dec..c34858dbc 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java @@ -261,8 +261,8 @@ private static class ParseDerAnyResult { DerTagClass tagClass; boolean constructed; byte tagValue; - byte[] content; - int nextOffset; + int valueStart; + int valueEnd; } @Value @@ -342,14 +342,15 @@ private static ParseDerAnyResult parseDerAny(@NonNull byte[] der, int offset) { DerTagClass.parse(tag), (tag & 0x20) != 0, (byte) (tag & 0x1f), - Arrays.copyOfRange(der, contentLen.nextOffset, contentEnd), + contentLen.nextOffset, contentEnd); } } /** - * Parse a DER header with the given tag value, constructed bit and tag class, and return a copy - * of the value octets. If any of the three criteria do not match, return empty instead. + * Parse a DER header with the given tag value, constructed bit and tag class, and return the + * start and end offsets of the value octets. If any of the three criteria do not match, return + * empty instead. * * @param der DER source to read from. * @param offset The offset in der from which to start reading. @@ -359,11 +360,12 @@ private static ParseDerAnyResult parseDerAny(@NonNull byte[] der, int offset) { * bit) of the tag octet. * @param expectTagClass The expected tag class. This is the 2 most significant bits of the tag * octet. - * @return A copy of the value octets, if the parsed tag matches expectTag, + * @return The start and end offsets of the value octets, if the parsed tag matches + * expectTag, * constructed and expectTagClass, otherwise empty. {@link * ParseDerResult#nextOffset} is always returned. */ - public static ParseDerResult> parseDerTaggedOrSkip( + public static ParseDerResult> parseDerTaggedOrSkip( @NonNull byte[] der, int offset, byte expectTag, @@ -373,16 +375,16 @@ public static ParseDerResult> parseDerTaggedOrSkip( if (result.tagValue == expectTag && result.constructed == constructed && result.tagClass == expectTagClass) { - return new ParseDerResult<>(Optional.of(result.content), result.nextOffset); + return new ParseDerResult<>(Optional.of(result.valueStart), result.valueEnd); } else { - return new ParseDerResult<>(Optional.empty(), result.nextOffset); + return new ParseDerResult<>(Optional.empty(), result.valueEnd); } } /** - * Parse a DER header with the given tag value, constructed bit and tag class, and return a copy - * of the value octets. If any of the three criteria do not match, throw an {@link - * IllegalArgumentException}. + * Parse a DER header with the given tag value, constructed bit and tag class, and return the + * start and end offsets of the value octets. If any of the three criteria do not match, throw an + * {@link IllegalArgumentException}. * * @param der DER source to read from. * @param offset The offset in der from which to start reading. @@ -392,11 +394,12 @@ public static ParseDerResult> parseDerTaggedOrSkip( * bit) of the tag octet. * @param expectTagClass The expected tag class. This is the 2 most significant bits of the tag * octet. - * @return A copy of the value octets, if the parsed tag matches expectTag, + * @return The start and end offsets of the value octets, if the parsed tag matches + * expectTag, * constructed and expectTagClass, otherwise empty. {@link * ParseDerResult#nextOffset} is always returned. */ - private static ParseDerResult parseDerTagged( + private static ParseDerResult parseDerTagged( @NonNull byte[] der, int offset, byte expectTag, @@ -406,7 +409,7 @@ private static ParseDerResult parseDerTagged( if (result.tagValue == expectTag) { if (result.constructed == constructed) { if (result.tagClass == expectTagClass) { - return new ParseDerResult<>(result.content, result.nextOffset); + return new ParseDerResult<>(result.valueStart, result.valueEnd); } else { throw new IllegalArgumentException( String.format( @@ -476,16 +479,19 @@ public static ParseDerResult> parseDerSequenceContents( */ public static ParseDerResult> parseDerSequence( @NonNull byte[] der, int offset, @NonNull ParseDerSequenceElementFunction parseElement) { - final ParseDerResult seq = + final ParseDerResult seq = parseDerTagged(der, offset, (byte) 0x10, true, DerTagClass.UNIVERSAL); final ParseDerResult> res = - parseDerSequenceContents(seq.result, 0, seq.result.length, parseElement); + parseDerSequenceContents(der, seq.result, seq.nextOffset, parseElement); return new ParseDerResult<>(res.result, seq.nextOffset); } /** Parse an Octet String. */ public static ParseDerResult parseDerOctetString(@NonNull byte[] der, int offset) { - return parseDerTagged(der, offset, (byte) 0x04, false, DerTagClass.UNIVERSAL); + ParseDerResult res = + parseDerTagged(der, offset, (byte) 0x04, false, DerTagClass.UNIVERSAL); + return new ParseDerResult<>( + Arrays.copyOfRange(der, res.result, res.nextOffset), res.nextOffset); } public static byte[] encodeDerObjectId(@NonNull byte[] oid) { diff --git a/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java b/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java index 9703a773c..f0314f0c8 100755 --- a/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java @@ -208,30 +208,31 @@ public static ParseCrlDistributionPointsExtensionResult parseCrlDistributionPoin (innerSequenceDer, distributionPointChoiceOffset) -> { // DistributionPoint ::= SEQUENCE { // distributionPoint [0] DistributionPointName OPTIONAL, - final BinaryUtil.ParseDerResult> dpElement = + final BinaryUtil.ParseDerResult> dpElementOffsets = BinaryUtil.parseDerTaggedOrSkip( innerSequenceDer, distributionPointChoiceOffset, (byte) 0, true, BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); - if (dpElement.result.isPresent()) { + if (dpElementOffsets.result.isPresent()) { // DistributionPointName ::= CHOICE { // fullName [0] GeneralNames, - final BinaryUtil.ParseDerResult> dpNameElement = - BinaryUtil.parseDerTaggedOrSkip( - dpElement.result.get(), - 0, - (byte) 0, - true, - BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + final BinaryUtil.ParseDerResult> + dpNameElementOffsets = + BinaryUtil.parseDerTaggedOrSkip( + innerSequenceDer, + dpElementOffsets.result.get(), + (byte) 0, + true, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); - if (dpNameElement.result.isPresent()) { + if (dpNameElementOffsets.result.isPresent()) { return BinaryUtil.parseDerSequenceContents( - dpNameElement.result.get(), - 0, - dpNameElement.result.get().length, + innerSequenceDer, + dpNameElementOffsets.result.get(), + dpNameElementOffsets.nextOffset, (generalNamesDer, generalNamesElementOffset) -> { // fullName [0] GeneralNames, // GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName @@ -243,21 +244,26 @@ public static ParseCrlDistributionPointsExtensionResult parseCrlDistributionPoin // https://datatracker.ietf.org/doc/html/rfc5280#appendix-A.2 // so the SEQUENCE tag in GeneralNames is implicit. // The IA5String tag is also implicit from the CHOICE tag. - final BinaryUtil.ParseDerResult> generalName = - BinaryUtil.parseDerTaggedOrSkip( - generalNamesDer, - generalNamesElementOffset, - (byte) 6, - false, - BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); - if (generalName.result.isPresent()) { + final BinaryUtil.ParseDerResult> + generalNameOffsets = + BinaryUtil.parseDerTaggedOrSkip( + generalNamesDer, + generalNamesElementOffset, + (byte) 6, + false, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + if (generalNameOffsets.result.isPresent()) { String uriString = new String( - generalName.result.get(), StandardCharsets.US_ASCII); + Arrays.copyOfRange( + generalNamesDer, + generalNameOffsets.result.get(), + generalNameOffsets.nextOffset), + StandardCharsets.US_ASCII); try { return new BinaryUtil.ParseDerResult<>( Optional.of(new URL(uriString)), - generalName.nextOffset); + generalNameOffsets.nextOffset); } catch (MalformedURLException e) { throw new IllegalArgumentException( String.format( @@ -267,7 +273,7 @@ public static ParseCrlDistributionPointsExtensionResult parseCrlDistributionPoin } } else { return new BinaryUtil.ParseDerResult<>( - Optional.empty(), generalName.nextOffset); + Optional.empty(), generalNameOffsets.nextOffset); } }); } @@ -275,7 +281,7 @@ public static ParseCrlDistributionPointsExtensionResult parseCrlDistributionPoin // Ignore all other forms of distribution points return new BinaryUtil.ParseDerResult<>( - Collections.emptyList(), dpElement.nextOffset); + Collections.emptyList(), dpElementOffsets.nextOffset); })); return distributionPoints.result.stream() From fc6d4251a4cb3621c74ddb05c69f9a9d5a7dafcb Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 12 Dec 2024 20:35:03 +0100 Subject: [PATCH 109/132] Update JavaDoc to reflect CRLDistributionPoints improvements --- .../fido/metadata/FidoMetadataDownloader.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index 90d550285..2a0cfbc0e 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -545,9 +545,9 @@ public FidoMetadataDownloaderBuilder clock(@NonNull Clock clock) { /** * Use the provided CRLs. * - *

    CRLs will also be downloaded from distribution points if the - * com.sun.security.enableCRLDP system property is set to true (assuming the - * use of the {@link CertPathValidator} implementation from the SUN provider). + *

    CRLs will also be downloaded from distribution points for any certificates with a + * CRLDistributionPoints extension, if the extension can be successfully interpreted. A warning + * message will be logged CRLDistributionPoints parsing fails. * * @throws InvalidAlgorithmParameterException if {@link CertStore#getInstance(String, * CertStoreParameters)} does. @@ -563,9 +563,9 @@ public FidoMetadataDownloaderBuilder useCrls(@NonNull Collection crls) /** * Use CRLs in the provided {@link CertStore}. * - *

    CRLs will also be downloaded from distribution points if the - * com.sun.security.enableCRLDP system property is set to true (assuming the - * use of the {@link CertPathValidator} implementation from the SUN provider). + *

    CRLs will also be downloaded from distribution points for any certificates with a + * CRLDistributionPoints extension, if the extension can be successfully interpreted. A warning + * message will be logged CRLDistributionPoints parsing fails. * * @see #useCrls(Collection) */ @@ -693,7 +693,7 @@ public FidoMetadataDownloaderBuilder verifyDownloadsOnly(final boolean verifyDow * @throws InvalidAlgorithmParameterException if certificate path validation fails. * @throws InvalidKeyException if signature verification fails. * @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm - * is not available. + * or the "Collection" type {@link CertStore} is not available. * @throws SignatureException if signature verification fails. * @throws UnexpectedLegalHeader if the downloaded BLOB (if any) contains a "legalHeader" * value not configured in {@link @@ -796,7 +796,7 @@ public MetadataBLOB loadCachedBlob() * @throws InvalidAlgorithmParameterException if certificate path validation fails. * @throws InvalidKeyException if signature verification fails. * @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm - * is not available. + * or the "Collection" type {@link CertStore} is not available. * @throws SignatureException if signature verification fails. * @throws UnexpectedLegalHeader if the downloaded BLOB (if any) contains a "legalHeader" * value not configured in {@link @@ -968,7 +968,8 @@ private X509Certificate retrieveTrustRootCert() * @throws IOException on failure to parse the BLOB contents. * @throws InvalidAlgorithmParameterException if certificate path validation fails. * @throws InvalidKeyException if signature verification fails. - * @throws NoSuchAlgorithmException if signature verification fails. + * @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm + * or the "Collection" type {@link CertStore} is not available. * @throws SignatureException if signature verification fails. * @throws FidoMetadataDownloaderException if the explicitly configured BLOB (if any) has a bad * signature. From 25ccfc2478eb7b248fe58d3202450e65dd225f0d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 12 Dec 2024 20:35:20 +0100 Subject: [PATCH 110/132] Fix code snippet syntax in JavaDoc --- .../com/yubico/fido/metadata/FidoMetadataDownloader.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index 2a0cfbc0e..4ac9e6641 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -1227,9 +1227,9 @@ List fetchHeaderCertChain( * and assemble them into a {@link CertStore} ready to be injected into {@link * PKIXParameters#addCertStore(CertStore)} to provide CRLs for the verification procedure. * - *

    We do this ourselves so that users don't have to set the `com.sun.security.enableCRLDP=true` - * system property. This is required by the default SUN provider in order to enable - * CRLDistributionPoints resolution. + *

    We do this ourselves so that users don't have to set the + * com.sun.security.enableCRLDP=true system property. This is required by the default SUN + * provider in order to enable CRLDistributionPoints resolution. * *

    Any CRLDistributionPoints entries in unknown format are ignored and log a warning. */ From 4adf4cae20d394c4f6cb4719f7c31c946793ff86 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 12 Dec 2024 20:36:20 +0100 Subject: [PATCH 111/132] Remove documentation of com.sun.security.enableCRLDP setting --- webauthn-server-attestation/README.adoc | 11 ----------- webauthn-server-demo/build.gradle.kts | 3 --- 2 files changed, 14 deletions(-) diff --git a/webauthn-server-attestation/README.adoc b/webauthn-server-attestation/README.adoc index f0a86db7a..0307e645f 100644 --- a/webauthn-server-attestation/README.adoc +++ b/webauthn-server-attestation/README.adoc @@ -222,17 +222,6 @@ RegistrationResult result = rp.finishRegistration(/* ... */); Set metadata = mds.findEntries(result); ---------- - 5. If you use the SUN provider for the `PKIX` certificate path validation algorithm, which many deployments do by default: - set the `com.sun.security.enableCRLDP` system property to `true`. - This is required for the SUN `PKIX` provider to support the CRL Distribution Points extension, - which is needed in order to verify the BLOB signature. -+ -For example, this can be done on the JVM command line using a `-Dcom.sun.security.enableCRLDP=true` option. -See the https://docs.oracle.com/javase/9/security/java-pki-programmers-guide.htm#GUID-EB250086-0AC1-4D60-AE2A-FC7461374746__SECTION-139-623E860E[Java PKI Programmers Guide] -for details. -+ -This step may not be necessary if you use a different provider for the `PKIX` certificate path validation algorithm. - == Selecting trusted authenticators diff --git a/webauthn-server-demo/build.gradle.kts b/webauthn-server-demo/build.gradle.kts index 82830c46e..7a77b2347 100644 --- a/webauthn-server-demo/build.gradle.kts +++ b/webauthn-server-demo/build.gradle.kts @@ -55,9 +55,6 @@ dependencies { application { mainClass.set("demo.webauthn.EmbeddedServer") - - // Required for processing CRL distribution points extension - applicationDefaultJvmArgs = listOf("-Dcom.sun.security.enableCRLDP=true") } for (task in listOf(tasks.installDist, tasks.distZip, tasks.distTar)) { From b28e10ecb1cc46a5934667751f599914c126e809 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 12 Dec 2024 20:50:55 +0100 Subject: [PATCH 112/132] Add CRLDistributionPoints feature to NEWS --- NEWS | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/NEWS b/NEWS index 341090cb6..365fad2e6 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,7 @@ == Version 2.6.0 (unreleased) == +`webauthn-server-core`: + New features: * Added method `getParsedPublicKey(): java.security.PublicKey` to @@ -61,6 +63,14 @@ New features: version increase. * (Experimental) Added `credProps` extension to assertion extension outputs. +`webauthn-server-attestation`: + +New features: + +* `FidoMetadataDownloader` now parses the CRLDistributionPoints extension on the + application level, so the `com.sun.security.enableCRLDP=true` system property + setting is no longer necessary. + == Version 2.5.4 == From 81ac11696b01aee912caed6f6e0fd1f9d3330404 Mon Sep 17 00:00:00 2001 From: Dennis Fokin Date: Thu, 19 Dec 2024 14:00:58 +0100 Subject: [PATCH 113/132] Add enterprise attestation serial number helper --- .../webauthn/attestation/CertificateUtil.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java new file mode 100644 index 000000000..cb228d050 --- /dev/null +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -0,0 +1,32 @@ +package com.yubico.webauthn.attestation; + +import java.nio.ByteBuffer; +import java.security.cert.X509Certificate; +import java.util.Optional; + +public class CertificateUtil { + public static final String ID_FIDO_GEN_CE_SERNUM = "1.3.6.1.4.1.45724.1.1.2"; + + private static byte[] parseSerNum(byte[] bytes) { + if (bytes != null) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + + if (buffer.get() == (byte) 0x04 && buffer.get() > 0 && buffer.get() == (byte) 0x04) { + + byte length = buffer.get(); + byte[] serNumBytes = new byte[length]; + buffer.get(serNumBytes); + + return serNumBytes; + } + } + + throw new IllegalArgumentException( + "X.509 extension 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum) is not valid."); + } + + public static Optional parseFidoSerNumExtension(X509Certificate cert) { + return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM)) + .map(CertificateUtil::parseSerNum); + } +} From a119182aa0e8bc9639ec6cc1468b80e33f41f8a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 13:15:39 +0000 Subject: [PATCH 114/132] Bump com.diffplug.spotless:spotless-plugin-gradle from 6.25.0 to 7.0.1 Bumps [com.diffplug.spotless:spotless-plugin-gradle](https://github.com/diffplug/spotless) from 6.25.0 to 7.0.1. - [Release notes](https://github.com/diffplug/spotless/releases) - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/gradle/6.25.0...gradle/7.0.1) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-plugin-gradle dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 2efaa001c..c758d36c0 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.25.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:7.0.1") implementation("io.github.cosmicsilence:gradle-scalafix:0.2.2") } } From 1f0a7415de6fdb86228d811cb7694777dc0aa535 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 14 Jan 2025 14:58:25 +0100 Subject: [PATCH 115/132] Remove self-contradicting test assertion This variable is named `goodResult`, implying that the verification should be a success, but the assertion checks that the verification should be a failure. The arguments to `verifyX5cRequirements` are the same as above for the `result` variable with `badCert`. This code is probably a copy-paste error. --- .../com/yubico/webauthn/RelyingPartyRegistrationSpec.scala | 7 ------- 1 file changed, 7 deletions(-) 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 618a7e3d5..297ec21f0 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 @@ -2051,13 +2051,6 @@ class RelyingPartyRegistrationSpec 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, From 07c9b32a8db51c9f3e95f7ca8e1886508c47d169 Mon Sep 17 00:00:00 2001 From: Dennis Fokin Date: Wed, 15 Jan 2025 09:25:04 +0100 Subject: [PATCH 116/132] Add tests for parseFidoSerNumExtenstion --- .../attestation/CertificateUtilSpec.scala | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala new file mode 100644 index 000000000..9cb4bbb1d --- /dev/null +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -0,0 +1,44 @@ +package com.yubico.webauthn.attestation + +import com.yubico.webauthn.TestAuthenticator +import com.yubico.webauthn.data.ByteArray +import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.x500.X500Name +import org.junit.runner.RunWith +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper +import org.scalatestplus.junit.JUnitRunner + +import java.security.cert.X509Certificate + +@RunWith(classOf[JUnitRunner]) +class CertificateUtilSpec extends AnyFunSpec { + describe("parseFidoSerNumExtension") { + val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" + it("should correctly parse the serial number from a valid certificate with the id-fido-gen-ce-sernum extension.") { + val goodCert: X509Certificate = TestAuthenticator + .generateAttestationCertificate( + name = new X500Name( + "O=Yubico, C=SE, OU=Authenticator Attestation" + ), + extensions = List( + ( + idFidoGenCeSernum, + false, + new DEROctetString(Array[Byte](0, 1, 2, 3)), + ) + ), + ) + ._1 + + val result = new ByteArray( + CertificateUtil + .parseFidoSerNumExtension(goodCert) + .get + ) + result.shouldEqual(ByteArray.fromHex("00010203")); + } + + } + +} From 5afe02d985543e0bc95fdb412ff3a3faa2e53af0 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:18:29 +0100 Subject: [PATCH 117/132] Annotate CertificateUtil with @lombok.experimental.UtilityClass --- .../java/com/yubico/webauthn/attestation/CertificateUtil.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java index cb228d050..b15b9c02b 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -3,7 +3,9 @@ import java.nio.ByteBuffer; import java.security.cert.X509Certificate; import java.util.Optional; +import lombok.experimental.UtilityClass; +@UtilityClass public class CertificateUtil { public static final String ID_FIDO_GEN_CE_SERNUM = "1.3.6.1.4.1.45724.1.1.2"; From a6a4c965146fba110a07b4618fb7e518f60ea81a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:37:37 +0100 Subject: [PATCH 118/132] Use should-Matchers --- .../yubico/webauthn/attestation/CertificateUtilSpec.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index 9cb4bbb1d..ef3ad4337 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -6,13 +6,13 @@ import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.x500.X500Name import org.junit.runner.RunWith import org.scalatest.funspec.AnyFunSpec -import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import java.security.cert.X509Certificate @RunWith(classOf[JUnitRunner]) -class CertificateUtilSpec extends AnyFunSpec { +class CertificateUtilSpec extends AnyFunSpec with Matchers { describe("parseFidoSerNumExtension") { val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" it("should correctly parse the serial number from a valid certificate with the id-fido-gen-ce-sernum extension.") { @@ -36,7 +36,7 @@ class CertificateUtilSpec extends AnyFunSpec { .parseFidoSerNumExtension(goodCert) .get ) - result.shouldEqual(ByteArray.fromHex("00010203")); + result should equal(ByteArray.fromHex("00010203")) } } From 1124ea5b3e81c0450abf8cdc6f76d5a3e78674ab Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:43:29 +0100 Subject: [PATCH 119/132] Add test of parseFidoSerNumExtension with real enterprise attestation cert --- .../attestation/CertificateUtilSpec.scala | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index ef3ad4337..4ba68408a 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -1,5 +1,7 @@ package com.yubico.webauthn.attestation +import com.yubico.internal.util.BinaryUtil +import com.yubico.internal.util.CertificateParser import com.yubico.webauthn.TestAuthenticator import com.yubico.webauthn.data.ByteArray import org.bouncycastle.asn1.DEROctetString @@ -10,6 +12,7 @@ import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import java.security.cert.X509Certificate +import scala.jdk.OptionConverters.RichOptional @RunWith(classOf[JUnitRunner]) class CertificateUtilSpec extends AnyFunSpec with Matchers { @@ -39,6 +42,36 @@ class CertificateUtilSpec extends AnyFunSpec with Matchers { result should equal(ByteArray.fromHex("00010203")) } - } + it("correctly parses the serial number from a real YubiKey enterprise attestation certificate.") { + val cert = CertificateParser.parsePem("""-----BEGIN CERTIFICATE----- + |MIIC8zCCAdugAwIBAgIJAKr/KiUzkKrgMA0GCSqGSIb3DQEBCwUAMC8xLTArBgNV + |BAMMJFl1YmljbyBGSURPIFJvb3QgQ0EgU2VyaWFsIDQ1MDIwMzU1NjAgFw0yNDA1 + |MDEwMDAwMDBaGA8yMDYwMDQzMDAwMDAwMFowcDELMAkGA1UEBhMCU0UxEjAQBgNV + |BAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlv + |bjEpMCcGA1UEAwwgWXViaWNvIEZpZG8gRUUgKFNlcmlhbD0yODI5OTAwMykwWTAT + |BgcqhkjOPQIBBggqhkjOPQMBBwNCAATImNkI1cwqkW5B3qNrY3pc8zBLhvGyfyfS + |WCLrODSe8xaRPcZoXYGGwZ0Ua/Hp5nxyD+w1hjS9O9gx8mSDvp+zo4GZMIGWMBMG + |CisGAQQBgsQKDQEEBQQDBQcBMBUGCysGAQQBguUcAQECBAYEBAGvzvswIgYJKwYB + |BAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMC + |AiQwIQYLKwYBBAGC5RwBAQQEEgQQuQ59wTFuT+6iWlamZqZw/jAMBgNVHRMBAf8E + |AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAFEMXw1HUDC/TfMFxp2ZrmgQLa5fmzs2Jh + |C22TUAuY26CYT5dmMUsS5aJd96MtC5gKS57h1auGr2Y4FMxQS9FJHzXAzAtYJfKh + |j1uS2BSTXf9GULdFKcWvvv50kJ2VmXLge3UgHDBJ8LwrDlZFyISeMZ8jSbmrNu2c + |8uNBBSfqdor+5H91L1brC9yYneHdxYk6YiEvDBxWjiMa9DQuySh/4a21nasgt0cB + |prEbfFOLRDm7GDsRTPyefZjZ84yi4Ao+15x+7DM0UwudEVtjOWB2BJtJyxIkXXNF + |iWFZaxezq0Xt2Kl2sYnMR97ynw/U4TzZDjgb56pN81oKz8Od9B/u + |-----END CERTIFICATE-----""".stripMargin) + + val result = + CertificateUtil + .parseFidoSerNumExtension(cert) + .toScala + .map(new ByteArray(_)) + result should equal(Some(ByteArray.fromHex("01AFCEFB"))) + + // For YubiKeys, the sernum octet string represents a big-endian integer + BinaryUtil.getUint32(result.get.getBytes) should be(28299003) + } + } } From 3f9adac4f5435eb7f07d1d4b2af43f2cef9dd5f6 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:45:10 +0100 Subject: [PATCH 120/132] Use Option.map instead of Optional.get --- .../webauthn/attestation/CertificateUtilSpec.scala | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index 4ba68408a..c8ad7d7db 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -19,7 +19,7 @@ class CertificateUtilSpec extends AnyFunSpec with Matchers { describe("parseFidoSerNumExtension") { val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" it("should correctly parse the serial number from a valid certificate with the id-fido-gen-ce-sernum extension.") { - val goodCert: X509Certificate = TestAuthenticator + val (cert, _): (X509Certificate, _) = TestAuthenticator .generateAttestationCertificate( name = new X500Name( "O=Yubico, C=SE, OU=Authenticator Attestation" @@ -32,14 +32,13 @@ class CertificateUtilSpec extends AnyFunSpec with Matchers { ) ), ) - ._1 - val result = new ByteArray( + val result = CertificateUtil - .parseFidoSerNumExtension(goodCert) - .get - ) - result should equal(ByteArray.fromHex("00010203")) + .parseFidoSerNumExtension(cert) + .toScala + .map(new ByteArray(_)) + result should equal(Some(ByteArray.fromHex("00010203"))) } it("correctly parses the serial number from a real YubiKey enterprise attestation certificate.") { From c7a2dc59a090b91feae9f21dde0a0bef54382c5f Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:49:37 +0100 Subject: [PATCH 121/132] Shorten test name and rephrase to stating expected behaviour --- .../com/yubico/webauthn/attestation/CertificateUtilSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index c8ad7d7db..79bbdae0d 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -18,7 +18,7 @@ import scala.jdk.OptionConverters.RichOptional class CertificateUtilSpec extends AnyFunSpec with Matchers { describe("parseFidoSerNumExtension") { val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" - it("should correctly parse the serial number from a valid certificate with the id-fido-gen-ce-sernum extension.") { + it("correctly parses the id-fido-gen-ce-sernum extension.") { val (cert, _): (X509Certificate, _) = TestAuthenticator .generateAttestationCertificate( name = new X500Name( From ee6b44da3063b698c8ebd2c10b43fb68d9a669a3 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:54:56 +0100 Subject: [PATCH 122/132] Use default cert name --- .../yubico/webauthn/attestation/CertificateUtilSpec.scala | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index 79bbdae0d..d6a676a25 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -5,7 +5,6 @@ import com.yubico.internal.util.CertificateParser import com.yubico.webauthn.TestAuthenticator import com.yubico.webauthn.data.ByteArray import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.x500.X500Name import org.junit.runner.RunWith import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers @@ -21,16 +20,13 @@ class CertificateUtilSpec extends AnyFunSpec with Matchers { it("correctly parses the id-fido-gen-ce-sernum extension.") { val (cert, _): (X509Certificate, _) = TestAuthenticator .generateAttestationCertificate( - name = new X500Name( - "O=Yubico, C=SE, OU=Authenticator Attestation" - ), extensions = List( ( idFidoGenCeSernum, false, new DEROctetString(Array[Byte](0, 1, 2, 3)), ) - ), + ) ) val result = From afd34634bb1bf7ff1c74f4f0a80f8248b05371b4 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:55:26 +0100 Subject: [PATCH 123/132] Add test of parseFidoSerNumExtension on cert without sernum extension --- .../webauthn/attestation/CertificateUtilSpec.scala | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index d6a676a25..ac11d1481 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -17,6 +17,7 @@ import scala.jdk.OptionConverters.RichOptional class CertificateUtilSpec extends AnyFunSpec with Matchers { describe("parseFidoSerNumExtension") { val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" + it("correctly parses the id-fido-gen-ce-sernum extension.") { val (cert, _): (X509Certificate, _) = TestAuthenticator .generateAttestationCertificate( @@ -37,6 +38,16 @@ class CertificateUtilSpec extends AnyFunSpec with Matchers { result should equal(Some(ByteArray.fromHex("00010203"))) } + it("returns empty when cert has no id-fido-gen-ce-sernum extension.") { + val (cert, _): (X509Certificate, _) = + TestAuthenticator.generateAttestationCertificate(extensions = Nil) + val result = + CertificateUtil + .parseFidoSerNumExtension(cert) + .toScala + result should be(None) + } + it("correctly parses the serial number from a real YubiKey enterprise attestation certificate.") { val cert = CertificateParser.parsePem("""-----BEGIN CERTIFICATE----- |MIIC8zCCAdugAwIBAgIJAKr/KiUzkKrgMA0GCSqGSIb3DQEBCwUAMC8xLTArBgNV From afc1cc9489937dd81f01599dae6331db08d02e6e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 18:56:22 +0100 Subject: [PATCH 124/132] Add license headers --- .../webauthn/attestation/CertificateUtil.java | 24 +++++++++++++++++++ .../attestation/CertificateUtilSpec.scala | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java index b15b9c02b..3060ebcfe 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -1,3 +1,27 @@ +// Copyright (c) 2024, 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.attestation; import java.nio.ByteBuffer; diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index ac11d1481..e95e41910 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -1,3 +1,27 @@ +// Copyright (c) 2024, 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.attestation import com.yubico.internal.util.BinaryUtil From 91a8015b1da2be931d8b1d5917840824407be149 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 23:43:12 +0100 Subject: [PATCH 125/132] Add ScalaCheck shrinker for ByteArray --- .../com/yubico/webauthn/data/Generators.scala | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 8a98c17b4..787d90159 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 @@ -52,6 +52,7 @@ import com.yubico.webauthn.extension.uvm.Generators.userVerificationMethod import org.scalacheck.Arbitrary import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen +import org.scalacheck.Shrink import java.net.URL import java.security.interfaces.ECPublicKey @@ -349,6 +350,35 @@ object Generators { implicit val arbitraryByteArray: Arbitrary[ByteArray] = Arbitrary( arbitrary[Array[Byte]].map(new ByteArray(_)) ) + implicit val shrinkByteArray: Shrink[ByteArray] = Shrink({ b => + // Attempt to remove as much as possible at a time: first the back half, then the back 1/4, then the back 1/8, etc. + val prefixes = Stream.unfold(0) { len => + val nextLen = (len + b.size()) / 2 + if (nextLen == len || nextLen == b.size()) { + None + } else { + Some((new ByteArray(b.getBytes.slice(0, nextLen)), nextLen)) + } + } + + // Same but removing from the front instead. + val suffixes = Stream.unfold(0) { len => + val nextLen = (len + b.size()) / 2 + if (nextLen == len || nextLen == b.size()) { + None + } else { + Some( + ( + new ByteArray(b.getBytes.slice(b.size() - nextLen, b.size())), + nextLen, + ) + ) + } + } + + prefixes concat suffixes + }) + def byteArray(maxSize: Int): Gen[ByteArray] = Gen.listOfN(maxSize, arbitrary[Byte]).map(ba => new ByteArray(ba.toArray)) From 47cabc2ee138511f5da13f7ed9a2d6b5b5b2dba3 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 16 Jan 2025 11:15:03 +0100 Subject: [PATCH 126/132] Use generated byte arrays in synthetic test of parseFidoSerNumExtension --- .../attestation/CertificateUtilSpec.scala | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index e95e41910..48ccf5580 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -28,38 +28,52 @@ import com.yubico.internal.util.BinaryUtil import com.yubico.internal.util.CertificateParser import com.yubico.webauthn.TestAuthenticator import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.Generators.arbitraryByteArray +import com.yubico.webauthn.data.Generators.shrinkByteArray import org.bouncycastle.asn1.DEROctetString import org.junit.runner.RunWith import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks import java.security.cert.X509Certificate import scala.jdk.OptionConverters.RichOptional @RunWith(classOf[JUnitRunner]) -class CertificateUtilSpec extends AnyFunSpec with Matchers { +class CertificateUtilSpec + extends AnyFunSpec + with Matchers + with ScalaCheckDrivenPropertyChecks { describe("parseFidoSerNumExtension") { val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" it("correctly parses the id-fido-gen-ce-sernum extension.") { - val (cert, _): (X509Certificate, _) = TestAuthenticator - .generateAttestationCertificate( - extensions = List( - ( - idFidoGenCeSernum, - false, - new DEROctetString(Array[Byte](0, 1, 2, 3)), + forAll( + // 500-byte long serial numbers are not realistic, but would be valid DER data. + sizeRange(500) + ) { + // Using Array[Byte] here causes an (almost) infinite loop in the shrinker in case of failure. + // See: https://github.com/typelevel/scalacheck/issues/968#issuecomment-2594018791 + sernum: ByteArray => + val (cert, _): (X509Certificate, _) = TestAuthenticator + .generateAttestationCertificate( + extensions = List( + ( + idFidoGenCeSernum, + false, + new DEROctetString(sernum.getBytes), + ) + ) ) - ) - ) - val result = - CertificateUtil - .parseFidoSerNumExtension(cert) - .toScala - .map(new ByteArray(_)) - result should equal(Some(ByteArray.fromHex("00010203"))) + val result = + CertificateUtil + .parseFidoSerNumExtension(cert) + .toScala + .map(new ByteArray(_)) + result should equal(Some(sernum)) + } } it("returns empty when cert has no id-fido-gen-ce-sernum extension.") { From a466021b95c741c054c64c9cc12be96eae760086 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 15 Jan 2025 23:56:44 +0100 Subject: [PATCH 127/132] Use BinaryUtil.parseDerOctetString in CertificateUtil.parseSerNum --- .../webauthn/attestation/CertificateUtil.java | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java index 3060ebcfe..c5c8618f4 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -24,7 +24,7 @@ package com.yubico.webauthn.attestation; -import java.nio.ByteBuffer; +import com.yubico.internal.util.BinaryUtil; import java.security.cert.X509Certificate; import java.util.Optional; import lombok.experimental.UtilityClass; @@ -34,21 +34,14 @@ public class CertificateUtil { public static final String ID_FIDO_GEN_CE_SERNUM = "1.3.6.1.4.1.45724.1.1.2"; private static byte[] parseSerNum(byte[] bytes) { - if (bytes != null) { - ByteBuffer buffer = ByteBuffer.wrap(bytes); - - if (buffer.get() == (byte) 0x04 && buffer.get() > 0 && buffer.get() == (byte) 0x04) { - - byte length = buffer.get(); - byte[] serNumBytes = new byte[length]; - buffer.get(serNumBytes); - - return serNumBytes; - } + try { + byte[] extensionValueContents = BinaryUtil.parseDerOctetString(bytes, 0).result; + byte[] sernumContents = BinaryUtil.parseDerOctetString(extensionValueContents, 0).result; + return sernumContents; + } catch (Exception e) { + throw new IllegalArgumentException( + "X.509 extension 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum) is not valid.", e); } - - throw new IllegalArgumentException( - "X.509 extension 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum) is not valid."); } public static Optional parseFidoSerNumExtension(X509Certificate cert) { From d181f752eb9e629d566b85f9272ef4d8d834f23a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 16 Jan 2025 00:17:49 +0100 Subject: [PATCH 128/132] Return ByteArray from parseFidoSerNumExtension --- .../com/yubico/webauthn/attestation/CertificateUtil.java | 6 ++++-- .../yubico/webauthn/attestation/CertificateUtilSpec.scala | 2 -- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java index c5c8618f4..0675f0bbb 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -25,6 +25,7 @@ package com.yubico.webauthn.attestation; import com.yubico.internal.util.BinaryUtil; +import com.yubico.webauthn.data.ByteArray; import java.security.cert.X509Certificate; import java.util.Optional; import lombok.experimental.UtilityClass; @@ -44,8 +45,9 @@ private static byte[] parseSerNum(byte[] bytes) { } } - public static Optional parseFidoSerNumExtension(X509Certificate cert) { + public static Optional parseFidoSerNumExtension(X509Certificate cert) { return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM)) - .map(CertificateUtil::parseSerNum); + .map(CertificateUtil::parseSerNum) + .map(ByteArray::new); } } diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index 48ccf5580..66ed15479 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -71,7 +71,6 @@ class CertificateUtilSpec CertificateUtil .parseFidoSerNumExtension(cert) .toScala - .map(new ByteArray(_)) result should equal(Some(sernum)) } } @@ -110,7 +109,6 @@ class CertificateUtilSpec CertificateUtil .parseFidoSerNumExtension(cert) .toScala - .map(new ByteArray(_)) result should equal(Some(ByteArray.fromHex("01AFCEFB"))) From 482f4a2f498f58ce33e45139fe50987f2749c1d7 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 16 Jan 2025 00:18:49 +0100 Subject: [PATCH 129/132] Rename parseFidoSerNumExtension to parseFidoSernumExtension "sernum" feels like an atomic term in this case, especially with the background of the OID label `id-fido-gen-ce-sernum`. --- .../com/yubico/webauthn/attestation/CertificateUtil.java | 2 +- .../yubico/webauthn/attestation/CertificateUtilSpec.scala | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java index 0675f0bbb..8a57dbf49 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -45,7 +45,7 @@ private static byte[] parseSerNum(byte[] bytes) { } } - public static Optional parseFidoSerNumExtension(X509Certificate cert) { + public static Optional parseFidoSernumExtension(X509Certificate cert) { return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM)) .map(CertificateUtil::parseSerNum) .map(ByteArray::new); diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala index 66ed15479..099d5bc83 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -69,7 +69,7 @@ class CertificateUtilSpec val result = CertificateUtil - .parseFidoSerNumExtension(cert) + .parseFidoSernumExtension(cert) .toScala result should equal(Some(sernum)) } @@ -80,7 +80,7 @@ class CertificateUtilSpec TestAuthenticator.generateAttestationCertificate(extensions = Nil) val result = CertificateUtil - .parseFidoSerNumExtension(cert) + .parseFidoSernumExtension(cert) .toScala result should be(None) } @@ -107,7 +107,7 @@ class CertificateUtilSpec val result = CertificateUtil - .parseFidoSerNumExtension(cert) + .parseFidoSernumExtension(cert) .toScala result should equal(Some(ByteArray.fromHex("01AFCEFB"))) From 4cb64e3d102c2e1f6591c13e8af862b31e0bfe5e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 16 Jan 2025 00:22:47 +0100 Subject: [PATCH 130/132] Add JavaDoc and README docs parseFidoSernumExtension --- webauthn-server-attestation/README.adoc | 28 ++++++++++++++- .../webauthn/attestation/CertificateUtil.java | 34 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/webauthn-server-attestation/README.adoc b/webauthn-server-attestation/README.adoc index 0307e645f..fd3c34ca3 100644 --- a/webauthn-server-attestation/README.adoc +++ b/webauthn-server-attestation/README.adoc @@ -9,6 +9,7 @@ An optional module which extends link:../[`webauthn-server-core`] with a trust root source for verifying https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-attestation[attestation statements], by interfacing with the https://fidoalliance.org/metadata/[FIDO Metadata Service]. +The module also provides helper functions for inspecting properties of attestation certificates. *Table of contents* @@ -17,7 +18,7 @@ toc::[] == Features -This module does four things: +The FIDO MDS integration does four things: - Download, verify and cache metadata BLOBs from the FIDO Metadata Service. - Re-download the metadata BLOB when out of date or invalid. @@ -414,3 +415,28 @@ link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server- class uses `CertPathValidator.getInstance("PKIX")` to retrieve a `CertPathValidator` instance. If you need to override any aspect of certificate path validation, such as CRL retrieval or OCSP, you may provide a custom `CertPathValidator` provider for the `"PKIX"` algorithm. + + +== Using enterprise attestation + +link:https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-attestationconveyancepreference-enterprise[Enterprise attestation] +is the idea of having attestation statements contain a unique identifier such as a device serial number. +For example, this identifier could be used by an employer provisioning security keys for their employees. +By recording which employee has which security key serial numbers, +the employer can automatically trust the employee upon successful WebAuthn registration +without having to first authenticate the employee by other means. + +Because enterprise attestation by design introduces powerful user tracking, +it is only allowed in certain contexts and is otherwise blocked by the client. +See the +link:https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-feature-descriptions-enterp-attstn[CTAP2 section on Enterprise Attestation] +for guidance on how to enable enterprise attestation - +this typically involves a special agreement with an authenticator or client vendor. + +At time of writing, there is only one standardized way to convey an enterprise attestation identifer: + +- An X.509 certificate extension with OID `1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum)` + MAY indicate a unique octet string such as a serial number + see + https://w3c.github.io/webauthn/#sctn-enterprise-packed-attestation-cert-requirements[Web Authentication Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements]. + The `CertificateUtil` class provides `parseFidoSernumExtension` helper function for parsing this extension if present. diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java index 8a57dbf49..a91216f53 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -25,7 +25,10 @@ package com.yubico.webauthn.attestation; import com.yubico.internal.util.BinaryUtil; +import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.RelyingParty; import com.yubico.webauthn.data.ByteArray; +import java.nio.ByteBuffer; import java.security.cert.X509Certificate; import java.util.Optional; import lombok.experimental.UtilityClass; @@ -45,6 +48,37 @@ private static byte[] parseSerNum(byte[] bytes) { } } + /** + * Attempt to parse the FIDO enterprise attestation serial number extension from the given + * certificate. + * + *

    NOTE: This function does NOT verify that the returned serial number is authentic and + * trustworthy. See: + * + *

    + * + *

    Note that the serial number is an opaque byte array with no defined structure in general. + * For example, the byte array may or may not represent a big-endian integer depending on the + * authenticator vendor. + * + *

    The extension has OID 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum). + * + * @param cert the attestation certificate to parse the serial number from. + * @return The serial number, if present and validly encoded. Empty if the extension is not + * present in the certificate. + * @throws IllegalArgumentException if the extension is present but not validly encoded. + * @see RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource) + * @see RegistrationResult#isAttestationTrusted() + * @see RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean) + * @see WebAuthn + * Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements + * @see ByteBuffer#getLong() + */ public static Optional parseFidoSernumExtension(X509Certificate cert) { return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM)) .map(CertificateUtil::parseSerNum) From 1be5dddd8b4f8bdb4645a6f62a271dc16e45935e Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 16 Jan 2025 10:55:08 +0100 Subject: [PATCH 131/132] Add parseFidoSernumExtension to NEWS --- NEWS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS b/NEWS index 2de34f101..df1d21692 100644 --- a/NEWS +++ b/NEWS @@ -68,6 +68,8 @@ New features: * `FidoMetadataDownloader` now parses the CRLDistributionPoints extension on the application level, so the `com.sun.security.enableCRLDP=true` system property setting is no longer necessary. +* Added helper function `CertificateUtil.parseFidoSernumExtension` for parsing + serial number from enterprise attestation certificates. == Version 2.5.4 == From 0cbba5786ce011321c7e8ed73eba15c3b685f78a Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 16 Jan 2025 14:24:28 +0100 Subject: [PATCH 132/132] Revert new experimental interfaces and classes These will be postponed to the next minor version instead. --- NEWS | 31 - .../yubico/webauthn/AssertionResultV2.java | 246 - .../com/yubico/webauthn/CredentialRecord.java | 225 - .../CredentialRepositoryV1ToV2Adapter.java | 43 - .../webauthn/CredentialRepositoryV2.java | 100 - .../yubico/webauthn/FinishAssertionSteps.java | 274 +- .../webauthn/FinishRegistrationSteps.java | 21 +- .../yubico/webauthn/RegisteredCredential.java | 75 +- .../com/yubico/webauthn/RelyingParty.java | 27 +- .../com/yubico/webauthn/RelyingPartyV2.java | 707 --- .../ToPublicKeyCredentialDescriptor.java | 34 - .../yubico/webauthn/UsernameRepository.java | 66 - .../data/PublicKeyCredentialDescriptor.java | 16 +- .../webauthn/RelyingPartyAssertionSpec.scala | 225 +- .../RelyingPartyStartOperationSpec.scala | 890 --- .../RelyingPartyUserIdentificationSpec.scala | 253 - .../RelyingPartyV2AssertionSpec.scala | 2930 ---------- .../RelyingPartyV2RegistrationSpec.scala | 4855 ----------------- .../com/yubico/webauthn/test/Helpers.scala | 198 - .../webauthn/InMemoryRegistrationStorage.java | 104 +- .../java/demo/webauthn/WebAuthnServer.java | 16 +- .../webauthn/data/CredentialRegistration.java | 41 +- 22 files changed, 282 insertions(+), 11095 deletions(-) delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java delete mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java delete mode 100644 webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala delete mode 100644 webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala diff --git a/NEWS b/NEWS index df1d21692..9ac461368 100644 --- a/NEWS +++ b/NEWS @@ -29,37 +29,6 @@ New features: 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. -* (Experimental) Added property `RegisteredCredential.transports`. - ** NOTE: Experimental features may receive breaking changes without a major - version increase. - ** NOTE: Experimental features may receive breaking changes without a major - version increase. `webauthn-server-attestation`: 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 deleted file mode 100644 index 5b027ffbc..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResultV2.java +++ /dev/null @@ -1,246 +0,0 @@ -// 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)}. - * - * @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 { - - /** 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. - * - * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted - * before reaching a mature release. - */ - @Deprecated 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 deleted file mode 100644 index 0b06f1b30..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRecord.java +++ /dev/null @@ -1,225 +0,0 @@ -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; - -/** - * 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. - */ -@Deprecated -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. - */ - @Deprecated - @NonNull - 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. - */ - @Deprecated - @NonNull - 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. - */ - @Deprecated - @NonNull - 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. - */ - @Deprecated - 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. - */ - @Deprecated - Optional> getTransports(); - - // 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. 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. EXPERIMENTAL: This feature is from a not yet mature - * standard; it could change as the standard matures. - */ - @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(); - } - - /** - * 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/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java deleted file mode 100644 index 41e02e876..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV1ToV2Adapter.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.yubico.webauthn; - -import com.yubico.webauthn.data.ByteArray; -import java.util.Collections; -import java.util.Optional; -import java.util.Set; -import lombok.AllArgsConstructor; - -@AllArgsConstructor -class CredentialRepositoryV1ToV2Adapter - implements CredentialRepositoryV2, UsernameRepository { - - private final CredentialRepository inner; - - @Override - public Set getCredentialDescriptorsForUserHandle( - 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); - } - - @Override - public Optional getUsernameForUserHandle(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 deleted file mode 100644 index f9a75e9cd..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepositoryV2.java +++ /dev/null @@ -1,100 +0,0 @@ -// 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 RelyingPartyV2} 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 { - - /** - * 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. - * - *

    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); - - /** - * Look up the public key, backup flags and current signature count for the given credential - * registered to the given user. - * - *

    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 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. - */ - @Deprecated - 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. - * @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/FinishAssertionSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java index 06bc78462..79cf442db 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 @@ -48,7 +48,7 @@ @Slf4j @AllArgsConstructor -final class FinishAssertionSteps { +final class FinishAssertionSteps { private static final String CLIENT_DATA_TYPE = "webauthn.get"; private static final String SPC_CLIENT_DATA_TYPE = "payment.get"; @@ -59,33 +59,13 @@ final class FinishAssertionSteps { private final Optional callerTokenBindingId; private final Set origins; private final String rpId; - private final CredentialRepositoryV2 credentialRepositoryV2; - private final Optional usernameRepository; + private final CredentialRepository credentialRepository; private final boolean allowOriginPort; private final boolean allowOriginSubdomain; private final boolean validateSignatureCounter; private final boolean 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(), - credRepoV2, - Optional.of(credRepoV2), - rp.isAllowOriginPort(), - rp.isAllowOriginSubdomain(), - rp.isValidateSignatureCounter(), - options.isSecurePaymentConfirmation()); - } - - FinishAssertionSteps(RelyingPartyV2 rp, FinishAssertionOptions options) { + FinishAssertionSteps(RelyingParty rp, FinishAssertionOptions options) { this( options.getRequest(), options.getResponse(), @@ -93,7 +73,6 @@ static FinishAssertionSteps fromV1( rp.getOrigins(), rp.getIdentity().getId(), rp.getCredentialRepository(), - Optional.ofNullable(rp.getUsernameRepository()), rp.isAllowOriginPort(), rp.isAllowOriginSubdomain(), rp.isValidateSignatureCounter(), @@ -101,7 +80,7 @@ static FinishAssertionSteps fromV1( } private Optional getUsernameForUserHandle(final ByteArray userHandle) { - return usernameRepository.flatMap(unameRepo -> unameRepo.getUsernameForUserHandle(userHandle)); + return credentialRepository.getUsernameForUserHandle(userHandle); } public Step5 begin() { @@ -112,11 +91,7 @@ public AssertionResult run() throws InvalidSignatureCountException { return begin().run(); } - public AssertionResultV2 runV2() throws InvalidSignatureCountException { - return begin().runV2(); - } - - interface Step> { + interface Step> { Next nextStep(); void validate() throws InvalidSignatureCountException; @@ -125,10 +100,6 @@ default Optional result() { return Optional.empty(); } - default Optional> resultV2() { - return Optional.empty(); - } - default Next next() throws InvalidSignatureCountException { validate(); return nextStep(); @@ -141,20 +112,12 @@ 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(); @@ -177,105 +140,86 @@ public void validate() { } @Value - 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 finalUserHandle; - private final Optional finalUsername; - private final Optional registration; - - public Step6() { - requestedUserHandle = request.getUserHandle(); - requestedUsername = request.getUsername(); - responseUserHandle = response.getResponse().getUserHandle(); + class Step6 implements Step { - effectiveRequestUserHandle = - OptionalUtil.orElseOptional( - requestedUserHandle, - () -> - usernameRepository.flatMap( - unr -> requestedUsername.flatMap(unr::getUserHandleForUsername))); + private final Optional userHandle = + OptionalUtil.orElseOptional( + request.getUserHandle(), + () -> + OptionalUtil.orElseOptional( + response.getResponse().getUserHandle(), + () -> + request + .getUsername() + .flatMap(credentialRepository::getUserHandleForUsername))); - effectiveRequestUsername = - OptionalUtil.orElseOptional( - requestedUsername, - () -> - requestedUserHandle.flatMap(FinishAssertionSteps.this::getUsernameForUserHandle)); + private final Optional username = + OptionalUtil.orElseOptional( + request.getUsername(), + () -> userHandle.flatMap(credentialRepository::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)); - } + private final Optional registration = + userHandle.flatMap(uh -> credentialRepository.lookup(response.getId(), uh)); @Override public Step7 nextStep() { - return new Step7(finalUsername, finalUserHandle.get(), registration); + return new Step7(username.get(), userHandle.get(), registration); } @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."); - - if (requestedUserHandle.isPresent() && responseUserHandle.isPresent()) { + 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()) { assertTrue( - requestedUserHandle.get().equals(responseUserHandle.get()), + request.getUserHandle().get().equals(response.getResponse().getUserHandle().get()), "User handle set in request (%s) does not match user handle in response (%s).", - requestedUserHandle.get(), - responseUserHandle.get()); + request.getUserHandle().get(), + response.getResponse().getUserHandle().get()); } - 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( + 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()); assertTrue(registration.isPresent(), "Unknown credential: %s", response.getId()); assertTrue( - finalUserHandle.get().equals(registration.get().getUserHandle()), + userHandle.get().equals(registration.get().getUserHandle()), "User handle %s does not own credential %s", - finalUserHandle.get(), + userHandle.get(), response.getId()); - if (usernameRepository.isPresent()) { + final Optional usernameFromRequest = request.getUsername(); + final Optional userHandleFromResponse = response.getResponse().getUserHandle(); + if (usernameFromRequest.isPresent() && userHandleFromResponse.isPresent()) { assertTrue( - finalUsername.isPresent(), - "Unknown username for user handle: %s", - finalUserHandle.get()); + userHandleFromResponse.equals( + credentialRepository.getUserHandleForUsername(usernameFromRequest.get())), + "User handle %s in response does not match username %s in request", + userHandleFromResponse, + usernameFromRequest); } } } @Value - class Step7 implements Step { - private final Optional username; + class Step7 implements Step { + private final String username; private final ByteArray userHandle; - private final Optional credential; + private final Optional credential; @Override public Step8 nextStep() { @@ -293,10 +237,10 @@ public void validate() { } @Value - class Step8 implements Step { + class Step8 implements Step { - private final Optional username; - private final C credential; + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -326,9 +270,9 @@ public ByteArray signature() { // Nothing to do for step 9 @Value - class Step10 implements Step { - private final Optional username; - private final C credential; + class Step10 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -346,9 +290,9 @@ public CollectedClientData clientData() { } @Value - class Step11 implements Step { - private final Optional username; - private final C credential; + class Step11 implements Step { + private final String username; + private final RegisteredCredential credential; private final CollectedClientData clientData; @Override @@ -369,9 +313,9 @@ public Step12 nextStep() { } @Value - class Step12 implements Step { - private final Optional username; - private final C credential; + class Step12 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -390,9 +334,9 @@ public Step13 nextStep() { } @Value - class Step13 implements Step { - private final Optional username; - private final C credential; + class Step13 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -410,9 +354,9 @@ public Step14 nextStep() { } @Value - class Step14 implements Step { - private final Optional username; - private final C credential; + class Step14 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -427,9 +371,9 @@ public Step15 nextStep() { } @Value - class Step15 implements Step { - private final Optional username; - private final C credential; + class Step15 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -459,9 +403,9 @@ public Step16 nextStep() { } @Value - class Step16 implements Step { - private final Optional username; - private final C credential; + class Step16 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -477,9 +421,9 @@ public Step17 nextStep() { } @Value - class Step17 implements Step { - private final Optional username; - private final C credential; + class Step17 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -502,9 +446,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 Optional username; - private final C credential; + class PendingStep16 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -525,9 +469,9 @@ public Step18 nextStep() { } @Value - class Step18 implements Step { - private final Optional username; - private final C credential; + class Step18 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() {} @@ -539,9 +483,9 @@ public Step19 nextStep() { } @Value - class Step19 implements Step { - private final Optional username; - private final C credential; + class Step19 implements Step { + private final String username; + private final RegisteredCredential credential; @Override public void validate() { @@ -559,9 +503,9 @@ public ByteArray clientDataJsonHash() { } @Value - class Step20 implements Step { - private final Optional username; - private final C credential; + class Step20 implements Step { + private final String username; + private final RegisteredCredential credential; private final ByteArray clientDataJsonHash; @Override @@ -604,13 +548,13 @@ public ByteArray signedBytes() { } @Value - class Step21 implements Step { - private final Optional username; - private final C credential; + class Step21 implements Step { + private final String username; + private final RegisteredCredential credential; private final long assertionSignatureCount; private final long storedSignatureCountBefore; - public Step21(Optional username, C credential) { + public Step21(String username, RegisteredCredential credential) { this.username = username; this.credential = credential; this.assertionSignatureCount = @@ -638,9 +582,9 @@ public Finished nextStep() { } @Value - class Finished implements Step { - private final C credential; - private final Optional username; + class Finished implements Step { + private final RegisteredCredential credential; + private final String username; private final long assertionSignatureCount; private final boolean signatureCounterValid; @@ -657,17 +601,7 @@ public Finished nextStep() { @Override public Optional result() { return Optional.of( - new AssertionResult( - true, - response, - (RegisteredCredential) credential, - username.get(), - signatureCounterValid)); - } - - public Optional> resultV2() { - return Optional.of( - new AssertionResultV2(true, response, credential, signatureCounterValid)); + new AssertionResult(true, response, credential, username, 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 52caf8b4b..60d1350bf 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 @@ -84,27 +84,12 @@ final class FinishRegistrationSteps { private final String rpId; private final boolean allowUntrustedAttestation; private final Optional attestationTrustSource; - private final CredentialRepositoryV2 credentialRepositoryV2; + private final CredentialRepository credentialRepository; private final Clock clock; private final boolean allowOriginPort; private final boolean allowOriginSubdomain; - 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) { + FinishRegistrationSteps(RelyingParty rp, FinishRegistrationOptions options) { this( options.getRequest(), options.getResponse(), @@ -645,7 +630,7 @@ class Step22 implements Step { @Override public void validate() { assertTrue( - !credentialRepositoryV2.credentialIdExists(response.getId()), + credentialRepository.lookupAll(response.getId()).isEmpty(), "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 0c1a5b15a..21246e5b2 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 @@ -30,21 +30,16 @@ 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; 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; @@ -60,7 +55,7 @@ */ @Value @Builder(toBuilder = true) -public final class RegisteredCredential implements CredentialRecord { +public final class RegisteredCredential { /** * The credential @@ -122,37 +117,6 @@ 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() - * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted - * before reaching a mature release. - */ - @Deprecated @Builder.Default private final Set transports = null; - /** * The state of the BE flag when * this credential was registered, if known. @@ -207,53 +171,16 @@ 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; } - /** - * 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() - * @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); - } - /** * 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/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index b8f0bafcb..ba90555bd 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 @@ -516,7 +516,7 @@ public RegistrationResult finishRegistration(FinishRegistrationOptions finishReg * #finishRegistration(FinishRegistrationOptions)} instead of this method. */ FinishRegistrationSteps _finishRegistration(FinishRegistrationOptions options) { - return FinishRegistrationSteps.fromV1(this, options); + return new FinishRegistrationSteps(this, options); } public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptions) { @@ -574,8 +574,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 FinishAssertionSteps.fromV1(this, options); + FinishAssertionSteps _finishAssertion(FinishAssertionOptions options) { + return new FinishAssertionSteps(this, options); } public static RelyingPartyBuilder.MandatoryStages builder() { @@ -608,31 +608,10 @@ public class Step2 { * credentialRepository} is a required parameter. * * @see RelyingPartyBuilder#credentialRepository(CredentialRepository) - * @see #credentialRepositoryV2(CredentialRepositoryV2) */ public RelyingPartyBuilder credentialRepository(CredentialRepository credentialRepository) { return builder.credentialRepository(credentialRepository); } - - /** - * {@link RelyingPartyBuilder#credentialRepository(CredentialRepository) - * 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 - * deleted before reaching a mature release. - */ - @Deprecated - public - RelyingPartyV2.RelyingPartyV2Builder credentialRepositoryV2( - 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 deleted file mode 100644 index 9ce2883c3..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingPartyV2.java +++ /dev/null @@ -1,707 +0,0 @@ -// 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.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; -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: - * - *

      - *
    • the user handle for a user logging in via user name - *
    • the user name for a user logging in via user handle - *
    • the credential IDs to include in {@link - * PublicKeyCredentialCreationOptions#getExcludeCredentials()} - *
    • the credential IDs to include in {@link - * PublicKeyCredentialRequestOptions#getAllowCredentials()} - *
    • that the correct user owns the credential when verifying an assertion - *
    • the public key to use to verify an assertion - *
    • the stored signature counter when verifying an assertion - *
    - */ - @NonNull private final CredentialRepositoryV2 credentialRepository; - - /** - * 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. - */ - @Deprecated 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 - .getCredentialDescriptorsForUserHandle( - startRegistrationOptions.getUser().getId()) - .stream() - .map(ToPublicKeyCredentialDescriptor::toPublicKeyCredentialDescriptor) - .collect(Collectors.toSet())) - .authenticatorSelection(startRegistrationOptions.getAuthenticatorSelection()) - .extensions( - startRegistrationOptions - .getExtensions() - .merge( - RegistrationExtensionInputs.builder() - .appidExclude(appId) - .credProps() - .build())) - .timeout(startRegistrationOptions.getTimeout()) - .hints(startRegistrationOptions.getHints()); - 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) { - 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()) - .rpId(identity.getId()) - .allowCredentials( - OptionalUtil.orElseOptional( - startAssertionOptions.getUserHandle(), - () -> - Optional.ofNullable(usernameRepository) - .flatMap( - unr -> - startAssertionOptions - .getUsername() - .flatMap(unr::getUserHandleForUsername))) - .map(credentialRepository::getCredentialDescriptorsForUserHandle) - .map( - descriptors -> - descriptors.stream() - .map( - ToPublicKeyCredentialDescriptor - ::toPublicKeyCredentialDescriptor) - .collect(Collectors.toList()))) - .extensions( - startAssertionOptions - .getExtensions() - .merge(startAssertionOptions.getExtensions().toBuilder().appid(appId).build())) - .timeout(startAssertionOptions.getTimeout()) - .hints(startAssertionOptions.getHints()); - - 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/ToPublicKeyCredentialDescriptor.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java deleted file mode 100644 index 92a6f2f35..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/ToPublicKeyCredentialDescriptor.java +++ /dev/null @@ -1,34 +0,0 @@ -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/UsernameRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java deleted file mode 100644 index 101937f69..000000000 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/UsernameRepository.java +++ /dev/null @@ -1,66 +0,0 @@ -// 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 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 { - - /** - * 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. - * - * @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); - - /** - * 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. - * - * @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); -} 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 be1810f2d..b2487b5c1 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,7 +29,6 @@ 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; @@ -50,8 +49,7 @@ */ @Value @Builder(toBuilder = true) -public class PublicKeyCredentialDescriptor - implements Comparable, ToPublicKeyCredentialDescriptor { +public class PublicKeyCredentialDescriptor implements Comparable { /** The type of the credential the caller is referring to. */ @NonNull @Builder.Default @@ -110,18 +108,6 @@ 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/RelyingPartyAssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala index 4d8d8fc8b..5c1755947 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 @@ -195,7 +195,7 @@ class RelyingPartyAssertionSpec userVerificationRequirement: UserVerificationRequirement = UserVerificationRequirement.PREFERRED, validateSignatureCounter: Boolean = true, - ): FinishAssertionSteps[RegisteredCredential] = { + ): FinishAssertionSteps = { val clientDataJsonBytes: ByteArray = if (clientDataJson == null) null else new ByteArray(clientDataJson.getBytes("UTF-8")) @@ -577,8 +577,7 @@ class RelyingPartyAssertionSpec ), credentialId = new ByteArray(Array(0, 1, 2, 3)), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step5 = - steps.begin + val step: FinishAssertionSteps#Step5 = steps.begin step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -601,8 +600,7 @@ class RelyingPartyAssertionSpec ), credentialId = new ByteArray(Array(4, 5, 6, 7)), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step5 = - steps.begin + val step: FinishAssertionSteps#Step5 = steps.begin step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -619,8 +617,7 @@ class RelyingPartyAssertionSpec allowCredentials = allowCredentials, credentialId = new ByteArray(Array(0, 1, 2, 3)), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step5 = - steps.begin + val step: FinishAssertionSteps#Step5 = steps.begin step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -677,8 +674,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = Some(owner.username), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -694,8 +690,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, userHandleForResponse = Some(owner.userHandle), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -709,8 +704,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = Some(owner.username), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -727,8 +721,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, userHandleForResponse = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -744,8 +737,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -762,8 +754,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -778,8 +769,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -794,8 +784,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -809,8 +798,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -824,8 +812,7 @@ class RelyingPartyAssertionSpec userHandleForUser = owner.userHandle, usernameForRequest = None, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step6 = - steps.begin.next + val step: FinishAssertionSteps#Step6 = steps.begin.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -852,7 +839,7 @@ class RelyingPartyAssertionSpec ) ) val step: steps.Step7 = new steps.Step7( - Some(Defaults.username).toJava, + Defaults.username, Defaults.userHandle, None.toJava, ) @@ -877,8 +864,7 @@ class RelyingPartyAssertionSpec ) ) ) - val step: FinishAssertionSteps[RegisteredCredential]#Step7 = - steps.begin.next.next + val step: FinishAssertionSteps#Step7 = steps.begin.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -888,8 +874,7 @@ 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[RegisteredCredential]#Step8 = - steps.begin.next.next.next + val step: FinishAssertionSteps#Step8 = steps.begin.next.next.next step.validations shouldBe a[Success[_]] step.clientData should not be null @@ -943,7 +928,7 @@ class RelyingPartyAssertionSpec "type": "" }""" ) - val step: FinishAssertionSteps[RegisteredCredential]#Step10 = + val step: FinishAssertionSteps#Step10 = steps.begin.next.next.next.next step.validations shouldBe a[Success[_]] @@ -957,7 +942,7 @@ class RelyingPartyAssertionSpec ) { it("The default test case succeeds.") { val steps = finishAssertion() - val step: FinishAssertionSteps[RegisteredCredential]#Step11 = + val step: FinishAssertionSteps#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -976,7 +961,7 @@ class RelyingPartyAssertionSpec ), isSecurePaymentConfirmation = isSecurePaymentConfirmation, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step11 = + val step: FinishAssertionSteps#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1008,7 +993,7 @@ class RelyingPartyAssertionSpec it("the default test case fails.") { val steps = finishAssertion(isSecurePaymentConfirmation = Some(true)) - val step: FinishAssertionSteps[RegisteredCredential]#Step11 = + val step: FinishAssertionSteps#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1026,7 +1011,7 @@ class RelyingPartyAssertionSpec .set[ObjectNode]("type", new TextNode("payment.get")) ), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step11 = + val step: FinishAssertionSteps#Step11 = steps.begin.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1070,7 +1055,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[RegisteredCredential]#Step12 = + val step: FinishAssertionSteps#Step12 = steps.begin.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1095,7 +1080,7 @@ class RelyingPartyAssertionSpec allowOriginPort = allowOriginPort, allowOriginSubdomain = allowOriginSubdomain, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step13 = + val step: FinishAssertionSteps#Step13 = steps.begin.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1118,7 +1103,7 @@ class RelyingPartyAssertionSpec allowOriginPort = allowOriginPort, allowOriginSubdomain = allowOriginSubdomain, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step13 = + val step: FinishAssertionSteps#Step13 = steps.begin.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1296,7 +1281,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[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1307,7 +1292,7 @@ class RelyingPartyAssertionSpec val clientDataJson = """{"challenge":"AAEBAgMFCA0VIjdZEGl5Yls","origin":"https://localhost","hashAlgorithm":"SHA-256","type":"webauthn.get"}""" val steps = finishAssertion(clientDataJson = clientDataJson) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1318,7 +1303,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[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1333,7 +1318,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1348,7 +1333,7 @@ class RelyingPartyAssertionSpec callerTokenBindingId = None, clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1362,7 +1347,7 @@ class RelyingPartyAssertionSpec callerTokenBindingId = None, clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1379,7 +1364,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1394,7 +1379,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1410,7 +1395,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1426,7 +1411,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("YELLOWSUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1442,7 +1427,7 @@ class RelyingPartyAssertionSpec Some(ByteArray.fromBase64Url("ORANGESUBMARINE")), clientDataJson = clientDataJson, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step14 = + val step: FinishAssertionSteps#Step14 = steps.begin.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1458,7 +1443,7 @@ class RelyingPartyAssertionSpec rpId = Defaults.rpId.toBuilder.id("root.evil").build(), origins = Some(Set("https://localhost")), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step15 = + val step: FinishAssertionSteps#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1468,7 +1453,7 @@ class RelyingPartyAssertionSpec it("Succeeds if RP ID is the same.") { val steps = finishAssertion() - val step: FinishAssertionSteps[RegisteredCredential]#Step15 = + val step: FinishAssertionSteps#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1490,7 +1475,7 @@ class RelyingPartyAssertionSpec .drop(32) ), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step15 = + val step: FinishAssertionSteps#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1500,7 +1485,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[RegisteredCredential]#Step15 = + val step: FinishAssertionSteps#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1516,7 +1501,7 @@ class RelyingPartyAssertionSpec ).getBytes ++ Defaults.authenticatorData.getBytes.drop(32) ), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step15 = + val step: FinishAssertionSteps#Step15 = steps.begin.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1527,13 +1512,13 @@ class RelyingPartyAssertionSpec { def checks[ - Next <: FinishAssertionSteps.Step[RegisteredCredential, _], - Step <: FinishAssertionSteps.Step[RegisteredCredential, Next], + Next <: FinishAssertionSteps.Step[_], + Step <: FinishAssertionSteps.Step[Next], ]( - stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step + stepsToStep: FinishAssertionSteps => Step ) = { def check[Ret]( - stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step + stepsToStep: FinishAssertionSteps => Step )( chk: Step => Ret )(uvr: UserVerificationRequirement, authData: ByteArray): Ret = { @@ -1544,7 +1529,7 @@ class RelyingPartyAssertionSpec chk(stepsToStep(steps)) } def checkFailsWith( - stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step + stepsToStep: FinishAssertionSteps => Step ): (UserVerificationRequirement, ByteArray) => Unit = check(stepsToStep) { step => step.validations shouldBe a[Failure[_]] @@ -1554,7 +1539,7 @@ class RelyingPartyAssertionSpec step.tryNext shouldBe a[Failure[_]] } def checkSucceedsWith( - stepsToStep: FinishAssertionSteps[RegisteredCredential] => Step + stepsToStep: FinishAssertionSteps => Step ): (UserVerificationRequirement, ByteArray) => Unit = check(stepsToStep) { step => step.validations shouldBe a[Success[_]] @@ -1584,9 +1569,7 @@ class RelyingPartyAssertionSpec .toArray ) val (checkFails, checkSucceeds) = - checks[FinishAssertionSteps[ - RegisteredCredential - ]#Step17, FinishAssertionSteps[RegisteredCredential]#Step16]( + checks[FinishAssertionSteps#Step17, FinishAssertionSteps#Step16]( _.begin.next.next.next.next.next.next.next.next.next.next ) @@ -1636,8 +1619,8 @@ class RelyingPartyAssertionSpec ) val (checkFails, checkSucceeds) = checks[ - FinishAssertionSteps[RegisteredCredential]#PendingStep16, - FinishAssertionSteps[RegisteredCredential]#Step17, + FinishAssertionSteps#PendingStep16, + FinishAssertionSteps#Step17, ]( _.begin.next.next.next.next.next.next.next.next.next.next.next ) @@ -1679,26 +1662,24 @@ class RelyingPartyAssertionSpec backupFlagsGen = arbitrary[Boolean].map(bs => (true, bs)), ) ) { authData => - 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 + 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 step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -1718,26 +1699,24 @@ class RelyingPartyAssertionSpec arbitrary[Boolean], ) { case (authData, storedBs) => - 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 + 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 step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[ @@ -1756,7 +1735,7 @@ class RelyingPartyAssertionSpec requestedExtensions = extensionInputs, clientExtensionResults = clientExtensionOutputs, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step18 = + val step: FinishAssertionSteps#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1771,7 +1750,7 @@ class RelyingPartyAssertionSpec requestedExtensions = extensionInputs, clientExtensionResults = clientExtensionOutputs, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step18 = + val step: FinishAssertionSteps#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1796,7 +1775,7 @@ class RelyingPartyAssertionSpec ) ), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step18 = + val step: FinishAssertionSteps#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1821,7 +1800,7 @@ class RelyingPartyAssertionSpec ) ), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step18 = + val step: FinishAssertionSteps#Step18 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1832,7 +1811,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[RegisteredCredential]#Step19 = + val step: FinishAssertionSteps#Step19 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1849,7 +1828,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[RegisteredCredential]#Step20 = + val step: FinishAssertionSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1866,7 +1845,7 @@ class RelyingPartyAssertionSpec .set("foo", jsonFactory.textNode("bar")) ) ) - val step: FinishAssertionSteps[RegisteredCredential]#Step20 = + val step: FinishAssertionSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1885,7 +1864,7 @@ class RelyingPartyAssertionSpec rpId = Defaults.rpId.toBuilder.id(rpId).build(), origins = Some(Set("https://localhost")), ) - val step: FinishAssertionSteps[RegisteredCredential]#Step20 = + val step: FinishAssertionSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1905,7 +1884,7 @@ class RelyingPartyAssertionSpec .toArray ) ) - val step: FinishAssertionSteps[RegisteredCredential]#Step20 = + val step: FinishAssertionSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1921,7 +1900,7 @@ class RelyingPartyAssertionSpec .toArray ) ) - val step: FinishAssertionSteps[RegisteredCredential]#Step20 = + val step: FinishAssertionSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -1968,7 +1947,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step21 = + val step: FinishAssertionSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -1985,7 +1964,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step21 = + val step: FinishAssertionSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] @@ -2007,7 +1986,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step21 = + val step: FinishAssertionSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -2027,7 +2006,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = false, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step21 = + val step: FinishAssertionSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] @@ -2041,7 +2020,7 @@ class RelyingPartyAssertionSpec credentialRepository = Some(cr), validateSignatureCounter = true, ) - val step: FinishAssertionSteps[RegisteredCredential]#Step21 = + val step: FinishAssertionSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next val result = Try(step.run()) @@ -2070,7 +2049,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[RegisteredCredential]#Finished = + val step: FinishAssertionSteps#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/RelyingPartyStartOperationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala index 4afd9a8f6..a56688eb7 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 @@ -44,7 +44,6 @@ 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 @@ -956,895 +955,6 @@ class RelyingPartyStartOperationSpec } } - 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 - ) - } - } - - describe("allows setting the hints") { - val rp = relyingParty(userId = userId) - - it("to string values in the spec or not.") { - val pkcco = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .hints("hej", "security-key", "hoj", "client-device", "hybrid") - .build() - ) - pkcco.getHints.asScala should equal( - List( - "hej", - PublicKeyCredentialHint.SECURITY_KEY.getValue, - "hoj", - PublicKeyCredentialHint.CLIENT_DEVICE.getValue, - PublicKeyCredentialHint.HYBRID.getValue, - ) - ) - } - - it("to PublicKeyCredentialHint values in the spec or not.") { - val pkcco = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .hints( - PublicKeyCredentialHint.of("hej"), - PublicKeyCredentialHint.HYBRID, - PublicKeyCredentialHint.SECURITY_KEY, - PublicKeyCredentialHint.of("hoj"), - PublicKeyCredentialHint.CLIENT_DEVICE, - ) - .build() - ) - pkcco.getHints.asScala should equal( - List( - "hej", - PublicKeyCredentialHint.HYBRID.getValue, - PublicKeyCredentialHint.SECURITY_KEY.getValue, - "hoj", - PublicKeyCredentialHint.CLIENT_DEVICE.getValue, - ) - ) - } - - it("or not, defaulting to the empty list.") { - val pkcco = rp.startRegistration( - StartRegistrationOptions.builder().user(userId).build() - ) - pkcco.getHints.asScala should equal(List()) - } - } - - 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) - } - - it("respects the authenticatorAttachment parameter.") { - val rp = relyingParty(userId = userId) - - 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 - .builder() - .authenticatorAttachment( - Optional.empty[AuthenticatorAttachment] - ) - .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("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 - } - } - - it("sets allowCredentials automatically if given a username.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty( - credentials = credentials, - 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)) - } - } - - 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, usernameRepository = true) - 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, - usernameRepository = true, - ) - 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, - usernameRepository = true, - ) - 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, usernameRepository = true) - 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, - 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) - ) - } - } - - 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() - ) - - result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - Some(requestAppId) - ) - } - } - } - - describe("allows setting the hints") { - val rp = relyingParty(userId = userId) - - it("to string values in the spec or not.") { - val pkcro = rp.startAssertion( - StartAssertionOptions - .builder() - .hints("hej", "security-key", "hoj", "client-device", "hybrid") - .build() - ) - pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( - List( - "hej", - PublicKeyCredentialHint.SECURITY_KEY.getValue, - "hoj", - PublicKeyCredentialHint.CLIENT_DEVICE.getValue, - PublicKeyCredentialHint.HYBRID.getValue, - ) - ) - } - - it("to PublicKeyCredentialHint values in the spec or not.") { - val pkcro = rp.startAssertion( - StartAssertionOptions - .builder() - .hints( - PublicKeyCredentialHint.of("hej"), - PublicKeyCredentialHint.HYBRID, - PublicKeyCredentialHint.SECURITY_KEY, - PublicKeyCredentialHint.of("hoj"), - PublicKeyCredentialHint.CLIENT_DEVICE, - ) - .build() - ) - pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( - List( - "hej", - PublicKeyCredentialHint.HYBRID.getValue, - PublicKeyCredentialHint.SECURITY_KEY.getValue, - "hoj", - PublicKeyCredentialHint.CLIENT_DEVICE.getValue, - ) - ) - } - - it("or not, defaulting to the empty list.") { - val pkcro = rp.startAssertion(StartAssertionOptions.builder().build()) - pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( - List() - ) - } - } - - 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("StartAssertionOptions") { it("resets username when userHandle is set.") { 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 4fd6222d5..2d53b1ffb 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,8 +32,6 @@ 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 @@ -271,255 +269,4 @@ 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 deleted file mode 100644 index ca7fa2aaf..000000000 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2AssertionSpec.scala +++ /dev/null @@ -1,2930 +0,0 @@ -// 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.BinaryUtil -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.LargeBlob.LargeBlobAuthenticationOutput -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.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 getCredentialDescriptorsForUserHandle( - 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 = BinaryUtil.fromHex( - "39C94FBBDDC694A925E6F8657C66916CFE84CD0222EDFCF281B21F5CDC347923" - ) - val y = BinaryUtil.fromHex( - "D6B0D2021CFE1724A6FE81E3568C4FFAE339298216A30AFC18C0B975F2E2A891" - ) - val u2fPubkey = - new ByteArray(BinaryUtil.concat(BinaryUtil.fromHex("04"), x, y)) - - val rp = RelyingParty - .builder() - .identity(testData.rpId) - .credentialRepositoryV2( - Helpers.CredentialRepositoryV2.withUser( - testData.userId, - credentialId = testData.assertion.get.response.getId, - publicKeyCose = - CredentialRecord.cosePublicKeyFromEs256Raw(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( - LargeBlobAuthenticationOutput.write(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( - LargeBlobAuthenticationOutput - .read(ByteArray.fromHex("00010203")) - ) - .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 deleted file mode 100644 index f623ac2ef..000000000 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyV2RegistrationSpec.scala +++ /dev/null @@ -1,4855 +0,0 @@ -// 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.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.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, - ) - BinaryUtil.concat( - java.util.Arrays.copyOfRange( - authDataBytes, - 0, - 32 + 1 + 4 + 16 + 2, - ), - authData.getAttestedCredentialData.get.getCredentialId.getBytes, - 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( - CredentialPropertiesOutput.builder().rk(true).build() - ) - .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( - CredentialPropertiesOutput.builder().rk(false).build() - ) - .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( - LargeBlobRegistrationOutput.supported(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 60d7c540e..ddaeca89b 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,14 +1,8 @@ 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.ToPublicKeyCredentialDescriptor -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 @@ -106,156 +100,6 @@ object Helpers { } } - object CredentialRepositoryV2 { - def empty[C <: CredentialRecord] = - new CredentialRepositoryV2[C] { - override def getCredentialDescriptorsForUserHandle( - 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 getCredentialDescriptorsForUserHandle( - 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 getCredentialDescriptorsForUserHandle( - userHandle: ByteArray - ): java.util.Set[_ <: ToPublicKeyCredentialDescriptor] = { - getCredentialIdsCount += 1 - inner.getCredentialDescriptorsForUserHandle(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 getCredentialDescriptorsForUserHandle( - userHandle: ByteArray - ): java.util.Set[_ <: ToPublicKeyCredentialDescriptor] = - users - .filter({ - case (u, c) => - u.getId == userHandle && c.getUserHandle == userHandle - }) - .map({ - case (_, credential) => credential - }) - .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, @@ -267,46 +111,4 @@ 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 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 1a4add942..0cba71a9c 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,12 @@ import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; -import com.yubico.webauthn.AssertionResultV2; -import com.yubico.webauthn.CredentialRepositoryV2; -import com.yubico.webauthn.UsernameRepository; +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.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import demo.webauthn.data.CredentialRegistration; import java.util.Collection; import java.util.HashSet; @@ -42,8 +44,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class InMemoryRegistrationStorage - implements CredentialRepositoryV2, UsernameRepository { +public class InMemoryRegistrationStorage implements CredentialRepository { private final Cache> storage = CacheBuilder.newBuilder().maximumSize(1000).expireAfterAccess(1, TimeUnit.DAYS).build(); @@ -51,23 +52,41 @@ public class InMemoryRegistrationStorage private static final Logger logger = LoggerFactory.getLogger(InMemoryRegistrationStorage.class); //////////////////////////////////////////////////////////////////////////////// - // The following methods are required by the CredentialRepositoryV2 interface. + // The following methods are required by the CredentialRepository interface. //////////////////////////////////////////////////////////////////////////////// @Override - public Set getCredentialDescriptorsForUserHandle(ByteArray userHandle) { - return getRegistrationsByUserHandle(userHandle); + public Set getCredentialIdsForUsername(String username) { + return getRegistrationsByUsername(username).stream() + .map( + registration -> + PublicKeyCredentialDescriptor.builder() + .id(registration.getCredential().getCredentialId()) + .transports(registration.getTransports()) + .build()) + .collect(Collectors.toSet()); + } + + @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()) - && userHandle.equals(credReg.getUserHandle())) + .filter(credReg -> credentialId.equals(credReg.getCredential().getCredentialId())) .findAny(); logger.debug( @@ -75,33 +94,31 @@ public Optional lookup(ByteArray credentialId, ByteArray credentialId, userHandle, registrationMaybe); - - return registrationMaybe; + return registrationMaybe.map( + registration -> + RegisteredCredential.builder() + .credentialId(registration.getCredential().getCredentialId()) + .userHandle(registration.getUserIdentity().getId()) + .publicKeyCose(registration.getCredential().getPublicKeyCose()) + .signatureCount(registration.getCredential().getSignatureCount()) + .build()); } @Override - public 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()); - } - - @Override - public Optional getUsernameForUserHandle(ByteArray userHandle) { - return getRegistrationsByUserHandle(userHandle).stream() - .findAny() - .map(CredentialRegistration::getUsername); + 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())); } //////////////////////////////////////////////////////////////////////////////// @@ -126,28 +143,27 @@ public Collection getRegistrationsByUsername(String user } } - public Set getRegistrationsByUserHandle(ByteArray userHandle) { + public Collection getRegistrationsByUserHandle(ByteArray userHandle) { return storage.asMap().values().stream() .flatMap(Collection::stream) .filter( credentialRegistration -> userHandle.equals(credentialRegistration.getUserIdentity().getId())) - .collect(Collectors.toSet()); + .collect(Collectors.toList()); } - public void updateSignatureCount(AssertionResultV2 result) { + public void updateSignatureCount(AssertionResult result) { CredentialRegistration registration = getRegistrationByUsernameAndCredentialId( - result.getCredential().getUsername(), result.getCredential().getCredentialId()) + result.getUsername(), result.getCredential().getCredentialId()) .orElseThrow( () -> new NoSuchElementException( String.format( "Credential \"%s\" is not registered to user \"%s\"", - result.getCredential().getCredentialId(), - result.getCredential().getUsername()))); + result.getCredential().getCredentialId(), result.getUsername()))); - Set regs = storage.getIfPresent(result.getCredential().getUsername()); + Set regs = storage.getIfPresent(result.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 b4f9165e1..d3fc6d4cd 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java @@ -39,13 +39,12 @@ import com.yubico.internal.util.CertificateParser; import com.yubico.internal.util.JacksonCodecs; import com.yubico.util.Either; -import com.yubico.webauthn.AssertionResultV2; +import com.yubico.webauthn.AssertionResult; 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; @@ -145,7 +144,7 @@ private static MetadataService getMetadataService() private final Clock clock = Clock.systemDefaultZone(); private final ObjectMapper jsonMapper = JacksonCodecs.json(); - private final RelyingPartyV2 rp; + private final RelyingParty rp; public WebAuthnServer() throws CertificateException, @@ -191,8 +190,7 @@ public WebAuthnServer( rp = RelyingParty.builder() .identity(rpIdentity) - .credentialRepositoryV2(this.userStorage) - .usernameRepository(this.userStorage) + .credentialRepository(this.userStorage) .origins(origins) .attestationConveyancePreference(Optional.of(AttestationConveyancePreference.DIRECT)) .attestationTrustSource(metadataService) @@ -490,7 +488,7 @@ public Either, SuccessfulAuthenticationResult> finishAuthentication return Either.left(Arrays.asList("Assertion failed!", "No such assertion in progress.")); } else { try { - AssertionResultV2 result = + AssertionResult result = rp.finishAssertion( FinishAssertionOptions.builder() .request(request.getRequest()) @@ -503,7 +501,7 @@ public Either, SuccessfulAuthenticationResult> finishAuthentication } catch (Exception e) { logger.error( "Failed to update signature count for user \"{}\", credential \"{}\"", - result.getCredential().getUsername(), + result.getUsername(), response.getCredential().getId(), e); } @@ -512,8 +510,8 @@ public Either, SuccessfulAuthenticationResult> finishAuthentication new SuccessfulAuthenticationResult( request, response, - userStorage.getRegistrationsByUsername(result.getCredential().getUsername()), - result.getCredential().getUsername(), + userStorage.getRegistrationsByUsername(result.getUsername()), + result.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 138352671..ef5878821 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,24 +26,20 @@ 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.Set; import java.util.SortedSet; import lombok.Builder; -import lombok.NonNull; import lombok.Value; import lombok.With; @Value @Builder @With -public class CredentialRegistration implements CredentialRecord { +public class CredentialRegistration { UserIdentity userIdentity; Optional credentialNickname; @@ -62,39 +58,4 @@ 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> getTransports() { - return Optional.ofNullable(transports); - } - - @Override - public Optional isBackupEligible() { - return credential.isBackupEligible(); - } - - @Override - public Optional isBackedUp() { - return credential.isBackedUp(); - } }