mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 18:18:52 +00:00
EST RFC 7030 hardening master bundle Phases 5-7: end-to-end serverkeygen
+ 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.
This commit is contained in:
@@ -7,12 +7,16 @@ import (
|
||||
"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.
|
||||
@@ -178,3 +182,124 @@ func TestESTService_SimpleEnroll_WithProfile(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user