From 595e317b3a0bbfafd923eb5d9d42bad177fc5792 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 12 Apr 2021 16:32:37 +0200 Subject: [PATCH 1/5] Rename internal function Crypto.hash to sha256 --- ...droidSafetynetAttestationStatementVerifier.java | 2 +- .../src/main/java/com/yubico/webauthn/Crypto.java | 6 +++--- .../com/yubico/webauthn/FinishAssertionSteps.java | 6 +++--- .../yubico/webauthn/FinishRegistrationSteps.java | 4 ++-- .../PackedAttestationStatementVerifierSpec.scala | 4 ++-- .../com/yubico/webauthn/RegistrationTestData.scala | 2 +- .../webauthn/RelyingPartyAssertionSpec.scala | 6 +++--- .../webauthn/RelyingPartyRegistrationSpec.scala | 14 +++++++------- .../com/yubico/webauthn/TestAuthenticator.scala | 13 +++++++------ .../main/java/com/yubico/webauthn/U2fVerifier.java | 4 ++-- 10 files changed, 31 insertions(+), 30 deletions(-) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java index b9df09523..d728bb24f 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java @@ -68,7 +68,7 @@ public boolean verifyAttestationSignature( ByteArray signedData = attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash); - ByteArray hashSignedData = Crypto.hash(signedData); + ByteArray hashSignedData = Crypto.sha256(signedData); ByteArray nonceByteArray = ByteArray.fromBase64(payload.get("nonce").textValue()); ExceptionUtil.assure( hashSignedData.equals(nonceByteArray), diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java index de9348d97..9075f95f1 100755 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java @@ -137,12 +137,12 @@ public static boolean verifySignature( } } - public static ByteArray hash(ByteArray bytes) { + public static ByteArray sha256(ByteArray bytes) { //noinspection UnstableApiUsage return new ByteArray(Hashing.sha256().hashBytes(bytes.getBytes()).asBytes()); } - public static ByteArray hash(String str) { - return hash(new ByteArray(str.getBytes(StandardCharsets.UTF_8))); + public static ByteArray sha256(String str) { + return sha256(new ByteArray(str.getBytes(StandardCharsets.UTF_8))); } } 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 af9f654f2..67d705bb6 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 @@ -421,7 +421,7 @@ class Step11 implements Step { public void validate() { try { assure( - Crypto.hash(rpId) + Crypto.sha256(rpId) .equals(response.getResponse().getParsedAuthenticatorData().getRpIdHash()), "Wrong RP ID hash."); } catch (IllegalArgumentException e) { @@ -429,7 +429,7 @@ public void validate() { request.getPublicKeyCredentialRequestOptions().getExtensions().getAppid(); if (appid.isPresent()) { assure( - Crypto.hash(appid.get().getId()) + Crypto.sha256(appid.get().getId()) .equals(response.getResponse().getParsedAuthenticatorData().getRpIdHash()), "Wrong RP ID hash."); } else { @@ -537,7 +537,7 @@ public Step16 nextStep() { } public ByteArray clientDataJsonHash() { - return Crypto.hash(response.getResponse().getClientDataJSON()); + return Crypto.sha256(response.getResponse().getClientDataJSON()); } } 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 0282107ad..919fbff45 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 @@ -259,7 +259,7 @@ public Step8 nextStep() { } public ByteArray clientDataJsonHash() { - return Crypto.hash(response.getResponse().getClientDataJSON()); + return Crypto.sha256(response.getResponse().getClientDataJSON()); } } @@ -292,7 +292,7 @@ class Step9 implements Step { @Override public void validate() { assure( - Crypto.hash(rpId) + Crypto.sha256(rpId) .equals(response.getResponse().getAttestation().getAuthenticatorData().getRpIdHash()), "Wrong RP ID hash."); } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/PackedAttestationStatementVerifierSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/PackedAttestationStatementVerifierSpec.scala index a797f61db..3b2adf847 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/PackedAttestationStatementVerifierSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/PackedAttestationStatementVerifierSpec.scala @@ -82,7 +82,7 @@ class PackedAttestationStatementVerifierSpec val result = verifier.verifyAttestationSignature( credential.getResponse.getAttestation, - Crypto.hash(credential.getResponse.getClientDataJSON), + Crypto.sha256(credential.getResponse.getClientDataJSON), ) key.getAlgorithm should be("EC") @@ -100,7 +100,7 @@ class PackedAttestationStatementVerifierSpec val result = verifier.verifyAttestationSignature( credential.getResponse.getAttestation, - Crypto.hash(credential.getResponse.getClientDataJSON), + Crypto.sha256(credential.getResponse.getClientDataJSON), ) key.getAlgorithm should be("RSA") diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RegistrationTestData.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RegistrationTestData.scala index 9742aefd1..bf6adc768 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RegistrationTestData.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RegistrationTestData.scala @@ -510,7 +510,7 @@ case class RegistrationTestData( def clientDataJsonBytes: ByteArray = new ByteArray(clientDataJson.getBytes("UTF-8")) def clientData = new CollectedClientData(clientDataJsonBytes) - def clientDataJsonHash: ByteArray = Crypto.hash(clientDataJsonBytes) + def clientDataJsonHash: ByteArray = Crypto.sha256(clientDataJsonBytes) def aaguid: ByteArray = new AttestationObject( attestationObject 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 b6e1428fc..521375b87 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 @@ -73,7 +73,7 @@ class RelyingPartyAssertionSpec private def jsonFactory: JsonNodeFactory = JsonNodeFactory.instance - private def sha256(bytes: ByteArray): ByteArray = Crypto.hash(bytes) + private def sha256(bytes: ByteArray): ByteArray = Crypto.sha256(bytes) private def sha256(data: String): ByteArray = sha256(new ByteArray(data.getBytes(Charset.forName("UTF-8")))) @@ -1419,7 +1419,7 @@ class RelyingPartyAssertionSpec it("A test case with a different signed RP ID hash fails.") { val rpId = "ARGHABLARGHLER" - val rpIdHash: ByteArray = Crypto.hash(rpId) + val rpIdHash: ByteArray = Crypto.sha256(rpId) val steps = finishAssertion( authenticatorData = new ByteArray( (rpIdHash.getBytes.toVector ++ Defaults.authenticatorData.getBytes.toVector @@ -1499,7 +1499,7 @@ class RelyingPartyAssertionSpec ) val signature = TestAuthenticator.makeAssertionSignature( authenticatorData, - Crypto.hash(Defaults.clientDataJsonBytes), + Crypto.sha256(Defaults.clientDataJsonBytes), Defaults.credentialKey.getPrivate, ) 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 35cc74250..e63dd81ff 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 @@ -86,7 +86,7 @@ class RelyingPartyRegistrationSpec private def toJson(obj: Map[String, String]): JsonNode = toJsonObject(obj.view.mapValues(jsonFactory.textNode).toMap) - private def sha256(bytes: ByteArray): ByteArray = Crypto.hash(bytes) + private def sha256(bytes: ByteArray): ByteArray = Crypto.sha256(bytes) def flipByte(index: Int, bytes: ByteArray): ByteArray = editByte(bytes, index, b => (0xff ^ b).toByte) @@ -1142,7 +1142,7 @@ class RelyingPartyRegistrationSpec RegistrationTestData.FidoU2f.BasicAttestation ) val step: FinishRegistrationSteps#Step14 = new steps.Step14( - Crypto.hash( + Crypto.sha256( new ByteArray( testData.clientDataJsonBytes.getBytes.updated( 20, @@ -1169,7 +1169,7 @@ class RelyingPartyRegistrationSpec credentialId = Some(new ByteArray(Array.fill(16)(0))), ) val step: FinishRegistrationSteps#Step14 = new steps.Step14( - Crypto.hash(testData.clientDataJsonBytes), + Crypto.sha256(testData.clientDataJsonBytes), new AttestationObject(testData.attestationObject), Some(new FidoU2fAttestationStatementVerifier).asJava, Nil.asJava, @@ -1218,7 +1218,7 @@ class RelyingPartyRegistrationSpec credentialId = Some(new ByteArray(Array.fill(16)(0))), ) val step: FinishRegistrationSteps#Step14 = new steps.Step14( - Crypto.hash(testData.clientDataJsonBytes), + Crypto.sha256(testData.clientDataJsonBytes), new AttestationObject(testData.attestationObject), Some(new FidoU2fAttestationStatementVerifier).asJava, Nil.asJava, @@ -1268,7 +1268,7 @@ class RelyingPartyRegistrationSpec new FidoU2fAttestationStatementVerifier() .verifyAttestationSignature( credential.getResponse.getAttestation, - Crypto.hash(credential.getResponse.getClientDataJSON), + Crypto.sha256(credential.getResponse.getClientDataJSON), ) } @@ -1320,7 +1320,7 @@ class RelyingPartyRegistrationSpec new FidoU2fAttestationStatementVerifier() .verifyAttestationSignature( credential.getResponse.getAttestation, - Crypto.hash(credential.getResponse.getClientDataJSON), + Crypto.sha256(credential.getResponse.getClientDataJSON), ) } @@ -1372,7 +1372,7 @@ class RelyingPartyRegistrationSpec val steps = finishRegistration(testData = testData) val step: FinishRegistrationSteps#Step14 = new steps.Step14( - Crypto.hash(testData.clientDataJsonBytes), + Crypto.sha256(testData.clientDataJsonBytes), new AttestationObject(testData.attestationObject), Some(new NoneAttestationStatementVerifier).asJava, Nil.asJava, 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 7820cadaa..638c0f1ac 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 @@ -579,7 +579,7 @@ object TestAuthenticator { .signature( makeAssertionSignature( authDataBytes, - Crypto.hash(clientDataJsonBytes), + Crypto.sha256(clientDataJsonBytes), credentialKey.getPrivate, alg, ) @@ -611,7 +611,7 @@ object TestAuthenticator { new ByteArray( (Vector[Byte](0) ++ rpIdHash.getBytes - ++ Crypto.hash(clientDataJson).getBytes + ++ Crypto.sha256(clientDataJson).getBytes ++ credentialId.getBytes ++ credentialPublicKeyRawBytes.getBytes).toArray ) @@ -655,7 +655,7 @@ object TestAuthenticator { signer: AttestationSigner, ): JsonNode = { val signedData = new ByteArray( - authDataBytes.getBytes ++ Crypto.hash(clientDataJson).getBytes + authDataBytes.getBytes ++ Crypto.sha256(clientDataJson).getBytes ) val signature = signer match { case SelfAttestation(keypair, alg) => @@ -693,7 +693,8 @@ object TestAuthenticator { cert: AttestationCert, ctsProfileMatch: Boolean = true, ): JsonNode = { - val nonce = Crypto.hash(authDataBytes concat Crypto.hash(clientDataJson)) + val nonce = + Crypto.sha256(authDataBytes concat Crypto.sha256(clientDataJson)) val f = JsonNodeFactory.instance @@ -722,11 +723,11 @@ object TestAuthenticator { "nonce" -> f.textNode(nonce.getBase64), "timestampMs" -> f.numberNode(Instant.now().toEpochMilli), "apkPackageName" -> f.textNode("com.yubico.webauthn.test"), - "apkDigestSha256" -> f.textNode(Crypto.hash("foo").getBase64), + "apkDigestSha256" -> f.textNode(Crypto.sha256("foo").getBase64), "ctsProfileMatch" -> f.booleanNode(ctsProfileMatch), "aplCertificateDigestSha256" -> f .arrayNode() - .add(f.textNode(Crypto.hash("foo").getBase64)), + .add(f.textNode(Crypto.sha256("foo").getBase64)), "basicIntegrity" -> f.booleanNode(true), ).asJava ) diff --git a/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java b/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java index 51467888c..8f922fadd 100644 --- a/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java +++ b/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java @@ -44,9 +44,9 @@ public class U2fVerifier { public static boolean verify( AppId appId, RegistrationRequest request, U2fRegistrationResponse response) throws CertificateException, IOException, Base64UrlException { - final ByteArray appIdHash = Crypto.hash(appId.getId()); + final ByteArray appIdHash = Crypto.sha256(appId.getId()); final ByteArray clientDataHash = - Crypto.hash(response.getCredential().getU2fResponse().getClientDataJSON()); + Crypto.sha256(response.getCredential().getU2fResponse().getClientDataJSON()); final JsonNode clientData = JacksonCodecs.json() From 00bab2df39a9d73a1ccc260df4572a69aa36e362 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 12 Apr 2021 21:11:37 +0200 Subject: [PATCH 2/5] Don't return transports from attestation if unknown or empty --- NEWS | 8 ++++++ .../resolver/SimpleAttestationResolver.java | 17 +++++++----- .../yubico/internal/util/OptionalUtil.java | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java diff --git a/NEWS b/NEWS index e28974c98..c8d03c7e8 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,11 @@ +== Version 1.9.0 (unreleased) == + +webauthn-server-attestation: + +* Fixed that `SimpleAttestationResolver` would return empty transports when + transports are unknown. + + == Version 1.8.0 == Changes: diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/resolver/SimpleAttestationResolver.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/resolver/SimpleAttestationResolver.java index 081d6b83b..a3e7a4d57 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/resolver/SimpleAttestationResolver.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/resolver/SimpleAttestationResolver.java @@ -31,6 +31,7 @@ import com.yubico.internal.util.CertificateParser; import com.yubico.internal.util.CollectionUtil; import com.yubico.internal.util.ExceptionUtil; +import com.yubico.internal.util.OptionalUtil; import com.yubico.webauthn.attestation.Attestation; import com.yubico.webauthn.attestation.AttestationResolver; import com.yubico.webauthn.attestation.DeviceMatcher; @@ -135,9 +136,11 @@ public Optional resolve( .vendorProperties(Optional.of(vendorProperties)) .deviceProperties(Optional.ofNullable(deviceProperties)) .transports( - Optional.of( - Transport.fromInt( - getTransports(attestationCertificate) | metadataTransports))) + OptionalUtil.zipWith( + getTransports(attestationCertificate), + Optional.of(metadataTransports).filter(t -> t != 0), + (a, b) -> a | b) + .map(Transport::fromInt)) .build(); }); } @@ -158,11 +161,11 @@ private boolean deviceMatches( } } - private static int getTransports(X509Certificate cert) { + private static Optional getTransports(X509Certificate cert) { byte[] extensionValue = cert.getExtensionValue(TRANSPORTS_EXT_OID); if (extensionValue == null) { - return 0; + return Optional.empty(); } ExceptionUtil.assure( @@ -186,14 +189,14 @@ private static int getTransports(X509Certificate cert) { } } - return transports; + return Optional.of(transports); } @Override public Attestation untrustedFromCertificate(X509Certificate attestationCertificate) { return Attestation.builder() .trusted(false) - .transports(Optional.of(Transport.fromInt(getTransports(attestationCertificate)))) + .transports(getTransports(attestationCertificate).map(Transport::fromInt)) .build(); } } 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 new file mode 100644 index 000000000..d59bd643d --- /dev/null +++ b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java @@ -0,0 +1,27 @@ +package com.yubico.internal.util; + +import java.util.Optional; +import java.util.function.BinaryOperator; +import lombok.experimental.UtilityClass; + +/** Utilities for working with {@link Optional} values. */ +@UtilityClass +public class OptionalUtil { + + /** + * If both a and b are present, return f(a, b). + * + *

If only a is present, return a. + * + *

Otherwise, return b. + */ + public static Optional zipWith(Optional a, Optional b, BinaryOperator f) { + if (a.isPresent() && b.isPresent()) { + return Optional.of(f.apply(a.get(), b.get())); + } else if (a.isPresent()) { + return a; + } else { + return b; + } + } +} From a13daeeab76ec2cf02f655fb0d08c83927a04a6b Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Mon, 19 Apr 2021 18:36:03 +0200 Subject: [PATCH 3/5] Implement Apple attestation statement format --- NEWS | 4 + .../DeviceIdentificationSpec.scala | 175 ++++++++++++++++++ .../AppleAttestationStatementVerifier.java | 126 +++++++++++++ .../webauthn/FinishRegistrationSteps.java | 6 + .../yubico/webauthn/data/AttestationType.java | 22 +++ ...ppleAttestationStatementVerifierSpec.scala | 173 +++++++++++++++++ .../RelyingPartyRegistrationSpec.scala | 81 ++++++++ .../yubico/webauthn/TestAuthenticator.scala | 100 ++++++++++ .../yubico/webauthn/WebAuthnTestCodecs.scala | 4 +- .../com/yubico/webauthn/data/Generators.scala | 10 + .../yubico/webauthn/test/RealExamples.scala | 89 +++++++++ 11 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 webauthn-server-core/src/main/java/com/yubico/webauthn/AppleAttestationStatementVerifier.java create mode 100644 webauthn-server-core/src/test/scala/com/yubico/webauthn/AppleAttestationStatementVerifierSpec.scala diff --git a/NEWS b/NEWS index c8d03c7e8..857f0d373 100644 --- a/NEWS +++ b/NEWS @@ -5,6 +5,10 @@ webauthn-server-attestation: * Fixed that `SimpleAttestationResolver` would return empty transports when transports are unknown. +webauthn-server-core: + +* Added support for the `"apple"` attestation statement format. + == Version 1.8.0 == diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/DeviceIdentificationSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/DeviceIdentificationSpec.scala index 4ab00007f..acaa74e8f 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/DeviceIdentificationSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/DeviceIdentificationSpec.scala @@ -151,6 +151,51 @@ class DeviceIdentificationSpec extends FunSpec with Matchers { ) } } + + describe("fails to identify") { + def check(testData: RealExamples.Example): Unit = { + val rp = RelyingParty + .builder() + .identity(testData.rp) + .credentialRepository(Helpers.CredentialRepository.empty) + .metadataService(new StandardMetadataService()) + .build() + + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request( + PublicKeyCredentialCreationOptions + .builder() + .rp(testData.rp) + .user(testData.user) + .challenge(testData.attestation.challenge) + .pubKeyCredParams( + List(PublicKeyCredentialParameters.ES256).asJava + ) + .build() + ) + .response(testData.attestation.credential) + .build() + ); + + result.isAttestationTrusted should be(false) + result.getAttestationMetadata.isPresent should be(true) + result.getAttestationMetadata.get.getDeviceProperties.isPresent should be( + false + ) + result.getAttestationMetadata.get.getVendorProperties.isPresent should be( + false + ) + result.getAttestationMetadata.get.getTransports.isPresent should be( + false + ) + } + + it("an Apple iOS device.") { + check(RealExamples.AppleAttestationIos) + } + } } describe("The default AttestationResolver") { @@ -217,4 +262,134 @@ class DeviceIdentificationSpec extends FunSpec with Matchers { } } + describe( + "A StandardMetadataService configured with an Apple root certificate" + ) { + // Apple WebAuthn Root CA cert downloaded from https://www.apple.com/certificateauthority/private/ on 2021-04-12 + // https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem + val mds = metadataService("""{ + | "identifier": "98cf2729-e2b9-4633-8b6a-b295cda99ccf", + | "version": 1, + | "vendorInfo": { + | "name": "Apple Inc. (Metadata file by Yubico)" + | }, + | "trustedCertificates": [ + | "-----BEGIN CERTIFICATE-----\nMIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w\nHQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ\nbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx\nNTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG\nA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k\nxu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/\npcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk\n2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA\nMGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3\njAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B\n1bWeT0vT\n-----END CERTIFICATE-----" + | ], + | "devices": [ + | { + | "displayName": "Apple device", + | "selectors": [ + | { + | "type": "x509Extension", + | "parameters": { + | "key": "1.2.840.113635.100.8.2" + | } + | } + | ] + | } + | ] + |}""".stripMargin) + + describe("successfully identifies") { + def check( + expectedName: String, + testData: RealExamples.Example, + ): Unit = { + val rp = RelyingParty + .builder() + .identity(testData.rp) + .credentialRepository(Helpers.CredentialRepository.empty) + .metadataService(mds) + .build() + + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request( + PublicKeyCredentialCreationOptions + .builder() + .rp(testData.rp) + .user(testData.user) + .challenge(testData.attestation.challenge) + .pubKeyCredParams( + List(PublicKeyCredentialParameters.ES256).asJava + ) + .build() + ) + .response(testData.attestation.credential) + .build() + ) + + result.isAttestationTrusted should be(true) + result.getAttestationMetadata.isPresent should be(true) + result.getAttestationMetadata.get.getDeviceProperties.isPresent should be( + true + ) + result.getAttestationMetadata.get.getDeviceProperties + .get() + .get("displayName") should equal(expectedName) + result.getAttestationMetadata.get.getTransports.isPresent should be( + false + ) + } + + it("an Apple iOS device.") { + check( + "Apple device", + RealExamples.AppleAttestationIos, + ) + } + + it("an Apple MacOS device.") { + check( + "Apple device", + RealExamples.AppleAttestationMacos, + ) + } + } + + describe("fails to identify") { + def check(testData: RealExamples.Example): Unit = { + val rp = RelyingParty + .builder() + .identity(testData.rp) + .credentialRepository(Helpers.CredentialRepository.empty) + .metadataService(mds) + .build() + + val result = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request( + PublicKeyCredentialCreationOptions + .builder() + .rp(testData.rp) + .user(testData.user) + .challenge(testData.attestation.challenge) + .pubKeyCredParams( + List(PublicKeyCredentialParameters.ES256).asJava + ) + .build() + ) + .response(testData.attestation.credential) + .build() + ) + + result.isAttestationTrusted should be(false) + result.getAttestationMetadata.isPresent should be(true) + result.getAttestationMetadata.get.getVendorProperties.isPresent should be( + false + ) + result.getAttestationMetadata.get.getDeviceProperties.isPresent should be( + false + ) + } + + it("a YubiKey 5 NFC.") { + check(RealExamples.YubiKey5) + } + } + } + } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AppleAttestationStatementVerifier.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AppleAttestationStatementVerifier.java new file mode 100644 index 000000000..efb3f5d93 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AppleAttestationStatementVerifier.java @@ -0,0 +1,126 @@ +// 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.ExceptionUtil; +import com.yubico.webauthn.data.AttestationObject; +import com.yubico.webauthn.data.AttestationType; +import com.yubico.webauthn.data.ByteArray; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +final class AppleAttestationStatementVerifier + implements AttestationStatementVerifier, X5cAttestationStatementVerifier { + + private static final String NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2"; + + @Override + public AttestationType getAttestationType(AttestationObject attestation) { + return AttestationType.ANONYMIZATION_CA; + } + + @Override + public boolean verifyAttestationSignature( + AttestationObject attestationObject, ByteArray clientDataJsonHash) { + final Optional attestationCert; + try { + attestationCert = getX5cAttestationCertificate(attestationObject); + } catch (CertificateException e) { + throw ExceptionUtil.wrapAndLog( + log, + String.format( + "Failed to parse X.509 certificate from attestation object: %s", attestationObject), + e); + } + + return attestationCert + .map( + attestationCertificate -> { + final ByteArray nonceToHash = + attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash); + + final ByteArray nonce = Crypto.sha256(nonceToHash); + + byte[] nonceExtension = attestationCertificate.getExtensionValue(NONCE_EXTENSION_OID); + if (nonceExtension == null) { + throw new IllegalArgumentException( + "Apple anonymous attestation certificate must contain extension OID: " + + NONCE_EXTENSION_OID); + } + + // X.509 extension values is a DER octet string: 0x0426 + // Then the extension contains a 1-element sequence: 0x3024 + // The element has context-specific tag "[1]": 0xa122 + // Then the sequence contains a 32-byte octet string: 0x0420 + final ByteArray expectedExtensionValue = + new ByteArray( + new byte[] { + 0x04, 0x26, 0x30, 0x24, (-128) + (0xa1 - 128), 0x22, 0x04, 0x20 + }) + .concat(nonce); + + if (!expectedExtensionValue.equals(new ByteArray(nonceExtension))) { + throw new IllegalArgumentException( + String.format( + "Apple anonymous attestation certificate extension %s must equal nonceToHash. Expected: %s, was: %s", + NONCE_EXTENSION_OID, + expectedExtensionValue, + new ByteArray(nonceExtension))); + } + + final PublicKey credentialPublicKey; + try { + credentialPublicKey = + WebAuthnCodecs.importCosePublicKey( + attestationObject + .getAuthenticatorData() + .getAttestedCredentialData() + .get() + .getCredentialPublicKey()); + } catch (Exception e) { + throw ExceptionUtil.wrapAndLog(log, "Failed to import credential public key", e); + } + + final PublicKey certPublicKey = attestationCertificate.getPublicKey(); + + if (!credentialPublicKey.equals(certPublicKey)) { + throw new IllegalArgumentException( + String.format( + "Apple anonymous attestation certificate subject public key must equal credential public key. Expected: %s, was: %s", + credentialPublicKey, certPublicKey)); + } + + return true; + }) + .orElseThrow( + () -> + new IllegalArgumentException( + "Failed to parse attestation certificate from \"apple\" attestation statement.")); + } +} 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 919fbff45..374de13cb 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 @@ -405,6 +405,8 @@ public Optional attestationStatementVerifier() { return Optional.of(new PackedAttestationStatementVerifier()); case "android-safetynet": return Optional.of(new AndroidSafetynetAttestationStatementVerifier()); + case "apple": + return Optional.of(new AppleAttestationStatementVerifier()); default: return Optional.empty(); } @@ -502,11 +504,13 @@ public Optional trustResolver() { case UNKNOWN: return Optional.empty(); + case ANONYMIZATION_CA: case ATTESTATION_CA: case BASIC: switch (attestation.getFormat()) { case "android-key": case "android-safetynet": + case "apple": case "fido-u2f": case "packed": case "tpm": @@ -544,6 +548,7 @@ public void validate() { assure(allowUntrustedAttestation, "Self attestation is not allowed."); break; + case ANONYMIZATION_CA: case ATTESTATION_CA: case BASIC: assure( @@ -579,6 +584,7 @@ public boolean attestationTrusted() { case UNKNOWN: return false; + case ANONYMIZATION_CA: case ATTESTATION_CA: case BASIC: return attestationMetadata().filter(Attestation::isTrusted).isPresent(); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationType.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationType.java index aa516fc58..06f0c11f4 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationType.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationType.java @@ -78,6 +78,28 @@ public enum AttestationType { */ ATTESTATION_CA, + /** + * In this case, the authenticator uses an Anonymization CA which dynamically generates + * per-credential attestation certificates such that the attestation statements presented to + * Relying Parties do not provide uniquely identifiable information, e.g., that might be used for + * tracking purposes. + * + *

Note: Attestation statements conveying attestations of type AttCA or AnonCA use the same + * data structure as those of type Basic, so the three attestation types are, in general, + * distinguishable only with externally provided knowledge regarding the contents of the + * attestation certificates conveyed in the attestation statement. + * + *

Note: Attestation statements conveying attestations of this type use the same data structure + * as attestation statements conveying attestations of type #BASIC, so the two attestation types + * are, in general, distinguishable only with externally provided knowledge regarding the contents + * of the attestation certificates conveyed in the attestation statement. + * + * @see Anonymization + * CA + */ + ANONYMIZATION_CA, + /** * In this case, the Authenticator receives direct anonymous attestation (DAA) credentials from a * single DAA-Issuer. These DAA credentials are used along with blinding to sign the attested diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/AppleAttestationStatementVerifierSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/AppleAttestationStatementVerifierSpec.scala new file mode 100644 index 000000000..287f700d3 --- /dev/null +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/AppleAttestationStatementVerifierSpec.scala @@ -0,0 +1,173 @@ +// Copyright (c) 2021, 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.CertificateParser +import com.yubico.webauthn.TestAuthenticator.AttestationMaker +import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.Generators.arbitraryByteArray +import com.yubico.webauthn.test.RealExamples +import org.junit.runner.RunWith +import org.scalatest.FunSpec +import org.scalatest.Matchers +import org.scalatestplus.junit.JUnitRunner +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +@RunWith(classOf[JUnitRunner]) +class AppleAttestationStatementVerifierSpec + extends FunSpec + with Matchers + with TestWithEachProvider + with ScalaCheckDrivenPropertyChecks { + + val verifier = new AppleAttestationStatementVerifier + + testWithEachProvider { it => + describe("AppleAttestationStatementVerifier") { + + describe("accepts") { + describe("a real apple attestation statement example") { + it("from iOS.") { + val example = RealExamples.AppleAttestationIos + val result = verifier.verifyAttestationSignature( + example.attestation.attestationObject, + example.attestation.clientDataJSONHash, + ) + result should be(true) + } + + it("from MacOS.") { + val example = RealExamples.AppleAttestationMacos + val result = verifier.verifyAttestationSignature( + example.attestation.attestationObject, + example.attestation.clientDataJSONHash, + ) + result should be(true) + } + } + + it("a test-generated apple attestation statement.") { + val (attestationMaker, _, _) = AttestationMaker.apple() + val (pkc, _) = TestAuthenticator.createBasicAttestedCredential( + attestationMaker = attestationMaker + ) + val result = verifier.verifyAttestationSignature( + pkc.getResponse.getAttestation, + Crypto.sha256(pkc.getResponse.getClientDataJSON), + ) + result should be(true) + } + } + + describe("rejects") { + + it("a real apple attestation statement example that doesn't match the clientDataJSONHash.") { + val example = RealExamples.AppleAttestationIos + verifier.verifyAttestationSignature( + example.attestation.attestationObject, + example.attestation.clientDataJSONHash, + ) should be(true) + + forAll( + com.yubico.webauthn.data.Generators + .flipOneBit(example.attestation.clientDataJSONHash) + ) { modifiedHash => + an[IllegalArgumentException] shouldBe thrownBy { + verifier.verifyAttestationSignature( + example.attestation.attestationObject, + modifiedHash, + ) + } + } + } + + it("an attestation statement without the attestation cert extension 1.2.840.113635.100.8.2 .") { + val (attestationMaker, _, _) = + AttestationMaker.apple(addNonceExtension = false) + val (pkc, _) = TestAuthenticator.createBasicAttestedCredential( + attestationMaker = attestationMaker + ) + an[IllegalArgumentException] shouldBe thrownBy { + verifier.verifyAttestationSignature( + pkc.getResponse.getAttestation, + Crypto.sha256(pkc.getResponse.getClientDataJSON), + ) + } + } + + it("an attestation statement where the 1.2.840.113635.100.8.2 extension value does not equal the nonceToHash.") { + forAll { incorrectNonce: ByteArray => + val (attestationMaker, _, _) = + AttestationMaker.apple(nonceValue = Some(incorrectNonce)) + val (pkc, _) = TestAuthenticator.createBasicAttestedCredential( + attestationMaker = attestationMaker + ) + + an[IllegalArgumentException] shouldBe thrownBy { + verifier.verifyAttestationSignature( + pkc.getResponse.getAttestation, + Crypto.sha256(pkc.getResponse.getClientDataJSON), + ) + } + } + } + + it("an attestation statement where the certificate subject public key does not equal the credential public key.") { + val certSubjectKeypair = TestAuthenticator.generateEcKeypair() + val (appleAttestationMaker, caCert, _) = + AttestationMaker.apple(certSubjectPublicKey = + Some(certSubjectKeypair.getPublic) + ) + val (pkc, _) = TestAuthenticator.createBasicAttestedCredential( + attestationMaker = appleAttestationMaker + ) + + // In this test, the signature chain on its own is valid... + val certNodes = + pkc.getResponse.getAttestation.getAttestationStatement.get("x5c") + var cert = CertificateParser.parseDer(certNodes.get(0).binaryValue) + for { certIndex <- 1 until certNodes.size } { + val nextCert = + CertificateParser.parseDer(certNodes.get(certIndex).binaryValue) + cert.verify(nextCert.getPublicKey) + cert = nextCert + } + if (cert != caCert) { + cert.verify(caCert.getPublicKey) + } + + // ...but the leaf subject has the wrong public key. + an[IllegalArgumentException] shouldBe thrownBy { + verifier.verifyAttestationSignature( + pkc.getResponse.getAttestation, + Crypto.sha256(pkc.getResponse.getClientDataJSON), + ) + } + } + } + } + + } +} 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 e63dd81ff..64cf95e8f 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 @@ -48,6 +48,7 @@ import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity import com.yubico.webauthn.data.UserVerificationRequirement import com.yubico.webauthn.test.Helpers +import com.yubico.webauthn.test.RealExamples import com.yubico.webauthn.test.Util.toStepWithUtilities import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.x500.X500Name @@ -2806,6 +2807,86 @@ class RelyingPartyRegistrationSpec ) } + 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 { 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 638c0f1ac..0c6ec2d39 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 @@ -46,6 +46,8 @@ import com.yubico.webauthn.test.Util import org.bouncycastle.asn1.ASN1ObjectIdentifier import org.bouncycastle.asn1.ASN1Primitive import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERTaggedObject import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.asn1.x509.BasicConstraints import org.bouncycastle.asn1.x509.Extension @@ -244,6 +246,41 @@ object TestAuthenticator { ctsProfileMatch = ctsProfileMatch, ) } + def apple( + addNonceExtension: Boolean = true, + nonceValue: Option[ByteArray] = None, + certSubjectPublicKey: Option[PublicKey] = None, + ): (AttestationMaker, X509Certificate, PrivateKey) = { + val (caCert, caKey) = + generateAttestationCertificate( + COSEAlgorithmIdentifier.ES256, + name = new X500Name( + "CN=Yubico WebAuthn unit tests CA, O=Yubico, OU=Apple Attestation" + ), + ) + + ( + new AttestationMaker { + override val format = "apple" + override def makeAttestationStatement( + authDataBytes: ByteArray, + clientDataJson: String, + ): JsonNode = + makeAppleAttestationStatement( + caCert, + caKey, + authDataBytes, + clientDataJson, + addNonceExtension, + nonceValue, + certSubjectPublicKey, + ) + }, + caCert, + caKey, + ) + } + def none(): AttestationMaker = new AttestationMaker { override val format = "none" @@ -757,6 +794,69 @@ object TestAuthenticator { attStmt } + def makeAppleAttestationStatement( + caCert: X509Certificate, + caKey: PrivateKey, + authDataBytes: ByteArray, + clientDataJson: String, + addNonceExtension: Boolean = true, + nonceValue: Option[ByteArray] = None, + certSubjectPublicKey: Option[PublicKey] = None, + ): JsonNode = { + val clientDataJSON = new ByteArray( + clientDataJson.getBytes(StandardCharsets.UTF_8) + ) + val clientDataJsonHash = Crypto.sha256(clientDataJSON) + val nonceToHash = authDataBytes.concat(clientDataJsonHash) + val nonce = Crypto.sha256(nonceToHash) + + val subjectCert = buildCertificate( + certSubjectPublicKey.getOrElse( + WebAuthnTestCodecs.importCosePublicKey( + new AuthenticatorData( + authDataBytes + ).getAttestedCredentialData.get.getCredentialPublicKey + ) + ), + new X500Name( + "CN=Yubico WebAuthn unit tests CA, O=Yubico, OU=Apple Attestation" + ), + new X500Name( + "CN=Apple attestation test credential, O=Yubico, OU=Apple Attestation" + ), + caKey, + COSEAlgorithmIdentifier.ES256, + extensions = if (addNonceExtension) { + List( + ( + "1.2.840.113635.100.8.2", + false, + new DERSequence( + new DERTaggedObject( + 1, + new DEROctetString(nonceValue.getOrElse(nonce).getBytes), + ) + ), + ) + ) + } else Nil, + ) + + val f = JsonNodeFactory.instance + f.objectNode() + .setAll( + Map( + "x5c" -> f + .arrayNode() + .addAll( + List(subjectCert, caCert) + .map(crt => f.binaryNode(crt.getEncoded)) + .asJava + ) + ).asJava + ) + } + def makeAuthDataBytes( rpId: String = Defaults.rpId, counterBytes: ByteArray = ByteArray.fromHex("00000539"), diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala index d188e26b6..4b408af31 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala @@ -13,10 +13,12 @@ import java.security.interfaces.RSAPublicKey import java.security.spec.PKCS8EncodedKeySpec /** - * Re-exports from [[WebAuthnCodecs]] so tests can use it + * Re-exports from [[WebAuthnCodecs]] and [[Crypto]] so tests can use it */ object WebAuthnTestCodecs { + def sha256(bytes: ByteArray): ByteArray = Crypto.sha256(bytes) + def ecPublicKeyToRaw = WebAuthnCodecs.ecPublicKeyToRaw _ def importCosePublicKey = WebAuthnCodecs.importCosePublicKey _ 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 24169d9bc..618eff093 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 @@ -272,6 +272,16 @@ object Generators { def byteArray(size: Int): Gen[ByteArray] = Gen.listOfN(size, arbitrary[Byte]).map(ba => new ByteArray(ba.toArray)) + def flipOneBit(bytes: ByteArray): Gen[ByteArray] = + for { + byteIndex: Int <- Gen.choose(0, bytes.size() - 1) + bitIndex: Int <- Gen.choose(0, 7) + flipMask: Byte = (1 << bitIndex).toByte + } yield new ByteArray( + bytes.getBytes + .updated(byteIndex, (bytes.getBytes()(byteIndex) ^ flipMask).toByte) + ) + implicit val arbitraryClientAssertionExtensionOutputs : Arbitrary[ClientAssertionExtensionOutputs] = Arbitrary( for { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala index 87a23c3b0..e0c932d46 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala @@ -1,6 +1,7 @@ package com.yubico.webauthn.test import com.yubico.internal.util.JacksonCodecs +import com.yubico.webauthn.WebAuthnTestCodecs import com.yubico.webauthn.data.AttestationObject import com.yubico.webauthn.data.AuthenticatorAssertionResponse import com.yubico.webauthn.data.AuthenticatorAttestationResponse @@ -8,6 +9,7 @@ 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.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity @@ -18,6 +20,9 @@ sealed trait HasClientData { def clientData: String def clientDataJSON: ByteArray = new ByteArray(clientData.getBytes(StandardCharsets.UTF_8)) + def clientDataJSONHash: ByteArray = WebAuthnTestCodecs.sha256(clientDataJSON) + def collectedClientData: CollectedClientData = + new CollectedClientData(clientDataJSON) def challenge: ByteArray = ByteArray.fromBase64Url( JacksonCodecs.json().readTree(clientData).get("challenge").textValue() @@ -371,4 +376,88 @@ object RealExamples { ), ) + val AppleAttestationIos = Example( + RelyingPartyIdentity + .builder() + .id("demo.yubico.com") + .name("YubicoDemo") + .build(), + UserIdentity + .builder() + .name("Yubico demo user") + .displayName("Yubico demo user") + .id( + ByteArray.fromBase64Url("Fe0QmfU9xebikAVYRtOyGfI5ulgxbVVf7VNaON8edmU=") + ) + .build(), + AttestationExample( + new String( + ByteArray + .fromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUUs2c25Jak40MGNNZG9oNlUtR3NEZnlFYzlQY3pKdEgtSTczM3daSDRIZyIsIm9yaWdpbiI6Imh0dHBzOi8vZGVtby55dWJpY28uY29tIn0=" + ) + .getBytes, + StandardCharsets.UTF_8, + ), + ByteArray.fromBase64("o2NmbXRlYXBwbGVnYXR0U3RtdKFjeDVjglkCRjCCAkIwggHJoAMCAQICBgF4xhYQszAKBggqhkjOPQQDAjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIxMDQxMTEyMzcxOFoXDTIxMDQxNDEyMzcxOFowgZExSTBHBgNVBAMMQDMxYzRlOTM2YzgwZjY1Y2VjNzcxZWZkOGNhNWMxNDdlZTgxZjY4ZjVhODE5YTUzNDFiMDU5NmJkYmU4YWI0OTExGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYc87v7q19IYjqS3vizLAet/NcW0NVpYRvzvZFfCT00nBR0rzITI4iuuBupVtSRFhZfHa3GhYUu/w3Mo2h3s/+qNVMFMwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBPAwMwYJKoZIhvdjZAgCBCYwJKEiBCC+B6u5EUpszNBikhFRpOuBolX7jPReSqGkIvBr0orEZDAKBggqhkjOPQQDAgNnADBkAjAZpK9Vw3hR3uCca+kUAorfR4Sj/HkCcmydzm/KuewaYC5lmIwRTw9SKEVmAAITRlUCMEC9P/ksVc5DUHtKt+rQ9mXHeobdGymHSM7xZtYMNOfze8hPo5HLnwtWCB5qF8MQRVkCODCCAjQwggG6oAMCAQICEFYlU5XHp/tA6+Io2CYIU7YwCgYIKoZIzj0EAwMwSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODM4MDFaFw0zMDAzMTMwMDAwMDBaMEgxHDAaBgNVBAMME0FwcGxlIFdlYkF1dGhuIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASDLocvJhSRgQIlufX81rtjeLX1Xz/LBFvHNZk0df1UkETfm/4ZIRdlxpod2gULONRQg0AaQ0+yTREtVsPhz7/LmJH+wGlggb75bLx3yI3dr0alruHdUVta+quTvpwLJpGjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUJtdk2cV4wlpn0afeaxLQG2PxxtcwHQYDVR0OBBYEFOuugsT/oaxbUdTPJGEFAL5jvXeIMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA3YsaNIGl+tnbtOdle4QeFEwnt1uHakGGwrFHV1Azcifv5VRFfvZIlQxjLlxIPnDBAjAsimBE3CAfz+Wbw00pMMFIeFHZYO1qdfHrSsq+OM0luJfQyAW+8Mf3iwelccboDgdoYXV0aERhdGFYmMRs74KtG1Rkd1kdAIsIdZ7D5tLstPOUdL/qaWmSXQO3RQAAAAAAAAAAAAAAAAAAAAAAAAAAABRK0rg7vzmd/BAatDNkXX6aBhPZSaUBAgMmIAEhWCBhzzu/urX0hiOpLe+LMsB6381xbQ1WlhG/O9kV8JPTSSJYIMFHSvMhMjiK64G6lW1JEWFl8drcaFhS7/DcyjaHez/6"), + ), + AssertionExample( + id = ByteArray.fromBase64Url("StK4O785nfwQGrQzZF1-mgYT2Uk"), + clientData = new String( + ByteArray + .fromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoid2V5TG9keXVzUl96SWtPWUg3bTVUYjBreGViQnEtV2QzYVJreUhMeHl0SSIsIm9yaWdpbiI6Imh0dHBzOi8vZGVtby55dWJpY28uY29tIn0=" + ) + .getBytes, + StandardCharsets.UTF_8, + ), + authDataBytes = ByteArray.fromBase64( + "xGzvgq0bVGR3WR0Aiwh1nsPm0uy085R0v+ppaZJdA7cFAAAAAA==" + ), + sig = + ByteArray.fromBase64("MEUCIQDv9Sye6lyu6nonnsI9bSjkBXyhPRmei4LGRhfuOGc0AwIgPEQFsGHZDMIeSVDmgB85otg1Ba0XNl7S/Bgj6diIIoo="), + ), + ) + + val AppleAttestationMacos = Example( + RelyingPartyIdentity + .builder() + .id("demo.yubico.com") + .name("YubicoDemo") + .build(), + UserIdentity + .builder() + .name("Yubico demo user") + .displayName("Yubico demo user") + .id(ByteArray.fromBase64("+8eKyPo9MGrhWx8Y7ZeoczjaS5mbRr2kqF7/zllIgZ8=")) + .build(), + AttestationExample( + new String( + ByteArray + .fromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoicWszNE1GRVA4dWxXaHVpOEpncmt0ZVE5RXhIV2NKYndJcjNDUm1lVGtqZyIsIm9yaWdpbiI6Imh0dHBzOi8vZGVtby55dWJpY28uY29tIn0=" + ) + .getBytes, + StandardCharsets.UTF_8, + ), + ByteArray.fromBase64("o2NmbXRlYXBwbGVnYXR0U3RtdKFjeDVjglkCRjCCAkIwggHJoAMCAQICBgF4xjGSqDAKBggqhkjOPQQDAjBIMRwwGgYDVQQDDBNBcHBsZSBXZWJBdXRobiBDQSAxMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIxMDQxMTEzMDcyMFoXDTIxMDQxNDEzMDcyMFowgZExSTBHBgNVBAMMQDYxYmQ5NzY4M2JlMTk0NTVjOGJjOWVhNDZhMjY4NzU0MzVjMmIwNmVlMTI4YzY4ZDFiMGE4NDczODkwNTgzMjYxGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdrvYDb+UbAjcbbommtRqw+2Lm1fvHG6ll1dOgeEM25H8ThQ0yj4R3hVbc/ean1I5eqc/RXDFm/jJI/Lmp1uEFqNVMFMwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBPAwMwYJKoZIhvdjZAgCBCYwJKEiBCAQ6ifyo7KWlR86ueS0JMAuIi66gYkJsX+VxAcvbtEEcTAKBggqhkjOPQQDAgNnADBkAjAIu8Vx1tdGHSarO63RF7QaUo3/Iuk1CXA2Z0YIbDG4mLS15JQ/AUwctOpePcZoDngCMFMfnXi6jlhNBmppj5/8VQz2Kbz5eNxg+dqALz59ctCqXkdCVLMhUOpHWgMhhOadj1kCODCCAjQwggG6oAMCAQICEFYlU5XHp/tA6+Io2CYIU7YwCgYIKoZIzj0EAwMwSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODM4MDFaFw0zMDAzMTMwMDAwMDBaMEgxHDAaBgNVBAMME0FwcGxlIFdlYkF1dGhuIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASDLocvJhSRgQIlufX81rtjeLX1Xz/LBFvHNZk0df1UkETfm/4ZIRdlxpod2gULONRQg0AaQ0+yTREtVsPhz7/LmJH+wGlggb75bLx3yI3dr0alruHdUVta+quTvpwLJpGjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUJtdk2cV4wlpn0afeaxLQG2PxxtcwHQYDVR0OBBYEFOuugsT/oaxbUdTPJGEFAL5jvXeIMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA3YsaNIGl+tnbtOdle4QeFEwnt1uHakGGwrFHV1Azcifv5VRFfvZIlQxjLlxIPnDBAjAsimBE3CAfz+Wbw00pMMFIeFHZYO1qdfHrSsq+OM0luJfQyAW+8Mf3iwelccboDgdoYXV0aERhdGFYmMRs74KtG1Rkd1kdAIsIdZ7D5tLstPOUdL/qaWmSXQO3RQAAAAAAAAAAAAAAAAAAAAAAAAAAABRhYCgh40b6Uj1WdjckwPAdCwd4fKUBAgMmIAEhWCB2u9gNv5RsCNxtuiaa1GrD7YubV+8cbqWXV06B4QzbkSJYIPxOFDTKPhHeFVtz95qfUjl6pz9FcMWb+Mkj8uanW4QW"), + ), + AssertionExample( + id = ByteArray.fromBase64Url("YWAoIeNG-lI9VnY3JMDwHQsHeHw"), + clientData = new String( + ByteArray + .fromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVVdobmx5VTdlVzZBTEw1M1VPcENnU1N3ckEzNm92R3VpQUV6ZE91OFdTYyIsIm9yaWdpbiI6Imh0dHBzOi8vZGVtby55dWJpY28uY29tIn0=" + ) + .getBytes, + StandardCharsets.UTF_8, + ), + authDataBytes = ByteArray.fromBase64( + "xGzvgq0bVGR3WR0Aiwh1nsPm0uy085R0v+ppaZJdA7cFAAAAAA==" + ), + sig = + ByteArray.fromBase64("MEUCIQDkspL//pE98spvRtyTAZqPjmpd6/G+KmNsjMUfX7pKkAIgcld+Y3j0yt95CMqKmR99SKuoiitIL8SBElZw/qFEX5s="), + ), + ) + } From d329b8ece9afd0744e36bfb403e9098bf931bd8d Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 21 Apr 2021 01:03:20 +0200 Subject: [PATCH 4/5] Publish root project as dependency platform --- build.gradle | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/build.gradle b/build.gradle index 69cb2f628..3431ad5f1 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,8 @@ plugins { import io.franzbecker.gradle.lombok.LombokPlugin import io.franzbecker.gradle.lombok.task.DelombokTask +rootProject.description = "Metadata root for the com.yubico:webauthn-server-* module family" + project.ext.isCiBuild = System.env.CI == 'true' project.ext.publishEnabled = !isCiBuild && @@ -276,6 +278,66 @@ subprojects { project -> } } +// The root project has no sources, but the dependency platform also needs to be published as an artifact +// See https://docs.gradle.org/current/userguide/java_platform_plugin.html +// See https://github.com/Yubico/java-webauthn-server/issues/93#issuecomment-822806951 +if (publishEnabled) { + apply plugin: 'maven-publish' + apply plugin: 'signing' + + publishing { + publications { + jars(MavenPublication) { + from components.javaPlatform + + pom { + name = project.name + description = project.description + url = 'https://developers.yubico.com/java-webauthn-server/' + + developers { + developer { + id = 'emil' + name = 'Emil Lundberg' + email = 'emil@yubico.com' + } + } + + licenses { + license { + name = 'BSD-license' + comments = 'Revised 2-clause BSD license' + } + } + + scm { + url = 'scm:git:git://github.com/Yubico/java-webauthn-server.git' + connection = 'scm:git:git://github.com/Yubico/java-webauthn-server.git' + developerConnection = 'scm:git:ssh://git@github.com/Yubico/java-webauthn-server.git' + tag = 'HEAD' + } + } + } + } + + repositories { + maven { + name = "sonatypeNexus" + url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + credentials { + username ossrhUsername + password ossrhPassword + } + } + } + } + + signing { + useGpgCmd() + sign publishing.publications.jars + } +} + task pitestMerge(type: com.yubico.gradle.pitest.tasks.PitestMergeTask) coveralls { From 4b1841a88630a45257d432eed092eb3c7da6d300 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 21 Apr 2021 01:29:15 +0200 Subject: [PATCH 5/5] Mention webauthn-server-parent module in NEWS --- NEWS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NEWS b/NEWS index 857f0d373..7b36cf9c1 100644 --- a/NEWS +++ b/NEWS @@ -9,6 +9,11 @@ webauthn-server-core: * Added support for the `"apple"` attestation statement format. +Other: + +* Dependency versions moved to new meta-module `webauthn-server-parent`. Users + should never need to depend on `webauthn-server-parent` directly. + == Version 1.8.0 ==