From a1db2fa29bf81e0f998d83fcf4e1455c9073e8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Sun, 24 Mar 2019 13:34:41 +0100 Subject: [PATCH] Move JOSE related methods into an utility class --- .../java/org/shredzone/acme4j/Account.java | 24 +- .../org/shredzone/acme4j/AccountBuilder.java | 51 +-- .../acme4j/challenge/TokenChallenge.java | 14 +- .../acme4j/connector/DefaultConnection.java | 45 +-- .../shredzone/acme4j/toolbox/AcmeUtils.java | 74 ----- .../shredzone/acme4j/toolbox/JSONBuilder.java | 14 +- .../shredzone/acme4j/toolbox/JoseUtils.java | 247 +++++++++++++++ .../shredzone/acme4j/AccountBuilderTest.java | 54 +--- .../acme4j/toolbox/AcmeUtilsTest.java | 72 +---- .../acme4j/toolbox/JoseUtilsTest.java | 299 ++++++++++++++++++ 10 files changed, 591 insertions(+), 303 deletions(-) create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JoseUtils.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JoseUtilsTest.java diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java index ca6f5133..b5307ec2 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java @@ -14,7 +14,6 @@ package org.shredzone.acme4j; import static java.util.stream.Collectors.toList; -import static org.shredzone.acme4j.toolbox.AcmeUtils.keyAlgorithm; import java.net.URI; import java.net.URL; @@ -29,9 +28,6 @@ import java.util.Objects; import javax.annotation.CheckForNull; import javax.annotation.ParametersAreNonnullByDefault; -import org.jose4j.jwk.PublicJsonWebKey; -import org.jose4j.jws.JsonWebSignature; -import org.jose4j.lang.JoseException; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.connector.ResourceIterator; @@ -42,6 +38,7 @@ import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.shredzone.acme4j.toolbox.JoseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -209,30 +206,17 @@ public class Account extends AcmeJsonResource { try (Connection conn = getSession().connect()) { URL keyChangeUrl = getSession().resourceUrl(Resource.KEY_CHANGE); - PublicJsonWebKey newKeyJwk = PublicJsonWebKey.Factory.newPublicJwk(newKeyPair.getPublic()); JSONBuilder payloadClaim = new JSONBuilder(); payloadClaim.put("account", getLocation()); payloadClaim.putKey("oldKey", getLogin().getKeyPair().getPublic()); - JsonWebSignature innerJws = new JsonWebSignature(); - innerJws.setPayload(payloadClaim.toString()); - innerJws.getHeaders().setObjectHeaderValue("url", keyChangeUrl); - innerJws.getHeaders().setJwkHeaderValue("jwk", newKeyJwk); - innerJws.setAlgorithmHeaderValue(keyAlgorithm(newKeyJwk)); - innerJws.setKey(newKeyPair.getPrivate()); - innerJws.sign(); + JSONBuilder jose = JoseUtils.createJoseRequest(keyChangeUrl, newKeyPair, + payloadClaim, null, null); - JSONBuilder outerClaim = new JSONBuilder(); - outerClaim.put("protected", innerJws.getHeaders().getEncodedHeader()); - outerClaim.put("signature", innerJws.getEncodedSignature()); - outerClaim.put("payload", innerJws.getEncodedPayload()); - - conn.sendSignedRequest(keyChangeUrl, outerClaim, getLogin()); + conn.sendSignedRequest(keyChangeUrl, jose, getLogin()); getLogin().setKeyPair(newKeyPair); - } catch (JoseException ex) { - throw new AcmeProtocolException("Cannot sign key-change", ex); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java index ca22ee74..243ba595 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java @@ -14,23 +14,17 @@ package org.shredzone.acme4j; import static java.util.Objects.requireNonNull; -import static org.shredzone.acme4j.toolbox.AcmeUtils.macKeyAlgorithm; import java.net.URI; import java.net.URL; import java.security.KeyPair; -import java.security.PublicKey; import java.util.ArrayList; import java.util.List; -import java.util.Map; import javax.annotation.ParametersAreNonnullByDefault; import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; -import org.jose4j.jwk.PublicJsonWebKey; -import org.jose4j.jws.JsonWebSignature; -import org.jose4j.keys.HmacKey; -import org.jose4j.lang.JoseException; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; @@ -38,6 +32,7 @@ import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.shredzone.acme4j.toolbox.JoseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -166,7 +161,7 @@ public class AccountBuilder { */ public AccountBuilder withKeyIdentifier(String kid, String encodedMacKey) { byte[] encodedKey = AcmeUtils.base64UrlDecode(requireNonNull(encodedMacKey, "encodedMacKey")); - return withKeyIdentifier(kid, new HmacKey(encodedKey)); + return withKeyIdentifier(kid, new SecretKeySpec(encodedKey, "HMAC")); } /** @@ -209,7 +204,7 @@ public class AccountBuilder { claims.put("termsOfServiceAgreed", termsOfServiceAgreed); } if (keyIdentifier != null) { - claims.put("externalAccountBinding", createExternalAccountBinding( + claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding( keyIdentifier, keyPair.getPublic(), macKey, resourceUrl)); } if (onlyExisting != null) { @@ -232,42 +227,4 @@ public class AccountBuilder { } } - /** - * Creates a JSON structure for external account binding. - * - * @param kid - * Key Identifier provided by the CA - * @param accountKey - * {@link PublicKey} of the account to register - * @param macKey - * {@link SecretKey} to sign the key identifier with - * @param resource - * "newAccount" resource URL - * @return Created JSON structure - */ - private Map createExternalAccountBinding(String kid, - PublicKey accountKey, SecretKey macKey, URL resource) - throws AcmeException { - try { - PublicJsonWebKey keyJwk = PublicJsonWebKey.Factory.newPublicJwk(accountKey); - - JsonWebSignature innerJws = new JsonWebSignature(); - innerJws.setPayload(keyJwk.toJson()); - innerJws.getHeaders().setObjectHeaderValue("url", resource); - innerJws.getHeaders().setObjectHeaderValue("kid", kid); - innerJws.setAlgorithmHeaderValue(macKeyAlgorithm(macKey)); - innerJws.setKey(macKey); - innerJws.setDoKeyValidation(false); - innerJws.sign(); - - JSONBuilder outerClaim = new JSONBuilder(); - outerClaim.put("protected", innerJws.getHeaders().getEncodedHeader()); - outerClaim.put("signature", innerJws.getEncodedSignature()); - outerClaim.put("payload", innerJws.getEncodedPayload()); - return outerClaim.toMap(); - } catch (JoseException ex) { - throw new AcmeException("Could not create external account binding", ex); - } - } - } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TokenChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TokenChallenge.java index ed59bd9b..60c1bbd1 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TokenChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TokenChallenge.java @@ -19,12 +19,11 @@ import java.security.PublicKey; import javax.annotation.ParametersAreNonnullByDefault; -import org.jose4j.jwk.PublicJsonWebKey; -import org.jose4j.lang.JoseException; import org.shredzone.acme4j.Login; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.JoseUtils; /** * An extension of {@link Challenge} that handles challenges with a {@code token} and @@ -66,15 +65,8 @@ public class TokenChallenge extends Challenge { * override this method if a different algorithm is used. */ public String getAuthorization() { - try { - PublicKey pk = getLogin().getKeyPair().getPublic(); - PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(pk); - return getToken() - + '.' - + base64UrlEncode(jwk.calculateThumbprint("SHA-256")); - } catch (JoseException ex) { - throw new AcmeProtocolException("Cannot compute key thumbprint", ex); - } + PublicKey pk = getLogin().getKeyPair().getPublic(); + return getToken() + '.' + base64UrlEncode(JoseUtils.thumbprint(pk)); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java index 24307f39..ab8c4723 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java @@ -14,7 +14,6 @@ package org.shredzone.acme4j.connector; import static java.util.stream.Collectors.toList; -import static org.shredzone.acme4j.toolbox.AcmeUtils.keyAlgorithm; import java.io.IOException; import java.io.InputStream; @@ -24,6 +23,7 @@ import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; @@ -41,9 +41,6 @@ import javax.annotation.CheckForNull; import javax.annotation.Nullable; import javax.annotation.ParametersAreNonnullByDefault; -import org.jose4j.jwk.PublicJsonWebKey; -import org.jose4j.jws.JsonWebSignature; -import org.jose4j.lang.JoseException; import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Problem; import org.shredzone.acme4j.Session; @@ -58,6 +55,7 @@ import org.shredzone.acme4j.exception.AcmeUserActionRequiredException; import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.shredzone.acme4j.toolbox.JoseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -385,8 +383,6 @@ public class DefaultConnection implements Connection { resetNonce(session); } - String claimJson = claims != null ? claims.toString() : ""; - conn = httpConnector.openConnection(url, session.getProxy()); conn.setRequestMethod("POST"); conn.setRequestProperty(ACCEPT_HEADER, accept); @@ -395,34 +391,15 @@ public class DefaultConnection implements Connection { conn.setRequestProperty(CONTENT_TYPE_HEADER, "application/jose+json"); conn.setDoOutput(true); - final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(keypair.getPublic()); - JsonWebSignature jws = new JsonWebSignature(); - jws.setPayload(claimJson); - jws.getHeaders().setObjectHeaderValue("nonce", session.getNonce()); - jws.getHeaders().setObjectHeaderValue("url", url); - if (accountLocation == null) { - jws.getHeaders().setJwkHeaderValue("jwk", jwk); - } else { - jws.getHeaders().setObjectHeaderValue("kid", accountLocation); - } + JSONBuilder jose = JoseUtils.createJoseRequest( + url, + keypair, + claims, + session.getNonce(), + accountLocation != null ? accountLocation.toString() : null + ); - jws.setAlgorithmHeaderValue(keyAlgorithm(jwk)); - jws.setKey(keypair.getPrivate()); - jws.sign(); - - if (LOG.isDebugEnabled()) { - LOG.debug("{} {}", claims != null ? "POST" : "POST-as-GET", url); - if (claims != null) { - LOG.debug(" Payload: {}", claimJson); - } - LOG.debug(" JWS Header: {}", jws.getHeaders().getFullHeaderAsJsonString()); - } - - JSONBuilder jb = new JSONBuilder(); - jb.put("protected", jws.getHeaders().getEncodedHeader()); - jb.put("payload", jws.getEncodedPayload()); - jb.put("signature", jws.getEncodedSignature()); - byte[] outputData = jb.toString().getBytes(DEFAULT_CHARSET); + byte[] outputData = jose.toString().getBytes(StandardCharsets.UTF_8); conn.setFixedLengthStreamingMode(outputData.length); conn.connect(); @@ -442,8 +419,6 @@ public class DefaultConnection implements Connection { return rc; } catch (IOException ex) { throw new AcmeNetworkException(ex); - } catch (JoseException ex) { - throw new AcmeProtocolException("Failed to generate a JSON request", ex); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java index 53e89647..f765c01e 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java @@ -34,13 +34,7 @@ import javax.annotation.Nullable; import javax.annotation.ParametersAreNonnullByDefault; import javax.annotation.WillNotClose; import javax.annotation.concurrent.Immutable; -import javax.crypto.SecretKey; -import org.jose4j.jwk.EllipticCurveJsonWebKey; -import org.jose4j.jwk.JsonWebKey; -import org.jose4j.jwk.RsaJsonWebKey; -import org.jose4j.jws.AlgorithmIdentifiers; -import org.jose4j.jws.JsonWebSignature; import org.shredzone.acme4j.exception.AcmeProtocolException; /** @@ -191,74 +185,6 @@ public final class AcmeUtils { return IDN.toASCII(domain.trim()).toLowerCase(); } - /** - * Analyzes the key used in the {@link JsonWebKey}, and returns the key algorithm - * identifier for {@link JsonWebSignature}. - * - * @param jwk - * {@link JsonWebKey} to analyze - * @return algorithm identifier - * @throws IllegalArgumentException - * there is no corresponding algorithm identifier for the key - */ - public static String keyAlgorithm(JsonWebKey jwk) { - if (jwk instanceof EllipticCurveJsonWebKey) { - EllipticCurveJsonWebKey ecjwk = (EllipticCurveJsonWebKey) jwk; - - switch (ecjwk.getCurveName()) { - case "P-256": - return AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256; - - case "P-384": - return AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384; - - case "P-521": - return AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512; - - default: - throw new IllegalArgumentException("Unknown EC name " - + ecjwk.getCurveName()); - } - - } else if (jwk instanceof RsaJsonWebKey) { - return AlgorithmIdentifiers.RSA_USING_SHA256; - - } else { - throw new IllegalArgumentException("Unknown algorithm " + jwk.getAlgorithm()); - } - } - - /** - * Analyzes the {@link SecretKey}, and returns the key algorithm - * identifier for {@link JsonWebSignature}. - * - * @param macKey - * {@link SecretKey} to analyze - * @return algorithm identifier - * @throws IllegalArgumentException - * there is no corresponding algorithm identifier for the key - */ - public static String macKeyAlgorithm(SecretKey macKey) { - if (!"HMAC".equals(macKey.getAlgorithm())) { - throw new IllegalArgumentException("Bad algorithm: " + macKey.getAlgorithm()); - } - - int size = macKey.getEncoded().length * 8; - switch (size) { - case 256: - return AlgorithmIdentifiers.HMAC_SHA256; - - case 384: - return AlgorithmIdentifiers.HMAC_SHA384; - - case 512: - return AlgorithmIdentifiers.HMAC_SHA512; - - default: - throw new IllegalArgumentException("Bad key size: " + size); - } - } - /** * Parses a RFC 3339 formatted date. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java index 21020524..52b1bb36 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java @@ -30,10 +30,6 @@ import javax.annotation.Nullable; import javax.annotation.ParametersAreNonnullByDefault; import org.jose4j.json.JsonUtil; -import org.jose4j.jwk.JsonWebKey; -import org.jose4j.jwk.PublicJsonWebKey; -import org.jose4j.lang.JoseException; -import org.shredzone.acme4j.exception.AcmeProtocolException; /** * Builder for JSON structures. @@ -131,14 +127,8 @@ public class JSONBuilder { public JSONBuilder putKey(String key, PublicKey publickey) { Objects.requireNonNull(publickey, "publickey"); - try { - final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(publickey); - Map jwkParams = jwk.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY); - object(key).data.putAll(jwkParams); - return this; - } catch (JoseException ex) { - throw new AcmeProtocolException("Invalid key", ex); - } + data.put(key, JoseUtils.publicKeyToJWK(publickey)); + return this; } /** diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JoseUtils.java b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JoseUtils.java new file mode 100644 index 00000000..49737675 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JoseUtils.java @@ -0,0 +1,247 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2019 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.toolbox; + +import java.net.URL; +import java.security.KeyPair; +import java.security.PublicKey; +import java.util.Map; + +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; +import javax.crypto.SecretKey; + +import org.jose4j.jwk.EllipticCurveJsonWebKey; +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.PublicJsonWebKey; +import org.jose4j.jwk.RsaJsonWebKey; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.lang.JoseException; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class that takes care of all the JOSE stuff. + * + * @since 2.7 + */ +@ParametersAreNonnullByDefault +public final class JoseUtils { + + private static final Logger LOG = LoggerFactory.getLogger(JoseUtils.class); + + private JoseUtils() { + // Utility class without constructor + } + + /** + * Creates an ACME JOSE request. + * + * @param url + * {@link URL} of the ACME call + * @param keypair + * {@link KeyPair} to sign the request with + * @param payload + * ACME JSON payload. If {@code null}, a POST-as-GET request is generated + * instead. + * @param nonce + * Nonce to be used. {@code null} if no nonce is to be used in the JOSE + * header. + * @param kid + * kid to be used in the JOSE header. If {@code null}, a jwk header of the + * given key is used instead. + * @return JSON structure of the JOSE request, ready to be sent. + */ + public static JSONBuilder createJoseRequest(URL url, KeyPair keypair, + @Nullable JSONBuilder payload, @Nullable String nonce, @Nullable String kid) { + try { + PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(keypair.getPublic()); + + JsonWebSignature jws = new JsonWebSignature(); + jws.getHeaders().setObjectHeaderValue("url", url); + + if (kid != null) { + jws.getHeaders().setObjectHeaderValue("kid", kid); + } else { + jws.getHeaders().setJwkHeaderValue("jwk", jwk); + } + + if (nonce != null) { + jws.getHeaders().setObjectHeaderValue("nonce", nonce); + } + + jws.setPayload(payload != null ? payload.toString() : ""); + jws.setAlgorithmHeaderValue(keyAlgorithm(jwk)); + jws.setKey(keypair.getPrivate()); + jws.sign(); + + if (LOG.isDebugEnabled()) { + LOG.debug("{} {}", payload != null ? "POST" : "POST-as-GET", url); + if (payload != null) { + LOG.debug(" Payload: {}", payload); + } + LOG.debug(" JWS Header: {}", jws.getHeaders().getFullHeaderAsJsonString()); + } + + JSONBuilder jb = new JSONBuilder(); + jb.put("protected", jws.getHeaders().getEncodedHeader()); + jb.put("payload", jws.getEncodedPayload()); + jb.put("signature", jws.getEncodedSignature()); + return jb; + } catch (JoseException ex) { + throw new AcmeProtocolException("Failed to sign a JSON request", ex); + } + } + + /** + * Creates a JSON structure for external account binding. + * + * @param kid + * Key Identifier provided by the CA + * @param accountKey + * {@link PublicKey} of the account to register + * @param macKey + * {@link SecretKey} to sign the key identifier with + * @param resource + * "newAccount" resource URL + * @return Created JSON structure + */ + public static Map createExternalAccountBinding(String kid, + PublicKey accountKey, SecretKey macKey, URL resource) throws AcmeException { + try { + PublicJsonWebKey keyJwk = PublicJsonWebKey.Factory.newPublicJwk(accountKey); + + JsonWebSignature innerJws = new JsonWebSignature(); + innerJws.setPayload(keyJwk.toJson()); + innerJws.getHeaders().setObjectHeaderValue("url", resource); + innerJws.getHeaders().setObjectHeaderValue("kid", kid); + innerJws.setAlgorithmHeaderValue(macKeyAlgorithm(macKey)); + innerJws.setKey(macKey); + innerJws.setDoKeyValidation(false); + innerJws.sign(); + + JSONBuilder outerClaim = new JSONBuilder(); + outerClaim.put("protected", innerJws.getHeaders().getEncodedHeader()); + outerClaim.put("signature", innerJws.getEncodedSignature()); + outerClaim.put("payload", innerJws.getEncodedPayload()); + return outerClaim.toMap(); + } catch (JoseException ex) { + throw new AcmeException("Could not create external account binding", ex); + } + } + + /** + * Converts a {@link PublicKey} to a JOSE JWK structure. + * + * @param key + * {@link PublicKey} to convert + * @return JSON map containing the JWK structure + */ + public static Map publicKeyToJWK(PublicKey key) { + try { + return PublicJsonWebKey.Factory.newPublicJwk(key) + .toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY); + } catch (JoseException ex) { + throw new IllegalArgumentException("Bad public key", ex); + } + } + + /** + * Computes a thumbprint of the given public key. + * + * @param key + * {@link PublicKey} to get the thumbprint of + * @return Thumbprint of the key + */ + public static byte[] thumbprint(PublicKey key) { + try { + PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(key); + return jwk.calculateThumbprint("SHA-256"); + } catch (JoseException ex) { + throw new IllegalArgumentException("Bad public key", ex); + } + } + + /** + * Analyzes the key used in the {@link JsonWebKey}, and returns the key algorithm + * identifier for {@link JsonWebSignature}. + * + * @param jwk + * {@link JsonWebKey} to analyze + * @return algorithm identifier + * @throws IllegalArgumentException + * there is no corresponding algorithm identifier for the key + */ + public static String keyAlgorithm(JsonWebKey jwk) { + if (jwk instanceof EllipticCurveJsonWebKey) { + EllipticCurveJsonWebKey ecjwk = (EllipticCurveJsonWebKey) jwk; + + switch (ecjwk.getCurveName()) { + case "P-256": + return AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256; + + case "P-384": + return AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384; + + case "P-521": + return AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512; + + default: + throw new IllegalArgumentException("Unknown EC name " + + ecjwk.getCurveName()); + } + + } else if (jwk instanceof RsaJsonWebKey) { + return AlgorithmIdentifiers.RSA_USING_SHA256; + + } else { + throw new IllegalArgumentException("Unknown algorithm " + jwk.getAlgorithm()); + } + } + + /** + * Analyzes the {@link SecretKey}, and returns the key algorithm identifier for {@link + * JsonWebSignature}. + * + * @param macKey + * {@link SecretKey} to analyze + * @return algorithm identifier + * @throws IllegalArgumentException + * there is no corresponding algorithm identifier for the key + */ + public static String macKeyAlgorithm(SecretKey macKey) { + if (!"HMAC".equals(macKey.getAlgorithm())) { + throw new IllegalArgumentException("Bad algorithm: " + macKey.getAlgorithm()); + } + + int size = macKey.getEncoded().length * 8; + switch (size) { + case 256: + return AlgorithmIdentifiers.HMAC_SHA256; + + case 384: + return AlgorithmIdentifiers.HMAC_SHA384; + + case 512: + return AlgorithmIdentifiers.HMAC_SHA512; + + default: + throw new IllegalArgumentException("Bad key size: " + size); + } + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java index 3e4cee9d..dadbcc7f 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java @@ -13,9 +13,11 @@ */ package org.shredzone.acme4j; -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.*; -import static org.shredzone.acme4j.toolbox.TestUtils.*; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThat; +import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; +import static org.shredzone.acme4j.toolbox.TestUtils.url; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; import java.net.HttpURLConnection; @@ -24,9 +26,7 @@ import java.security.KeyPair; import javax.crypto.SecretKey; -import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwx.CompactSerializer; -import org.jose4j.lang.JoseException; import org.junit.Test; import org.mockito.Mockito; import org.shredzone.acme4j.connector.Resource; @@ -34,6 +34,7 @@ import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.shredzone.acme4j.toolbox.JoseUtilsTest; import org.shredzone.acme4j.toolbox.TestUtils; /** @@ -115,41 +116,20 @@ public class AccountBuilderTest { TestableConnectionProvider provider = new TestableConnectionProvider() { @Override public int sendSignedRequest(URL url, JSONBuilder claims, Session session, KeyPair keypair) { - try { - assertThat(session, is(notNullValue())); - assertThat(url, is(resourceUrl)); - assertThat(keypair, is(accountKey)); + assertThat(session, is(notNullValue())); + assertThat(url, is(resourceUrl)); + assertThat(keypair, is(accountKey)); - JSON binding = claims.toJSON() - .get("externalAccountBinding") - .asObject(); + JSON binding = claims.toJSON() + .get("externalAccountBinding") + .asObject(); - String encodedHeader = binding.get("protected").asString(); - String encodedSignature = binding.get("signature").asString(); - String encodedPayload = binding.get("payload").asString(); + String encodedHeader = binding.get("protected").asString(); + String encodedSignature = binding.get("signature").asString(); + String encodedPayload = binding.get("payload").asString(); + String serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature); - String serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature); - JsonWebSignature jws = new JsonWebSignature(); - jws.setCompactSerialization(serialized); - jws.setKey(macKey); - assertThat(jws.verifySignature(), is(true)); - - assertThat(jws.getHeader("url"), is(resourceUrl.toString())); - assertThat(jws.getHeader("kid"), is(keyIdentifier)); - assertThat(jws.getHeader("alg"), is("HS256")); - - String decodedPayload = jws.getPayload(); - StringBuilder expectedPayload = new StringBuilder(); - expectedPayload.append('{'); - expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\","); - expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\","); - expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\""); - expectedPayload.append("}"); - assertThat(decodedPayload, sameJSONAs(expectedPayload.toString())); - } catch (JoseException ex) { - ex.printStackTrace(); - fail("decoding inner payload failed"); - } + JoseUtilsTest.assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey); return HttpURLConnection.HTTP_CREATED; } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java index eac94010..cc60cb0d 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java @@ -13,8 +13,10 @@ */ package org.shredzone.acme4j.toolbox; -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.*; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; import static org.shredzone.acme4j.toolbox.AcmeUtils.*; import java.io.ByteArrayOutputStream; @@ -25,7 +27,6 @@ import java.io.Writer; import java.lang.reflect.Constructor; import java.lang.reflect.Modifier; import java.net.URI; -import java.security.KeyPair; import java.security.Security; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; @@ -38,11 +39,10 @@ import java.util.List; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.hamcrest.BaseMatcher; import org.hamcrest.Description; -import org.jose4j.jwk.PublicJsonWebKey; import org.junit.BeforeClass; import org.junit.Test; import org.shredzone.acme4j.exception.AcmeProtocolException; -import org.shredzone.acme4j.toolbox.AcmeUtils.PemLabel; +import org.shredzone.acme4j.toolbox.AcmeUtils.*; /** * Unit tests for {@link AcmeUtils}. @@ -133,68 +133,6 @@ public class AcmeUtilsTest { is("xn--exmle-hra7p.xn--m-7ba6w")); } - /** - * Test if RSA using SHA-256 keys are properly detected. - */ - @Test - public void testRsaKey() throws Exception { - KeyPair rsaKeyPair = TestUtils.createKeyPair(); - final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(rsaKeyPair.getPublic()); - - String type = keyAlgorithm(jwk); - - assertThat(type, is("RS256")); - } - - /** - * Test if ECDSA using NIST P-256 curve and SHA-256 keys are properly detected. - */ - @Test - public void testP256ECKey() throws Exception { - KeyPair ecKeyPair = TestUtils.createECKeyPair("secp256r1"); - final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic()); - - String type = keyAlgorithm(jwk); - - assertThat(type, is("ES256")); - } - - /** - * Test if ECDSA using NIST P-384 curve and SHA-384 keys are properly detected. - */ - @Test - public void testP384ECKey() throws Exception { - KeyPair ecKeyPair = TestUtils.createECKeyPair("secp384r1"); - final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic()); - - String type = keyAlgorithm(jwk); - - assertThat(type, is("ES384")); - } - - /** - * Test if ECDSA using NIST P-521 curve and SHA-512 keys are properly detected. - */ - @Test - public void testP521ECKey() throws Exception { - KeyPair ecKeyPair = TestUtils.createECKeyPair("secp521r1"); - final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic()); - - String type = keyAlgorithm(jwk); - - assertThat(type, is("ES512")); - } - - /** - * Test if MAC key algorithms are properly detected. - */ - @Test - public void testMacKey() throws Exception { - assertThat(macKeyAlgorithm(TestUtils.createSecretKey("SHA-256")), is("HS256")); - assertThat(macKeyAlgorithm(TestUtils.createSecretKey("SHA-384")), is("HS384")); - assertThat(macKeyAlgorithm(TestUtils.createSecretKey("SHA-512")), is("HS512")); - } - /** * Test valid strings. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JoseUtilsTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JoseUtilsTest.java new file mode 100644 index 00000000..167e8fbe --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JoseUtilsTest.java @@ -0,0 +1,299 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2019 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.toolbox; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; +import static org.shredzone.acme4j.toolbox.TestUtils.url; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; + +import java.net.URL; +import java.security.KeyPair; +import java.util.Base64; +import java.util.Map; + +import javax.crypto.SecretKey; + +import org.jose4j.jwk.PublicJsonWebKey; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwx.CompactSerializer; +import org.jose4j.lang.JoseException; +import org.junit.Test; + +/** + * Unit tests for {@link JoseUtils}. + */ +public class JoseUtilsTest { + + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + + /** + * Test if a JOSE ACME POST request is correctly created. + */ + @Test + public void testCreateJosePostRequest() throws Exception { + URL resourceUrl = url("http://example.com/acme/resource"); + KeyPair accountKey = TestUtils.createKeyPair(); + String nonce = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes()); + JSONBuilder payload = new JSONBuilder(); + payload.put("foo", 123); + payload.put("bar", "a-string"); + + Map jose = JoseUtils + .createJoseRequest(resourceUrl, accountKey, payload, nonce, TestUtils.ACCOUNT_URL) + .toMap(); + + String encodedHeader = jose.get("protected").toString(); + String encodedSignature = jose.get("signature").toString(); + String encodedPayload = jose.get("payload").toString(); + + StringBuilder expectedHeader = new StringBuilder(); + expectedHeader.append('{'); + expectedHeader.append("\"nonce\":\"").append(nonce).append("\","); + expectedHeader.append("\"url\":\"").append(resourceUrl).append("\","); + expectedHeader.append("\"alg\":\"RS256\","); + expectedHeader.append("\"kid\":\"").append(TestUtils.ACCOUNT_URL).append('"'); + expectedHeader.append('}'); + + assertThat(new String(URL_DECODER.decode(encodedHeader), UTF_8), sameJSONAs(expectedHeader.toString())); + assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8), sameJSONAs("{\"foo\":123,\"bar\":\"a-string\"}")); + assertThat(encodedSignature, not(emptyOrNullString())); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature)); + jws.setKey(accountKey.getPublic()); + assertThat(jws.verifySignature(), is(true)); + } + + /** + * Test if a JOSE ACME POST-as-GET request is correctly created. + */ + @Test + public void testCreateJosePostAsGetRequest() throws Exception { + URL resourceUrl = url("http://example.com/acme/resource"); + KeyPair accountKey = TestUtils.createKeyPair(); + String nonce = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes()); + + Map jose = JoseUtils + .createJoseRequest(resourceUrl, accountKey, null, nonce, TestUtils.ACCOUNT_URL) + .toMap(); + + String encodedHeader = jose.get("protected").toString(); + String encodedSignature = jose.get("signature").toString(); + String encodedPayload = jose.get("payload").toString(); + + StringBuilder expectedHeader = new StringBuilder(); + expectedHeader.append('{'); + expectedHeader.append("\"nonce\":\"").append(nonce).append("\","); + expectedHeader.append("\"url\":\"").append(resourceUrl).append("\","); + expectedHeader.append("\"alg\":\"RS256\","); + expectedHeader.append("\"kid\":\"").append(TestUtils.ACCOUNT_URL).append('"'); + expectedHeader.append('}'); + + assertThat(new String(URL_DECODER.decode(encodedHeader), UTF_8), sameJSONAs(expectedHeader.toString())); + assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8), is("")); + assertThat(encodedSignature, not(emptyOrNullString())); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature)); + jws.setKey(accountKey.getPublic()); + assertThat(jws.verifySignature(), is(true)); + } + + /** + * Test if a JOSE ACME Key-Change request is correctly created. + */ + @Test + public void testCreateJoseKeyChangeRequest() throws Exception { + URL resourceUrl = url("http://example.com/acme/resource"); + KeyPair accountKey = TestUtils.createKeyPair(); + JSONBuilder payload = new JSONBuilder(); + payload.put("foo", 123); + payload.put("bar", "a-string"); + + Map jose = JoseUtils + .createJoseRequest(resourceUrl, accountKey, payload, null, null) + .toMap(); + + String encodedHeader = jose.get("protected").toString(); + String encodedSignature = jose.get("signature").toString(); + String encodedPayload = jose.get("payload").toString(); + + StringBuilder expectedHeader = new StringBuilder(); + expectedHeader.append('{'); + expectedHeader.append("\"url\":\"").append(resourceUrl).append("\","); + expectedHeader.append("\"alg\":\"RS256\","); + expectedHeader.append("\"jwk\": {"); + expectedHeader.append("\"kty\": \"").append(TestUtils.KTY).append("\","); + expectedHeader.append("\"e\": \"").append(TestUtils.E).append("\","); + expectedHeader.append("\"n\": \"").append(TestUtils.N).append("\"}"); + expectedHeader.append("}"); + + assertThat(new String(URL_DECODER.decode(encodedHeader), UTF_8), sameJSONAs(expectedHeader.toString())); + assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8), sameJSONAs("{\"foo\":123,\"bar\":\"a-string\"}")); + assertThat(encodedSignature, not(emptyOrNullString())); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature)); + jws.setKey(accountKey.getPublic()); + assertThat(jws.verifySignature(), is(true)); + } + + /** + * Test if an external account binding is correctly created. + */ + @Test + public void testCreateExternalAccountBinding() throws Exception { + KeyPair accountKey = TestUtils.createKeyPair(); + String keyIdentifier = "NCC-1701"; + SecretKey macKey = TestUtils.createSecretKey("SHA-256"); + URL resourceUrl = url("http://example.com/acme/resource"); + + Map binding = JoseUtils.createExternalAccountBinding( + keyIdentifier, accountKey.getPublic(), macKey, resourceUrl); + + String encodedHeader = binding.get("protected").toString(); + String encodedSignature = binding.get("signature").toString(); + String encodedPayload = binding.get("payload").toString(); + String serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature); + + assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey); + } + + /** + * Test if public key is correctly converted to JWK structure. + */ + @Test + public void testPublicKeyToJWK() throws Exception { + Map json = JoseUtils.publicKeyToJWK(TestUtils.createKeyPair().getPublic()); + assertThat(json.size(), is(3)); + assertThat(json.get("kty"), is(TestUtils.KTY)); + assertThat(json.get("n"), is(TestUtils.N)); + assertThat(json.get("e"), is(TestUtils.E)); + } + + /** + * Test if thumbprint is correctly computed. + */ + @Test + public void testThumbprint() throws Exception { + byte[] thumb = JoseUtils.thumbprint(TestUtils.createKeyPair().getPublic()); + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(thumb); + assertThat(encoded, is(TestUtils.THUMBPRINT)); + } + + /** + * Test if RSA using SHA-256 keys are properly detected. + */ + @Test + public void testRsaKey() throws Exception { + KeyPair rsaKeyPair = TestUtils.createKeyPair(); + final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(rsaKeyPair.getPublic()); + + String type = JoseUtils.keyAlgorithm(jwk); + + assertThat(type, is("RS256")); + } + + /** + * Test if ECDSA using NIST P-256 curve and SHA-256 keys are properly detected. + */ + @Test + public void testP256ECKey() throws Exception { + KeyPair ecKeyPair = TestUtils.createECKeyPair("secp256r1"); + final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic()); + + String type = JoseUtils.keyAlgorithm(jwk); + + assertThat(type, is("ES256")); + } + + /** + * Test if ECDSA using NIST P-384 curve and SHA-384 keys are properly detected. + */ + @Test + public void testP384ECKey() throws Exception { + KeyPair ecKeyPair = TestUtils.createECKeyPair("secp384r1"); + final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic()); + + String type = JoseUtils.keyAlgorithm(jwk); + + assertThat(type, is("ES384")); + } + + /** + * Test if ECDSA using NIST P-521 curve and SHA-512 keys are properly detected. + */ + @Test + public void testP521ECKey() throws Exception { + KeyPair ecKeyPair = TestUtils.createECKeyPair("secp521r1"); + final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic()); + + String type = JoseUtils.keyAlgorithm(jwk); + + assertThat(type, is("ES512")); + } + + /** + * Test if MAC key algorithms are properly detected. + */ + @Test + public void testMacKey() throws Exception { + assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-256")), is("HS256")); + assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-384")), is("HS384")); + assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-512")), is("HS512")); + } + + /** + * Asserts that the serialized external account binding is valid. Unit test fails if + * the account binding is invalid. + * + * @param serialized + * Serialized external account binding JOSE structure + * @param resourceUrl + * Expected resource {@link URL} + * @param keyIdentifier + * Expected key identifier + * @param macKey + * Expected {@link SecretKey} + */ + public static void assertExternalAccountBinding(String serialized, URL resourceUrl, + String keyIdentifier, SecretKey macKey) { + try { + JsonWebSignature jws = new JsonWebSignature(); + jws.setCompactSerialization(serialized); + jws.setKey(macKey); + assertThat(jws.verifySignature(), is(true)); + + assertThat(jws.getHeader("url"), is(resourceUrl.toString())); + assertThat(jws.getHeader("kid"), is(keyIdentifier)); + assertThat(jws.getHeader("alg"), is("HS256")); + + String decodedPayload = jws.getPayload(); + StringBuilder expectedPayload = new StringBuilder(); + expectedPayload.append('{'); + expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\","); + expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\","); + expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\""); + expectedPayload.append("}"); + assertThat(decodedPayload, sameJSONAs(expectedPayload.toString())); + } catch (JoseException ex) { + fail(ex.getMessage()); + } + } + +}