Skip to content

Commit

Permalink
Implement Apple attestation statement format
Browse files Browse the repository at this point in the history
  • Loading branch information
emlun committed Apr 19, 2021
1 parent 00bab2d commit a13daee
Show file tree
Hide file tree
Showing 11 changed files with 789 additions and 1 deletion.
4 changes: 4 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
}
}
}

}
Original file line number Diff line number Diff line change
@@ -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<X509Certificate> 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."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ public Optional<AttestationStatementVerifier> 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();
}
Expand Down Expand Up @@ -502,11 +504,13 @@ public Optional<AttestationTrustResolver> 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":
Expand Down Expand Up @@ -544,6 +548,7 @@ public void validate() {
assure(allowUntrustedAttestation, "Self attestation is not allowed.");
break;

case ANONYMIZATION_CA:
case ATTESTATION_CA:
case BASIC:
assure(
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
*
* <p>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 <a
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#anonymization-ca">Anonymization
* CA</a>
*/
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
Expand Down
Loading

0 comments on commit a13daee

Please sign in to comment.