001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2019 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j.toolbox;
015
016import static java.nio.charset.StandardCharsets.UTF_8;
017import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
018import static org.assertj.core.api.Assertions.assertThat;
019import static org.junit.jupiter.api.Assertions.fail;
020import static org.shredzone.acme4j.toolbox.TestUtils.url;
021
022import java.net.URL;
023import java.util.Base64;
024import java.util.HashMap;
025
026import javax.crypto.SecretKey;
027
028import org.jose4j.jwk.PublicJsonWebKey;
029import org.jose4j.jws.JsonWebSignature;
030import org.jose4j.jwx.CompactSerializer;
031import org.jose4j.lang.JoseException;
032import org.junit.jupiter.api.Test;
033import org.junit.jupiter.params.ParameterizedTest;
034import org.junit.jupiter.params.provider.CsvSource;
035
036/**
037 * Unit tests for {@link JoseUtils}.
038 */
039public class JoseUtilsTest {
040
041    private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
042    private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
043
044    /**
045     * Test if a JOSE ACME POST request is correctly created.
046     */
047    @Test
048    public void testCreateJosePostRequest() throws Exception {
049        var resourceUrl = url("http://example.com/acme/resource");
050        var accountKey = TestUtils.createKeyPair();
051        var nonce = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
052        var payload = new JSONBuilder();
053        payload.put("foo", 123);
054        payload.put("bar", "a-string");
055
056        var jose = JoseUtils
057                .createJoseRequest(resourceUrl, accountKey, payload, nonce, TestUtils.ACCOUNT_URL)
058                .toMap();
059
060        var encodedHeader = jose.get("protected").toString();
061        var encodedSignature = jose.get("signature").toString();
062        var encodedPayload = jose.get("payload").toString();
063
064        var expectedHeader = new StringBuilder();
065        expectedHeader.append('{');
066        expectedHeader.append("\"nonce\":\"").append(nonce).append("\",");
067        expectedHeader.append("\"url\":\"").append(resourceUrl).append("\",");
068        expectedHeader.append("\"alg\":\"RS256\",");
069        expectedHeader.append("\"kid\":\"").append(TestUtils.ACCOUNT_URL).append('"');
070        expectedHeader.append('}');
071
072        assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
073                .isEqualTo(expectedHeader.toString());
074        assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8))
075                .isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}");
076        assertThat(encodedSignature).isNotEmpty();
077
078        var jws = new JsonWebSignature();
079        jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
080        jws.setKey(accountKey.getPublic());
081        assertThat(jws.verifySignature()).isTrue();
082    }
083
084    /**
085     * Test if a JOSE ACME POST-as-GET request is correctly created.
086     */
087    @Test
088    public void testCreateJosePostAsGetRequest() throws Exception {
089        var resourceUrl = url("http://example.com/acme/resource");
090        var accountKey = TestUtils.createKeyPair();
091        var nonce = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
092
093        var jose = JoseUtils
094                .createJoseRequest(resourceUrl, accountKey, null, nonce, TestUtils.ACCOUNT_URL)
095                .toMap();
096
097        var encodedHeader = jose.get("protected").toString();
098        var encodedSignature = jose.get("signature").toString();
099        var encodedPayload = jose.get("payload").toString();
100
101        var expectedHeader = new StringBuilder();
102        expectedHeader.append('{');
103        expectedHeader.append("\"nonce\":\"").append(nonce).append("\",");
104        expectedHeader.append("\"url\":\"").append(resourceUrl).append("\",");
105        expectedHeader.append("\"alg\":\"RS256\",");
106        expectedHeader.append("\"kid\":\"").append(TestUtils.ACCOUNT_URL).append('"');
107        expectedHeader.append('}');
108
109        assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
110                .isEqualTo(expectedHeader.toString());
111        assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8)).isEmpty();
112        assertThat(encodedSignature).isNotEmpty();
113
114        var jws = new JsonWebSignature();
115        jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
116        jws.setKey(accountKey.getPublic());
117        assertThat(jws.verifySignature()).isTrue();
118    }
119
120    /**
121     * Test if a JOSE ACME Key-Change request is correctly created.
122     */
123    @Test
124    public void testCreateJoseKeyChangeRequest() throws Exception {
125        var resourceUrl = url("http://example.com/acme/resource");
126        var accountKey = TestUtils.createKeyPair();
127        var payload = new JSONBuilder();
128        payload.put("foo", 123);
129        payload.put("bar", "a-string");
130
131        var jose = JoseUtils
132                .createJoseRequest(resourceUrl, accountKey, payload, null, null)
133                .toMap();
134
135        var encodedHeader = jose.get("protected").toString();
136        var encodedSignature = jose.get("signature").toString();
137        var encodedPayload = jose.get("payload").toString();
138
139        var expectedHeader = new StringBuilder();
140        expectedHeader.append('{');
141        expectedHeader.append("\"url\":\"").append(resourceUrl).append("\",");
142        expectedHeader.append("\"alg\":\"RS256\",");
143        expectedHeader.append("\"jwk\": {");
144        expectedHeader.append("\"kty\": \"").append(TestUtils.KTY).append("\",");
145        expectedHeader.append("\"e\": \"").append(TestUtils.E).append("\",");
146        expectedHeader.append("\"n\": \"").append(TestUtils.N).append("\"}");
147        expectedHeader.append("}");
148
149        assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
150                .isEqualTo(expectedHeader.toString());
151        assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8))
152                .isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}");
153        assertThat(encodedSignature).isNotEmpty();
154
155        var jws = new JsonWebSignature();
156        jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
157        jws.setKey(accountKey.getPublic());
158        assertThat(jws.verifySignature()).isTrue();
159    }
160
161    /**
162     * Test if an external account binding is correctly created.
163     */
164    @ParameterizedTest
165    @CsvSource({"SHA-256,HS256", "SHA-384,HS384", "SHA-512,HS512", "SHA-512,HS256"})
166    public void testCreateExternalAccountBinding(String keyAlg, String macAlg) throws Exception {
167        var accountKey = TestUtils.createKeyPair();
168        var keyIdentifier = "NCC-1701";
169        var macKey = TestUtils.createSecretKey(keyAlg);
170        var resourceUrl = url("http://example.com/acme/resource");
171
172        var binding = JoseUtils.createExternalAccountBinding(
173                keyIdentifier, accountKey.getPublic(), macKey, macAlg, resourceUrl);
174
175        var encodedHeader = binding.get("protected").toString();
176        var encodedSignature = binding.get("signature").toString();
177        var encodedPayload = binding.get("payload").toString();
178        var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
179
180        assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey, macAlg);
181    }
182
183    /**
184     * Test if public key is correctly converted to JWK structure.
185     */
186    @Test
187    public void testPublicKeyToJWK() throws Exception {
188        var json = JoseUtils.publicKeyToJWK(TestUtils.createKeyPair().getPublic());
189        assertThat(json).hasSize(3);
190        assertThat(json.get("kty")).isEqualTo(TestUtils.KTY);
191        assertThat(json.get("n")).isEqualTo(TestUtils.N);
192        assertThat(json.get("e")).isEqualTo(TestUtils.E);
193    }
194
195    /**
196     * Test if JWK structure is correctly converted to public key.
197     */
198    @Test
199    public void testJWKToPublicKey() throws Exception {
200        var json = new HashMap<String, Object>();
201        json.put("kty", TestUtils.KTY);
202        json.put("n", TestUtils.N);
203        json.put("e", TestUtils.E);
204        var key = JoseUtils.jwkToPublicKey(json);
205        assertThat(key.getEncoded()).isEqualTo(TestUtils.createKeyPair().getPublic().getEncoded());
206    }
207
208    /**
209     * Test if thumbprint is correctly computed.
210     */
211    @Test
212    public void testThumbprint() throws Exception {
213        var thumb = JoseUtils.thumbprint(TestUtils.createKeyPair().getPublic());
214        var encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(thumb);
215        assertThat(encoded).isEqualTo(TestUtils.THUMBPRINT);
216    }
217
218    /**
219     * Test if RSA using SHA-256 keys are properly detected.
220     */
221    @Test
222    public void testRsaKey() throws Exception {
223        var rsaKeyPair = TestUtils.createKeyPair();
224        var jwk = PublicJsonWebKey.Factory.newPublicJwk(rsaKeyPair.getPublic());
225
226        var type = JoseUtils.keyAlgorithm(jwk);
227        assertThat(type).isEqualTo("RS256");
228    }
229
230    /**
231     * Test if ECDSA using NIST P-256 curve and SHA-256 keys are properly detected.
232     */
233    @Test
234    public void testP256ECKey() throws Exception {
235        var ecKeyPair = TestUtils.createECKeyPair("secp256r1");
236        var jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic());
237
238        var type = JoseUtils.keyAlgorithm(jwk);
239        assertThat(type).isEqualTo("ES256");
240    }
241
242    /**
243     * Test if ECDSA using NIST P-384 curve and SHA-384 keys are properly detected.
244     */
245    @Test
246    public void testP384ECKey() throws Exception {
247        var ecKeyPair = TestUtils.createECKeyPair("secp384r1");
248        var jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic());
249
250        var type = JoseUtils.keyAlgorithm(jwk);
251        assertThat(type).isEqualTo("ES384");
252    }
253
254    /**
255     * Test if ECDSA using NIST P-521 curve and SHA-512 keys are properly detected.
256     */
257    @Test
258    public void testP521ECKey() throws Exception {
259        var ecKeyPair = TestUtils.createECKeyPair("secp521r1");
260        var jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic());
261
262        var type = JoseUtils.keyAlgorithm(jwk);
263        assertThat(type).isEqualTo("ES512");
264    }
265
266    /**
267     * Test if MAC key algorithms are properly detected.
268     */
269    @Test
270    public void testMacKey() throws Exception {
271        assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-256"))).isEqualTo("HS256");
272        assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-384"))).isEqualTo("HS384");
273        assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-512"))).isEqualTo("HS512");
274    }
275
276    /**
277     * Asserts that the serialized external account binding is valid. Unit test fails if
278     * the account binding is invalid.
279     *
280     * @param serialized
281     *         Serialized external account binding JOSE structure
282     * @param resourceUrl
283     *         Expected resource {@link URL}
284     * @param keyIdentifier
285     *         Expected key identifier
286     * @param macKey
287     *         Expected {@link SecretKey}
288     * @param macAlg
289     *         Expected algorithm
290     */
291    public static void assertExternalAccountBinding(String serialized, URL resourceUrl,
292                                                    String keyIdentifier, SecretKey macKey,
293                                                    String macAlg) {
294        try {
295            var jws = new JsonWebSignature();
296            jws.setCompactSerialization(serialized);
297            jws.setKey(macKey);
298            assertThat(jws.verifySignature()).isTrue();
299
300            assertThat(jws.getHeader("url")).isEqualTo(resourceUrl.toString());
301            assertThat(jws.getHeader("kid")).isEqualTo(keyIdentifier);
302            assertThat(jws.getHeader("alg")).isEqualTo(macAlg);
303
304            var decodedPayload = jws.getPayload();
305            var expectedPayload = new StringBuilder();
306            expectedPayload.append('{');
307            expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\",");
308            expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\",");
309            expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\"");
310            expectedPayload.append("}");
311            assertThatJson(decodedPayload).isEqualTo(expectedPayload.toString());
312        } catch (JoseException ex) {
313            fail(ex);
314        }
315    }
316
317}