mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
a546a1bbef
SCEP RFC 8894 + Intune master bundle — Phase 2 of 14.
Implements the new RFC 8894 PKIMessage parse path: EnvelopedData parser
+ decryptor, signerInfo parser + signature verifier, handler dispatch
that tries the RFC 8894 path FIRST and falls through to the legacy MVP
raw-CSR path on any parse failure. Backward compat with lightweight SCEP
clients is preserved by design — no behavior change for any existing
deploy that doesn't set CERTCTL_SCEP_RA_*.
internal/pkcs7/envelopeddata.go (new, ~330 LoC)
* ParseEnvelopedData: parses CMS EnvelopedData per RFC 5652 §6.1, with
optional outer ContentInfo unwrapping. Handles SET OF RecipientInfo
+ IssuerAndSerial form rid (RFC 8894 §3.2.2).
* EnvelopedData.Decrypt: RSA PKCS#1 v1.5 key-trans + AES-CBC (128/192/
256) or DES-EDE3-CBC content decryption with **constant-time PKCS#7
padding strip** (no branch on padding-byte values; closes the
padding-oracle leak surface). Recipient mismatch is BadMessageCheck
per RFC 8894 §3.3.2.2 (NOT BadCertID); every failure mode returns
the same ErrEnvelopedDataDecrypt sentinel to close timing-leak legs
of Bleichenbacher attacks.
* Equivalent to micromdm/scep's cryptoutil/cryptoutil.go::DecryptPKCS-
Envelope (cited in code comments; not vendored — fuzz-target
ownership stays in this sub-package per the operating rule).
internal/pkcs7/signedinfo.go (new, ~370 LoC)
* ParseSignedData / ParseSignerInfos: parses CMS SignedData per RFC
5652 §5.3. Resolves each SignerInfo's SID (IssuerAndSerial v1 OR
[0] SubjectKeyId v3) against the SignedData certificates SET to
pluck the device's transient signing cert.
* SignerInfo.VerifySignature: re-serialises signedAttrs as the
canonical SET OF Attribute (the RFC 5652 §5.4 quirk every CMS
implementation hits — wire form is [0] IMPLICIT but the signature
is over EXPLICIT SET OF). Hashes with SHA-1/SHA-256/SHA-512 +
verifies via RSA PKCS1v15 or ECDSA per the cert's pubkey type.
* Auth-attr extractors: GetMessageType (PrintableString-decimal),
GetTransactionID, GetSenderNonce, GetMessageDigest. SCEP attr OIDs
pinned (RFC 8894 §3.2.1.4).
internal/pkcs7/{envelopeddata,signedinfo}_fuzz_test.go (new)
* FuzzParseEnvelopedData / FuzzParseSignedData / FuzzParseSignerInfos
/ FuzzVerifySignerInfoSignature — every parser certctl adds gets a
panic-safety fuzzer (the fuzz-target-ownership rule from
cowork/CLAUDE.md::Operating Rules). Local 5s runs hit ~270k
executions per parser without panic. Errors are expected for
arbitrary inputs; only panics are bugs.
internal/pkcs7/{envelopeddata,signedinfo}_test.go (new)
* Round-trip tests that materialise real RSA/ECDSA pairs, hand-build
the wire bytes, parse + decrypt + verify, and assert plaintext /
auth-attr equality. The build helpers use this package's ASN1Wrap
primitives directly (asn1.Marshal of structs containing nested
asn1.RawValue is finicky for mixed Class/Tag); gives byte-level
control matching what real SCEP clients emit.
* Negative tests: tampered ciphertext / tampered auth-attrs / wrong
RA / wrong key / mismatched recipients / random garbage all return
the appropriate sentinel error without panic.
internal/service/scep.go
* PKCSReqWithEnvelope: RFC 8894 envelope-aware variant. Returns
*SCEPResponseEnvelope (not error + *SCEPEnrollResult) because RFC
8894 §3.3 mandates a CertRep PKIMessage on every response, even
failures — the handler shouldn't translate Go errors into SCEP
failInfo codes. Returns nil to signal 'invalid challenge password'
so the caller can translate to HTTP 403 (matches MVP path's wire
shape; RFC 8894 §3.3.1 is silent on this case).
* mapServiceErrorToFailInfo: exact mapping table from the prompt
(CSR parse → BadRequest, CSR sig → BadMessageCheck, crypto policy
→ BadAlg, default → BadRequest).
internal/api/handler/scep.go
* SCEPService interface gains PKCSReqWithEnvelope.
* SCEPHandler now optionally carries an RA cert + key pair. SetRAPair
upgrades the handler to the RFC 8894 path; without that call the
handler stays MVP-only (the v2.0.x behavior).
* pkiOperation: tries the RFC 8894 path FIRST when the RA pair is
set. tryParseRFC8894 helper does the full pipeline (ParseSignedData
→ VerifySignature → extract auth-attrs → ParseEnvelopedData → Decrypt
→ x509.ParseCertificateRequest the recovered bytes). On any failure
it falls through to the legacy extractCSRFromPKCS7 MVP path —
backward compat is non-negotiable.
* Phase 2 emits the legacy certs-only response on RFC 8894 success;
Phase 3 (next commit) swaps in writeCertRepPKIMessage with the
proper status / failInfo / nonce-echo wire shape.
cmd/server/main.go
* Per-profile loop now calls loadSCEPRAPair after preflight to load
the cert + key + inject via SetRAPair. crypto + crypto/tls imports
added.
* loadSCEPRAPair helper: tls.X509KeyPair-based parse + leaf cert
extraction. Failures here indicate TOCTOU between preflight + load.
internal/api/handler/scep_handler_test.go +
internal/api/router/router_scep_profiles_test.go
* mockSCEPService / scepProfileMockService gain PKCSReqWithEnvelope
stubs to satisfy the extended interface. Existing test cases
unchanged (they exercise the MVP path; RA pair is unset).
Verification:
* gofmt + go vet clean for the files I touched.
* go test -short -count=1 green across pkcs7 / api/handler /
api/router / service / cmd/server.
* Coverage: pkcs7 78.4% (was 100% — drops because new code includes
paths the round-trip tests don't yet hit, like decryption alg
fall-through and v3 SubjectKeyId SID matching).
* Fuzz-target seed-corpus runs (5s each, ~270k execs/parser): no
panic. Pre-merge fuzz-time bumps to 30s per the prompt's
verification gate.
Phase 2 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
286 lines
7.7 KiB
Go
286 lines
7.7 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,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|