From beec5156c2c64cd30a81ea484be9f2377f32b4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Sun, 22 Sep 2024 16:32:00 +0200 Subject: [PATCH] Add Google CA provider --- .../org/shredzone/acme4j/AccountBuilder.java | 6 +- .../acme4j/provider/AcmeProvider.java | 14 ++++ .../provider/google/GoogleAcmeProvider.java | 70 +++++++++++++++++ .../acme4j/provider/google/package-info.java | 29 +++++++ ...org.shredzone.acme4j.provider.AcmeProvider | 3 + .../shredzone/acme4j/AccountBuilderTest.java | 32 +++++++- .../google/GoogleAcmeProviderTest.java | 75 +++++++++++++++++++ .../org/shredzone/acme4j/it/ProviderIT.java | 28 +++++++ pom.xml | 2 +- src/doc/docs/ca/google.md | 23 ++++++ src/doc/docs/ca/index.md | 1 + src/doc/mkdocs.yml | 1 + 12 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/GoogleAcmeProvider.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/package-info.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/provider/google/GoogleAcmeProviderTest.java create mode 100644 src/doc/docs/ca/google.md 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 f5c499ae..c1bd0268 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AccountBuilder.java @@ -21,6 +21,7 @@ import java.security.KeyPair; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Set; import javax.crypto.SecretKey; @@ -279,7 +280,10 @@ public Login createLogin(Session session) throws AcmeException { claims.put("termsOfServiceAgreed", termsOfServiceAgreed); } if (keyIdentifier != null && macKey != null) { - var algorithm = macAlgorithm != null ? macAlgorithm : macKeyAlgorithm(macKey); + var algorithm = Optional.ofNullable(macAlgorithm) + .or(session.provider()::getProposedEabMacAlgorithm) +// FIXME: Cannot use a Supplier here due to a Spotbugs false positive "null pointer dereference" + .orElse(macKeyAlgorithm(macKey)); claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding( keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl)); } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java index 048a0b53..e306a5b4 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java @@ -15,6 +15,7 @@ import java.net.URI; import java.net.URL; +import java.util.Optional; import java.util.ServiceLoader; import edu.umd.cs.findbugs.annotations.Nullable; @@ -96,4 +97,17 @@ public interface AcmeProvider { @Nullable Challenge createChallenge(Login login, JSON data); + /** + * Returns a proposal for the EAB MAC algorithm to be used. Only set if the CA + * requires External Account Binding and the MAC algorithm cannot be correctly derived + * from the MAC key. Empty otherwise. + * + * @return Proposed MAC algorithm to be used for EAB, or empty for the default + * behavior. + * @since 3.5.0 + */ + default Optional getProposedEabMacAlgorithm() { + return Optional.empty(); + } + } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/GoogleAcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/GoogleAcmeProvider.java new file mode 100644 index 00000000..682bacc7 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/GoogleAcmeProvider.java @@ -0,0 +1,70 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2024 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.provider.google; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Optional; + +import org.jose4j.jws.AlgorithmIdentifiers; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.provider.AbstractAcmeProvider; +import org.shredzone.acme4j.provider.AcmeProvider; + +/** + * An {@link AcmeProvider} for the Google Trust Services. + *

+ * The {@code serverUri} is {@code "acme://pki.goog"} for the production server, + * and {@code "acme://pki.goog/staging"} for the staging server. + * + * @see https://pki.goog/ + * @since 3.5.0 + */ +public class GoogleAcmeProvider extends AbstractAcmeProvider { + + private static final String PRODUCTION_DIRECTORY_URL = "https://dv.acme-v02.api.pki.goog/directory"; + private static final String STAGING_DIRECTORY_URL = "https://dv.acme-v02.test-api.pki.goog/directory"; + + @Override + public boolean accepts(URI serverUri) { + return "acme".equals(serverUri.getScheme()) + && "pki.goog".equals(serverUri.getHost()); + } + + @Override + public URL resolve(URI serverUri) { + var path = serverUri.getPath(); + String directoryUrl; + if (path == null || path.isEmpty() || "/".equals(path)) { + directoryUrl = PRODUCTION_DIRECTORY_URL; + } else if ("/staging".equals(path)) { + directoryUrl = STAGING_DIRECTORY_URL; + } else { + throw new IllegalArgumentException("Unknown URI " + serverUri); + } + + try { + return new URL(directoryUrl); + } catch (MalformedURLException ex) { + throw new AcmeProtocolException(directoryUrl, ex); + } + } + + @Override + public Optional getProposedEabMacAlgorithm() { + return Optional.of(AlgorithmIdentifiers.HMAC_SHA256); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/package-info.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/package-info.java new file mode 100644 index 00000000..41f7f323 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/package-info.java @@ -0,0 +1,29 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2024 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. + */ + +/** + * This package contains the {@link org.shredzone.acme4j.provider.AcmeProvider} for the + * Google Trust Services. + * + * @see https://pki.goog/ + */ +@ReturnValuesAreNonnullByDefault +@DefaultAnnotationForParameters(NonNull.class) +@DefaultAnnotationForFields(NonNull.class) +package org.shredzone.acme4j.provider.google; + +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault; diff --git a/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeProvider b/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeProvider index 60a808b5..cfa76ea5 100644 --- a/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeProvider +++ b/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeProvider @@ -1,4 +1,7 @@ +# Google Trust Services: https://pki.goog/ +org.shredzone.acme4j.provider.google.GoogleAcmeProvider + # Let's Encrypt: https://letsencrypt.org org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider 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 8a52cbe1..3d1b8349 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java @@ -22,6 +22,7 @@ import java.net.HttpURLConnection; import java.net.URL; import java.security.KeyPair; +import java.util.Optional; import edu.umd.cs.findbugs.annotations.Nullable; import org.jose4j.jwx.CompactSerializer; @@ -113,11 +114,29 @@ public JSON readJsonResponse() { */ @ParameterizedTest @CsvSource({ - "SHA-256,HS256,", "SHA-384,HS384,", "SHA-512,HS512,", - "SHA-256,HS256,HS256", "SHA-384,HS384,HS384", "SHA-512,HS512,HS512", - "SHA-512,HS256,HS256" + // Derived from key size + "SHA-256,HS256,,", + "SHA-384,HS384,,", + "SHA-512,HS512,,", + + // Enforced, but same as key size + "SHA-256,HS256,HS256,", + "SHA-384,HS384,HS384,", + "SHA-512,HS512,HS512,", + + // Enforced, different from key size + "SHA-512,HS256,HS256,", + + // Proposed by provider + "SHA-256,HS256,,HS256", + "SHA-512,HS256,,HS256", + "SHA-512,HS512,HS512,HS256", }) - public void testRegistrationWithKid(String keyAlg, String expectedMacAlg, @Nullable String macAlg) throws Exception { + public void testRegistrationWithKid(String keyAlg, + String expectedMacAlg, + @Nullable String macAlg, + @Nullable String providerAlg + ) throws Exception { var accountKey = TestUtils.createKeyPair(); var keyIdentifier = "NCC-1701"; var macKey = TestUtils.createSecretKey(keyAlg); @@ -152,6 +171,11 @@ public URL getLocation() { public JSON readJsonResponse() { return JSON.empty(); } + + @Override + public Optional getProposedEabMacAlgorithm() { + return Optional.ofNullable(providerAlg); + } }; provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/google/GoogleAcmeProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/google/GoogleAcmeProviderTest.java new file mode 100644 index 00000000..2c42a632 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/google/GoogleAcmeProviderTest.java @@ -0,0 +1,75 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2024 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.provider.google; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.shredzone.acme4j.toolbox.TestUtils.url; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.assertj.core.api.AutoCloseableSoftAssertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link GoogleAcmeProvider}. + */ +public class GoogleAcmeProviderTest { + + private static final String PRODUCTION_DIRECTORY_URL = "https://dv.acme-v02.api.pki.goog/directory"; + private static final String STAGING_DIRECTORY_URL = "https://dv.acme-v02.test-api.pki.goog/directory"; + + /** + * Tests if the provider accepts the correct URIs. + */ + @Test + public void testAccepts() throws URISyntaxException { + var provider = new GoogleAcmeProvider(); + + try (var softly = new AutoCloseableSoftAssertions()) { + softly.assertThat(provider.accepts(new URI("acme://pki.goog"))).isTrue(); + softly.assertThat(provider.accepts(new URI("acme://pki.goog/"))).isTrue(); + softly.assertThat(provider.accepts(new URI("acme://pki.goog/staging"))).isTrue(); + softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse(); + softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse(); + softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse(); + } + } + + /** + * Test if acme URIs are properly resolved. + */ + @Test + public void testResolve() throws URISyntaxException { + var provider = new GoogleAcmeProvider(); + + assertThat(provider.resolve(new URI("acme://pki.goog"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL)); + assertThat(provider.resolve(new URI("acme://pki.goog/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL)); + assertThat(provider.resolve(new URI("acme://pki.goog/staging"))).isEqualTo(url(STAGING_DIRECTORY_URL)); + + assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://pki.goog/v99"))); + } + + /** + * Test if correct MAC algorithm is proposed. + */ + @Test + public void testMacAlgorithm() { + var provider = new GoogleAcmeProvider(); + + assertThat(provider.getProposedEabMacAlgorithm()).isNotEmpty().contains("HS256"); + } + +} diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/ProviderIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/ProviderIT.java index 0510aeb7..5d7f5c99 100644 --- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/ProviderIT.java +++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/ProviderIT.java @@ -37,6 +37,26 @@ */ public class ProviderIT { + /** + * Test Google CA + */ + @Test + public void testGoogle() throws AcmeException, MalformedURLException { + var session = new Session("acme://pki.goog"); + assertThat(session.getMetadata().getWebsite()).hasValue(new URL("https://pki.goog")); + assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); + assertThat(session.getMetadata().isExternalAccountRequired()).isTrue(); + assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty(); + + var sessionStage = new Session("acme://pki.goog/staging"); + assertThat(sessionStage.getMetadata().getWebsite()).hasValue(new URL("https://pki.goog")); + assertThatNoException().isThrownBy(() -> sessionStage.resourceUrl(Resource.NEW_ACCOUNT)); + assertThat(sessionStage.getMetadata().isExternalAccountRequired()).isTrue(); + assertThat(sessionStage.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(sessionStage.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty(); + } + /** * Test Let's Encrypt */ @@ -47,12 +67,14 @@ public void testLetsEncrypt() throws AcmeException, MalformedURLException { assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(session.getMetadata().isExternalAccountRequired()).isFalse(); assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty(); var sessionStage = new Session("acme://letsencrypt.org/staging"); assertThat(sessionStage.getMetadata().getWebsite()).hasValue(new URL("https://letsencrypt.org/docs/staging-environment/")); assertThatNoException().isThrownBy(() -> sessionStage.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(sessionStage.getMetadata().isExternalAccountRequired()).isFalse(); assertThat(sessionStage.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(sessionStage.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty(); } /** @@ -65,6 +87,7 @@ public void testPebble() throws AcmeException, MalformedURLException { assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(session.getMetadata().isExternalAccountRequired()).isFalse(); assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty(); } /** @@ -77,12 +100,14 @@ public void testSslCom() throws AcmeException, MalformedURLException { assertThatNoException().isThrownBy(() -> sessionEcc.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(sessionEcc.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionEcc.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(sessionEcc.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty(); var sessionRsa = new Session("acme://ssl.com/rsa"); assertThat(sessionRsa.getMetadata().getWebsite()).hasValue(new URL("https://www.ssl.com")); assertThatNoException().isThrownBy(() -> sessionRsa.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(sessionRsa.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionRsa.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(sessionRsa.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty(); // If this test fails, the metadata has been fixed on server side. Then remove // the patch at ZeroSSLAcmeProvider, and update the documentation. @@ -101,12 +126,14 @@ public void testSslComStaging() throws AcmeException, MalformedURLException { assertThatNoException().isThrownBy(() -> sessionEccStage.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(sessionEccStage.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionEccStage.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(sessionEccStage.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty(); var sessionRsaStage = new Session("acme://ssl.com/staging/rsa"); assertThat(sessionRsaStage.getMetadata().getWebsite()).hasValue(new URL("https://www.ssl.com")); assertThatNoException().isThrownBy(() -> sessionRsaStage.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(sessionRsaStage.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionRsaStage.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(sessionRsaStage.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty(); // If this test fails, the metadata has been fixed on server side. Then remove // the patch at ZeroSSLAcmeProvider, and update the documentation. @@ -124,6 +151,7 @@ public void testZeroSsl() throws AcmeException, MalformedURLException { assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); assertThat(session.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); + assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty(); // ZeroSSL has no documented staging server (as of February 2024) } diff --git a/pom.xml b/pom.xml index f24c78ec..0f6a8d9f 100644 --- a/pom.xml +++ b/pom.xml @@ -81,7 +81,7 @@ com.github.spotbugs spotbugs-maven-plugin - 4.8.5.0 + 4.8.6.3 diff --git a/src/doc/docs/ca/google.md b/src/doc/docs/ca/google.md new file mode 100644 index 00000000..e88df378 --- /dev/null +++ b/src/doc/docs/ca/google.md @@ -0,0 +1,23 @@ +# Google + +Web site: [Google Trust Services](https://pki.goog/) + +Available since acme4j 3.5.0 + +## Connection URIs + +* `acme://pki.goog` - Production server +* `acme://pki.goog/staging` - Staging server + +## Note + +_Google Trust Services_ requires account creation with [External Account Binding](../usage/account.md#external-account-binding). See [this tutorial](https://cloud.google.com/certificate-manager/docs/public-ca-tutorial) about how to create the EAB secrets. You will get a `keyId` and a `b64MacKey` that can be directly passed into `AccountBuilder.withKeyIdentifier()`. + +!!! note + You cannot use the production EAB secrets for accessing the staging server, but you need separate secrets! Please read the respective chapter of the tutorial about how to create them. + +_Google Trust Services_ request `HS256` as MAC algorithm. If you use the connection URIs above, this is set automatically. If you use a `https` connection URI, you will need to set the MAC algorithm manually by adding `withMacAlgorithm("HS256")` to the `AccountBuilder`. + +## Disclaimer + +_acme4j_ is not officially supported or endorsed by Google. If you have _acme4j_ related issues, please do not ask them for support, but [open an issue here](https://github.com/shred/acme4j/issues). diff --git a/src/doc/docs/ca/index.md b/src/doc/docs/ca/index.md index 0f803ef1..1b6b00d9 100644 --- a/src/doc/docs/ca/index.md +++ b/src/doc/docs/ca/index.md @@ -6,6 +6,7 @@ _acme4j_ should support any CA that is providing an ACME server. The _acme4j_ package contains these providers: +* [Google](google.md) * [Let's Encrypt](letsencrypt.md) * [Pebble](pebble.md) * [SSL.com](sslcom.md) diff --git a/src/doc/mkdocs.yml b/src/doc/mkdocs.yml index 6d2dc0e6..6403b071 100644 --- a/src/doc/mkdocs.yml +++ b/src/doc/mkdocs.yml @@ -43,6 +43,7 @@ nav: - 'challenge/email-reply-00.md' - CA: - 'ca/index.md' + - 'ca/google.md' - 'ca/letsencrypt.md' - 'ca/pebble.md' - 'ca/sslcom.md'