Add Google CA provider

pull/168/head
Richard Körber 2024-09-22 16:32:00 +02:00
parent 0ccd68c09a
commit beec5156c2
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
12 changed files with 278 additions and 6 deletions

View File

@ -21,6 +21,7 @@ import java.net.URI;
import java.security.KeyPair; import java.security.KeyPair;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
@ -279,7 +280,10 @@ public class AccountBuilder {
claims.put("termsOfServiceAgreed", termsOfServiceAgreed); claims.put("termsOfServiceAgreed", termsOfServiceAgreed);
} }
if (keyIdentifier != null && macKey != null) { 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( claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding(
keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl)); keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl));
} }

View File

@ -15,6 +15,7 @@ package org.shredzone.acme4j.provider;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.util.Optional;
import java.util.ServiceLoader; import java.util.ServiceLoader;
import edu.umd.cs.findbugs.annotations.Nullable; import edu.umd.cs.findbugs.annotations.Nullable;
@ -96,4 +97,17 @@ public interface AcmeProvider {
@Nullable @Nullable
Challenge createChallenge(Login login, JSON data); 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<String> getProposedEabMacAlgorithm() {
return Optional.empty();
}
} }

View File

@ -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 <em>Google Trust Services</em>.
* <p>
* The {@code serverUri} is {@code "acme://pki.goog"} for the production server,
* and {@code "acme://pki.goog/staging"} for the staging server.
*
* @see <a href="https://pki.goog/">https://pki.goog/</a>
* @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<String> getProposedEabMacAlgorithm() {
return Optional.of(AlgorithmIdentifiers.HMAC_SHA256);
}
}

View File

@ -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 <a href="https://pki.goog/">https://pki.goog/</a>
*/
@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;

View File

@ -1,4 +1,7 @@
# Google Trust Services: https://pki.goog/
org.shredzone.acme4j.provider.google.GoogleAcmeProvider
# Let's Encrypt: https://letsencrypt.org # Let's Encrypt: https://letsencrypt.org
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider

View File

@ -22,6 +22,7 @@ import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.security.KeyPair; import java.security.KeyPair;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable; import edu.umd.cs.findbugs.annotations.Nullable;
import org.jose4j.jwx.CompactSerializer; import org.jose4j.jwx.CompactSerializer;
@ -113,11 +114,29 @@ public class AccountBuilderTest {
*/ */
@ParameterizedTest @ParameterizedTest
@CsvSource({ @CsvSource({
"SHA-256,HS256,", "SHA-384,HS384,", "SHA-512,HS512,", // Derived from key size
"SHA-256,HS256,HS256", "SHA-384,HS384,HS384", "SHA-512,HS512,HS512", "SHA-256,HS256,,",
"SHA-512,HS256,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 accountKey = TestUtils.createKeyPair();
var keyIdentifier = "NCC-1701"; var keyIdentifier = "NCC-1701";
var macKey = TestUtils.createSecretKey(keyAlg); var macKey = TestUtils.createSecretKey(keyAlg);
@ -152,6 +171,11 @@ public class AccountBuilderTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return JSON.empty(); return JSON.empty();
} }
@Override
public Optional<String> getProposedEabMacAlgorithm() {
return Optional.ofNullable(providerAlg);
}
}; };
provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl); provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl);

View File

@ -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");
}
}

View File

@ -37,6 +37,26 @@ import org.shredzone.acme4j.exception.AcmeException;
*/ */
public class ProviderIT { 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 * Test Let's Encrypt
*/ */
@ -47,12 +67,14 @@ public class ProviderIT {
assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(session.getMetadata().isExternalAccountRequired()).isFalse(); assertThat(session.getMetadata().isExternalAccountRequired()).isFalse();
assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse();
assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty();
var sessionStage = new Session("acme://letsencrypt.org/staging"); var sessionStage = new Session("acme://letsencrypt.org/staging");
assertThat(sessionStage.getMetadata().getWebsite()).hasValue(new URL("https://letsencrypt.org/docs/staging-environment/")); assertThat(sessionStage.getMetadata().getWebsite()).hasValue(new URL("https://letsencrypt.org/docs/staging-environment/"));
assertThatNoException().isThrownBy(() -> sessionStage.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> sessionStage.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(sessionStage.getMetadata().isExternalAccountRequired()).isFalse(); assertThat(sessionStage.getMetadata().isExternalAccountRequired()).isFalse();
assertThat(sessionStage.getMetadata().isAutoRenewalEnabled()).isFalse(); assertThat(sessionStage.getMetadata().isAutoRenewalEnabled()).isFalse();
assertThat(sessionStage.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty();
} }
/** /**
@ -65,6 +87,7 @@ public class ProviderIT {
assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(session.getMetadata().isExternalAccountRequired()).isFalse(); assertThat(session.getMetadata().isExternalAccountRequired()).isFalse();
assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse();
assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isNotEmpty();
} }
/** /**
@ -77,12 +100,14 @@ public class ProviderIT {
assertThatNoException().isThrownBy(() -> sessionEcc.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> sessionEcc.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(sessionEcc.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionEcc.getMetadata().isExternalAccountRequired()).isTrue();
assertThat(sessionEcc.getMetadata().isAutoRenewalEnabled()).isFalse(); assertThat(sessionEcc.getMetadata().isAutoRenewalEnabled()).isFalse();
assertThat(sessionEcc.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty();
var sessionRsa = new Session("acme://ssl.com/rsa"); var sessionRsa = new Session("acme://ssl.com/rsa");
assertThat(sessionRsa.getMetadata().getWebsite()).hasValue(new URL("https://www.ssl.com")); assertThat(sessionRsa.getMetadata().getWebsite()).hasValue(new URL("https://www.ssl.com"));
assertThatNoException().isThrownBy(() -> sessionRsa.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> sessionRsa.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(sessionRsa.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionRsa.getMetadata().isExternalAccountRequired()).isTrue();
assertThat(sessionRsa.getMetadata().isAutoRenewalEnabled()).isFalse(); 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 // If this test fails, the metadata has been fixed on server side. Then remove
// the patch at ZeroSSLAcmeProvider, and update the documentation. // the patch at ZeroSSLAcmeProvider, and update the documentation.
@ -101,12 +126,14 @@ public class ProviderIT {
assertThatNoException().isThrownBy(() -> sessionEccStage.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> sessionEccStage.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(sessionEccStage.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionEccStage.getMetadata().isExternalAccountRequired()).isTrue();
assertThat(sessionEccStage.getMetadata().isAutoRenewalEnabled()).isFalse(); assertThat(sessionEccStage.getMetadata().isAutoRenewalEnabled()).isFalse();
assertThat(sessionEccStage.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty();
var sessionRsaStage = new Session("acme://ssl.com/staging/rsa"); var sessionRsaStage = new Session("acme://ssl.com/staging/rsa");
assertThat(sessionRsaStage.getMetadata().getWebsite()).hasValue(new URL("https://www.ssl.com")); assertThat(sessionRsaStage.getMetadata().getWebsite()).hasValue(new URL("https://www.ssl.com"));
assertThatNoException().isThrownBy(() -> sessionRsaStage.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> sessionRsaStage.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(sessionRsaStage.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(sessionRsaStage.getMetadata().isExternalAccountRequired()).isTrue();
assertThat(sessionRsaStage.getMetadata().isAutoRenewalEnabled()).isFalse(); 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 // If this test fails, the metadata has been fixed on server side. Then remove
// the patch at ZeroSSLAcmeProvider, and update the documentation. // the patch at ZeroSSLAcmeProvider, and update the documentation.
@ -124,6 +151,7 @@ public class ProviderIT {
assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT)); assertThatNoException().isThrownBy(() -> session.resourceUrl(Resource.NEW_ACCOUNT));
assertThat(session.getMetadata().isExternalAccountRequired()).isTrue(); assertThat(session.getMetadata().isExternalAccountRequired()).isTrue();
assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse(); assertThat(session.getMetadata().isAutoRenewalEnabled()).isFalse();
assertThat(session.resourceUrlOptional(Resource.RENEWAL_INFO)).isEmpty();
// ZeroSSL has no documented staging server (as of February 2024) // ZeroSSL has no documented staging server (as of February 2024)
} }

View File

@ -81,7 +81,7 @@
<plugin> <plugin>
<groupId>com.github.spotbugs</groupId> <groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId> <artifactId>spotbugs-maven-plugin</artifactId>
<version>4.8.5.0</version> <version>4.8.6.3</version>
<executions> <executions>
<execution> <execution>
<goals> <goals>

23
src/doc/docs/ca/google.md Normal file
View File

@ -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).

View File

@ -6,6 +6,7 @@ _acme4j_ should support any CA that is providing an ACME server.
The _acme4j_ package contains these providers: The _acme4j_ package contains these providers:
* [Google](google.md)
* [Let's Encrypt](letsencrypt.md) * [Let's Encrypt](letsencrypt.md)
* [Pebble](pebble.md) * [Pebble](pebble.md)
* [SSL.com](sslcom.md) * [SSL.com](sslcom.md)

View File

@ -43,6 +43,7 @@ nav:
- 'challenge/email-reply-00.md' - 'challenge/email-reply-00.md'
- CA: - CA:
- 'ca/index.md' - 'ca/index.md'
- 'ca/google.md'
- 'ca/letsencrypt.md' - 'ca/letsencrypt.md'
- 'ca/pebble.md' - 'ca/pebble.md'
- 'ca/sslcom.md' - 'ca/sslcom.md'