mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:41:36 +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:
@@ -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