diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java index c60e4a28..cddb7692 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java @@ -131,6 +131,31 @@ public class Metadata { .orElse(false); } + /** + * Returns whether the CA supports the profile feature. + * + * @since 3.5 + * @throws AcmeNotSupportedException if the server does not support the profile feature. + */ + public boolean isProfileAllowed() { + return meta.getFeature("profile").optional().isPresent(); + } + + /** + * Returns whether the CA supports the requested profile. + * + * @since 3.5 + * @throws AcmeNotSupportedException if the server does not support the requested profile. + */ + public boolean isProfileAllowed(String profile) { + return meta.getFeature("profile").optional() + .map(Value::asObject) + .orElseGet(JSON::empty) + .get(profile) + .optional() + .isPresent(); + } + /** * Returns whether the CA supports subdomain auth according to RFC9444. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java index 8d8f5e80..7d8271cb 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java @@ -54,6 +54,7 @@ public class OrderBuilder { private @Nullable Duration autoRenewalLifetime; private @Nullable Duration autoRenewalLifetimeAdjust; private boolean autoRenewalGet; + private @Nullable String profile; /** * Create a new {@link OrderBuilder}. @@ -269,6 +270,25 @@ public class OrderBuilder { return this; } + /** + * Notifies the CA of the desired profile of the ordered certificate. + *
+ * Optional, only supported if the CA supports profiles. However, in this + * case the client may include this field. + * + * @param profile + * Identifier of the desired profile + * @return itself + * @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It may be changed or removed + * without notice to reflect future changes to the draft. SemVer rules do not apply + * here. + * @since 3.5.0 + */ + public OrderBuilder profile(String profile) { + this.profile = Objects.requireNonNull(profile); + return this; + } + /** * Notifies the CA that the ordered certificate will replace a previously issued * certificate. The certificate is identified by its ARI unique identifier. @@ -351,6 +371,14 @@ public class OrderBuilder { throw new AcmeNotSupportedException("renewal-information"); } + if (profile != null && !session.getMetadata().isProfileAllowed()) { + throw new AcmeNotSupportedException("profile"); + } + + if (profile != null && !session.getMetadata().isProfileAllowed(profile)) { + throw new AcmeNotSupportedException("profile with value " + profile); + } + var hasAncestorDomain = identifierSet.stream() .filter(id -> Identifier.TYPE_DNS.equals(id.getType())) .anyMatch(id -> id.toMap().containsKey(Identifier.KEY_ANCESTOR_DOMAIN)); @@ -393,6 +421,10 @@ public class OrderBuilder { claims.put("replaces", replaces); } + if(profile != null) { + claims.put("profile", profile); + } + conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, login); var order = new Order(login, conn.getLocation()); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java index 41fa8956..efcfc14d 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java @@ -335,6 +335,111 @@ public class OrderBuilderTest { provider.close(); } + /** + * Test that a new profile {@link Order} can be created. + */ + @Test + public void testProfileOrderCertificate() throws Exception { + + var provider = new TestableConnectionProvider() { + @Override + public int sendSignedRequest(URL url, JSONBuilder claims, Login login) { + assertThat(url).isEqualTo(resourceUrl); + assertThatJson(claims.toString()).isEqualTo(getJSON("requestProfileOrderRequest").toString()); + assertThat(login).isNotNull(); + return HttpURLConnection.HTTP_CREATED; + } + + @Override + public JSON readJsonResponse() { + return getJSON("requestAutoRenewOrderResponse"); + } + + @Override + public URL getLocation() { + return locationUrl; + } + }; + + var login = provider.createLogin(); + + provider.putMetadata("profile",JSON.parse( + "{\"classic\": true}" + ).toMap()); + provider.putTestResource(Resource.NEW_ORDER, resourceUrl); + + var account = new Account(login); + account.newOrder() + .domain("example.org") + .profile("classic") + .create(); + + provider.close(); + } + + /** + * Test that a profile {@link Order} cannot be created if the profile is unsupported by the CA. + */ + @Test + public void testUnsupportedProfileOrderCertificateFails() throws Exception { + + var provider = new TestableConnectionProvider() { + @Override + public int sendSignedRequest(URL url, JSONBuilder claims, Login login) { + assertThat(url).isEqualTo(resourceUrl); + assertThatJson(claims.toString()).isEqualTo(getJSON("requestProfileOrderRequest").toString()); + assertThat(login).isNotNull(); + return HttpURLConnection.HTTP_CREATED; + } + + @Override + public JSON readJsonResponse() { + return getJSON("requestAutoRenewOrderResponse"); + } + + @Override + public URL getLocation() { + return locationUrl; + } + }; + + assertThrows(AcmeNotSupportedException.class, () -> { + provider.putTestResource(Resource.NEW_ORDER, resourceUrl); + + var login = provider.createLogin(); + + var account = new Account(login); + account.newOrder() + .domain("example.org") + .profile("invalid") + .create(); + + provider.close(); + }); + } + + /** + * Test that a profile {@link Order} cannot be created if the feature is unsupported by the CA. + */ + @Test + public void testProfileOrderCertificateFails() { + assertThrows(AcmeNotSupportedException.class, () -> { + var provider = new TestableConnectionProvider(); + provider.putTestResource(Resource.NEW_ORDER, resourceUrl); + + var login = provider.createLogin(); + + var account = new Account(login); + account.newOrder() + .domain("example.org") + .profile("classic") + .create(); + + provider.close(); + }); + } + + /** * Test that the ARI replaces field is set. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java index 86ca97a2..cc74ba19 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java @@ -183,6 +183,9 @@ public class SessionTest { softly.assertThat(meta.getAutoRenewalMaxDuration()).isEqualTo(Duration.ofDays(365)); softly.assertThat(meta.getAutoRenewalMinLifetime()).isEqualTo(Duration.ofHours(24)); softly.assertThat(meta.isAutoRenewalGetAllowed()).isTrue(); + softly.assertThat(meta.isProfileAllowed()).isTrue(); + softly.assertThat(meta.isProfileAllowed("classic")).isTrue(); + softly.assertThat(meta.isProfileAllowed("invalid")).isFalse(); softly.assertThat(meta.isExternalAccountRequired()).isTrue(); softly.assertThat(meta.isSubdomainAuthAllowed()).isTrue(); softly.assertThat(meta.getJSON()).isNotNull(); @@ -235,6 +238,8 @@ public class SessionTest { .isThrownBy(meta::getAutoRenewalMinLifetime); softly.assertThatExceptionOfType(AcmeNotSupportedException.class) .isThrownBy(meta::isAutoRenewalGetAllowed); + softly.assertThat(meta.isProfileAllowed()).isFalse(); + softly.assertThat(meta.isProfileAllowed("classic")).isFalse(); } } diff --git a/acme4j-client/src/test/resources/json/directory.json b/acme4j-client/src/test/resources/json/directory.json index a75c90d3..91937e64 100644 --- a/acme4j-client/src/test/resources/json/directory.json +++ b/acme4j-client/src/test/resources/json/directory.json @@ -23,6 +23,10 @@ "foo", "bar", "barfoo" - ] + ], + "profiles": { + "classic": "The profile you're accustomed to", + "custom": "Some other profile" + } } }