mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:41:30 +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:
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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==
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -62,11 +73,11 @@ func (c BulkRevocationCriteria) IsEmpty() bool {
|
|||||||
|
|
||||||
// BulkRevocationResult contains the outcome of a bulk revocation operation.
|
// BulkRevocationResult contains the outcome of a bulk revocation operation.
|
||||||
type BulkRevocationResult struct {
|
type BulkRevocationResult struct {
|
||||||
TotalMatched int `json:"total_matched"`
|
TotalMatched int `json:"total_matched"`
|
||||||
TotalRevoked int `json:"total_revoked"`
|
TotalRevoked int `json:"total_revoked"`
|
||||||
TotalSkipped int `json:"total_skipped"`
|
TotalSkipped int `json:"total_skipped"`
|
||||||
TotalFailed int `json:"total_failed"`
|
TotalFailed int `json:"total_failed"`
|
||||||
Errors []BulkRevocationError `json:"errors,omitempty"`
|
Errors []BulkRevocationError `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkRevocationError records a per-certificate revocation failure.
|
// BulkRevocationError records a per-certificate revocation failure.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
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 '';
|
||||||
Reference in New Issue
Block a user