From 0f56583c1818095b3dcf51aa077c8357e9f0380d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Thu, 24 Dec 2015 16:24:34 +0100 Subject: [PATCH] Add ProofOfPossession challenge --- README.md | 3 - .../shredzone/acme4j/challenge/Challenge.java | 10 -- .../acme4j/challenge/DnsChallenge.java | 12 +- .../acme4j/challenge/GenericChallenge.java | 8 -- .../acme4j/challenge/HttpChallenge.java | 12 +- .../challenge/ProofOfPossessionChallenge.java | 100 ++++++++++++-- .../acme4j/challenge/TlsSniChallenge.java | 12 +- .../acme4j/util/ValidationBuilder.java | 129 ++++++++++++++++++ .../ProofOfPossessionChallengeTest.java | 100 ++++++++++++++ .../acme4j/impl/AbstractAcmeClientTest.java | 2 +- .../org/shredzone/acme4j/util/TestUtils.java | 36 ++++- .../acme4j/util/ValidationBuilderTest.java | 90 ++++++++++++ .../src/test/resources/domain-private.key | Bin 0 -> 1218 bytes .../src/test/resources/domain-public.key | Bin 0 -> 294 bytes .../src/test/resources/json.properties | 6 + .../markdown/challenge/proof-of-possession.md | 51 ++++++- 16 files changed, 531 insertions(+), 40 deletions(-) create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/util/ValidationBuilder.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallengeTest.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/util/ValidationBuilderTest.java create mode 100644 acme4j-client/src/test/resources/domain-private.key create mode 100644 acme4j-client/src/test/resources/domain-public.key diff --git a/README.md b/README.md index 924cff01..13d9ebae 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,8 @@ The ACME specifications are in draft status and subject to change. See the [online documentation](http://www.shredzone.org/maven/acme4j/) for how to use _acme4j_. Or just have a look at [the source code of an example](https://github.com/shred/acme4j/blob/master/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java). -## Missing -The following feature is planned to be completed for the first beta release, but is still missing: -* `proofOfPossession-01` challenge support. ## Contribute diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java index 5bd0b2f6..2b5beacd 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java @@ -17,7 +17,6 @@ import java.io.Serializable; import java.net.URI; import java.util.Map; -import org.shredzone.acme4j.Account; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.util.ClaimBuilder; @@ -48,15 +47,6 @@ public interface Challenge extends Serializable { */ String getValidated(); - /** - * Authorizes a {@link Challenge} by signing it with an {@link Account}. This is - * required before triggering the challenge. - * - * @param account - * {@link Account} to sign the challenge with - */ - void authorize(Account account); - /** * Sets the challenge state by reading the given JSON map. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java index 347504ec..a6258597 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java @@ -76,9 +76,17 @@ public class DnsChallenge extends GenericChallenge { } } - @Override + /** + * Authorizes the {@link Challenge} by signing it with an {@link Account}. + * + * @param account + * {@link Account} to sign the challenge with + */ public void authorize(Account account) { - super.authorize(account); + if (account == null) { + throw new NullPointerException("account must not be null"); + } + authorization = getToken() + '.' + Base64Url.encode(jwkThumbprint(account.getKeyPair().getPublic())); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java index 8abe97c4..30308329 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java @@ -29,7 +29,6 @@ import org.jose4j.json.JsonUtil; import org.jose4j.jwk.JsonWebKey; import org.jose4j.jwk.JsonWebKey.OutputControlLevel; import org.jose4j.lang.JoseException; -import org.shredzone.acme4j.Account; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.util.ClaimBuilder; @@ -81,13 +80,6 @@ public class GenericChallenge implements Challenge { return get(KEY_VALIDATED); } - @Override - public void authorize(Account account) { - if (account == null) { - throw new NullPointerException("account must not be null"); - } - } - @Override public void unmarshall(Map map) { String type = map.get(KEY_TYPE).toString(); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java index e5150b02..80b7b75e 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java @@ -60,9 +60,17 @@ public class HttpChallenge extends GenericChallenge { return authorization; } - @Override + /** + * Authorizes the {@link Challenge} by signing it with an {@link Account}. + * + * @param account + * {@link Account} to sign the challenge with + */ public void authorize(Account account) { - super.authorize(account); + if (account == null) { + throw new NullPointerException("account must not be null"); + } + authorization = getToken() + '.' + Base64Url.encode(jwkThumbprint(account.getKeyPair().getPublic())); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java index 0e7fd152..47b81538 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java @@ -13,15 +13,27 @@ */ package org.shredzone.acme4j.challenge; -import java.security.PublicKey; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.KeyPair; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.jose4j.base64url.Base64Url; +import org.jose4j.json.JsonUtil; +import org.jose4j.lang.JoseException; import org.shredzone.acme4j.Account; import org.shredzone.acme4j.util.ClaimBuilder; +import org.shredzone.acme4j.util.ValidationBuilder; /** * Implements the {@code proofOfPossession-01} challenge. - *

- * TODO: Currently this challenge is not implemented. * * @author Richard "Shred" Körber */ @@ -33,18 +45,88 @@ public class ProofOfPossessionChallenge extends GenericChallenge { */ public static final String TYPE = "proofOfPossession-01"; - private PublicKey accountKey; + private Collection certs; + private String validation; + + /** + * Gets the collection of {@link X509Certificate} known by the server. + */ + public Collection getCertificates() { + return certs; + } + + /** + * Authorizes the challenge by signing it with the {@link Account} of the current + * domain owner. + * + * @param ownerAccount + * {@link Account} of the certificate holder + * @param domainKeypair + * {@link KeyPair} matching one of the requested certificates + * @param domains + * Domains to validate + */ + public void authorize(Account ownerAccount, KeyPair domainKeypair, String... domains) { + importValidation(new ValidationBuilder() + .domains(domains) + .sign(ownerAccount, domainKeypair)); + } + + /** + * Imports a validation JWS. + * + * @param validation + * JWS of the validation + * @see ValidationBuilder + */ + public void importValidation(String validation) { + try { + Map json = JsonUtil.parseJson(validation); + if (!json.keySet().containsAll(Arrays.asList("header", "payload", "signature"))) { + throw new IllegalArgumentException("not a JWS"); + } + } catch (JoseException ex) { + throw new IllegalArgumentException("invalid JSON", ex); + } + + this.validation = validation; + } @Override - public void authorize(Account account) { - super.authorize(account); - accountKey = account.getKeyPair().getPublic(); + public void unmarshall(Map map) { + super.unmarshall(map); + + List certData = get("certs"); + if (certData != null) { + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + + certs = new ArrayList<>(certData.size()); + for (String c : certData) { + byte[] certDer = Base64Url.decode(c); + try (ByteArrayInputStream in = new ByteArrayInputStream(certDer)) { + certs.add((X509Certificate) certificateFactory.generateCertificate(in)); + } + } + } catch (CertificateException | IOException ex) { + throw new IllegalArgumentException("Invalid certs", ex); + } + } } @Override public void marshall(ClaimBuilder cb) { - super.marshall(cb); - cb.putKey("accountKey", accountKey); + if (validation == null) { + throw new IllegalStateException("not validated"); + } + + try { + cb.put(KEY_TYPE, getType()); + cb.put("authorization", JsonUtil.parseJson(validation)); + } catch (JoseException ex) { + // should not happen, as the JSON is prevalidated in the setter + throw new IllegalStateException("validation: invalid JSON", ex); + } } @Override diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java index d4421508..825cb0a9 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java @@ -73,9 +73,17 @@ public class TlsSniChallenge extends GenericChallenge { return subject; } - @Override + /** + * Authorizes the {@link Challenge} by signing it with an {@link Account}. + * + * @param account + * {@link Account} to sign the challenge with + */ public void authorize(Account account) { - super.authorize(account); + if (account == null) { + throw new NullPointerException("account must not be null"); + } + authorization = getToken() + '.' + Base64Url.encode(jwkThumbprint(account.getKeyPair().getPublic())); String hash = computeHash(authorization); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/util/ValidationBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/util/ValidationBuilder.java new file mode 100644 index 00000000..fefaeab4 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/util/ValidationBuilder.java @@ -0,0 +1,129 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 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.util; + +import java.security.KeyPair; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.jose4j.jwk.PublicJsonWebKey; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.lang.JoseException; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.challenge.ProofOfPossessionChallenge; + +/** + * Generates a validation string for {@link ProofOfPossessionChallenge}. + * + * @author Richard "Shred" Körber + */ +public class ValidationBuilder { + + private final List> identifiers = new ArrayList<>(); + + /** + * Adds a domain to the validation. + * + * @param domain + * Domain to be added + * @return {@code this} + */ + public ValidationBuilder domain(String domain) { + if (domain == null || domain.isEmpty()) { + throw new IllegalArgumentException("domain must not be empty or null"); + } + + ClaimBuilder cb = new ClaimBuilder(); + cb.put("type", "dns").put("value", domain); + identifiers.add(cb.toMap()); + return this; + } + + /** + * Adds a collection of domains to the validation. + * + * @param domains + * Domains to be added + * @return {@code this} + */ + public ValidationBuilder domains(Collection domains) { + if (domains == null) { + throw new NullPointerException("domains must not be null"); + } + + for (String d : domains) { + domain(d); + } + return this; + } + + /** + * Adds multiple domains to the validation. + * + * @param domains + * Domains to be added + * @return {@code this} + */ + public ValidationBuilder domains(String... domains) { + return domains(Arrays.asList(domains)); + } + + /** + * Signs with the given {@link KeyPair} and returns a signed JSON Web Signature + * structure that can be used for validation. + * + * @param account + * {@link Account} of the current domain owner + * @param keypair + * One of the {@link KeyPair} requested by the challenge + * @return JWS validation object + */ + public String sign(Account account, KeyPair keypair) { + if (account == null) { + throw new NullPointerException("account must not be null"); + } + if (keypair == null) { + throw new NullPointerException("keypair must not be null"); + } + + try { + ClaimBuilder claims = new ClaimBuilder(); + claims.put("type", ProofOfPossessionChallenge.TYPE); + claims.array("identifiers", identifiers.toArray()); + claims.putKey("accountKey", account.getKeyPair().getPublic()); + + final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(keypair.getPublic()); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toString()); + jws.getHeaders().setJwkHeaderValue("jwk", jwk); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + jws.setKey(keypair.getPrivate()); + jws.sign(); + + ClaimBuilder auth = new ClaimBuilder(); + auth.put("header", jws.getHeaders().getFullHeaderAsJsonString()); + auth.put("payload", jws.getEncodedPayload()); + auth.put("signature", jws.getEncodedSignature()); + return auth.toString(); + } catch (JoseException ex) { + throw new IllegalArgumentException("Failed to sign", ex); + } + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallengeTest.java new file mode 100644 index 00000000..85781152 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallengeTest.java @@ -0,0 +1,100 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 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.challenge; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; + +import java.io.IOException; +import java.security.KeyPair; +import java.security.cert.X509Certificate; + +import org.junit.Test; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.util.ClaimBuilder; +import org.shredzone.acme4j.util.TestUtils; +import org.shredzone.acme4j.util.ValidationBuilder; + +/** + * Unit tests for {@link ProofOfPossessionChallenge}. + * + * @author Richard "Shred" Körber + */ +public class ProofOfPossessionChallengeTest { + + /** + * Test that {@link ProofOfPossessionChallenge} generates a correct authorization key. + */ + @Test + public void testProofOfPossessionChallenge() throws IOException { + X509Certificate cert = TestUtils.createCertificate(); + KeyPair keypair = TestUtils.createKeyPair(); + Account account = new Account(keypair); + KeyPair domainKeyPair = TestUtils.createDomainKeyPair(); + + ProofOfPossessionChallenge challenge = new ProofOfPossessionChallenge(); + challenge.unmarshall(TestUtils.getJsonAsMap("proofOfPossessionChallenge")); + + assertThat(challenge.getCertificates(), contains(cert)); + + assertThat(challenge.getType(), is(ProofOfPossessionChallenge.TYPE)); + assertThat(challenge.getStatus(), is(Status.PENDING)); + + try { + challenge.marshall(new ClaimBuilder()); + fail("marshall() without previous authorize()"); + } catch (IllegalStateException ex) { + // expected + } + + challenge.authorize(account, domainKeyPair, "example.org"); + + ClaimBuilder cb = new ClaimBuilder(); + challenge.marshall(cb); + + assertThat(cb.toString(), sameJSONAs("{\"type\"=\"" + + ProofOfPossessionChallenge.TYPE + "\",\"authorization\"=" + + new ValidationBuilder().domain("example.org").sign(account, domainKeyPair) + + "}")); + } + + /** + * Test that {@link ProofOfPossessionChallenge#importValidation(String)} works + * correctly. + */ + @Test + public void testImportValidation() throws IOException { + KeyPair keypair = TestUtils.createKeyPair(); + Account account = new Account(keypair); + KeyPair domainKeyPair = TestUtils.createDomainKeyPair(); + + String validation = new ValidationBuilder() + .domain("example.org") + .sign(account, domainKeyPair); + + ProofOfPossessionChallenge challenge = new ProofOfPossessionChallenge(); + challenge.unmarshall(TestUtils.getJsonAsMap("proofOfPossessionChallenge")); + challenge.importValidation(validation); + + ClaimBuilder cb = new ClaimBuilder(); + challenge.marshall(cb); + + assertThat(cb.toString(), sameJSONAs("{\"type\"=\"" + + ProofOfPossessionChallenge.TYPE + "\",\"authorization\"=" + validation + + "}")); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/impl/AbstractAcmeClientTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/impl/AbstractAcmeClientTest.java index 9b28aa4f..d133013e 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/impl/AbstractAcmeClientTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/impl/AbstractAcmeClientTest.java @@ -311,7 +311,7 @@ public class AbstractAcmeClientTest { TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection); - Challenge challenge = new HttpChallenge(); + HttpChallenge challenge = new HttpChallenge(); challenge.unmarshall(getJsonAsMap("triggerHttpChallenge")); challenge.authorize(testAccount); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/util/TestUtils.java b/acme4j-client/src/test/java/org/shredzone/acme4j/util/TestUtils.java index a140e3d5..0070c226 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/util/TestUtils.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/util/TestUtils.java @@ -51,6 +51,11 @@ public final class TestUtils { public static final String KTY = "RSA"; public static final String THUMBPRINT = "HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"; + public static final String D_N = "tP7p9wOe0NWocwLu7h233i1JqUPW1MeLeilyHY7oMKnXZFyf1l0saqLcrBtOj3EyaG6qVfpiLEWEIiuWclPYSR_QSt9lCi9xAoWbYq9-mqseehXPaejynlIMsP2UiCAenSHjJEer6Ug6nFelGVgav3mypwYFUdvc18wI00clKYhRAc4dZodilRzDTLy95V1S3RCxGf-lE0XYg7ieO_ovSMERtH_7NsjZnBiaE7mwm0YZzreCr8oSuHwhC63kgY27FnCgH0h63LICSPVVDJZPLcWAmSXv1k0qoVTsRzFutRN6RB_96wqTTBi8Qm98lyCpXcsxa3BH-4TCvLEaa2KkeQ"; + public static final String D_E = "AQAB"; + public static final String D_KTY = "RSA"; + public static final String D_THUMBPRINT = "0VPbh7-I6swlkBu0TrNKSQp6d69bukzeQA0ksuX3FFs"; + private static final ResourceBundle JSON_RESOURCE = ResourceBundle.getBundle("json"); private TestUtils() { @@ -103,8 +108,8 @@ public final class TestUtils { } /** - * Creates a standard key pair for testing. This keypair is read from a test resource - * and is guaranteed not to change between test runs. + * Creates a standard account {@link KeyPair} for testing. The key pair is read from a + * test resource and is guaranteed not to change between test runs. *

* The constants {@link #N}, {@link #E}, {@link #KTY} and {@link #THUMBPRINT} are * related to the returned key pair and can be used for asserting results. @@ -129,6 +134,33 @@ public final class TestUtils { } } + /** + * Creates a standard domain key pair for testing. This keypair is read from a test + * resource and is guaranteed not to change between test runs. + *

+ * The constants {@link #D_N}, {@link #D_E}, {@link #D_KTY} and {@link #D_THUMBPRINT} + * are related to the returned key pair and can be used for asserting results. + * + * @return {@link KeyPair} for testing + */ + public static KeyPair createDomainKeyPair() throws IOException { + try { + KeyFactory keyFactory = KeyFactory.getInstance(KTY); + + X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec( + getResourceAsByteArray("/domain-public.key")); + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec( + getResourceAsByteArray("/domain-private.key")); + PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec); + + return new KeyPair(publicKey, privateKey); + } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) { + throw new IOException(ex); + } + } + /** * Creates a standard certificate for testing. This certificate is read from a test * resource and is guaranteed not to change between test runs. diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/util/ValidationBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/util/ValidationBuilderTest.java new file mode 100644 index 00000000..e83125cd --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/util/ValidationBuilderTest.java @@ -0,0 +1,90 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 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.util; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; + +import java.io.IOException; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.Map; + +import org.jose4j.base64url.Base64Url; +import org.jose4j.json.JsonUtil; +import org.jose4j.lang.JoseException; +import org.junit.Test; +import org.shredzone.acme4j.Account; + +/** + * Unit test for {@link ValidationBuilder}. + * + * @author Richard "Shred" Körber + */ +public class ValidationBuilderTest { + + /** + * Test if a correct JWS validation object is generated. + */ + @Test + public void testValidationBuilder() throws IOException, JoseException { + Account account = new Account(TestUtils.createKeyPair()); + KeyPair domainKeyPair = TestUtils.createDomainKeyPair(); + + assertThat(account.getKeyPair(), not(domainKeyPair)); + + ValidationBuilder vb = new ValidationBuilder(); + vb.domain("abc.de").domain("ef.gh"); + vb.domains("ijk.lm", "no.pq", "rst.uv"); + vb.domains(Arrays.asList("w.x", "y.z")); + String json = vb.sign(account, domainKeyPair); + + Map data = JsonUtil.parseJson(json); + + String header = (String) data.get("header"); + String payload = Base64Url.decodeToUtf8String((String) data.get("payload")); + String signature = (String) data.get("signature"); + + StringBuilder expectedHeader = new StringBuilder(); + expectedHeader.append('{'); + expectedHeader.append("\"alg\":\"RS256\","); + expectedHeader.append("\"jwk\":{"); + expectedHeader.append("\"kty\":\"").append(TestUtils.D_KTY).append("\","); + expectedHeader.append("\"e\":\"").append(TestUtils.D_E).append("\","); + expectedHeader.append("\"n\":\"").append(TestUtils.D_N).append("\""); + expectedHeader.append("}}"); + + StringBuilder expectedPayload = new StringBuilder(); + expectedPayload.append('{'); + expectedPayload.append("\"type\":\"proofOfPossession-01\","); + expectedPayload.append("\"identifiers\":["); + for (String d : Arrays.asList("abc.de", "ef.gh", "ijk.lm", "no.pq", "rst.uv", "w.x", "y.z")) { + expectedPayload.append("{\"type\":\"dns\",\"value\":\"").append(d).append("\"}"); + if (!"y.z".equals(d)) { + expectedPayload.append(','); + } + } + expectedPayload.append("],\"accountKey\":{"); + expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\","); + expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\","); + expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\""); + expectedPayload.append("}}"); + + assertThat(header, sameJSONAs(expectedHeader.toString()).allowingExtraUnexpectedFields()); + assertThat(payload, sameJSONAs(expectedPayload.toString())); + assertThat(signature, not(isEmptyOrNullString())); + } + +} diff --git a/acme4j-client/src/test/resources/domain-private.key b/acme4j-client/src/test/resources/domain-private.key new file mode 100644 index 0000000000000000000000000000000000000000..4c1fb03307fda07ac43b4e88b21ead31776ab2c1 GIT binary patch literal 1218 zcmV;z1U>sOf&{(-0RS)!1_>&LNQUrs4#*Aqyhl|0)hbn0JQ$;_XD2L z)u?jC03{gj9x9-SfMBuA_1NIINXr5RWn zzj?B!1_e>u+}F$q(?=yKh*1H~9cG7Ol^nxNyuIaJQr!@-8ULjdMc9M5o;&(4NWl@b zfBQDb*_;@f6S=UPMj6hxg0IRFxO^cCt>l4?yB2VuA4q!KvI0o;RScF-EyaMDCGXZv zDxp;DM=@@-6M94+{p$*oOc=aEZ+w>^sa?x4Yj8*Vgu=YB8f#*tc>)6g009Dm0RS$c z!n@v;>uxa7IR8K(E&wwi*jQg6K`kLuPEct=Pfud(JEZ==(6Pj}AT5OF79IBO8iXj@Y1L+ zA+f5y^<6>8l9|ne5~pf?>I5x6SlpB?TEPF{FN}5FprLyQZkunxS|4pHmyZ+M1#|Ka zGdd-5+l$g5dxY9|Ry)w_8fbb(CyclO3hDSP@SDa^MO>0e{Hr7^zb(`Qr(dqubJ*+* z`g+U4=g^N5f^?l?x7Hq;ib9Uf8}=Do=c^pt-XPHFLiX1&#{DKtW)(<)8Ib~kfdJnH z4@d-sy)t-(KV2}cRSf6H_3rK88)Q)n!w6;p0jdGJf1Bzh>bxl!_i0I`LpMVdNSzTl z1=o8N{%s7VtL28jusiAN&!~WhNc$?}%|`L0AFoWgLcGy=X#TcMtZGL<-MbBN?_L?d zlgpvDhC)h=St%8PnpDWjEwV#9W8DIQfdJ3K@1@tI3}MMf!(MMuYb;35w*0TPjeEYB z2|x#<%+~mVp()6o#J2H#w9DmyGxIEu`{q`;Z1}qM73NIx8Dmd%g)S|U^$Q6EmKo^6tvkf|{W6OKt&( zQWP10Pky+F=|#Vhzk(52x%?W5FA7C!+YJ02 z5bR&LW;e04l5VcY2z~vFd;pDL%EQU!qCuqx=z-O^rk=DJ{nb(*>KM^>Qg_MLRjpb) zTe$*(fD#s^C6p*W-7}UZn5u6fgg%*>9`%_`&}l7!XYS&9LtVBd?E*rkFq6fK?iU=j z1+f1vcY4=U=6n7ybc>GlQIf0pwO6ShPUwG*294Ti`sr=);D^0brDbvXEmy+m_WG(4 g74!_5M#y5@A%Cf{bKgU6wN$Oen9HOAOK$AZ@T>1qs{jB1 literal 0 HcmV?d00001 diff --git a/acme4j-client/src/test/resources/domain-public.key b/acme4j-client/src/test/resources/domain-public.key new file mode 100644 index 0000000000000000000000000000000000000000..14fa25a0fc20e26767185ccfd426cd01399dbfd7 GIT binary patch literal 294 zcmV+>0ondAf&n5h4F(A+hDe6@4FLfG1potr0S^E$f&mHwf&l>lwEpS$1D?>;sB;4D z?j5(@ElH_E*3`#~dMR=pj_5F{*JNCu)?F-WqTH+-PLFXiXl|-i`eH0agd!`Ja#Pqz zAJ9tQWeP8G0)?AmuYQ`V9(oneY3TBvQVg*Dl!zc6ogw2SN2}>bI-FOf8CV*>d9tSl z1yS4F*USjhMkg zoEVxDxv-l?8P2zYugVg*d?5?1bXJdPE=n>k5-h7`#Gne3u}pUCS|Ra7X)u!o0B>Yht8%0s{d60q$XeqyPW_ literal 0 HcmV?d00001 diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index b8350057..8fb7287c 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -150,6 +150,12 @@ httpChallenge = \ "token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" \ } +proofOfPossessionChallenge = \ + { \ + "type": "proofOfPossession-01", \ + "certs": ["MIIDVzCCAj-gAwIBAgIJAM4KDTzb0Y7NMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQwHhcNMTUxMjEwMDAxMTA4WhcNMjUxMjA3MDAxMTA4WjBCMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr0g3w4C8xbj_5lzJiDxk0HkEJeZeyruq-0AzOPMigJZ7zxZtX_KUxOIHrQ4qjcFhl0DmQImoM0wESU-kcsjAHCx8E1lgRVlVsMfLAQPHkg5UybqfadzKT3ALcSD-9F9mVIP6liC_6KzLTASmx6zM7j92KTl1ArObZr5mh0jvSNORrMhEC4Byn3-NTxjuHON1rWppCMwpeNNhFzaAig3O8PY8IyaLXNP2Ac5pXn0iW16S-Im9by7751UeW5a7DznmuMEM-WY640ffJDQ4-I64H403uAgvvSu-BGw8SEEZGuBCxoCnG1g6y6OvJyN5TgqFdGosAfm1u-_MP1seoPdpBQIDAQABo1AwTjAdBgNVHQ4EFgQUrie5ZLOrA_HuhW1b_CHjzEvj34swHwYDVR0jBBgwFoAUrie5ZLOrA_HuhW1b_CHjzEvj34swDAYDVR0TBAUwAwEB_zANBgkqhkiG9w0BAQsFAAOCAQEAkSOP0FUgIIUeJTObgXrenHzZpLAkqXi37dgdYuPhNveo3agueP51N7yIoh6YGShiJ73Rvr-lVYTwFXStrLih1Wh3tWvksMxnvocgd7l6USRb5_AgH7eHeFK4DoCAak2hUAcCLDRJN3XMhNLpyJhw7GJxowVIGUlxcW5Asrmh9qflfyMyjripTP3CdHobmNcNHyScjNncKj37m8vomel9acekTtDl2Ci7nLdE-3VqQCXMIfLiF3PO0gGpKei0RuVCSOG6W83zVInCPd_l3aluSR-f_VZlk8KGQ4As4uTQi89j-J1YepzG0ASMZpjVbXeIg5QBAywVxBh5XVTz37KN8A"] \ + } + tlsSniChallenge = \ { \ "type":"tls-sni-01", \ diff --git a/src/site/markdown/challenge/proof-of-possession.md b/src/site/markdown/challenge/proof-of-possession.md index 87ac805d..c41b788d 100644 --- a/src/site/markdown/challenge/proof-of-possession.md +++ b/src/site/markdown/challenge/proof-of-possession.md @@ -1,3 +1,52 @@ # Proof of Possession -This challenge is not yet implemented in _acme4j_. +With the Proof of Possesion challenge, you prove to the CA that you are able to provide a verification document that is signed with a key that is known to the server. The main purpose of this challenge is to transfer the control of a domain to your account. + +The challenge object contains a list of `X509Certificate`s that are already known to the CA: + +```java +ProofOfPossessionChallenge challenge = + auth.findChallenge(ProofOfPossessionChallenge.TYPE); +Collection certificates = challenge.getCertificates(); +``` + +In the next step, the _current owner of the domain_ authorizes the challenge, by signing it with a key pair that corresponds to one of the `certificates`: + +```java +Account ownerAccount = ... // Account of the domain owner +KeyPair domainKeyPair = ... // Key pair matching a certificates +String domain = ... // Domain to authorize + +challenge.authorize(ownerAccount, domainKeyPair, domain); +``` + +The challenge is completed when the domain is associated with the account of the `ownerAccount`, and the `domainKeyPair` matches one of the `certificates`. + +## Importing a Validation + +A problem with this challenge is that a third party needs to provide the account and domain key pairs for authorization. + +There is a way to prepare the validation externally, and import a validation document into the challenge in a separate step. The validation document is signed by the domain owner, but does not contain any private keys. + +_acme4j_ offers a `ValidationBuilder` class for generating the validation document: + +```java +Account ownerAccount = ... // Account of the domain owner +KeyPair domainKeyPair = ... // Key pair matching a certificates + +ValidationBuilder vb = new ValidationBuilder(); +vb.domain("example.org"); +String json = vb.sign(ownerAccount, domainKeyPair); +``` + +This `json` string can be transported (e.g. via email) and then imported into the challenge: + +```java +String json = ... // validation document + +ProofOfPossessionChallenge challenge = + auth.findChallenge(ProofOfPossessionChallenge.TYPE); +challenge.importValidation(json); +``` + +The challenge is authorized now, and is ready to be executed.