mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
acme: support serial-only revocation via local cert-version lookup
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.
This commit is contained in:
@@ -7,9 +7,11 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"golang.org/x/crypto/acme"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// Config represents the ACME issuer connector configuration.
|
||||
@@ -84,6 +87,28 @@ type Config struct {
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
}
|
||||
|
||||
// CertificateLookupRepo lets the ACME connector recover a previously-issued
|
||||
// certificate's PEM chain from the local cert store given only the serial.
|
||||
// RFC 8555 §7.6 requires the certificate DER bytes (not just the serial) on
|
||||
// the revoke wire — this interface bridges the gap so an operator who calls
|
||||
// RevokeCertificate with just a serial in hand (lost PEM, rotated key, GUI
|
||||
// revoke action) gets the same outcome as one who supplies the full DER.
|
||||
//
|
||||
// Defined at the connector boundary on purpose: the connector doesn't import
|
||||
// the service or repository packages — it accepts whatever satisfies this
|
||||
// shape. Production wiring in cmd/server/main.go injects the postgres
|
||||
// CertificateRepository (which has GetVersionBySerial); tests inject a fake.
|
||||
//
|
||||
// Audit fix #7.
|
||||
type CertificateLookupRepo interface {
|
||||
// GetVersionBySerial returns the certificate version (the row that
|
||||
// holds the PEM chain) whose SerialNumber matches the supplied
|
||||
// serial, scoped to the issuerID. Returns sql.ErrNoRows when no
|
||||
// match exists. Per RFC 5280 §5.2.3 serials are unique only within
|
||||
// a single issuer, so the scope is required.
|
||||
GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error)
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||
// (Let's Encrypt, Sectigo, ZeroSSL, etc.).
|
||||
//
|
||||
@@ -104,6 +129,35 @@ type Connector struct {
|
||||
|
||||
// DNS-01 challenge solver (nil if using HTTP-01)
|
||||
dnsSolver DNSSolver
|
||||
|
||||
// issuerID + certLookup are wired by the registry's Rebuild via
|
||||
// SetIssuerID + SetCertificateLookup. When certLookup is nil, the
|
||||
// serial-only revoke path falls back to the legacy "not supported"
|
||||
// error so old wiring paths keep their behaviour. Audit fix #7.
|
||||
issuerID string
|
||||
certLookup CertificateLookupRepo
|
||||
}
|
||||
|
||||
// SetIssuerID records the issuer ID so the serial-only revoke path can
|
||||
// scope the cert-version lookup correctly. Per RFC 5280 §5.2.3 serial
|
||||
// numbers are only unique within a single issuer, so the scope is
|
||||
// required for the lookup to be deterministic. Mirrors the existing
|
||||
// SetIssuerID setter on local.Connector.
|
||||
//
|
||||
// Audit fix #7.
|
||||
func (c *Connector) SetIssuerID(id string) {
|
||||
c.issuerID = id
|
||||
}
|
||||
|
||||
// SetCertificateLookup wires the cert-version lookup so the ACME
|
||||
// connector can recover the leaf-cert PEM (and thus the DER bytes
|
||||
// needed by RFC 8555 §7.6) from a serial-only revoke request. nil
|
||||
// means revoke-by-serial is not supported (the historical V1
|
||||
// behaviour, preserved for old wiring paths).
|
||||
//
|
||||
// Audit fix #7.
|
||||
func (c *Connector) SetCertificateLookup(repo CertificateLookupRepo) {
|
||||
c.certLookup = repo
|
||||
}
|
||||
|
||||
// New creates a new ACME connector with the given configuration and logger.
|
||||
@@ -515,20 +569,146 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at the ACME CA.
|
||||
// RevokeCertificate revokes a certificate at the ACME CA. RFC 8555 §7.6
|
||||
// requires the certificate DER bytes (not just the serial) on the revoke
|
||||
// wire — 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 method recovers the leaf-cert DER by looking the serial up in
|
||||
// the local cert-version store (CertificateLookupRepo, wired by the
|
||||
// registry's Rebuild), decoding the PEM chain into DER, and calling
|
||||
// golang.org/x/crypto/acme.Client.RevokeCert with (accountKey, der,
|
||||
// reasonCode). The reason is mapped from the RFC 5280 string in the
|
||||
// request via mapRevocationReason; nil reason maps to 0 (unspecified).
|
||||
//
|
||||
// Audit fix #7. Pre-fix this returned the literal error
|
||||
// "ACME revocation by serial not supported in V1; provide certificate
|
||||
// DER" which made GUI-driven revoke unusable for ACME-issued certs.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing ACME revocation request", "serial", request.Serial)
|
||||
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return fmt.Errorf("ACME client init: %w", err)
|
||||
if c.certLookup == nil {
|
||||
// Backward-compat fallback. Only fires in test paths or old
|
||||
// wiring where SetCertificateLookup was not called. The audit
|
||||
// mandates the lookup wire as the production path; this is
|
||||
// retained for the test cases that build the connector
|
||||
// directly without the registry.
|
||||
return fmt.Errorf("ACME revocation by serial requires CertificateLookup wiring; call SetCertificateLookup")
|
||||
}
|
||||
|
||||
// ACME revocation requires the certificate DER, not just the serial.
|
||||
// For now, log a warning. Full revocation requires storing the cert DER
|
||||
// or re-fetching it from the order.
|
||||
c.logger.Warn("ACME revocation requires certificate DER bytes; serial-only revocation not supported in V1",
|
||||
"serial", request.Serial)
|
||||
return fmt.Errorf("ACME revocation by serial not supported in V1; provide certificate DER")
|
||||
if c.issuerID == "" {
|
||||
// Same backward-compat reasoning. The registry calls
|
||||
// SetIssuerID alongside SetCertificateLookup; both are
|
||||
// required for the lookup to be deterministic per RFC 5280
|
||||
// §5.2.3.
|
||||
return fmt.Errorf("ACME revocation by serial requires issuer ID wiring; call SetIssuerID")
|
||||
}
|
||||
|
||||
version, err := c.certLookup.GetVersionBySerial(ctx, c.issuerID, request.Serial)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("ACME revoke: no local cert with serial %s for issuer %s (cert may not have been issued through certctl)", request.Serial, c.issuerID)
|
||||
}
|
||||
return fmt.Errorf("ACME revoke: cert version lookup: %w", err)
|
||||
}
|
||||
|
||||
if version == nil || version.PEMChain == "" {
|
||||
return fmt.Errorf("ACME revoke: local cert version row has empty PEM chain (corrupt row?) — serial=%s", request.Serial)
|
||||
}
|
||||
|
||||
// PEMChain is "leaf cert PEM\nchain PEMs..."; we only need the
|
||||
// leaf for the ACME revoke wire. pem.Decode returns the FIRST
|
||||
// block, which is exactly the leaf, then leaves the rest in the
|
||||
// trailing slice (which we discard).
|
||||
block, _ := pem.Decode([]byte(version.PEMChain))
|
||||
if block == nil {
|
||||
return fmt.Errorf("ACME revoke: cert version PEM malformed — no PEM block found in chain (serial=%s)", request.Serial)
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return fmt.Errorf("ACME revoke: cert version PEM has unexpected block type %q (expected CERTIFICATE, serial=%s)", block.Type, request.Serial)
|
||||
}
|
||||
der := block.Bytes
|
||||
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return fmt.Errorf("ACME revoke: client init: %w", err)
|
||||
}
|
||||
|
||||
reasonCode, err := mapRevocationReason(request.Reason)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME revoke: %w", err)
|
||||
}
|
||||
|
||||
// golang.org/x/crypto/acme.Client.RevokeCert authenticates the
|
||||
// revoke with the supplied account key (RFC 8555 §7.6 case 1,
|
||||
// "revocation request signed with account key"). The same account
|
||||
// key issued the cert, so this path covers all certctl-issued
|
||||
// ACME certs. Revocation via the cert's private key is the
|
||||
// alternative auth path (RFC 8555 §7.6 case 2) and is out of
|
||||
// scope here.
|
||||
c.logger.Info("ACME revoke: issuing RevokeCert", "serial", request.Serial, "reason_code", reasonCode)
|
||||
if err := c.client.RevokeCert(ctx, c.accountKey, der, reasonCode); err != nil {
|
||||
return fmt.Errorf("ACME RevokeCert: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("ACME certificate revoked", "serial", request.Serial)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapRevocationReason translates an RFC 5280 §5.3.1 reason string (as
|
||||
// it appears in a RevocationRequest from the certctl service layer)
|
||||
// into the integer reason code that
|
||||
// golang.org/x/crypto/acme.CRLReasonCode expects. Codes match RFC 5280 §5.3.1: 0 unspecified,
|
||||
// 1 keyCompromise, 2 cACompromise, 3 affiliationChanged, 4 superseded,
|
||||
// 5 cessationOfOperation, 6 certificateHold, 8 removeFromCRL,
|
||||
// 9 privilegeWithdrawn, 10 aACompromise. (7 is reserved.)
|
||||
//
|
||||
// A nil reason maps to 0 (unspecified) per RFC 5280 §5.3.1's "if the
|
||||
// reason code extension is absent the reason is unspecified". An
|
||||
// unknown reason string returns an error rather than silently mapping
|
||||
// to unspecified — operators rely on the reason for compliance
|
||||
// reporting (PCI-DSS / HIPAA) and a silent demotion would obscure a
|
||||
// real bug.
|
||||
//
|
||||
// Accepted forms: the canonical RFC 5280 camelCase ("keyCompromise"),
|
||||
// underscore_lower ("key_compromise"), and ALL_CAPS_UNDERSCORE
|
||||
// ("KEY_COMPROMISE"). The certctl revocation service emits the
|
||||
// camelCase form today, but the more relaxed parsing makes it
|
||||
// trivially safe for operators typing reasons via the API.
|
||||
//
|
||||
// Audit fix #7.
|
||||
func mapRevocationReason(reason *string) (acme.CRLReasonCode, error) {
|
||||
if reason == nil || *reason == "" {
|
||||
return acme.CRLReasonUnspecified, nil
|
||||
}
|
||||
// Normalise: lowercase, strip underscores. "keyCompromise",
|
||||
// "key_compromise", "KEY_COMPROMISE" all collapse to
|
||||
// "keycompromise" and match.
|
||||
normalized := strings.ToLower(strings.ReplaceAll(*reason, "_", ""))
|
||||
switch normalized {
|
||||
case "unspecified":
|
||||
return acme.CRLReasonUnspecified, nil
|
||||
case "keycompromise":
|
||||
return acme.CRLReasonKeyCompromise, nil
|
||||
case "cacompromise":
|
||||
return acme.CRLReasonCACompromise, nil
|
||||
case "affiliationchanged":
|
||||
return acme.CRLReasonAffiliationChanged, nil
|
||||
case "superseded":
|
||||
return acme.CRLReasonSuperseded, nil
|
||||
case "cessationofoperation":
|
||||
return acme.CRLReasonCessationOfOperation, nil
|
||||
case "certificatehold":
|
||||
return acme.CRLReasonCertificateHold, nil
|
||||
case "removefromcrl":
|
||||
return acme.CRLReasonRemoveFromCRL, nil
|
||||
case "privilegewithdrawn":
|
||||
return acme.CRLReasonPrivilegeWithdrawn, nil
|
||||
case "aacompromise":
|
||||
return acme.CRLReasonAACompromise, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown revocation reason %q (expected one of: unspecified, keyCompromise, cACompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aACompromise)", *reason)
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrderStatus retrieves the current status of an ACME order.
|
||||
|
||||
@@ -563,10 +563,17 @@ func TestFetchNonce_HappyPath(t *testing.T) {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RevokeCertificate / GetCACertPEM / GenerateCRL / SignOCSPResponse —
|
||||
// always-error paths
|
||||
// fallback / always-error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRevokeCertificate_AlwaysError(t *testing.T) {
|
||||
// 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":""}`)
|
||||
@@ -578,17 +585,19 @@ func TestRevokeCertificate_AlwaysError(t *testing.T) {
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
})
|
||||
// Intentionally do NOT call SetCertificateLookup — that's the
|
||||
// behaviour under test.
|
||||
|
||||
reason := "key compromise"
|
||||
reason := "keyCompromise"
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{
|
||||
Serial: "ABC123",
|
||||
Reason: &reason,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from V1 ACME revocation")
|
||||
t.Fatal("expected error when CertificateLookup is unwired")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not supported") {
|
||||
t.Errorf("error %q should mention 'not supported'", err)
|
||||
if !strings.Contains(err.Error(), "CertificateLookup") {
|
||||
t.Errorf("error %q should reference CertificateLookup wiring", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package acme
|
||||
|
||||
// Audit fix #7 — serial-only ACME revocation tests.
|
||||
//
|
||||
// The happy path (issue → revoke-by-serial against a real ACME server)
|
||||
// is covered by the pebble integration test in pebble_mock_test.go's
|
||||
// follow-up; this file pins the failure-mode branches and the pure
|
||||
// mapRevocationReason translation.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// fakeCertLookup implements CertificateLookupRepo for tests. The two
|
||||
// fields control the GetVersionBySerial behavior; tests set them per
|
||||
// scenario.
|
||||
type fakeCertLookup struct {
|
||||
version *domain.CertificateVersion
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCertLookup) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
|
||||
return f.version, f.err
|
||||
}
|
||||
|
||||
// newConnectorForRevoke builds an ACME connector pre-wired for a
|
||||
// revoke test. The cert-lookup is set to the supplied fake; the
|
||||
// issuer ID is "iss-test" unless cleared by the caller.
|
||||
func newConnectorForRevoke(t *testing.T, lookup CertificateLookupRepo) *Connector {
|
||||
t.Helper()
|
||||
c := New(&Config{
|
||||
DirectoryURL: "https://acme.example.test/dir",
|
||||
Email: "ops@example.com",
|
||||
}, testLogger())
|
||||
c.SetIssuerID("iss-test")
|
||||
c.SetCertificateLookup(lookup)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_NoCertLookupWired(t *testing.T) {
|
||||
c := New(&Config{DirectoryURL: "https://x.test/dir", Email: "a@b"}, testLogger())
|
||||
// Intentionally NOT calling SetCertificateLookup — exercises the
|
||||
// backward-compat fallback for tests/old wiring paths.
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "AB:CD"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when CertificateLookup is unwired")
|
||||
}
|
||||
if !contains(err.Error(), "CertificateLookup") {
|
||||
t.Errorf("expected wiring-error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_NoIssuerIDWired(t *testing.T) {
|
||||
c := New(&Config{DirectoryURL: "https://x.test/dir", Email: "a@b"}, testLogger())
|
||||
c.SetCertificateLookup(&fakeCertLookup{})
|
||||
// Skip SetIssuerID — exercises the second backward-compat guard.
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "AB:CD"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when issuer ID is unwired")
|
||||
}
|
||||
if !contains(err.Error(), "issuer ID") {
|
||||
t.Errorf("expected issuer-ID-error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_LookupReturnsNotFound(t *testing.T) {
|
||||
c := newConnectorForRevoke(t, &fakeCertLookup{err: sql.ErrNoRows})
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "DEAD:BEEF"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when lookup returns ErrNoRows")
|
||||
}
|
||||
// Operator-facing error must mention serial + suggest the cert
|
||||
// wasn't issued through certctl.
|
||||
if !contains(err.Error(), "DEAD:BEEF") {
|
||||
t.Errorf("expected error to include serial, got: %v", err)
|
||||
}
|
||||
if !contains(err.Error(), "may not have been issued through certctl") {
|
||||
t.Errorf("expected operator-facing hint about cert not in local store, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_LookupArbitraryError(t *testing.T) {
|
||||
c := newConnectorForRevoke(t, &fakeCertLookup{err: errors.New("connection refused")})
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "AB:CD"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate")
|
||||
}
|
||||
if !contains(err.Error(), "connection refused") {
|
||||
t.Errorf("expected wrapped repo error, got: %v", err)
|
||||
}
|
||||
if !contains(err.Error(), "lookup") {
|
||||
t.Errorf("expected 'lookup' framing in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_VersionPEMEmpty(t *testing.T) {
|
||||
c := newConnectorForRevoke(t, &fakeCertLookup{
|
||||
version: &domain.CertificateVersion{
|
||||
SerialNumber: "AB:CD",
|
||||
PEMChain: "",
|
||||
},
|
||||
})
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "AB:CD"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when version row has empty PEMChain")
|
||||
}
|
||||
if !contains(err.Error(), "empty PEM chain") {
|
||||
t.Errorf("expected empty-PEM error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_PEMMalformed_NoBlock(t *testing.T) {
|
||||
c := newConnectorForRevoke(t, &fakeCertLookup{
|
||||
version: &domain.CertificateVersion{
|
||||
SerialNumber: "AB:CD",
|
||||
PEMChain: "this is not a PEM block at all",
|
||||
},
|
||||
})
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "AB:CD"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PEM chain has no decodable block")
|
||||
}
|
||||
if !contains(err.Error(), "no PEM block") {
|
||||
t.Errorf("expected no-PEM-block error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCertificate_PEMMalformed_WrongType(t *testing.T) {
|
||||
// A valid PEM block, but type is PRIVATE KEY — must be rejected
|
||||
// as "expected CERTIFICATE".
|
||||
pemPrivKey := "-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBgkqhkiG9w0BAQE=\n-----END PRIVATE KEY-----\n"
|
||||
c := newConnectorForRevoke(t, &fakeCertLookup{
|
||||
version: &domain.CertificateVersion{
|
||||
SerialNumber: "AB:CD",
|
||||
PEMChain: pemPrivKey,
|
||||
},
|
||||
})
|
||||
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{Serial: "AB:CD"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PEM block type is not CERTIFICATE")
|
||||
}
|
||||
if !contains(err.Error(), "PRIVATE KEY") {
|
||||
t.Errorf("expected error to mention the actual block type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapRevocationReason_TableDriven covers the full RFC 5280 §5.3.1
|
||||
// reason set plus the canonical / underscore / ALL-CAPS spelling
|
||||
// variants and the unknown-reason and nil-reason behaviors.
|
||||
func TestMapRevocationReason_TableDriven(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
cases := []struct {
|
||||
name string
|
||||
reason *string
|
||||
want acme.CRLReasonCode
|
||||
wantErr bool
|
||||
}{
|
||||
// Nil → unspecified. RFC 5280 §5.3.1: "if the reason code
|
||||
// extension is absent the reason is unspecified".
|
||||
{"nil_reason_unspecified", nil, acme.CRLReasonUnspecified, false},
|
||||
{"empty_string_unspecified", str(""), acme.CRLReasonUnspecified, false},
|
||||
|
||||
// Canonical RFC 5280 camelCase.
|
||||
{"camel_unspecified", str("unspecified"), acme.CRLReasonUnspecified, false},
|
||||
{"camel_keyCompromise", str("keyCompromise"), acme.CRLReasonKeyCompromise, false},
|
||||
{"camel_cACompromise", str("cACompromise"), acme.CRLReasonCACompromise, false},
|
||||
{"camel_affiliationChanged", str("affiliationChanged"), acme.CRLReasonAffiliationChanged, false},
|
||||
{"camel_superseded", str("superseded"), acme.CRLReasonSuperseded, false},
|
||||
{"camel_cessationOfOperation", str("cessationOfOperation"), acme.CRLReasonCessationOfOperation, false},
|
||||
{"camel_certificateHold", str("certificateHold"), acme.CRLReasonCertificateHold, false},
|
||||
{"camel_removeFromCRL", str("removeFromCRL"), acme.CRLReasonRemoveFromCRL, false},
|
||||
{"camel_privilegeWithdrawn", str("privilegeWithdrawn"), acme.CRLReasonPrivilegeWithdrawn, false},
|
||||
{"camel_aACompromise", str("aACompromise"), acme.CRLReasonAACompromise, false},
|
||||
|
||||
// underscore_lower.
|
||||
{"underscore_key_compromise", str("key_compromise"), acme.CRLReasonKeyCompromise, false},
|
||||
{"underscore_ca_compromise", str("ca_compromise"), acme.CRLReasonCACompromise, false},
|
||||
|
||||
// ALL_CAPS_UNDERSCORE.
|
||||
{"caps_KEY_COMPROMISE", str("KEY_COMPROMISE"), acme.CRLReasonKeyCompromise, false},
|
||||
{"caps_REMOVE_FROM_CRL", str("REMOVE_FROM_CRL"), acme.CRLReasonRemoveFromCRL, false},
|
||||
|
||||
// Unknown — must error rather than silently demote.
|
||||
{"unknown_reason_errors", str("totallyMadeUp"), 0, true},
|
||||
{"reserved_code_7_unhandled", str("reserved"), 0, true}, // Reserved per RFC 5280, no canonical name.
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := mapRevocationReason(tc.reason)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
if !tc.wantErr && got != tc.want {
|
||||
t.Errorf("got code %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// contains is a tiny helper to avoid pulling strings into every test.
|
||||
func contains(haystack, needle string) bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -619,6 +619,22 @@ func (m *mockCertificateRepository) GetByIssuerAndSerial(ctx context.Context, is
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// GetVersionBySerial mirrors GetByIssuerAndSerial but returns the version
|
||||
// row (where the PEM lives) — used by the ACME serial-only revoke path.
|
||||
func (m *mockCertificateRepository) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
|
||||
for _, cert := range m.certs {
|
||||
if cert.IssuerID != issuerID {
|
||||
continue
|
||||
}
|
||||
for _, v := range m.versions[cert.ID] {
|
||||
if v.SerialNumber == serial {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
type mockJobRepository struct {
|
||||
jobs map[string]*domain.Job
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ type CertificateRepository interface {
|
||||
// issuer. Returns sql.ErrNoRows when no match exists so callers can
|
||||
// distinguish "unknown cert" from a real repository error.
|
||||
GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error)
|
||||
// GetVersionBySerial retrieves the certificate_versions row whose
|
||||
// serial_number matches, scoped to the issuer via a JOIN on
|
||||
// managed_certificates. Returns the version (PEMChain, NotBefore,
|
||||
// NotAfter, etc.) — used by the ACME serial-only revoke path to
|
||||
// recover the leaf-cert DER when the operator no longer has the
|
||||
// PEM in hand. Returns sql.ErrNoRows when no match exists.
|
||||
GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error)
|
||||
}
|
||||
|
||||
// RevocationRepository defines operations for managing certificate revocations.
|
||||
|
||||
@@ -608,6 +608,47 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// GetVersionBySerial returns the certificate_versions row whose serial_number
|
||||
// matches the supplied serial, scoped to the issuer via a JOIN on
|
||||
// managed_certificates. Returns sql.ErrNoRows when no match exists so callers
|
||||
// can distinguish "unknown cert" from a real repository error.
|
||||
//
|
||||
// Callers needing this method are protocol endpoints that carry the issuer
|
||||
// in the request path (ACME revoke-by-serial, OCSP, CRL generation); per RFC
|
||||
// 5280 §5.2.3 serial numbers are unique only within a single issuer, so a
|
||||
// serial-only lookup would be ambiguous across issuers.
|
||||
//
|
||||
// Audit fix #7: ACME revoke-by-serial uses this to recover the leaf cert PEM
|
||||
// when the operator only has a serial in hand.
|
||||
func (r *CertificateRepository) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
|
||||
var v domain.CertificateVersion
|
||||
var csrPEM sql.NullString
|
||||
var keyAlgo sql.NullString
|
||||
var keySize sql.NullInt64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT cv.id, cv.certificate_id, cv.serial_number, cv.not_before, cv.not_after,
|
||||
cv.fingerprint_sha256, cv.pem_chain, cv.csr_pem, cv.key_algorithm, cv.key_size, cv.created_at
|
||||
FROM certificate_versions cv
|
||||
JOIN managed_certificates mc ON mc.id = cv.certificate_id
|
||||
WHERE mc.issuer_id = $1 AND cv.serial_number = $2
|
||||
ORDER BY cv.created_at DESC
|
||||
LIMIT 1
|
||||
`, issuerID, serial).Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &keyAlgo, &keySize, &v.CreatedAt)
|
||||
v.CSRPEM = csrPEM.String
|
||||
v.KeyAlgorithm = keyAlgo.String
|
||||
v.KeySize = int(keySize.Int64)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get certificate version by issuer+serial: %w", err)
|
||||
}
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// GetLatestVersion returns the most recent certificate version for a certificate.
|
||||
func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
||||
var v domain.CertificateVersion
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
|
||||
"github.com/shankar0123/certctl/internal/crypto"
|
||||
@@ -38,6 +39,14 @@ type IssuerRegistry struct {
|
||||
// the per-issuer-type counter + histogram + failure tables.
|
||||
// Closes the #4 audit-readiness blocker (per-issuer-type metrics).
|
||||
metrics *IssuanceMetrics
|
||||
|
||||
// acmeCertLookup — when set, every freshly-constructed
|
||||
// *acme.Connector is wired with SetCertificateLookup + SetIssuerID
|
||||
// so its serial-only revoke path can recover the leaf-cert DER
|
||||
// from the local cert store. Closes the #7 audit-readiness blocker.
|
||||
// Nil leaves the legacy "ACME revocation by serial requires
|
||||
// CertificateLookup wiring" error in place for old wiring paths.
|
||||
acmeCertLookup acme.CertificateLookupRepo
|
||||
}
|
||||
|
||||
// LocalIssuerDeps groups the optional dependencies that the local
|
||||
@@ -83,6 +92,24 @@ func (r *IssuerRegistry) SetIssuanceMetrics(m *IssuanceMetrics) {
|
||||
r.metrics = m
|
||||
}
|
||||
|
||||
// SetACMECertLookup wires the cert-version lookup repo for every
|
||||
// *acme.Connector constructed by Rebuild. The lookup is used by the
|
||||
// serial-only revoke path (RevokeCertificate) to recover the leaf-
|
||||
// cert DER bytes from the local cert store; without it, ACME
|
||||
// RevokeCertificate falls back to the legacy V1 "not supported"
|
||||
// error. Closes the #7 audit-readiness blocker.
|
||||
//
|
||||
// Wire on the registry, not per-call: the registry already owns the
|
||||
// post-factory wiring step (mirrors SetLocalIssuerDeps), and the same
|
||||
// repo serves every ACME connector regardless of issuer ID — the
|
||||
// connector scopes its own lookups via the issuer ID injected by
|
||||
// SetIssuerID inside Rebuild.
|
||||
func (r *IssuerRegistry) SetACMECertLookup(repo acme.CertificateLookupRepo) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.acmeCertLookup = repo
|
||||
}
|
||||
|
||||
// Get returns the issuer connector for the given ID and whether it exists.
|
||||
func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) {
|
||||
r.mu.RLock()
|
||||
@@ -189,6 +216,18 @@ func (r *IssuerRegistry) Rebuild(ctx context.Context, configs []*domain.Issuer,
|
||||
"key_dir", r.localDeps.KeyDir)
|
||||
}
|
||||
|
||||
// Audit fix #7: when the cert-version lookup is configured on
|
||||
// the registry, inject it into every freshly-constructed
|
||||
// *acme.Connector so its serial-only revoke path can recover
|
||||
// the leaf-cert DER bytes. SetIssuerID is paired so the lookup
|
||||
// can scope by issuer per RFC 5280 §5.2.3.
|
||||
if acmeConn, ok := connector.(*acme.Connector); ok && r.acmeCertLookup != nil {
|
||||
acmeConn.SetIssuerID(cfg.ID)
|
||||
acmeConn.SetCertificateLookup(r.acmeCertLookup)
|
||||
r.logger.Info("ACME issuer wired with cert-version lookup for serial-only revoke",
|
||||
"id", cfg.ID)
|
||||
}
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(connector)
|
||||
// Wire per-issuer-type metrics (audit fix #4) when SetIssuanceMetrics
|
||||
// was called. The adapter is the IssuerConnector interface; type-
|
||||
|
||||
@@ -214,6 +214,10 @@ func (m *mockCertRepoWithGetError) GetByIssuerAndSerial(ctx context.Context, iss
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockCertRepoWithGetError) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockCertRepoWithGetError) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
|
||||
return nil, m.GetExpiringCertificatesErr
|
||||
}
|
||||
|
||||
@@ -168,6 +168,22 @@ func (m *mockCertRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, seria
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// GetVersionBySerial mirrors GetByIssuerAndSerial but returns the version
|
||||
// row — exists to support the ACME serial-only revoke path tests.
|
||||
func (m *mockCertRepo) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
|
||||
for _, cert := range m.Certs {
|
||||
if cert.IssuerID != issuerID {
|
||||
continue
|
||||
}
|
||||
for _, v := range m.Versions[cert.ID] {
|
||||
if v.SerialNumber == serial {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
|
||||
m.Certs[cert.ID] = cert
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user