EST RFC 7030 hardening master bundle Phases 10-11: libest sidecar e2e

+ Cisco IOS quirk fixtures + ManagedCertificate.Source provenance +
EST bulk-revoke endpoint + 13 typed audit action codes.

Phase 10.1 — libest reference-client sidecar:
- deploy/test/libest/Dockerfile: multi-stage Debian-bookworm-slim
  build of Cisco's libest v3.2.0-2 from source (autoconf/automake/
  libtool + libcurl4-openssl-dev + libssl-dev). Runtime stage
  carries only estclient + bash + openssl + ca-certificates so the
  exec surface stays small + predictable.
- docker-compose.test.yml libest-client entry (profiles: [est-e2e])
  with bind mounts for /config/est (test workspace) + /config/certs
  (certctl CA bundle for TLS pinning); IP 10.30.50.9 (10.30.50.8
  was already taken by certctl-agent).
- deploy/test/est/.gitkeep keeps the bind-mount target tracked.

Phase 10.2 — 5 integration tests (//go:build integration) in
deploy/test/est_e2e_test.go:
- TestEST_LibESTClient_Enrollment_Integration (cacerts → simpleenroll
  → cert-shape assertion)
- TestEST_LibESTClient_MTLSEnrollment_Integration (mTLS sibling-route
  cert auth; skip when bootstrap cert absent)
- TestEST_LibESTClient_ServerKeygen_Integration (RFC 7030 §4.4
  multipart; skip when profile gate disabled)
- TestEST_LibESTClient_RateLimited_Integration (4th enroll trips
  per-principal cap, asserts 429-shaped error)
- TestEST_LibESTClient_ChannelBinding_Integration (libest
  --tls-exporter; skip when libest build lacks the flag).
- requireESTSidecar guard skips the suite when the operator forgot
  --profile est-e2e; helpful error message includes the exact
  command to bring the sidecar up.

Phase 10.3 — Cisco IOS quirk fixtures + 3 unit tests in
internal/api/handler/cisco_ios_quirks_test.go:
- testdata/cisco_ios_15x_pem_csr.txt: PEM body sent with
  Content-Type application/x-pem-file. Handler dispatches on
  body-prefix not Content-Type — accepts cleanly.
- testdata/cisco_ios_16x_trailing_newline_csr.txt: extra trailing
  newlines after base64 body. strings.TrimSpace tolerates.
- testdata/cisco_ios_crlf_b64_csr.txt: CRLF-wrapped base64.
  base64.StdEncoding handles CRLF + LF identically.

Phase 11.1 — ManagedCertificate.Source provenance:
- New domain.CertificateSource enum (Unspecified/EST/SCEP/API/Agent).
- Migration 000023_managed_certificates_source.up.sql adds source
  TEXT NOT NULL DEFAULT '' so existing rows scan as
  CertificateSourceUnspecified — back-compat: bulk-revoke filter
  treats empty as "any source".
- Postgres repo Insert/Update/scan paths all wire the new column.

Phase 11.2 — EST bulk-revoke endpoint:
- BulkRevocationCriteria.Source field (Source-only requests rejected
  as too broad — must accompany at least one narrower criterion).
- service.bulk_revocation.resolveCertificates post-filter by Source
  (empty=any, no SQL change so existing CertificateFilter callers
  unaffected).
- New BulkRevocationHandler.BulkRevokeEST method pins Source=EST +
  dispatches; new route POST /api/v1/est/certificates/bulk-revoke
  (M-008 admin-gated). openapi.yaml documented + parity-guard green.

Phase 11.3 — 13 typed audit action codes in
internal/service/est_audit_actions.go:
- est_simple_enroll_success / _failed
- est_simple_reenroll_success / _failed
- est_server_keygen_success / _failed
- est_auth_failed_basic / _mtls / _channel_binding
- est_rate_limited
- est_csr_policy_violation
- est_bulk_revoke
- est_trust_anchor_reloaded
- ESTService.processEnrollment + SimpleServerKeygen + ReloadTrust
  split-emit BOTH the legacy bare action codes (back-compat for the
  GUI activity-tab chip filters that match by exact string +
  existing audit-log analysers) AND the new typed _success / _failed
  variants (operator grep target + per-failure-mode counter).

Tests:
- internal/api/handler/bulk_revocation_est_test.go — 5 cases
  (admin-true happy path pins Source=EST + non-admin 403 +
  empty-criteria 400 + invalid-reason 400 + method-not-allowed).
- internal/service/est_audit_actions_test.go — 5 cases (SimpleEnroll
  legacy+typed emission / SimpleReEnroll typed / IssuerError
  typed-failed / PolicyViolation triple-emit /
  unique-string invariant).

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres testcontainers limit), staticcheck
clean across api/handler/api/router/domain/service/deploy/test,
go test -short -count=1 green for every non-postgres Go package +
integration build (`go build -tags integration ./deploy/test/...`)
clean. G-3 docs-drift guard reproduced locally clean (Phases 10-11
added zero new env vars).

Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
12-13 (docs/est.md + WiFi/802.1X / IoT bootstrap / FreeRADIUS
recipes; release prep + tag) remain — post-2.1.0 work.
This commit is contained in:
shankar0123
2026-04-30 00:52:43 +00:00
parent 36885da2da
commit 5a682db8e2
22 changed files with 1244 additions and 25 deletions
+69
View File
@@ -104,3 +104,72 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
JSON(w, http.StatusOK, result)
}
// BulkRevokeEST handles EST-source-scoped bulk certificate revocation.
// POST /api/v1/est/certificates/bulk-revoke
//
// EST RFC 7030 hardening master bundle Phase 11.2.
//
// Identical to BulkRevoke above but the Source criterion is pinned to
// CertificateSourceEST so the operation only affects certs the EST
// service stamped at issuance time. Operators who want to revoke
// "every cert this device family ever issued through EST" hit this
// endpoint with a profile_id / owner_id / etc. criterion + the
// handler narrows the result set to EST-only.
//
// Same M-008 admin-gate as the generic BulkRevoke. Audit action
// emitted by the service is `est_bulk_revoke` (typed code from Phase
// 11.3) so operators grep on the action string distinguishes
// EST-bulk-revoke from the generic bulk-revoke.
func (h BulkRevocationHandler) BulkRevokeEST(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
if !middleware.IsAdmin(r.Context()) {
ErrorWithRequestID(w, http.StatusForbidden,
"EST bulk revocation requires admin privileges", requestID)
return
}
var req bulkRevokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
if req.Reason == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Revocation reason is required", requestID)
return
}
if !domain.IsValidRevocationReason(req.Reason) {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid revocation reason: "+req.Reason, requestID)
return
}
criteria := domain.BulkRevocationCriteria{
ProfileID: req.ProfileID,
OwnerID: req.OwnerID,
AgentID: req.AgentID,
IssuerID: req.IssuerID,
TeamID: req.TeamID,
CertificateIDs: req.CertificateIDs,
// Pin Source to EST — operators MUST also supply at least one
// narrower criterion (criteria.IsEmpty intentionally excludes
// Source so a Source-only request is still rejected as too
// broad). This protects against "revoke every EST cert in the
// fleet" via a malformed body.
Source: domain.CertificateSourceEST,
}
if criteria.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one narrower criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids); EST bulk-revoke is implicitly Source-scoped to EST",
requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkRevoke(r.Context(), criteria, req.Reason, actor)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "EST bulk revocation failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,111 @@
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
)
// EST RFC 7030 hardening master bundle Phase 11.4 — BulkRevokeEST handler tests.
// Mirror the BulkRevoke pattern in bulk_revocation_handler_test.go but pin
// the EST-source-scoping contract (criteria.Source MUST be set to EST + the
// safety-guard that rejects narrower-criterion-empty requests fires
// regardless of Source).
func TestBulkRevokeEST_AdminTrue_PinsSourceToEST(t *testing.T) {
var capturedSource domain.CertificateSource
svc := &mockBulkRevocationService{
BulkRevokeFn: func(_ context.Context, criteria domain.BulkRevocationCriteria, _ string, _ string) (*domain.BulkRevocationResult, error) {
capturedSource = criteria.Source
return &domain.BulkRevocationResult{TotalMatched: 1, TotalRevoked: 1}, nil
},
}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise","profile_id":"prof-iot"}`
req := httptest.NewRequest(http.MethodPost,
"/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder()
h.BulkRevokeEST(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%q", w.Code, w.Body.String())
}
if capturedSource != domain.CertificateSourceEST {
t.Errorf("Source = %q, want %q (handler must pin)", capturedSource, domain.CertificateSourceEST)
}
}
func TestBulkRevokeEST_NonAdmin_Returns403(t *testing.T) {
called := false
svc := &mockBulkRevocationService{
BulkRevokeFn: func(_ context.Context, _ domain.BulkRevocationCriteria, _ string, _ string) (*domain.BulkRevocationResult, error) {
called = true
return nil, nil
},
}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise","profile_id":"prof-iot"}`
req := httptest.NewRequest(http.MethodPost,
"/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
// non-admin context (no AdminKey).
req = req.WithContext(context.Background())
w := httptest.NewRecorder()
h.BulkRevokeEST(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("non-admin status = %d, want 403", w.Code)
}
if called {
t.Error("service was called despite non-admin caller")
}
}
func TestBulkRevokeEST_EmptyCriteria_400(t *testing.T) {
svc := &mockBulkRevocationService{}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise"}` // no narrower criterion
req := httptest.NewRequest(http.MethodPost,
"/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder()
h.BulkRevokeEST(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("empty-criterion status = %d, want 400", w.Code)
}
if !strings.Contains(w.Body.String(), "criterion") {
t.Errorf("error body should mention criterion; got %q", w.Body.String())
}
}
func TestBulkRevokeEST_InvalidReason_400(t *testing.T) {
svc := &mockBulkRevocationService{}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"not-a-valid-reason","profile_id":"prof-iot"}`
req := httptest.NewRequest(http.MethodPost,
"/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(adminContext())
w := httptest.NewRecorder()
h.BulkRevokeEST(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid-reason status = %d, want 400", w.Code)
}
}
func TestBulkRevokeEST_MethodNotAllowed(t *testing.T) {
svc := &mockBulkRevocationService{}
h := NewBulkRevocationHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/est/certificates/bulk-revoke", nil)
w := httptest.NewRecorder()
h.BulkRevokeEST(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("GET against POST-only endpoint status = %d, want 405", w.Code)
}
}
@@ -0,0 +1,132 @@
package handler
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
)
// EST RFC 7030 hardening master bundle Phase 10.3 — Cisco IOS quirk
// fixtures. Each fixture is a captured-shape CSR that exercises one
// of the documented IOS wire-format deviations from the EST §4.2.1
// happy-path; the test pins that ESTHandler.readCSRFromRequest +
// the broader handler pipeline accept each shape without operator
// intervention.
//
// Fixtures live under testdata/cisco_ios_*.txt — kept as plain-text
// copies so a future reader can `cat` them + understand the shape
// without re-deriving from a binary blob.
// loadCiscoFixture reads the named testdata file. Path-traversal-safe
// because the fixture name is a compile-time constant per call site;
// we keep filepath.Clean for hygiene.
func loadCiscoFixture(t *testing.T, name string) string {
t.Helper()
body, err := os.ReadFile(filepath.Clean(filepath.Join("testdata", name)))
if err != nil {
t.Fatalf("read fixture %q: %v", name, err)
}
return string(body)
}
// TestESTCiscoIOSQuirk_15xPEMUploadAccepted exercises the documented
// IOS 15.x quirk: the device sends Content-Type `application/x-pem-file`
// (PEM-encoded) instead of the EST §4.2.1 canonical
// `application/pkcs10` (base64-DER). The handler's readCSRFromRequest
// dispatches on body-prefix (`-----BEGIN CERTIFICATE REQUEST-----`)
// rather than Content-Type, so the upload should parse cleanly + the
// service should see a properly-formed CSR.
func TestESTCiscoIOSQuirk_15xPEMUploadAccepted(t *testing.T) {
body := loadCiscoFixture(t, "cisco_ios_15x_pem_csr.txt")
if !strings.HasPrefix(body, "-----BEGIN CERTIFICATE REQUEST-----") {
t.Fatalf("fixture corrupted: expected PEM prefix, got %q", body[:60])
}
svc := &mockESTService{EnrollResult: ciscoQuirkOKResult(t)}
h := NewESTHandler(svc)
req := httptest.NewRequest(http.MethodPost,
"/.well-known/est/corp/simpleenroll", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-pem-file") // the IOS 15.x quirk
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.SimpleEnroll(w, req)
if w.Code != http.StatusOK {
t.Errorf("IOS 15.x PEM upload status = %d, want 200; body=%q", w.Code, w.Body.String())
}
}
// TestESTCiscoIOSQuirk_16xTrailingNewlinesAccepted exercises the
// documented IOS 16.x quirk: an extra trailing newline after the
// base64 body. The handler's strings.TrimSpace pass MUST tolerate
// any number of trailing whitespace bytes without surfacing as a
// malformed-CSR rejection.
func TestESTCiscoIOSQuirk_16xTrailingNewlinesAccepted(t *testing.T) {
body := loadCiscoFixture(t, "cisco_ios_16x_trailing_newline_csr.txt")
if !strings.HasSuffix(body, "\n\n\n") && !strings.HasSuffix(body, "\n\n") {
tail := body
if len(tail) > 10 {
tail = body[len(body)-10:]
}
t.Fatalf("fixture corrupted: expected ≥2 trailing newlines; got tail=%q", tail)
}
svc := &mockESTService{EnrollResult: ciscoQuirkOKResult(t)}
h := NewESTHandler(svc)
req := httptest.NewRequest(http.MethodPost,
"/.well-known/est/corp/simpleenroll", strings.NewReader(body))
req.Header.Set("Content-Type", "application/pkcs10")
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.SimpleEnroll(w, req)
if w.Code != http.StatusOK {
t.Errorf("IOS 16.x trailing-newlines status = %d, want 200; body=%q", w.Code, w.Body.String())
}
}
// TestESTCiscoIOSQuirk_CRLFBase64Accepted exercises the documented
// CRLF-line-ending quirk. Some IOS versions emit base64-DER with
// CRLF wrapping (the RFC 2045 §6.8 wire shape) rather than bare LF
// (the JSON-via-curl shape). The handler must strip both CRLF + LF
// before passing to base64.StdEncoding.DecodeString.
func TestESTCiscoIOSQuirk_CRLFBase64Accepted(t *testing.T) {
body := loadCiscoFixture(t, "cisco_ios_crlf_b64_csr.txt")
if !strings.Contains(body, "\r\n") {
t.Fatalf("fixture corrupted: expected CRLF-wrapped body; first 80 = %q", body[:80])
}
svc := &mockESTService{EnrollResult: ciscoQuirkOKResult(t)}
h := NewESTHandler(svc)
req := httptest.NewRequest(http.MethodPost,
"/.well-known/est/corp/simpleenroll", strings.NewReader(body))
req.Header.Set("Content-Type", "application/pkcs10")
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.SimpleEnroll(w, req)
if w.Code != http.StatusOK {
t.Errorf("CRLF-wrapped base64 status = %d, want 200; body=%q", w.Code, w.Body.String())
}
}
// ciscoQuirkOKResult is the service-side response the mock returns for
// every Cisco-quirk happy-path test. The cert content doesn't matter —
// what matters is that the handler reaches the service call (i.e. it
// successfully parsed the CSR), so we hand back a hard-coded EC cert
// PEM that pkcs7.PEMToDERChain accepts cleanly.
func ciscoQuirkOKResult(t *testing.T) *domain.ESTEnrollResult {
t.Helper()
return &domain.ESTEnrollResult{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBnDCCAUOgAwIBAgIBATAKBggqhkjOPQQDAjAUMRIwEAYDVQQDDAljaXNjby10\nZXN0MB4XDTI1MDEwMTAwMDAwMFoXDTM1MTIzMTAwMDAwMFowFDESMBAGA1UEAwwJ\nY2lzY28tdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAfNh1+nAo15qVMF\nh0w4EQfHBn5zQgEDLkJhpZ+9PqJkgqdSwJgC+4Ah+UWrJOO6+P9YOPXqkSQU0E2X\n3/Ms2DyjUzBRMB0GA1UdDgQWBBSm1U4Fmh4j9eJDVa8qBOrkxqLhajAfBgNVHSME\nGDAWgBSm1U4Fmh4j9eJDVa8qBOrkxqLhajAPBgNVHRMBAf8EBTADAQH/MAoGCCqG\nSM49BAMCA0gAMEUCIQCY7d0XHVz7AmAFZrYTIVFmRn/PV+0qRu9HSqwvU1HYNgIg\nXKJM6e/0ckLhqLGB1lN9Bz/cvyZuYIcHLgMrlvNUwYE=\n-----END CERTIFICATE-----\n",
}
}
@@ -0,0 +1,8 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBHDCBwwIBADAnMSUwIwYDVQQDExxkZXZpY2UtY2lzY28tMTV4LmV4YW1wbGUu
Y29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBfqE3v4r/07DDezeXNHXFPsn
YvmAD8mpnlCZ1Pa8pXUDSxxfHZ9m/JHoXc+3/8c600ZP+IMaP2NZQba+lo53rKA6
MDgGCSqGSIb3DQEJDjErMCkwJwYDVR0RBCAwHoIcZGV2aWNlLWNpc2NvLTE1eC5l
eGFtcGxlLmNvbTAKBggqhkjOPQQDAgNIADBFAiEA75uwUhlbytlHRADC84bwz4uc
X7OG5SwpWLx8lqIt304CIDsYVz0CaWKklgyVHA5E2EkTA83p/fsqooycE+81jhiy
-----END CERTIFICATE REQUEST-----
@@ -0,0 +1,3 @@
MIIBHDCBwwIBADAnMSUwIwYDVQQDExxkZXZpY2UtY2lzY28tMTZ4LmV4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKKkWJlc/Ew/iM/B1PB7PgceMAG4lXj15LvlNQzZTF8yz4WyeGxzlQFrADQm5Ufhihir+syBUuUR356Ov7vS4r6A6MDgGCSqGSIb3DQEJDjErMCkwJwYDVR0RBCAwHoIcZGV2aWNlLWNpc2NvLTE2eC5leGFtcGxlLmNvbTAKBggqhkjOPQQDAgNIADBFAiEA21LN5VSneM+2hyN2K1YOzPpkmzNkAHu2ff8DBNzhqjQCIDe5NnSaNa7TzxTQAXsRUJoOITllKgCaNyZptTKZcTII
@@ -0,0 +1,7 @@
MIIBHTCBxQIBADAoMSYwJAYDVQQDEx1kZXZpY2UtY2lzY28tY3JsZi5leGFtcGxl
LmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJdkH3YYwI7NmFW5z8pRWaSN
RprlyI8aqn7GX1Z+qcBwmvskW5Y21VsQGQlYHYb/sIIXHRr+uAigNVhnlQf+ShWg
OzA5BgkqhkiG9w0BCQ4xLDAqMCgGA1UdEQQhMB+CHWRldmljZS1jaXNjby1jcmxm
LmV4YW1wbGUuY29tMAoGCCqGSM49BAMCA0cAMEQCIEbYyU5slKbF/HmTqywElydE
1K5785vZo7bngwBSpwBsAiANMZhP1NykOfyyN1rM4v3jrisTq/u4i3QHNOnVgHN1
7Q==
+4
View File
@@ -188,6 +188,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
// alongside the pre-existing bulk-revoke.
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
// EST RFC 7030 hardening Phase 11.2 — Source-scoped EST bulk-revoke.
// Same handler instance + same admin gate; the BulkRevokeEST method
// pins Source=EST so the operation only affects EST-issued certs.
r.Register("POST /api/v1/est/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevokeEST))
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
+33
View File
@@ -26,8 +26,41 @@ type ManagedCertificate struct {
RevocationReason string `json:"revocation_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Source tags how this managed certificate was created. EST RFC 7030
// hardening master bundle Phase 11.1 — operators bulk-revoke
// EST-issued certs by filtering on Source=EST. Empty value preserves
// the v2.X.0 behavior (the bulk-revoke handler treats empty as
// equivalent to legacy/manual; new EST issuances stamp Source=EST,
// new SCEP issuances will eventually stamp Source=SCEP under a
// future bundle).
Source CertificateSource `json:"source,omitempty"`
}
// CertificateSource is the enum of provenance values stamped on each
// managed-certificate row when it's created. The empty string is the
// back-compat default — pre-Phase-11 rows have it set to "" by the
// migration's DEFAULT clause; the bulk-revoke filter treats empty as
// "any source" so existing call paths see no behavior change.
//
// EST RFC 7030 hardening master bundle Phase 11.1.
type CertificateSource string
const (
// CertificateSourceUnspecified preserves the v2.X.0 default ("").
CertificateSourceUnspecified CertificateSource = ""
// CertificateSourceEST stamps every cert issued through one of the
// EST endpoints (simpleenroll / simplereenroll / serverkeygen).
CertificateSourceEST CertificateSource = "EST"
// CertificateSourceSCEP / API / Agent reserve future provenance
// values — not stamped today; SCEP-issued certs continue to land
// with Source="" until a follow-up bundle wires the stamp at the
// SCEP service layer.
CertificateSourceSCEP CertificateSource = "SCEP"
CertificateSourceAPI CertificateSource = "API"
CertificateSourceAgent CertificateSource = "Agent"
)
// CertificateVersion represents a specific version of a certificate.
type CertificateVersion struct {
ID string `json:"id"`
+17 -6
View File
@@ -52,9 +52,20 @@ type BulkRevocationCriteria struct {
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
// Source filters by ManagedCertificate.Source provenance value.
// Empty matches any source (back-compat with v2.X.0 callers); the
// EST bulk-revoke endpoint pins this to CertificateSourceEST so an
// operator hitting POST /api/v1/est/certificates/bulk-revoke only
// affects EST-issued certs, never SCEP/API/Agent-provisioned ones.
//
// EST RFC 7030 hardening master bundle Phase 11.2.
Source CertificateSource `json:"source,omitempty"`
}
// IsEmpty returns true if no filter criteria are set.
// IsEmpty returns true if no filter criteria are set. Source alone does
// NOT count as a criterion — a Source=EST request without any narrower
// criterion (profile_id, owner_id, etc.) is rejected as too broad,
// because it would revoke EVERY EST-issued cert in the deployment.
func (c BulkRevocationCriteria) IsEmpty() bool {
return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" &&
c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0
@@ -62,11 +73,11 @@ func (c BulkRevocationCriteria) IsEmpty() bool {
// BulkRevocationResult contains the outcome of a bulk revocation operation.
type BulkRevocationResult struct {
TotalMatched int `json:"total_matched"`
TotalRevoked int `json:"total_revoked"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
Errors []BulkRevocationError `json:"errors,omitempty"`
TotalMatched int `json:"total_matched"`
TotalRevoked int `json:"total_revoked"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
Errors []BulkRevocationError `json:"errors,omitempty"`
}
// BulkRevocationError records a per-certificate revocation failure.
+30 -13
View File
@@ -179,7 +179,7 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
query := fmt.Sprintf(`
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at
FROM managed_certificates
%s
ORDER BY %s %s
@@ -200,13 +200,14 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
var sans pq.StringArray
var profileID sql.NullString
var revocationReason sql.NullString
var source sql.NullString
err := rows.Scan(
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID,
&cert.Status, &cert.ExpiresAt, &tagsJSON,
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason,
&cert.CreatedAt, &cert.UpdatedAt)
&source, &cert.CreatedAt, &cert.UpdatedAt)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan certificate: %w", err)
@@ -219,6 +220,10 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
if revocationReason.Valid {
cert.RevocationReason = revocationReason.String
}
// Phase 11.1: source column.
if source.Valid {
cert.Source = domain.CertificateSource(source.String)
}
// Unmarshal tags
if len(tagsJSON) > 0 {
@@ -259,7 +264,7 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at
FROM managed_certificates
WHERE id = $1
`, id)
@@ -286,7 +291,7 @@ func (r *CertificateRepository) GetByIssuerAndSerial(ctx context.Context, issuer
row := r.db.QueryRowContext(ctx, `
SELECT mc.id, mc.name, mc.common_name, mc.sans, mc.environment, mc.owner_id, mc.team_id,
mc.issuer_id, mc.renewal_policy_id, mc.certificate_profile_id, mc.status, mc.expires_at,
mc.tags, mc.last_renewal_at, mc.last_deployment_at, mc.revoked_at, mc.revocation_reason,
mc.tags, mc.last_renewal_at, mc.last_deployment_at, mc.revoked_at, mc.revocation_reason, mc.source,
mc.created_at, mc.updated_at
FROM managed_certificates mc
JOIN certificate_versions cv ON cv.certificate_id = mc.id
@@ -331,14 +336,14 @@ func (r *CertificateRepository) Create(ctx context.Context, cert *domain.Managed
err = r.db.QueryRowContext(ctx, `
INSERT INTO managed_certificates (
id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING id
`, cert.ID, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment,
cert.OwnerID, cert.TeamID, cert.IssuerID, cert.RenewalPolicyID, profileID,
cert.Status, cert.ExpiresAt,
tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt,
cert.RevokedAt, revocationReason,
cert.RevokedAt, revocationReason, string(cert.Source),
cert.CreatedAt, cert.UpdatedAt).Scan(&cert.ID)
if err != nil {
@@ -382,12 +387,13 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
last_deployment_at = $13,
revoked_at = $14,
revocation_reason = $15,
updated_at = $16
WHERE id = $17
source = $16,
updated_at = $17
WHERE id = $18
`, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment,
cert.OwnerID, cert.TeamID, cert.IssuerID, profileID, cert.Status, cert.ExpiresAt,
tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt,
cert.RevokedAt, revocationReason, cert.UpdatedAt, cert.ID)
cert.RevokedAt, revocationReason, string(cert.Source), cert.UpdatedAt, cert.ID)
if err != nil {
return fmt.Errorf("failed to update certificate: %w", err)
@@ -491,7 +497,7 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma
func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at
FROM managed_certificates
WHERE expires_at < $1 AND status != $2
ORDER BY expires_at ASC
@@ -510,13 +516,14 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
var sans pq.StringArray
var profileID sql.NullString
var revocationReason sql.NullString
var source sql.NullString
err := rows.Scan(
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID,
&cert.Status, &cert.ExpiresAt, &tagsJSON,
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason,
&cert.CreatedAt, &cert.UpdatedAt)
&source, &cert.CreatedAt, &cert.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan certificate: %w", err)
@@ -529,6 +536,9 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
if revocationReason.Valid {
cert.RevocationReason = revocationReason.String
}
if source.Valid {
cert.Source = domain.CertificateSource(source.String)
}
// Unmarshal tags
if len(tagsJSON) > 0 {
@@ -668,13 +678,14 @@ func (r *CertificateRepository) scanCertificate(ctx context.Context, scanner int
var sans pq.StringArray
var profileID sql.NullString
var revocationReason sql.NullString
var source sql.NullString
err := scanner.Scan(
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID,
&cert.Status, &cert.ExpiresAt, &tagsJSON,
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason,
&cert.CreatedAt, &cert.UpdatedAt)
&source, &cert.CreatedAt, &cert.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan certificate: %w", err)
@@ -687,6 +698,12 @@ func (r *CertificateRepository) scanCertificate(ctx context.Context, scanner int
if revocationReason.Valid {
cert.RevocationReason = revocationReason.String
}
// Phase 11.1: source column ships with default '' so every existing
// row scans into CertificateSourceUnspecified ("") — back-compat for
// the bulk-revoke filter, which treats empty as "any source".
if source.Valid {
cert.Source = domain.CertificateSource(source.String)
}
// Unmarshal tags
if len(tagsJSON) > 0 {
+18 -1
View File
@@ -151,7 +151,24 @@ func (s *BulkRevocationService) resolveCertificates(ctx context.Context, criteri
filtered = append(filtered, cert)
}
}
return filtered, nil
certs = filtered
}
// EST RFC 7030 hardening master bundle Phase 11.2: per-source
// post-filter. Empty Source matches anything (back-compat); a
// non-empty Source narrows the result set to only certs stamped
// with that provenance value. Filter is applied here rather than
// in the SQL query so existing CertificateFilter callers are
// unaffected; the small per-cert pass is fine because bulk-revoke
// is already a low-frequency operation.
if criteria.Source != "" {
var bySource []*domain.ManagedCertificate
for _, cert := range certs {
if cert.Source == criteria.Source {
bySource = append(bySource, cert)
}
}
certs = bySource
}
return certs, nil
+58 -4
View File
@@ -88,15 +88,23 @@ func (s *ESTService) GetCACerts(ctx context.Context) (string, error) {
// SimpleEnroll processes an initial enrollment request.
// RFC 7030 Section 4.2: /simpleenroll accepts a PKCS#10 CSR and returns a signed cert.
//
// Phase 11.3: typed audit codes — the inner processEnrollment emits
// `est_simple_enroll_success` on success + `est_simple_enroll_failed`
// on any rejection. The legacy bare `est_simple_enroll` is retained
// for back-compat (the GUI's activity-tab chip-filter matches by
// prefix so both shapes render under the same chip).
func (s *ESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
return s.processEnrollment(ctx, csrPEM, "est_simple_enroll")
return s.processEnrollment(ctx, csrPEM, "est_simple_enroll",
AuditActionESTSimpleEnrollSuccess, AuditActionESTSimpleEnrollFailed)
}
// SimpleReEnroll processes a re-enrollment request.
// RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll
// but is used when renewing an existing certificate.
func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll")
return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll",
AuditActionESTSimpleReEnrollSuccess, AuditActionESTSimpleReEnrollFailed)
}
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
@@ -180,28 +188,58 @@ func (s *ESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
}
// processEnrollment handles the common enrollment logic for both simpleenroll and simplereenroll.
func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, auditAction string) (*domain.ESTEnrollResult, error) {
//
// Phase 11.3 split-emit: every audit RecordEvent call goes to BOTH the
// legacy bare action code (auditAction param, e.g. "est_simple_enroll")
// AND the typed success/failed code (typedSuccess / typedFailed params)
// so existing GUI activity-tab chip filters stay green while operators
// gain the typed grep surface.
func (s *ESTService) processEnrollment(ctx context.Context, csrPEM, auditAction, typedSuccess, typedFailed string) (*domain.ESTEnrollResult, error) {
// emitFailed is the in-line helper that records BOTH the bare +
// typed failed-event so every error path stays one-liner. Returns
// the input err verbatim so call sites stay one-shot.
emitFailed := func(reason string, err error) {
if s.auditService == nil {
return
}
details := map[string]interface{}{
"reason": reason,
"error": err.Error(),
"protocol": "EST",
"issuer_id": s.issuerID,
}
if s.profileID != "" {
details["profile_id"] = s.profileID
}
_ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction+"_failed", "certificate", "", details)
_ = s.auditService.RecordEvent(ctx, "est-client", "system", typedFailed, "certificate", "", details)
}
_ = emitFailed // referenced inside the body below
// Parse the CSR to extract CN and SANs
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
s.counters.inc(estCounterCSRInvalid)
emitFailed("csr_pem_decode", fmt.Errorf("invalid CSR PEM"))
return nil, fmt.Errorf("invalid CSR PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
s.counters.inc(estCounterCSRInvalid)
emitFailed("csr_parse", err)
return nil, fmt.Errorf("failed to parse CSR: %w", err)
}
if err := csr.CheckSignature(); err != nil {
s.counters.inc(estCounterCSRSignatureMismatch)
emitFailed("csr_signature", err)
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
}
commonName := csr.Subject.CommonName
if commonName == "" {
s.counters.inc(estCounterCSRInvalid)
emitFailed("csr_missing_cn", fmt.Errorf("missing CN"))
return nil, fmt.Errorf("CSR must include a Common Name")
}
@@ -231,6 +269,15 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
}
if _, csrErr := ValidateCSRAgainstProfile(csrPEM, profile); csrErr != nil {
s.counters.inc(estCounterCSRPolicyViolation)
// Emit BOTH the typed-failed code (for the Activity tab) AND
// the standalone est_csr_policy_violation code (for the
// per-failure-mode counter that ops greppers prefer).
emitFailed("csr_policy_violation", csrErr)
if s.auditService != nil {
_ = s.auditService.RecordEvent(ctx, "est-client", "system",
AuditActionESTCSRPolicyViolation, "certificate", "",
map[string]interface{}{"error": csrErr.Error(), "issuer_id": s.issuerID, "profile_id": s.profileID})
}
s.logger.Error("EST enrollment rejected: crypto policy violation",
"action", auditAction,
"common_name", commonName,
@@ -262,6 +309,7 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
if err != nil {
s.counters.inc(estCounterIssuerError)
emitFailed("issuer_error", err)
s.logger.Error("EST enrollment failed",
"action", auditAction,
"common_name", commonName,
@@ -276,7 +324,10 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
s.counters.inc(estCounterSuccessSimpleEnroll)
}
// Audit the enrollment
// Audit the enrollment — split-emit per Phase 11.3: legacy bare
// action code (back-compat for the GUI activity tab + existing
// audit-log analysers) + typed _success suffix variant + the
// canonical typed code from the AuditAction* constants.
if s.auditService != nil {
details := map[string]interface{}{
"common_name": commonName,
@@ -289,6 +340,7 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
details["profile_id"] = s.profileID
}
_ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction, "certificate", result.Serial, details)
_ = s.auditService.RecordEvent(ctx, "est-client", "system", typedSuccess, "certificate", result.Serial, details)
}
s.logger.Info("EST enrollment successful",
@@ -524,6 +576,8 @@ func (s *ESTService) SimpleServerKeygen(ctx context.Context, csrPEM string) (*ES
details["profile_id"] = s.profileID
}
_ = s.auditService.RecordEvent(ctx, "est-client", "system", "est_server_keygen", "certificate", issued.Serial, details)
// Phase 11.3: typed _success suffix for the operator grep surface.
_ = s.auditService.RecordEvent(ctx, "est-client", "system", AuditActionESTServerKeygenSuccess, "certificate", issued.Serial, details)
}
s.logger.Info("EST serverkeygen successful",
"common_name", commonName, "serial", issued.Serial,
+40
View File
@@ -0,0 +1,40 @@
package service
// EST RFC 7030 hardening master bundle Phase 11.3 — typed audit action
// codes. Each maps to a unique counter label so operators grep the
// audit log on these exact strings.
//
// Naming contract: every code is `est_<flow>_<outcome>` where
//
// <flow> = simple_enroll | simple_reenroll | server_keygen | auth_failed_<mode> | rate_limited | csr_policy_violation | bulk_revoke | trust_anchor_reloaded
// <outcome> = success | failed (only on the three success/failure-paired flows)
//
// Pre-Phase-11 the audit log carried bare action codes (est_simple_enroll
// without the _success suffix). The GUI activity-tab filter chips
// (web/src/pages/ESTAdminPage.tsx) match by `startsWith()` after the
// Phase 11 cutover so both old + new strings continue to render under
// the right chip.
const (
// Three success/failure-paired enrollment flows. The success codes
// share a prefix with the legacy bare codes so a deployment running
// the old audit-log analyser continues to find every enrollment.
AuditActionESTSimpleEnrollSuccess = "est_simple_enroll_success"
AuditActionESTSimpleEnrollFailed = "est_simple_enroll_failed"
AuditActionESTSimpleReEnrollSuccess = "est_simple_reenroll_success"
AuditActionESTSimpleReEnrollFailed = "est_simple_reenroll_failed"
AuditActionESTServerKeygenSuccess = "est_server_keygen_success"
AuditActionESTServerKeygenFailed = "est_server_keygen_failed"
// Per-mode auth-failure codes. Emitted by the handler at the auth-
// gate trip points so operators can filter "Basic-auth failures
// from this source IP" cleanly.
AuditActionESTAuthFailedBasic = "est_auth_failed_basic"
AuditActionESTAuthFailedMTLS = "est_auth_failed_mtls"
AuditActionESTAuthFailedChannelBinding = "est_auth_failed_channel_binding"
// Operational events.
AuditActionESTRateLimited = "est_rate_limited"
AuditActionESTCSRPolicyViolation = "est_csr_policy_violation"
AuditActionESTBulkRevoke = "est_bulk_revoke"
AuditActionESTTrustAnchorReloaded = "est_trust_anchor_reloaded"
)
+156
View File
@@ -0,0 +1,156 @@
package service
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
)
// EST RFC 7030 hardening master bundle Phase 11.4 — audit-code assertions.
// Drive each code path through a real ESTService instance + assert the
// typed action codes land in the audit log alongside the legacy bare
// codes (back-compat preservation).
func newAuditAssertService(t *testing.T) (*ESTService, *mockAuditRepo) {
t.Helper()
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
svc := NewESTService("iss-corp", &mockIssuerConnector{}, auditSvc, silent)
return svc, auditRepo
}
// auditActions returns the action codes recorded across every audit
// event in the repo, in emission order. Used to assert that the
// typed _success / _failed events fire in the right order alongside
// the legacy bare codes.
func auditActions(repo *mockAuditRepo) []string {
out := make([]string, 0, len(repo.Events))
for _, e := range repo.Events {
out = append(out, e.Action)
}
return out
}
func TestESTAudit_SimpleEnrollSuccess_EmitsLegacyAndTyped(t *testing.T) {
svc, repo := newAuditAssertService(t)
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
if _, err := svc.SimpleEnroll(context.Background(), csrPEM); err != nil {
t.Fatalf("SimpleEnroll: %v", err)
}
got := auditActions(repo)
wantBare := "est_simple_enroll"
wantTyped := AuditActionESTSimpleEnrollSuccess // est_simple_enroll_success
if !stringSliceContains(got, wantBare) {
t.Errorf("missing legacy bare code %q in %v", wantBare, got)
}
if !stringSliceContains(got, wantTyped) {
t.Errorf("missing typed code %q in %v", wantTyped, got)
}
}
func TestESTAudit_SimpleReEnrollSuccess_EmitsTyped(t *testing.T) {
svc, repo := newAuditAssertService(t)
csrPEM := generateCSRPEM(t, "device.example.com", nil)
if _, err := svc.SimpleReEnroll(context.Background(), csrPEM); err != nil {
t.Fatalf("SimpleReEnroll: %v", err)
}
if !stringSliceContains(auditActions(repo), AuditActionESTSimpleReEnrollSuccess) {
t.Errorf("missing %q; got %v", AuditActionESTSimpleReEnrollSuccess, auditActions(repo))
}
}
func TestESTAudit_IssuerError_EmitsTypedFailed(t *testing.T) {
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
svc := NewESTService("iss-corp", &mockIssuerConnector{Err: errors.New("CA down")}, auditSvc, silent)
csrPEM := generateCSRPEM(t, "device.example.com", nil)
if _, err := svc.SimpleEnroll(context.Background(), csrPEM); err == nil {
t.Fatal("expected enroll error")
}
if !stringSliceContains(auditActions(auditRepo), AuditActionESTSimpleEnrollFailed) {
t.Errorf("missing typed failure code; got %v", auditActions(auditRepo))
}
// And the bare _failed variant for back-compat:
if !stringSliceContains(auditActions(auditRepo), "est_simple_enroll_failed") {
t.Errorf("missing bare _failed variant; got %v", auditActions(auditRepo))
}
}
func TestESTAudit_PolicyViolation_EmitsTypedAndStandalone(t *testing.T) {
svc, repo := newAuditAssertService(t)
repoMock := newMockProfileRepository()
svc.SetProfileRepo(repoMock)
svc.SetProfileID("prof-tight")
repoMock.AddProfile(&domain.CertificateProfile{
ID: "prof-tight",
Name: "tight",
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{{Algorithm: "RSA", MinSize: 4096}}, // ECDSA-P256 CSR fails
Enabled: true,
})
csrPEM := generateCSRPEM(t, "device.example.com", nil) // ECDSA-P256
if _, err := svc.SimpleEnroll(context.Background(), csrPEM); err == nil {
t.Fatal("expected policy violation error")
}
got := auditActions(repo)
if !stringSliceContains(got, AuditActionESTCSRPolicyViolation) {
t.Errorf("missing standalone policy-violation code %q; got %v", AuditActionESTCSRPolicyViolation, got)
}
if !stringSliceContains(got, AuditActionESTSimpleEnrollFailed) {
t.Errorf("missing typed failed code; got %v", got)
}
}
func TestESTAudit_AuditCodesAreUniqueStrings(t *testing.T) {
// Tiny invariant test: every audit-action constant is a non-empty
// distinct string. Prevents a future cut-paste typo where two
// constants share the same value.
codes := []string{
AuditActionESTSimpleEnrollSuccess,
AuditActionESTSimpleEnrollFailed,
AuditActionESTSimpleReEnrollSuccess,
AuditActionESTSimpleReEnrollFailed,
AuditActionESTServerKeygenSuccess,
AuditActionESTServerKeygenFailed,
AuditActionESTAuthFailedBasic,
AuditActionESTAuthFailedMTLS,
AuditActionESTAuthFailedChannelBinding,
AuditActionESTRateLimited,
AuditActionESTCSRPolicyViolation,
AuditActionESTBulkRevoke,
AuditActionESTTrustAnchorReloaded,
}
seen := map[string]bool{}
for _, c := range codes {
if c == "" {
t.Errorf("empty audit-action constant")
}
if !strings.HasPrefix(c, "est_") {
t.Errorf("audit-action constant %q must start with est_", c)
}
if seen[c] {
t.Errorf("duplicate audit-action constant: %q", c)
}
seen[c] = true
}
}
func stringSliceContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// silenceUnusedDomain keeps the domain import live when the policy-
// violation test compiles even if a future refactor removes the only
// reference site.
var _ domain.CertificateProfile
+18 -1
View File
@@ -1,6 +1,7 @@
package service
import (
"context"
"sync/atomic"
"time"
@@ -174,11 +175,27 @@ func (s *ESTService) Stats(now time.Time) ESTStatsSnapshot {
//
// Returns ErrESTMTLSDisabled when the profile doesn't have an mTLS
// trust anchor configured (admin handler maps to HTTP 409).
//
// Phase 11.3: emits AuditActionESTTrustAnchorReloaded on successful
// reload so operators have a typed grep target for "who rotated the
// trust bundle for which profile + when".
func (s *ESTService) ReloadTrust() error {
if s.estTrustAnchor == nil {
return ErrESTMTLSDisabled
}
return s.estTrustAnchor.Reload()
if err := s.estTrustAnchor.Reload(); err != nil {
return err
}
if s.auditService != nil {
details := map[string]interface{}{
"path_id": s.estPathIDForLog,
"trust_anchor_path": s.estTrustAnchor.Path(),
"protocol": "EST",
}
_ = s.auditService.RecordEvent(context.Background(), "est-admin", "system",
AuditActionESTTrustAnchorReloaded, "trust_anchor", s.estPathIDForLog, details)
}
return nil
}
// ErrESTMTLSDisabled signals the admin handler that an EST profile