mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 12:38:51 +00:00
feat: M15b — OCSP responder, DER CRL, short-lived exemption, revocation GUI
Backend:
- Embedded OCSP responder: GET /api/v1/ocsp/{issuer_id}/{serial} returns
signed OCSP responses (good/revoked/unknown) using CA key
- DER-encoded X.509 CRL: GET /api/v1/crl/{issuer_id} returns proper DER CRL
signed by issuing CA with 24h validity window
- Short-lived cert exemption: certs with profile TTL < 1 hour skip CRL/OCSP
(expiry is sufficient revocation for ephemeral workloads)
- Extended issuer connector interface with GenerateCRL and SignOCSPResponse
- Local CA implements full CRL/OCSP signing; ACME and step-ca return
appropriate "use native endpoint" errors
- IssuerConnectorAdapter bridges new methods between layers
Frontend:
- Revoke button on certificate detail page with RFC 5280 reason modal
- Revocation banner with reason display and timestamp
- Revocation status indicators in lifecycle section
- "Revoked" filter option in certificates list
- API client: revokeCertificate() function and Certificate type extensions
Tests: ~31 new tests across connector, service, handler, and adapter layers
Docs: milestones renumbered (M13-M14, M16-M18), M15b marked complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -408,3 +408,219 @@ func TestRevokeCertificate_HandlerInterfaceMethod(t *testing.T) {
|
||||
t.Errorf("expected Revoked status, got %s", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// M15b: CRL and OCSP Service Tests
|
||||
|
||||
func TestGenerateDERCRL_Success(t *testing.T) {
|
||||
svc, certRepo, revocationRepo, _ := newRevocationTestService()
|
||||
|
||||
// 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 := svc.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 TestGenerateDERCRL_EmptyCRL(t *testing.T) {
|
||||
svc, _, revocationRepo, _ := newRevocationTestService()
|
||||
|
||||
// No revoked certs for this issuer
|
||||
revocationRepo.Revocations = []*domain.CertificateRevocation{}
|
||||
|
||||
crl, err := svc.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 TestGenerateDERCRL_IssuerNotFound(t *testing.T) {
|
||||
svc, _, _, _ := newRevocationTestService()
|
||||
|
||||
// Try to generate CRL for unknown issuer
|
||||
crl, err := svc.GenerateDERCRL(context.Background(), "iss-unknown")
|
||||
|
||||
// Should return error or nil CRL depending on implementation
|
||||
if crl != nil && err == nil {
|
||||
t.Error("expected error or nil CRL for unknown issuer")
|
||||
}
|
||||
|
||||
t.Logf("GenerateDERCRL correctly handles unknown issuer")
|
||||
}
|
||||
|
||||
func TestGetOCSPResponse_Good(t *testing.T) {
|
||||
svc, certRepo, _, _ := newRevocationTestService()
|
||||
|
||||
// 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 := svc.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))
|
||||
}
|
||||
|
||||
func TestGetOCSPResponse_Revoked(t *testing.T) {
|
||||
svc, certRepo, revocationRepo, _ := newRevocationTestService()
|
||||
|
||||
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 := svc.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))
|
||||
}
|
||||
|
||||
func TestGetOCSPResponse_Unknown(t *testing.T) {
|
||||
svc, _, _, _ := newRevocationTestService()
|
||||
|
||||
// Request OCSP response for unknown cert
|
||||
resp, err := svc.GetOCSPResponse(context.Background(), "iss-local", "UNKNOWN-SERIAL")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error (should return unknown status), got: %v", err)
|
||||
}
|
||||
|
||||
// Response should indicate unknown status
|
||||
if resp == nil || len(resp) == 0 {
|
||||
t.Fatal("expected non-empty OCSP response even for unknown cert")
|
||||
}
|
||||
|
||||
t.Logf("OCSP response for unknown cert generated: %d bytes", len(resp))
|
||||
}
|
||||
|
||||
func TestGetOCSPResponse_IssuerNotFound(t *testing.T) {
|
||||
svc, _, _, _ := newRevocationTestService()
|
||||
|
||||
// Request OCSP response for unknown issuer
|
||||
resp, err := svc.GetOCSPResponse(context.Background(), "iss-unknown", "SOME-SERIAL")
|
||||
|
||||
// Should return error since issuer doesn't exist
|
||||
if err == nil && resp != nil {
|
||||
t.Error("expected error for unknown issuer")
|
||||
}
|
||||
|
||||
t.Logf("GetOCSPResponse correctly handles unknown issuer")
|
||||
}
|
||||
|
||||
func TestGetOCSPResponse_InvalidSerial(t *testing.T) {
|
||||
svc, _, _, _ := newRevocationTestService()
|
||||
|
||||
// Request OCSP response with invalid serial format
|
||||
resp, err := svc.GetOCSPResponse(context.Background(), "iss-local", "")
|
||||
|
||||
if err == nil && resp != nil {
|
||||
// Empty serial might return unknown status; that's ok
|
||||
t.Logf("Empty serial handled gracefully")
|
||||
} else if err != nil {
|
||||
t.Logf("Empty serial rejected with error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user