mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 22:48:52 +00:00
fefa5a5fd7
Closes the #7 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. Pre-fix, ACME RevokeCertificate at acme.go:L519-L529 returned the literal error "ACME revocation by serial not supported in V1; provide certificate DER". RFC 8555 §7.6 genuinely requires the cert DER bytes (not just the serial), but a CLM platform's job is to abstract over that limitation. Operators routinely have only the serial in hand: lost PEM, rotated key, GUI revoke action driven by a row in the certs list. This commit: - Adds CertificateLookupRepo interface at the ACME connector boundary (connector boundary, NOT a service/repository import — the connector accepts whatever satisfies the shape). Production wiring in cmd/server/main.go injects the postgres CertificateRepository; tests inject a fake. - Adds CertificateRepository.GetVersionBySerial(ctx, issuerID, serial) + interface declaration in repository/interfaces.go, returning the certificate_versions row whose SerialNumber matches, scoped to the issuer via JOIN on managed_certificates. Mirrors the existing GetByIssuerAndSerial shape but returns the version (where PEMChain lives). Per RFC 5280 §5.2.3 the issuer scope is required for determinism. - Adds SetCertificateLookup + SetIssuerID setters on *acme.Connector. Mirror the pattern local.Connector already uses for OCSP responder wiring. Both must be wired before serial-only revoke works; unwired state falls back to a more actionable error pointing at the wiring requirement (the historical "not supported" wording is retired). - Rewrites RevokeCertificate end-to-end: lookup → empty-PEM check → pem.Decode → block.Type == "CERTIFICATE" check → ensureClient → golang.org/x/crypto/acme.Client.RevokeCert(ctx, accountKey, der, reasonCode). RFC 8555 §7.6 case 1 (revocation request signed with account key) — the same account key issued the cert, so authority is intrinsic. The not-found path returns an actionable operator- facing error pointing at the local-store requirement. - Adds mapRevocationReason translating RFC 5280 §5.3.1 reason strings (unspecified, keyCompromise, cACompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aACompromise) into golang.org/x/crypto/acme. CRLReasonCode. Accepts canonical camelCase + underscore_lower + ALL_CAPS_UNDERSCORE. Nil reason → 0 (unspecified). Unknown reason errors rather than silently demoting (operators rely on the reason for compliance reporting). - Wiring update in service/issuer_registry.go: SetACMECertLookup setter on the registry; Rebuild type-asserts *acme.Connector and calls SetCertificateLookup + SetIssuerID, mirroring the existing *local.Connector branch. cmd/server/main.go calls issuerRegistry.SetACMECertLookup(certificateRepo) immediately after SetIssuanceMetrics — the postgres repo satisfies the interface via GetVersionBySerial. - Tests: * acme_revoke_test.go (new): TestRevokeCertificate_NoCertLookupWired, TestRevokeCertificate_NoIssuerIDWired, TestRevokeCertificate_LookupReturnsNotFound (operator-facing "may not have been issued through certctl" hint pinned), TestRevokeCertificate_LookupArbitraryError, TestRevokeCertificate_VersionPEMEmpty (corrupt-row guard), TestRevokeCertificate_PEMMalformed_NoBlock, TestRevokeCertificate_PEMMalformed_WrongType (PRIVATE KEY block rejected as not a CERTIFICATE). * TestMapRevocationReason_TableDriven: full RFC 5280 reason set plus camelCase / underscore / ALL-CAPS variants plus nil-reason and unknown-reason cases. * acme_failure_test.go: renamed TestRevokeCertificate_AlwaysError → TestRevokeCertificate_UnwiredCertLookupFallback; the test still exercises the same backward-compat branch but now asserts the new "CertificateLookup wiring" error wording. - Mock-repo updates (3 sites): mockCertificateRepository in internal/integration/lifecycle_test.go, mockCertRepo in internal/service/testutil_test.go, mockCertRepoWithGetError in internal/service/shortlived_test.go each gain a GetVersionBySerial implementation that mirrors the GetByIssuerAndSerial logic but returns the version row. - docs/connectors.md ACME section: new "Revocation by serial number" subsection covering the workflow, the local-store requirement (cert was issued through certctl, not imported), the reason-code mapping with the three accepted spelling variants, and a pointer to the audit reference. Out of scope (intentional, per spec): - Recovering the DER from outside the local cert store (CT logs, CSR + signature reconstruction). If the cert wasn't issued through certctl, revoke-by-serial via certctl isn't possible. - Revocation via the cert's private key (RFC 8555 §7.6 case 2). The account-key path covers all certctl-issued certs because the same account key issued them. - Pebble-backed integration test for the happy path. Pebble integration is the right home for that — the unit tests in this commit pin all failure-mode branches before the network call, and the wiring branch in Rebuild is exercised by the existing TestIssuerRegistryRebuild paths. Verified locally: - gofmt -l . clean - go vet ./... clean - staticcheck ./... clean - go test -short -count=1 across connector, service, repository, integration, api/middleware, api/handler: green Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #7.
939 lines
32 KiB
Go
939 lines
32 KiB
Go
package acme
|
|
|
|
// Bundle J (Coverage Audit Closure) — ACME failure-mode regression suite.
|
|
//
|
|
// Closes finding C-001. Per gap-backlog.md C-001 the failure modes that
|
|
// matter are: 401 from upstream, 403, 429+Retry-After, 5xx, malformed
|
|
// directory JSON, malformed order JSON, expired EAB credentials, ARI
|
|
// deferral with unreachable CA, EAB auto-fetch failure.
|
|
//
|
|
// Strategy:
|
|
// - Hermetic httptest.Server for every case — no network.
|
|
// - For paths that go through ensureClient (which would otherwise need a
|
|
// full ACME registration), we pre-set c.client and c.accountKey so
|
|
// ensureClient short-circuits. This lets us exercise the post-init
|
|
// failure paths (ARI, profile, revoke, getOrderStatus) deterministically.
|
|
// - Per row we assert (a) error is non-nil, (b) error message is
|
|
// informative + does not leak credentials/keys, (c) no panic.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
goacme "golang.org/x/crypto/acme"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// silentLogger discards everything. Reuses testLogger() from acme_test.go
|
|
// when called as a peer. This file's tests use testLogger() which returns
|
|
// a slog logger writing to stderr at error level.
|
|
|
|
// preWiredConnector returns a Connector with a synthesized account key + acme
|
|
// client pre-set, so calls into ensureClient short-circuit. This lets tests
|
|
// exercise post-init paths (ARI, profile, revoke, getOrderStatus) without
|
|
// having to mock the full ACME registration flow.
|
|
func preWiredConnector(t *testing.T, cfg *Config) *Connector {
|
|
t.Helper()
|
|
c := New(cfg, testLogger())
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
|
}
|
|
c.accountKey = key
|
|
c.client = &goacme.Client{
|
|
Key: key,
|
|
DirectoryURL: cfg.DirectoryURL,
|
|
HTTPClient: c.httpClient(),
|
|
}
|
|
return c
|
|
}
|
|
|
|
// makeTestCertPEM produces a minimal valid PEM-encoded self-signed cert
|
|
// suitable for ARI cert-ID computation. The cert content is irrelevant —
|
|
// computeARICertID only hashes the DER bytes.
|
|
func makeTestCertPEM(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("gen key: %v", err)
|
|
}
|
|
tmpl := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "test"},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("create cert: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EAB auto-fetch failure modes (Bundle J — gap-backlog.md C-001 row 9-10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestFetchZeroSSLEAB_NetworkError simulates a connect-refused / unreachable
|
|
// ZeroSSL endpoint by pointing at a closed httptest server.
|
|
func TestFetchZeroSSLEAB_NetworkError(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close() // close before fetch — connect will fail
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = url
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected network error from closed server")
|
|
}
|
|
if !strings.Contains(err.Error(), "request failed") {
|
|
t.Errorf("error %q should wrap 'request failed'", err)
|
|
}
|
|
}
|
|
|
|
// TestFetchZeroSSLEAB_MalformedJSON pins the parse-error branch.
|
|
func TestFetchZeroSSLEAB_MalformedJSON(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"success":true,"eab_kid":`) // truncated
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected JSON parse error")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse response") {
|
|
t.Errorf("error %q should wrap 'parse response'", err)
|
|
}
|
|
}
|
|
|
|
// TestFetchZeroSSLEAB_5xx pins the non-200 branch.
|
|
func TestFetchZeroSSLEAB_5xx(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = io.WriteString(w, `internal`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected 500 to error")
|
|
}
|
|
if !strings.Contains(err.Error(), "status 500") {
|
|
t.Errorf("error %q should mention 'status 500'", err)
|
|
}
|
|
if strings.Contains(err.Error(), "x@example.com") {
|
|
// the email isn't sensitive but we should not echo it back into errors
|
|
// either; pin the absence as a defense-in-depth check.
|
|
t.Logf("note: email is in error message — acceptable here, but watch for credential leaks")
|
|
}
|
|
}
|
|
|
|
// TestFetchZeroSSLEAB_401Unauthorized confirms upstream 401 propagates.
|
|
func TestFetchZeroSSLEAB_401Unauthorized(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = io.WriteString(w, `{"success":false,"error":"invalid api key"}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected 401 to error")
|
|
}
|
|
if !strings.Contains(err.Error(), "status 401") {
|
|
t.Errorf("error %q should mention 'status 401'", err)
|
|
}
|
|
}
|
|
|
|
// TestEnsureClient_EABAutoFetchFails confirms the connector's startup-time
|
|
// auto-EAB call propagates the underlying HTTP failure cleanly.
|
|
func TestEnsureClient_EABAutoFetchFails(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.zerossl.com/v2/DV90",
|
|
Email: "test@example.com",
|
|
// EAB intentionally empty → triggers auto-fetch
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected ensureClient to fail when ZeroSSL EAB auto-fetch fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "auto-fetch ZeroSSL EAB credentials") {
|
|
t.Errorf("error %q should wrap auto-fetch failure", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ARI failure modes (Bundle J — C-001 row 9 "ARI deferral with unreachable CA")
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestGetRenewalInfo_DirectoryUnreachable pins the unreachable-CA fallback
|
|
// path. With an unreachable directory, getARIEndpoint silently falls back to
|
|
// the constructed URL pattern; the subsequent ARI GET will then also fail.
|
|
func TestGetRenewalInfo_DirectoryUnreachable(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: url + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
ARIHTTPTimeoutSeconds: 1,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected error when both directory and ARI fallback unreachable")
|
|
}
|
|
if !strings.Contains(err.Error(), "ARI request failed") {
|
|
t.Errorf("error %q should wrap 'ARI request failed'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARI5xx pins the non-2xx (other than 404) branch. The
|
|
// directory handler emits an absolute URL pointing back at the same test
|
|
// server's /renewalInfo path, which 5xx's all requests.
|
|
func TestGetRenewalInfo_ARI5xx(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected ARI 5xx to error")
|
|
}
|
|
if !strings.Contains(err.Error(), "status 500") {
|
|
t.Errorf("error %q should mention 'status 500'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARI404Returns_NilNil pins the "CA does not support ARI"
|
|
// short-circuit.
|
|
func TestGetRenewalInfo_ARI404Returns_NilNil(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
http.Error(w, "no ARI", http.StatusNotFound)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
res, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err != nil {
|
|
t.Fatalf("expected nil error on 404, got: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Errorf("expected nil result on 404, got: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARIMalformedJSON pins the parse-error branch.
|
|
func TestGetRenewalInfo_ARIMalformedJSON(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"suggestedWindow": invalid`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected parse error on malformed ARI JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse ARI response") {
|
|
t.Errorf("error %q should wrap 'parse ARI response'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARIEmptyWindow pins the "missing or empty
|
|
// suggestedWindow" branch.
|
|
func TestGetRenewalInfo_ARIEmptyWindow(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected error on empty suggestedWindow")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing or empty suggestedWindow") {
|
|
t.Errorf("error %q should mention 'missing or empty suggestedWindow'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_HappyPath pins the success branch end-to-end.
|
|
func TestGetRenewalInfo_HappyPath(t *testing.T) {
|
|
start := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
end := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"suggestedWindow":{"start":%q,"end":%q},"explanationURL":"https://example.com/why"}`, start, end)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
res, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if res.SuggestedWindowStart.IsZero() || res.SuggestedWindowEnd.IsZero() {
|
|
t.Errorf("window timestamps should be parsed, got start=%v end=%v", res.SuggestedWindowStart, res.SuggestedWindowEnd)
|
|
}
|
|
if res.ExplanationURL != "https://example.com/why" {
|
|
t.Errorf("explanationURL = %q; want 'https://example.com/why'", res.ExplanationURL)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_DirectoryMalformedJSONUsesFallback pins that a malformed
|
|
// directory JSON does NOT abort — getARIEndpoint silently uses the
|
|
// constructARIURLFallback URL, which then drives the ARI GET.
|
|
func TestGetRenewalInfo_DirectoryMalformedJSONUsesFallback(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{not json`)
|
|
return
|
|
}
|
|
// /renewalInfo/{certID} after fallback (directory URL stripped of /directory)
|
|
http.Error(w, "fallback hit ok", http.StatusNotFound)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
res, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
// 404 from the fallback URL is the "no ARI" short-circuit → (nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected nil error on fallback 404, got: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Errorf("expected nil result, got: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARIInvalidPEM pins the cert-ID computation error branch
|
|
// with a known-bad PEM.
|
|
func TestGetRenewalInfo_ARIInvalidPEM(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: "https://acme.invalid/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
_, err := c.GetRenewalInfo(context.Background(), "not a pem")
|
|
if err == nil {
|
|
t.Fatal("expected error on invalid PEM")
|
|
}
|
|
if !strings.Contains(err.Error(), "compute ARI cert ID") {
|
|
t.Errorf("error %q should wrap 'compute ARI cert ID'", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// authorizeOrderWithProfile failure modes (Bundle J — C-001 rows 1-7)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// authorizeOrderWithProfile fast-paths to client.AuthorizeOrder when profile
|
|
// is empty. With profile set, it does Discover + GetReg + fetchNonce + JWS-
|
|
// signed POST. We test the failure paths for the JWS-POST branch and rely
|
|
// on the existing tests for the no-profile fast path.
|
|
//
|
|
// To exercise these, we need a Discover-able directory + a GetReg-cooperative
|
|
// server. Building the GetReg JWS-validate is heavy; we instead test the
|
|
// pre-GetReg failures (Discover failure modes) which exercise the early
|
|
// branches of authorizeOrderWithProfile.
|
|
|
|
// TestAuthorizeOrderWithProfile_DiscoveryFails pins the directory-fetch
|
|
// failure branch. We close the directory server before the call.
|
|
func TestAuthorizeOrderWithProfile_DiscoveryFails(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: url + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
Profile: "tlsserver",
|
|
})
|
|
|
|
_, err := c.authorizeOrderWithProfile(context.Background(),
|
|
[]goacme.AuthzID{{Type: "dns", Value: "example.com"}},
|
|
"tlsserver")
|
|
if err == nil {
|
|
t.Fatal("expected error when directory unreachable")
|
|
}
|
|
if !strings.Contains(err.Error(), "directory discovery failed") {
|
|
t.Errorf("error %q should wrap 'directory discovery failed'", err)
|
|
}
|
|
}
|
|
|
|
// TestAuthorizeOrderWithProfile_NoProfileFastPath confirms the fast-path
|
|
// (empty profile) delegates to client.AuthorizeOrder which fails on an
|
|
// unreachable directory with a different error wrap.
|
|
func TestAuthorizeOrderWithProfile_NoProfileFastPath(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: "http://127.0.0.1:1/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
})
|
|
|
|
_, err := c.authorizeOrderWithProfile(context.Background(),
|
|
[]goacme.AuthzID{{Type: "dns", Value: "example.com"}},
|
|
"") // empty profile → fast path
|
|
if err == nil {
|
|
t.Fatal("expected error when directory unreachable")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchNonce failure modes (helper used by profile flow)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFetchNonce_NoURL(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
_, err := c.fetchNonce(context.Background(), "")
|
|
if err == nil || !strings.Contains(err.Error(), "no nonce URL") {
|
|
t.Fatalf("expected 'no nonce URL' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchNonce_NoReplayHeader(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Don't set Replay-Nonce
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
_, err := c.fetchNonce(context.Background(), ts.URL)
|
|
if err == nil || !strings.Contains(err.Error(), "Replay-Nonce") {
|
|
t.Fatalf("expected Replay-Nonce error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchNonce_NetworkError(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
_, err := c.fetchNonce(context.Background(), url)
|
|
if err == nil || !strings.Contains(err.Error(), "nonce request failed") {
|
|
t.Fatalf("expected nonce request error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchNonce_HappyPath(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", "test-nonce-abc")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
nonce, err := c.fetchNonce(context.Background(), ts.URL)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if nonce != "test-nonce-abc" {
|
|
t.Errorf("nonce = %q; want 'test-nonce-abc'", nonce)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RevokeCertificate / GetCACertPEM / GenerateCRL / SignOCSPResponse —
|
|
// fallback / always-error paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestRevokeCertificate_UnwiredCertLookupFallback exercises the
|
|
// backward-compat branch in RevokeCertificate that fires when
|
|
// SetCertificateLookup was never called. Audit fix #7 replaced the
|
|
// historical "ACME revocation by serial not supported in V1" error
|
|
// with a more actionable one pointing at the wiring requirement; the
|
|
// production path always wires the lookup, so this branch only fires
|
|
// in tests / old wiring paths.
|
|
func TestRevokeCertificate_UnwiredCertLookupFallback(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"newOrder":"","newAccount":"","newNonce":""}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL,
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
})
|
|
// Intentionally do NOT call SetCertificateLookup — that's the
|
|
// behaviour under test.
|
|
|
|
reason := "keyCompromise"
|
|
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{
|
|
Serial: "ABC123",
|
|
Reason: &reason,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when CertificateLookup is unwired")
|
|
}
|
|
if !strings.Contains(err.Error(), "CertificateLookup") {
|
|
t.Errorf("error %q should reference CertificateLookup wiring", err)
|
|
}
|
|
}
|
|
|
|
// TestGetOrderStatus_EnsureClientFails confirms client-init failures
|
|
// propagate through GetOrderStatus.
|
|
func TestGetOrderStatus_EnsureClientFails(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "bad",
|
|
EABHmac: "!!!not-base64!!!",
|
|
}, testLogger())
|
|
|
|
_, err := c.GetOrderStatus(context.Background(), "order-id")
|
|
if err == nil {
|
|
t.Fatal("expected error when EAB decode fails during ensureClient")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME client init") {
|
|
t.Errorf("error %q should wrap 'ACME client init'", err)
|
|
}
|
|
}
|
|
|
|
// TestRenewCertificate_DelegatesToIssue confirms RenewCertificate goes
|
|
// through IssueCertificate and inherits its early-failure path
|
|
// (ensureClient fails → propagated). We use an EAB decode failure.
|
|
func TestRenewCertificate_DelegatesToIssue(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "bad",
|
|
EABHmac: "!!!not-base64!!!",
|
|
}, testLogger())
|
|
|
|
_, err := c.RenewCertificate(context.Background(), issuer.RenewalRequest{
|
|
CommonName: "example.com",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error to propagate from underlying IssueCertificate")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME client init") {
|
|
t.Errorf("error %q should wrap 'ACME client init'", err)
|
|
}
|
|
}
|
|
|
|
// TestIssueCertificate_EnsureClientFails confirms client-init failures
|
|
// propagate through IssueCertificate.
|
|
func TestIssueCertificate_EnsureClientFails(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "bad",
|
|
EABHmac: "!!!not-base64!!!",
|
|
}, testLogger())
|
|
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "example.com",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when EAB decode fails during ensureClient")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME client init") {
|
|
t.Errorf("error %q should wrap 'ACME client init'", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// startChallengeServer — covers the HTTP-01 challenge server path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestStartChallengeServer_ServesKnownToken(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
HTTPPort: 0, // ephemeral
|
|
}, testLogger())
|
|
|
|
// Pre-load a token
|
|
c.challengeMu.Lock()
|
|
c.challengeTokens["tok-abc"] = "key-auth-xyz"
|
|
c.challengeMu.Unlock()
|
|
|
|
// Use port 0 so the OS picks a free port. The Server is bound via
|
|
// net.Listen on the formatted addr; for port 0 the listener gets a real
|
|
// port. We invoke the function and shut down immediately.
|
|
srv, err := c.startChallengeServer()
|
|
if err != nil {
|
|
t.Skipf("could not bind challenge server (env may not allow): %v", err)
|
|
}
|
|
defer func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
}()
|
|
|
|
// The server is bound; we can't trivially address it because Addr is set
|
|
// to the formatted port string from cfg (":0"), and net.Listen returned a
|
|
// real addr we don't capture. So this test only proves the function
|
|
// returns without error and the goroutine starts. Functional verification
|
|
// of the handler is exercised below.
|
|
if srv == nil {
|
|
t.Fatal("expected non-nil server")
|
|
}
|
|
}
|
|
|
|
// TestChallengeHandler_KnownAndUnknownTokens exercises the http handler
|
|
// directly without binding a port, by replaying it through httptest.
|
|
func TestChallengeHandler_KnownAndUnknownTokens(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
HTTPPort: 1, // unused by this test
|
|
}, testLogger())
|
|
|
|
c.challengeMu.Lock()
|
|
c.challengeTokens["good-token"] = "key-auth-data"
|
|
c.challengeMu.Unlock()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, r *http.Request) {
|
|
token := r.URL.Path[len("/.well-known/acme-challenge/"):]
|
|
c.challengeMu.RLock()
|
|
keyAuth, ok := c.challengeTokens[token]
|
|
c.challengeMu.RUnlock()
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write([]byte(keyAuth))
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
// Known token
|
|
resp, err := http.Get(srv.URL + "/.well-known/acme-challenge/good-token")
|
|
if err != nil {
|
|
t.Fatalf("get good-token: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if string(body) != "key-auth-data" {
|
|
t.Errorf("body = %q; want 'key-auth-data'", string(body))
|
|
}
|
|
|
|
// Unknown token
|
|
resp, err = http.Get(srv.URL + "/.well-known/acme-challenge/missing")
|
|
if err != nil {
|
|
t.Fatalf("get missing: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 404 {
|
|
t.Errorf("status = %d; want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// presentPersistRecord — covers the dns-persist-01 helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPresentPersistRecord_NoSolver(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
// dnsSolver is nil
|
|
err := c.presentPersistRecord(context.Background(), "example.com", "tok", "value")
|
|
if err == nil || !strings.Contains(err.Error(), "DNS solver not configured") {
|
|
t.Fatalf("expected 'DNS solver not configured' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeDNSSolver implements DNSSolver for testing presentPersistRecord
|
|
// fallback path.
|
|
type fakeDNSSolver struct {
|
|
presentCalled bool
|
|
cleanupCalled bool
|
|
domain string
|
|
token string
|
|
keyAuth string
|
|
}
|
|
|
|
func (f *fakeDNSSolver) Present(ctx context.Context, domain, token, keyAuth string) error {
|
|
f.presentCalled = true
|
|
f.domain = domain
|
|
f.token = token
|
|
f.keyAuth = keyAuth
|
|
return nil
|
|
}
|
|
func (f *fakeDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
|
|
f.cleanupCalled = true
|
|
return nil
|
|
}
|
|
|
|
func TestPresentPersistRecord_FallbackToPresent(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
fake := &fakeDNSSolver{}
|
|
c.dnsSolver = fake
|
|
|
|
err := c.presentPersistRecord(context.Background(), "example.com", "tok123", "letsencrypt.org; accounturi=acct-uri")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !fake.presentCalled {
|
|
t.Error("expected fallback Present to be called for non-ScriptDNSSolver")
|
|
}
|
|
if fake.domain != "example.com" || fake.token != "tok123" {
|
|
t.Errorf("Present args: domain=%q token=%q", fake.domain, fake.token)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computeARICertID additional cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestComputeARICertID_ValidPEM(t *testing.T) {
|
|
pemStr := makeTestCertPEM(t)
|
|
id, err := computeARICertID(pemStr)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if id == "" {
|
|
t.Error("expected non-empty cert ID")
|
|
}
|
|
// The ID should be base64url-no-padding (so no '=' or '+' or '/')
|
|
if strings.ContainsAny(id, "=+/") {
|
|
t.Errorf("cert ID %q should be base64url-no-padding", id)
|
|
}
|
|
}
|
|
|
|
// TestComputeARICertID_DeterministicForSameInput pins idempotency.
|
|
func TestComputeARICertID_DeterministicForSameInput(t *testing.T) {
|
|
pemStr := makeTestCertPEM(t)
|
|
id1, err1 := computeARICertID(pemStr)
|
|
id2, err2 := computeARICertID(pemStr)
|
|
if err1 != nil || err2 != nil {
|
|
t.Fatalf("err1=%v err2=%v", err1, err2)
|
|
}
|
|
if id1 != id2 {
|
|
t.Errorf("cert ID not deterministic: %q vs %q", id1, id2)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchZeroSSLEAB additional success-shape variations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFetchZeroSSLEAB_SuccessFalse(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"success":false,"error":"throttled","eab_kid":"","eab_hmac_key":""}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil || !strings.Contains(err.Error(), "EAB generation failed") {
|
|
t.Fatalf("expected 'EAB generation failed', got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "throttled") {
|
|
t.Errorf("error %q should include upstream message 'throttled'", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// preWiredConnector smoke — confirms the fixture works as expected
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPreWiredConnector_ShortCircuitsEnsureClient(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
})
|
|
// ensureClient should be a no-op
|
|
if err := c.ensureClient(context.Background()); err != nil {
|
|
t.Errorf("expected pre-wired ensureClient to no-op, got: %v", err)
|
|
}
|
|
if c.client == nil {
|
|
t.Error("client should remain set")
|
|
}
|
|
if c.accountKey == nil {
|
|
t.Error("accountKey should remain set")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defense-in-depth: error messages must NOT leak HMAC key bytes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestErrorPaths_DoNotLeakHMACKey is a defense-in-depth grep over a sampling
|
|
// of error returns. The HMAC key is base64url-decoded into a []byte and
|
|
// attached to the account; if any wrapped error accidentally serialized the
|
|
// key, this test would catch it.
|
|
func TestErrorPaths_DoNotLeakHMACKey(t *testing.T) {
|
|
// Use a known HMAC key + capture its base64url form
|
|
rawKey := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
|
hmacB64 := "AQIDBAUGBwg" // base64url-no-padding of rawKey (8 bytes -> 11 chars)
|
|
c := New(&Config{
|
|
DirectoryURL: "https://127.0.0.1:1/directory", // unreachable
|
|
Email: "test@example.com",
|
|
EABKid: "kid-abc",
|
|
EABHmac: hmacB64,
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
// We don't care about the error type — only that the message doesn't
|
|
// contain any byte of the raw key (or its base64url form, since the
|
|
// b64 form is already committed to logs/errors as a kid in some places
|
|
// and may surface; we ban the raw byte sequence specifically).
|
|
if err == nil {
|
|
// If success (e.g. server reachable somehow), nothing to verify
|
|
return
|
|
}
|
|
// Convert raw key to a string and search; this is a very weak sanity
|
|
// check (random byte values may coincidentally appear), but the byte
|
|
// sequence is short and specific enough for this defense check.
|
|
for _, b := range rawKey {
|
|
// Looking for the byte verbatim would catch a fmt.Sprintf("%v", key)
|
|
if strings.ContainsRune(err.Error(), rune(b)) && b > 0 && b < 0x20 {
|
|
// Control byte in error message → suspicious. A normal error
|
|
// message shouldn't contain raw control bytes.
|
|
t.Errorf("error message contains suspicious control byte %#x; possible HMAC key leak: %q", b, err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compile-time check that the issuer.Connector interface is implemented.
|
|
var _ issuer.Connector = (*Connector)(nil)
|
|
|
|
// Suppress unused-import warning on json (we may not use it in some paths).
|
|
var _ = json.Unmarshal
|