Skip to content

Add Android safety-net attestation statement offline verification. #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion webauthn-server-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ dependencies {
[group: 'com.google.guava', name: 'guava', version:'19.0'],
[group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version:'2.9.6'],
[group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-cbor', version:'2.9.6'],
'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.6',
[group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jdk8', version:'2.9.6'],
[group: 'com.google.http-client', name: 'google-http-client-jackson2', version:'1.22.0'],
[group: 'org.apache.httpcomponents', name: 'httpclient', version:'4.5.2'],
)

testCompile(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.yubico.webauthn;

import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.api.client.util.Base64;
import com.google.api.client.util.Key;


public class AndroidSafetynetAttestationStatement extends JsonWebSignature.Payload {
/**
* Embedded nonce sent as part of the request.
*/
@Key
private String nonce;

/**
* Timestamp of the request.
*/
@Key
private long timestampMs;

/**
* Package name of the APK that submitted this request.
*/
@Key
private String apkPackageName;

/**
* Digest of certificate of the APK that submitted this request.
*/
@Key
private String[] apkCertificateDigestSha256;

/**
* Digest of the APK that submitted this request.
*/
@Key
private String apkDigestSha256;

/**
* The device passed CTS and matches a known profile.
*/
@Key
private boolean ctsProfileMatch;


/**
* The device has passed a basic integrity test, but the CTS profile could not be verified.
*/
@Key
private boolean basicIntegrity;

public byte[] getNonce() {
return Base64.decodeBase64(nonce);
}

public long getTimestampMs() {
return timestampMs;
}

public String getApkPackageName() {
return apkPackageName;
}

public byte[] getApkDigestSha256() {
return Base64.decodeBase64(apkDigestSha256);
}

public byte[][] getApkCertificateDigestSha256() {
byte[][] certs = new byte[apkCertificateDigestSha256.length][];
for (int i = 0; i < apkCertificateDigestSha256.length; i++) {
certs[i] = Base64.decodeBase64(apkCertificateDigestSha256[i]);
}
return certs;
}

public boolean isCtsProfileMatch() {
return ctsProfileMatch;
}

public boolean hasBasicIntegrity() {
return basicIntegrity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.yubico.webauthn;

import com.fasterxml.jackson.databind.JsonNode;
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.nio.charset.Charset;
import java.io.IOException;
import javax.net.ssl.SSLException;
import java.security.GeneralSecurityException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Optional;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.api.client.json.jackson2.JacksonFactory;
import org.apache.http.conn.ssl.DefaultHostnameVerifier;

@Slf4j
class AndroidSafetynetAttestationStatementVerifier implements AttestationStatementVerifier {

private final BouncyCastleCrypto crypto = new BouncyCastleCrypto();

private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier();

private X509Certificate mX5cCert = null;

public Optional<List<X509Certificate>> getAttestationTrustPath() {
if (mX5cCert != null) {
List<X509Certificate> certs = new ArrayList<>(1);
certs.add(mX5cCert);
return Optional.of(certs);
}
return Optional.empty();
}

@Override
public AttestationType getAttestationType(AttestationObject attestation) {
return AttestationType.BASIC;
}

@Override
public boolean verifyAttestationSignature(AttestationObject attestationObject, ByteArray clientDataJsonHash) {
final JsonNode ver = attestationObject.getAttestationStatement().get("ver");
final JsonNode response = attestationObject.getAttestationStatement().get("response");

if (ver == null || !ver.isTextual() ) {
throw new IllegalArgumentException("attStmt.ver must be set as text! " + ver.toString());
}

if (response == null || !response.isBinary() ) {
throw new IllegalArgumentException("attStmt.response must be set to a binary value.");
}

String attStmtString;
try {
attStmtString = new String(response.binaryValue(), Charset.forName("UTF-8"));
} catch (IOException ioe) {
throw ExceptionUtil.wrapAndLog(log, "reponseNode.isBinary() was true but reponseNode.binaryValue() failed", ioe);
}

if (attStmtString != null && !attStmtString.isEmpty()) {
final AndroidSafetynetAttestationStatement attStmtObj = parseAndVerify(attStmtString);
if (attStmtObj != null) {
final byte[] nonce = attStmtObj.getNonce();
final boolean isCtsProfileMatch = attStmtObj.isCtsProfileMatch();

if (isCtsProfileMatch) {
// Verify that the nonce in the response is identical to the SHA-256 hash of
// the concatenation of authenticatorData and clientDataHash.
ByteArray signedData = attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash);
ByteArray hashSignedData = crypto.hash(signedData);
ByteArray nonceByteArray = new ByteArray(nonce);

final int compareResult = hashSignedData.compareTo(nonceByteArray);
return (compareResult == 0);
}
}
}

return false;
}

/**
* This code is copied from android-play-saftynet attestion sample.
* @param signedAttestationStatment
* @return
*/
private AndroidSafetynetAttestationStatement parseAndVerify(String signedAttestationStatment) {
// Parse JSON Web Signature format.
JsonWebSignature jws;
try {
jws = JsonWebSignature.parser(JacksonFactory.getDefaultInstance())
.setPayloadClass(AndroidSafetynetAttestationStatement.class).parse(signedAttestationStatment);
} catch (IOException e) {
System.err.println("Failure: " + signedAttestationStatment + " is not valid JWS " +
"format.");
return null;
}

// Verify the signature of the JWS and retrieve the signature certificate.
X509Certificate cert;
try {
cert = jws.verifySignature();
if (cert == null) {
System.err.println("Failure: Signature verification failed.");
return null;
}
} catch (GeneralSecurityException e) {
System.err.println(
"Failure: Error during cryptographic verification of the JWS signature.");
return null;
}

// Verify the hostname of the certificate.
if (!verifyHostname("attest.android.com", cert)) {
System.err.println("Failure: Certificate isn't issued for the hostname attest.android" +
".com.");
return null;
}

// Save the cefrtificate
mX5cCert = cert;

// Extract and use the payload data.
AndroidSafetynetAttestationStatement stmt = (AndroidSafetynetAttestationStatement) jws.getPayload();
return stmt;
}

/**
* Verifies that the certificate matches the specified hostname.
* Uses the {@link DefaultHostnameVerifier} from the Apache HttpClient library
* to confirm that the hostname matches the certificate.
*
* @param hostname
* @param leafCert
* @return
*/
private static boolean verifyHostname(String hostname, X509Certificate leafCert) {
try {
// Check that the hostname matches the certificate. This method throws an exception if
// the cert could not be verified.
HOSTNAME_VERIFIER.verify(hostname, leafCert);
return true;
} catch (SSLException e) {
e.printStackTrace();
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ private Optional<AttestationStatementVerifier> attestationStatementVerifier() {
return Optional.of(new NoneAttestationStatementVerifier());
case "packed":
return Optional.of(new PackedAttestationStatementVerifier());
case "android-safetynet":
return Optional.of(new AndroidSafetynetAttestationStatementVerifier());
default:
return Optional.empty();
}
Expand Down Expand Up @@ -457,6 +459,8 @@ public Optional<List<X509Certificate>> attestationTrustPath() {
} catch (CertificateException e) {
throw new IllegalArgumentException("Failed to resolve attestation trust path.", e);
}
} else if (attestationStatementVerifier instanceof AndroidSafetynetAttestationStatementVerifier) {
return ((AndroidSafetynetAttestationStatementVerifier)attestationStatementVerifier).getAttestationTrustPath();
} else {
return Optional.empty();
}
Expand All @@ -469,12 +473,20 @@ public class Step15 implements Step<Step16> {
private final AttestationType attestationType;
private final List<String> prevWarnings;

public String format() {
return attestation.getFormat();
}

@Override
public void validate() {
assure(
attestationType == AttestationType.SELF_ATTESTATION || attestationType == NONE || trustResolver().isPresent(),
"Failed to obtain attestation trust anchors."
);
final String attStmtFormat = format();
final boolean isAndroidSafetynetFmt = attStmtFormat.equals("android-safetynet");
if (!isAndroidSafetynetFmt) {
assure(
attestationType == AttestationType.SELF_ATTESTATION || attestationType == NONE || trustResolver().isPresent(),
"Failed to obtain attestation trust anchors."
);
}
}

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

@Override
public void validate() {
switch (attestationType) {
case SELF_ATTESTATION:
assure(allowUntrustedAttestation, "Self attestation is not allowed.");
break;

case BASIC:
assure(allowUntrustedAttestation || attestationTrusted(), "Failed to derive trust for attestation key.");
break;

case NONE:
assure(allowUntrustedAttestation, "No attestation is not allowed.");
break;

default:
throw new UnsupportedOperationException("Attestation type not implemented: " + attestationType);
if (attestation.getFormat() != "android-safetynet") {
switch (attestationType) {
case SELF_ATTESTATION:
assure(allowUntrustedAttestation, "Self attestation is not allowed.");
break;

case BASIC:
assure(allowUntrustedAttestation || attestationTrusted(), "Failed to derive trust for attestation key.");
break;

case NONE:
assure(allowUntrustedAttestation, "No attestation is not allowed.");
break;

default:
throw new UnsupportedOperationException("Attestation type not implemented: " + attestationType);
}
}
}

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

case BASIC:
if (attestation.getFormat() == "android-safetynet") {
return true;
}
return attestationMetadata().filter(Attestation::isTrusted).isPresent();
default:
throw new UnsupportedOperationException("Attestation type not implemented: " + attestationType);
}
}

public Optional<Attestation> attestationMetadata() {
if (attestation.getFormat() == "android-safetynet") {
return Optional.empty();
}
return trustResolver.flatMap(tr -> {
try {
return Optional.of(tr.resolveTrustAnchor(attestation));
Expand Down