Skip to content

Commit cbd767d

Browse files
committed
Release 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 #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.
2 parents 33c2041 + e5f067e commit cbd767d

File tree

65 files changed

+1145
-679
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1145
-679
lines changed

NEWS

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
== Version 0.8.0 ==
2+
3+
Possibly breaking changes:
4+
5+
* User Presence (UP) is now always required by the spec, not only when UV is not
6+
required; implementation updated to reflect this.
7+
8+
9+
New features:
10+
11+
* Added support for `android-safetynet` attestation statement format
12+
** Thanks to Ren Lin for the contribution, see https://github.com/Yubico/java-webauthn-server/pull/5
13+
* Implementation updated to reflect Proposed Recommendation version of the spec,
14+
released 2019-01-17
15+
16+
Bug fixes:
17+
18+
* Fixed validation of zero-valued assertion signature counter
19+
** Previously, a zero-valued assertion signature counter was always regarded as
20+
valid. Now, it is only considered valid if the stored signature counter is
21+
also zero.
22+
23+
124
== Version 0.7.0 ==
225

326
=== `webauthn-server-attestation` ===

build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ subprojects { project ->
146146
)
147147

148148
testCompile(
149-
[group: 'junit', name: 'junit', version:'4.12'],
150-
[group: 'org.mockito', name: 'mockito-core', version:'2.8.47'],
149+
'junit:junit:4.12',
150+
'org.mockito:mockito-core:2.8.47',
151151
)
152152

153153
}

webauthn-server-attestation/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ dependencies {
88

99
compile(
1010
project(':webauthn-server-core'),
11-
'org.bouncycastle:bcpkix-jdk15on:1.54',
1211
'com.google.guava:guava:19.0',
12+
'org.bouncycastle:bcpkix-jdk15on:1.54',
1313
)
1414

1515
testCompile(

webauthn-server-core/README

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ it.
1717
* Attestation statement formats:
1818
** https://www.w3.org/TR/webauthn/#tpm-attestation[`tpm`]
1919
** https://www.w3.org/TR/webauthn/#android-key-attestation[`android-key`]
20-
** https://www.w3.org/TR/webauthn/#android-safetynet-attestation[`android-safetynet`]
2120
* Extensions:
2221
** https://www.w3.org/TR/webauthn/#sctn-simple-txauth-extension[`txAuthSimple`]
2322
** https://www.w3.org/TR/webauthn/#sctn-generic-txauth-extension[`txAuthGeneric`]

webauthn-server-core/build.gradle

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,22 @@ dependencies {
1010

1111
compile(
1212
project(':yubico-util'),
13-
[group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version:'1.54'],
1413
'com.augustcellars.cose:cose-java:0.9.4',
15-
[group: 'com.google.guava', name: 'guava', version:'19.0'],
16-
[group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version:'2.9.6'],
17-
[group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-cbor', version:'2.9.6'],
14+
'com.fasterxml.jackson.core:jackson-databind:2.9.6',
15+
'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.9.6',
1816
'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.9.6',
17+
'com.google.guava:guava:19.0',
18+
'org.apache.httpcomponents:httpclient:4.5.2',
19+
'org.bouncycastle:bcpkix-jdk15on:1.54',
1920
)
2021

2122
testCompile(
2223
project(':yubico-util-scala'),
2324
'commons-io:commons-io:2.5',
2425
'org.mockito:mockito-core:2.10.0',
2526
'org.scala-lang:scala-library:2.11.3',
26-
'org.scalatest:scalatest_2.11:3.0.4',
2727
'org.scalacheck:scalacheck_2.11:1.13.5',
28+
'org.scalatest:scalatest_2.11:3.0.4',
2829
)
2930

3031
}

webauthn-server-core/src/main/java/com/yubico/internal/util/WebAuthnCodecs.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,12 @@ public static String getSignatureAlgorithmName(PublicKey key) {
135135
}
136136
}
137137

138+
public static String jwsAlgorithmNameToJavaAlgorithmName(String alg) {
139+
switch (alg) {
140+
case "RS256":
141+
return "SHA256withRSA";
142+
}
143+
throw new IllegalArgumentException("Unknown algorithm: " + alg);
144+
}
145+
138146
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package com.yubico.webauthn;
2+
3+
import javax.net.ssl.SSLException;
4+
5+
import com.fasterxml.jackson.databind.JsonNode;
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import com.fasterxml.jackson.databind.node.ArrayNode;
8+
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
9+
import com.yubico.internal.util.CertificateParser;
10+
import com.yubico.internal.util.ExceptionUtil;
11+
import com.yubico.internal.util.WebAuthnCodecs;
12+
import com.yubico.webauthn.data.AttestationObject;
13+
import com.yubico.webauthn.data.AttestationType;
14+
import com.yubico.webauthn.data.ByteArray;
15+
import com.yubico.webauthn.data.exception.Base64UrlException;
16+
import java.io.IOException;
17+
import java.nio.charset.StandardCharsets;
18+
import java.security.InvalidKeyException;
19+
import java.security.NoSuchAlgorithmException;
20+
import java.security.Signature;
21+
import java.security.SignatureException;
22+
import java.security.cert.CertificateException;
23+
import java.security.cert.X509Certificate;
24+
import java.util.ArrayList;
25+
import java.util.List;
26+
import lombok.Value;
27+
import lombok.extern.slf4j.Slf4j;
28+
import org.apache.http.conn.ssl.DefaultHostnameVerifier;
29+
30+
@Slf4j
31+
class AndroidSafetynetAttestationStatementVerifier implements AttestationStatementVerifier, X5cAttestationStatementVerifier {
32+
33+
private final BouncyCastleCrypto crypto = new BouncyCastleCrypto();
34+
35+
private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier();
36+
37+
@Override
38+
public AttestationType getAttestationType(AttestationObject attestation) {
39+
return AttestationType.BASIC;
40+
}
41+
42+
@Override
43+
public JsonNode getX5cArray(AttestationObject attestationObject) {
44+
JsonNodeFactory jsonFactory = JsonNodeFactory.instance;
45+
ArrayNode array = jsonFactory.arrayNode();
46+
for (JsonNode cert : parseJws(attestationObject).getHeader().get("x5c")) {
47+
array.add(jsonFactory.binaryNode(ByteArray.fromBase64(cert.textValue()).getBytes()));
48+
}
49+
return array;
50+
}
51+
52+
@Override
53+
public boolean verifyAttestationSignature(AttestationObject attestationObject, ByteArray clientDataJsonHash) {
54+
final JsonNode ver = attestationObject.getAttestationStatement().get("ver");
55+
56+
if (ver == null || !ver.isTextual()) {
57+
throw new IllegalArgumentException("Property \"ver\" of android-safetynet attestation statement must be a string, was: " + ver);
58+
}
59+
60+
JsonWebSignatureCustom jws = parseJws(attestationObject);
61+
62+
if (!verifySignature(jws)) {
63+
return false;
64+
}
65+
66+
JsonNode payload = jws.getPayload();
67+
68+
ByteArray signedData = attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash);
69+
ByteArray hashSignedData = crypto.hash(signedData);
70+
ByteArray nonceByteArray = ByteArray.fromBase64(payload.get("nonce").textValue());
71+
ExceptionUtil.assure(
72+
hashSignedData.equals(nonceByteArray),
73+
"Nonce does not equal authenticator data + client data. Expected nonce: %s, was nonce: %s",
74+
hashSignedData.getBase64Url(),
75+
nonceByteArray.getBase64Url()
76+
);
77+
78+
ExceptionUtil.assure(
79+
payload.get("ctsProfileMatch").booleanValue(),
80+
"Expected ctsProfileMatch to be true, was: %s",
81+
payload.get("ctsProfileMatch")
82+
);
83+
84+
return true;
85+
}
86+
87+
private static JsonWebSignatureCustom parseJws(AttestationObject attestationObject) {
88+
return new JsonWebSignatureCustom(new String(getResponseBytes(attestationObject).getBytes(), StandardCharsets.UTF_8));
89+
}
90+
91+
private static ByteArray getResponseBytes(AttestationObject attestationObject) {
92+
final JsonNode response = attestationObject.getAttestationStatement().get("response");
93+
if (response == null || !response.isBinary()) {
94+
throw new IllegalArgumentException("Property \"response\" of android-safetynet attestation statement must be a binary value, was: " + response);
95+
}
96+
97+
try {
98+
return new ByteArray(response.binaryValue());
99+
} catch (IOException ioe) {
100+
throw ExceptionUtil.wrapAndLog(log, "response.isBinary() was true but response.binaryValue failed: " + response, ioe);
101+
}
102+
}
103+
104+
private boolean verifySignature(JsonWebSignatureCustom jws) {
105+
// Verify the signature of the JWS and retrieve the signature certificate.
106+
X509Certificate attestationCertificate = jws.getX5c().get(0);
107+
108+
String signatureAlgorithmName = WebAuthnCodecs.jwsAlgorithmNameToJavaAlgorithmName(jws.getAlgorithm());
109+
110+
Signature signatureVerifier;
111+
try {
112+
signatureVerifier = Signature.getInstance(signatureAlgorithmName, crypto.getProvider());
113+
} catch (NoSuchAlgorithmException e) {
114+
throw ExceptionUtil.wrapAndLog(log, "Failed to get a Signature instance for " + signatureAlgorithmName, e);
115+
}
116+
try {
117+
signatureVerifier.initVerify(attestationCertificate.getPublicKey());
118+
} catch (InvalidKeyException e) {
119+
throw ExceptionUtil.wrapAndLog(log, "Attestation key is invalid: " + attestationCertificate, e);
120+
}
121+
try {
122+
signatureVerifier.update(jws.getSignedBytes().getBytes());
123+
} catch (SignatureException e) {
124+
throw ExceptionUtil.wrapAndLog(log, "Signature object in invalid state: " + signatureVerifier, e);
125+
}
126+
127+
// Verify the hostname of the certificate.
128+
ExceptionUtil.assure(
129+
verifyHostname(attestationCertificate),
130+
"Certificate isn't issued for the hostname attest.android.com: %s",
131+
attestationCertificate
132+
);
133+
134+
try {
135+
return signatureVerifier.verify(jws.getSignature().getBytes());
136+
} catch (SignatureException e) {
137+
throw ExceptionUtil.wrapAndLog(log, "Failed to verify signature of JWS: " + jws, e);
138+
}
139+
}
140+
141+
@Value
142+
private static class JsonWebSignatureCustom {
143+
public final JsonNode header;
144+
public final JsonNode payload;
145+
public final ByteArray signedBytes;
146+
public final ByteArray signature;
147+
public final List<X509Certificate> x5c;
148+
public final String algorithm;
149+
150+
JsonWebSignatureCustom(String jwsCompact) {
151+
String[] parts = jwsCompact.split("\\.");
152+
ObjectMapper json = WebAuthnCodecs.json();
153+
154+
try {
155+
final ByteArray header = ByteArray.fromBase64Url(parts[0]);
156+
final ByteArray payload = ByteArray.fromBase64Url(parts[1]);
157+
158+
this.header = json.readTree(header.getBytes());
159+
this.payload = json.readTree(payload.getBytes());
160+
this.signedBytes = new ByteArray((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8));
161+
this.signature = ByteArray.fromBase64Url(parts[2]);
162+
this.x5c = getX5c(this.header);
163+
this.algorithm = this.header.get("alg").textValue();
164+
} catch (IOException | Base64UrlException e) {
165+
throw ExceptionUtil.wrapAndLog(log, "Failed to parse JWS: " + jwsCompact, e);
166+
} catch (CertificateException e) {
167+
throw ExceptionUtil.wrapAndLog(log, "Failed to parse attestation certificates in JWS header: " + jwsCompact, e);
168+
}
169+
}
170+
171+
private static List<X509Certificate> getX5c(JsonNode header) throws IOException, CertificateException {
172+
List<X509Certificate> result = new ArrayList<>();
173+
for (JsonNode jsonNode : header.get("x5c")) {
174+
result.add(CertificateParser.parseDer(jsonNode.binaryValue()));
175+
}
176+
return result;
177+
}
178+
}
179+
180+
/**
181+
* Verifies that the certificate matches the hostname "attest.android.com".
182+
*/
183+
private static boolean verifyHostname(X509Certificate leafCert) {
184+
try {
185+
HOSTNAME_VERIFIER.verify("attest.android.com", leafCert);
186+
return true;
187+
} catch (SSLException e) {
188+
return false;
189+
}
190+
}
191+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public class AssertionRequest {
5454
* The username of the user to authenticate, if the user has already been identified.
5555
* <p>
5656
* If this is absent, this indicates that this is a request for an assertion by a <a
57-
* href="https://w3c.github.io/webauthn/#client-side-resident-public-key-credential-source">client-side-resident
57+
* href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#client-side-resident-public-key-credential-source">client-side-resident
5858
* credential</a>, and identification of the user has been deferred until the response is received.
5959
* </p>
6060
*/

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,20 @@ public class AssertionResult {
5050
private final boolean success;
5151

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

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

8081
/**
81-
* The new <a href="https://w3c.github.io/webauthn/#signcount">signature count</a> of the credential used for the
82-
* assertion.
82+
* The new <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#signcount">signature count</a> of the
83+
* credential used for the assertion.
8384
*
8485
* <p>
8586
* You should update this value in your database.
@@ -93,7 +94,8 @@ public class AssertionResult {
9394
* <code>true</code> if and only if the {@link AuthenticatorData#getSignatureCounter() signature counter value}
9495
* in the assertion was strictly greater than {@link RegisteredCredential#getSignatureCount() the stored one}.
9596
*
96-
* @see <a href="https://w3c.github.io/webauthn/#sec-authenticator-data">§6.1. Authenticator Data</a>
97+
* @see <a href="https://www.w3.org/TR/2019/PR-webauthn-20190117/#sec-authenticator-data">§6.1. Authenticator
98+
* Data</a>
9799
* @see AuthenticatorData#getSignatureCounter()
98100
* @see RegisteredCredential#getSignatureCount()
99101
* @see com.yubico.webauthn.RelyingParty.RelyingPartyBuilder#validateSignatureCounter(boolean)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@
2525
package com.yubico.webauthn;
2626

2727
import com.yubico.webauthn.attestation.Attestation;
28-
import com.yubico.webauthn.data.AttestationObject;
2928
import java.security.cert.CertificateEncodingException;
29+
import java.security.cert.X509Certificate;
30+
import java.util.List;
3031

3132

3233
interface AttestationTrustResolver {
3334

34-
Attestation resolveTrustAnchor(AttestationObject attestationObject) throws CertificateEncodingException;
35+
Attestation resolveTrustAnchor(List<X509Certificate> certificateChain) throws CertificateEncodingException;
3536

3637
}

0 commit comments

Comments
 (0)