Skip to content

Commit

Permalink
Release 0.8.0
Browse files Browse the repository at this point in the history
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
emlun committed Jan 21, 2019
2 parents 33c2041 + e5f067e commit cbd767d
Show file tree
Hide file tree
Showing 65 changed files with 1,145 additions and 679 deletions.
23 changes: 23 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
== Version 0.8.0 ==

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 https://github.com/Yubico/java-webauthn-server/pull/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.


== Version 0.7.0 ==

=== `webauthn-server-attestation` ===
Expand Down
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ subprojects { project ->
)

testCompile(
[group: 'junit', name: 'junit', version:'4.12'],
[group: 'org.mockito', name: 'mockito-core', version:'2.8.47'],
'junit:junit:4.12',
'org.mockito:mockito-core:2.8.47',
)

}
Expand Down
2 changes: 1 addition & 1 deletion webauthn-server-attestation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ dependencies {

compile(
project(':webauthn-server-core'),
'org.bouncycastle:bcpkix-jdk15on:1.54',
'com.google.guava:guava:19.0',
'org.bouncycastle:bcpkix-jdk15on:1.54',
)

testCompile(
Expand Down
1 change: 0 additions & 1 deletion webauthn-server-core/README
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ it.
* Attestation statement formats:
** https://www.w3.org/TR/webauthn/#tpm-attestation[`tpm`]
** https://www.w3.org/TR/webauthn/#android-key-attestation[`android-key`]
** https://www.w3.org/TR/webauthn/#android-safetynet-attestation[`android-safetynet`]
* Extensions:
** https://www.w3.org/TR/webauthn/#sctn-simple-txauth-extension[`txAuthSimple`]
** https://www.w3.org/TR/webauthn/#sctn-generic-txauth-extension[`txAuthGeneric`]
Expand Down
11 changes: 6 additions & 5 deletions webauthn-server-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,22 @@ dependencies {

compile(
project(':yubico-util'),
[group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version:'1.54'],
'com.augustcellars.cose:cose-java:0.9.4',
[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.core:jackson-databind:2.9.6',
'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.9.6',
'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.6',
'com.google.guava:guava:19.0',
'org.apache.httpcomponents:httpclient:4.5.2',
'org.bouncycastle:bcpkix-jdk15on:1.54',
)

testCompile(
project(':yubico-util-scala'),
'commons-io:commons-io:2.5',
'org.mockito:mockito-core:2.10.0',
'org.scala-lang:scala-library:2.11.3',
'org.scalatest:scalatest_2.11:3.0.4',
'org.scalacheck:scalacheck_2.11:1.13.5',
'org.scalatest:scalatest_2.11:3.0.4',
)

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,12 @@ public static String getSignatureAlgorithmName(PublicKey key) {
}
}

public static String jwsAlgorithmNameToJavaAlgorithmName(String alg) {
switch (alg) {
case "RS256":
return "SHA256withRSA";
}
throw new IllegalArgumentException("Unknown algorithm: " + alg);
}

}
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public class AssertionRequest {
* The username of the user to authenticate, if the user has already been identified.
* <p>
* If this is absent, this indicates that this is a request for an assertion by a <a
* href="https://w3c.github.io/webauthn/#client-side-resident-public-key-credential-source">client-side-resident
* href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#client-side-resident-public-key-credential-source">client-side-resident
* credential</a>, and identification of the user has been deferred until the response is received.
* </p>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,20 @@ public class AssertionResult {
private final boolean success;

/**
* The <a href="https://w3c.github.io/webauthn/#credential-id">credential ID</a> of the credential used for the
* assertion.
* The <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#credential-id">credential ID</a> of the credential
* used for the assertion.
*
* @see <a href="https://w3c.github.io/webauthn/#credential-id">Credential ID</a>
* @see <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#credential-id">Credential ID</a>
* @see PublicKeyCredentialRequestOptions#getAllowCredentials()
*/
@NonNull
private final ByteArray credentialId;

/**
* The <a href="https://w3c.github.io/webauthn/#user-handle">user handle</a> of the authenticated user.
* The <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#user-handle">user handle</a> of the authenticated
* user.
*
* @see <a href="https://w3c.github.io/webauthn/#user-handle">User Handle</a>
* @see <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#user-handle">User Handle</a>
* @see UserIdentity#getId()
* @see #getUsername()
*/
Expand All @@ -78,8 +79,8 @@ public class AssertionResult {
private final String username;

/**
* The new <a href="https://w3c.github.io/webauthn/#signcount">signature count</a> of the credential used for the
* assertion.
* The new <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#signcount">signature count</a> of the
* credential used for the assertion.
*
* <p>
* You should update this value in your database.
Expand All @@ -93,7 +94,8 @@ public class AssertionResult {
* <code>true</code> if and only if the {@link AuthenticatorData#getSignatureCounter() signature counter value}
* in the assertion was strictly greater than {@link RegisteredCredential#getSignatureCount() the stored one}.
*
* @see <a href="https://w3c.github.io/webauthn/#sec-authenticator-data">§6.1. Authenticator Data</a>
* @see <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#sec-authenticator-data">§6.1. Authenticator
* Data</a>
* @see AuthenticatorData#getSignatureCounter()
* @see RegisteredCredential#getSignatureCount()
* @see com.yubico.webauthn.RelyingParty.RelyingPartyBuilder#validateSignatureCounter(boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@
package com.yubico.webauthn;

import com.yubico.webauthn.attestation.Attestation;
import com.yubico.webauthn.data.AttestationObject;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.List;


interface AttestationTrustResolver {

Attestation resolveTrustAnchor(AttestationObject attestationObject) throws CertificateEncodingException;
Attestation resolveTrustAnchor(List<X509Certificate> certificateChain) throws CertificateEncodingException;

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public class FinishAssertionOptions {
/**
* The client's response to the {@link #getRequest() request}.
*
* @see <a href="https://w3c.github.io/webauthn/#getAssertion">navigator.credentials.get()</a>
* @see <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#getAssertion">navigator.credentials.get()</a>
*/
@NonNull
private final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> response;
Expand All @@ -58,7 +58,7 @@ public class FinishAssertionOptions {
* The <a href="https://tools.ietf.org/html/rfc8471#section-3.2">token binding ID</a> of the connection to the
* client, if any.
*
* @see <a href="https://w3c.github.io/webauthn/#discover-from-external-source">The Token Binding Protocol Version 1.0</a>
* @see <a href="https://tools.ietf.org/html/rfc8471">The Token Binding Protocol Version 1.0</a>
*/
@NonNull
@Builder.Default
Expand Down
Loading

0 comments on commit cbd767d

Please sign in to comment.