From c9689acd222afd04fdc83139a8408438bd57ec5b Mon Sep 17 00:00:00 2001 From: Shankar Date: Fri, 27 Mar 2026 15:34:48 -0400 Subject: [PATCH] feat: wire ACME EAB into account registration + ZeroSSL auto-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EAB credentials (KID + HMAC) were defined in the ACME connector config but never wired into the acme.Account registration call. This fixes the dead code and adds automatic EAB credential fetching for ZeroSSL — when the directory URL is detected as ZeroSSL and no EAB credentials are provided, certctl calls ZeroSSL's public API to get them automatically. Changes: - Wire EABKid/EABHmac into acme.Account.ExternalAccountBinding - Add isZeroSSL() detection and fetchZeroSSLEAB() auto-fetch - Add CERTCTL_ACME_EAB_KID/CERTCTL_ACME_EAB_HMAC env vars to main.go - Add 13 ACME connector tests (config validation, EAB decode, ZeroSSL auto-EAB with mock servers, URL detection) - Update docs: README, architecture, connectors, demo-advanced, testing-guide with EAB/auto-EAB documentation Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +- cmd/server/main.go | 5 +- docs/architecture.md | 4 +- docs/connectors.md | 24 ++ docs/demo-advanced.md | 22 ++ docs/testing-guide.md | 20 ++ internal/connector/issuer/acme/acme.go | 92 +++++++ internal/connector/issuer/acme/acme_test.go | 264 ++++++++++++++++++++ 8 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 internal/connector/issuer/acme/acme_test.go diff --git a/README.md b/README.md index dde0809..d08a600 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ certctl gives you a single pane of glass for every TLS certificate in your organ **Core capabilities:** - **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds. -- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges (Let's Encrypt, Sectigo, any ACME-compatible CA), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file. +- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges (Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, any ACME-compatible CA), External Account Binding (EAB) for CAs that require it (auto-fetched for ZeroSSL), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file. - **Agent-side key generation** — agents generate ECDSA P-256 keys locally, store them with 0600 permissions, and submit only the CSR. Private keys never touch the control plane. This is the default mode, not an opt-in feature. - **Certificate discovery** — agents scan filesystems for existing PEM/DER certificates and report findings for triage. The network scanner probes TLS endpoints across CIDR ranges to find certificates you didn't know existed. - **Revocation infrastructure** — RFC 5280 revocation with all standard reason codes, DER-encoded X.509 CRL per issuer, embedded OCSP responder, and short-lived certificate exemption (certs under 1 hour skip CRL/OCSP). @@ -220,6 +220,8 @@ All server environment variables use the `CERTCTL_` prefix: | `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) | | `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) | | `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration | +| `CERTCTL_ACME_EAB_KID` | — | External Account Binding Key ID (required by ZeroSSL, Google Trust Services, SSL.com) | +| `CERTCTL_ACME_EAB_HMAC` | — | External Account Binding HMAC key (base64url-encoded) | | `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default), `dns-01`, or `dns-persist-01` | | `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode | | `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode | diff --git a/cmd/server/main.go b/cmd/server/main.go index e0e41a5..6dde93e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -97,11 +97,14 @@ func main() { localCA := local.New(localCAConfig, logger) logger.Info("initialized Local CA issuer connector") - // Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.) + // Initialize ACME issuer connector (for Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, etc.) // Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types. + // EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com. acmeConnector := acmeissuer.New(&acmeissuer.Config{ DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"), Email: os.Getenv("CERTCTL_ACME_EMAIL"), + EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"), + EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"), ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"), DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"), DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"), diff --git a/docs/architecture.md b/docs/architecture.md index 61b74c1..460f6d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,7 +77,7 @@ flowchart TB subgraph "Issuer Backends" CA1["Local CA\n(crypto/x509, sub-CA)"] - CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)"] + CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"] CA3["step-ca\n(/sign API)"] CA4["OpenSSL / Custom CA\n(script-based)"] CA6["Vault PKI\n(planned)"] @@ -563,7 +563,7 @@ type Connector interface { } ``` -Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint). +Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint). ### Target Connector diff --git a/docs/connectors.md b/docs/connectors.md index acfe76f..ecf7fe8 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -202,11 +202,35 @@ DNS-PERSIST-01 configuration: The present script creates a TXT record at `_validation-persist.` with the value `letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/`. This record is permanent — no cleanup script is needed. +ZeroSSL configuration (requires External Account Binding): +```json +{ + "directory_url": "https://acme.zerossl.com/v2/DV90", + "email": "admin@example.com", + "eab_kid": "your-zerossl-eab-kid", + "eab_hmac": "your-zerossl-eab-hmac-base64url" +} +``` + +ZeroSSL, Google Trust Services, and SSL.com require External Account Binding (EAB) for ACME account registration. For most CAs, get your EAB credentials from the CA's dashboard and provide them via `eab_kid` and `eab_hmac`. The HMAC key must be base64url-encoded (no padding). CAs that don't require EAB (Let's Encrypt, Buypass) ignore these fields. + +**ZeroSSL auto-EAB:** When the directory URL points to ZeroSSL and no EAB credentials are provided, certctl automatically fetches them from ZeroSSL's public API (`api.zerossl.com/acme/eab-credentials-email`) using your configured email address. No dashboard visit required — just set the directory URL and email, and it works. This is the same approach used by Caddy and acme.sh. + +Minimal ZeroSSL configuration (auto-EAB): +```json +{ + "directory_url": "https://acme.zerossl.com/v2/DV90", + "email": "admin@example.com" +} +``` + DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name — `_acme-challenge.` for dns-01, `_validation-persist.` for dns-persist-01), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it (dns-01 only). Environment variables for the default ACME connector: - `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL - `CERTCTL_ACME_EMAIL` — Contact email for account registration +- `CERTCTL_ACME_EAB_KID` — External Account Binding Key ID (required by ZeroSSL, Google Trust Services, SSL.com) +- `CERTCTL_ACME_EAB_HMAC` — External Account Binding HMAC key (base64url-encoded) - `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default), `dns-01`, or `dns-persist-01` - `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01) - `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01) diff --git a/docs/demo-advanced.md b/docs/demo-advanced.md index f2a2b05..f1c9a27 100644 --- a/docs/demo-advanced.md +++ b/docs/demo-advanced.md @@ -11,6 +11,7 @@ This demo goes beyond browsing pre-loaded data. You'll create a team, register a 2. [How the pieces fit together](#how-the-pieces-fit-together) 3. [Alternative Issuers Reference](#alternative-issuers-reference) - [Sub-CA Mode](#sub-ca-mode-local-ca-chained-to-enterprise-root) + - [ACME with ZeroSSL](#acme-with-zerossl-auto-eab) - [ACME with DNS-01 Challenges](#acme-with-dns-01-challenges-wildcard-certificates) - [ACME with DNS-PERSIST-01](#acme-with-dns-persist-01-zero-touch-renewals) - [step-ca (Smallstep Private CA)](#step-ca-smallstep-private-ca) @@ -96,6 +97,27 @@ docker compose -f deploy/docker-compose.yml restart server The CA key can be RSA, ECDSA, or PKCS#8 format. The connector validates that the certificate has `IsCA=true` and `KeyUsageCertSign`. +### ACME with ZeroSSL (Auto-EAB) + +ZeroSSL is a free ACME CA that requires External Account Binding (EAB) for account registration. certctl auto-fetches EAB credentials from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — you just need an email address: + +```bash +# Minimal config — certctl auto-fetches EAB credentials from ZeroSSL +export CERTCTL_ACME_DIRECTORY_URL="https://acme.zerossl.com/v2/DV90" +export CERTCTL_ACME_EMAIL="ops@example.com" +``` + +No dashboard visit, no manual EAB credential copy-paste. certctl calls `api.zerossl.com/acme/eab-credentials-email` with your email, gets back a KID + HMAC key, and uses them for ACME account registration automatically. + +If you already have EAB credentials (e.g., from the ZeroSSL dashboard or for other CAs like Google Trust Services or SSL.com), you can provide them explicitly: + +```bash +export CERTCTL_ACME_DIRECTORY_URL="https://acme.zerossl.com/v2/DV90" +export CERTCTL_ACME_EMAIL="ops@example.com" +export CERTCTL_ACME_EAB_KID="your-key-id" +export CERTCTL_ACME_EAB_HMAC="your-base64url-hmac-key" +``` + ### ACME with DNS-01 Challenges (Wildcard Certificates) For Let's Encrypt or other ACME providers with wildcard support: diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 5d6b884..5237c79 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -1490,6 +1490,26 @@ curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-le" | jq '{id, type}' --- +**Test 6.2.3 — Configure ACME with External Account Binding (ZeroSSL)** + +Edit `deploy/docker-compose.yml` to set EAB environment variables: +- `CERTCTL_ACME_DIRECTORY_URL: https://acme.zerossl.com/v2/DV90` +- `CERTCTL_ACME_EAB_KID: your-zerossl-kid` +- `CERTCTL_ACME_EAB_HMAC: your-base64url-hmac-key` + +Restart and verify the issuer accepts the config: + +```bash +curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-prod" | jq '{id, type}' +``` + +**What:** Verifies that ACME issuers read External Account Binding credentials from environment variables. +**Why:** ZeroSSL, Google Trust Services, and SSL.com require EAB for ACME account registration. Without EAB, account creation fails and no certificates can be issued from these CAs. +**Expected:** HTTP 200. ACME issuer functional with EAB credentials loaded. +**PASS if** HTTP 200 and issuer responds. **FAIL** if 500 or startup errors related to EAB. + +--- + ## Part 7: Target Connectors & Deployment **What this validates:** CRUD for deployment targets, including type-specific configuration for all 5 target types. diff --git a/internal/connector/issuer/acme/acme.go b/internal/connector/issuer/acme/acme.go index fb712c3..b9f2b2e 100644 --- a/internal/connector/issuer/acme/acme.go +++ b/internal/connector/issuer/acme/acme.go @@ -6,12 +6,16 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "fmt" + "io" "log/slog" "net" "net/http" + "net/url" + "strings" "sync" "time" @@ -201,6 +205,33 @@ func (c *Connector) ensureClient(ctx context.Context) error { acct := &acme.Account{ Contact: []string{"mailto:" + c.config.Email}, } + + // Auto-fetch EAB credentials from ZeroSSL if directory URL is ZeroSSL and no EAB provided. + // ZeroSSL offers a public endpoint that returns EAB credentials given an email address, + // so users don't need to visit the ZeroSSL dashboard manually. + if c.config.EABKid == "" && c.config.EABHmac == "" && isZeroSSL(c.config.DirectoryURL) { + kid, hmac, eabErr := fetchZeroSSLEAB(ctx, c.config.Email) + if eabErr != nil { + return fmt.Errorf("failed to auto-fetch ZeroSSL EAB credentials: %w", eabErr) + } + c.config.EABKid = kid + c.config.EABHmac = hmac + c.logger.Info("auto-fetched EAB credentials from ZeroSSL", "eab_kid", kid) + } + + // External Account Binding (required by ZeroSSL, Google Trust Services, SSL.com, etc.) + if c.config.EABKid != "" && c.config.EABHmac != "" { + hmacKey, decodeErr := base64.RawURLEncoding.DecodeString(c.config.EABHmac) + if decodeErr != nil { + return fmt.Errorf("failed to decode EAB HMAC key (expected base64url): %w", decodeErr) + } + acct.ExternalAccountBinding = &acme.ExternalAccountBinding{ + KID: c.config.EABKid, + Key: hmacKey, + } + c.logger.Info("using External Account Binding for ACME registration", "eab_kid", c.config.EABKid) + } + _, err = c.client.Register(ctx, acct, acme.AcceptTOS) if err != nil { // Account may already exist, try to get it @@ -216,6 +247,67 @@ func (c *Connector) ensureClient(ctx context.Context) error { return nil } +// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB credentials. +// Variable (not const) to allow test overrides. +var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials-email" + +// isZeroSSL returns true if the ACME directory URL points to ZeroSSL. +func isZeroSSL(directoryURL string) bool { + return strings.Contains(strings.ToLower(directoryURL), "zerossl.com") +} + +// fetchZeroSSLEAB retrieves EAB credentials from ZeroSSL's public API endpoint. +// ZeroSSL provides this so users don't need to visit the dashboard manually. +// Returns (kid, hmac_key, error). The HMAC key is already base64url-encoded. +func fetchZeroSSLEAB(ctx context.Context, email string) (string, string, error) { + if email == "" { + return "", "", fmt.Errorf("email is required for ZeroSSL EAB auto-fetch") + } + + form := url.Values{"email": {email}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, zeroSSLEABEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("ZeroSSL API returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Success bool `json:"success"` + EABKid string `json:"eab_kid"` + EABHmac string `json:"eab_hmac_key"` + ErrorMsg string `json:"error"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", "", fmt.Errorf("parse response: %w", err) + } + + if !result.Success || result.EABKid == "" || result.EABHmac == "" { + errDetail := result.ErrorMsg + if errDetail == "" { + errDetail = string(body) + } + return "", "", fmt.Errorf("ZeroSSL EAB generation failed: %s", errDetail) + } + + return result.EABKid, result.EABHmac, nil +} + // IssueCertificate submits a certificate issuance request to the ACME CA. // // Flow: diff --git a/internal/connector/issuer/acme/acme_test.go b/internal/connector/issuer/acme/acme_test.go new file mode 100644 index 0000000..bf41383 --- /dev/null +++ b/internal/connector/issuer/acme/acme_test.go @@ -0,0 +1,264 @@ +package acme + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +func TestValidateConfig_MissingDirectoryURL(t *testing.T) { + c := New(nil, testLogger()) + cfg, _ := json.Marshal(map[string]string{"email": "test@example.com"}) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "directory_url is required") { + t.Fatalf("expected directory_url error, got: %v", err) + } +} + +func TestValidateConfig_MissingEmail(t *testing.T) { + c := New(nil, testLogger()) + cfg, _ := json.Marshal(map[string]string{"directory_url": "https://example.com/directory"}) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "email is required") { + t.Fatalf("expected email error, got: %v", err) + } +} + +func TestValidateConfig_InvalidChallengeType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`) + })) + defer srv.Close() + + c := New(nil, testLogger()) + cfg, _ := json.Marshal(map[string]string{ + "directory_url": srv.URL, + "email": "test@example.com", + "challenge_type": "invalid-challenge", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "invalid challenge_type") { + t.Fatalf("expected invalid challenge_type error, got: %v", err) + } +} + +func TestValidateConfig_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`) + })) + defer srv.Close() + + c := New(nil, testLogger()) + cfg, _ := json.Marshal(map[string]string{ + "directory_url": srv.URL, + "email": "test@example.com", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestValidateConfig_EABFieldsPreserved(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`) + })) + defer srv.Close() + + c := New(nil, testLogger()) + cfg, _ := json.Marshal(map[string]string{ + "directory_url": srv.URL, + "email": "test@example.com", + "eab_kid": "kid-12345", + "eab_hmac": base64.RawURLEncoding.EncodeToString([]byte("test-hmac-key")), + }) + err := c.ValidateConfig(context.Background(), cfg) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if c.config.EABKid != "kid-12345" { + t.Fatalf("expected EABKid to be preserved, got: %s", c.config.EABKid) + } + if c.config.EABHmac == "" { + t.Fatal("expected EABHmac to be preserved") + } +} + +func TestEnsureClient_EABDecodeError(t *testing.T) { + c := New(&Config{ + DirectoryURL: "https://acme.example.com/directory", + Email: "test@example.com", + EABKid: "kid-12345", + EABHmac: "!!!not-valid-base64url!!!", + }, testLogger()) + + err := c.ensureClient(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decode EAB HMAC") { + t.Fatalf("expected EAB decode error, got: %v", err) + } +} + +func TestEnsureClient_EABBindingSet(t *testing.T) { + // We can't fully mock the ACME protocol (JWS nonce exchange), but we can + // verify that valid EAB credentials are decoded and attached to the account + // without panicking. The ensureClient call will fail at the network level + // (no real ACME server), but it must NOT fail at EAB decoding. + hmacKey := base64.RawURLEncoding.EncodeToString([]byte("test-hmac-secret-key")) + c := New(&Config{ + DirectoryURL: "https://127.0.0.1:1/directory", // unreachable — that's fine + Email: "test@example.com", + EABKid: "kid-zerossl-12345", + EABHmac: hmacKey, + }, testLogger()) + + err := c.ensureClient(context.Background()) + // Expected: network error (unreachable server), NOT an EAB decode error + if err != nil && strings.Contains(err.Error(), "decode EAB HMAC") { + t.Fatalf("EAB decode should not fail with valid base64url key, got: %v", err) + } + // We expect some error (network unreachable) — that's correct + if err == nil { + t.Log("ensureClient succeeded (unexpected but not a failure for this test)") + } +} + +// --- ZeroSSL auto-EAB tests --- + +func TestIsZeroSSL(t *testing.T) { + tests := []struct { + url string + expect bool + }{ + {"https://acme.zerossl.com/v2/DV90", true}, + {"https://ACME.ZEROSSL.COM/v2/DV90", true}, + {"https://acme-v02.api.letsencrypt.org/directory", false}, + {"https://acme.example.com/directory", false}, + {"", false}, + } + for _, tt := range tests { + if got := isZeroSSL(tt.url); got != tt.expect { + t.Errorf("isZeroSSL(%q) = %v, want %v", tt.url, got, tt.expect) + } + } +} + +func TestFetchZeroSSLEAB_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("expected form content-type, got %s", ct) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if email := r.FormValue("email"); email != "test@example.com" { + t.Errorf("expected email test@example.com, got %s", email) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"eab_kid":"kid_abc123","eab_hmac_key":"dGVzdC1obWFjLWtleQ"}`) + })) + defer srv.Close() + + // Override the endpoint for testing + origEndpoint := zeroSSLEABEndpoint + defer func() { zeroSSLEABEndpoint = origEndpoint }() + zeroSSLEABEndpoint = srv.URL + + kid, hmac, err := fetchZeroSSLEAB(context.Background(), "test@example.com") + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if kid != "kid_abc123" { + t.Errorf("expected kid_abc123, got %s", kid) + } + if hmac != "dGVzdC1obWFjLWtleQ" { + t.Errorf("expected dGVzdC1obWFjLWtleQ, got %s", hmac) + } +} + +func TestFetchZeroSSLEAB_EmptyEmail(t *testing.T) { + _, _, err := fetchZeroSSLEAB(context.Background(), "") + if err == nil || !strings.Contains(err.Error(), "email is required") { + t.Fatalf("expected email required error, got: %v", err) + } +} + +func TestFetchZeroSSLEAB_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"success":false,"error":"invalid email"}`) + })) + defer srv.Close() + + origEndpoint := zeroSSLEABEndpoint + defer func() { zeroSSLEABEndpoint = origEndpoint }() + zeroSSLEABEndpoint = srv.URL + + _, _, err := fetchZeroSSLEAB(context.Background(), "bad@example.com") + if err == nil || !strings.Contains(err.Error(), "status 400") { + t.Fatalf("expected API error, got: %v", err) + } +} + +func TestFetchZeroSSLEAB_MissingCredentials(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":false,"error":"rate limited"}`) + })) + defer srv.Close() + + origEndpoint := zeroSSLEABEndpoint + defer func() { zeroSSLEABEndpoint = origEndpoint }() + zeroSSLEABEndpoint = srv.URL + + _, _, err := fetchZeroSSLEAB(context.Background(), "test@example.com") + if err == nil || !strings.Contains(err.Error(), "EAB generation failed") { + t.Fatalf("expected EAB generation failed error, got: %v", err) + } +} + +func TestEnsureClient_ZeroSSLAutoEAB(t *testing.T) { + // Mock ZeroSSL EAB endpoint + eabSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"eab_kid":"auto-kid-123","eab_hmac_key":"dGVzdC1obWFjLWtleQ"}`) + })) + defer eabSrv.Close() + + origEndpoint := zeroSSLEABEndpoint + defer func() { zeroSSLEABEndpoint = origEndpoint }() + zeroSSLEABEndpoint = eabSrv.URL + + // Use an unreachable ACME directory — we only care that auto-EAB fetch happens + c := New(&Config{ + DirectoryURL: "https://acme.zerossl.com/v2/DV90", + Email: "test@example.com", + // EABKid and EABHmac intentionally empty — should auto-fetch + }, testLogger()) + + err := c.ensureClient(context.Background()) + // Will fail at ACME protocol level (unreachable ZeroSSL directory), but + // EAB credentials should have been auto-fetched and set on config + if c.config.EABKid != "auto-kid-123" { + t.Errorf("expected auto-fetched EABKid, got: %s (err: %v)", c.config.EABKid, err) + } + if c.config.EABHmac != "dGVzdC1obWFjLWtleQ" { + t.Errorf("expected auto-fetched EABHmac, got: %s", c.config.EABHmac) + } +}