From b62ee4398c0c0cfa4ec9009031e38a7c34eb7e42 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 5 Jul 2023 21:45:15 +0200 Subject: [PATCH] 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.") {