mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +00:00
feat: wire ACME EAB into account registration + ZeroSSL auto-fetch
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
+4
-1
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -202,11 +202,35 @@ DNS-PERSIST-01 configuration:
|
||||
|
||||
The present script creates a TXT record at `_validation-persist.<domain>` with the value `letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/<your-id>`. 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.<domain>` for dns-01, `_validation-persist.<domain>` 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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user