mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:51:30 +00:00
fe7e766510
M-004 — OCSP issuer binding (composite key):
The OCSP lookup path now binds (issuer_id, serial) as a composite key
rather than resolving by serial alone. CertificateRepository and
RevocationRepository gain GetByIssuerAndSerial methods; ca_operations.go
scopes both lookups by the issuer_id path param. When no managed cert
binds to that (issuer, serial) tuple, GetOCSPResponse constructs an
RFC 6960 §2.2 'unknown' response (CertStatus=2) instead of the prior
default 'good'. Short-lived cert exemption (profile TTL < 1h) is
preserved. Real repo errors (non-sql.ErrNoRows) fail closed with a log.
Regression coverage: internal/service/ca_operations_test.go
- TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer
- TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial
M-005 — Discovery Claim/Dismiss actor propagation:
DiscoveryService.ClaimDiscovered and DismissDiscovered now accept an
explicit 'actor string' parameter (propagation pattern mirrors
bulk_revocation.go / revocation_svc.go). The handler layer passes
resolveActor(r.Context()) — the named-key identity established by the
M-002 auth unification — and the service falls back to 'api' (the same
safe sentinel resolveActor uses when no auth context is present) only
when the caller passes an empty string. Never falls back to 'operator'.
Regression coverage: internal/service/discovery_test.go
- TestDiscoveryService_ClaimDiscovered_AuditActor
- TestDiscoveryService_DismissDiscovered_AuditActor
- TestDiscoveryService_ClaimDiscovered_EmptyActorFallsBackToAPI
- TestDiscoveryService_DismissDiscovered_EmptyActorFallsBackToAPI
Each new test asserts event.Actor matches the caller-supplied string (or
'api' on empty input) and explicitly asserts event.Actor != 'operator'
to lock in the historical fix intent.
Files:
internal/api/handler/discovery.go — pass resolveActor(ctx)
internal/api/handler/discovery_handler_test.go — updated call sites
internal/integration/lifecycle_test.go — updated mock wiring
internal/repository/interfaces.go — GetByIssuerAndSerial on
CertificateRepository +
RevocationRepository
internal/repository/postgres/certificate.go — composite key lookup
internal/service/ca_operations.go — (issuer_id, serial) scoping
internal/service/ca_operations_test.go — 2 new M-004 tests
internal/service/discovery.go — actor parameter + 'api' fallback
internal/service/discovery_test.go — 4 new M-005 tests
internal/service/shortlived_test.go — mock signature update
internal/service/testutil_test.go — mock GetByIssuerAndSerial
261 lines
8.3 KiB
Go
261 lines
8.3 KiB
Go
// Tests for CAOperationsSvc, the focused sub-service that handles CRL generation
|
|
// and OCSP response signing extracted from CertificateService (TICKET-007).
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// helper to create a CAOperationsSvc for testing
|
|
func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo) {
|
|
caSvc, revocationRepo, certRepo, _ := newCAOperationsSvcTestWithIssuer()
|
|
return caSvc, revocationRepo, certRepo
|
|
}
|
|
|
|
// newCAOperationsSvcTestWithIssuer also returns the mock issuer connector
|
|
// so tests can assert on the captured OCSPSignRequest.
|
|
func newCAOperationsSvcTestWithIssuer() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo, *mockIssuerConnector) {
|
|
revocationRepo := newMockRevocationRepository()
|
|
certRepo := newMockCertificateRepository()
|
|
profileRepo := newMockProfileRepository()
|
|
|
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
|
registry := NewIssuerRegistry(slog.Default())
|
|
issuer := &mockIssuerConnector{}
|
|
registry.Set("iss-local", issuer)
|
|
registry.Set("iss-other", &mockIssuerConnector{})
|
|
caSvc.SetIssuerRegistry(registry)
|
|
|
|
return caSvc, revocationRepo, certRepo, issuer
|
|
}
|
|
|
|
func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) {
|
|
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
|
|
|
|
// Add some revoked certificates to the repo
|
|
now := time.Now()
|
|
revocationRepo.Revocations = []*domain.CertificateRevocation{
|
|
{
|
|
SerialNumber: "SERIAL-001",
|
|
CertificateID: "cert-1",
|
|
IssuerID: "iss-local",
|
|
Reason: "keyCompromise",
|
|
RevokedAt: now.Add(-24 * time.Hour),
|
|
RevokedBy: "admin",
|
|
},
|
|
{
|
|
SerialNumber: "SERIAL-002",
|
|
CertificateID: "cert-2",
|
|
IssuerID: "iss-local",
|
|
Reason: "superseded",
|
|
RevokedAt: now.Add(-12 * time.Hour),
|
|
RevokedBy: "admin",
|
|
},
|
|
}
|
|
|
|
crl, err := caSvc.GenerateDERCRL(context.Background(), "iss-local")
|
|
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
if crl == nil {
|
|
t.Fatal("expected non-nil CRL")
|
|
}
|
|
|
|
if len(crl) == 0 {
|
|
t.Fatal("expected non-empty CRL")
|
|
}
|
|
|
|
t.Logf("DER CRL generated successfully: %d bytes", len(crl))
|
|
}
|
|
|
|
func TestCAOperationsSvc_GenerateDERCRL_EmptyCRL(t *testing.T) {
|
|
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
|
|
|
|
// No revoked certs for this issuer
|
|
revocationRepo.Revocations = []*domain.CertificateRevocation{}
|
|
|
|
crl, err := caSvc.GenerateDERCRL(context.Background(), "iss-local")
|
|
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
if crl == nil {
|
|
t.Fatal("expected non-nil CRL even when empty")
|
|
}
|
|
|
|
if len(crl) == 0 {
|
|
t.Fatal("expected non-empty CRL bytes (at least the CRL structure)")
|
|
}
|
|
|
|
t.Logf("Empty DER CRL generated successfully: %d bytes", len(crl))
|
|
}
|
|
|
|
func TestCAOperationsSvc_GetOCSPResponse_Good(t *testing.T) {
|
|
caSvc, _, certRepo := newCAOperationsSvcTest()
|
|
|
|
// Add a non-revoked certificate
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-ocsp-good",
|
|
CommonName: "good.example.com",
|
|
IssuerID: "iss-local",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
version := &domain.CertificateVersion{
|
|
ID: "ver-ocsp-good",
|
|
CertificateID: "cert-ocsp-good",
|
|
SerialNumber: "OCSP-GOOD-001",
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
certRepo.Versions["cert-ocsp-good"] = []*domain.CertificateVersion{version}
|
|
|
|
// Request OCSP response for good cert
|
|
resp, err := caSvc.GetOCSPResponse(context.Background(), "iss-local", "OCSP-GOOD-001")
|
|
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
if resp == nil || len(resp) == 0 {
|
|
t.Fatal("expected non-empty OCSP response for good cert")
|
|
}
|
|
|
|
t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
|
|
}
|
|
|
|
// TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer guards the M-004 fix:
|
|
// a cert with the queried serial exists but under a *different* issuer. Before
|
|
// the fix, OCSP fell through to "good" (CertStatus 0) because no revocation row
|
|
// matched the (issuer_id, serial) tuple. Per RFC 5280 §5.2.3 serials are unique
|
|
// only within a single issuer, and per RFC 6960 §2.2 unknown certs must report
|
|
// "unknown" (CertStatus 2), not "good".
|
|
func TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer(t *testing.T) {
|
|
caSvc, _, certRepo, issuer := newCAOperationsSvcTestWithIssuer()
|
|
|
|
// Real cert exists, but bound to iss-other (not iss-local).
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-cross-issuer",
|
|
CommonName: "cross.example.com",
|
|
IssuerID: "iss-other",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
certRepo.Versions["cert-cross-issuer"] = []*domain.CertificateVersion{{
|
|
ID: "ver-cross-issuer",
|
|
CertificateID: "cert-cross-issuer",
|
|
SerialNumber: "CROSS-ISSUER-001",
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
|
CreatedAt: time.Now(),
|
|
}}
|
|
|
|
// Query OCSP for iss-local + CROSS-ISSUER-001. The serial exists, but
|
|
// under iss-other — our JOIN-scoped lookup should return no match.
|
|
resp, err := caSvc.GetOCSPResponse(context.Background(), "iss-local", "CROSS-ISSUER-001")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if resp == nil || len(resp) == 0 {
|
|
t.Fatal("expected non-empty OCSP response")
|
|
}
|
|
|
|
if issuer.LastOCSPSignRequest == nil {
|
|
t.Fatal("expected SignOCSPResponse to be called")
|
|
}
|
|
if got, want := issuer.LastOCSPSignRequest.CertStatus, 2; got != want {
|
|
t.Errorf("CertStatus = %d, want %d (unknown) — cross-issuer lookup must not return good", got, want)
|
|
}
|
|
}
|
|
|
|
// TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial guards the M-004 fix
|
|
// for the "forged/guessed serial" case: no certificate exists at this
|
|
// (issuer_id, serial) tuple anywhere in inventory. Per RFC 6960 §2.2 we must
|
|
// report "unknown" (CertStatus 2), never "good" — returning good for a serial
|
|
// we never issued is a protocol violation that would allow an attacker to get
|
|
// certctl to vouch for a cert it never signed.
|
|
func TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial(t *testing.T) {
|
|
caSvc, _, _, issuer := newCAOperationsSvcTestWithIssuer()
|
|
|
|
// No cert rows added. Query for an arbitrary serial under iss-local.
|
|
resp, err := caSvc.GetOCSPResponse(context.Background(), "iss-local", "DEADBEEF-NEVER-ISSUED")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if resp == nil || len(resp) == 0 {
|
|
t.Fatal("expected non-empty OCSP response")
|
|
}
|
|
|
|
if issuer.LastOCSPSignRequest == nil {
|
|
t.Fatal("expected SignOCSPResponse to be called")
|
|
}
|
|
if got, want := issuer.LastOCSPSignRequest.CertStatus, 2; got != want {
|
|
t.Errorf("CertStatus = %d, want %d (unknown) — unissued serials must not return good", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCAOperationsSvc_GetOCSPResponse_Revoked(t *testing.T) {
|
|
caSvc, revocationRepo, certRepo := newCAOperationsSvcTest()
|
|
|
|
now := time.Now()
|
|
|
|
// Add a revoked certificate
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-ocsp-revoked",
|
|
CommonName: "revoked.example.com",
|
|
IssuerID: "iss-local",
|
|
Status: domain.CertificateStatusRevoked,
|
|
RevokedAt: &now,
|
|
RevocationReason: "keyCompromise",
|
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
version := &domain.CertificateVersion{
|
|
ID: "ver-ocsp-revoked",
|
|
CertificateID: "cert-ocsp-revoked",
|
|
SerialNumber: "OCSP-REVOKED-001",
|
|
NotBefore: time.Now().Add(-24 * time.Hour),
|
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
certRepo.Versions["cert-ocsp-revoked"] = []*domain.CertificateVersion{version}
|
|
|
|
// Add revocation record
|
|
revocationRepo.Revocations = []*domain.CertificateRevocation{
|
|
{
|
|
SerialNumber: "OCSP-REVOKED-001",
|
|
CertificateID: "cert-ocsp-revoked",
|
|
IssuerID: "iss-local",
|
|
Reason: "keyCompromise",
|
|
RevokedAt: now.Add(-24 * time.Hour),
|
|
RevokedBy: "admin",
|
|
},
|
|
}
|
|
|
|
// Request OCSP response for revoked cert
|
|
resp, err := caSvc.GetOCSPResponse(context.Background(), "iss-local", "OCSP-REVOKED-001")
|
|
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
if resp == nil || len(resp) == 0 {
|
|
t.Fatal("expected non-empty OCSP response for revoked cert")
|
|
}
|
|
|
|
t.Logf("OCSP response for revoked cert generated: %d bytes", len(resp))
|
|
}
|