mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +00:00
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:
@@ -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==
|
||||
@@ -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))
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user