Skip to content

Commit a13daee

Browse files
committed
Implement Apple attestation statement format
1 parent 00bab2d commit a13daee

File tree

11 files changed

+789
-1
lines changed

11 files changed

+789
-1
lines changed

NEWS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ webauthn-server-attestation:
55
* Fixed that `SimpleAttestationResolver` would return empty transports when
66
transports are unknown.
77

8+
webauthn-server-core:
9+
10+
* Added support for the `"apple"` attestation statement format.
11+
812

913
== Version 1.8.0 ==
1014

webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/DeviceIdentificationSpec.scala

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,51 @@ class DeviceIdentificationSpec extends FunSpec with Matchers {
151151
)
152152
}
153153
}
154+
155+
describe("fails to identify") {
156+
def check(testData: RealExamples.Example): Unit = {
157+
val rp = RelyingParty
158+
.builder()
159+
.identity(testData.rp)
160+
.credentialRepository(Helpers.CredentialRepository.empty)
161+
.metadataService(new StandardMetadataService())
162+
.build()
163+
164+
val result = rp.finishRegistration(
165+
FinishRegistrationOptions
166+
.builder()
167+
.request(
168+
PublicKeyCredentialCreationOptions
169+
.builder()
170+
.rp(testData.rp)
171+
.user(testData.user)
172+
.challenge(testData.attestation.challenge)
173+
.pubKeyCredParams(
174+
List(PublicKeyCredentialParameters.ES256).asJava
175+
)
176+
.build()
177+
)
178+
.response(testData.attestation.credential)
179+
.build()
180+
);
181+
182+
result.isAttestationTrusted should be(false)
183+
result.getAttestationMetadata.isPresent should be(true)
184+
result.getAttestationMetadata.get.getDeviceProperties.isPresent should be(
185+
false
186+
)
187+
result.getAttestationMetadata.get.getVendorProperties.isPresent should be(
188+
false
189+
)
190+
result.getAttestationMetadata.get.getTransports.isPresent should be(
191+
false
192+
)
193+
}
194+
195+
it("an Apple iOS device.") {
196+
check(RealExamples.AppleAttestationIos)
197+
}
198+
}
154199
}
155200

156201
describe("The default AttestationResolver") {
@@ -217,4 +262,134 @@ class DeviceIdentificationSpec extends FunSpec with Matchers {
217262
}
218263
}
219264

265+
describe(
266+
"A StandardMetadataService configured with an Apple root certificate"
267+
) {
268+
// Apple WebAuthn Root CA cert downloaded from https://www.apple.com/certificateauthority/private/ on 2021-04-12
269+
// https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem
270+
val mds = metadataService("""{
271+
| "identifier": "98cf2729-e2b9-4633-8b6a-b295cda99ccf",
272+
| "version": 1,
273+
| "vendorInfo": {
274+
| "name": "Apple Inc. (Metadata file by Yubico)"
275+
| },
276+
| "trustedCertificates": [
277+
| "-----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-----"
278+
| ],
279+
| "devices": [
280+
| {
281+
| "displayName": "Apple device",
282+
| "selectors": [
283+
| {
284+
| "type": "x509Extension",
285+
| "parameters": {
286+
| "key": "1.2.840.113635.100.8.2"
287+
| }
288+
| }
289+
| ]
290+
| }
291+
| ]
292+
|}""".stripMargin)
293+
294+
describe("successfully identifies") {
295+
def check(
296+
expectedName: String,
297+
testData: RealExamples.Example,
298+
): Unit = {
299+
val rp = RelyingParty
300+
.builder()
301+
.identity(testData.rp)
302+
.credentialRepository(Helpers.CredentialRepository.empty)
303+
.metadataService(mds)
304+
.build()
305+
306+
val result = rp.finishRegistration(
307+
FinishRegistrationOptions
308+
.builder()
309+
.request(
310+
PublicKeyCredentialCreationOptions
311+
.builder()
312+
.rp(testData.rp)
313+
.user(testData.user)
314+
.challenge(testData.attestation.challenge)
315+
.pubKeyCredParams(
316+
List(PublicKeyCredentialParameters.ES256).asJava
317+
)
318+
.build()
319+
)
320+
.response(testData.attestation.credential)
321+
.build()
322+
)
323+
324+
result.isAttestationTrusted should be(true)
325+
result.getAttestationMetadata.isPresent should be(true)
326+
result.getAttestationMetadata.get.getDeviceProperties.isPresent should be(
327+
true
328+
)
329+
result.getAttestationMetadata.get.getDeviceProperties
330+
.get()
331+
.get("displayName") should equal(expectedName)
332+
result.getAttestationMetadata.get.getTransports.isPresent should be(
333+
false
334+
)
335+
}
336+
337+
it("an Apple iOS device.") {
338+
check(
339+
"Apple device",
340+
RealExamples.AppleAttestationIos,
341+
)
342+
}
343+
344+
it("an Apple MacOS device.") {
345+
check(
346+
"Apple device",
347+
RealExamples.AppleAttestationMacos,
348+
)
349+
}
350+
}
351+
352+
describe("fails to identify") {
353+
def check(testData: RealExamples.Example): Unit = {
354+
val rp = RelyingParty
355+
.builder()
356+
.identity(testData.rp)
357+
.credentialRepository(Helpers.CredentialRepository.empty)
358+
.metadataService(mds)
359+
.build()
360+
361+
val result = rp.finishRegistration(
362+
FinishRegistrationOptions
363+
.builder()
364+
.request(
365+
PublicKeyCredentialCreationOptions
366+
.builder()
367+
.rp(testData.rp)
368+
.user(testData.user)
369+
.challenge(testData.attestation.challenge)
370+
.pubKeyCredParams(
371+
List(PublicKeyCredentialParameters.ES256).asJava
372+
)
373+
.build()
374+
)
375+
.response(testData.attestation.credential)
376+
.build()
377+
)
378+
379+
result.isAttestationTrusted should be(false)
380+
result.getAttestationMetadata.isPresent should be(true)
381+
result.getAttestationMetadata.get.getVendorProperties.isPresent should be(
382+
false
383+
)
384+
result.getAttestationMetadata.get.getDeviceProperties.isPresent should be(
385+
false
386+
)
387+
}
388+
389+
it("a YubiKey 5 NFC.") {
390+
check(RealExamples.YubiKey5)
391+
}
392+
}
393+
}
394+
220395
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2018, Yubico AB
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions are met:
6+
//
7+
// 1. Redistributions of source code must retain the above copyright notice, this
8+
// list of conditions and the following disclaimer.
9+
//
10+
// 2. Redistributions in binary form must reproduce the above copyright notice,
11+
// this list of conditions and the following disclaimer in the documentation
12+
// and/or other materials provided with the distribution.
13+
//
14+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15+
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16+
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18+
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19+
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20+
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21+
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22+
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
25+
package com.yubico.webauthn;
26+
27+
import com.yubico.internal.util.ExceptionUtil;
28+
import com.yubico.webauthn.data.AttestationObject;
29+
import com.yubico.webauthn.data.AttestationType;
30+
import com.yubico.webauthn.data.ByteArray;
31+
import java.security.PublicKey;
32+
import java.security.cert.CertificateException;
33+
import java.security.cert.X509Certificate;
34+
import java.util.Optional;
35+
import lombok.extern.slf4j.Slf4j;
36+
37+
@Slf4j
38+
final class AppleAttestationStatementVerifier
39+
implements AttestationStatementVerifier, X5cAttestationStatementVerifier {
40+
41+
private static final String NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2";
42+
43+
@Override
44+
public AttestationType getAttestationType(AttestationObject attestation) {
45+
return AttestationType.ANONYMIZATION_CA;
46+
}
47+
48+
@Override
49+
public boolean verifyAttestationSignature(
50+
AttestationObject attestationObject, ByteArray clientDataJsonHash) {
51+
final Optional<X509Certificate> attestationCert;
52+
try {
53+
attestationCert = getX5cAttestationCertificate(attestationObject);
54+
} catch (CertificateException e) {
55+
throw ExceptionUtil.wrapAndLog(
56+
log,
57+
String.format(
58+
"Failed to parse X.509 certificate from attestation object: %s", attestationObject),
59+
e);
60+
}
61+
62+
return attestationCert
63+
.map(
64+
attestationCertificate -> {
65+
final ByteArray nonceToHash =
66+
attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash);
67+
68+
final ByteArray nonce = Crypto.sha256(nonceToHash);
69+
70+
byte[] nonceExtension = attestationCertificate.getExtensionValue(NONCE_EXTENSION_OID);
71+
if (nonceExtension == null) {
72+
throw new IllegalArgumentException(
73+
"Apple anonymous attestation certificate must contain extension OID: "
74+
+ NONCE_EXTENSION_OID);
75+
}
76+
77+
// X.509 extension values is a DER octet string: 0x0426
78+
// Then the extension contains a 1-element sequence: 0x3024
79+
// The element has context-specific tag "[1]": 0xa122
80+
// Then the sequence contains a 32-byte octet string: 0x0420
81+
final ByteArray expectedExtensionValue =
82+
new ByteArray(
83+
new byte[] {
84+
0x04, 0x26, 0x30, 0x24, (-128) + (0xa1 - 128), 0x22, 0x04, 0x20
85+
})
86+
.concat(nonce);
87+
88+
if (!expectedExtensionValue.equals(new ByteArray(nonceExtension))) {
89+
throw new IllegalArgumentException(
90+
String.format(
91+
"Apple anonymous attestation certificate extension %s must equal nonceToHash. Expected: %s, was: %s",
92+
NONCE_EXTENSION_OID,
93+
expectedExtensionValue,
94+
new ByteArray(nonceExtension)));
95+
}
96+
97+
final PublicKey credentialPublicKey;
98+
try {
99+
credentialPublicKey =
100+
WebAuthnCodecs.importCosePublicKey(
101+
attestationObject
102+
.getAuthenticatorData()
103+
.getAttestedCredentialData()
104+
.get()
105+
.getCredentialPublicKey());
106+
} catch (Exception e) {
107+
throw ExceptionUtil.wrapAndLog(log, "Failed to import credential public key", e);
108+
}
109+
110+
final PublicKey certPublicKey = attestationCertificate.getPublicKey();
111+
112+
if (!credentialPublicKey.equals(certPublicKey)) {
113+
throw new IllegalArgumentException(
114+
String.format(
115+
"Apple anonymous attestation certificate subject public key must equal credential public key. Expected: %s, was: %s",
116+
credentialPublicKey, certPublicKey));
117+
}
118+
119+
return true;
120+
})
121+
.orElseThrow(
122+
() ->
123+
new IllegalArgumentException(
124+
"Failed to parse attestation certificate from \"apple\" attestation statement."));
125+
}
126+
}

webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,8 @@ public Optional<AttestationStatementVerifier> attestationStatementVerifier() {
405405
return Optional.of(new PackedAttestationStatementVerifier());
406406
case "android-safetynet":
407407
return Optional.of(new AndroidSafetynetAttestationStatementVerifier());
408+
case "apple":
409+
return Optional.of(new AppleAttestationStatementVerifier());
408410
default:
409411
return Optional.empty();
410412
}
@@ -502,11 +504,13 @@ public Optional<AttestationTrustResolver> trustResolver() {
502504
case UNKNOWN:
503505
return Optional.empty();
504506

507+
case ANONYMIZATION_CA:
505508
case ATTESTATION_CA:
506509
case BASIC:
507510
switch (attestation.getFormat()) {
508511
case "android-key":
509512
case "android-safetynet":
513+
case "apple":
510514
case "fido-u2f":
511515
case "packed":
512516
case "tpm":
@@ -544,6 +548,7 @@ public void validate() {
544548
assure(allowUntrustedAttestation, "Self attestation is not allowed.");
545549
break;
546550

551+
case ANONYMIZATION_CA:
547552
case ATTESTATION_CA:
548553
case BASIC:
549554
assure(
@@ -579,6 +584,7 @@ public boolean attestationTrusted() {
579584
case UNKNOWN:
580585
return false;
581586

587+
case ANONYMIZATION_CA:
582588
case ATTESTATION_CA:
583589
case BASIC:
584590
return attestationMetadata().filter(Attestation::isTrusted).isPresent();

webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationType.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,28 @@ public enum AttestationType {
7878
*/
7979
ATTESTATION_CA,
8080

81+
/**
82+
* In this case, the authenticator uses an Anonymization CA which dynamically generates
83+
* per-credential attestation certificates such that the attestation statements presented to
84+
* Relying Parties do not provide uniquely identifiable information, e.g., that might be used for
85+
* tracking purposes.
86+
*
87+
* <p>Note: Attestation statements conveying attestations of type AttCA or AnonCA use the same
88+
* data structure as those of type Basic, so the three attestation types are, in general,
89+
* distinguishable only with externally provided knowledge regarding the contents of the
90+
* attestation certificates conveyed in the attestation statement.
91+
*
92+
* <p>Note: Attestation statements conveying attestations of this type use the same data structure
93+
* as attestation statements conveying attestations of type #BASIC, so the two attestation types
94+
* are, in general, distinguishable only with externally provided knowledge regarding the contents
95+
* of the attestation certificates conveyed in the attestation statement.
96+
*
97+
* @see <a
98+
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#anonymization-ca">Anonymization
99+
* CA</a>
100+
*/
101+
ANONYMIZATION_CA,
102+
81103
/**
82104
* In this case, the Authenticator receives direct anonymous attestation (DAA) credentials from a
83105
* single DAA-Issuer. These DAA credentials are used along with blinding to sign the attested

0 commit comments

Comments
 (0)