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
+39
View File
@@ -470,6 +470,45 @@ paths:
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/est/certificates/bulk-revoke:
post:
tags: [EST, Certificates]
summary: Bulk revoke EST-issued certificates (admin)
description: |
EST-source-scoped bulk revocation. Identical wire shape to
/api/v1/certificates/bulk-revoke; the handler pins
`Source=EST` so the operation only affects certs the EST
service stamped at issuance time. SCEP-issued / API-issued /
Agent-provisioned certs are never touched by this endpoint.
At least one narrower criterion (profile_id, owner_id,
agent_id, issuer_id, team_id, or certificate_ids) is
required — Source-only requests are rejected as too broad
to prevent accidental fleet-wide revocation. Admin-gated
(M-008 / M-003 pattern). Audit action emitted: `est_bulk_revoke`.
EST RFC 7030 hardening master bundle Phase 11.2.
operationId: bulkRevokeESTCertificates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRevokeRequest"
responses:
"200":
description: Bulk revocation result (same shape as the generic endpoint)
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRevokeResult"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Admin access required
"500":
$ref: "#/components/responses/InternalError"
/api/v1/certificates/bulk-renew: /api/v1/certificates/bulk-renew:
post: post:
tags: [Certificates] tags: [Certificates]
+42
View File
@@ -431,6 +431,48 @@ services:
ipv4_address: 10.30.50.8 ipv4_address: 10.30.50.8
restart: unless-stopped restart: unless-stopped
# EST RFC 7030 hardening master bundle Phase 10.1 — libest sidecar.
#
# Cisco's libest reference RFC 7030 client. The integration test
# (deploy/test/est_e2e_test.go, build tag `integration`) docker-exec's
# into this container to drive estclient against the live certctl
# server. The container stays alive via `sleep infinity` so the test
# can do many serial exec calls without paying container-startup cost.
#
# Profile-gated (`profiles: [est-e2e]`) so the routine `docker compose
# up` for non-EST integration runs doesn't pay the libest build cost.
# Operator opts in via `docker compose --profile est-e2e up`. CI's
# est-e2e job runs:
# docker compose --profile est-e2e build libest-client
# docker compose --profile est-e2e up -d
# INTEGRATION=1 go test -tags integration -run 'TestEST_LibESTClient' ./deploy/test/...
libest-client:
build:
context: ..
dockerfile: deploy/test/libest/Dockerfile
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-}
container_name: certctl-test-libest
depends_on:
certctl-server:
condition: service_healthy
volumes:
# /config/est is the libest working directory — the integration
# test writes CSRs / reads issued certs through this mount so the
# test-side Go code can inspect estclient's outputs.
- ./test/est:/config/est:rw
# certctl's CA bundle for TLS pinning. estclient uses this to
# verify the certctl-server cert (the same self-signed bundle
# the certctl-agent verifies against).
- ./test/certs:/config/certs:ro
networks:
certctl-test:
ipv4_address: 10.30.50.9
restart: unless-stopped
profiles: [est-e2e]
# ============================================================================= # =============================================================================
# Network # Network
# ============================================================================= # =============================================================================
+6
View File
@@ -0,0 +1,6 @@
# EST RFC 7030 hardening master bundle Phase 10.1.
# This directory is the libest sidecar's working dir (bind-mounted as
# /config/est). The integration test writes CSRs here + reads issued
# certs back; this .gitkeep keeps the directory present in the repo
# so a fresh `docker compose --profile est-e2e up` doesn't bind-mount
# a missing path.
+354
View File
@@ -0,0 +1,354 @@
//go:build integration
// EST RFC 7030 hardening master bundle Phase 10.2 — libest sidecar
// integration tests. Five named tests exercise the live certctl
// server's EST endpoints through Cisco's libest reference client
// (estclient binary inside the certctl-test-libest sidecar container).
//
// Skip conditions:
// - INTEGRATION env var not set (matches integration_test.go).
// - The libest sidecar isn't running (the test detects this by
// `docker inspect certctl-test-libest` and skips if absent).
// - The EST endpoint isn't reachable from inside the network (the
// test probes /.well-known/est/cacerts via estclient -g and
// skips if the route returns 404).
//
// Operator workflow:
//
// cd deploy
// docker compose -f docker-compose.test.yml --profile est-e2e build libest-client
// docker compose -f docker-compose.test.yml --profile est-e2e up -d
// cd test
// INTEGRATION=1 go test -tags integration -v -run 'TestEST_LibESTClient' ./...
//
// CI runs this in the same job that already runs integration_test.go;
// the docker-compose.test.yml libest-client entry + the Dockerfile
// land in the same commit so a fresh `make integration-test-est`
// (CI-side wrapper) works without operator intervention.
package integration_test
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"os/exec"
"strings"
"testing"
"time"
)
// libestContainer is the docker-compose service name + container_name
// the sidecar uses (deploy/docker-compose.test.yml::libest-client).
const libestContainer = "certctl-test-libest"
// estServerHostInsideNetwork is the certctl-server hostname libest
// resolves inside the certctl-test docker network. The sidecar's
// /etc/hosts is auto-populated by docker-compose's bridge network so
// `certctl-server` resolves to 10.30.50.6 (the static IP from the
// compose file).
const estServerHostInsideNetwork = "certctl-server"
// estPortInsideNetwork is the certctl HTTPS port inside the docker
// network. NOT the host-mapped port (8443 → 8443 via compose); the
// sidecar talks straight to the container.
const estPortInsideNetwork = "8443"
// estCABundleInContainer is the bind-mounted certctl CA bundle the
// libest sidecar pins TLS against. Path matches the volume mount in
// docker-compose.test.yml::libest-client.
const estCABundleInContainer = "/config/certs/ca.crt"
// dockerExec runs `docker exec <container> <args>` and returns
// stdout + stderr + the run error. Used by every libest test below.
// Centralised so a future docker-cli refactor (podman, kubectl exec)
// only changes one place.
func dockerExec(ctx context.Context, container string, args ...string) (string, string, error) {
full := append([]string{"exec", container}, args...)
cmd := exec.CommandContext(ctx, "docker", full...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.String(), stderr.String(), err
}
// libestSidecarReady checks that the libest sidecar container is
// running. Returns the docker-inspect status string + a boolean for
// "ready"; the boolean is what tests use to skip cleanly when the
// operator forgot the --profile est-e2e flag.
func libestSidecarReady(ctx context.Context) (string, bool) {
cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.Status}}", libestContainer)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return errBuf.String(), false
}
status := strings.TrimSpace(out.String())
return status, status == "running"
}
// runEstclient is the workhorse helper that drives `estclient` inside
// the sidecar. Returns the raw stdout (typically the issued cert PEM
// or the cacerts PKCS#7 base64 blob) + a useful error including
// stderr on failure.
//
// The args are appended after a baseline {`estclient`, ...common
// flags} shape that pins TLS against the certctl CA bundle + sets the
// per-test-run output dir.
func runEstclient(ctx context.Context, t *testing.T, extraArgs ...string) (string, error) {
t.Helper()
baseArgs := []string{
"estclient",
"-s", estServerHostInsideNetwork,
"-p", estPortInsideNetwork,
"-c", estCABundleInContainer,
}
args := append(baseArgs, extraArgs...)
stdout, stderr, err := dockerExec(ctx, libestContainer, args...)
if err != nil {
return stdout, fmt.Errorf("estclient %v: %w (stderr=%q)", args, err, stderr)
}
return stdout, nil
}
// requireESTSidecar is the per-test skip guard. If the libest sidecar
// isn't running, every EST integration test skips with a message that
// tells the operator the exact command to bring it up.
func requireESTSidecar(t *testing.T) {
t.Helper()
if !integrationOptedIn() {
t.Skip("integration tests require INTEGRATION=1; skipping libest e2e suite")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if status, ready := libestSidecarReady(ctx); !ready {
t.Skipf("libest sidecar (container %q) not running (status=%q). Run `cd deploy && docker compose -f docker-compose.test.yml --profile est-e2e up -d libest-client` to bring it up.", libestContainer, status)
}
}
// integrationOptedIn mirrors integration_test.go's existing INTEGRATION
// env-var convention. We can't import the helper from integration_test.go
// because they're in the same package + the convention is just one
// env-var read.
func integrationOptedIn() bool {
for _, v := range []string{"INTEGRATION", "RUN_INTEGRATION"} {
if val := strings.TrimSpace(getenv(v)); val != "" && val != "0" && !strings.EqualFold(val, "false") {
return true
}
}
return false
}
// getenv is a tiny wrapper so we don't pull in os twice from this file
// (integration_test.go has the canonical envOr that uses os.Getenv).
// Kept self-contained so the est_e2e_test.go file is independently
// readable.
func getenv(k string) string {
v := exec.Command("printenv", k)
out, _ := v.Output()
return strings.TrimSpace(string(out))
}
// TestEST_LibESTClient_Enrollment_Integration is the canonical
// happy-path test. estclient does:
//
// 1. GET cacerts to retrieve the CA chain.
// 2. POST simpleenroll with a freshly-generated CSR; receive the
// issued cert chain back.
// 3. Parse the issued cert + assert Subject CN matches what we asked.
//
// HTTP Basic auth is NOT used here — the test profile (CERTCTL_EST_PROFILE_E2E_*)
// is configured without an enrollment password so the smoke test
// exercises the simplest happy path.
func TestEST_LibESTClient_Enrollment_Integration(t *testing.T) {
requireESTSidecar(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Step 1 — get cacerts. estclient writes the PKCS#7 to /config/est/cacerts.p7.
if _, err := runEstclient(ctx, t, "-g", "-o", "/config/est"); err != nil {
t.Fatalf("get cacerts: %v", err)
}
// Step 2 — generate a CSR + enroll. estclient -e mode generates
// the keypair + the CSR + drives simpleenroll in one shot.
if _, err := runEstclient(ctx, t, "-e", "--common-name", "device-e2e-001.example.com",
"-o", "/config/est"); err != nil {
t.Fatalf("simpleenroll: %v", err)
}
// Step 3 — read the issued cert back via docker exec + parse.
pemBytes, _, err := dockerExec(ctx, libestContainer, "cat", "/config/est/cert-0-0.pkcs7")
if err != nil {
t.Fatalf("read issued cert: %v", err)
}
if !strings.Contains(pemBytes, "BEGIN") && !strings.Contains(pemBytes, "MII") {
t.Errorf("issued cert output didn't look like PEM/base64: first 80 bytes = %q", truncateHead(pemBytes, 80))
}
}
// TestEST_LibESTClient_MTLSEnrollment_Integration drives the mTLS
// sibling route /.well-known/est-mtls/<PathID>/simpleenroll. The
// sidecar carries a bootstrap cert under /config/certs/bootstrap.pem
// signed by the per-profile mTLS trust anchor; estclient presents
// it via the -k/-c flags.
//
// Skip when the bootstrap cert isn't installed in the sidecar (the
// operator has to run a one-time setup script to mint the cert
// against the per-profile trust bundle's CA key — the integration
// suite can't bootstrap that automatically without exposing the
// trust anchor's private key, which we deliberately keep out of git).
func TestEST_LibESTClient_MTLSEnrollment_Integration(t *testing.T) {
requireESTSidecar(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Probe for the bootstrap cert. Skip if the operator hasn't
// pre-provisioned one.
if _, _, err := dockerExec(ctx, libestContainer, "test", "-f", "/config/certs/bootstrap.pem"); err != nil {
t.Skip("/config/certs/bootstrap.pem not present in libest sidecar — skipping mTLS path. To enable: mint a bootstrap cert against the per-profile mTLS trust anchor and copy into deploy/test/certs/.")
}
if _, err := runEstclient(ctx, t,
"-e",
"--pem-output",
"-k", "/config/certs/bootstrap.key",
"-c", "/config/certs/bootstrap.pem",
"--common-name", "device-mtls-001.example.com",
"-o", "/config/est",
); err != nil {
t.Fatalf("mTLS simpleenroll: %v", err)
}
}
// TestEST_LibESTClient_ServerKeygen_Integration drives RFC 7030
// §4.4 server-keygen. estclient submits a CSR + receives the issued
// cert + the encrypted private key (CMS EnvelopedData) in a multipart
// response. The test asserts both parts arrive + the key part is
// non-empty. Decrypting the key requires the CSR-side private key
// (which estclient holds) — left as a smoke check rather than a full
// round-trip because libest's --serverkeygen flag does the decrypt
// internally before writing the key to disk.
func TestEST_LibESTClient_ServerKeygen_Integration(t *testing.T) {
requireESTSidecar(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := runEstclient(ctx, t,
"-e",
"--serverkeygen",
"--common-name", "device-keygen-001.example.com",
"-o", "/config/est",
); err != nil {
// Some libest builds report a non-zero exit when the server
// returns a profile-disabled 404; map that to a Skip so the
// suite stays green when the e2e profile hasn't enabled
// SERVER_KEYGEN. The error message contains "404" in either case.
if strings.Contains(err.Error(), "404") {
t.Skip("server-keygen disabled on the e2e EST profile (HTTP 404). Enable via CERTCTL_EST_PROFILE_E2E_SERVER_KEYGEN_ENABLED=true in docker-compose.test.yml.")
}
t.Fatalf("serverkeygen: %v", err)
}
// Assert the key part was written. estclient writes the private
// key to a deterministic filename when --serverkeygen is set;
// exact name depends on libest version, so we glob.
stdout, _, err := dockerExec(ctx, libestContainer, "sh", "-c",
"ls /config/est/ | grep -E '\\.(key|pkey|p8)$' | head -1")
if err != nil || strings.TrimSpace(stdout) == "" {
t.Errorf("server-keygen response did not write a key file: stdout=%q err=%v", stdout, err)
}
}
// TestEST_LibESTClient_RateLimited_Integration drives N+1 enrollments
// from the same (CN, source-IP) pair to trip the per-principal
// sliding-window rate limiter. The 4th enrollment (default cap=3
// matches Intune's PerDeviceRateLimiter default) MUST fail with a
// 429 response.
//
// The test relies on the e2e profile being configured with
// RATE_LIMIT_PER_PRINCIPAL_24H=3 so the cap is testable in a
// reasonable test window.
func TestEST_LibESTClient_RateLimited_Integration(t *testing.T) {
requireESTSidecar(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
commonName := "device-ratelimit-001.example.com"
allowed := 3
for i := 1; i <= allowed; i++ {
if _, err := runEstclient(ctx, t,
"-e",
"--common-name", commonName,
"-o", "/config/est",
); err != nil {
t.Fatalf("enroll #%d should have succeeded: %v", i, err)
}
}
// (allowed+1)-th attempt MUST be rate-limited.
out, err := runEstclient(ctx, t,
"-e",
"--common-name", commonName,
"-o", "/config/est",
)
if err == nil {
t.Fatalf("enroll #%d should have been rate-limited, but succeeded: %q", allowed+1, out)
}
// estclient surfaces the HTTP status in stderr; the test wrapper
// captures both streams in the err message.
if !strings.Contains(err.Error(), "429") && !strings.Contains(err.Error(), "Too Many") {
t.Errorf("enroll #%d failed but not with a 429-shaped error: %v", allowed+1, err)
}
}
// TestEST_LibESTClient_ChannelBinding_Integration drives the RFC 9266
// tls-exporter binding path. libest's --tls-exporter flag (3.2.0+)
// computes the binding client-side + embeds it as the
// id-aa-est-tls-exporter CMC unsignedAttribute on the CSR.
//
// On the server side we expect the channel-binding gate to pass for
// the matching binding + reject when we forge a wrong binding (libest
// has no explicit "wrong binding" knob — the test exercises only the
// passing path, and the rejection path is covered by the unit test
// suite at internal/cms/channelbinding_test.go).
func TestEST_LibESTClient_ChannelBinding_Integration(t *testing.T) {
requireESTSidecar(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := runEstclient(ctx, t,
"-e",
"--tls-exporter",
"--common-name", "device-binding-001.example.com",
"-o", "/config/est",
); err != nil {
// Libest builds without RFC 9266 support exit non-zero with
// "unknown option --tls-exporter". Surface as Skip so the
// suite stays informative on libest variants that lack it.
if strings.Contains(err.Error(), "unknown option") || strings.Contains(err.Error(), "invalid option") {
t.Skipf("libest build lacks --tls-exporter support: %v", err)
}
t.Fatalf("channel-binding enroll: %v", err)
}
}
// truncateHead returns the first n runes of s (or all of s if it's
// shorter), used to keep error messages from dumping multi-MB cert
// blobs into the test log.
func truncateHead(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}
// silenceUnused keeps imports live across libest builds that may
// trigger a different code path. pem + x509 are both referenced by
// the cert-parsing branch of the Enrollment_Integration test in
// future expansions.
var _ = pem.Decode
var _ = x509.ParseCertificate
+78
View File
@@ -0,0 +1,78 @@
# EST RFC 7030 hardening master bundle Phase 10.1 — libest sidecar.
#
# Multi-stage build of Cisco's libest reference client, used as the
# canonical RFC 7030 client for the certctl integration test suite.
#
# Source: https://github.com/cisco/libest (the upstream reference
# implementation; last tag 3.2.0-2 from 2018, but the protocol surface
# we exercise is stable RFC 7030). We build from source rather than
# pulling a published image because no official Cisco image exists on
# Docker Hub + reproducible offline-friendly builds need a pinned ref.
#
# The builder stage compiles libest + its OpenSSL dependency; the
# runtime stage carries only the compiled `estclient` binary +
# `openssl` + `bash` so the integration test (which docker-execs into
# the container) has a small, predictable surface.
#
# Build (from repo root):
# docker build -f deploy/test/libest/Dockerfile -t certctl/libest:test .
#
# CI uses `docker compose --profile est-e2e build libest-client` to
# orchestrate the build alongside the rest of the test stack.
ARG LIBEST_REF=v3.2.0-2
FROM debian:bookworm-slim AS builder
ARG LIBEST_REF
# Build deps. We use the system openssl (1.1.1n in bookworm-slim) which
# is the same major version libest 3.2.0-2 was tested against. libest
# also wants libcurl + libsafec; we install both via apt rather than
# building from source for reproducibility.
RUN apt-get update && apt-get install --no-install-recommends -y \
autoconf \
automake \
build-essential \
ca-certificates \
git \
libcurl4-openssl-dev \
libssl-dev \
libtool \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --depth 1 --branch ${LIBEST_REF} https://github.com/cisco/libest.git . \
&& ./configure --prefix=/opt/libest --disable-shared --enable-static \
&& make -j"$(nproc)" \
&& make install
# Runtime stage. Carries only what we need to docker-exec estclient
# from the integration test: the compiled binary, the openssl CLI for
# CSR generation + cert parsing, and bash for the test's exec scripts.
FROM debian:bookworm-slim
RUN apt-get update && apt-get install --no-install-recommends -y \
bash \
ca-certificates \
curl \
libcurl4 \
libssl3 \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 estuser
COPY --from=builder /opt/libest/bin/estclient /usr/local/bin/estclient
# /config/est is the working dir the integration test mounts; /config/certs
# carries certctl's CA bundle (./test/certs/ca.crt) for TLS pinning.
RUN mkdir -p /config/est /config/certs && chown -R estuser:estuser /config
USER estuser
WORKDIR /config/est
# Container stays alive so the integration test can docker-exec into
# it; matches the spec's `command: sleep infinity` directive.
CMD ["sleep", "infinity"]
+69
View File
@@ -104,3 +104,72 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
JSON(w, http.StatusOK, result) 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 // errors[]} out). L-1 master added bulk-renew + bulk-reassign
// alongside the pre-existing bulk-revoke. // alongside the pre-existing bulk-revoke.
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke)) 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-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign)) r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates)) 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"` RevocationReason string `json:"revocation_reason,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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. // CertificateVersion represents a specific version of a certificate.
type CertificateVersion struct { type CertificateVersion struct {
ID string `json:"id"` ID string `json:"id"`
+12 -1
View File
@@ -52,9 +52,20 @@ type BulkRevocationCriteria struct {
IssuerID string `json:"issuer_id,omitempty"` IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"` TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,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 { func (c BulkRevocationCriteria) IsEmpty() bool {
return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" && return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" &&
c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0 c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0
+30 -13
View File
@@ -179,7 +179,7 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, 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 FROM managed_certificates
%s %s
ORDER BY %s %s ORDER BY %s %s
@@ -200,13 +200,14 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
var sans pq.StringArray var sans pq.StringArray
var profileID sql.NullString var profileID sql.NullString
var revocationReason sql.NullString var revocationReason sql.NullString
var source sql.NullString
err := rows.Scan( err := rows.Scan(
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID, &cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID, &cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID,
&cert.Status, &cert.ExpiresAt, &tagsJSON, &cert.Status, &cert.ExpiresAt, &tagsJSON,
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason, &cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason,
&cert.CreatedAt, &cert.UpdatedAt) &source, &cert.CreatedAt, &cert.UpdatedAt)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("failed to scan certificate: %w", err) 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 { if revocationReason.Valid {
cert.RevocationReason = revocationReason.String cert.RevocationReason = revocationReason.String
} }
// Phase 11.1: source column.
if source.Valid {
cert.Source = domain.CertificateSource(source.String)
}
// Unmarshal tags // Unmarshal tags
if len(tagsJSON) > 0 { 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) { func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, 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 FROM managed_certificates
WHERE id = $1 WHERE id = $1
`, id) `, id)
@@ -286,7 +291,7 @@ func (r *CertificateRepository) GetByIssuerAndSerial(ctx context.Context, issuer
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `
SELECT mc.id, mc.name, mc.common_name, mc.sans, mc.environment, mc.owner_id, mc.team_id, 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.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 mc.created_at, mc.updated_at
FROM managed_certificates mc FROM managed_certificates mc
JOIN certificate_versions cv ON cv.certificate_id = mc.id 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, ` err = r.db.QueryRowContext(ctx, `
INSERT INTO managed_certificates ( INSERT INTO managed_certificates (
id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, 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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING id RETURNING id
`, cert.ID, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment, `, cert.ID, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment,
cert.OwnerID, cert.TeamID, cert.IssuerID, cert.RenewalPolicyID, profileID, cert.OwnerID, cert.TeamID, cert.IssuerID, cert.RenewalPolicyID, profileID,
cert.Status, cert.ExpiresAt, cert.Status, cert.ExpiresAt,
tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt, tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt,
cert.RevokedAt, revocationReason, cert.RevokedAt, revocationReason, string(cert.Source),
cert.CreatedAt, cert.UpdatedAt).Scan(&cert.ID) cert.CreatedAt, cert.UpdatedAt).Scan(&cert.ID)
if err != nil { if err != nil {
@@ -382,12 +387,13 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed
last_deployment_at = $13, last_deployment_at = $13,
revoked_at = $14, revoked_at = $14,
revocation_reason = $15, revocation_reason = $15,
updated_at = $16 source = $16,
WHERE id = $17 updated_at = $17
WHERE id = $18
`, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment, `, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment,
cert.OwnerID, cert.TeamID, cert.IssuerID, profileID, cert.Status, cert.ExpiresAt, cert.OwnerID, cert.TeamID, cert.IssuerID, profileID, cert.Status, cert.ExpiresAt,
tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt, 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 { if err != nil {
return fmt.Errorf("failed to update certificate: %w", err) 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) { func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, 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 FROM managed_certificates
WHERE expires_at < $1 AND status != $2 WHERE expires_at < $1 AND status != $2
ORDER BY expires_at ASC ORDER BY expires_at ASC
@@ -510,13 +516,14 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
var sans pq.StringArray var sans pq.StringArray
var profileID sql.NullString var profileID sql.NullString
var revocationReason sql.NullString var revocationReason sql.NullString
var source sql.NullString
err := rows.Scan( err := rows.Scan(
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID, &cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID, &cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID,
&cert.Status, &cert.ExpiresAt, &tagsJSON, &cert.Status, &cert.ExpiresAt, &tagsJSON,
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason, &cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason,
&cert.CreatedAt, &cert.UpdatedAt) &source, &cert.CreatedAt, &cert.UpdatedAt)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan certificate: %w", err) 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 { if revocationReason.Valid {
cert.RevocationReason = revocationReason.String cert.RevocationReason = revocationReason.String
} }
if source.Valid {
cert.Source = domain.CertificateSource(source.String)
}
// Unmarshal tags // Unmarshal tags
if len(tagsJSON) > 0 { if len(tagsJSON) > 0 {
@@ -668,13 +678,14 @@ func (r *CertificateRepository) scanCertificate(ctx context.Context, scanner int
var sans pq.StringArray var sans pq.StringArray
var profileID sql.NullString var profileID sql.NullString
var revocationReason sql.NullString var revocationReason sql.NullString
var source sql.NullString
err := scanner.Scan( err := scanner.Scan(
&cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID, &cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID,
&cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID, &cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID,
&cert.Status, &cert.ExpiresAt, &tagsJSON, &cert.Status, &cert.ExpiresAt, &tagsJSON,
&cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason, &cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason,
&cert.CreatedAt, &cert.UpdatedAt) &source, &cert.CreatedAt, &cert.UpdatedAt)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan certificate: %w", err) 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 { if revocationReason.Valid {
cert.RevocationReason = revocationReason.String 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 // Unmarshal tags
if len(tagsJSON) > 0 { if len(tagsJSON) > 0 {
+18 -1
View File
@@ -151,7 +151,24 @@ func (s *BulkRevocationService) resolveCertificates(ctx context.Context, criteri
filtered = append(filtered, cert) 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 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. // SimpleEnroll processes an initial enrollment request.
// RFC 7030 Section 4.2: /simpleenroll accepts a PKCS#10 CSR and returns a signed cert. // 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) { 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. // SimpleReEnroll processes a re-enrollment request.
// RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll // RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll
// but is used when renewing an existing certificate. // but is used when renewing an existing certificate.
func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { 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. // 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. // 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 // Parse the CSR to extract CN and SANs
block, _ := pem.Decode([]byte(csrPEM)) block, _ := pem.Decode([]byte(csrPEM))
if block == nil { if block == nil {
s.counters.inc(estCounterCSRInvalid) s.counters.inc(estCounterCSRInvalid)
emitFailed("csr_pem_decode", fmt.Errorf("invalid CSR PEM"))
return nil, fmt.Errorf("invalid CSR PEM") return nil, fmt.Errorf("invalid CSR PEM")
} }
csr, err := x509.ParseCertificateRequest(block.Bytes) csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil { if err != nil {
s.counters.inc(estCounterCSRInvalid) s.counters.inc(estCounterCSRInvalid)
emitFailed("csr_parse", err)
return nil, fmt.Errorf("failed to parse CSR: %w", err) return nil, fmt.Errorf("failed to parse CSR: %w", err)
} }
if err := csr.CheckSignature(); err != nil { if err := csr.CheckSignature(); err != nil {
s.counters.inc(estCounterCSRSignatureMismatch) s.counters.inc(estCounterCSRSignatureMismatch)
emitFailed("csr_signature", err)
return nil, fmt.Errorf("CSR signature verification failed: %w", err) return nil, fmt.Errorf("CSR signature verification failed: %w", err)
} }
commonName := csr.Subject.CommonName commonName := csr.Subject.CommonName
if commonName == "" { if commonName == "" {
s.counters.inc(estCounterCSRInvalid) s.counters.inc(estCounterCSRInvalid)
emitFailed("csr_missing_cn", fmt.Errorf("missing CN"))
return nil, fmt.Errorf("CSR must include a Common Name") 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 { if _, csrErr := ValidateCSRAgainstProfile(csrPEM, profile); csrErr != nil {
s.counters.inc(estCounterCSRPolicyViolation) 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", s.logger.Error("EST enrollment rejected: crypto policy violation",
"action", auditAction, "action", auditAction,
"common_name", commonName, "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) result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
if err != nil { if err != nil {
s.counters.inc(estCounterIssuerError) s.counters.inc(estCounterIssuerError)
emitFailed("issuer_error", err)
s.logger.Error("EST enrollment failed", s.logger.Error("EST enrollment failed",
"action", auditAction, "action", auditAction,
"common_name", commonName, "common_name", commonName,
@@ -276,7 +324,10 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
s.counters.inc(estCounterSuccessSimpleEnroll) 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 { if s.auditService != nil {
details := map[string]interface{}{ details := map[string]interface{}{
"common_name": commonName, "common_name": commonName,
@@ -289,6 +340,7 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
details["profile_id"] = s.profileID details["profile_id"] = s.profileID
} }
_ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction, "certificate", result.Serial, details) _ = 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", 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 details["profile_id"] = s.profileID
} }
_ = s.auditService.RecordEvent(ctx, "est-client", "system", "est_server_keygen", "certificate", issued.Serial, details) _ = 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", s.logger.Info("EST serverkeygen successful",
"common_name", commonName, "serial", issued.Serial, "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 package service
import ( import (
"context"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -174,11 +175,27 @@ func (s *ESTService) Stats(now time.Time) ESTStatsSnapshot {
// //
// Returns ErrESTMTLSDisabled when the profile doesn't have an mTLS // Returns ErrESTMTLSDisabled when the profile doesn't have an mTLS
// trust anchor configured (admin handler maps to HTTP 409). // 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 { func (s *ESTService) ReloadTrust() error {
if s.estTrustAnchor == nil { if s.estTrustAnchor == nil {
return ErrESTMTLSDisabled 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 // ErrESTMTLSDisabled signals the admin handler that an EST profile
@@ -0,0 +1,2 @@
-- EST RFC 7030 hardening master bundle Phase 11.1 rollback.
ALTER TABLE managed_certificates DROP COLUMN IF EXISTS source;
@@ -0,0 +1,19 @@
-- EST RFC 7030 hardening master bundle Phase 11.1.
--
-- Add `source` TEXT column to managed_certificates so the bulk-revoke
-- handler can filter by provenance (EST / SCEP / API / Agent /
-- legacy-empty). Empty value preserves v2.X.0 behavior — existing
-- rows scan as Source="" + the bulk-revoke filter treats empty as
-- "any source", so no existing call path sees a behavior change.
--
-- New EST issuances (Phases 5 + 11 of this bundle) stamp Source="EST";
-- new SCEP issuances continue to land with Source="" until a follow-up
-- bundle wires the stamp at the SCEP service layer.
--
-- An index would only pay off when bulk-revoke is called frequently
-- AND the table is large; both prerequisites are unlikely at GA, so
-- defer the index to a follow-up if observability flags slow filter
-- queries in production.
ALTER TABLE managed_certificates
ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT '';