mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:51:30 +00:00
b33b843908
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.
Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.
== Phase 4: RenewalReq + GetCertInitial wiring ============================
internal/service/scep.go
* RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
existing valid cert. Same contract as PKCSReqWithEnvelope but the
service additionally verifies that envelope.SignerCert chains to
the issuer's CA (verifyRenewalSignerCertChain). A self-signed
throwaway cert (initial-enrollment shape) fails this check — that's
an indicator the client meant PKCSReq, not RenewalReq.
* GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
Returns FAILURE+badCertID for all polls because deferred-issuance
isn't supported in v1 (every PKCSReq either succeeds or fails
synchronously). Wiring stays in place for a future enhancement.
* Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
grep the audit log to distinguish initial enrollments from renewals.
internal/api/handler/scep.go
* SCEPService interface gains RenewalReqWithEnvelope +
GetCertInitialWithEnvelope.
* pkiOperation RFC 8894 path now switches on envelope.MessageType:
PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
badRequest per RFC 8894 §3.3.2.2.
== Phase 5.1: GetCACaps capability advertisement =========================
internal/service/scep.go
* Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
to add 'SHA-512' (modern digest alternative now implemented in the
Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
Phase 4). ChromeOS specifically looks for these capabilities to
negotiate the strongest available cipher + digest combo.
* scep_test.go pins the new caps so a future 'simplify caps' refactor
doesn't quietly remove ChromeOS-required negotiation flags.
== Phase 5.2: ChromeOS-shape integration tests ===========================
internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
* 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
in-test (acting as the ChromeOS client), POSTs through the handler,
parses the CertRep response back via the same internal/pkcs7/
builders the handler uses.
* TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
POSTed; verifies CertRep parses + RA signature verifies.
* TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
* TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
* TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
signature falls through to MVP path (which also rejects since the
encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
* TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
* TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
path keeps working when no RA pair is configured. Backward compat
is non-negotiable.
== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============
internal/domain/profile.go
* Added MustStaple bool to CertificateProfile. Default false; operators
opt in once they've confirmed the TLS reverse proxy / load balancer
staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
require explicit config).
internal/connector/issuer/interface.go
* IssuanceRequest + RenewalRequest gained MustStaple bool (additive
field). Connectors that don't support extension injection (Vault,
EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
issuer-only feature in V2 since upstream connectors enforce their
own extension policy.
internal/connector/issuer/local/local.go
* Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
RFC 7633 §6).
* generateCertificate signature gained mustStaple bool; when true,
appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
mustStapleExtensionValue} to template.ExtraExtensions before
x509.CreateCertificate.
internal/connector/issuer/local/must_staple_test.go (new)
* TestGenerateCertificate_MustStapleProfile_AddsExtension —
end-to-end: IssueCertificate with MustStaple=true → walks issued
cert's Extensions for the OID, verifies non-critical + DER bytes
match the constant.
* TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
'omit by default' contract (adding it by default would break
customer deployments where the TLS path doesn't staple).
* TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
DER bytes against RFC 7633 §6 verbatim; round-trips through
asn1.Unmarshal as []int{5}.
Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.
== Phase 5.4: docs/legacy-est-scep.md ====================================
Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).
== Verification ==========================================================
* gofmt + go vet clean for the files I touched.
* staticcheck ./internal/api/handler/... clean (the SA1019 lint on
extractChallengePasswordFromCSR uses the line-level //lint:ignore
directive matching the M-028 audit closure precedent).
* go test -short -count=1 green across api/handler / api/router /
service / pkcs7 / connector/issuer/local / domain / cmd/server.
* G-3 docs-drift CI guard local check: empty diff in both directions.
Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.
Living progress at cowork/scep-rfc8894-intune/progress.md.
302 lines
8.5 KiB
Go
302 lines
8.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/pem"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// mockSCEPService implements SCEPService for testing.
|
|
type mockSCEPService struct {
|
|
CACaps string
|
|
CACertPEM string
|
|
CACertErr error
|
|
EnrollResult *domain.SCEPEnrollResult
|
|
EnrollErr error
|
|
}
|
|
|
|
func (m *mockSCEPService) GetCACaps(ctx context.Context) string {
|
|
if m.CACaps != "" {
|
|
return m.CACaps
|
|
}
|
|
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
|
|
}
|
|
|
|
func (m *mockSCEPService) GetCACert(ctx context.Context) (string, error) {
|
|
return m.CACertPEM, m.CACertErr
|
|
}
|
|
|
|
func (m *mockSCEPService) PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error) {
|
|
return m.EnrollResult, m.EnrollErr
|
|
}
|
|
|
|
// PKCSReqWithEnvelope is the RFC 8894 envelope-aware variant added in SCEP
|
|
// RFC 8894 + Intune master bundle Phase 2.4. The MVP-only handler tests
|
|
// don't exercise this path (RA pair is unset), so this stub is only here
|
|
// to satisfy the interface; behavior mirrors PKCSReq's success/failure
|
|
// based on the same EnrollResult / EnrollErr fields the existing tests
|
|
// already populate.
|
|
func (m *mockSCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
|
if m.EnrollErr != nil {
|
|
return &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusFailure,
|
|
FailInfo: domain.SCEPFailBadRequest,
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
}
|
|
return &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusSuccess,
|
|
Result: m.EnrollResult,
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
}
|
|
|
|
// RenewalReqWithEnvelope + GetCertInitialWithEnvelope added in Phase 4 to
|
|
// satisfy the extended SCEPService interface. Same MVP-only test fixture
|
|
// rules apply — these stubs mirror PKCSReqWithEnvelope's shape.
|
|
func (m *mockSCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
|
return m.PKCSReqWithEnvelope(ctx, csrPEM, challengePassword, envelope)
|
|
}
|
|
|
|
func (m *mockSCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
|
return &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusFailure,
|
|
FailInfo: domain.SCEPFailBadCertID,
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
}
|
|
|
|
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
ct := w.Header().Get("Content-Type")
|
|
if ct != "text/plain" {
|
|
t.Errorf("expected text/plain, got %s", ct)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "POSTPKIOperation") {
|
|
t.Errorf("expected POSTPKIOperation in response, got: %s", body)
|
|
}
|
|
if !strings.Contains(body, "SHA-256") {
|
|
t.Errorf("expected SHA-256 in response, got: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_GetCACaps_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACaps", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_GetCACert_Success_SingleCert(t *testing.T) {
|
|
certPEM := generateTestCertPEM(t)
|
|
svc := &mockSCEPService{
|
|
CACertPEM: certPEM,
|
|
}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACert", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
ct := w.Header().Get("Content-Type")
|
|
if ct != "application/x-x509-ca-cert" {
|
|
t.Errorf("expected application/x-x509-ca-cert, got %s", ct)
|
|
}
|
|
if w.Body.Len() == 0 {
|
|
t.Error("expected non-empty body")
|
|
}
|
|
}
|
|
|
|
func TestSCEP_GetCACert_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACert", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_GetCACert_ServiceError(t *testing.T) {
|
|
svc := &mockSCEPService{
|
|
CACertErr: errors.New("CA unavailable"),
|
|
}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACert", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_PKIOperation_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=PKIOperation", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_PKIOperation_EmptyBody(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(""))
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_PKIOperation_InvalidBody(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader("not-valid-asn1-or-csr"))
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSCEP_PKIOperation_ServiceError(t *testing.T) {
|
|
svc := &mockSCEPService{
|
|
EnrollErr: errors.New("enrollment failed"),
|
|
}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
// Generate a valid raw CSR DER to send as body (fallback path)
|
|
csrPEM := generateTestCSRPEM(t)
|
|
block, _ := pem.Decode([]byte(csrPEM))
|
|
if block == nil {
|
|
t.Fatal("failed to decode CSR PEM")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSCEP_PKIOperation_Success_RawCSR(t *testing.T) {
|
|
certPEM := generateTestCertPEM(t)
|
|
svc := &mockSCEPService{
|
|
EnrollResult: &domain.SCEPEnrollResult{
|
|
CertPEM: certPEM,
|
|
ChainPEM: "",
|
|
},
|
|
}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
csrPEM := generateTestCSRPEM(t)
|
|
block, _ := pem.Decode([]byte(csrPEM))
|
|
if block == nil {
|
|
t.Fatal("failed to decode CSR PEM")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
ct := w.Header().Get("Content-Type")
|
|
if ct != "application/x-pki-message" {
|
|
t.Errorf("expected application/x-pki-message, got %s", ct)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
|
|
svc := &mockSCEPService{
|
|
EnrollErr: errors.New("invalid challenge password"),
|
|
}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
csrPEM := generateTestCSRPEM(t)
|
|
block, _ := pem.Decode([]byte(csrPEM))
|
|
if block == nil {
|
|
t.Fatal("failed to decode CSR PEM")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSCEP_UnknownOperation(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=UnknownOp", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSCEP_MissingOperation(t *testing.T) {
|
|
svc := &mockSCEPService{}
|
|
h := NewSCEPHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep", nil)
|
|
w := httptest.NewRecorder()
|
|
h.HandleSCEP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|