mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 18:48:51 +00:00
03593d4304
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>
265 lines
8.7 KiB
Go
265 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|