diff --git a/api/src/main/java/io/jsonwebtoken/security/AsymmetricJwkBuilder.java b/api/src/main/java/io/jsonwebtoken/security/AsymmetricJwkBuilder.java index cf0e29b15..ca57c5d02 100644 --- a/api/src/main/java/io/jsonwebtoken/security/AsymmetricJwkBuilder.java +++ b/api/src/main/java/io/jsonwebtoken/security/AsymmetricJwkBuilder.java @@ -16,7 +16,7 @@ package io.jsonwebtoken.security; import java.security.Key; -import java.util.Set; +import java.util.Collection; /** * A {@link JwkBuilder} that builds asymmetric (public or private) JWKs. @@ -69,7 +69,7 @@ public interface AsymmetricJwkBuilder, * *

Per * JWK RFC 7517, Section 4.3, last paragraph, - * the {@code use} (Public Key Use) and {@link #operations(Set) key_ops (Key Operations)} members + * the use (Public Key Use) and {@link #operations(Collection) key_ops (Key Operations)} members * SHOULD NOT be used together; however, if both are used, the information they convey MUST be * consistent. Applications should specify which of these members they use, if either is to be used by the * application.

diff --git a/api/src/main/java/io/jsonwebtoken/security/Jwk.java b/api/src/main/java/io/jsonwebtoken/security/Jwk.java index 7f3a947af..daef62678 100644 --- a/api/src/main/java/io/jsonwebtoken/security/Jwk.java +++ b/api/src/main/java/io/jsonwebtoken/security/Jwk.java @@ -95,69 +95,15 @@ public interface Jwk extends Identifiable, Map { String getAlgorithm(); /** - * Returns the JWK - * {@code key_ops} (Key Operations) - * parameter values or {@code null} if not present. Any values within the returned {@code Set} are - * CaSe-SeNsItIvE. - * - *

The JWK specification defines the - * following values:

- * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
JWK Key Operations
ValueOperation
{@code sign}compute digital signatures or MAC
{@code verify}verify digital signatures or MAC
{@code encrypt}encrypt content
{@code decrypt}decrypt content and validate decryption, if applicable
{@code wrapKey}encrypt key
{@code unwrapKey}decrypt key and validate decryption, if applicable
{@code deriveKey}derive key
{@code deriveBits}derive bits not to be used as a key
- * - *

Other values MAY be used. For best interoperability with other applications however, it is - * recommended to use only the values above.

- * - *

Multiple unrelated key operations SHOULD NOT be specified for a key because of the potential - * vulnerabilities associated with using the same key with multiple algorithms. Thus, the combinations - * {@code sign} with {@code verify}, {@code encrypt} with {@code decrypt}, and {@code wrapKey} with - * {@code unwrapKey} are permitted, but other combinations SHOULD NOT be used.

+ * Returns the JWK {@code key_ops} + * (Key Operations) parameter values or {@code null} if not present. All JWK standard Key Operations are + * available via the {@link Jwks.OP} registry, but other (custom) values MAY be present in the returned + * set. * * @return the JWK {@code key_ops} value or {@code null} if not present. + * @see key_ops(Key Operations) Parameter */ - Set getOperations(); + Set getOperations(); /** * Returns the required JWK @@ -188,6 +134,11 @@ public interface Jwk extends Identifiable, Map { * {@code oct} * Octet sequence (used to represent symmetric keys) * + * + * {@code OKP} + * Octet Key Pair (used to represent Edwards + * Elliptic Curve keys) + * * * * diff --git a/api/src/main/java/io/jsonwebtoken/security/JwkBuilder.java b/api/src/main/java/io/jsonwebtoken/security/JwkBuilder.java index 25aa92665..4e5aad115 100644 --- a/api/src/main/java/io/jsonwebtoken/security/JwkBuilder.java +++ b/api/src/main/java/io/jsonwebtoken/security/JwkBuilder.java @@ -18,7 +18,7 @@ import io.jsonwebtoken.lang.MapMutator; import java.security.Key; -import java.util.Set; +import java.util.Collection; /** * A {@link SecurityBuilder} that produces a JWK. A JWK is an immutable set of name/value pairs that represent a @@ -103,6 +103,47 @@ public interface JwkBuilder, T extends JwkBuilde */ T idFromThumbprint(HashAlgorithm alg); + /** + * Specifies an operation for which the key may be used by adding it to the + * JWK {@code key_ops} (Key Operations) + * Parameter values. This method may be called multiple times. + * + *

The {@code key_ops} (key operations) parameter identifies the operation(s) for which the key is + * intended to be used. The {@code key_ops} parameter is intended for use cases in which public, + * private, or symmetric keys may be present.

+ * + *

Security Vulnerability Notice

+ * + *

Multiple unrelated key operations SHOULD NOT be specified for a key because of the potential + * vulnerabilities associated with using the same key with multiple algorithms. Thus, the combinations + * {@link Jwks.OP#SIGN sign} with {@link Jwks.OP#VERIFY verify}, + * {@link Jwks.OP#ENCRYPT encrypt} with {@link Jwks.OP#DECRYPT decrypt}, and + * {@link Jwks.OP#WRAP_KEY wrapKey} with {@link Jwks.OP#UNWRAP_KEY unwrapKey} are permitted, but other combinations + * SHOULD NOT be used. This is enforced by the builder's key operation + * {@link #operationPolicy(KeyOperationPolicy) policy}.

+ * + *

Standard {@code KeyOperation}s and Overrides

+ * + *

All RFC-standard JWK Key Operations in the {@link Jwks.OP} registry are supported via the builder's default + * operations {@link #operationPolicy(KeyOperationPolicy) policy}, but other (custom) values + * MAY be specified (for example, using a {@link Jwks.OP#builder()}).

+ * + *

If the {@code JwkBuilder} is being used to rebuild or parse an existing JWK however, any custom operations + * should be enabled for the {@code JwkBuilder} by {@link #operationPolicy(KeyOperationPolicy) specifying} + * an operations policy that includes the custom values (e.g. via + * {@link Jwks.OP#policy()}.{@link KeyOperationPolicyBuilder#add(KeyOperation) add(customKeyOperation)}).

+ * + *

For best interoperability with other applications however, it is recommended to use only the {@link Jwks.OP} + * constants.

+ * + * @param operation the value to add to the JWK {@code key_ops} value set + * @return the builder for method chaining. + * @throws IllegalArgumentException if {@code op} is {@code null} or if the operation is not permitted + * by the operations {@link #operationPolicy(KeyOperationPolicy) policy}. + * @see Jwks.OP + */ + T operation(KeyOperation operation) throws IllegalArgumentException; + /** * Sets the JWK {@code key_ops} * (Key Operations) Parameter values. @@ -111,68 +152,63 @@ public interface JwkBuilder, T extends JwkBuilde * intended to be used. The {@code key_ops} parameter is intended for use cases in which public, * private, or symmetric keys may be present.

* - *

The JWK specification defines the - * following values:

- * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
JWK Key Operations
ValueOperation
{@code sign}compute digital signatures or MAC
{@code verify}verify digital signatures or MAC
{@code encrypt}encrypt content
{@code decrypt}decrypt content and validate decryption, if applicable
{@code wrapKey}encrypt key
{@code unwrapKey}decrypt key and validate decryption, if applicable
{@code deriveKey}derive key
{@code deriveBits}derive bits not to be used as a key
- * - *

(Note that {@code key_ops} values intentionally match the {@code KeyUsage} values defined in the - * Web Cryptography API specification.)

- * - *

Other values MAY be used. For best interoperability with other applications however, it is - * recommended to use only the values above. Each value is a CaSe-SeNsItIvE string. Use of the - * {@code key_ops} member is OPTIONAL, unless the application requires its presence.

+ *

Security Vulnerability Notice

* *

Multiple unrelated key operations SHOULD NOT be specified for a key because of the potential * vulnerabilities associated with using the same key with multiple algorithms. Thus, the combinations - * {@code sign} with {@code verify}, {@code encrypt} with {@code decrypt}, and {@code wrapKey} with - * {@code unwrapKey} are permitted, but other combinations SHOULD NOT be used.

+ * {@link Jwks.OP#SIGN sign} with {@link Jwks.OP#VERIFY verify}, + * {@link Jwks.OP#ENCRYPT encrypt} with {@link Jwks.OP#DECRYPT decrypt}, and + * {@link Jwks.OP#WRAP_KEY wrapKey} with {@link Jwks.OP#UNWRAP_KEY unwrapKey} are permitted, but other combinations + * SHOULD NOT be used. This is enforced by the builder's default + * operation {@link #operationPolicy(KeyOperationPolicy) policy}.

+ * + *

Standard {@code KeyOperation}s and Overrides

+ * + *

All RFC-standard JWK Key Operations in the {@link Jwks.OP} registry are supported via the builder's default + * operations {@link #operationPolicy(KeyOperationPolicy) policy}, but other (custom) values + * MAY be specified (for example, using a {@link Jwks.OP#builder()}).

+ * + *

If the {@code JwkBuilder} is being used to rebuild or parse an existing JWK however, any custom operations + * should be enabled for the {@code JwkBuilder} by {@link #operationPolicy(KeyOperationPolicy) specifying} + * an operations policy that includes the custom values (e.g. via + * {@link Jwks.OP#policy()}.{@link KeyOperationPolicyBuilder#add(KeyOperation) add(customKeyOperation)}).

+ * + *

For best interoperability with other applications however, it is recommended to use only the {@link Jwks.OP} + * constants.

+ * + * @param ops the JWK {@code key_ops} value set, or {@code null} if not present. + * @return the builder for method chaining. + * @throws IllegalArgumentException {@code ops} is {@code null} or empty, or if any of the operations are not + * permitted by the operations {@link #operationPolicy(KeyOperationPolicy) policy}. + * @see Jwks.OP + */ + T operations(Collection ops) throws IllegalArgumentException; + + /** + * Sets the builder's {@link KeyOperationPolicy} that determines which key + * {@link #operations(Collection) operations} may be assigned to the JWK. Unless overridden by this method, the + * builder uses the default RFC-recommended policy where: + *
    + *
  • All {@link Jwks.OP RFC-standard key operations} are supported.
  • + *
  • Multiple unrelated operations may not be assigned to the JWK per the + * RFC 7517, Section 4.3 recommendation: + *
    +     * Multiple unrelated key operations SHOULD NOT be specified for a key
    +     * because of the potential vulnerabilities associated with using the
    +     * same key with multiple algorithms.
    +     * 
  • + *
+ * + *

If you wish to enable a different policy, perhaps to support additional custom {@code KeyOperation} values, + * one can be created by using the {@link Jwks.OP#policy()} builder, or by implementing the + * {@link KeyOperationPolicy} interface directly.

* - * @param ops the JWK {@code key_ops} value set. + * @param policy the policy to apply during JWK construction * @return the builder for method chaining. - * @throws IllegalArgumentException if {@code ops} is {@code null} or empty. + * @throws IllegalArgumentException if the specified policy is null, or the policy's + * {@link KeyOperationPolicy#getOperations() operations} collection is null or + * empty. + * @see Jwks.OP#policy() */ - T operations(Set ops) throws IllegalArgumentException; + T operationPolicy(KeyOperationPolicy policy) throws IllegalArgumentException; } diff --git a/api/src/main/java/io/jsonwebtoken/security/JwkParserBuilder.java b/api/src/main/java/io/jsonwebtoken/security/JwkParserBuilder.java index 7390765f4..eb58ba794 100644 --- a/api/src/main/java/io/jsonwebtoken/security/JwkParserBuilder.java +++ b/api/src/main/java/io/jsonwebtoken/security/JwkParserBuilder.java @@ -58,4 +58,28 @@ public interface JwkParserBuilder extends Builder { */ JwkParserBuilder deserializeJsonWith(Deserializer> deserializer); + /** + * Sets the parser's key operation policy that determines which {@link KeyOperation}s may be assigned to parsed + * JWKs. Unless overridden by this method, the parser uses the default RFC-recommended policy where: + *
    + *
  • All {@link Jwks.OP RFC-standard key operations} are supported.
  • + *
  • Multiple unrelated operations may not be assigned to the JWK per the + * RFC 7517, Section 4.3 recommendation: + *
    +     * Multiple unrelated key operations SHOULD NOT be specified for a key
    +     * because of the potential vulnerabilities associated with using the
    +     * same key with multiple algorithms.
    +     * 
  • + *
+ * + *

If you wish to enable a different policy, perhaps to support additional custom {@code KeyOperation} values, + * one can be created by using the {@link Jwks.OP#policy()} builder, or by implementing the + * {@link KeyOperationPolicy} interface directly.

+ * + * @param policy the policy to use to determine which {@link KeyOperation}s may be assigned to parsed JWKs. + * @return the builder for method chaining. + * @throws IllegalArgumentException if {@code policy} is null + */ + JwkParserBuilder operationPolicy(KeyOperationPolicy policy) throws IllegalArgumentException; + } diff --git a/api/src/main/java/io/jsonwebtoken/security/Jwks.java b/api/src/main/java/io/jsonwebtoken/security/Jwks.java index 6c0ef613f..4a824ded7 100644 --- a/api/src/main/java/io/jsonwebtoken/security/Jwks.java +++ b/api/src/main/java/io/jsonwebtoken/security/Jwks.java @@ -45,12 +45,13 @@ private Jwks() { private static final String PARSERBUILDER_CLASSNAME = "io.jsonwebtoken.impl.security.DefaultJwkParserBuilder"; /** - * Constants for all standard Elliptic Curves in the {@code JSON Web Key Elliptic Curve Registry} - * defined by RFC 7518, Section 7.6 - * (for Weierstrass Elliptic Curves) and - * RFC 8037, Section 5 (for Edwards Elliptic Curves). - * Each standard algorithm is available as a - * ({@code public static final}) constant for direct type-safe reference in application code. For example: + * Constants for all standard JWK + * crv (Curve) parameter values + * defined in the JSON Web Key Elliptic + * Curve Registry (including its + * Edwards Elliptic Curve additions). + * Each standard algorithm is available as a ({@code public static final}) constant for direct type-safe + * reference in application code. For example: *
      * Jwks.CRV.P256.keyPair().build();
*

They are also available together as a {@link Registry} instance via the {@link #get()} method.

@@ -262,6 +263,137 @@ private HASH() { } } + /** + * Constants for all standard JWK + * key_ops (Key Operations) parameter values + * defined in the JSON Web Key Operations + * Registry. Each standard key operation is available as a ({@code public static final}) constant for + * direct type-safe reference in application code. For example: + *
+     * Jwks.builder()
+     *     .operations(Jwks.OP.SIGN)
+     *     // ... etc ...
+     *     .build();
+ *

They are also available together as a {@link Registry} instance via the {@link #get()} method.

+ * + * @see #get() + * @since JJWT_RELEASE_VERSION + */ + public static final class OP { + + private static final String IMPL_CLASSNAME = "io.jsonwebtoken.impl.security.StandardKeyOperations"; + private static final Registry REGISTRY = Classes.newInstance(IMPL_CLASSNAME); + + private static final String BUILDER_CLASSNAME = "io.jsonwebtoken.impl.security.DefaultKeyOperationBuilder"; + + + private static final String POLICY_BUILDER_CLASSNAME = + "io.jsonwebtoken.impl.security.DefaultKeyOperationPolicyBuilder"; + + /** + * Creates a new {@link KeyOperationBuilder} for creating custom {@link KeyOperation} instances. + * + * @return a new {@link KeyOperationBuilder} for creating custom {@link KeyOperation} instances. + */ + public static KeyOperationBuilder builder() { + return Classes.newInstance(BUILDER_CLASSNAME); + } + + /** + * Creates a new {@link KeyOperationPolicyBuilder} for creating custom {@link KeyOperationPolicy} instances. + * + * @return a new {@link KeyOperationPolicyBuilder} for creating custom {@link KeyOperationPolicy} instances. + */ + public static KeyOperationPolicyBuilder policy() { + return Classes.newInstance(POLICY_BUILDER_CLASSNAME); + } + + /** + * Returns a registry of all standard Key Operations in the {@code JSON Web Key Operations Registry} + * defined by RFC 7517, Section 8.3. + * + * @return a registry of all standard Key Operations in the {@code JSON Web Key Operations Registry}. + */ + public static Registry get() { + return REGISTRY; + } + + /** + * {@code sign} operation indicating a key is intended to be used to compute digital signatures or + * MACs. It's related operation is {@link #VERIFY}. + * + * @see #VERIFY + * @see Key Operation Registry Contents + */ + public static final KeyOperation SIGN = get().forKey("sign"); + + /** + * {@code verify} operation indicating a key is intended to be used to verify digital signatures or + * MACs. It's related operation is {@link #SIGN}. + * + * @see #SIGN + * @see Key Operation Registry Contents + */ + public static final KeyOperation VERIFY = get().forKey("verify"); + + /** + * {@code encrypt} operation indicating a key is intended to be used to encrypt content. It's + * related operation is {@link #DECRYPT}. + * + * @see #DECRYPT + * @see Key Operation Registry Contents + */ + public static final KeyOperation ENCRYPT = get().forKey("encrypt"); + + /** + * {@code decrypt} operation indicating a key is intended to be used to decrypt content. It's + * related operation is {@link #ENCRYPT}. + * + * @see #ENCRYPT + * @see Key Operation Registry Contents + */ + public static final KeyOperation DECRYPT = get().forKey("decrypt"); + + /** + * {@code wrapKey} operation indicating a key is intended to be used to encrypt another key. It's + * related operation is {@link #UNWRAP_KEY}. + * + * @see #UNWRAP_KEY + * @see Key Operation Registry Contents + */ + public static final KeyOperation WRAP_KEY = get().forKey("wrapKey"); + + /** + * {@code unwrapKey} operation indicating a key is intended to be used to decrypt another key and validate + * decryption, if applicable. It's related operation is + * {@link #WRAP_KEY}. + * + * @see #WRAP_KEY + * @see Key Operation Registry Contents + */ + public static final KeyOperation UNWRAP_KEY = get().forKey("unwrapKey"); + + /** + * {@code deriveKey} operation indicating a key is intended to be used to derive another key. It does not have + * a related operation. + * + * @see Key Operation Registry Contents + */ + public static final KeyOperation DERIVE_KEY = get().forKey("deriveKey"); + + /** + * {@code deriveBits} operation indicating a key is intended to be used to derive bits that are not to be + * used as key. It does not have a related operation. + * + * @see Key Operation Registry Contents + */ + public static final KeyOperation DERIVE_BITS = get().forKey("deriveBits"); + + //prevent instantiation + private OP() { + } + } + /** * Return a new JWK builder instance, allowing for type-safe JWK builder coercion based on a provided key or key pair. * diff --git a/api/src/main/java/io/jsonwebtoken/security/KeyOperation.java b/api/src/main/java/io/jsonwebtoken/security/KeyOperation.java new file mode 100644 index 000000000..38c4222cd --- /dev/null +++ b/api/src/main/java/io/jsonwebtoken/security/KeyOperation.java @@ -0,0 +1,55 @@ +/* + * 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.security; + +import io.jsonwebtoken.Identifiable; + +/** + * A {@code KeyOperation} identifies a behavior for which a key may be used. Key validation + * algorithms may inspect a key's operations and reject the key if it is being used in a manner inconsistent + * with its indicated operations. + * + *

KeyOperation Identifier

+ * + *

This interface extends {@link Identifiable}; the value returned from {@link #getId()} is a + * CaSe-SeNsItIvE value that uniquely identifies the operation among other KeyOperation instances.

+ * + * @see JWK key_ops (Key Operations) Parameter + * @see JSON Web Key Operations Registry + * @since JJWT_RELEASE_VERSION + */ +public interface KeyOperation extends Identifiable { + + /** + * Returns a brief description of the key operation behavior. + * + * @return a brief description of the key operation behavior. + */ + String getDescription(); + + /** + * Returns {@code true} if the specified {@code operation} is an acceptable use case for the key already assigned + * this operation, {@code false} otherwise. As described in the + * JWK key_ops (Key Operations) Parameter + * specification, Key validation algorithms will likely reject keys with inconsistent or unrelated operations + * because of the security vulnerabilities that could occur otherwise. + * + * @param operation the key operation to check if it is related to (consistent or compatible with) this operation. + * @return {@code true} if the specified {@code operation} is an acceptable use case for the key already assigned + * this operation, {@code false} otherwise. + */ + boolean isRelated(KeyOperation operation); +} diff --git a/api/src/main/java/io/jsonwebtoken/security/KeyOperationBuilder.java b/api/src/main/java/io/jsonwebtoken/security/KeyOperationBuilder.java new file mode 100644 index 000000000..99013daeb --- /dev/null +++ b/api/src/main/java/io/jsonwebtoken/security/KeyOperationBuilder.java @@ -0,0 +1,75 @@ +/* + * 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.security; + +import io.jsonwebtoken.lang.Builder; + +import java.util.Collection; + +/** + * A {@code KeyOperationBuilder} produces {@link KeyOperation} instances that may be added to a JWK's + * {@link JwkBuilder#operations(Collection) key operations} parameter. This is primarily only useful for creating + * custom (non-standard) {@code KeyOperation}s for use with a custom {@link KeyOperationPolicy}, as all standard ones + * are available already via the {@link Jwks.OP} registry singleton. + * + * @see Jwks.OP#builder() + * @see Jwks.OP#policy() + * @see JwkBuilder#operationPolicy(KeyOperationPolicy) + * @since JJWT_RELEASE_VERSION + */ +public interface KeyOperationBuilder extends Builder { + + /** + * Sets the CaSe-SeNsItIvE {@link KeyOperation#getId() id} expected to be unique compared to all other + * {@code KeyOperation}s. + * + * @param id the key operation id + * @return the builder for method chaining + */ + KeyOperationBuilder id(String id); + + /** + * Sets the key operation {@link KeyOperation#getDescription() description}. + * + * @param description the key operation description + * @return the builder for method chaining + */ + KeyOperationBuilder description(String description); + + /** + * Indicates that the {@code KeyOperation} with the given {@link KeyOperation#getId() id} is cryptographically + * related (and complementary) to this one, and may be specified together in a JWK's + * {@link Jwk#getOperations() operations} set. + * + *

More concretely, calling this method will ensure the following:

+ *
+     *     KeyOperation built = Jwks.operation()/*...*/.related(otherId).build();
+     *     KeyOperation other = getKeyOperation(otherId);
+     *     assert built.isRelated(other);
+ * + *

A {@link JwkBuilder}'s key operation {@link JwkBuilder#operationPolicy(KeyOperationPolicy) policy} is likely + * to {@link KeyOperationPolicyBuilder#allowUnrelated(boolean) reject} any unrelated operations specified + * together due to the potential security vulnerabilities that could occur.

+ * + *

This method may be called multiple times to add/append a related {@code id} to the constructed + * {@code KeyOperation}'s total set of related ids.

+ * + * @param id the id of a KeyOperation that will be considered cryptographically related to this one. + * @return the builder for method chaining. + * @see JwkBuilder#operationPolicy(KeyOperationPolicy) + */ + KeyOperationBuilder related(String id); +} diff --git a/api/src/main/java/io/jsonwebtoken/security/KeyOperationPolicy.java b/api/src/main/java/io/jsonwebtoken/security/KeyOperationPolicy.java new file mode 100644 index 000000000..d94536f15 --- /dev/null +++ b/api/src/main/java/io/jsonwebtoken/security/KeyOperationPolicy.java @@ -0,0 +1,43 @@ +/* + * 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.security; + +import java.util.Collection; + +/** + * A key operation policy determines which {@link KeyOperation}s may be assigned to a JWK. + * + * @since JJWT_RELEASE_VERSION + * @see JwkBuilder#operationPolicy(KeyOperationPolicy) + */ +public interface KeyOperationPolicy { + + /** + * Returns all supported {@code KeyOperation}s that may be assigned to a JWK. + * + * @return all supported {@code KeyOperation}s that may be assigned to a JWK. + */ + Collection getOperations(); + + /** + * Returns quietly if all of the specified key operations are allowed to be assigned to a JWK, + * or throws an {@link IllegalArgumentException} otherwise. + * + * @param ops the operations to validate + */ + @SuppressWarnings("GrazieInspection") + void validate(Collection ops) throws IllegalArgumentException; +} diff --git a/api/src/main/java/io/jsonwebtoken/security/KeyOperationPolicyBuilder.java b/api/src/main/java/io/jsonwebtoken/security/KeyOperationPolicyBuilder.java new file mode 100644 index 000000000..4f98183b8 --- /dev/null +++ b/api/src/main/java/io/jsonwebtoken/security/KeyOperationPolicyBuilder.java @@ -0,0 +1,112 @@ +/* + * 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.security; + +import io.jsonwebtoken.Identifiable; +import io.jsonwebtoken.lang.Builder; + +import java.util.Collection; + + +/** + * A {@code KeyOperationPolicyBuilder} produces a {@link KeyOperationPolicy} that determines + * which {@link KeyOperation}s may be assigned to a JWK. Custom {@code KeyOperation}s (such as those created by a + * {@link Jwks.OP#builder()}) may be added to a policy via the {@link #add(KeyOperation)} or {@link #add(Collection)} + * methods. + * + * @see Jwks.OP#policy() + * @see JwkBuilder#operationPolicy(KeyOperationPolicy) + * @see Jwks.OP#builder() + * @since JJWT_RELEASE_VERSION + */ +public interface KeyOperationPolicyBuilder extends Builder { + + /** + * Sets if a JWK is allowed to have unrelated {@link KeyOperation}s in its {@code key_ops} parameter values. + * The default value is {@code false} per the JWK + * RFC 7517, Section 4.3 recommendation: + * + *
+     * Multiple unrelated key operations SHOULD NOT be specified for a key
+     * because of the potential vulnerabilities associated with using the
+     * same key with multiple algorithms.
+     * 
+ * + *

Only set this value to {@code true} if you fully understand the security implications of using the same key + * with multiple algorithms in your application. Otherwise it is best not to use this builder method, or + * explicitly set it to {@code false}.

+ * + * @param allow if a JWK is allowed to have unrelated key {@link KeyOperation}s in its {@code key_ops} + * parameter values. + * @return the builder for method chaining + */ + KeyOperationPolicyBuilder allowUnrelated(boolean allow); + + /** + * Adds the specified key operation to the policy's total set of supported key operations + * used to validate a key's intended usage, replacing any existing one with an identical (CaSe-SeNsItIvE) + * {@link Identifiable#getId() id}. + * + *

Standard {@code KeyOperation}s and Overrides

+ * + *

The RFC standard {@link Jwks.OP} key operations are supported by default and do not need + * to be added via this method, but beware: If the {@code op} argument has a JWK standard + * {@link Identifiable#getId() id}, it will replace the JJWT standard operation implementation. + * This is to allow application developers to favor their own implementations over JJWT's default implementations + * if necessary (for example, to support legacy or custom behavior).

+ * + *

If a custom {@code KeyOperation} is desired, one may be easily created with a {@link Jwks.OP#builder()}.

+ * + * @param op a key operation to add to the policy's total set of supported operations, replacing any + * existing one with the same exact (CaSe-SeNsItIvE) {@link KeyOperation#getId() id}. + * @return the builder for method chaining. + * @see Jwks.OP + * @see Jwks.OP#builder() + * @see JwkBuilder#operationPolicy(KeyOperationPolicy) + * @see JwkBuilder#operations(Collection) + */ + KeyOperationPolicyBuilder add(KeyOperation op); + + /** + * Adds the specified key operations to the policy's total set of supported key operations + * used to validate a key's intended usage, replacing any existing ones with identical + * {@link Identifiable#getId() id}s. + * + *

There may be only one registered {@code KeyOperation} per CaSe-SeNsItIvE {@code id}, and the + * {@code ops} collection is added in iteration order; if a duplicate id is found when iterating the {@code ops} + * collection, the later operation will evict any existing operation with the same {@code id}.

+ * + *

Standard {@code KeyOperation}s and Overrides

+ * + *

The RFC standard {@link Jwks.OP} key operations are supported by default and do not need + * to be added via this method, but beware: any operation in the {@code ops} argument with a + * JWK standard {@link Identifiable#getId() id} will replace the JJWT standard operation implementation. + * This is to allow application developers to favor their own implementations over JJWT's default implementations + * if necessary (for example, to support legacy or custom behavior).

+ * + *

If custom {@code KeyOperation}s are desired, they may be easily created with a {@link Jwks.OP#builder()}.

+ * + * @param ops collection of key operations to add to the policy's total set of supported operations, replacing any + * existing ones with the same exact (CaSe-SeNsItIvE) {@link KeyOperation#getId() id}s. + * @return the builder for method chaining. + * @see Jwks.OP + * @see Jwks.OP#builder() + * @see JwkBuilder#operationPolicy(KeyOperationPolicy) + * @see JwkBuilder#operations(Collection) + */ + KeyOperationPolicyBuilder add(Collection ops); + +} diff --git a/impl/src/main/java/io/jsonwebtoken/impl/lang/Converter.java b/impl/src/main/java/io/jsonwebtoken/impl/lang/Converter.java index 638b601c4..ae9fe3b79 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/lang/Converter.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/lang/Converter.java @@ -15,9 +15,21 @@ */ package io.jsonwebtoken.impl.lang; -public interface Converter { +public interface Converter { + /** + * Converts the specified (Java idiomatic type) value to the canonical RFC-required data type. + * + * @param a the preferred idiomatic value + * @return the canonical RFC-required data type value. + */ B applyTo(A a); + /** + * Converts the specified canonical (RFC-compliant data type) value to the preferred Java idiomatic type. + * + * @param b the canonical value to convert + * @return the preferred Java idiomatic type value. + */ A applyFrom(B b); } 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 61b0aaa20..af0a8f8bc 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwk.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwk.java @@ -29,6 +29,7 @@ import io.jsonwebtoken.security.Jwk; import io.jsonwebtoken.security.JwkThumbprint; import io.jsonwebtoken.security.Jwks; +import io.jsonwebtoken.security.KeyOperation; import java.nio.charset.StandardCharsets; import java.security.Key; @@ -42,7 +43,9 @@ public abstract class AbstractJwk implements Jwk, FieldReadabl static final Field ALG = Fields.string("alg", "Algorithm"); public static final Field KID = Fields.string("kid", "Key ID"); - static final Field> KEY_OPS = Fields.stringSet("key_ops", "Key Operations"); + static final Field> KEY_OPS = + Fields.builder(KeyOperation.class).setConverter(KeyOperationConverter.DEFAULT) + .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); @@ -124,7 +127,7 @@ public String getName() { } @Override - public Set getOperations() { + public Set getOperations() { return Collections.immutable(this.context.getOperations()); } 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 9dbfb53a1..d75628d67 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwkBuilder.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/AbstractJwkBuilder.java @@ -16,11 +16,18 @@ package io.jsonwebtoken.impl.security; import io.jsonwebtoken.impl.lang.DelegatingMapMutator; +import io.jsonwebtoken.impl.lang.Field; +import io.jsonwebtoken.impl.lang.Fields; +import io.jsonwebtoken.impl.lang.IdRegistry; import io.jsonwebtoken.lang.Assert; +import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.lang.Registry; import io.jsonwebtoken.security.HashAlgorithm; import io.jsonwebtoken.security.Jwk; import io.jsonwebtoken.security.JwkBuilder; import io.jsonwebtoken.security.Jwks; +import io.jsonwebtoken.security.KeyOperation; +import io.jsonwebtoken.security.KeyOperationPolicy; import io.jsonwebtoken.security.MalformedKeyException; import io.jsonwebtoken.security.SecretJwk; import io.jsonwebtoken.security.SecretJwkBuilder; @@ -29,6 +36,8 @@ import java.security.Key; import java.security.Provider; import java.security.SecureRandom; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.Set; abstract class AbstractJwkBuilder, T extends JwkBuilder> @@ -37,6 +46,10 @@ abstract class AbstractJwkBuilder, T extends Jwk protected final JwkFactory jwkFactory; + static final KeyOperationPolicy DEFAULT_OPERATION_POLICY = Jwks.OP.policy().build(); + + protected KeyOperationPolicy opsPolicy = DEFAULT_OPERATION_POLICY; // default + @SuppressWarnings("unchecked") protected AbstractJwkBuilder(JwkContext jwkContext) { this(jwkContext, (JwkFactory) DispatchingJwkFactory.DEFAULT_INSTANCE); @@ -95,9 +108,39 @@ public T idFromThumbprint(HashAlgorithm alg) { } @Override - public T operations(Set ops) { - Assert.notEmpty(ops, "Operations cannot be null or empty."); - this.DELEGATE.setOperations(ops); + public T operation(KeyOperation operation) throws IllegalArgumentException { + Assert.notNull(operation, "KeyOperation cannot be null."); + return operations(Collections.setOf(operation)); + } + + @Override + public T operations(Collection ops) { + Assert.notEmpty(ops, "KeyOperations collection argument cannot be null or empty."); + Set set = new LinkedHashSet<>(ops); // new ones override existing ones + Set existing = this.DELEGATE.getOperations(); + if (!Collections.isEmpty(existing)) { + set.addAll(existing); + } + this.opsPolicy.validate(set); + this.DELEGATE.setOperations(set); + return self(); + } + + @Override + public T operationPolicy(KeyOperationPolicy policy) throws IllegalArgumentException { + Assert.notNull(policy, "Policy cannot be null."); + Collection ops = policy.getOperations(); + Assert.notEmpty(ops, "Policy operations cannot be null or empty."); + this.opsPolicy = policy; + + // update the JWK internal field to enable the policy's values: + Registry registry = new IdRegistry<>("JSON Web Key Operation", ops); + Field> field = Fields.builder(KeyOperation.class) + .setConverter(new KeyOperationConverter(registry)).set() + .setId(AbstractJwk.KEY_OPS.getId()) + .setName(AbstractJwk.KEY_OPS.getName()) + .build(); + setDelegate(this.DELEGATE.field(field)); return self(); } @@ -112,7 +155,9 @@ public J build() { String msg = "A " + Key.class.getName() + " or one or more name/value pairs must be provided to create a JWK."; throw new IllegalStateException(msg); } + try { + this.opsPolicy.validate(this.DELEGATE.get(AbstractJwk.KEY_OPS)); return jwkFactory.createJwk(this.DELEGATE); } catch (IllegalArgumentException iae) { //if we get an IAE, it means the builder state wasn't configured enough in order to create diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultDynamicJwkBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultDynamicJwkBuilder.java index 3cba163f8..3ab3796d7 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultDynamicJwkBuilder.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultDynamicJwkBuilder.java @@ -47,7 +47,11 @@ public class DefaultDynamicJwkBuilder> extends AbstractJwkBuilder> implements DynamicJwkBuilder { public DefaultDynamicJwkBuilder() { - super(new DefaultJwkContext()); + this(new DefaultJwkContext()); + } + + public DefaultDynamicJwkBuilder(JwkContext ctx) { + super(ctx); } @Override diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkContext.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkContext.java index 35eb79f81..f9655a43e 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkContext.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkContext.java @@ -21,12 +21,16 @@ import io.jsonwebtoken.lang.Assert; import io.jsonwebtoken.lang.Collections; import io.jsonwebtoken.security.HashAlgorithm; +import io.jsonwebtoken.security.Jwks; +import io.jsonwebtoken.security.KeyOperation; import java.security.Key; import java.security.PrivateKey; import java.security.Provider; import java.security.PublicKey; import java.security.SecureRandom; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; @@ -118,6 +122,18 @@ public DefaultJwkContext(Set> fields, JwkContext other, boolean remo } } + @Override + public JwkContext field(Field field) { + Assert.notNull(field, "Field cannot be null."); + Map> newFields = new LinkedHashMap<>(this.FIELDS); + newFields.remove(field.getId()); // remove old/default + newFields.put(field.getId(), field); // add new one + Set> fieldSet = new LinkedHashSet<>(newFields.values()); + return this.key != null ? + new DefaultJwkContext<>(fieldSet, this, key) : + new DefaultJwkContext(fieldSet, this, false); + } + @Override public String getName() { String value = get(AbstractJwk.KTY); @@ -177,12 +193,12 @@ public HashAlgorithm getIdThumbprintAlgorithm() { } @Override - public Set getOperations() { + public Set getOperations() { return get(AbstractJwk.KEY_OPS); } @Override - public JwkContext setOperations(Set ops) { + public JwkContext setOperations(Collection ops) { put(AbstractJwk.KEY_OPS, ops); return this; } @@ -216,11 +232,11 @@ public boolean isSigUse() { if ("sig".equals(getPublicKeyUse())) { return true; } - Set ops = getOperations(); + Set ops = getOperations(); if (Collections.isEmpty(ops)) { return false; } - return ops.contains("sign") || ops.contains("verify"); + return ops.contains(Jwks.OP.SIGN) || ops.contains(Jwks.OP.VERIFY); } @Override diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParser.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParser.java index 86bdf8a88..8e7e62143 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParser.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParser.java @@ -22,6 +22,7 @@ import io.jsonwebtoken.security.JwkParser; import io.jsonwebtoken.security.Jwks; import io.jsonwebtoken.security.KeyException; +import io.jsonwebtoken.security.KeyOperationPolicy; import io.jsonwebtoken.security.MalformedKeyException; import java.nio.charset.StandardCharsets; @@ -34,9 +35,14 @@ public class DefaultJwkParser implements JwkParser { private final Deserializer> deserializer; - public DefaultJwkParser(Provider provider, Deserializer> deserializer) { + private final KeyOperationPolicy opsPolicy; + + public DefaultJwkParser(Provider provider, Deserializer> deserializer, KeyOperationPolicy policy) { this.provider = provider; this.deserializer = Assert.notNull(deserializer, "Deserializer cannot be null."); + Assert.notNull(policy, "KeyOperationPolicy cannot be null."); + Assert.notEmpty(policy.getOperations(), "KeyOperationPolicy's operations cannot be null or empty."); + this.opsPolicy = policy; } // visible for testing @@ -56,7 +62,7 @@ public Jwk parse(String json) throws KeyException { throw new MalformedKeyException(msg); } - JwkBuilder builder = Jwks.builder(); + JwkBuilder builder = Jwks.builder().operationPolicy(this.opsPolicy); if (this.provider != null) { builder.provider(this.provider); diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParserBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParserBuilder.java index d71822be0..e0cc106c8 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParserBuilder.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultJwkParserBuilder.java @@ -17,18 +17,21 @@ import io.jsonwebtoken.impl.lang.Services; import io.jsonwebtoken.io.Deserializer; +import io.jsonwebtoken.lang.Assert; import io.jsonwebtoken.security.JwkParser; import io.jsonwebtoken.security.JwkParserBuilder; +import io.jsonwebtoken.security.KeyOperationPolicy; import java.security.Provider; import java.util.Map; -@SuppressWarnings("unused") //used via reflection by Jwks.parser() public class DefaultJwkParserBuilder implements JwkParserBuilder { private Provider provider; - private Deserializer> deserializer; + private Deserializer> deserializer; + + private KeyOperationPolicy opsPolicy = AbstractJwkBuilder.DEFAULT_OPERATION_POLICY; @Override public JwkParserBuilder provider(Provider provider) { @@ -42,6 +45,14 @@ public JwkParserBuilder deserializeJsonWith(Deserializer> deseria return this; } + @Override + public JwkParserBuilder operationPolicy(KeyOperationPolicy policy) throws IllegalArgumentException { + this.opsPolicy = Assert.notNull(policy, "KeyOperationPolicy may not be null."); + Assert.notEmpty(policy.getOperations(), "KeyOperationPolicy's operations may not be null or empty."); + this.opsPolicy = policy; + return this; + } + @Override public JwkParser build() { if (this.deserializer == null) { @@ -50,6 +61,6 @@ public JwkParser build() { this.deserializer = Services.loadFirst(Deserializer.class); } - return new DefaultJwkParser(this.provider, this.deserializer); + return new DefaultJwkParser(this.provider, this.deserializer, this.opsPolicy); } } diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperation.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperation.java new file mode 100644 index 000000000..5fbd63d9b --- /dev/null +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperation.java @@ -0,0 +1,89 @@ +/* + * 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.lang.Assert; +import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.lang.Strings; +import io.jsonwebtoken.security.KeyOperation; + +import java.util.Set; + +final class DefaultKeyOperation implements KeyOperation { + + private static final String CUSTOM_DESCRIPTION = "Custom key operation"; + + static final KeyOperation SIGN = of("sign", "Compute digital signature or MAC", "verify"); + static final KeyOperation VERIFY = of("verify", "Verify digital signature or MAC", "sign"); + static final KeyOperation ENCRYPT = of("encrypt", "Encrypt content", "decrypt"); + static final KeyOperation DECRYPT = + of("decrypt", "Decrypt content and validate decryption, if applicable", "encrypt"); + static final KeyOperation WRAP = of("wrapKey", "Encrypt key", "unwrapKey"); + static final KeyOperation UNWRAP = + of("unwrapKey", "Decrypt key and validate decryption, if applicable", "wrapKey"); + static final KeyOperation DERIVE_KEY = of("deriveKey", "Derive key", null); + static final KeyOperation DERIVE_BITS = + of("deriveBits", "Derive bits not to be used as a key", null); + + final String id; + final String description; + final Set related; + + static KeyOperation of(String id, String description, String related) { + return new DefaultKeyOperation(id, description, Collections.setOf(related)); + } + + DefaultKeyOperation(String id) { + this(id, null, null); + } + + DefaultKeyOperation(String id, String description, Set related) { + this.id = Assert.hasText(id, "id cannot be null or empty."); + this.description = Strings.hasText(description) ? description : CUSTOM_DESCRIPTION; + this.related = related != null ? Collections.immutable(related) : Collections.emptySet(); + } + + @Override + public String getId() { + return this.id; + } + + @Override + public String getDescription() { + return this.description; + } + + @Override + public boolean isRelated(KeyOperation operation) { + return equals(operation) || (operation != null && this.related.contains(operation.getId())); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj == this || + (obj instanceof KeyOperation && this.id.equals(((KeyOperation) obj).getId())); + } + + @Override + public String toString() { + return "'" + this.id + "' (" + this.description + ")"; + } +} diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationBuilder.java new file mode 100644 index 000000000..50e8884bd --- /dev/null +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationBuilder.java @@ -0,0 +1,55 @@ +/* + * 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.lang.Strings; +import io.jsonwebtoken.security.KeyOperation; +import io.jsonwebtoken.security.KeyOperationBuilder; + +import java.util.LinkedHashSet; +import java.util.Set; + +public class DefaultKeyOperationBuilder implements KeyOperationBuilder { + + private String id; + private String description; + private final Set related = new LinkedHashSet<>(); + + @Override + public KeyOperationBuilder id(String id) { + this.id = id; + return this; + } + + @Override + public KeyOperationBuilder description(String description) { + this.description = description; + return this; + } + + @Override + public KeyOperationBuilder related(String related) { + if (Strings.hasText(related)) { + this.related.add(related); + } + return this; + } + + @Override + public KeyOperation build() { + return new DefaultKeyOperation(this.id, this.description, this.related); + } +} diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicy.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicy.java new file mode 100644 index 000000000..9ee136ef0 --- /dev/null +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicy.java @@ -0,0 +1,76 @@ +/* + * 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.lang.Assert; +import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.lang.Objects; +import io.jsonwebtoken.security.KeyOperation; +import io.jsonwebtoken.security.KeyOperationPolicy; + +import java.util.Collection; + +final class DefaultKeyOperationPolicy implements KeyOperationPolicy { + + private final Collection ops; + + private final boolean allowUnrelated; + + DefaultKeyOperationPolicy(Collection ops, boolean allowUnrelated) { + Assert.notEmpty(ops, "KeyOperation collection cannot be null or empty."); + this.ops = Collections.immutable(ops); + this.allowUnrelated = allowUnrelated; + } + + @Override + public Collection getOperations() { + return this.ops; + } + + @Override + public void validate(Collection ops) { + if (allowUnrelated || Collections.isEmpty(ops)) return; + for (KeyOperation operation : ops) { + for (KeyOperation inner : ops) { + if (!operation.isRelated(inner)) { + String msg = "Unrelated key operations are not allowed. KeyOperation [" + inner + + "] is unrelated to [" + operation + "]."; + throw new IllegalArgumentException(msg); + } + } + } + } + + @Override + public int hashCode() { + int hash = Boolean.valueOf(this.allowUnrelated).hashCode(); + KeyOperation[] ops = this.ops.toArray(new KeyOperation[0]); + hash = 31 * hash + Objects.nullSafeHashCode((Object[]) ops); + return hash; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (!(obj instanceof DefaultKeyOperationPolicy)) { + return false; + } + DefaultKeyOperationPolicy other = (DefaultKeyOperationPolicy) obj; + return this.allowUnrelated == other.allowUnrelated && + Collections.size(this.ops) == Collections.size(other.ops) && + this.ops.containsAll(other.ops); + } +} diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicyBuilder.java b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicyBuilder.java new file mode 100644 index 000000000..2923a9b33 --- /dev/null +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicyBuilder.java @@ -0,0 +1,68 @@ +/* + * 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.lang.Assert; +import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.security.Jwks; +import io.jsonwebtoken.security.KeyOperation; +import io.jsonwebtoken.security.KeyOperationPolicy; +import io.jsonwebtoken.security.KeyOperationPolicyBuilder; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +public class DefaultKeyOperationPolicyBuilder implements KeyOperationPolicyBuilder { + + private final Map ops; + private boolean allowUnrelated = false; + + public DefaultKeyOperationPolicyBuilder() { + this.ops = new LinkedHashMap<>(Jwks.OP.get()); + } + + @Override + public KeyOperationPolicyBuilder allowUnrelated(boolean allow) { + this.allowUnrelated = allow; + return this; + } + + @Override + public KeyOperationPolicyBuilder add(KeyOperation op) { + if (op != null) { + String id = Assert.hasText(op.getId(), "KeyOperation id cannot be null or empty."); + this.ops.remove(id); + this.ops.put(id, op); + } + return this; + } + + @Override + public KeyOperationPolicyBuilder add(Collection ops) { + if (!Collections.isEmpty(ops)) { + for (KeyOperation op : ops) { + add(op); + } + } + return this; + } + + @Override + public KeyOperationPolicy build() { + return new DefaultKeyOperationPolicy(Collections.immutable(this.ops.values()), this.allowUnrelated); + } +} diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/JwkContext.java b/impl/src/main/java/io/jsonwebtoken/impl/security/JwkContext.java index 8a86c6be7..d70adc2d4 100644 --- a/impl/src/main/java/io/jsonwebtoken/impl/security/JwkContext.java +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/JwkContext.java @@ -17,20 +17,25 @@ import io.jsonwebtoken.Identifiable; import io.jsonwebtoken.impl.X509Context; +import io.jsonwebtoken.impl.lang.Field; import io.jsonwebtoken.impl.lang.FieldReadable; import io.jsonwebtoken.impl.lang.Nameable; import io.jsonwebtoken.security.HashAlgorithm; +import io.jsonwebtoken.security.KeyOperation; import java.security.Key; import java.security.Provider; import java.security.PublicKey; import java.security.SecureRandom; +import java.util.Collection; import java.util.Map; import java.util.Set; public interface JwkContext extends Identifiable, Map, FieldReadable, Nameable, X509Context> { + JwkContext field(Field field); + JwkContext setId(String id); JwkContext setIdThumbprintAlgorithm(HashAlgorithm alg); @@ -41,9 +46,9 @@ public interface JwkContext extends Identifiable, Map setType(String type); - Set getOperations(); + Set getOperations(); - JwkContext setOperations(Set operations); + JwkContext setOperations(Collection operations); String getAlgorithm(); diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/KeyOperationConverter.java b/impl/src/main/java/io/jsonwebtoken/impl/security/KeyOperationConverter.java new file mode 100644 index 000000000..409243fea --- /dev/null +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/KeyOperationConverter.java @@ -0,0 +1,50 @@ +/* + * 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.Converter; +import io.jsonwebtoken.lang.Assert; +import io.jsonwebtoken.lang.Registry; +import io.jsonwebtoken.security.Jwks; +import io.jsonwebtoken.security.KeyOperation; + +final class KeyOperationConverter implements Converter { + + static final Converter DEFAULT = new KeyOperationConverter(Jwks.OP.get()); + + private final Registry registry; + + KeyOperationConverter(Registry registry) { + this.registry = Assert.notEmpty(registry, "KeyOperation registry cannot be null or empty."); + } + + @Override + public String applyTo(KeyOperation operation) { + Assert.notNull(operation, "KeyOperation cannot be null."); + return operation.getId(); + } + + @Override + public KeyOperation applyFrom(Object o) { + if (o instanceof KeyOperation) { + return (KeyOperation) o; + } + String id = Assert.isInstanceOf(String.class, o, "Argument must be a KeyOperation or String."); + Assert.hasText(id, "KeyOperation string value cannot be null or empty."); + KeyOperation keyOp = this.registry.get(id); + return keyOp != null ? keyOp : Jwks.OP.builder().id(id).build(); // custom operations are allowed + } +} diff --git a/impl/src/main/java/io/jsonwebtoken/impl/security/StandardKeyOperations.java b/impl/src/main/java/io/jsonwebtoken/impl/security/StandardKeyOperations.java new file mode 100644 index 000000000..f22c2af06 --- /dev/null +++ b/impl/src/main/java/io/jsonwebtoken/impl/security/StandardKeyOperations.java @@ -0,0 +1,37 @@ +/* + * 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.DelegatingRegistry; +import io.jsonwebtoken.impl.lang.IdRegistry; +import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.security.KeyOperation; + +public class StandardKeyOperations extends DelegatingRegistry { + + public StandardKeyOperations() { + super(new IdRegistry<>("JSON Web Key Operation", Collections.of( + DefaultKeyOperation.SIGN, + DefaultKeyOperation.VERIFY, + DefaultKeyOperation.ENCRYPT, + DefaultKeyOperation.DECRYPT, + DefaultKeyOperation.WRAP, + DefaultKeyOperation.UNWRAP, + DefaultKeyOperation.DERIVE_KEY, + DefaultKeyOperation.DERIVE_BITS + ))); + } +} diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkBuilderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkBuilderTest.groovy index cea4fdfbb..aa1efaa41 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkBuilderTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/AbstractJwkBuilderTest.groovy @@ -15,7 +15,7 @@ */ package io.jsonwebtoken.impl.security - +import io.jsonwebtoken.lang.Collections import io.jsonwebtoken.security.Jwk import io.jsonwebtoken.security.Jwks import io.jsonwebtoken.security.MalformedKeyException @@ -112,34 +112,137 @@ class AbstractJwkBuilderTest { assertEquals kid, jwk.kid //test raw get via JWA member id } + @Test + //ensures that even if a raw single String value is present, it is represented as a Set per the JWA spec (string array) + void testOperationsByPutSingleStringValue() { + def s = 'wrapKey' + def op = Jwks.OP.get().get(s) + def canonical = Collections.setOf(s) + def idiomatic = Collections.setOf(op) + def jwk = builder().add('key_ops', s).build() // <-- put uses single raw String value, not a set + assertEquals idiomatic, jwk.getOperations() // <-- still get an idiomatic set + assertEquals canonical, jwk.key_ops // <-- still get a canonical set + } + + @Test + //ensures that even if a raw single KeyOperation value is present, it is represented as a Set per the JWA spec (string array) + void testOperationsByPutSingleIdiomaticValue() { + def s = 'wrapKey' + def op = Jwks.OP.get().get(s) + def canonical = Collections.setOf(s) + def idiomatic = Collections.setOf(op) + def jwk = builder().add('key_ops', op).build() // <-- put uses single raw KeyOperation value, not a set + assertEquals idiomatic, jwk.getOperations() // <-- still get an idiomatic set + assertEquals canonical, jwk.key_ops // <-- still get a canonical set + } + + @Test + void testOperation() { + def s = 'wrapKey' + def op = Jwks.OP.get().get(s) + def canonical = Collections.setOf(s) + def idiomatic = Collections.setOf(op) + def jwk = builder().operation(op).build() + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops + } + + @Test + void testOperationCustom() { + def s = UUID.randomUUID().toString() + def op = Jwks.OP.builder().id(s).build() + def canonical = Collections.setOf(s) + def idiomatic = Collections.setOf(op) + def jwk = builder().operation(op).build() + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops + } + + @Test + void testOperationCustomOverridesDefault() { + def s = 'sign' + def op = Jwks.OP.builder().id(s).related('verify').build() + def canonical = Collections.setOf(s) + def idiomatic = Collections.setOf(op) + def jwk = builder().operation(op).build() + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops + assertSame op, jwk.getOperations().iterator().next() + + //now assert that the standard VERIFY operation treats this as related since it has the same ID: + canonical = Collections.setOf(s, 'verify') + idiomatic = Collections.setOf(op, Jwks.OP.VERIFY) + jwk = builder().operation(op).operation(Jwks.OP.VERIFY).build() as Jwk + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops + } + @Test void testOperations() { - def a = UUID.randomUUID().toString() - def b = UUID.randomUUID().toString() - def set = [a, b] as Set - def jwk = builder().operations(set).build() - assertEquals set, jwk.getOperations() - assertEquals set, jwk.key_ops + def a = 'sign' + def b = 'verify' + def canonical = Collections.setOf(a, b) + def idiomatic = Collections.setOf(Jwks.OP.SIGN, Jwks.OP.VERIFY) + def jwk = builder().operations(idiomatic).build() + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops + } + + @Test + void testOperationsUnrelated() { + try { + // exception thrown on setter, before calling build: + builder().operations(Collections.setOf(Jwks.OP.SIGN, Jwks.OP.ENCRYPT)) + fail() + } catch (IllegalArgumentException e) { + String msg = 'Unrelated key operations are not allowed. KeyOperation [\'encrypt\' (Encrypt content)] is ' + + 'unrelated to [\'sign\' (Compute digital signature or MAC)].' + assertEquals msg, e.getMessage() + } + } + + @Test + void testOperationsPutUnrelatedStrings() { + try { + builder().add('key_ops', ['sign', 'encrypt']).build() + fail() + } catch (MalformedKeyException e) { + String msg = 'Unable to create JWK: Unrelated key operations are not allowed. KeyOperation ' + + '[\'encrypt\' (Encrypt content)] is unrelated to [\'sign\' (Compute digital signature or MAC)].' + assertEquals msg, e.getMessage() + } + } + + @Test + void testOperationsByCanonicalPut() { + def a = 'encrypt' + def b = 'decrypt' + def canonical = Collections.setOf(a, b) + def idiomatic = Collections.setOf(Jwks.OP.ENCRYPT, Jwks.OP.DECRYPT) + def jwk = builder().add('key_ops', canonical).build() // Set of String values, not KeyOperation objects + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops } @Test - void testOperationsByPut() { - def a = UUID.randomUUID().toString() - def b = UUID.randomUUID().toString() - def set = [a, b] as Set - def jwk = builder().add('key_ops', set).build() - assertEquals set, jwk.getOperations() - assertEquals set, jwk.key_ops + void testOperationsByIdiomaticPut() { + def a = 'encrypt' + def b = 'decrypt' + def canonical = Collections.setOf(a, b) + def idiomatic = Collections.setOf(Jwks.OP.ENCRYPT, Jwks.OP.DECRYPT) + def jwk = builder().add('key_ops', idiomatic).build() // Set of KeyOperation values, not strings + assertEquals idiomatic, jwk.getOperations() + assertEquals canonical, jwk.key_ops } + @Test - //ensures that even if a raw single value is present it is represented as a Set per the JWA spec (string array) - void testOperationsByPutSingleValue() { - def a = UUID.randomUUID().toString() - def set = [a] as Set - def jwk = builder().add('key_ops', a).build() // <-- put uses single raw value, not a set - assertEquals set, jwk.getOperations() // <-- still get a set - assertEquals set, jwk.key_ops // <-- still get a set + void testCustomOperationOverridesDefault() { + def op = Jwks.OP.builder().id('sign').description('Different Description') + .related(Jwks.OP.VERIFY.id).build() + def builder = builder().operationPolicy(Jwks.OP.policy().add(op).build()) + def jwk = builder.operations(Collections.setOf(op, Jwks.OP.VERIFY)).build() + println jwk } @Test @@ -159,6 +262,7 @@ class AbstractJwkBuilderTest { JwkContext newContext(JwkContext src, Key key) { return null } + @Override Jwk createJwk(JwkContext jwkContext) { throw new IllegalArgumentException("foo") diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkContextTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkContextTest.groovy index f77993071..06d47ad22 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkContextTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkContextTest.groovy @@ -16,11 +16,12 @@ package io.jsonwebtoken.impl.security import io.jsonwebtoken.impl.lang.Bytes +import io.jsonwebtoken.impl.lang.Field +import io.jsonwebtoken.impl.lang.Fields import io.jsonwebtoken.io.Encoders import org.junit.Test -import static org.junit.Assert.assertArrayEquals -import static org.junit.Assert.assertEquals +import static org.junit.Assert.* class DefaultJwkContextTest { @@ -120,4 +121,24 @@ class DefaultJwkContextTest { String s = '{kty=oct, k=}' assertEquals "$s", "${ctx.toString()}" } + + @Test + void testFieldWithoutKey() { + def ctx = new DefaultJwkContext(DefaultSecretJwk.FIELDS) + Field field = Fields.string('kid', 'My Key ID') + def newCtx = ctx.field(field) + assertSame field, newCtx.@FIELDS.get('kid') + assertNull newCtx.getKey() + } + + @Test + void testFieldWithKey() { + def key = TestKeys.HS256 + def ctx = new DefaultJwkContext(DefaultSecretJwk.FIELDS) + ctx.setKey(key) + Field field = Fields.string('kid', 'My Key ID') + def newCtx = ctx.field(field) + assertSame field, newCtx.@FIELDS.get('kid') // registry created with custom field instead of default + assertSame key, newCtx.getKey() // copied over correctly + } } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserBuilderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserBuilderTest.groovy index 195f5f512..700d54d74 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserBuilderTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserBuilderTest.groovy @@ -16,7 +16,9 @@ package io.jsonwebtoken.impl.security import io.jsonwebtoken.io.Deserializer +import io.jsonwebtoken.lang.Strings import io.jsonwebtoken.security.Jwks +import io.jsonwebtoken.security.MalformedKeyException import org.junit.Test import java.security.Provider @@ -26,6 +28,22 @@ import static org.junit.Assert.* class DefaultJwkParserBuilderTest { + // This JSON was borrowed from RFC7520Section3Test.FIGURE_2 and modified to + // replace the 'use' member with 'key_ops` for this test: + static String UNRELATED_OPS_JSON = Strings.trimAllWhitespace(''' + { + "kty": "EC", + "kid": "bilbo.baggins@hobbiton.example", + "key_ops": ["sign", "encrypt"], + "crv": "P-521", + "x": "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9 + A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt", + "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVy + SsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1", + "d": "AAhRON2r9cqXX1hg-RoI6R1tX5p2rUAYdmpHZoC1XNM56KtscrX6zb + KipQrCW9CGZH3T4ubpnoTKLDYJ_fF3_rJt" + }''') + @Test void testDefault() { def builder = Jwks.parser() as DefaultJwkParserBuilder @@ -50,4 +68,26 @@ class DefaultJwkParserBuilderTest { def parser = Jwks.parser().deserializeJsonWith(deserializer).build() as DefaultJwkParser assertSame deserializer, parser.deserializer } + + @Test + void testOperationPolicy() { + def parser = Jwks.parser().build() as DefaultJwkParser + + try { + // parse a JWK that has unrelated operations (prevented by default): + parser.parse(UNRELATED_OPS_JSON) + fail() + } catch (MalformedKeyException expected) { + String msg = "Unable to create JWK: Unrelated key operations are not allowed. KeyOperation " + + "['encrypt' (Encrypt content)] is unrelated to ['sign' (Compute digital signature or MAC)]." + assertEquals msg, expected.message + } + } + + @Test + void testOperationPolicyOverride() { + def policy = Jwks.OP.policy().allowUnrelated(true).build() + def parser = Jwks.parser().operationPolicy(policy).build() as DefaultJwkParser + assertNotNull parser.parse(UNRELATED_OPS_JSON) // no exception because policy allows it + } } diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserTest.groovy index 4cb357722..afad5efa9 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultJwkParserTest.groovy @@ -78,7 +78,7 @@ class DefaultJwkParserTest { @Test void testDeserializationFailure() { - def parser = new DefaultJwkParser(null, Services.loadFirst(Deserializer)) { + def parser = new DefaultJwkParser(null, Services.loadFirst(Deserializer), AbstractJwkBuilder.DEFAULT_OPERATION_POLICY) { @Override protected Map deserialize(String json) { throw new DeserializationException("test") diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationBuilderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationBuilderTest.groovy new file mode 100644 index 000000000..d9b5e24eb --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationBuilderTest.groovy @@ -0,0 +1,76 @@ +/* + * 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.security.Jwks +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.* + +class DefaultKeyOperationBuilderTest { + + private DefaultKeyOperationBuilder builder + + @Before + void setUp() { + this.builder = new DefaultKeyOperationBuilder() + } + + @Test + void testId() { + def id = 'foo' + def op = builder.id(id).build() as DefaultKeyOperation + assertEquals id, op.id + assertEquals DefaultKeyOperation.CUSTOM_DESCRIPTION, op.description + assertFalse op.isRelated(Jwks.OP.SIGN) + } + + @Test + void testDescription() { + def id = 'foo' + def description = 'test' + def op = builder.id(id).description(description).build() + assertEquals id, op.id + assertEquals 'test', op.description + } + + @Test + void testRelated() { + def id = 'foo' + def related = 'related' + def opA = builder.id(id).related(related).build() + def opB = builder.id(related).related(id).build() + assertEquals id, opA.id + assertEquals related, opB.id + assertTrue opA.isRelated(opB) + assertTrue opB.isRelated(opA) + assertFalse opA.isRelated(Jwks.OP.SIGN) + assertFalse opA.isRelated(Jwks.OP.SIGN) + } + + @Test + void testRelatedNull() { + def op = builder.id('foo').related(null).build() + assertTrue op.related.isEmpty() + } + + @Test + void testRelatedEmpty() { + def op = builder.id('foo').related(' ').build() + assertTrue op.related.isEmpty() + } +} diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicyBuilderTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicyBuilderTest.groovy new file mode 100644 index 000000000..c4a7023d8 --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationPolicyBuilderTest.groovy @@ -0,0 +1,116 @@ +/* + * 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.security.Jwks +import io.jsonwebtoken.security.KeyOperation +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.* + +class DefaultKeyOperationPolicyBuilderTest { + + DefaultKeyOperationPolicyBuilder builder + + @Before + void setUp() { + builder = new DefaultKeyOperationPolicyBuilder() + } + + @Test + void testDefault() { + def policy = builder.build() + assertTrue policy.operations.containsAll(Jwks.OP.get().values()) + // unrelated operations not allowed: + def op = Jwks.OP.builder().id('foo').build() + try { + policy.validate([op, Jwks.OP.SIGN]) + fail("Unrelated operations are not allowed by default.") + } catch (IllegalArgumentException expected) { + String msg = 'Unrelated key operations are not allowed. KeyOperation ' + + '[\'sign\' (Compute digital signature or MAC)] is unrelated to [\'foo\' (Custom key operation)].' + assertEquals msg, expected.getMessage() + } + } + + @Test + void testAdd() { + def op = Jwks.OP.builder().id('foo').build() + def policy = builder.add(op).build() + assertTrue policy.operations.contains(op) + } + + @Test + void testAddNull() { + def orig = builder.build() + def policy = builder.add((KeyOperation) null).build() + assertEquals orig, policy + } + + @Test + void testAddCollection() { + def foo = Jwks.OP.builder().id('foo').build() + def bar = Jwks.OP.builder().id('bar').build() + def policy = builder.add([foo, bar]).build() + assertTrue policy.operations.contains(foo) + assertTrue policy.operations.contains(bar) + } + + @Test + void testAddNullCollection() { + def orig = builder.build() + def policy = builder.add((Collection) null).build() + assertEquals orig, policy + } + + @Test + void testAllowUnrelatedTrue() { // testDefault has it false as expected + def foo = Jwks.OP.builder().id('foo').build() + def policy = builder.allowUnrelated(true).build() + policy.validate([foo, Jwks.OP.SIGN]) // no exception thrown since unrelated == true + } + + @Test + void testHashCode() { + def a = builder.add(Jwks.OP.builder().id('foo').build()).build() + def b = builder.build() + assertFalse a.is(b) // identity equals is different + def ahc = a.hashCode() + def bhc = b.hashCode() + assertEquals ahc, bhc // still same hashcode + } + + @Test + void testEquals() { + def a = builder.add(Jwks.OP.builder().id('foo').build()).build() + def b = builder.build() + assertFalse a.is(b) // identity equals is different + assertEquals a, b // but still equals + } + + @Test + void testEqualsIdentity() { + def policy = builder.build() + assertEquals policy, policy + } + + @SuppressWarnings('ChangeToOperator') + @Test + void testEqualsUnexpectedType() { + assertFalse builder.build().equals(new Object()) + } +} diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationTest.groovy new file mode 100644 index 000000000..a341b4617 --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/DefaultKeyOperationTest.groovy @@ -0,0 +1,60 @@ +/* + * 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.security.Jwks +import org.junit.Test + +import static org.junit.Assert.* + +class DefaultKeyOperationTest { + + @Test + void testCustom() { + def op = new DefaultKeyOperation('foo') + assertEquals DefaultKeyOperation.CUSTOM_DESCRIPTION, op.getDescription() + assertNotNull op.related + assertTrue op.related.isEmpty() + } + + @Test + void testUnrelated() { + assertFalse new DefaultKeyOperation('foo').isRelated(Jwks.OP.SIGN) + } + + @Test + void testRelatedNull() { + assertFalse Jwks.OP.SIGN.isRelated(null) + } + + @Test + void testRelatedEquals() { + def op = Jwks.OP.SIGN as DefaultKeyOperation + assertTrue op.isRelated(op) + } + + @Test + void testRelatedTrue() { + def op = Jwks.OP.SIGN as DefaultKeyOperation + assertTrue op.isRelated(Jwks.OP.VERIFY) + } + + @Test + void testRelatedFalse() { + def op = Jwks.OP.SIGN as DefaultKeyOperation + assertFalse op.isRelated(Jwks.OP.ENCRYPT) + } +} 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 cd6c88057..42aee8741 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/JwksTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/JwksTest.groovy @@ -19,6 +19,7 @@ import io.jsonwebtoken.Jwts import io.jsonwebtoken.impl.lang.Converters import io.jsonwebtoken.io.Decoders import io.jsonwebtoken.io.Encoders +import io.jsonwebtoken.lang.Collections import io.jsonwebtoken.security.* import org.junit.Test @@ -143,7 +144,9 @@ class JwksTest { @Test void testOperations() { - testProperty('operations', 'key_ops', ['foo', 'bar'] as Set) + def val = [Jwks.OP.SIGN, Jwks.OP.VERIFY] as Set + def canonical = Collections.setOf('sign', 'verify') + testProperty('operations', 'key_ops', val, canonical) } @Test diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/KeyOperationConverterTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/KeyOperationConverterTest.groovy new file mode 100644 index 000000000..1720159f4 --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/KeyOperationConverterTest.groovy @@ -0,0 +1,42 @@ +/* + * 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.security.Jwks +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertSame + +class KeyOperationConverterTest { + + @Test + void testApplyFromStandardId() { + Jwks.OP.get().values().each { + def id = it.id + def op = KeyOperationConverter.DEFAULT.applyFrom(id) + assertSame it, op + } + } + + @Test + void testApplyFromCustomId() { + def id = 'custom' + def op = KeyOperationConverter.DEFAULT.applyFrom(id) + assertEquals id, op.id + assertEquals DefaultKeyOperation.CUSTOM_DESCRIPTION, op.description + } +} diff --git a/impl/src/test/groovy/io/jsonwebtoken/impl/security/PrivateConstructorsTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/impl/security/PrivateConstructorsTest.groovy index 177bd9e37..58fdf42c3 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/PrivateConstructorsTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/PrivateConstructorsTest.groovy @@ -34,5 +34,6 @@ class PrivateConstructorsTest { new Jwts.ZIP() new Jwks.CRV() new Jwks.HASH() + new Jwks.OP() } } 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 f047f2d25..ad3921cf7 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/impl/security/SecretJwkFactoryTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/impl/security/SecretJwkFactoryTest.groovy @@ -30,7 +30,8 @@ import static org.junit.Assert.* */ class SecretJwkFactoryTest { - @Test // if a jwk does not have an 'alg' or 'use' field, we default to an AES key + @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 result = Jwks.builder().add(jwk).build() as SecretJwk @@ -47,7 +48,7 @@ class SecretJwkFactoryTest { @Test void testSignOpSetsKeyHmacSHA256() { SecretJwk jwk = Jwks.builder().key(TestKeys.HS256).build() - SecretJwk result = Jwks.builder().add(jwk).operations(["sign"] as Set).build() as SecretJwk + SecretJwk result = Jwks.builder().add(jwk).operations([Jwks.OP.SIGN]).build() as SecretJwk assertNull result.getAlgorithm() assertNull result.get('use') assertEquals 'HmacSHA256', result.toKey().getAlgorithm() @@ -63,7 +64,7 @@ class SecretJwkFactoryTest { @Test void testSignOpSetsKeyHmacSHA384() { SecretJwk jwk = Jwks.builder().key(TestKeys.HS384).build() - SecretJwk result = Jwks.builder().add(jwk).operations(["sign"] as Set).build() as SecretJwk + SecretJwk result = Jwks.builder().add(jwk).operations([Jwks.OP.SIGN]).build() as SecretJwk assertNull result.getAlgorithm() assertNull result.get('use') assertEquals 'HmacSHA384', result.toKey().getAlgorithm() @@ -79,13 +80,14 @@ class SecretJwkFactoryTest { @Test void testSignOpSetsKeyHmacSHA512() { SecretJwk jwk = Jwks.builder().key(TestKeys.HS512).build() - SecretJwk result = Jwks.builder().add(jwk).operations(["sign"] as Set).build() as SecretJwk + SecretJwk result = Jwks.builder().add(jwk).operations([Jwks.OP.SIGN]).build() as SecretJwk assertNull result.getAlgorithm() assertNull result.get('use') assertEquals 'HmacSHA512', result.toKey().getAlgorithm() } - @Test // no 'alg' jwk property, but 'use' is 'sig', so forces jcaName to be HmacSHA256 + @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() assertFalse jwk.containsKey('alg') @@ -94,7 +96,8 @@ class SecretJwkFactoryTest { assertEquals 'HmacSHA256', result.toKey().getAlgorithm() // jcaName has been changed to a sig algorithm } - @Test // no 'alg' jwk property, but 'use' is 'sig', so forces jcaName to be HmacSHA384 + @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() assertFalse jwk.containsKey('alg') @@ -103,7 +106,8 @@ class SecretJwkFactoryTest { assertEquals 'HmacSHA384', result.toKey().getAlgorithm() } - @Test // no 'alg' jwk property, but 'use' is 'sig', so forces jcaName to be HmacSHA512 + @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() assertFalse jwk.containsKey('alg') @@ -112,7 +116,8 @@ class SecretJwkFactoryTest { assertEquals 'HmacSHA512', result.toKey().getAlgorithm() } - @Test // no 'alg' jwk property, but 'use' is something other than 'sig', so jcaName should default to AES + @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() assertFalse jwk.containsKey('alg') diff --git a/impl/src/test/groovy/io/jsonwebtoken/JwksCRVTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/security/JwksCRVTest.groovy similarity index 95% rename from impl/src/test/groovy/io/jsonwebtoken/JwksCRVTest.groovy rename to impl/src/test/groovy/io/jsonwebtoken/security/JwksCRVTest.groovy index 107e3151c..981accf97 100644 --- a/impl/src/test/groovy/io/jsonwebtoken/JwksCRVTest.groovy +++ b/impl/src/test/groovy/io/jsonwebtoken/security/JwksCRVTest.groovy @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.jsonwebtoken +package io.jsonwebtoken.security import io.jsonwebtoken.impl.security.ECCurve import io.jsonwebtoken.impl.security.EdwardsCurve import io.jsonwebtoken.impl.security.StandardCurves -import io.jsonwebtoken.security.Jwks import org.junit.Test import static org.junit.Assert.assertSame diff --git a/impl/src/test/groovy/io/jsonwebtoken/security/JwksOPTest.groovy b/impl/src/test/groovy/io/jsonwebtoken/security/JwksOPTest.groovy new file mode 100644 index 000000000..db1fe50a8 --- /dev/null +++ b/impl/src/test/groovy/io/jsonwebtoken/security/JwksOPTest.groovy @@ -0,0 +1,60 @@ +/* + * 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.security + +import io.jsonwebtoken.impl.security.StandardKeyOperations +import org.junit.Test + +import static org.junit.Assert.* + +class JwksOPTest { + + @Test + void testRegistry() { + assertTrue Jwks.OP.get() instanceof StandardKeyOperations + } + + static void testInstance(KeyOperation op, String id, String description, KeyOperation related) { + assertEquals id, op.getId() + assertEquals description, op.getDescription() + if (related) { + assertTrue op.isRelated(related) + } + assertEquals id.hashCode(), op.hashCode() + assertEquals "'$id' ($description)" as String, op.toString() + assertTrue op.equals(op) + assertTrue op.is(op) + assertTrue op == op + assertEquals op, Jwks.OP.get().get(id) + assertSame op, Jwks.OP.get().get(id) + } + + @Test + void testInstances() { + testInstance(Jwks.OP.SIGN, 'sign', 'Compute digital signature or MAC', Jwks.OP.VERIFY) + testInstance(Jwks.OP.VERIFY, 'verify', 'Verify digital signature or MAC', Jwks.OP.SIGN) + testInstance(Jwks.OP.ENCRYPT, 'encrypt', 'Encrypt content', Jwks.OP.DECRYPT) + testInstance(Jwks.OP.DECRYPT, 'decrypt', 'Decrypt content and validate decryption, if applicable', Jwks.OP.ENCRYPT) + testInstance(Jwks.OP.WRAP_KEY, 'wrapKey', 'Encrypt key', Jwks.OP.UNWRAP_KEY) + testInstance(Jwks.OP.UNWRAP_KEY, 'unwrapKey', 'Decrypt key and validate decryption, if applicable', Jwks.OP.WRAP_KEY) + + testInstance(Jwks.OP.DERIVE_KEY, 'deriveKey', 'Derive key', null) + assertFalse Jwks.OP.DERIVE_KEY.isRelated(Jwks.OP.DERIVE_BITS) + + testInstance(Jwks.OP.DERIVE_BITS, 'deriveBits', 'Derive bits not to be used as a key', null) + assertFalse Jwks.OP.DERIVE_BITS.isRelated(Jwks.OP.DERIVE_KEY) + } +}