Skip to content

Commit 7bc544b

Browse files
author
Lin Ren
committed
Add Android safety-net attestation statement offline verification.
1 parent 38d7332 commit 7bc544b

File tree

4 files changed

+281
-20
lines changed

4 files changed

+281
-20
lines changed

webauthn-server-core/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ dependencies {
1515
[group: 'com.google.guava', name: 'guava', version:'19.0'],
1616
[group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version:'2.9.6'],
1717
[group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-cbor', version:'2.9.6'],
18-
'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.6',
18+
[group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jdk8', version:'2.9.6'],
19+
[group: 'com.google.http-client', name: 'google-http-client-jackson2', version:'1.22.0'],
20+
[group: 'org.apache.httpcomponents', name: 'httpclient', version:'4.5.2'],
1921
)
2022

2123
testCompile(
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.yubico.webauthn;
2+
3+
import com.google.api.client.json.webtoken.JsonWebSignature;
4+
import com.google.api.client.util.Base64;
5+
import com.google.api.client.util.Key;
6+
7+
8+
public class AndroidSafetynetAttestationStatement extends JsonWebSignature.Payload {
9+
/**
10+
* Embedded nonce sent as part of the request.
11+
*/
12+
@Key
13+
private String nonce;
14+
15+
/**
16+
* Timestamp of the request.
17+
*/
18+
@Key
19+
private long timestampMs;
20+
21+
/**
22+
* Package name of the APK that submitted this request.
23+
*/
24+
@Key
25+
private String apkPackageName;
26+
27+
/**
28+
* Digest of certificate of the APK that submitted this request.
29+
*/
30+
@Key
31+
private String[] apkCertificateDigestSha256;
32+
33+
/**
34+
* Digest of the APK that submitted this request.
35+
*/
36+
@Key
37+
private String apkDigestSha256;
38+
39+
/**
40+
* The device passed CTS and matches a known profile.
41+
*/
42+
@Key
43+
private boolean ctsProfileMatch;
44+
45+
46+
/**
47+
* The device has passed a basic integrity test, but the CTS profile could not be verified.
48+
*/
49+
@Key
50+
private boolean basicIntegrity;
51+
52+
public byte[] getNonce() {
53+
return Base64.decodeBase64(nonce);
54+
}
55+
56+
public long getTimestampMs() {
57+
return timestampMs;
58+
}
59+
60+
public String getApkPackageName() {
61+
return apkPackageName;
62+
}
63+
64+
public byte[] getApkDigestSha256() {
65+
return Base64.decodeBase64(apkDigestSha256);
66+
}
67+
68+
public byte[][] getApkCertificateDigestSha256() {
69+
byte[][] certs = new byte[apkCertificateDigestSha256.length][];
70+
for (int i = 0; i < apkCertificateDigestSha256.length; i++) {
71+
certs[i] = Base64.decodeBase64(apkCertificateDigestSha256[i]);
72+
}
73+
return certs;
74+
}
75+
76+
public boolean isCtsProfileMatch() {
77+
return ctsProfileMatch;
78+
}
79+
80+
public boolean hasBasicIntegrity() {
81+
return basicIntegrity;
82+
}
83+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package com.yubico.webauthn;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.yubico.internal.util.ExceptionUtil;
5+
import com.yubico.webauthn.data.AttestationObject;
6+
import com.yubico.webauthn.data.AttestationType;
7+
import com.yubico.webauthn.data.ByteArray;
8+
import java.nio.charset.Charset;
9+
import java.io.IOException;
10+
import javax.net.ssl.SSLException;
11+
import java.security.GeneralSecurityException;
12+
import java.security.cert.X509Certificate;
13+
import java.util.Arrays;
14+
import java.util.Optional;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import lombok.extern.slf4j.Slf4j;
18+
import com.google.api.client.json.webtoken.JsonWebSignature;
19+
import com.google.api.client.json.jackson2.JacksonFactory;
20+
import org.apache.http.conn.ssl.DefaultHostnameVerifier;
21+
22+
@Slf4j
23+
class AndroidSafetynetAttestationStatementVerifier implements AttestationStatementVerifier {
24+
25+
private final BouncyCastleCrypto crypto = new BouncyCastleCrypto();
26+
27+
private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier();
28+
29+
private X509Certificate mX5cCert = null;
30+
31+
public Optional<List<X509Certificate>> getAttestationTrustPath() {
32+
if (mX5cCert != null) {
33+
List<X509Certificate> certs = new ArrayList<>(1);
34+
certs.add(mX5cCert);
35+
return Optional.of(certs);
36+
}
37+
return Optional.empty();
38+
}
39+
40+
@Override
41+
public AttestationType getAttestationType(AttestationObject attestation) {
42+
return AttestationType.BASIC;
43+
}
44+
45+
@Override
46+
public boolean verifyAttestationSignature(AttestationObject attestationObject, ByteArray clientDataJsonHash) {
47+
final JsonNode ver = attestationObject.getAttestationStatement().get("ver");
48+
final JsonNode response = attestationObject.getAttestationStatement().get("response");
49+
50+
if (ver == null || !ver.isTextual() ) {
51+
throw new IllegalArgumentException("attStmt.ver must be set as text! " + ver.toString());
52+
}
53+
54+
if (response == null || !response.isBinary() ) {
55+
throw new IllegalArgumentException("attStmt.response must be set to a binary value.");
56+
}
57+
58+
String attStmtString;
59+
try {
60+
attStmtString = new String(response.binaryValue(), Charset.forName("UTF-8"));
61+
} catch (IOException ioe) {
62+
throw ExceptionUtil.wrapAndLog(log, "reponseNode.isBinary() was true but reponseNode.binaryValue() failed", ioe);
63+
}
64+
65+
if (attStmtString != null && !attStmtString.isEmpty()) {
66+
final AndroidSafetynetAttestationStatement attStmtObj = parseAndVerify(attStmtString);
67+
if (attStmtObj != null) {
68+
final byte[] nonce = attStmtObj.getNonce();
69+
final boolean isCtsProfileMatch = attStmtObj.isCtsProfileMatch();
70+
71+
if (isCtsProfileMatch) {
72+
// Verify that the nonce in the response is identical to the SHA-256 hash of
73+
// the concatenation of authenticatorData and clientDataHash.
74+
ByteArray signedData = attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash);
75+
ByteArray hashSignedData = crypto.hash(signedData);
76+
ByteArray nonceByteArray = new ByteArray(nonce);
77+
78+
final int compareResult = hashSignedData.compareTo(nonceByteArray);
79+
return (compareResult == 0);
80+
}
81+
}
82+
}
83+
84+
return false;
85+
}
86+
87+
/**
88+
* This code is copied from android-play-saftynet attestion sample.
89+
* @param signedAttestationStatment
90+
* @return
91+
*/
92+
private AndroidSafetynetAttestationStatement parseAndVerify(String signedAttestationStatment) {
93+
// Parse JSON Web Signature format.
94+
JsonWebSignature jws;
95+
try {
96+
jws = JsonWebSignature.parser(JacksonFactory.getDefaultInstance())
97+
.setPayloadClass(AndroidSafetynetAttestationStatement.class).parse(signedAttestationStatment);
98+
} catch (IOException e) {
99+
System.err.println("Failure: " + signedAttestationStatment + " is not valid JWS " +
100+
"format.");
101+
return null;
102+
}
103+
104+
// Verify the signature of the JWS and retrieve the signature certificate.
105+
X509Certificate cert;
106+
try {
107+
cert = jws.verifySignature();
108+
if (cert == null) {
109+
System.err.println("Failure: Signature verification failed.");
110+
return null;
111+
}
112+
} catch (GeneralSecurityException e) {
113+
System.err.println(
114+
"Failure: Error during cryptographic verification of the JWS signature.");
115+
return null;
116+
}
117+
118+
// Verify the hostname of the certificate.
119+
if (!verifyHostname("attest.android.com", cert)) {
120+
System.err.println("Failure: Certificate isn't issued for the hostname attest.android" +
121+
".com.");
122+
return null;
123+
}
124+
125+
// Save the cefrtificate
126+
mX5cCert = cert;
127+
128+
// Extract and use the payload data.
129+
AndroidSafetynetAttestationStatement stmt = (AndroidSafetynetAttestationStatement) jws.getPayload();
130+
return stmt;
131+
}
132+
133+
/**
134+
* Verifies that the certificate matches the specified hostname.
135+
* Uses the {@link DefaultHostnameVerifier} from the Apache HttpClient library
136+
* to confirm that the hostname matches the certificate.
137+
*
138+
* @param hostname
139+
* @param leafCert
140+
* @return
141+
*/
142+
private static boolean verifyHostname(String hostname, X509Certificate leafCert) {
143+
try {
144+
// Check that the hostname matches the certificate. This method throws an exception if
145+
// the cert could not be verified.
146+
HOSTNAME_VERIFIER.verify(hostname, leafCert);
147+
return true;
148+
} catch (SSLException e) {
149+
e.printStackTrace();
150+
}
151+
152+
return false;
153+
}
154+
}

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

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ private Optional<AttestationStatementVerifier> attestationStatementVerifier() {
416416
return Optional.of(new NoneAttestationStatementVerifier());
417417
case "packed":
418418
return Optional.of(new PackedAttestationStatementVerifier());
419+
case "android-safetynet":
420+
return Optional.of(new AndroidSafetynetAttestationStatementVerifier());
419421
default:
420422
return Optional.empty();
421423
}
@@ -457,6 +459,8 @@ public Optional<List<X509Certificate>> attestationTrustPath() {
457459
} catch (CertificateException e) {
458460
throw new IllegalArgumentException("Failed to resolve attestation trust path.", e);
459461
}
462+
} else if (attestationStatementVerifier instanceof AndroidSafetynetAttestationStatementVerifier) {
463+
return ((AndroidSafetynetAttestationStatementVerifier)attestationStatementVerifier).getAttestationTrustPath();
460464
} else {
461465
return Optional.empty();
462466
}
@@ -469,12 +473,20 @@ public class Step15 implements Step<Step16> {
469473
private final AttestationType attestationType;
470474
private final List<String> prevWarnings;
471475

476+
public String format() {
477+
return attestation.getFormat();
478+
}
479+
472480
@Override
473481
public void validate() {
474-
assure(
475-
attestationType == AttestationType.SELF_ATTESTATION || attestationType == NONE || trustResolver().isPresent(),
476-
"Failed to obtain attestation trust anchors."
477-
);
482+
final String attStmtFormat = format();
483+
final boolean isAndroidSafetynetFmt = attStmtFormat.equals("android-safetynet");
484+
if (!isAndroidSafetynetFmt) {
485+
assure(
486+
attestationType == AttestationType.SELF_ATTESTATION || attestationType == NONE || trustResolver().isPresent(),
487+
"Failed to obtain attestation trust anchors."
488+
);
489+
}
478490
}
479491

480492
@Override
@@ -492,6 +504,8 @@ public Optional<AttestationTrustResolver> trustResolver() {
492504
case "fido-u2f":
493505
case "packed":
494506
return metadataService.map(KnownX509TrustAnchorsTrustResolver::new);
507+
case "android-safetynet":
508+
return Optional.empty();
495509
default:
496510
throw new UnsupportedOperationException(String.format(
497511
"Attestation type %s is not supported for attestation statement format \"%s\".",
@@ -517,21 +531,23 @@ public class Step16 implements Step<Step17> {
517531

518532
@Override
519533
public void validate() {
520-
switch (attestationType) {
521-
case SELF_ATTESTATION:
522-
assure(allowUntrustedAttestation, "Self attestation is not allowed.");
523-
break;
524-
525-
case BASIC:
526-
assure(allowUntrustedAttestation || attestationTrusted(), "Failed to derive trust for attestation key.");
527-
break;
528-
529-
case NONE:
530-
assure(allowUntrustedAttestation, "No attestation is not allowed.");
531-
break;
532-
533-
default:
534-
throw new UnsupportedOperationException("Attestation type not implemented: " + attestationType);
534+
if (attestation.getFormat() != "android-safetynet") {
535+
switch (attestationType) {
536+
case SELF_ATTESTATION:
537+
assure(allowUntrustedAttestation, "Self attestation is not allowed.");
538+
break;
539+
540+
case BASIC:
541+
assure(allowUntrustedAttestation || attestationTrusted(), "Failed to derive trust for attestation key.");
542+
break;
543+
544+
case NONE:
545+
assure(allowUntrustedAttestation, "No attestation is not allowed.");
546+
break;
547+
548+
default:
549+
throw new UnsupportedOperationException("Attestation type not implemented: " + attestationType);
550+
}
535551
}
536552
}
537553

@@ -547,13 +563,19 @@ public boolean attestationTrusted() {
547563
return false;
548564

549565
case BASIC:
566+
if (attestation.getFormat() == "android-safetynet") {
567+
return true;
568+
}
550569
return attestationMetadata().filter(Attestation::isTrusted).isPresent();
551570
default:
552571
throw new UnsupportedOperationException("Attestation type not implemented: " + attestationType);
553572
}
554573
}
555574

556575
public Optional<Attestation> attestationMetadata() {
576+
if (attestation.getFormat() == "android-safetynet") {
577+
return Optional.empty();
578+
}
557579
return trustResolver.flatMap(tr -> {
558580
try {
559581
return Optional.of(tr.resolveTrustAnchor(attestation));

0 commit comments

Comments
 (0)