Rework ACME workflow test to leverage Golang's ACME client library (#19949)

* Rework ACME workflow test to leverage Golang's ACME client library

 - Instead of testing manually, leverage the Golang ACME library
   to test against our implementation from the unit tests.

* Add tests for new-account and misc fixes

 - Set and return the account status for registration
 - Add handlers for the account/ api/updates
 - Switch acme/ to cluster local storage
 - Disable terms of service checks for now as we don't set the url

* PR feedback

 - Implement account deactivation
 - Create separate account update handler, to not mix account creation
   logic
 - Add kid field to account update definition
 - Add support to update contact details on an existing account
This commit is contained in:
Steven Clark 2023-04-12 09:05:42 -04:00 committed by GitHub
parent 282279121d
commit 4e6b88d58c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 242 additions and 51 deletions

View File

@ -107,20 +107,24 @@ func (a *acmeState) TidyNonces() {
a.nextExpiry.Store(nextRun.Unix()) a.nextExpiry.Store(nextRun.Unix())
} }
type ACMEStates string type ACMEAccountStatus string
func (aas ACMEAccountStatus) String() string {
return string(aas)
}
const ( const (
StatusValid = "valid" StatusValid ACMEAccountStatus = "valid"
StatusDeactivated = "deactivated" StatusDeactivated ACMEAccountStatus = "deactivated"
StatusRevoked = "revoked" StatusRevoked ACMEAccountStatus = "revoked"
) )
type acmeAccount struct { type acmeAccount struct {
KeyId string `json:"-"` KeyId string `json:"-"`
Status ACMEStates `json:"state"` Status ACMEAccountStatus `json:"status"`
Contact []string `json:"contact"` Contact []string `json:"contact"`
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
Jwk []byte `json:"jwk"` Jwk []byte `json:"jwk"`
} }
func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string, termsOfServiceAgreed bool) (*acmeAccount, error) { func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string, termsOfServiceAgreed bool) (*acmeAccount, error) {
@ -129,9 +133,12 @@ func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string,
Contact: contact, Contact: contact,
TermsOfServiceAgreed: termsOfServiceAgreed, TermsOfServiceAgreed: termsOfServiceAgreed,
Jwk: c.Jwk, Jwk: c.Jwk,
Status: StatusValid,
} }
json, err := logical.StorageEntryJSON(acmeAccountPrefix+c.Kid, acct) kid := cleanKid(c.Kid)
json, err := logical.StorageEntryJSON(acmeAccountPrefix+kid, acct)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating account entry: %w", err) return nil, fmt.Errorf("error creating account entry: %w", err)
} }
@ -143,6 +150,21 @@ func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string,
return acct, nil return acct, nil
} }
func (a *acmeState) UpdateAccount(ac *acmeContext, acct *acmeAccount) error {
kid := cleanKid(acct.KeyId)
json, err := logical.StorageEntryJSON(acmeAccountPrefix+kid, acct)
if err != nil {
return fmt.Errorf("error creating account entry: %w", err)
}
if err := ac.sc.Storage.Put(ac.sc.Context, json); err != nil {
return fmt.Errorf("error writing account entry: %w", err)
}
return nil
}
func cleanKid(keyID string) string { func cleanKid(keyID string) string {
pieces := strings.Split(keyID, "/") pieces := strings.Split(keyID, "/")
return pieces[len(pieces)-1] return pieces[len(pieces)-1]
@ -165,6 +187,8 @@ func (a *acmeState) LoadAccount(ac *acmeContext, keyID string) (*acmeAccount, er
return nil, fmt.Errorf("error loading account: %w", err) return nil, fmt.Errorf("error loading account: %w", err)
} }
acct.KeyId = keyID
return &acct, nil return &acct, nil
} }

View File

@ -126,6 +126,7 @@ func Backend(conf *logical.BackendConfig) *backend {
clusterConfigPath, clusterConfigPath,
"crls/", "crls/",
"certs/", "certs/",
acmePathPrefix,
}, },
Root: []string{ Root: []string{
@ -228,6 +229,10 @@ func Backend(conf *logical.BackendConfig) *backend {
pathAcmeRoleNewAccount(&b), pathAcmeRoleNewAccount(&b),
pathAcmeIssuerNewAccount(&b), pathAcmeIssuerNewAccount(&b),
pathAcmeIssuerAndRoleNewAccount(&b), pathAcmeIssuerAndRoleNewAccount(&b),
pathAcmeRootUpdateAccount(&b),
pathAcmeRoleUpdateAccount(&b),
pathAcmeIssuerUpdateAccount(&b),
pathAcmeIssuerAndRoleUpdateAccount(&b),
}, },
Secrets: []*framework.Secret{ Secrets: []*framework.Secret{
@ -248,6 +253,7 @@ func Backend(conf *logical.BackendConfig) *backend {
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-order") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-order")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/account/+")
} }
if constants.IsEnterprise { if constants.IsEnterprise {

View File

@ -6813,6 +6813,7 @@ func TestProperAuthing(t *testing.T) {
paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-account"] = shouldBeUnauthedWriteOnly paths[acmePrefix+"acme/new-account"] = shouldBeUnauthedWriteOnly
paths[acmePrefix+"acme/account/hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo="] = shouldBeUnauthedWriteOnly
} }
for path, checkerType := range paths { for path, checkerType := range paths {
@ -6858,6 +6859,9 @@ func TestProperAuthing(t *testing.T) {
if strings.Contains(raw_path, "{serial}") { if strings.Contains(raw_path, "{serial}") {
raw_path = strings.ReplaceAll(raw_path, "{serial}", serial) raw_path = strings.ReplaceAll(raw_path, "{serial}", serial)
} }
if strings.Contains(raw_path, "acme/account/") && strings.Contains(raw_path, "{kid}") {
raw_path = strings.ReplaceAll(raw_path, "{kid}", "hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo=")
}
handler, present := paths[raw_path] handler, present := paths[raw_path]
if !present { if !present {

View File

@ -6,6 +6,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/hashicorp/go-secure-stdlib/strutil"
"github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
) )
@ -28,6 +30,24 @@ func pathAcmeIssuerAndRoleNewAccount(b *backend) *framework.Path {
"/roles/"+framework.GenericNameRegex("role")+"/acme/new-account") "/roles/"+framework.GenericNameRegex("role")+"/acme/new-account")
} }
func pathAcmeRootUpdateAccount(b *backend) *framework.Path {
return patternAcmeNewAccount(b, "acme/account/"+framework.MatchAllRegex("kid"))
}
func pathAcmeRoleUpdateAccount(b *backend) *framework.Path {
return patternAcmeNewAccount(b, "roles/"+framework.GenericNameRegex("role")+"/acme/account/"+framework.MatchAllRegex("kid"))
}
func pathAcmeIssuerUpdateAccount(b *backend) *framework.Path {
return patternAcmeNewAccount(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/account/"+framework.MatchAllRegex("kid"))
}
func pathAcmeIssuerAndRoleUpdateAccount(b *backend) *framework.Path {
return patternAcmeNewAccount(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+
"/roles/"+framework.GenericNameRegex("role")+"/acme/account/"+framework.MatchAllRegex("kid"))
}
func addFieldsForACMEPath(fields map[string]*framework.FieldSchema, pattern string) map[string]*framework.FieldSchema { func addFieldsForACMEPath(fields map[string]*framework.FieldSchema, pattern string) map[string]*framework.FieldSchema {
if strings.Contains(pattern, framework.GenericNameRegex("role")) { if strings.Contains(pattern, framework.GenericNameRegex("role")) {
fields["role"] = &framework.FieldSchema{ fields["role"] = &framework.FieldSchema{
@ -69,10 +89,23 @@ func addFieldsForACMERequest(fields map[string]*framework.FieldSchema) map[strin
return fields return fields
} }
func addFieldsForACMEKidRequest(fields map[string]*framework.FieldSchema, pattern string) map[string]*framework.FieldSchema {
if strings.Contains(pattern, framework.GenericNameRegex("kid")) {
fields["kid"] = &framework.FieldSchema{
Type: framework.TypeString,
Description: `The key identifier provided by the CA`,
Required: true,
}
}
return fields
}
func patternAcmeNewAccount(b *backend, pattern string) *framework.Path { func patternAcmeNewAccount(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{} fields := map[string]*framework.FieldSchema{}
addFieldsForACMEPath(fields, pattern) addFieldsForACMEPath(fields, pattern)
addFieldsForACMERequest(fields) addFieldsForACMERequest(fields)
addFieldsForACMEKidRequest(fields, pattern)
return &framework.Path{ return &framework.Path{
Pattern: pattern, Pattern: pattern,
@ -171,6 +204,7 @@ func (b *backend) acmeNewAccountHandler(acmeCtx *acmeContext, r *logical.Request
var onlyReturnExisting bool var onlyReturnExisting bool
var contacts []string var contacts []string
var termsOfServiceAgreed bool var termsOfServiceAgreed bool
var status string
rawContact, present := data["contact"] rawContact, present := data["contact"]
if present { if present {
@ -205,6 +239,15 @@ func (b *backend) acmeNewAccountHandler(acmeCtx *acmeContext, r *logical.Request
} }
} }
// Per RFC 8555 7.3.6 Account deactivation, we will handle it within our update API.
rawStatus, present := data["status"]
if present {
status, ok = rawStatus.(string)
if !ok {
return nil, fmt.Errorf("invalid type (%T) for field 'onlyReturnExisting': %w", rawOnlyReturnExisting, ErrMalformed)
}
}
// We ignore the EAB parameter as it is currently not supported. // We ignore the EAB parameter as it is currently not supported.
// We have two paths here: search or create. // We have two paths here: search or create.
@ -212,7 +255,13 @@ func (b *backend) acmeNewAccountHandler(acmeCtx *acmeContext, r *logical.Request
return b.acmeNewAccountSearchHandler(acmeCtx, r, fields, userCtx, data) return b.acmeNewAccountSearchHandler(acmeCtx, r, fields, userCtx, data)
} }
return b.acmeNewAccountCreateHandler(acmeCtx, r, fields, userCtx, data, contacts, termsOfServiceAgreed) // Pass through the /new-account API calls to this specific handler as its requirements are different
// from the account update handler.
if strings.HasSuffix(r.Path, "/new-account") {
return b.acmeNewAccountCreateHandler(acmeCtx, r, fields, userCtx, data, contacts, termsOfServiceAgreed)
}
return b.acmeNewAccountUpdateHandler(acmeCtx, userCtx, contacts, status)
} }
func formatAccountResponse(location string, acct *acmeAccount) *logical.Response { func formatAccountResponse(location string, acct *acmeAccount) *logical.Response {
@ -265,10 +314,11 @@ func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, r *logical.R
return b.acmeNewAccountSearchHandler(acmeCtx, r, fields, userCtx, data) return b.acmeNewAccountSearchHandler(acmeCtx, r, fields, userCtx, data)
} }
// TODO: Limit this only when ToS are required by the operator. // TODO: Limit this only when ToS are required or set by the operator, since we don't have a
if !termsOfServiceAgreed { // ToS URL in the directory at the moment, we can not enforce this.
return nil, fmt.Errorf("terms of service not agreed to: %w", ErrUserActionRequired) //if !termsOfServiceAgreed {
} // return nil, fmt.Errorf("terms of service not agreed to: %w", ErrUserActionRequired)
//}
account, err := b.acmeState.CreateAccount(acmeCtx, userCtx, contact, termsOfServiceAgreed) account, err := b.acmeState.CreateAccount(acmeCtx, userCtx, contact, termsOfServiceAgreed)
if err != nil { if err != nil {
@ -285,3 +335,52 @@ func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, r *logical.R
resp.Data[logical.HTTPStatusCode] = http.StatusCreated resp.Data[logical.HTTPStatusCode] = http.StatusCreated
return resp, nil return resp, nil
} }
func (b *backend) acmeNewAccountUpdateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, status string) (*logical.Response, error) {
if !userCtx.Existing {
return nil, fmt.Errorf("cannot submit to account updates without a 'kid': %w", ErrMalformed)
}
if !b.acmeState.DoesAccountExist(acmeCtx, userCtx.Kid) {
return nil, fmt.Errorf("an account with this key does not exist: %w", ErrAccountDoesNotExist)
}
account, err := b.acmeState.LoadAccount(acmeCtx, userCtx.Kid)
if err != nil {
return nil, fmt.Errorf("error loading account: %w", err)
}
// Per RFC 8555 7.3.6 Account deactivation, if we were previously deactivated, we should return
// unauthorized. There is no way to reactivate any accounts per ACME RFC.
if account.Status != StatusValid {
// Treating "revoked" and "deactivated" as the same here.
return nil, ErrUnauthorized
}
shouldUpdate := false
// Check to see if we should update, we don't really care about ordering
if !strutil.EquivalentSlices(account.Contact, contact) {
shouldUpdate = true
account.Contact = contact
}
// Check to process account de-activation status was requested.
// 7.3.6. Account Deactivation
if string(StatusDeactivated) == status {
shouldUpdate = true
// TODO: This should cancel any ongoing operations (do not revoke certs),
// perhaps we should delete this account here?
account.Status = StatusDeactivated
}
if shouldUpdate {
err = b.acmeState.UpdateAccount(acmeCtx, account)
if err != nil {
return nil, fmt.Errorf("failed to update account: %w", err)
}
}
location := acmeCtx.baseUrl.String() + "account/" + userCtx.Kid
resp := formatAccountResponse(location, account)
return resp, nil
}

View File

@ -2,11 +2,19 @@ package pki
import ( import (
"context" "context"
"crypto"
"crypto/rand"
"crypto/rsa"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/hashicorp/go-cleanhttp"
"golang.org/x/crypto/acme"
"golang.org/x/net/http2"
"github.com/hashicorp/vault/api" "github.com/hashicorp/vault/api"
vaulthttp "github.com/hashicorp/vault/http" vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/vault" "github.com/hashicorp/vault/vault"
@ -17,54 +25,80 @@ import (
"gopkg.in/square/go-jose.v2/json" "gopkg.in/square/go-jose.v2/json"
) )
// TestAcmeDirectory a basic test that will validate the various directory APIs // TestAcmeBasicWorkflow a basic test that will validate a basic ACME workflow using the Golang ACME client.
// are available and produce the correct responses. func TestAcmeBasicWorkflow(t *testing.T) {
func TestAcmeDirectory(t *testing.T) {
t.Parallel() t.Parallel()
cluster, client, pathConfig := setupAcmeBackend(t) cluster, client, _ := setupAcmeBackend(t)
defer cluster.Cleanup() defer cluster.Cleanup()
cases := []struct { cases := []struct {
name string name string
prefixUrl string prefixUrl string
directoryUrl string
}{ }{
{"root", "", "pki/acme/directory"}, {"root", ""},
{"role", "/roles/test-role", "pki/roles/test-role/acme/directory"}, {"role", "/roles/test-role"},
{"issuer", "/issuer/default", "pki/issuer/default/acme/directory"}, {"issuer", "/issuer/default"},
{"issuer_role", "/issuer/default/roles/test-role", "pki/issuer/default/roles/test-role/acme/directory"}, {"issuer_role", "/issuer/default/roles/test-role"},
{"issuer_role_acme", "/issuer/acme/roles/acme", "pki/issuer/acme/roles/acme/acme/directory"}, {"issuer_role_acme", "/issuer/acme/roles/acme"},
} }
testCtx := context.Background() testCtx := context.Background()
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
dirResp, err := client.Logical().ReadRawWithContext(testCtx, tc.directoryUrl) baseAcmeURL := "/v1/pki" + tc.prefixUrl + "/acme/"
require.NoError(t, err, "failed reading ACME directory configuration") key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "failed creating rsa key")
require.Equal(t, 200, dirResp.StatusCode) acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, key)
require.Equal(t, "application/json", dirResp.Header.Get("Content-Type"))
requiredUrls := map[string]string{ discovery, err := acmeClient.Discover(testCtx)
"newNonce": pathConfig + tc.prefixUrl + "/acme/new-nonce", require.NoError(t, err, "failed acme discovery call")
"newAccount": pathConfig + tc.prefixUrl + "/acme/new-account",
"newOrder": pathConfig + tc.prefixUrl + "/acme/new-order",
"revokeCert": pathConfig + tc.prefixUrl + "/acme/revoke-cert",
"keyChange": pathConfig + tc.prefixUrl + "/acme/key-change",
}
rawBodyBytes, err := io.ReadAll(dirResp.Body) discoveryBaseUrl := client.Address() + baseAcmeURL
require.NoError(t, err, "failed reading from directory response body") require.Equal(t, discoveryBaseUrl+"new-nonce", discovery.NonceURL)
_ = dirResp.Body.Close() require.Equal(t, discoveryBaseUrl+"new-account", discovery.RegURL)
require.Equal(t, discoveryBaseUrl+"new-order", discovery.OrderURL)
require.Equal(t, discoveryBaseUrl+"revoke-cert", discovery.RevokeURL)
require.Equal(t, discoveryBaseUrl+"key-change", discovery.KeyChangeURL)
respType := map[string]interface{}{} // Attempt to update prior to creating an account
err = json.Unmarshal(rawBodyBytes, &respType) _, err = acmeClient.UpdateReg(testCtx, &acme.Account{Contact: []string{"mailto:shouldfail@example.com"}})
require.NoError(t, err, "failed unmarshalling ACME directory response body") require.ErrorIs(t, err, acme.ErrNoAccount, "expected failure attempting to update prior to account registration")
for key, expectedUrl := range requiredUrls { // Create new account
require.Contains(t, respType, key, "missing required value %s from data", key) acct, err := acmeClient.Register(testCtx, &acme.Account{
require.Equal(t, expectedUrl, respType[key], "different URL returned for %s", key) Contact: []string{"mailto:test@example.com", "mailto:test2@test.com"},
} }, func(tosURL string) bool { return true })
require.NoError(t, err, "failed registering account")
require.Equal(t, acme.StatusValid, acct.Status)
require.Contains(t, acct.Contact, "mailto:test@example.com")
require.Contains(t, acct.Contact, "mailto:test2@test.com")
require.Len(t, acct.Contact, 2)
// Call register again we should get existing account
_, err = acmeClient.Register(testCtx, acct, func(tosURL string) bool { return true })
require.ErrorIs(t, err, acme.ErrAccountAlreadyExists,
"We should have returned a 200 status code which would have triggered an error in the golang acme"+
" library")
// Update contact
acct.Contact = []string{"mailto:test3@example.com"}
acct2, err := acmeClient.UpdateReg(testCtx, acct)
require.NoError(t, err, "failed updating account")
require.Equal(t, acme.StatusValid, acct2.Status)
// We should get this back, not the original values.
require.Contains(t, acct2.Contact, "mailto:test3@example.com")
require.Len(t, acct2.Contact, 1)
// Deactivate account
err = acmeClient.DeactivateReg(testCtx)
require.NoError(t, err, "failed deactivating account")
// Make sure we get an unauthorized error trying to update the account again.
_, err = acmeClient.UpdateReg(testCtx, acct)
require.Error(t, err, "expected account to be deactivated")
require.IsType(t, &acme.Error{}, err, "expected acme error type")
acmeErr := err.(*acme.Error)
require.Equal(t, "urn:ietf:params:acme:error:unauthorized", acmeErr.ProblemType)
}) })
} }
} }
@ -172,7 +206,7 @@ func setupAcmeBackend(t *testing.T) (*vault.TestCluster, *api.Client, string) {
cluster, client := setupTestPkiCluster(t) cluster, client := setupTestPkiCluster(t)
// Setting templated AIAs should succeed. // Setting templated AIAs should succeed.
pathConfig := "https://localhost:8200/v1/pki" pathConfig := client.Address() + "/v1/pki"
_, err := client.Logical().WriteWithContext(context.Background(), "pki/config/cluster", map[string]interface{}{ _, err := client.Logical().WriteWithContext(context.Background(), "pki/config/cluster", map[string]interface{}{
"path": pathConfig, "path": pathConfig,
@ -182,7 +216,7 @@ func setupAcmeBackend(t *testing.T) (*vault.TestCluster, *api.Client, string) {
// Allow certain headers to pass through for ACME support // Allow certain headers to pass through for ACME support
_, err = client.Logical().WriteWithContext(context.Background(), "sys/mounts/pki/tune", map[string]interface{}{ _, err = client.Logical().WriteWithContext(context.Background(), "sys/mounts/pki/tune", map[string]interface{}{
"allowed_response_headers": []string{"Last-Modified", "Replay-Nonce", "Link"}, "allowed_response_headers": []string{"Last-Modified", "Replay-Nonce", "Link", "Location"},
}) })
require.NoError(t, err, "failed tuning mount response headers") require.NoError(t, err, "failed tuning mount response headers")
@ -203,3 +237,27 @@ func setupTestPkiCluster(t *testing.T) (*vault.TestCluster, *api.Client) {
mountPKIEndpoint(t, client, "pki") mountPKIEndpoint(t, client, "pki")
return cluster, client return cluster, client
} }
func getAcmeClientForCluster(t *testing.T, cluster *vault.TestCluster, baseUrl string, key crypto.Signer) acme.Client {
coreAddr := cluster.Cores[0].Listeners[0].Address
tlsConfig := cluster.Cores[0].TLSConfig()
transport := cleanhttp.DefaultPooledTransport()
transport.TLSClientConfig = tlsConfig.Clone()
if err := http2.ConfigureTransport(transport); err != nil {
t.Fatal(err)
}
httpClient := &http.Client{Transport: transport}
if baseUrl[0] == '/' {
baseUrl = baseUrl[1:]
}
if !strings.HasPrefix(baseUrl, "v1/") {
baseUrl = "v1/" + baseUrl
}
baseAcmeURL := fmt.Sprintf("https://%s/%s", coreAddr.String(), baseUrl)
return acme.Client{
Key: key,
HTTPClient: httpClient,
DirectoryURL: baseAcmeURL + "directory",
}
}