From b55f26175cc8a33564ecc0ded444dd48c027f19e Mon Sep 17 00:00:00 2001 From: lhazlewood <121180+lhazlewood@users.noreply.github.com> Date: Tue, 12 Sep 2023 20:38:01 -0700 Subject: [PATCH] JWK .equals and .hashCode (#823) * Adjusted JWK .equals implementations to only account for kty value and material fields (two JWKs are equal if their type and key material are equal, regardless of other public parameters and/or custom name/value pairs). * Adjusted JWK .hashCode implementation to pre-cache its value based on JwkThumpbrint fields since JWKs are immutable --- .../java/io/jsonwebtoken/impl/lang/Bytes.java | 10 +++ .../io/jsonwebtoken/impl/lang/Fields.java | 44 +++++++++++ .../impl/security/AbstractJwk.java | 57 +++++++++++++-- .../impl/security/AbstractJwkBuilder.java | 6 ++ .../impl/security/AbstractPrivateJwk.java | 8 ++ .../impl/security/AbstractPublicJwk.java | 8 ++ .../impl/security/DefaultEcPrivateJwk.java | 8 ++ .../impl/security/DefaultEcPublicJwk.java | 13 ++++ .../impl/security/DefaultMacAlgorithm.java | 6 +- .../impl/security/DefaultOctetPrivateJwk.java | 11 ++- .../impl/security/DefaultOctetPublicJwk.java | 11 +++ .../impl/security/DefaultRsaPrivateJwk.java | 39 ++++++++++ .../impl/security/DefaultRsaPublicJwk.java | 11 +++ .../impl/security/DefaultSecretJwk.java | 6 ++ .../impl/AbstractProtectedHeaderTest.groovy | 2 +- .../impl/DefaultJweHeaderTest.groovy | 2 +- .../jsonwebtoken/impl/lang/BytesTest.groovy | 35 +++++++++ .../jsonwebtoken/impl/lang/FieldsTest.groovy | 66 +++++++++++++++++ .../impl/lang/RedactedSupplierTest.groovy | 15 +++- .../impl/lang/TestFieldReadable.groovy | 26 +++++++ .../impl/security/AbstractJwkTest.groovy | 23 +++--- .../security/DefaultRsaPrivateJwkTest.groovy | 73 +++++++++++++++++++ .../impl/security/JwksTest.groovy | 6 +- .../impl/security/SecretJwkFactoryTest.groovy | 22 +++--- 24 files changed, 471 insertions(+), 37 deletions(-) create mode 100644 impl/src/test/groovy/io/jsonwebtoken/impl/lang/TestFieldReadable.groovy create mode 100644 impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwkTest.groovy diff --git a/impl/src/main/java/io/jsonwebtoken/impl/lang/Bytes.java b/impl/src/main/java/io/jsonwebtoken/impl/lang/Bytes.java index efabdf2a..691ac6f3 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/lang/Bytes.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/lang/Bytes.java @@ -176,6 +176,16 @@ public final class Bytes { return output; } + /** + * Clears the array by filling it with all zeros. Does nothing with a null or empty argument. + * + * @param bytes the (possibly null or empty) byte array to clear + */ + public static void clear(byte[] bytes) { + if (isEmpty(bytes)) return; + java.util.Arrays.fill(bytes, (byte) 0); + } + public static boolean isEmpty(byte[] bytes) { return length(bytes) == 0; } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/lang/Fields.java b/impl/src/main/java/io/jsonwebtoken/impl/lang/Fields.java index 5b2f4709..35373e17 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/lang/Fields.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/lang/Fields.java @@ -17,10 +17,12 @@ package io.jsonwebtoken.impl.lang; import io.jsonwebtoken.lang.Arrays; import io.jsonwebtoken.lang.Assert; +import io.jsonwebtoken.lang.Objects; import io.jsonwebtoken.lang.Registry; import java.math.BigInteger; import java.net.URI; +import java.security.MessageDigest; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Date; @@ -97,4 +99,46 @@ public final class Fields { newFields.put(id, field); // add new one return registry(newFields.values()); } + + private static byte[] bytes(BigInteger i) { + return i != null ? i.toByteArray() : null; + } + + public static boolean bytesEquals(BigInteger a, BigInteger b) { + //noinspection NumberEquality + if (a == b) return true; + if (a == null || b == null) return false; + byte[] aBytes = bytes(a); + byte[] bBytes = bytes(b); + try { + return MessageDigest.isEqual(aBytes, bBytes); + } finally { + Bytes.clear(aBytes); + Bytes.clear(bBytes); + } + } + + private static boolean equals(T a, T b, Field field) { + if (a == b) return true; + if (a == null || b == null) return false; + if (field.isSecret()) { + // byte[] and BigInteger are the only types of secret Fields in the JJWT codebase + // (i.e. Field.isSecret() == true). If a Field is ever marked as secret, and it's not one of these two + // data types, we need to know about it. So we use the 'assertSecret' helper above to ensure we do: + if (a instanceof byte[]) { + return b instanceof byte[] && MessageDigest.isEqual((byte[]) a, (byte[]) b); + } else if (a instanceof BigInteger) { + return b instanceof BigInteger && bytesEquals((BigInteger) a, (BigInteger) b); + } + } + // default to a standard null-safe comparison: + return Objects.nullSafeEquals(a, b); + } + + public static boolean equals(FieldReadable a, Object o, Field field) { + if (a == o) return true; + if (a == null || !(o instanceof FieldReadable)) return false; + FieldReadable b = (FieldReadable) o; + return equals(a.get(field), b.get(field), field); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwk.java index 33b5ce23..913a04af 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwk.java @@ -33,6 +33,9 @@ import io.jsonwebtoken.security.KeyOperation; import java.nio.charset.StandardCharsets; import java.security.Key; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.List; @@ -48,10 +51,11 @@ public abstract class AbstractJwk implements Jwk, FieldReadabl .set().setId("key_ops").setName("Key Operations").build(); static final Field KTY = Fields.string("kty", "Key Type"); static final Set> FIELDS = Collections.setOf(ALG, KID, KEY_OPS, KTY); - public static final String IMMUTABLE_MSG = "JWKs are immutable and may not be modified."; + protected final JwkContext context; private final List> THUMBPRINT_FIELDS; + private final int hashCode; /** * @param ctx the backing JwkContext containing the JWK field values. @@ -71,6 +75,40 @@ public abstract class AbstractJwk implements Jwk, FieldReadabl String kid = thumbprint.toString(); ctx.setId(kid); } + this.hashCode = computeHashCode(); + } + + /** + * Compute and return the JWK hashCode. As JWKs are immutable, this value will be cached as a final constant + * upon JWK instantiation. This uses the JWK's thumbprint fields during computation, but differs from JwkThumbprint + * calculation in two ways: + *
    + *
  1. JwkThumbprints use a MessageDigest calculation, which is unnecessary overhead for a hashcode
  2. + *
  3. The hashCode calculation uses each field's idiomatic (Java) object value instead of the + * JwkThumbprint-required canonical (String) value.
  4. + *
+ * + * @return the JWK hashcode + */ + private int computeHashCode() { + List list = new ArrayList<>(this.THUMBPRINT_FIELDS.size() + 1 /* possible discriminator */); + // So we don't leak information about the private key value, we need a discriminator to ensure that + // public and private key hashCodes are not identical (in case both JWKs need to be in the same hash set). + // So we add a discriminator String to the list of values that are used during hashCode calculation + Key key = Assert.notNull(toKey(), "JWK toKey() value cannot be null."); + if (key instanceof PublicKey) { + list.add("Public"); + } else if (key instanceof PrivateKey) { + list.add("Private"); + } + for (Field field : this.THUMBPRINT_FIELDS) { + // Unlike thumbprint calculation, we get the idiomatic (Java) value, not canonical (String) value + // (We could have used either actually, but the idiomatic value hashCode calculation is probably + // faster). + Object val = Assert.notNull(get(field), "computeHashCode: Field idiomatic value cannot be null."); + list.add(val); + } + return Objects.nullSafeHashCode(list.toArray()); } private String getRequiredThumbprintValue(Field field) { @@ -230,13 +268,20 @@ public abstract class AbstractJwk implements Jwk, FieldReadabl } @Override - public int hashCode() { - return this.context.hashCode(); + public final int hashCode() { + return this.hashCode; } - @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") @Override - public boolean equals(Object obj) { - return this.context.equals(obj); + public final boolean equals(Object obj) { + if (obj == this) return true; + if (obj instanceof Jwk) { + Jwk other = (Jwk) obj; + // this.getType() guaranteed non-null in constructor: + return getType().equals(other.getType()) && equals(other); + } + return false; } + + protected abstract boolean equals(Jwk jwk); } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwkBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwkBuilder.java index d75628d6..276800e5 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwkBuilder.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwkBuilder.java @@ -170,6 +170,12 @@ abstract class AbstractJwkBuilder, T extends Jwk implements SecretJwkBuilder { public DefaultSecretJwkBuilder(JwkContext ctx) { super(ctx); + // assign a standard algorithm if possible: + Key key = Assert.notNull(ctx.getKey(), "SecretKey cannot be null."); + DefaultMacAlgorithm mac = DefaultMacAlgorithm.findByKey(key); + if (mac != null) { + algorithm(mac.getId()); + } } } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPrivateJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPrivateJwk.java index e0ea3cf3..2884657e 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPrivateJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPrivateJwk.java @@ -17,6 +17,7 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; import io.jsonwebtoken.lang.Assert; +import io.jsonwebtoken.security.Jwk; import io.jsonwebtoken.security.KeyPair; import io.jsonwebtoken.security.PrivateJwk; import io.jsonwebtoken.security.PublicJwk; @@ -47,4 +48,11 @@ abstract class AbstractPrivateJwk toKeyPair() { return this.keyPair; } + + @Override + protected final boolean equals(Jwk jwk) { + return jwk instanceof PrivateJwk && equals((PrivateJwk) jwk); + } + + protected abstract boolean equals(PrivateJwk jwk); } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPublicJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPublicJwk.java index 7319fe05..a5df02a7 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPublicJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractPublicJwk.java @@ -16,6 +16,7 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; +import io.jsonwebtoken.security.Jwk; import io.jsonwebtoken.security.PublicJwk; import java.security.PublicKey; @@ -25,4 +26,11 @@ abstract class AbstractPublicJwk extends AbstractAsymmetric AbstractPublicJwk(JwkContext ctx, List> thumbprintFields) { super(ctx, thumbprintFields); } + + @Override + protected final boolean equals(Jwk jwk) { + return jwk instanceof PublicJwk && equals((PublicJwk) jwk); + } + + protected abstract boolean equals(PublicJwk jwk); } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPrivateJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPrivateJwk.java index 33a9a1c2..9dfa86f8 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPrivateJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPrivateJwk.java @@ -20,12 +20,15 @@ import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Collections; import io.jsonwebtoken.security.EcPrivateJwk; import io.jsonwebtoken.security.EcPublicJwk; +import io.jsonwebtoken.security.PrivateJwk; import java.math.BigInteger; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.util.Set; +import static io.jsonwebtoken.impl.security.DefaultEcPublicJwk.equalsPublic; + class DefaultEcPrivateJwk extends AbstractPrivateJwk implements EcPrivateJwk { static final Field D = Fields.secretBigInt("d", "ECC Private Key"); @@ -38,4 +41,9 @@ class DefaultEcPrivateJwk extends AbstractPrivateJwk jwk) { + return jwk instanceof EcPrivateJwk && equalsPublic(this, jwk) && Fields.equals(this, jwk, D); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPublicJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPublicJwk.java index 907b6659..9336ae72 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPublicJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultEcPublicJwk.java @@ -16,9 +16,11 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; +import io.jsonwebtoken.impl.lang.FieldReadable; import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Collections; import io.jsonwebtoken.security.EcPublicJwk; +import io.jsonwebtoken.security.PublicJwk; import java.math.BigInteger; import java.security.interfaces.ECPublicKey; @@ -39,4 +41,15 @@ class DefaultEcPublicJwk extends AbstractPublicJwk implements EcPub DefaultEcPublicJwk(JwkContext ctx) { super(ctx, THUMBPRINT_FIELDS); } + + static boolean equalsPublic(FieldReadable self, Object candidate) { + return Fields.equals(self, candidate, CRV) && + Fields.equals(self, candidate, X) && + Fields.equals(self, candidate, Y); + } + + @Override + protected boolean equals(PublicJwk jwk) { + return jwk instanceof EcPublicJwk && equalsPublic(this, jwk); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultMacAlgorithm.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultMacAlgorithm.java index 169f33b3..f72c169b 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultMacAlgorithm.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultMacAlgorithm.java @@ -53,7 +53,7 @@ final class DefaultMacAlgorithm extends AbstractSecureDigestAlgorithm JCA_NAME_MAP; + private static final Map JCA_NAME_MAP; static { JCA_NAME_MAP = new LinkedHashMap<>(6); @@ -96,7 +96,7 @@ final class DefaultMacAlgorithm extends AbstractSecureDigestAlgorithm extends AbstractPrivateJwk> implements OctetPrivateJwk { +import static io.jsonwebtoken.impl.security.DefaultOctetPublicJwk.equalsPublic; + +public class DefaultOctetPrivateJwk + extends AbstractPrivateJwk> implements OctetPrivateJwk { static final Field D = Fields.bytes("d", "The private key").setSecret(true).build(); @@ -37,4 +41,9 @@ public class DefaultOctetPrivateJwk e // https://www.rfc-editor.org/rfc/rfc7638#section-3.2.1 DefaultOctetPublicJwk.THUMBPRINT_FIELDS, pubJwk); } + + @Override + protected boolean equals(PrivateJwk jwk) { + return jwk instanceof OctetPrivateJwk && equalsPublic(this, jwk) && Fields.equals(this, jwk, D); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultOctetPublicJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultOctetPublicJwk.java index 4982d3ae..4ede32e1 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultOctetPublicJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultOctetPublicJwk.java @@ -16,9 +16,11 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; +import io.jsonwebtoken.impl.lang.FieldReadable; import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Collections; import io.jsonwebtoken.security.OctetPublicJwk; +import io.jsonwebtoken.security.PublicJwk; import java.security.PublicKey; import java.util.List; @@ -37,4 +39,13 @@ public class DefaultOctetPublicJwk extends AbstractPublicJw DefaultOctetPublicJwk(JwkContext ctx) { super(ctx, THUMBPRINT_FIELDS); } + + static boolean equalsPublic(FieldReadable self, Object candidate) { + return Fields.equals(self, candidate, CRV) && Fields.equals(self, candidate, X); + } + + @Override + protected boolean equals(PublicJwk jwk) { + return jwk instanceof OctetPublicJwk && equalsPublic(this, jwk); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwk.java index 384b4b20..bc908dcf 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwk.java @@ -16,8 +16,10 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; +import io.jsonwebtoken.impl.lang.FieldReadable; import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.security.PrivateJwk; import io.jsonwebtoken.security.RsaPrivateJwk; import io.jsonwebtoken.security.RsaPublicJwk; @@ -28,6 +30,8 @@ import java.security.spec.RSAOtherPrimeInfo; import java.util.List; import java.util.Set; +import static io.jsonwebtoken.impl.security.DefaultRsaPublicJwk.equalsPublic; + class DefaultRsaPrivateJwk extends AbstractPrivateJwk implements RsaPrivateJwk { static final Field PRIVATE_EXPONENT = Fields.secretBigInt("d", "Private Exponent"); @@ -54,4 +58,39 @@ class DefaultRsaPrivateJwk extends AbstractPrivateJwk aOthers = a.get(OTHER_PRIMES_INFO); + List bOthers = b.get(OTHER_PRIMES_INFO); + int aSize = Collections.size(aOthers); + int bSize = Collections.size(bOthers); + if (aSize != bSize) return false; + if (aSize == 0) return true; + RSAOtherPrimeInfo[] aInfos = aOthers.toArray(new RSAOtherPrimeInfo[0]); + RSAOtherPrimeInfo[] bInfos = bOthers.toArray(new RSAOtherPrimeInfo[0]); + for (int i = 0; i < aSize; i++) { + if (!equals(aInfos[i], bInfos[i])) return false; + } + return true; + } + + @Override + protected boolean equals(PrivateJwk jwk) { + return jwk instanceof RsaPrivateJwk && equalsPublic(this, jwk) && + Fields.equals(this, jwk, PRIVATE_EXPONENT) && + Fields.equals(this, jwk, FIRST_PRIME) && + Fields.equals(this, jwk, SECOND_PRIME) && + Fields.equals(this, jwk, FIRST_CRT_EXPONENT) && + Fields.equals(this, jwk, SECOND_CRT_EXPONENT) && + Fields.equals(this, jwk, FIRST_CRT_COEFFICIENT) && + equalsOtherPrimes(this, (FieldReadable) jwk); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPublicJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPublicJwk.java index 000c9e82..5357bdcc 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPublicJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultRsaPublicJwk.java @@ -16,8 +16,10 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; +import io.jsonwebtoken.impl.lang.FieldReadable; import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.security.PublicJwk; import io.jsonwebtoken.security.RsaPublicJwk; import java.math.BigInteger; @@ -38,4 +40,13 @@ class DefaultRsaPublicJwk extends AbstractPublicJwk implements Rsa DefaultRsaPublicJwk(JwkContext ctx) { super(ctx, THUMBPRINT_FIELDS); } + + static boolean equalsPublic(FieldReadable self, Object candidate) { + return Fields.equals(self, candidate, MODULUS) && Fields.equals(self, candidate, PUBLIC_EXPONENT); + } + + @Override + protected boolean equals(PublicJwk jwk) { + return jwk instanceof RsaPublicJwk && equalsPublic(this, jwk); + } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultSecretJwk.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultSecretJwk.java index 22be55cf..8ca1e170 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultSecretJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultSecretJwk.java @@ -18,6 +18,7 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.Field; import io.jsonwebtoken.impl.lang.Fields; import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.security.Jwk; import io.jsonwebtoken.security.SecretJwk; import javax.crypto.SecretKey; @@ -36,4 +37,9 @@ class DefaultSecretJwk extends AbstractJwk implements SecretJwk { DefaultSecretJwk(JwkContext ctx) { super(ctx, THUMBPRINT_FIELDS); } + + @Override + protected boolean equals(Jwk jwk) { + return jwk instanceof SecretJwk && Fields.equals(this, jwk, K); + } } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/AbstractProtectedHeaderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/AbstractProtectedHeaderTest.groovy index fdd6a4fd..2ffe22e1 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/AbstractProtectedHeaderTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/AbstractProtectedHeaderTest.groovy @@ -144,7 +144,7 @@ class AbstractProtectedHeaderTest { h([jwk: jwk]) fail() } catch (IllegalArgumentException expected) { - String msg = "Invalid JWT header 'jwk' (JSON Web Key) value: {kty=oct, k=}. " + + String msg = "Invalid JWT header 'jwk' (JSON Web Key) value: {alg=HS256, kty=oct, k=}. " + "Value must be a Public JWK, not a Secret JWK." assertEquals msg, expected.getMessage() } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJweHeaderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJweHeaderTest.groovy index 5c48e921..19bb9a78 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJweHeaderTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/DefaultJweHeaderTest.groovy @@ -63,7 +63,7 @@ class DefaultJweHeaderTest { h([epk: values]) fail() } catch (IllegalArgumentException expected) { - String msg = "Invalid JWE header 'epk' (Ephemeral Public Key) value: {kty=oct, k=}. " + + String msg = "Invalid JWE header 'epk' (Ephemeral Public Key) value: {alg=HS256, kty=oct, k=}. " + "Value must be a Public JWK, not a Secret JWK." assertEquals msg, expected.getMessage() } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/BytesTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/BytesTest.groovy index ba8778b1..43f96621 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/BytesTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/BytesTest.groovy @@ -290,4 +290,39 @@ class BytesTest { Bytes.length(-1) } + @Test + void testClearNull() { + Bytes.clear(null) // no exception + } + + @Test + void testClearEmpty() { + Bytes.clear(Bytes.EMPTY) // no exception + } + + @Test + void testClear() { + int len = 16 + byte[] bytes = Bytes.random(len) + boolean allZero = true + for(int i = 0; i < len; i++) { + if (bytes[i] != (byte)0) { + allZero = false + break + } + } + assertFalse allZero // guarantee that we start with random bytes + + Bytes.clear(bytes) + + allZero = true + for(int i = 0; i < len; i++) { + if (bytes[i] != (byte)0) { + allZero = false + break + } + } + assertTrue allZero // asserts zeroed out entirely + } + } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/FieldsTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/FieldsTest.groovy index ef1869e1..2dd8393b 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/FieldsTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/FieldsTest.groovy @@ -221,4 +221,70 @@ class FieldsTest { def field = Fields.builder(String.class).setId('foo').setName("FooName").build() assertFalse field.equals(new Object()) } + + @Test + void testBigIntegerBytesNull() { + assertNull Fields.bytes(null) + } + + @Test + void testBytesEqualsWhenBothAreNull() { + assertTrue Fields.bytesEquals(null, null) + } + + @Test + void testBytesEqualsIdentity() { + assertTrue Fields.bytesEquals(BigInteger.ONE, BigInteger.ONE) + } + + @Test + void testBytesEqualsWhenAIsNull() { + assertFalse Fields.bytesEquals(null, BigInteger.ONE) + } + + @Test + void testBytesEqualsWhenBIsNull() { + assertFalse Fields.bytesEquals(BigInteger.ONE, null) + } + + @Test + void testFieldValueEqualsWhenAIsNull() { + BigInteger a = null + BigInteger b = BigInteger.ONE + Field field = Fields.bigInt('foo', 'bar').build() + assertFalse Fields.equals(a, b, field) + } + + @Test + void testFieldValueEqualsWhenBIsNull() { + BigInteger a = BigInteger.ONE + BigInteger b = null + Field field = Fields.bigInt('foo', 'bar').build() + assertFalse Fields.equals(a, b, field) + } + + @Test + void testFieldValueEqualsSecretString() { + String a = 'hello' + String b = new String('hello'.toCharArray()) // new instance not in the string table (Groovy side effect) + Field field = Fields.builder(String.class).setId('foo').setName('bar').setSecret(true).build() + assertTrue Fields.equals(a, b, field) + } + + @Test + void testEqualsIdentity() { + FieldReadable r = new TestFieldReadable() + assertTrue Fields.equals(r, r, Fields.string('foo', 'bar')) + } + + @Test + void testEqualsWhenAIsNull() { + assertFalse Fields.equals(null, "hello", Fields.string('foo', 'bar')) + } + + @Test + void testEqualsWhenAIsFieldReadableButBIsNot() { + FieldReadable r = new TestFieldReadable() + assertFalse Fields.equals(r, "hello", Fields.string('foo', 'bar')) + } } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/RedactedSupplierTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/RedactedSupplierTest.groovy index a4686e51..7928fada 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/RedactedSupplierTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/RedactedSupplierTest.groovy @@ -17,8 +17,7 @@ package io.jsonwebtoken.impl.lang import org.junit.Test -import static org.junit.Assert.assertFalse -import static org.junit.Assert.assertTrue +import static org.junit.Assert.* class RedactedSupplierTest { @@ -43,4 +42,16 @@ class RedactedSupplierTest { assertFalse new RedactedSupplier<>(42).equals(new RedactedSupplier(30)) } + @Test + void testEqualsIdentity() { + def supplier = new RedactedSupplier('hello') + assertEquals supplier, supplier + } + + @Test + void testHashCode() { + int hashCode = 42.hashCode() + assertEquals hashCode, new RedactedSupplier(42).hashCode() + } + } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/lang/TestFieldReadable.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/TestFieldReadable.groovy new file mode 100644 index 00000000..67bb16e3 --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/lang/TestFieldReadable.groovy @@ -0,0 +1,26 @@ +/* + * Copyright © 2023 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.impl.lang + +class TestFieldReadable implements FieldReadable { + + def value = null + + @Override + Object get(Field field) { + return value + } +} diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkTest.groovy index 88a3b7ba..5db61d0e 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkTest.groovy @@ -16,7 +16,9 @@ package io.jsonwebtoken.impl.security import io.jsonwebtoken.lang.Collections +import io.jsonwebtoken.security.Jwk import io.jsonwebtoken.security.Jwks +import io.jsonwebtoken.security.SecretJwk import org.junit.Before import org.junit.Test @@ -44,7 +46,12 @@ class AbstractJwkTest { } static AbstractJwk newJwk(JwkContext ctx) { - return new AbstractJwk(ctx, Collections.of(AbstractJwk.KTY)) {} + return new AbstractJwk(ctx, Collections.of(AbstractJwk.KTY)) { + @Override + protected boolean equals(Jwk jwk) { + return this.@context.equals(jwk.@context) + } + } } @Before @@ -144,24 +151,22 @@ class AbstractJwkTest { @Test void testPrivateJwkHashCode() { - assertEquals jwk.hashCode(), jwk.@context.hashCode() - def secretJwk1 = Jwks.builder().key(TestKeys.HS256).add('hello', 'world').build() def secretJwk2 = Jwks.builder().key(TestKeys.HS256).add('hello', 'world').build() - assertEquals secretJwk1.hashCode(), secretJwk1.@context.hashCode() - assertEquals secretJwk2.hashCode(), secretJwk2.@context.hashCode() assertEquals secretJwk1.hashCode(), secretJwk2.hashCode() def ecPrivJwk1 = Jwks.builder().key(TestKeys.ES256.pair.private).add('hello', 'ecworld').build() def ecPrivJwk2 = Jwks.builder().key(TestKeys.ES256.pair.private).add('hello', 'ecworld').build() assertEquals ecPrivJwk1.hashCode(), ecPrivJwk2.hashCode() - assertEquals ecPrivJwk1.hashCode(), ecPrivJwk1.@context.hashCode() - assertEquals ecPrivJwk2.hashCode(), ecPrivJwk2.@context.hashCode() def rsaPrivJwk1 = Jwks.builder().key(TestKeys.RS256.pair.private).add('hello', 'rsaworld').build() def rsaPrivJwk2 = Jwks.builder().key(TestKeys.RS256.pair.private).add('hello', 'rsaworld').build() assertEquals rsaPrivJwk1.hashCode(), rsaPrivJwk2.hashCode() - assertEquals rsaPrivJwk1.hashCode(), rsaPrivJwk1.@context.hashCode() - assertEquals rsaPrivJwk2.hashCode(), rsaPrivJwk2.@context.hashCode() + } + + @Test + void testEqualsWithNonJwk() { + SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).build() + assertFalse jwk.equals(42) } } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwkTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwkTest.groovy new file mode 100644 index 00000000..72682b28 --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultRsaPrivateJwkTest.groovy @@ -0,0 +1,73 @@ +/* + * Copyright © 2023 jsonwebtoken.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.jsonwebtoken.impl.security + +import io.jsonwebtoken.impl.lang.FieldReadable +import io.jsonwebtoken.impl.lang.TestFieldReadable +import org.junit.Test + +import java.security.spec.RSAOtherPrimeInfo + +import static org.junit.Assert.assertFalse +import static org.junit.Assert.assertTrue + +class DefaultRsaPrivateJwkTest { + + @Test + void testEqualsOtherPrimesDifferentSizes() { + def info1 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.ONE) + def info2 = new RSAOtherPrimeInfo(BigInteger.TEN, BigInteger.TEN, BigInteger.TEN) + FieldReadable a = new TestFieldReadable(value: [info1, info2]) + FieldReadable b = new TestFieldReadable(value: [info1]) // different sizes + assertFalse DefaultRsaPrivateJwk.equalsOtherPrimes(a, b) + } + + @Test + void testEqualsOtherPrimes() { + def info1 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.ONE) + def info2 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.ONE) + FieldReadable a = new TestFieldReadable(value: [info1]) + FieldReadable b = new TestFieldReadable(value: [info2]) + assertTrue DefaultRsaPrivateJwk.equalsOtherPrimes(a, b) + } + + @Test + void testEqualsOtherPrimesIdentity() { + def info1 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.ONE) + FieldReadable a = new TestFieldReadable(value: [info1]) + FieldReadable b = new TestFieldReadable(value: [info1]) + assertTrue DefaultRsaPrivateJwk.equalsOtherPrimes(a, b) + } + + @Test + void testEqualsOtherPrimesNullElement() { + def info1 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.ONE) + // sizes are the same, but one element is null: + FieldReadable a = new TestFieldReadable(value: [null]) + FieldReadable b = new TestFieldReadable(value: [info1]) + assertFalse DefaultRsaPrivateJwk.equalsOtherPrimes(a, b) + } + + @Test + void testEqualsOtherPrimesInfoNotEqual() { + def info1 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.ONE) + def info2 = new RSAOtherPrimeInfo(BigInteger.ONE, BigInteger.ONE, BigInteger.TEN) // different + FieldReadable a = new TestFieldReadable(value: [info1]) + FieldReadable b = new TestFieldReadable(value: [info2]) + assertFalse DefaultRsaPrivateJwk.equalsOtherPrimes(a, b) + } + +} diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/JwksTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/JwksTest.groovy index 42aee874..b8e6d208 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/JwksTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/JwksTest.groovy @@ -55,14 +55,14 @@ class JwksTest { //test non-null value: //noinspection GroovyAssignabilityCheck - def builder = Jwks.builder().key(key) + def builder = Jwks.builder().key(key).delete('alg') // delete alg put there by SecretKeyBuilder builder."$name"(val) def jwk = builder.build() assertEquals val, jwk."get${cap}"() assertEquals expectedFieldValue, jwk."${id}" //test null value: - builder = Jwks.builder().key(key) + builder = Jwks.builder().key(key).delete('alg') try { builder."$name"(null) fail("IAE should have been thrown") @@ -74,7 +74,7 @@ class JwksTest { assertFalse jwk.containsKey(id) //test empty string value - builder = Jwks.builder().key(key) + builder = Jwks.builder().key(key).delete('alg') if (val instanceof String) { try { builder."$name"(' ' as String) diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/SecretJwkFactoryTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/SecretJwkFactoryTest.groovy index ad3921cf..a47a2c2b 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/SecretJwkFactoryTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/SecretJwkFactoryTest.groovy @@ -33,7 +33,7 @@ class SecretJwkFactoryTest { @Test // if a jwk does not have an 'alg' or 'use' field, we default to an AES key void testNoAlgNoSigJcaName() { - SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build() SecretJwk result = Jwks.builder().add(jwk).build() as SecretJwk assertEquals 'AES', result.toKey().getAlgorithm() } @@ -41,13 +41,13 @@ class SecretJwkFactoryTest { @Test void testJwkHS256AlgSetsKeyJcaNameCorrectly() { SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).build() - SecretJwk result = Jwks.builder().add(jwk).add('alg', 'HS256').build() as SecretJwk + SecretJwk result = Jwks.builder().add(jwk).build() as SecretJwk assertEquals 'HmacSHA256', result.toKey().getAlgorithm() } @Test void testSignOpSetsKeyHmacSHA256() { - SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build() SecretJwk result = Jwks.builder().add(jwk).operations([Jwks.OP.SIGN]).build() as SecretJwk assertNull result.getAlgorithm() assertNull result.get('use') @@ -57,13 +57,13 @@ class SecretJwkFactoryTest { @Test void testJwkHS384AlgSetsKeyJcaNameCorrectly() { SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).build() - SecretJwk result = Jwks.builder().add(jwk).add('alg', 'HS384').build() as SecretJwk + SecretJwk result = Jwks.builder().add(jwk).build() as SecretJwk assertEquals 'HmacSHA384', result.toKey().getAlgorithm() } @Test void testSignOpSetsKeyHmacSHA384() { - SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).delete('alg').build() SecretJwk result = Jwks.builder().add(jwk).operations([Jwks.OP.SIGN]).build() as SecretJwk assertNull result.getAlgorithm() assertNull result.get('use') @@ -73,13 +73,13 @@ class SecretJwkFactoryTest { @Test void testJwkHS512AlgSetsKeyJcaNameCorrectly() { SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).build() - SecretJwk result = Jwks.builder().add(jwk).add('alg', 'HS512').build() as SecretJwk + SecretJwk result = Jwks.builder().add(jwk).build() as SecretJwk assertEquals 'HmacSHA512', result.toKey().getAlgorithm() } @Test void testSignOpSetsKeyHmacSHA512() { - SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).delete('alg').build() SecretJwk result = Jwks.builder().add(jwk).operations([Jwks.OP.SIGN]).build() as SecretJwk assertNull result.getAlgorithm() assertNull result.get('use') @@ -89,7 +89,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).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build() assertFalse jwk.containsKey('alg') assertFalse jwk.containsKey('use') SecretJwk result = Jwks.builder().add(jwk).add('use', 'sig').build() as SecretJwk @@ -99,7 +99,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).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).delete('alg').build() assertFalse jwk.containsKey('alg') assertFalse jwk.containsKey('use') SecretJwk result = Jwks.builder().add(jwk).add('use', 'sig').build() as SecretJwk @@ -109,7 +109,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).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).delete('alg').build() assertFalse jwk.containsKey('alg') assertFalse jwk.containsKey('use') SecretJwk result = Jwks.builder().add(jwk).add('use', 'sig').build() as SecretJwk @@ -119,7 +119,7 @@ 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).build() + SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).delete('alg').build() assertFalse jwk.containsKey('alg') assertFalse jwk.containsKey('use') SecretJwk result = Jwks.builder().add(jwk).add('use', 'foo').build() as SecretJwk