Skip to content
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

add support for supplying multiple public keys, in order to gracefully support certificate rotation #241

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ba3ae16
add support for supplying multiple public keys that will be attempted…
thepeachbeetle Jul 25, 2017
f1e1eec
remove some <>'s and undo some accidental codestyle changes
thepeachbeetle Jul 25, 2017
75bd8a4
tests
thepeachbeetle Jul 26, 2017
b672444
Revert "remove some <>'s and undo some accidental codestyle changes"
thepeachbeetle Jul 26, 2017
3374298
Revert "add support for supplying multiple public keys that will be a…
thepeachbeetle Jul 26, 2017
e8ba619
tests
thepeachbeetle Jul 26, 2017
22830ed
Revert "tests"
thepeachbeetle Jul 26, 2017
08edd6f
support multiple private keys when validating signature of a token, e…
thepeachbeetle Jul 26, 2017
c1e9e9e
only retrieve either a single key or a collection of keys from the si…
thepeachbeetle Jul 26, 2017
ef42b0d
test coverage
thepeachbeetle Jul 26, 2017
35c674e
test coverage
thepeachbeetle Jul 27, 2017
2191bf8
re run the tests
thepeachbeetle Jul 27, 2017
25860fb
try both resolveKey and resolveKeys before throwing any exception gen…
thepeachbeetle Jul 27, 2017
3affd83
allow both resolveKey and resolveKeys to be evanulated when parsing p…
thepeachbeetle Jul 27, 2017
c1c25ba
additional tests to increase coverage
thepeachbeetle Jul 27, 2017
1e22d8f
re trigger tests
thepeachbeetle Jul 27, 2017
abde7e0
explicitely use a Date for 'expiration' in the testParseRequireExpira…
thepeachbeetle Jul 27, 2017
9f0c6ee
revert everything
thepeachbeetle Jul 27, 2017
6396313
supporting multiple public keys in SigningKeyResolver
thepeachbeetle Jul 27, 2017
c2d34cd
Merge pull request #1 from thepeachbeetle/attempt-1
Jul 27, 2017
aebb76f
Merge pull request #2 from thepeachbeetle/master
Jul 27, 2017
284c001
Merge pull request #3 from jwtk/master
Jul 27, 2017
f3d037e
Merge pull request #4 from thepeachbeetle/attempt-1
Jul 27, 2017
7bb498f
Merge pull request #5 from thepeachbeetle/master
Jul 27, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/main/java/io/jsonwebtoken/SigningKeyResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.jsonwebtoken;

import java.security.Key;
import java.util.Collection;

/**
* A {@code SigningKeyResolver} can be used by a {@link io.jsonwebtoken.JwtParser JwtParser} to find a signing key that
Expand Down Expand Up @@ -60,6 +61,17 @@ public interface SigningKeyResolver {
*/
Key resolveSigningKey(JwsHeader header, Claims claims);

/**
* Returns a collection signing key that should be used to attempt to validate a digital signature for the Claims JWS with the specified
* header and claims. This allows for a key rotation scenario to support multiple keys during an overlap period.
*
* @param header the header of the JWS to validate
* @param claims the claims (body) of the JWS to validate
* @return the signing key that should be used to validate a digital signature for the Claims JWS with the specified
* header and claims.
*/
Collection<Key> resolveSigningKeys(JwsHeader header, Claims claims);

/**
* Returns the signing key that should be used to validate a digital signature for the Plaintext JWS with the
* specified header and plaintext payload.
Expand All @@ -70,4 +82,16 @@ public interface SigningKeyResolver {
* specified header and plaintext payload.
*/
Key resolveSigningKey(JwsHeader header, String plaintext);

/**
* Returns the signing key that should be used to attempt to validate a digital signature for the Plaintext JWS with the
* specified header and plaintext payload. This allows for a key rotation scenario to support multiple keys during an overlap period.
*
* @param header the header of the JWS to validate
* @param plaintext the plaintext body of the JWS to validate
* @return the signing key that should be used to validate a digital signature for the Plaintext JWS with the
* specified header and plaintext payload.
*/
Collection<Key> resolveSigningKeys(JwsHeader header, String plaintext);

}
75 changes: 75 additions & 0 deletions src/main/java/io/jsonwebtoken/SigningKeyResolverAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.util.ArrayList;
import java.util.Collection;

/**
* An <a href="http://en.wikipedia.org/wiki/Adapter_pattern">Adapter</a> implementation of the
Expand Down Expand Up @@ -48,9 +50,28 @@ public Key resolveSigningKey(JwsHeader header, Claims claims) {
"Override the resolveSigningKey(JwsHeader, Claims) method instead and return a " +
"Key instance appropriate for the " + alg.name() + " algorithm.");
byte[] keyBytes = resolveSigningKeyBytes(header, claims);
if (keyBytes == null)
return null;
return new SecretKeySpec(keyBytes, alg.getJcaName());
}

@Override
public Collection<Key> resolveSigningKeys(JwsHeader header, Claims claims) {
SignatureAlgorithm alg = SignatureAlgorithm.forName(header.getAlgorithm());
Assert.isTrue(alg.isHmac(), "The default resolveSigningKey(JwsHeader, Claims) implementation cannot be " +
"used for asymmetric key algorithms (RSA, Elliptic Curve). " +
"Override the resolveSigningKey(JwsHeader, Claims) method instead and return a " +
"Key instance appropriate for the " + alg.name() + " algorithm.");
Collection<byte[]> keysBytes = resolveSigningKeysBytes(header, claims);
if (keysBytes == null)
return null;
Collection<Key> keys = new ArrayList<Key>();
for (byte[] keyBytes: keysBytes)
keys.add(new SecretKeySpec(keyBytes, alg.getJcaName()));

return keys;
}

@Override
public Key resolveSigningKey(JwsHeader header, String plaintext) {
SignatureAlgorithm alg = SignatureAlgorithm.forName(header.getAlgorithm());
Expand All @@ -59,9 +80,28 @@ public Key resolveSigningKey(JwsHeader header, String plaintext) {
"Override the resolveSigningKey(JwsHeader, String) method instead and return a " +
"Key instance appropriate for the " + alg.name() + " algorithm.");
byte[] keyBytes = resolveSigningKeyBytes(header, plaintext);
if (keyBytes == null)
return null;
return new SecretKeySpec(keyBytes, alg.getJcaName());
}

@Override
public Collection<Key> resolveSigningKeys(JwsHeader header, String plaintext) {
SignatureAlgorithm alg = SignatureAlgorithm.forName(header.getAlgorithm());
Assert.isTrue(alg.isHmac(), "The default resolveSigningKey(JwsHeader, String) implementation cannot be " +
"used for asymmetric key algorithms (RSA, Elliptic Curve). " +
"Override the resolveSigningKey(JwsHeader, String) method instead and return a " +
"Key instance appropriate for the " + alg.name() + " algorithm.");
Collection<byte[]> keysBytes = resolveSigningKeysBytes(header, plaintext);
if (keysBytes == null)
return null;
Collection<Key> keys = new ArrayList<Key>();
for (byte[] keyBytes: keysBytes)
keys.add(new SecretKeySpec(keyBytes, alg.getJcaName()));

return keys;
}

/**
* Convenience method invoked by {@link #resolveSigningKey(JwsHeader, Claims)} that obtains the necessary signing
* key bytes. This implementation simply throws an exception: if the JWS parsed is a Claims JWS, you must
Expand All @@ -81,6 +121,25 @@ public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) {
"resolveSigningKeyBytes(JwsHeader, Claims) method.");
}

/**
* Convenience method invoked by {@link #resolveSigningKey(JwsHeader, Claims)} that obtains the necessary signing
* key bytes. This implementation simply throws an exception: if the JWS parsed is a Claims JWS, you must
* override this method or the {@link #resolveSigningKey(JwsHeader, Claims)} method instead.
*
* <p><b>NOTE:</b> You cannot override this method when validating RSA signatures. If you expect RSA signatures,
* you must override the {@link #resolveSigningKey(JwsHeader, Claims)} method instead.</p>
*
* @param header the parsed {@link JwsHeader}
* @param claims the parsed {@link Claims}
* @return the signing key bytes to use to verify the JWS signature.
*/
public Collection<byte[]> resolveSigningKeysBytes(JwsHeader header, Claims claims) {
throw new UnsupportedJwtException("The specified SigningKeyResolver implementation does not support " +
"Claims JWS signing key resolution. Consider overriding either the " +
"resolveSigningKey(JwsHeader, Claims) method or, for HMAC algorithms, the " +
"resolveSigningKeyBytes(JwsHeader, Claims) method.");
}

/**
* Convenience method invoked by {@link #resolveSigningKey(JwsHeader, String)} that obtains the necessary signing
* key bytes. This implementation simply throws an exception: if the JWS parsed is a plaintext JWS, you must
Expand All @@ -96,4 +155,20 @@ public byte[] resolveSigningKeyBytes(JwsHeader header, String payload) {
"resolveSigningKey(JwsHeader, String) method or, for HMAC algorithms, the " +
"resolveSigningKeyBytes(JwsHeader, String) method.");
}

/**
* Convenience method invoked by {@link #resolveSigningKey(JwsHeader, String)} that obtains the necessary signing
* key bytes. This implementation simply throws an exception: if the JWS parsed is a plaintext JWS, you must
* override this method or the {@link #resolveSigningKey(JwsHeader, String)} method instead.
*
* @param header the parsed {@link JwsHeader}
* @param payload the parsed String plaintext payload
* @return the signing key bytes to use to verify the JWS signature.
*/
public Collection<byte[]> resolveSigningKeysBytes(JwsHeader header, String payload) {
throw new UnsupportedJwtException("The specified SigningKeyResolver implementation does not support " +
"plaintext JWS signing key resolution. Consider overriding either the " +
"resolveSigningKey(JwsHeader, String) method or, for HMAC algorithms, the " +
"resolveSigningKeyBytes(JwsHeader, String) method.");
}
}
109 changes: 78 additions & 31 deletions src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
import java.io.IOException;
import java.security.Key;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.Map;

Expand All @@ -61,9 +63,9 @@ public class DefaultJwtParser implements JwtParser {

private ObjectMapper objectMapper = new ObjectMapper();

private byte[] keyBytes;
private Collection<byte[]> keyBytes;

private Key key;
private Collection<Key> keys;

private SigningKeyResolver signingKeyResolver;

Expand Down Expand Up @@ -141,21 +143,27 @@ public JwtParser setAllowedClockSkewSeconds(long seconds) {
@Override
public JwtParser setSigningKey(byte[] key) {
Assert.notEmpty(key, "signing key cannot be null or empty.");
this.keyBytes = key;
if (this.keyBytes == null)
this.keyBytes = new ArrayList<byte[]>();
this.keyBytes.add(key);
return this;
}

@Override
public JwtParser setSigningKey(String base64EncodedKeyBytes) {
Assert.hasText(base64EncodedKeyBytes, "signing key cannot be null or empty.");
this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);
if (this.keyBytes == null)
this.keyBytes = new ArrayList<byte[]>();
this.keyBytes.add(TextCodec.BASE64.decode(base64EncodedKeyBytes));
return this;
}

@Override
public JwtParser setSigningKey(Key key) {
Assert.notNull(key, "signing key cannot be null.");
this.key = key;
if (this.keys == null)
this.keys = new ArrayList<Key>();
this.keys.add(key);
return this;
}

Expand Down Expand Up @@ -297,58 +305,97 @@ public Jwt parse(String jwt) throws ExpiredJwtException, MalformedJwtException,
throw new MalformedJwtException(msg);
}

if (key != null && keyBytes != null) {
if (keys != null && keyBytes != null) {
throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either.");
} else if ((key != null || keyBytes != null) && signingKeyResolver != null) {
String object = key != null ? "a key object" : "key bytes";
} else if ((keys != null || keyBytes != null) && signingKeyResolver != null) {
String object = keys != null ? "a key object" : "key bytes";
throw new IllegalStateException("A signing key resolver and " + object + " cannot both be specified. Choose either.");
}

//digitally signed, let's assert the signature:
Key key = this.key;
Collection<Key> keys = this.keys;
UnsupportedJwtException signingKeyResolverException = null;

if (key == null) { //fall back to keyBytes
if (keys == null) { //fall back to keyBytes

byte[] keyBytes = this.keyBytes;
//byte[] keyBytes = this.keyBytes;

if (Objects.isEmpty(keyBytes) && signingKeyResolver != null) { //use the signingKeyResolver
keys = new ArrayList<Key>();
if (claims != null) {
key = signingKeyResolver.resolveSigningKey(jwsHeader, claims);
try {
Key key = this.signingKeyResolver.resolveSigningKey(jwsHeader, claims);
if (key != null)
keys.add(key);
} catch (UnsupportedJwtException e) {
signingKeyResolverException = e;
}
try {
Collection<Key> keyList = this.signingKeyResolver.resolveSigningKeys(jwsHeader, claims);
if (!Objects.isEmpty(keyList))
keys.addAll(keyList);
} catch (UnsupportedJwtException e) {
signingKeyResolverException = e;
}
} else {
key = signingKeyResolver.resolveSigningKey(jwsHeader, payload);
try {
Key key = this.signingKeyResolver.resolveSigningKey(jwsHeader, payload);
if (key != null)
keys.add(key);
} catch (UnsupportedJwtException e) {
signingKeyResolverException = e;
}
try {
Collection<Key> keyList = this.signingKeyResolver.resolveSigningKeys(jwsHeader, payload);
if (!Objects.isEmpty(keyList))
keys.addAll(keyList);
} catch (UnsupportedJwtException e) {
signingKeyResolverException = e;
}
}
if (keys.size() == 0 && signingKeyResolverException != null)
throw signingKeyResolverException;
}

if (!Objects.isEmpty(keyBytes)) {

Assert.isTrue(algorithm.isHmac(),
"Key bytes can only be specified for HMAC signatures. Please specify a PublicKey or PrivateKey instance.");

key = new SecretKeySpec(keyBytes, algorithm.getJcaName());
keys = new ArrayList<Key>();
for (byte[] bytes: this.keyBytes)
keys.add(new SecretKeySpec(bytes, algorithm.getJcaName()));
}
}

Assert.notNull(key, "A signing key must be specified if the specified JWT is digitally signed.");
Assert.notNull(keys, "A signing key must be specified if the specified JWT is digitally signed.");

//re-create the jwt part without the signature. This is what needs to be signed for verification:
String jwtWithoutSignature = base64UrlEncodedHeader + SEPARATOR_CHAR + base64UrlEncodedPayload;

JwtSignatureValidator validator;
try {
validator = createSignatureValidator(algorithm, key);
} catch (IllegalArgumentException e) {
String algName = algorithm.getValue();
String msg = "The parsed JWT indicates it was signed with the " + algName + " signature " +
"algorithm, but the specified signing key of type " + key.getClass().getName() +
" may not be used to validate " + algName + " signatures. Because the specified " +
"signing key reflects a specific and expected algorithm, and the JWT does not reflect " +
"this algorithm, it is likely that the JWT was not expected and therefore should not be " +
"trusted. Another possibility is that the parser was configured with the incorrect " +
"signing key, but this cannot be assumed for security reasons.";
throw new UnsupportedJwtException(msg, e);
boolean signatureOk = false;
for (Key key: keys) {
JwtSignatureValidator validator;
try {
validator = createSignatureValidator(algorithm, key);
} catch (IllegalArgumentException e) {
String algName = algorithm.getValue();
String msg = "The parsed JWT indicates it was signed with the " + algName + " signature " +
"algorithm, but the specified signing key of type " + key.getClass().getName() +
" may not be used to validate " + algName + " signatures. Because the specified " +
"signing key reflects a specific and expected algorithm, and the JWT does not reflect " +
"this algorithm, it is likely that the JWT was not expected and therefore should not be " +
"trusted. Another possibility is that the parser was configured with the incorrect " +
"signing key, but this cannot be assumed for security reasons.";
throw new UnsupportedJwtException(msg, e);
}

if (validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
signatureOk = true;
break;
}
}

if (!validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
if (!signatureOk) {
String msg = "JWT signature does not match locally computed signature. JWT validity cannot be " +
"asserted and should not be trusted.";
throw new SignatureException(msg);
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/io/jsonwebtoken/lang/Objects.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.io.IOException;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Collection;

public final class Objects {

Expand Down Expand Up @@ -95,6 +96,16 @@ public static boolean isEmpty(Object[] array) {
return (array == null || array.length == 0);
}

/**
* Determine whether the given collection is empty:
* i.e. <code>null</code> or of zero length.
*
* @param array the Collection to check
*/
public static boolean isEmpty(Collection<?> collection) {
return (collection == null || collection.isEmpty());
}

/**
* Returns {@code true} if the specified byte array is null or of zero length, {@code false} otherwise.
*
Expand Down