-
Notifications
You must be signed in to change notification settings - Fork 145
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Possibly breaking changes: - User Presence (UP) is now always required by the spec, not only when UV is not required; implementation updated to reflect this. New features: - Added support for `android-safetynet` attestation statement format - Thanks to Ren Lin for the contribution, see #5 - Implementation updated to reflect Proposed Recommendation version of the spec, released 2019-01-17 Bug fixes: - Fixed validation of zero-valued assertion signature counter - Previously, a zero-valued assertion signature counter was always regarded as valid. Now, it is only considered valid if the stored signature counter is also zero.
- Loading branch information
Showing
65 changed files
with
1,145 additions
and
679 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
191 changes: 191 additions & 0 deletions
191
...-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
package com.yubico.webauthn; | ||
|
||
import javax.net.ssl.SSLException; | ||
|
||
import com.fasterxml.jackson.databind.JsonNode; | ||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
import com.fasterxml.jackson.databind.node.ArrayNode; | ||
import com.fasterxml.jackson.databind.node.JsonNodeFactory; | ||
import com.yubico.internal.util.CertificateParser; | ||
import com.yubico.internal.util.ExceptionUtil; | ||
import com.yubico.internal.util.WebAuthnCodecs; | ||
import com.yubico.webauthn.data.AttestationObject; | ||
import com.yubico.webauthn.data.AttestationType; | ||
import com.yubico.webauthn.data.ByteArray; | ||
import com.yubico.webauthn.data.exception.Base64UrlException; | ||
import java.io.IOException; | ||
import java.nio.charset.StandardCharsets; | ||
import java.security.InvalidKeyException; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.security.Signature; | ||
import java.security.SignatureException; | ||
import java.security.cert.CertificateException; | ||
import java.security.cert.X509Certificate; | ||
import java.util.ArrayList; | ||
import java.util.List; | ||
import lombok.Value; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.apache.http.conn.ssl.DefaultHostnameVerifier; | ||
|
||
@Slf4j | ||
class AndroidSafetynetAttestationStatementVerifier implements AttestationStatementVerifier, X5cAttestationStatementVerifier { | ||
|
||
private final BouncyCastleCrypto crypto = new BouncyCastleCrypto(); | ||
|
||
private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier(); | ||
|
||
@Override | ||
public AttestationType getAttestationType(AttestationObject attestation) { | ||
return AttestationType.BASIC; | ||
} | ||
|
||
@Override | ||
public JsonNode getX5cArray(AttestationObject attestationObject) { | ||
JsonNodeFactory jsonFactory = JsonNodeFactory.instance; | ||
ArrayNode array = jsonFactory.arrayNode(); | ||
for (JsonNode cert : parseJws(attestationObject).getHeader().get("x5c")) { | ||
array.add(jsonFactory.binaryNode(ByteArray.fromBase64(cert.textValue()).getBytes())); | ||
} | ||
return array; | ||
} | ||
|
||
@Override | ||
public boolean verifyAttestationSignature(AttestationObject attestationObject, ByteArray clientDataJsonHash) { | ||
final JsonNode ver = attestationObject.getAttestationStatement().get("ver"); | ||
|
||
if (ver == null || !ver.isTextual()) { | ||
throw new IllegalArgumentException("Property \"ver\" of android-safetynet attestation statement must be a string, was: " + ver); | ||
} | ||
|
||
JsonWebSignatureCustom jws = parseJws(attestationObject); | ||
|
||
if (!verifySignature(jws)) { | ||
return false; | ||
} | ||
|
||
JsonNode payload = jws.getPayload(); | ||
|
||
ByteArray signedData = attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash); | ||
ByteArray hashSignedData = crypto.hash(signedData); | ||
ByteArray nonceByteArray = ByteArray.fromBase64(payload.get("nonce").textValue()); | ||
ExceptionUtil.assure( | ||
hashSignedData.equals(nonceByteArray), | ||
"Nonce does not equal authenticator data + client data. Expected nonce: %s, was nonce: %s", | ||
hashSignedData.getBase64Url(), | ||
nonceByteArray.getBase64Url() | ||
); | ||
|
||
ExceptionUtil.assure( | ||
payload.get("ctsProfileMatch").booleanValue(), | ||
"Expected ctsProfileMatch to be true, was: %s", | ||
payload.get("ctsProfileMatch") | ||
); | ||
|
||
return true; | ||
} | ||
|
||
private static JsonWebSignatureCustom parseJws(AttestationObject attestationObject) { | ||
return new JsonWebSignatureCustom(new String(getResponseBytes(attestationObject).getBytes(), StandardCharsets.UTF_8)); | ||
} | ||
|
||
private static ByteArray getResponseBytes(AttestationObject attestationObject) { | ||
final JsonNode response = attestationObject.getAttestationStatement().get("response"); | ||
if (response == null || !response.isBinary()) { | ||
throw new IllegalArgumentException("Property \"response\" of android-safetynet attestation statement must be a binary value, was: " + response); | ||
} | ||
|
||
try { | ||
return new ByteArray(response.binaryValue()); | ||
} catch (IOException ioe) { | ||
throw ExceptionUtil.wrapAndLog(log, "response.isBinary() was true but response.binaryValue failed: " + response, ioe); | ||
} | ||
} | ||
|
||
private boolean verifySignature(JsonWebSignatureCustom jws) { | ||
// Verify the signature of the JWS and retrieve the signature certificate. | ||
X509Certificate attestationCertificate = jws.getX5c().get(0); | ||
|
||
String signatureAlgorithmName = WebAuthnCodecs.jwsAlgorithmNameToJavaAlgorithmName(jws.getAlgorithm()); | ||
|
||
Signature signatureVerifier; | ||
try { | ||
signatureVerifier = Signature.getInstance(signatureAlgorithmName, crypto.getProvider()); | ||
} catch (NoSuchAlgorithmException e) { | ||
throw ExceptionUtil.wrapAndLog(log, "Failed to get a Signature instance for " + signatureAlgorithmName, e); | ||
} | ||
try { | ||
signatureVerifier.initVerify(attestationCertificate.getPublicKey()); | ||
} catch (InvalidKeyException e) { | ||
throw ExceptionUtil.wrapAndLog(log, "Attestation key is invalid: " + attestationCertificate, e); | ||
} | ||
try { | ||
signatureVerifier.update(jws.getSignedBytes().getBytes()); | ||
} catch (SignatureException e) { | ||
throw ExceptionUtil.wrapAndLog(log, "Signature object in invalid state: " + signatureVerifier, e); | ||
} | ||
|
||
// Verify the hostname of the certificate. | ||
ExceptionUtil.assure( | ||
verifyHostname(attestationCertificate), | ||
"Certificate isn't issued for the hostname attest.android.com: %s", | ||
attestationCertificate | ||
); | ||
|
||
try { | ||
return signatureVerifier.verify(jws.getSignature().getBytes()); | ||
} catch (SignatureException e) { | ||
throw ExceptionUtil.wrapAndLog(log, "Failed to verify signature of JWS: " + jws, e); | ||
} | ||
} | ||
|
||
@Value | ||
private static class JsonWebSignatureCustom { | ||
public final JsonNode header; | ||
public final JsonNode payload; | ||
public final ByteArray signedBytes; | ||
public final ByteArray signature; | ||
public final List<X509Certificate> x5c; | ||
public final String algorithm; | ||
|
||
JsonWebSignatureCustom(String jwsCompact) { | ||
String[] parts = jwsCompact.split("\\."); | ||
ObjectMapper json = WebAuthnCodecs.json(); | ||
|
||
try { | ||
final ByteArray header = ByteArray.fromBase64Url(parts[0]); | ||
final ByteArray payload = ByteArray.fromBase64Url(parts[1]); | ||
|
||
this.header = json.readTree(header.getBytes()); | ||
this.payload = json.readTree(payload.getBytes()); | ||
this.signedBytes = new ByteArray((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8)); | ||
this.signature = ByteArray.fromBase64Url(parts[2]); | ||
this.x5c = getX5c(this.header); | ||
this.algorithm = this.header.get("alg").textValue(); | ||
} catch (IOException | Base64UrlException e) { | ||
throw ExceptionUtil.wrapAndLog(log, "Failed to parse JWS: " + jwsCompact, e); | ||
} catch (CertificateException e) { | ||
throw ExceptionUtil.wrapAndLog(log, "Failed to parse attestation certificates in JWS header: " + jwsCompact, e); | ||
} | ||
} | ||
|
||
private static List<X509Certificate> getX5c(JsonNode header) throws IOException, CertificateException { | ||
List<X509Certificate> result = new ArrayList<>(); | ||
for (JsonNode jsonNode : header.get("x5c")) { | ||
result.add(CertificateParser.parseDer(jsonNode.binaryValue())); | ||
} | ||
return result; | ||
} | ||
} | ||
|
||
/** | ||
* Verifies that the certificate matches the hostname "attest.android.com". | ||
*/ | ||
private static boolean verifyHostname(X509Certificate leafCert) { | ||
try { | ||
HOSTNAME_VERIFIER.verify("attest.android.com", leafCert); | ||
return true; | ||
} catch (SSLException e) { | ||
return false; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.