mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:41:30 +00:00
43075a1b5c
+ profile-driven csrattrs + admin observability with per-status counters + reload-trust endpoint. Phase 5 — RFC 7030 §4.4 server-driven key generation: - internal/pkcs7/envelopeddata_builder.go is the inverse of the existing parser/decryptor: AES-256-CBC content cipher + RSA PKCS#1 v1.5 keyTrans + per-call random IV. Round-trip pinned in test (BuildEnvelopedData → ParseEnvelopedData → Decrypt returns the original plaintext byte-for-byte). - ESTService.SimpleServerKeygen runs the full §4.4 flow: parse client CSR → require RSA pubkey for keyTrans → resolve per-profile algorithm (RSA-2048 default; honors AllowedKeyAlgorithms) → in- memory keygen → re-build CSR with server pubkey → run existing issuer pipeline → marshal PKCS#8 → CMS-EnvelopedData wrap to a synthetic recipient cert wrapping the device's CSR-supplied pubkey → zeroize plaintext + PKCS#8 bytes → return CertPEM + ChainPEM + EncryptedKey. Typed sentinels ErrServerKeygenRequiresKey- Encipherment / ErrServerKeygenUnsupportedAlgorithm / ErrServerKeygenDisabled. - ESTHandler.ServerKeygen + ServerKeygenMTLS emit RFC 7030 §4.4.2 multipart/mixed with random per-response boundary; per-profile SetServerKeygenEnabled gate returns 404 when off (defense in depth even if the route was registered). - New routes POST /.well-known/est/[<PathID>/]serverkeygen + /.well-known/est-mtls/<PathID>/serverkeygen; openapi.yaml + openapi-parity guard updated. Phase 6 — Real csrattrs implementation: - New CertificateProfile.RequiredCSRAttributes []string + migration 000022_certificate_profiles_csrattrs.up.sql. The migration also lands the previously-unwired must_staple column (closes the 5.6 follow-up loop where the field shipped at the domain + service layer but the postgres scan/insert/update never persisted it). - domain.EKUStringToOID + AttributeStringToOID lookup tables: id-kp-* EKUs (RFC 5280 §4.2.1.12) + RFC 5280 DN attributes + RFC 2985 PKCS#10 attributes + Microsoft Intune device-serial OID. - ESTService.GetCSRAttrs replaces the v2.0.x nil/204 stub with a profile-derived SEQUENCE OF OID ASN.1 marshal. Unknown EKU / attribute strings dropped + warning-logged so a typo doesn't take down the entire endpoint. Phase 7 — Admin observability + counters + reload-trust: - internal/service/est_counters.go: estCounterTab (sync/atomic; 12 named labels) + ESTStatsSnapshot per-profile shape + ESTService.Stats(now) zero-allocation accessor + ReloadTrust() SIGHUP-equivalent + SetESTAdminMetadata setter. - Counter ticks wired into processEnrollment + SimpleServerKeygen at every success/failure leg. - internal/api/handler/admin_est.go mirrors AdminSCEPIntune verbatim: Profiles + ReloadTrust handlers + AdminESTServiceImpl. Both endpoints admin-gated (M-008 triplet pinned + admin_est.go added to AdminGatedHandlers). - New routes GET /api/v1/admin/est/profiles + POST /api/v1/admin/ est/reload-trust; openapi.yaml documented; openapi-parity guard reproduced clean. - cmd/server/main.go grows estServices map populated by the per- profile EST loop + handed to AdminEST. New MTLSTrust() + HasMTLSTrust() accessors on ESTHandler so main.go can pull the trust holder for the admin-metadata wire-up. - Per-profile counter isolation regression test (internal/service/est_profile_counter_isolation_test.go) proves a future shared-counter refactor would fail at compile-time pointer-identity check. Pre-commit verification (sandbox): gofmt clean, go vet clean (excluding repository/postgres which the sandbox can't build — disk-space testcontainers download), staticcheck clean across cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/ service/pkcs7/domain/cmd/server, go test -short -count=1 green for every non-postgres package. G-3 docs-drift guard reproduced locally clean (Phases 5-7 added zero new env vars; Phase 1 already documented per-profile SERVER_KEYGEN_ENABLED). Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases 8-13 (GUI ESTAdminPage / CLI+MCP / libest e2e / bulk revocation / docs/est.md / release prep) remain — post-2.1.0 work.
306 lines
9.8 KiB
Go
306 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/asn1"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// generateCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
|
func generateCSRPEM(t *testing.T, cn string, sans []string) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
template := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: cn},
|
|
DNSNames: sans,
|
|
}
|
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
|
if err != nil {
|
|
t.Fatalf("create CSR: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
|
}
|
|
|
|
func TestESTService_GetCACerts_Success(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
caPEM, err := svc.GetCACerts(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if caPEM == "" {
|
|
t.Error("expected non-empty CA PEM")
|
|
}
|
|
}
|
|
|
|
func TestESTService_GetCACerts_IssuerError(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{Err: errors.New("CA unavailable")}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
_, err := svc.GetCACerts(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "CA unavailable") {
|
|
t.Errorf("expected error to contain 'CA unavailable', got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestESTService_SimpleEnroll_Success(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
auditRepo := newMockAuditRepository()
|
|
auditSvc := NewAuditService(auditRepo)
|
|
svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
csrPEM := generateCSRPEM(t, "test.example.com", []string{"test.example.com"})
|
|
|
|
result, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.CertPEM == "" {
|
|
t.Error("expected non-empty CertPEM")
|
|
}
|
|
|
|
// Verify audit event was recorded
|
|
if len(auditRepo.Events) == 0 {
|
|
t.Error("expected audit event to be recorded")
|
|
}
|
|
}
|
|
|
|
func TestESTService_SimpleEnroll_InvalidCSR(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
_, err := svc.SimpleEnroll(context.Background(), "not-valid-pem")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid CSR")
|
|
}
|
|
}
|
|
|
|
func TestESTService_SimpleEnroll_MissingCN(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
csrPEM := generateCSRPEM(t, "", []string{"test.example.com"})
|
|
|
|
_, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing CN")
|
|
}
|
|
if !strings.Contains(err.Error(), "Common Name") {
|
|
t.Errorf("expected 'Common Name' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestESTService_SimpleEnroll_IssuerError(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{Err: errors.New("issuance failed")}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
csrPEM := generateCSRPEM(t, "test.example.com", nil)
|
|
|
|
_, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "issuance failed") {
|
|
t.Errorf("expected 'issuance failed', got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestESTService_SimpleReEnroll_Success(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
csrPEM := generateCSRPEM(t, "renew.example.com", []string{"renew.example.com"})
|
|
|
|
result, err := svc.SimpleReEnroll(context.Background(), csrPEM)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
}
|
|
|
|
func TestESTService_GetCSRAttrs_Empty(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
|
|
attrs, err := svc.GetCSRAttrs(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if attrs != nil {
|
|
t.Errorf("expected nil attrs, got %v", attrs)
|
|
}
|
|
}
|
|
|
|
func TestESTService_SimpleEnroll_WithProfile(t *testing.T) {
|
|
mockIssuer := &mockIssuerConnector{}
|
|
auditRepo := newMockAuditRepository()
|
|
auditSvc := NewAuditService(auditRepo)
|
|
svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
|
svc.SetProfileID("profile-wifi-client")
|
|
|
|
csrPEM := generateCSRPEM(t, "device.example.com", nil)
|
|
|
|
result, err := svc.SimpleEnroll(context.Background(), csrPEM)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
|
|
// Verify audit event includes profile_id
|
|
if len(auditRepo.Events) == 0 {
|
|
t.Fatal("expected audit event")
|
|
}
|
|
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
|
|
if lastEvent.Details == nil {
|
|
t.Fatal("expected audit details")
|
|
}
|
|
}
|
|
|
|
// EST RFC 7030 hardening master bundle Phase 6.3 csrattrs tests.
|
|
// Pin the contract that GetCSRAttrs returns DER(SEQUENCE OF OID) when the
|
|
// bound profile carries hints, falls back to the v2.0.x nil/204 stub when
|
|
// the profile is absent / empty / corrupt, and silently drops unknown
|
|
// EKU/attribute names rather than emitting garbage OIDs.
|
|
|
|
func newCSRAttrsTestService(t *testing.T) (*ESTService, *mockProfileRepo) {
|
|
t.Helper()
|
|
repo := newMockProfileRepository()
|
|
silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
|
svc := NewESTService("iss-local", &mockIssuerConnector{}, nil, silent)
|
|
svc.SetProfileRepo(repo)
|
|
return svc, repo
|
|
}
|
|
|
|
func TestESTService_GetCSRAttrs_NoProfileBound_Returns204Body(t *testing.T) {
|
|
svc, _ := newCSRAttrsTestService(t)
|
|
// SetProfileID intentionally NOT called — handler should see empty body
|
|
// + write 204 per RFC 7030 §4.5.2 (legacy stub semantic preserved).
|
|
got, err := svc.GetCSRAttrs(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("got non-nil body for unbound profile: %x", got)
|
|
}
|
|
}
|
|
|
|
func TestESTService_GetCSRAttrs_ProfileWithEKUsAndAttrs_ReturnsOIDList(t *testing.T) {
|
|
svc, repo := newCSRAttrsTestService(t)
|
|
svc.SetProfileID("prof-corp")
|
|
repo.AddProfile(&domain.CertificateProfile{
|
|
ID: "prof-corp",
|
|
Name: "corp",
|
|
AllowedEKUs: []string{"serverAuth", "clientAuth"},
|
|
RequiredCSRAttributes: []string{"serialNumber"},
|
|
Enabled: true,
|
|
})
|
|
|
|
der, err := svc.GetCSRAttrs(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(der) == 0 {
|
|
t.Fatal("expected non-empty body for profile with hints")
|
|
}
|
|
var got []asn1.ObjectIdentifier
|
|
if _, err := asn1.Unmarshal(der, &got); err != nil {
|
|
t.Fatalf("body should be DER(SEQUENCE OF OID); unmarshal: %v", err)
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 OIDs (2 EKUs + 1 attribute), got %d: %v", len(got), got)
|
|
}
|
|
// Pin the exact OIDs so a future EKUStringToOID typo trips the test.
|
|
wantSerialNumberOID := asn1.ObjectIdentifier{2, 5, 4, 5}
|
|
wantServerAuthOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 1}
|
|
wantClientAuthOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 2}
|
|
have := make(map[string]bool, len(got))
|
|
for _, o := range got {
|
|
have[o.String()] = true
|
|
}
|
|
for _, want := range []asn1.ObjectIdentifier{wantServerAuthOID, wantClientAuthOID, wantSerialNumberOID} {
|
|
if !have[want.String()] {
|
|
t.Errorf("missing OID %v in csrattrs response", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestESTService_GetCSRAttrs_EmptyProfile_Returns204Body(t *testing.T) {
|
|
svc, repo := newCSRAttrsTestService(t)
|
|
svc.SetProfileID("prof-empty")
|
|
repo.AddProfile(&domain.CertificateProfile{
|
|
ID: "prof-empty",
|
|
Name: "empty",
|
|
Enabled: true,
|
|
})
|
|
got, err := svc.GetCSRAttrs(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("empty profile should return nil body for 204; got %x", got)
|
|
}
|
|
}
|
|
|
|
func TestESTService_GetCSRAttrs_GarbageProfile_DropsUnknownAndKeepsValid(t *testing.T) {
|
|
svc, repo := newCSRAttrsTestService(t)
|
|
svc.SetProfileID("prof-garbage")
|
|
repo.AddProfile(&domain.CertificateProfile{
|
|
ID: "prof-garbage",
|
|
Name: "garbage",
|
|
AllowedEKUs: []string{"serverAuth", "thisIsNotAnEKU"},
|
|
RequiredCSRAttributes: []string{"serialNumber", "blarg-not-an-attribute"},
|
|
Enabled: true,
|
|
})
|
|
der, err := svc.GetCSRAttrs(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var got []asn1.ObjectIdentifier
|
|
if _, err := asn1.Unmarshal(der, &got); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Errorf("expected 2 OIDs (the valid subset); got %d: %v", len(got), got)
|
|
}
|
|
}
|
|
|
|
func TestESTService_GetCSRAttrs_ProfileLookupError_DegradesToNoHints(t *testing.T) {
|
|
svc, repo := newCSRAttrsTestService(t)
|
|
svc.SetProfileID("prof-missing")
|
|
repo.GetErr = errors.New("repo unreachable")
|
|
got, err := svc.GetCSRAttrs(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("profile lookup error must NOT propagate; got: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("profile-lookup-error path must degrade to nil body; got %x", got)
|
|
}
|
|
}
|