Secret JWK `k` values larger than HMAC-SHA minimums (#909)

- Ensured Secret JWK 'k' byte arrays for HMAC-SHA algorithms can be larger than the identified HS* algorithm. This is allowed per https://datatracker.ietf.org/doc/html/rfc7518#section-3.2: "A key of the same size as the hash output ... _or larger_ MUST be used with this algorithm"

- Ensured that, when using the JwkBuilder, Secret JWK 'alg' values would automatically be set to 'HS256', 'HS384', or 'HS512' if the specified Java SecretKey algorithm name equals a JCA standard name (HmacSHA256, HmacSHA384, etc) or JCA standard HMAC-SHA OID.

- Updated CHANGELOG.md accordingly.

Fixes #905
This commit is contained in:
lhazlewood 2024-01-27 19:54:40 -08:00 committed by Les Hazlewood
parent b12dabf100
commit 628bd6f4e8
8 changed files with 190 additions and 52 deletions

View File

@ -65,6 +65,8 @@ This release also:
[6.2.1.3](https://datatracker.ietf.org/doc/html/rfc7518#section-6.2.1.3), and
[6.2.2.1](https://datatracker.ietf.org/doc/html/rfc7518#section-6.2.2.1), respectively.
[Issue 901](https://github.com/jwtk/jjwt/issues/901).
* Ensures that Secret JWKs for HMAC-SHA algorithms with `k` sizes larger than the algorithm minimum can
be parsed/used as expected. See [Issue #905](https://github.com/jwtk/jjwt/issues/905)
* Fixes various typos in documentation and JavaDoc. Thanks to those contributing pull requests for these!
### 0.12.3

View File

@ -30,6 +30,7 @@ import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.SecureRandom;
@ -54,9 +55,22 @@ abstract class AesAlgorithm extends CryptoAlgorithm implements KeyBuilderSupplie
protected final int tagBitLength;
protected final boolean gcm;
static void assertKeyBitLength(int keyBitLength) {
if (keyBitLength == 128 || keyBitLength == 192 || keyBitLength == 256) return; // valid
String msg = "Invalid AES key length: " + Bytes.bitsMsg(keyBitLength) + ". AES only supports " +
"128, 192, or 256 bit keys.";
throw new IllegalArgumentException(msg);
}
static SecretKey keyFor(byte[] bytes) {
int bitlen = (int) Bytes.bitLength(bytes);
assertKeyBitLength(bitlen);
return new SecretKeySpec(bytes, KEY_ALG_NAME);
}
AesAlgorithm(String id, final String jcaTransformation, int keyBitLength) {
super(id, jcaTransformation);
Assert.isTrue(keyBitLength == 128 || keyBitLength == 192 || keyBitLength == 256, "Invalid AES key length: it must equal 128, 192, or 256.");
assertKeyBitLength(keyBitLength);
this.keyBitLength = keyBitLength;
this.gcm = jcaTransformation.startsWith("AES/GCM");
this.ivBitLength = jcaTransformation.equals("AESWrap") ? 0 : (this.gcm ? GCM_IV_SIZE : BLOCK_SIZE);

View File

@ -15,6 +15,7 @@
*/
package io.jsonwebtoken.impl.security;
import io.jsonwebtoken.Identifiable;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.impl.lang.Bytes;
import io.jsonwebtoken.impl.lang.ParameterReadable;
@ -22,11 +23,14 @@ import io.jsonwebtoken.impl.lang.RequiredParameterReader;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.lang.Assert;
import io.jsonwebtoken.lang.Strings;
import io.jsonwebtoken.security.AeadAlgorithm;
import io.jsonwebtoken.security.InvalidKeyException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.MacAlgorithm;
import io.jsonwebtoken.security.MalformedKeyException;
import io.jsonwebtoken.security.SecretJwk;
import io.jsonwebtoken.security.SecureDigestAlgorithm;
import io.jsonwebtoken.security.SecretKeyAlgorithm;
import io.jsonwebtoken.security.WeakKeyException;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
@ -44,61 +48,97 @@ class SecretJwkFactory extends AbstractFamilyJwkFactory<SecretKey, SecretJwk> {
protected SecretJwk createJwkFromKey(JwkContext<SecretKey> ctx) {
SecretKey key = Assert.notNull(ctx.getKey(), "JwkContext key cannot be null.");
String k;
byte[] encoded = null;
try {
byte[] encoded = KeysBridge.getEncoded(key);
encoded = KeysBridge.getEncoded(key);
k = Encoders.BASE64URL.encode(encoded);
Assert.hasText(k, "k value cannot be null or empty.");
} catch (Throwable t) {
String msg = "Unable to encode SecretKey to JWK: " + t.getMessage();
throw new InvalidKeyException(msg, t);
} finally {
Bytes.clear(encoded);
}
MacAlgorithm mac = DefaultMacAlgorithm.findByKey(key);
if (mac != null) {
ctx.put(AbstractJwk.ALG.getId(), mac.getId());
}
ctx.put(DefaultSecretJwk.K.getId(), k);
return new DefaultSecretJwk(ctx);
return createJwkFromValues(ctx);
}
private static void assertKeyBitLength(byte[] bytes, MacAlgorithm alg) {
long bitLen = Bytes.bitLength(bytes);
long requiredBitLen = alg.getKeyBitLength();
if (bitLen != requiredBitLen) {
if (bitLen < requiredBitLen) {
// Implementors note: Don't print out any information about the `bytes` value itself - size,
// content, etc., as it is considered secret material:
String msg = "Secret JWK " + AbstractJwk.ALG + " value is '" + alg.getId() +
"', but the " + DefaultSecretJwk.K + " length does not equal the '" + alg.getId() +
"' length requirement of " + Bytes.bitsMsg(requiredBitLen) +
". This discrepancy could be the result of an algorithm " +
"substitution attack or simply an erroneously constructed JWK. In either case, it is likely " +
"to result in unexpected or undesired security consequences.";
throw new MalformedKeyException(msg);
"', but the " + DefaultSecretJwk.K + " length is smaller than the " + alg.getId() +
" minimum length of " + Bytes.bitsMsg(requiredBitLen) +
" required by " +
"[JWA RFC 7518, Section 3.2](https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2), " +
"2nd paragraph: 'A key of the same size as the hash output or larger MUST be used with this " +
"algorithm.'";
throw new WeakKeyException(msg);
}
}
private static void assertSymmetric(Identifiable alg) {
if (alg instanceof MacAlgorithm || alg instanceof SecretKeyAlgorithm || alg instanceof AeadAlgorithm)
return; // valid
String msg = "Invalid Secret JWK " + AbstractJwk.ALG + " value '" + alg.getId() + "'. Secret JWKs " +
"may only be used with symmetric (secret) key algorithms.";
throw new MalformedKeyException(msg);
}
@Override
protected SecretJwk createJwkFromValues(JwkContext<SecretKey> ctx) {
ParameterReadable reader = new RequiredParameterReader(ctx);
byte[] bytes = reader.get(DefaultSecretJwk.K);
String jcaName = null;
final byte[] bytes = reader.get(DefaultSecretJwk.K);
SecretKey key;
String id = ctx.getAlgorithm();
if (Strings.hasText(id)) {
SecureDigestAlgorithm<?, ?> alg = Jwts.SIG.get().get(id);
if (alg instanceof MacAlgorithm) {
jcaName = ((CryptoAlgorithm) alg).getJcaName(); // valid for all JJWT alg implementations
Assert.hasText(jcaName, "Algorithm jcaName cannot be null or empty.");
assertKeyBitLength(bytes, (MacAlgorithm) alg);
}
}
if (!Strings.hasText(jcaName)) {
if (ctx.isSigUse()) {
String algId = ctx.getAlgorithm();
if (!Strings.hasText(algId)) { // optional per https://www.rfc-editor.org/rfc/rfc7517.html#section-4.4
// Here we try to infer the best type of key to create based on siguse and/or key length.
//
// AES requires 128, 192, or 256 bits, so anything larger than 256 cannot be AES, so we'll need to assume
// HMAC.
//
// Also, 256 bits works for either HMAC or AES, so we just have to choose one as there is no other
// RFC-based criteria for determining. Historically, we've chosen AES due to the larger number of
// KeyAlgorithm and AeadAlgorithm use cases, so that's our default.
int kBitLen = (int) Bytes.bitLength(bytes);
if (ctx.isSigUse() || kBitLen > Jwts.SIG.HS256.getKeyBitLength()) {
// The only JWA SecretKey signature algorithms are HS256, HS384, HS512, so choose based on bit length:
jcaName = "HmacSHA" + Bytes.bitLength(bytes);
} else { // not an HS* algorithm, and all standard AeadAlgorithms use AES keys:
jcaName = AesAlgorithm.KEY_ALG_NAME;
key = Keys.hmacShaKeyFor(bytes);
} else {
key = AesAlgorithm.keyFor(bytes);
}
ctx.setKey(key);
return new DefaultSecretJwk(ctx);
}
//otherwise 'alg' was specified, ensure it's valid for secret key use:
Identifiable alg = Jwts.SIG.get().get(algId);
if (alg == null) alg = Jwts.KEY.get().get(algId);
if (alg == null) alg = Jwts.ENC.get().get(algId);
if (alg != null) assertSymmetric(alg); // if we found a standard alg, it must be a symmetric key algorithm
if (alg instanceof MacAlgorithm) {
assertKeyBitLength(bytes, ((MacAlgorithm) alg));
String jcaName = ((CryptoAlgorithm) alg).getJcaName();
Assert.hasText(jcaName, "Algorithm jcaName cannot be null or empty.");
key = new SecretKeySpec(bytes, jcaName);
} else {
// all other remaining JWA-standard symmetric algs use AES:
key = AesAlgorithm.keyFor(bytes);
}
Assert.stateNotNull(jcaName, "jcaName cannot be null (invariant)");
SecretKey key = new SecretKeySpec(bytes, jcaName);
ctx.setKey(key);
return new DefaultSecretJwk(ctx);
}

View File

@ -29,10 +29,8 @@ import static org.junit.Assert.*
class AbstractJwkBuilderTest {
private static final SecretKey SKEY = TestKeys.A256GCM
private static AbstractJwkBuilder<SecretKey, SecretJwk, AbstractJwkBuilder> builder() {
return (AbstractJwkBuilder) Jwks.builder().key(SKEY)
return (AbstractJwkBuilder) Jwks.builder().key(TestKeys.NA256)
}
@Test

View File

@ -97,7 +97,7 @@ class JwkSerializationTest {
static void testSecretJwk(Serializer ser, Deserializer des) {
def key = TestKeys.A128GCM
def key = TestKeys.NA256
def jwk = Jwks.builder().key(key).id('id').build()
assertWrapped(jwk, ['k'])

View File

@ -40,7 +40,7 @@ import static org.junit.Assert.*
class JwksTest {
private static final SecretKey SKEY = Jwts.SIG.HS256.key().build()
private static final SecretKey SKEY = TestKeys.NA256
private static final java.security.KeyPair EC_PAIR = Jwts.SIG.ES256.keyPair().build()
private static String srandom() {
@ -172,7 +172,7 @@ class JwksTest {
@Test
void testOperations() {
def val = [Jwks.OP.SIGN, Jwks.OP.VERIFY] as Set<KeyOperation>
def jwk = Jwks.builder().key(TestKeys.A128GCM).operations().add(val).and().build()
def jwk = Jwks.builder().key(TestKeys.NA256).operations().add(val).and().build()
assertEquals val, jwk.getOperations()
}

View File

@ -15,9 +15,10 @@
*/
package io.jsonwebtoken.impl.security
import io.jsonwebtoken.security.Jwks
import io.jsonwebtoken.security.MalformedKeyException
import io.jsonwebtoken.security.SecretJwk
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.impl.lang.Bytes
import io.jsonwebtoken.io.Encoders
import io.jsonwebtoken.security.*
import org.junit.Test
import static org.junit.Assert.*
@ -30,10 +31,14 @@ import static org.junit.Assert.*
*/
class SecretJwkFactoryTest {
private static Set<MacAlgorithm> macAlgs() {
return Jwts.SIG.get().values().findAll({ it -> it instanceof MacAlgorithm }) as Collection<MacAlgorithm>
}
@Test
// if a jwk does not have an 'alg' or 'use' param, we default to an AES key
void testNoAlgNoSigJcaName() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA256).build()
SecretJwk result = Jwks.builder().add(jwk).build() as SecretJwk
assertEquals 'AES', result.toKey().getAlgorithm()
}
@ -47,7 +52,7 @@ class SecretJwkFactoryTest {
@Test
void testSignOpSetsKeyHmacSHA256() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA256).build()
SecretJwk result = Jwks.builder().add(jwk).operations().add(Jwks.OP.SIGN).and().build() as SecretJwk
assertNull result.getAlgorithm()
assertNull result.get('use')
@ -63,7 +68,7 @@ class SecretJwkFactoryTest {
@Test
void testSignOpSetsKeyHmacSHA384() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA384).build()
SecretJwk result = Jwks.builder().add(jwk).operations().add(Jwks.OP.SIGN).and().build() as SecretJwk
assertNull result.getAlgorithm()
assertNull result.get('use')
@ -79,7 +84,7 @@ class SecretJwkFactoryTest {
@Test
void testSignOpSetsKeyHmacSHA512() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA512).build()
SecretJwk result = Jwks.builder().add(jwk).operations().add(Jwks.OP.SIGN).and().build() as SecretJwk
assertNull result.getAlgorithm()
assertNull result.get('use')
@ -89,7 +94,7 @@ class SecretJwkFactoryTest {
@Test
// no 'alg' jwk property, but 'use' is 'sig', so forces jcaName to be HmacSHA256
void testNoAlgAndSigUseForHS256() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA256).build()
assertFalse jwk.containsKey('alg')
assertFalse jwk.containsKey('use')
SecretJwk result = Jwks.builder().add(jwk).add('use', 'sig').build() as SecretJwk
@ -99,7 +104,7 @@ class SecretJwkFactoryTest {
@Test
// no 'alg' jwk property, but 'use' is 'sig', so forces jcaName to be HmacSHA384
void testNoAlgAndSigUseForHS384() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA384).build()
assertFalse jwk.containsKey('alg')
assertFalse jwk.containsKey('use')
SecretJwk result = Jwks.builder().add(jwk).add('use', 'sig').build() as SecretJwk
@ -109,7 +114,7 @@ class SecretJwkFactoryTest {
@Test
// no 'alg' jwk property, but 'use' is 'sig', so forces jcaName to be HmacSHA512
void testNoAlgAndSigUseForHS512() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA512).build()
assertFalse jwk.containsKey('alg')
assertFalse jwk.containsKey('use')
SecretJwk result = Jwks.builder().add(jwk).add('use', 'sig').build() as SecretJwk
@ -119,20 +124,32 @@ class SecretJwkFactoryTest {
@Test
// no 'alg' jwk property, but 'use' is something other than 'sig', so jcaName should default to AES
void testNoAlgAndNonSigUse() {
SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build()
SecretJwk jwk = Jwks.builder().key(TestKeys.NA256).build()
assertFalse jwk.containsKey('alg')
assertFalse jwk.containsKey('use')
SecretJwk result = Jwks.builder().add(jwk).add('use', 'foo').build() as SecretJwk
assertEquals 'AES', result.toKey().getAlgorithm()
}
@Test
// 'oct' type, but 'alg' value is not a secret key algorithm (and therefore malformed)
void testMismatchedAlgorithm() {
try {
Jwks.builder().key(TestKeys.NA256).add('alg', Jwts.SIG.RS256.getId()).build()
fail()
} catch (MalformedKeyException expected) {
String msg = "Invalid Secret JWK ${AbstractJwk.ALG} value 'RS256'. Secret JWKs may only be used with " +
"symmetric (secret) key algorithms."
assertEquals msg, expected.message
}
}
/**
* Test the case where a jwk `alg` value is present, but the key material doesn't match that algs key length
* requirements. This would be a malformed key.
*/
@Test
void testSizeMismatchedSecretJwk() {
//first get a valid HS256 JWK:
SecretJwk validJwk = Jwks.builder().key(TestKeys.HS256).build()
@ -142,12 +159,72 @@ class SecretJwkFactoryTest {
.add('alg', 'HS384')
.build()
fail()
} catch (MalformedKeyException expected) {
String msg = "Secret JWK 'alg' (Algorithm) value is 'HS384', but the 'k' (Key Value) length does " +
"not equal the 'HS384' length requirement of 384 bits (48 bytes). This discrepancy could " +
"be the result of an algorithm substitution attack or simply an erroneously constructed " +
"JWK. In either case, it is likely to result in unexpected or undesired security consequences."
} catch (WeakKeyException expected) {
String msg = "Secret JWK 'alg' (Algorithm) value is 'HS384', but the 'k' (Key Value) length is smaller " +
"than the HS384 minimum length of 384 bits (48 bytes) required by " +
"[JWA RFC 7518, Section 3.2](https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2), 2nd " +
"paragraph: 'A key of the same size as the hash output or larger MUST be used with this " +
"algorithm.'"
assertEquals msg, expected.getMessage()
}
}
/**
* Test when a {@code k} size is smaller, equal to, and larger than the minimum required number of bits/bytes for
* a given HmacSHA* algorithm. The RFCs indicate smaller-than is not allowed, while equal-to and greater-than are
* allowed.
*
* This test asserts this allowed behavior per https://github.com/jwtk/jjwt/issues/905
* @see <a href="https://github.com/jwtk/jjwt/issues/905">JJWT Issue 905</a>
*/
@Test
void testAllowedKeyLengths() {
def parser = Jwks.parser().build()
for (MacAlgorithm alg : macAlgs()) {
// 3 key length sizes for each alg to test:
// index 0: smaller than minimum required
// index 1: minimum required
// index 2: more than minimum required:
def sizes = [alg.keyBitLength - Byte.SIZE, alg.keyBitLength, alg.keyBitLength + Byte.SIZE]
for (int i = 0; i < sizes.size(); i++) {
def kBitLength = sizes.get(i)
def k = Bytes.random(Bytes.length(kBitLength))
def jwkJson = """
{
"kid": "${UUID.randomUUID().toString()}",
"kty": "oct",
"alg": "${alg.getId()}",
"k": "${Encoders.BASE64URL.encode(k)}"
}""".toString()
def jwk
try {
jwk = parser.parse(jwkJson)
} catch (WeakKeyException expected) {
assertEquals("Should only occur on index 0 with less-than-minimum key length", 0, i)
String msg = "Secret JWK 'alg' (Algorithm) value is '${alg.getId()}', but the 'k' (Key Value) " +
"length is smaller than the ${alg.getId()} minimum length of " +
"${Bytes.bitsMsg(alg.keyBitLength)} required by " +
"[JWA RFC 7518, Section 3.2](https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2), " +
"2nd paragraph: 'A key of the same size as the hash output or larger MUST be used with " +
"this algorithm.'"
assertEquals msg, expected.getMessage()
continue // expected for index 0 (purposefully weak key), so let loop continue
}
// otherwise not weak, sizes should reflect equal-to or greater-than alg bitlength sizes
assert jwk instanceof SecretJwk
assertEquals alg.getId(), jwk.getAlgorithm()
def bytes = jwk.toKey().getEncoded()
assertTrue Bytes.bitLength(bytes) >= alg.keyBitLength
assertEquals Bytes.length(kBitLength), jwk.toKey().getEncoded().length
}
}
}
}

View File

@ -21,6 +21,7 @@ import io.jsonwebtoken.lang.Collections
import io.jsonwebtoken.security.Jwks
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
import java.security.KeyPair
import java.security.PrivateKey
import java.security.Provider
@ -42,6 +43,11 @@ class TestKeys {
static SecretKey HS512 = Jwts.SIG.HS512.key().build()
static Collection<SecretKey> HS = Collections.setOf(HS256, HS384, HS512)
static SecretKey NA256 = new SecretKeySpec(HS256.encoded, "NONE")
static SecretKey NA384 = new SecretKeySpec(HS384.encoded, "NONE")
static SecretKey NA512 = new SecretKeySpec(HS512.encoded, "NONE")
static Collection<SecretKey> NA = [NA256, NA384, NA512]
static SecretKey A128GCM, A192GCM, A256GCM, A128KW, A192KW, A256KW, A128GCMKW, A192GCMKW, A256GCMKW
static Collection<SecretKey> AGCM
static {
@ -59,6 +65,7 @@ class TestKeys {
static Collection<SecretKey> SECRET = new LinkedHashSet<>()
static {
SECRET.addAll(HS)
SECRET.addAll(NA)
SECRET.addAll(AGCM)
SECRET.addAll(ACBC)
}