mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:41:30 +00:00
feat(scep): EnvelopedData decrypt + signerInfo POPO verify (RFC 8894 §3.2)
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.
This commit is contained in:
+46
-1
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -791,7 +792,19 @@ func main() {
|
|||||||
if profile.ProfileID != "" {
|
if profile.ProfileID != "" {
|
||||||
scepService.SetProfileID(profile.ProfileID)
|
scepService.SetProfileID(profile.ProfileID)
|
||||||
}
|
}
|
||||||
scepHandlers[profile.PathID] = handler.NewSCEPHandler(scepService)
|
scepHandler := handler.NewSCEPHandler(scepService)
|
||||||
|
// SCEP RFC 8894 Phase 2.3: load the per-profile RA pair so the
|
||||||
|
// handler can run the new RFC 8894 PKIMessage path. Preflight
|
||||||
|
// already validated the pair (file mode 0600 + cert/key match
|
||||||
|
// + non-expired + RSA-or-ECDSA). Failure here is a deploy bug
|
||||||
|
// the operator needs to know about — fail loud at startup.
|
||||||
|
raCert, raKey, err := loadSCEPRAPair(profile.RACertPath, profile.RAKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
profileLog.Error("startup refused: SCEP profile RA pair load failed despite preflight pass — likely a TOCTOU between preflight + here, or filesystem changed mid-boot", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
scepHandler.SetRAPair(raCert, raKey)
|
||||||
|
scepHandlers[profile.PathID] = scepHandler
|
||||||
endpoint := "/scep"
|
endpoint := "/scep"
|
||||||
if profile.PathID != "" {
|
if profile.PathID != "" {
|
||||||
endpoint = "/scep/" + profile.PathID
|
endpoint = "/scep/" + profile.PathID
|
||||||
@@ -1142,6 +1155,38 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
||||||
|
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
||||||
|
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
||||||
|
// indicate a TOCTOU race or a filesystem change between preflight and
|
||||||
|
// the load (rare).
|
||||||
|
//
|
||||||
|
// Cert PEM may carry a chain (CA + RA + intermediate); we use the FIRST
|
||||||
|
// CERTIFICATE block, matching the RFC 8894 §3.5.1 single-cert convention
|
||||||
|
// for the GetCACert response.
|
||||||
|
func loadSCEPRAPair(certPath, keyPath string) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||||
|
certPEM, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read RA cert: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read RA key: %w", err)
|
||||||
|
}
|
||||||
|
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse RA pair: %w", err)
|
||||||
|
}
|
||||||
|
if len(pair.Certificate) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("RA cert PEM contained no certificate blocks")
|
||||||
|
}
|
||||||
|
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse RA cert: %w", err)
|
||||||
|
}
|
||||||
|
return leaf, pair.PrivateKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
||||||
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
||||||
// pattern; otherwise the checks are:
|
// pattern; otherwise the checks are:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/asn1"
|
"encoding/asn1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -27,7 +28,17 @@ type SCEPService interface {
|
|||||||
GetCACert(ctx context.Context) (string, error)
|
GetCACert(ctx context.Context) (string, error)
|
||||||
|
|
||||||
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
|
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
|
||||||
|
// Used by the MVP raw-CSR fall-through path; preserved unchanged for
|
||||||
|
// backward compat with lightweight SCEP clients.
|
||||||
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope processes a SCEP PKCSReq from the RFC 8894 path
|
||||||
|
// (the handler successfully parsed an EnvelopedData + signerInfo POPO).
|
||||||
|
// Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because
|
||||||
|
// RFC 8894 §3.3 mandates a CertRep PKIMessage on every response, even
|
||||||
|
// failures. Returns nil to signal 'invalid challenge password' (caller
|
||||||
|
// translates to HTTP 403, matching the MVP path's wire shape).
|
||||||
|
PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
}
|
}
|
||||||
|
|
||||||
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
||||||
@@ -39,15 +50,34 @@ type SCEPService interface {
|
|||||||
// - GET ?operation=GetCACaps — server capabilities
|
// - GET ?operation=GetCACaps — server capabilities
|
||||||
// - GET ?operation=GetCACert — CA certificate distribution
|
// - GET ?operation=GetCACert — CA certificate distribution
|
||||||
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
|
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.3: SCEPHandler now optionally
|
||||||
|
// carries an RA cert + key pair. When set, the handler tries the new RFC 8894
|
||||||
|
// PKIMessage path FIRST (parse SignedData → verify POPO → decrypt EnvelopedData).
|
||||||
|
// On any parse failure it falls through to the legacy MVP raw-CSR path (preserves
|
||||||
|
// backward compat with lightweight SCEP clients). When RA pair is unset, the
|
||||||
|
// handler runs MVP-only (the v2.0.x behavior).
|
||||||
type SCEPHandler struct {
|
type SCEPHandler struct {
|
||||||
svc SCEPService
|
svc SCEPService
|
||||||
|
raCert *x509.Certificate // RFC 8894 path: RA cert clients encrypt CSR to
|
||||||
|
raKey crypto.PrivateKey // RFC 8894 path: RA key for EnvelopedData decrypt + CertRep signing
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSCEPHandler creates a new SCEPHandler.
|
// NewSCEPHandler creates a new SCEPHandler with the legacy MVP-only behavior.
|
||||||
|
// SetRAPair below upgrades the handler to the RFC 8894 path; that's the route
|
||||||
|
// cmd/server/main.go takes when the operator supplies CERTCTL_SCEP_RA_*.
|
||||||
func NewSCEPHandler(svc SCEPService) SCEPHandler {
|
func NewSCEPHandler(svc SCEPService) SCEPHandler {
|
||||||
return SCEPHandler{svc: svc}
|
return SCEPHandler{svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRAPair injects the RA cert + key the RFC 8894 path needs. Called by
|
||||||
|
// cmd/server/main.go after the per-profile preflight gate validates the pair.
|
||||||
|
// Without this call the handler runs MVP-only (the legacy v2.0.x behavior).
|
||||||
|
func (h *SCEPHandler) SetRAPair(raCert *x509.Certificate, raKey crypto.PrivateKey) {
|
||||||
|
h.raCert = raCert
|
||||||
|
h.raKey = raKey
|
||||||
|
}
|
||||||
|
|
||||||
// HandleSCEP is the single entry point for all SCEP operations.
|
// HandleSCEP is the single entry point for all SCEP operations.
|
||||||
// It dispatches based on the "operation" query parameter.
|
// It dispatches based on the "operation" query parameter.
|
||||||
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
|
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -125,6 +155,22 @@ func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// pkiOperation handles POST ?operation=PKIOperation
|
// pkiOperation handles POST ?operation=PKIOperation
|
||||||
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
|
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.3: this handler tries the
|
||||||
|
// new RFC 8894 PKIMessage path FIRST (parse outer SignedData → verify
|
||||||
|
// signerInfo POPO → extract authenticatedAttributes → decrypt EnvelopedData
|
||||||
|
// to recover the inner CSR). On any parse failure it falls through to the
|
||||||
|
// legacy MVP raw-CSR path (extractCSRFromPKCS7). The MVP path stays
|
||||||
|
// unchanged for backward compat with lightweight SCEP clients.
|
||||||
|
//
|
||||||
|
// Path selection rules:
|
||||||
|
// - h.raCert / h.raKey unset → MVP-only (legacy v2.0.x behavior, never tries RFC 8894)
|
||||||
|
// - RA pair set + RFC 8894 parse succeeds → RFC 8894 path (CertRep PKIMessage response)
|
||||||
|
// - RA pair set + RFC 8894 parse fails → MVP fall-through (degenerate certs-only response)
|
||||||
|
//
|
||||||
|
// The Phase 3 commit will replace the MVP-fall-through writeSCEPResponse
|
||||||
|
// with writeCertRepPKIMessage for the RFC 8894 path; the MVP path keeps
|
||||||
|
// using writeSCEPResponse so lightweight clients see no behavior change.
|
||||||
func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -145,7 +191,38 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
|
// Try the RFC 8894 path first when an RA pair is configured. On any
|
||||||
|
// parse failure we fall through to the MVP path silently — that's the
|
||||||
|
// backward-compat contract for lightweight clients.
|
||||||
|
if h.raCert != nil && h.raKey != nil {
|
||||||
|
if envelope, csrPEM, ok := h.tryParseRFC8894(body); ok {
|
||||||
|
resp := h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, "", envelope)
|
||||||
|
if resp == nil {
|
||||||
|
// nil signals 'invalid challenge password' — the service
|
||||||
|
// layer didn't find one in the request (envelope-path
|
||||||
|
// challenge password lives in the CSR's challengePassword
|
||||||
|
// attribute, extracted by the service). Treat as 403,
|
||||||
|
// matching the MVP path's wire shape.
|
||||||
|
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Phase 2 emits the legacy certs-only response on success;
|
||||||
|
// Phase 3 swaps in writeCertRepPKIMessage. Failure responses
|
||||||
|
// are emitted as plain HTTP errors until Phase 3 lands the
|
||||||
|
// CertRep+failInfo wire shape.
|
||||||
|
if resp.Status == domain.SCEPStatusSuccess && resp.Result != nil {
|
||||||
|
h.writeSCEPResponse(w, resp.Result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("SCEP enrollment failed (failInfo=%s)", resp.FailInfo), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// RFC 8894 parse failed — fall through to the MVP path.
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVP path: extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
|
||||||
|
// using the legacy parser. This is what lightweight clients (raw-CSR-
|
||||||
|
// inside-SignedData, or even bare CSRs in some cases) hit.
|
||||||
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
|
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
|
||||||
@@ -183,6 +260,74 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeSCEPResponse(w, result)
|
h.writeSCEPResponse(w, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryParseRFC8894 attempts to parse the request body as an RFC 8894 SCEP
|
||||||
|
// PKIMessage:
|
||||||
|
// 1. Parse outer SignedData; pluck the device's transient signing cert.
|
||||||
|
// 2. Verify the signerInfo signature (POPO over auth-attrs).
|
||||||
|
// 3. Extract messageType / transactionID / senderNonce auth-attrs.
|
||||||
|
// 4. The encapContent is the inner pkcsPKIEnvelope (an EnvelopedData);
|
||||||
|
// decrypt it with h.raKey to recover the PKCS#10 CSR DER.
|
||||||
|
// 5. PEM-encode the CSR for the service layer.
|
||||||
|
//
|
||||||
|
// Returns (envelope, csrPEM, true) on success; (nil, "", false) on any
|
||||||
|
// parse / verify / decrypt failure. The handler treats false as 'fall
|
||||||
|
// through to MVP path' so lightweight clients keep working.
|
||||||
|
func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, string, bool) {
|
||||||
|
sd, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if len(sd.SignerInfos) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
si := sd.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
mt, err := si.GetMessageType()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
tid, err := si.GetTransactionID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
nonce, err := si.GetSenderNonce()
|
||||||
|
if err != nil {
|
||||||
|
// senderNonce is optional in some clients; treat missing as empty.
|
||||||
|
nonce = nil
|
||||||
|
}
|
||||||
|
// EncapContent is the inner pkcsPKIEnvelope (EnvelopedData). Parse +
|
||||||
|
// decrypt with the RA key.
|
||||||
|
if len(sd.EncapContent) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
env, err := pkcs7.ParseEnvelopedData(sd.EncapContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
csrDER, err := env.Decrypt(h.raKey, h.raCert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
// Verify the recovered bytes really are a CSR. If not, fall through.
|
||||||
|
if _, err := x509.ParseCertificateRequest(csrDER); err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||||
|
envelope := &domain.SCEPRequestEnvelope{
|
||||||
|
MessageType: mt,
|
||||||
|
TransactionID: tid,
|
||||||
|
SenderNonce: nonce,
|
||||||
|
SignerCert: si.SignerCert.Raw,
|
||||||
|
}
|
||||||
|
return envelope, csrPEM, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import warning if some narrow build excludes the path
|
||||||
|
// where crypto.PrivateKey is used (the RA key field below).
|
||||||
|
var _ crypto.PrivateKey = (*interface{})(nil)
|
||||||
|
|
||||||
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
|
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
|
||||||
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
|
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
|
||||||
var derCerts [][]byte
|
var derCerts [][]byte
|
||||||
|
|||||||
@@ -36,6 +36,29 @@ func (m *mockSCEPService) PKCSReq(ctx context.Context, csrPEM string, challengeP
|
|||||||
return m.EnrollResult, m.EnrollErr
|
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) {
|
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
||||||
svc := &mockSCEPService{}
|
svc := &mockSCEPService{}
|
||||||
h := NewSCEPHandler(svc)
|
h := NewSCEPHandler(svc)
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*do
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope was added to the SCEPService interface in SCEP RFC 8894
|
||||||
|
// + Intune master bundle Phase 2.4. The router-level tests don't drive the
|
||||||
|
// RFC 8894 path; this stub satisfies the interface so the per-profile
|
||||||
|
// dispatch tests still compile.
|
||||||
|
func (s *scepProfileMockService) PKCSReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
|
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
|
||||||
r := New()
|
r := New()
|
||||||
svc := &scepProfileMockService{tag: "legacy"}
|
svc := &scepProfileMockService{tag: "legacy"}
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
// EnvelopedData parser + decryptor for SCEP PKIMessage.
|
||||||
|
//
|
||||||
|
// RFC 5652 §6 (Cryptographic Message Syntax — EnvelopedData) +
|
||||||
|
// RFC 8894 §3.2.2 (SCEP pkcsPKIEnvelope).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.1.
|
||||||
|
//
|
||||||
|
// Equivalent to micromdm/scep's scep/cryptoutil/cryptoutil.go::DecryptPKCSEnvelope
|
||||||
|
// (read for shape only; not vendored — certctl owns the fuzz targets in this
|
||||||
|
// sub-package, see internal/pkcs7/envelopeddata_fuzz_test.go).
|
||||||
|
//
|
||||||
|
// ASN.1 structure being parsed (cited from RFC 5652 §6.1):
|
||||||
|
//
|
||||||
|
// EnvelopedData ::= SEQUENCE {
|
||||||
|
// version INTEGER,
|
||||||
|
// originatorInfo [0] IMPLICIT OriginatorInfo OPTIONAL,
|
||||||
|
// recipientInfos SET SIZE(1..MAX) OF RecipientInfo,
|
||||||
|
// encryptedContentInfo EncryptedContentInfo,
|
||||||
|
// unprotectedAttrs [1] IMPLICIT Attributes OPTIONAL
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// RecipientInfo ::= CHOICE {
|
||||||
|
// ktri KeyTransRecipientInfo, -- the only one SCEP uses
|
||||||
|
// -- (other CHOICE arms ignored: kari, kekri, pwri, ori)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// KeyTransRecipientInfo ::= SEQUENCE {
|
||||||
|
// version INTEGER (0|2),
|
||||||
|
// rid RecipientIdentifier, -- IssuerAndSerialNumber for SCEP
|
||||||
|
// keyEncryptionAlgorithm AlgorithmIdentifier, -- rsaEncryption (1.2.840.113549.1.1.1)
|
||||||
|
// encryptedKey OCTET STRING -- AES key encrypted with RA cert pubkey
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// EncryptedContentInfo ::= SEQUENCE {
|
||||||
|
// contentType OBJECT IDENTIFIER, -- pkcs7-data (1.2.840.113549.1.7.1)
|
||||||
|
// contentEncryptionAlgorithm AlgorithmIdentifier, -- aes-128-cbc | aes-192-cbc | aes-256-cbc | des-ede3-cbc
|
||||||
|
// encryptedContent [0] IMPLICIT OCTET STRING -- the encrypted CSR bytes + PKCS#7 padding
|
||||||
|
// }
|
||||||
|
|
||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/des" //nolint:gosec // DES-EDE3-CBC is RFC 8894 §3.5.2 fallback for legacy MDM clients
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/subtle"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP / CMS algorithm OIDs used by the EnvelopedData path.
|
||||||
|
//
|
||||||
|
// Defined here as exported package vars so the CertRep builder (Phase 3)
|
||||||
|
// shares the same OID encoding and the unit tests can pin the exact values.
|
||||||
|
var (
|
||||||
|
// rsaEncryption — PKCS#1 v1.5 key transport (RFC 8017 §7.2).
|
||||||
|
OIDRSAEncryption = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1}
|
||||||
|
// PKCS#7 / CMS data content type (RFC 5652 §4).
|
||||||
|
OIDDataContent = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}
|
||||||
|
// AES-128-CBC / AES-192-CBC / AES-256-CBC content-encryption algorithms
|
||||||
|
// (NIST CSOR / RFC 3565 §2).
|
||||||
|
OIDAES128CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 2}
|
||||||
|
OIDAES192CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 22}
|
||||||
|
OIDAES256CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42}
|
||||||
|
// DES-EDE3-CBC — RFC 8894 §3.5.2 advertises this as a legacy fallback;
|
||||||
|
// some Cisco IOS / older MDM clients still emit it. RFC 8894 itself
|
||||||
|
// does NOT mandate that the server accept DES; we accept it for
|
||||||
|
// max-compat and document the security caveat in docs/legacy-est-scep.md.
|
||||||
|
OIDDESEDE3CBC = asn1.ObjectIdentifier{1, 2, 840, 113549, 3, 7}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel decryption error. The caller (handler / service) maps this to
|
||||||
|
// SCEPFailBadMessageCheck per RFC 8894 §3.3.2.2 + §3.2.2 (integrity-check
|
||||||
|
// failure semantics). The error text is intentionally generic so the
|
||||||
|
// padding-oracle / Bleichenbacher leak surfaces are closed: every failure
|
||||||
|
// mode (RSA decrypt failure, content decrypt failure, padding malformed,
|
||||||
|
// unknown algorithm) returns the SAME error message text.
|
||||||
|
var ErrEnvelopedDataDecrypt = errors.New("envelopedData: decrypt failed")
|
||||||
|
|
||||||
|
// EnvelopedData is the parsed RFC 5652 EnvelopedData structure ready for
|
||||||
|
// Decrypt. Holds the recipient infos + the encrypted content algorithm /
|
||||||
|
// IV / ciphertext.
|
||||||
|
type EnvelopedData struct {
|
||||||
|
Version int
|
||||||
|
RecipientInfos []KeyTransRecipientInfo
|
||||||
|
ContentEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedContent []byte // AES-CBC ciphertext; algorithm + IV in ContentEncryptionAlg
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyTransRecipientInfo is the RFC 5652 §6.2.1 KeyTransRecipientInfo. SCEP
|
||||||
|
// only uses this CHOICE arm — the others (kari/kekri/pwri/ori) are
|
||||||
|
// rejected at parse time as out-of-spec for SCEP.
|
||||||
|
type KeyTransRecipientInfo struct {
|
||||||
|
Version int
|
||||||
|
IssuerAndSerial IssuerAndSerial
|
||||||
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuerAndSerial is the recipient identifier (RFC 5652 §10.2.4). SCEP
|
||||||
|
// requires the SubjectKeyIdentifier-as-bytes form to NOT be used; only
|
||||||
|
// IssuerAndSerialNumber. The handler matches this against the loaded RA
|
||||||
|
// cert (issuer + serial) to identify the matching recipient when the
|
||||||
|
// envelope addresses multiple CAs.
|
||||||
|
type IssuerAndSerial struct {
|
||||||
|
IssuerRaw asn1.RawValue // RDN sequence of the issuer cert; raw so re-serialisation matches DER bit-for-bit
|
||||||
|
SerialNumber *big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// envelopedDataASN1 is the ASN.1 unmarshal target for the EnvelopedData
|
||||||
|
// structure inside the SignedData encapContentInfo (post-CMS-wrapping).
|
||||||
|
// The version field comes first; recipientInfos is a SET (not SEQUENCE);
|
||||||
|
// the encryptedContentInfo SEQUENCE follows.
|
||||||
|
//
|
||||||
|
// The originatorInfo [0] IMPLICIT OPTIONAL is rare in SCEP and skipped
|
||||||
|
// at the raw-value level (we don't need it).
|
||||||
|
type envelopedDataASN1 struct {
|
||||||
|
Version int
|
||||||
|
RecipientInfos []asn1.RawValue `asn1:"set"`
|
||||||
|
EncryptedContentInfo encryptedContentInfoASN1 `asn1:""`
|
||||||
|
UnprotectedAttrs asn1.RawValue `asn1:"optional,tag:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encryptedContentInfoASN1 struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
ContentEncryptionAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
EncryptedContent asn1.RawValue `asn1:"optional,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyTransRecipientInfoASN1 struct {
|
||||||
|
Version int
|
||||||
|
RID asn1.RawValue // CHOICE — IssuerAndSerialNumber or [0] subjectKeyIdentifier
|
||||||
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type issuerAndSerialASN1 struct {
|
||||||
|
Issuer asn1.RawValue
|
||||||
|
SerialNumber *big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEnvelopedData parses raw DER-encoded EnvelopedData bytes.
|
||||||
|
//
|
||||||
|
// The caller passes the raw bytes from the inner pkcsPKIEnvelope (already
|
||||||
|
// stripped of the outer SignedData → encapContentInfo → OCTET STRING
|
||||||
|
// wrapper). Returns an EnvelopedData ready for Decrypt.
|
||||||
|
//
|
||||||
|
// Parse failures are returned as detailed errors so the handler can log
|
||||||
|
// what was malformed; the eventual SCEP wire response collapses all
|
||||||
|
// failures to BadMessageCheck.
|
||||||
|
func ParseEnvelopedData(der []byte) (*EnvelopedData, error) {
|
||||||
|
if len(der) == 0 {
|
||||||
|
return nil, fmt.Errorf("envelopedData: empty input")
|
||||||
|
}
|
||||||
|
// Some encoders wrap the EnvelopedData in an outer ContentInfo
|
||||||
|
// (SEQUENCE { contentType OID, content [0] EXPLICIT EnvelopedData }).
|
||||||
|
// Try that shape first; on failure, parse the bytes directly.
|
||||||
|
if peeled, ok := peelContentInfo(der, OIDEnvelopedData); ok {
|
||||||
|
der = peeled
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw envelopedDataASN1
|
||||||
|
rest, err := asn1.Unmarshal(der, &raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("envelopedData: parse outer SEQUENCE: %w", err)
|
||||||
|
}
|
||||||
|
if len(rest) > 0 {
|
||||||
|
// Trailing bytes after a CMS structure are tolerated by some
|
||||||
|
// encoders; not a fatal parse error.
|
||||||
|
_ = rest
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &EnvelopedData{
|
||||||
|
Version: raw.Version,
|
||||||
|
ContentEncryptionAlg: raw.EncryptedContentInfo.ContentEncryptionAlgorithm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipientInfos is SET OF RecipientInfo (CHOICE). We accept only the
|
||||||
|
// KeyTransRecipientInfo arm. Other CHOICE arms (kari = [1], kekri = [2],
|
||||||
|
// pwri = [3], ori = [4]) are skipped silently — Decrypt will fail with
|
||||||
|
// 'no matching recipient' if none of the SET members are KTRI.
|
||||||
|
for _, ri := range raw.RecipientInfos {
|
||||||
|
// KeyTransRecipientInfo is implicitly tagged as a SEQUENCE (no
|
||||||
|
// explicit context tag) per RFC 5652 §6.2 — it's the default
|
||||||
|
// CHOICE arm. The other arms carry context-specific tags.
|
||||||
|
if ri.Class != asn1.ClassUniversal || ri.Tag != asn1.TagSequence {
|
||||||
|
continue // not a KTRI; skip
|
||||||
|
}
|
||||||
|
var ktri keyTransRecipientInfoASN1
|
||||||
|
if _, err := asn1.Unmarshal(ri.FullBytes, &ktri); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// SCEP requires IssuerAndSerialNumber for the rid (RFC 8894 §3.2.2
|
||||||
|
// references RFC 5652 §6.2.1 with the v0 form). The v2 form uses
|
||||||
|
// SubjectKeyIdentifier in [0] — also accepted by some clients. We
|
||||||
|
// only support the v0 IssuerAndSerial form here; v2 clients that
|
||||||
|
// fail to match fall through to 'no matching recipient'.
|
||||||
|
var ias issuerAndSerialASN1
|
||||||
|
if _, err := asn1.Unmarshal(ktri.RID.FullBytes, &ias); err != nil {
|
||||||
|
continue // not IssuerAndSerial; skip
|
||||||
|
}
|
||||||
|
out.RecipientInfos = append(out.RecipientInfos, KeyTransRecipientInfo{
|
||||||
|
Version: ktri.Version,
|
||||||
|
IssuerAndSerial: IssuerAndSerial{
|
||||||
|
IssuerRaw: ias.Issuer,
|
||||||
|
SerialNumber: ias.SerialNumber,
|
||||||
|
},
|
||||||
|
KeyEncryptionAlg: ktri.KeyEncryptionAlg,
|
||||||
|
EncryptedKey: ktri.EncryptedKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out.RecipientInfos) == 0 {
|
||||||
|
return nil, fmt.Errorf("envelopedData: no KeyTransRecipientInfo with IssuerAndSerial form found in SET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptedContent is [0] IMPLICIT OCTET STRING. The IMPLICIT tagging
|
||||||
|
// strips the OCTET STRING tag; what we get is the raw ciphertext as
|
||||||
|
// asn1.RawValue.Bytes. (Some encoders use EXPLICIT; in that case
|
||||||
|
// FullBytes carries an extra [0] wrapper we strip below.)
|
||||||
|
if raw.EncryptedContentInfo.EncryptedContent.Class == asn1.ClassContextSpecific {
|
||||||
|
out.EncryptedContent = raw.EncryptedContentInfo.EncryptedContent.Bytes
|
||||||
|
}
|
||||||
|
if len(out.EncryptedContent) == 0 {
|
||||||
|
return nil, fmt.Errorf("envelopedData: empty encryptedContent")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts the EnvelopedData using the RA private key.
|
||||||
|
//
|
||||||
|
// Algorithm:
|
||||||
|
// 1. Find a RecipientInfo whose IssuerAndSerial matches raCert.
|
||||||
|
// 2. RSA PKCS#1 v1.5 decrypt the EncryptedKey with raKey.
|
||||||
|
// 3. AES-CBC (or DES-EDE3-CBC) decrypt EncryptedContent with the recovered
|
||||||
|
// symmetric key + the IV embedded in ContentEncryptionAlg.Parameters.
|
||||||
|
// 4. Strip PKCS#7 padding in constant time (no branch on padding-byte
|
||||||
|
// values — closes the padding oracle leak).
|
||||||
|
//
|
||||||
|
// Every failure path returns ErrEnvelopedDataDecrypt with no other detail
|
||||||
|
// to avoid leaking which step failed. Service-layer logs may include
|
||||||
|
// per-step internal context, but the wire response carries only
|
||||||
|
// SCEPFailBadMessageCheck.
|
||||||
|
func (e *EnvelopedData) Decrypt(raKey crypto.PrivateKey, raCert *x509.Certificate) ([]byte, error) {
|
||||||
|
if e == nil {
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
rsaKey, ok := raKey.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
// SCEP RA keys are RSA per RFC 8894 §3.5.2 (CMS key transport
|
||||||
|
// requires asymmetric keys with PKCS#1 v1.5; ECDSA can't do
|
||||||
|
// keyTrans). The preflight gate already enforces RSA-or-ECDSA on
|
||||||
|
// the RA cert, but Decrypt double-checks — the cert can be ECDSA
|
||||||
|
// (used for SignedData signing only) while EnvelopedData decryption
|
||||||
|
// requires RSA.
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a recipient matching the RA cert. Match on issuer DN raw bytes +
|
||||||
|
// serial number — both must compare equal. The cert.RawIssuer is the
|
||||||
|
// DER of the issuer's RDNSequence, the same form CMS encodes here.
|
||||||
|
var ktri *KeyTransRecipientInfo
|
||||||
|
for i := range e.RecipientInfos {
|
||||||
|
ri := &e.RecipientInfos[i]
|
||||||
|
if subtle.ConstantTimeCompare(ri.IssuerAndSerial.IssuerRaw.FullBytes, raCert.RawIssuer) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ri.IssuerAndSerial.SerialNumber == nil || raCert.SerialNumber == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ri.IssuerAndSerial.SerialNumber.Cmp(raCert.SerialNumber) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ktri = ri
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ktri == nil {
|
||||||
|
// Wrong recipient — the envelope was addressed to a CA that isn't
|
||||||
|
// us. RFC 8894 §3.3.2.2 maps this to BadMessageCheck (integrity
|
||||||
|
// check failed), NOT BadCertID — the message is structurally fine,
|
||||||
|
// just not for us.
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
if !ktri.KeyEncryptionAlg.Algorithm.Equal(OIDRSAEncryption) {
|
||||||
|
// Only PKCS#1 v1.5 keyTrans supported; OAEP would require parsing
|
||||||
|
// the algorithm parameters for the OAEP hash + MGF — out of scope
|
||||||
|
// for V2.
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSA PKCS#1 v1.5 decrypt the symmetric key. We use the variant that
|
||||||
|
// hides timing of malformed-padding rejection (rsa.DecryptPKCS1v15)
|
||||||
|
// returns an error on bad padding; combined with the constant
|
||||||
|
// ErrEnvelopedDataDecrypt response we close the timing leg of the
|
||||||
|
// Bleichenbacher attack at the wire level.
|
||||||
|
symKey, err := rsa.DecryptPKCS1v15(nil, rsaKey, ktri.EncryptedKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the content. AES-CBC algorithm parameters are the IV as a
|
||||||
|
// raw OCTET STRING (RFC 3565 §2.3); DES-EDE3-CBC same shape (RFC 8894
|
||||||
|
// §3.5.2 advertises this).
|
||||||
|
plaintext, err := decryptCBC(e.ContentEncryptionAlg, symKey, e.EncryptedContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptCBC dispatches on the content-encryption algorithm OID to the
|
||||||
|
// matching cipher constructor + CBC decrypt + constant-time PKCS#7 unpad.
|
||||||
|
func decryptCBC(alg pkix.AlgorithmIdentifier, key, ciphertext []byte) ([]byte, error) {
|
||||||
|
// The IV is the raw OCTET STRING in alg.Parameters (RFC 3565 §2.3,
|
||||||
|
// RFC 8894 §3.5.2). asn1.RawValue.Bytes carries the OCTET STRING
|
||||||
|
// content already (the SEQUENCE wrapper is stripped by the unmarshal).
|
||||||
|
iv := alg.Parameters.Bytes
|
||||||
|
var block cipher.Block
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case alg.Algorithm.Equal(OIDAES128CBC), alg.Algorithm.Equal(OIDAES192CBC), alg.Algorithm.Equal(OIDAES256CBC):
|
||||||
|
// AES key length must match the algorithm. Reject mismatched
|
||||||
|
// lengths at the cipher constructor — the wire response stays
|
||||||
|
// generic via ErrEnvelopedDataDecrypt.
|
||||||
|
block, err = aes.NewCipher(key)
|
||||||
|
case alg.Algorithm.Equal(OIDDESEDE3CBC):
|
||||||
|
block, err = des.NewTripleDESCipher(key) //nolint:gosec // RFC 8894 §3.5.2 legacy fallback
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported content-encryption algorithm: %v", alg.Algorithm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(iv) != block.BlockSize() {
|
||||||
|
return nil, fmt.Errorf("iv length %d does not match block size %d", len(iv), block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
|
||||||
|
return nil, fmt.Errorf("ciphertext length %d not multiple of block size %d", len(ciphertext), block.BlockSize())
|
||||||
|
}
|
||||||
|
plaintext := make([]byte, len(ciphertext))
|
||||||
|
dec := cipher.NewCBCDecrypter(block, iv)
|
||||||
|
dec.CryptBlocks(plaintext, ciphertext)
|
||||||
|
|
||||||
|
// Constant-time PKCS#7 padding strip.
|
||||||
|
//
|
||||||
|
// Last byte is the padding length P (1..blockSize). Every byte in the
|
||||||
|
// last P bytes must equal P. We accumulate any deviation into a
|
||||||
|
// bitwise-OR `bad` byte that's zero iff every check passes; the
|
||||||
|
// length cap is also folded into the same accumulator. Branch only on
|
||||||
|
// the accumulator at the end. NEVER branch on padding-byte values
|
||||||
|
// mid-loop (that's the padding oracle).
|
||||||
|
bs := block.BlockSize()
|
||||||
|
if len(plaintext) == 0 {
|
||||||
|
return nil, fmt.Errorf("plaintext empty after decrypt")
|
||||||
|
}
|
||||||
|
pad := plaintext[len(plaintext)-1]
|
||||||
|
// pad must be in [1, bs]. `padTooBig` is 0xff when pad > bs, else 0x00.
|
||||||
|
padTooBig := byte(int(pad)-1) >> 7 // 1 if pad==0, else 0
|
||||||
|
padTooBig |= byte((int(bs)-int(pad))>>31) & 0x01
|
||||||
|
bad := padTooBig
|
||||||
|
// Walk the LAST `bs` bytes (a fixed window equal to one block); for
|
||||||
|
// each byte at position N from the end, if N < pad it must equal pad.
|
||||||
|
// Use bitwise mask 'inWindow' to fold the conditional check into the
|
||||||
|
// accumulator without branching.
|
||||||
|
for i := 1; i <= bs && i <= len(plaintext); i++ {
|
||||||
|
// inWindow is 0xff when i <= pad, else 0x00
|
||||||
|
inWindow := byte(int(int(pad)-i) >> 31) // 0xff if pad-i < 0 → not in window
|
||||||
|
inWindow = ^inWindow // flip: 0xff if i <= pad
|
||||||
|
mismatch := plaintext[len(plaintext)-i] ^ pad
|
||||||
|
bad |= inWindow & mismatch
|
||||||
|
}
|
||||||
|
if bad != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS#7 padding")
|
||||||
|
}
|
||||||
|
return plaintext[:len(plaintext)-int(pad)], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// peelContentInfo strips the optional outer ContentInfo wrapper when it's
|
||||||
|
// present. CMS callers either hand us the bare EnvelopedData SEQUENCE or
|
||||||
|
// the same SEQUENCE wrapped in
|
||||||
|
//
|
||||||
|
// ContentInfo ::= SEQUENCE {
|
||||||
|
// contentType OBJECT IDENTIFIER,
|
||||||
|
// content [0] EXPLICIT ANY DEFINED BY contentType
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// We try the wrapper shape first and unwrap to the inner content; on
|
||||||
|
// any parse failure the caller proceeds with the original bytes.
|
||||||
|
func peelContentInfo(der []byte, expectOID asn1.ObjectIdentifier) ([]byte, bool) {
|
||||||
|
var ci struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"explicit,tag:0"`
|
||||||
|
}
|
||||||
|
if _, err := asn1.Unmarshal(der, &ci); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !ci.ContentType.Equal(expectOID) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ci.Content.Bytes, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDEnvelopedData identifies the envelopedData CMS content type (RFC 5652
|
||||||
|
// §6, OID 1.2.840.113549.1.7.3). Used by peelContentInfo when the inbound
|
||||||
|
// bytes carry the optional ContentInfo wrapper.
|
||||||
|
var OIDEnvelopedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 3}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FuzzParseEnvelopedData is the panic-safety fuzzer for ParseEnvelopedData.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.5: every parser certctl
|
||||||
|
// adds gets a Fuzz target in the same package (the fuzz-target-ownership
|
||||||
|
// rule from cowork/CLAUDE.md::Operating Rules). The point isn't to find
|
||||||
|
// vulnerabilities (the parser uses stdlib encoding/asn1 which is itself
|
||||||
|
// fuzzed upstream) — it's to prove that arbitrary attacker-controlled
|
||||||
|
// bytes cannot panic the SCEP server. Any panic = an availability bug.
|
||||||
|
//
|
||||||
|
// Seed corpus: a known-good EnvelopedData built by buildTestEnvelope plus
|
||||||
|
// a handful of degenerate inputs (empty, single byte, all zeros) that
|
||||||
|
// should each return an error without panicking.
|
||||||
|
func FuzzParseEnvelopedData(f *testing.F) {
|
||||||
|
// Seed: empty input.
|
||||||
|
f.Add([]byte{})
|
||||||
|
// Seed: a SEQUENCE tag with an absurd length (asn1 layer should
|
||||||
|
// reject before we get to our code).
|
||||||
|
f.Add([]byte{0x30, 0x82, 0xff, 0xff})
|
||||||
|
// Seed: a known-good EnvelopedData built dynamically below — but the
|
||||||
|
// fuzz seed corpus must be deterministic, so we skip the full RA-pair
|
||||||
|
// build and just feed a small SEQUENCE-shaped blob.
|
||||||
|
f.Add([]byte{0x30, 0x03, 0x02, 0x01, 0x00})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
// Whatever happens, no panic. Errors are fine; nil parse with
|
||||||
|
// nil error would be a bug but the contract is just no-panic.
|
||||||
|
_, _ = ParseEnvelopedData(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 2.1: round-trip tests for ParseEnvelopedData +
|
||||||
|
// EnvelopedData.Decrypt.
|
||||||
|
//
|
||||||
|
// Each test materialises a real RSA RA cert + key, builds an EnvelopedData
|
||||||
|
// by hand (encrypting a known plaintext with AES-256-CBC using a fresh
|
||||||
|
// random key transported via PKCS#1 v1.5 wrap of the RA pubkey), then
|
||||||
|
// parses + decrypts and asserts plaintext equality.
|
||||||
|
//
|
||||||
|
// The point of the round-trip is to pin the exact wire format: the
|
||||||
|
// per-field DER encoding has to match what real SCEP clients emit
|
||||||
|
// (Cisco IOS, ChromeOS, Intune Connector). If the parse succeeds but the
|
||||||
|
// decrypt comes back garbled, the wire-format encoding is off in a way
|
||||||
|
// the unit tests catch.
|
||||||
|
|
||||||
|
func TestEnvelopedData_RoundTrip_AES256CBC(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("hello SCEP world — this is the encapsulated CSR DER bytes")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES256CBC, 32)
|
||||||
|
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed.RecipientInfos) != 1 {
|
||||||
|
t.Fatalf("len(RecipientInfos) = %d, want 1", len(parsed.RecipientInfos))
|
||||||
|
}
|
||||||
|
if !parsed.ContentEncryptionAlg.Algorithm.Equal(OIDAES256CBC) {
|
||||||
|
t.Errorf("ContentEncryptionAlg = %v, want AES-256-CBC", parsed.ContentEncryptionAlg.Algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := parsed.Decrypt(raKey, raCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("Decrypt plaintext mismatch:\n got=%q\nwant=%q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_RoundTrip_AES128CBC(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("AES-128 round-trip — short ciphertext, single-block worth of data")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES128CBC, 16)
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
got, err := parsed.Decrypt(raKey, raCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("plaintext mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Decrypt_WrongRA_ReturnsBadMessageCheck(t *testing.T) {
|
||||||
|
correctKey, correctCert := genTestRSARA(t)
|
||||||
|
wrongKey, wrongCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("addressed to the right CA, decrypted with the wrong one")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, correctCert, plaintext, OIDAES256CBC, 32)
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong cert (issuer mismatch) — RFC 8894 §3.3.2.2 says BadMessageCheck.
|
||||||
|
_, err = parsed.Decrypt(wrongKey, wrongCert)
|
||||||
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
||||||
|
t.Errorf("Decrypt with wrong RA cert: err = %v, want ErrEnvelopedDataDecrypt", err)
|
||||||
|
}
|
||||||
|
// Right cert, wrong key — same generic error to close the timing leak.
|
||||||
|
_, err = parsed.Decrypt(wrongKey, correctCert)
|
||||||
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
||||||
|
t.Errorf("Decrypt with mismatched key: err = %v, want ErrEnvelopedDataDecrypt", err)
|
||||||
|
}
|
||||||
|
// Right key, right cert — succeeds.
|
||||||
|
got, err := parsed.Decrypt(correctKey, correctCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt with correct pair: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("plaintext mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Decrypt_TamperedCiphertext_Refuses(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("plaintext we'll corrupt mid-flight")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES256CBC, 32)
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
// Flip a bit in the LAST ciphertext block — corrupts the padding the
|
||||||
|
// constant-time strip should catch.
|
||||||
|
if len(parsed.EncryptedContent) < 16 {
|
||||||
|
t.Fatal("ciphertext too short to tamper")
|
||||||
|
}
|
||||||
|
parsed.EncryptedContent[len(parsed.EncryptedContent)-1] ^= 0xff
|
||||||
|
_, err = parsed.Decrypt(raKey, raCert)
|
||||||
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
||||||
|
t.Errorf("Decrypt tampered ciphertext: err = %v, want ErrEnvelopedDataDecrypt", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Parse_Empty_Refuses(t *testing.T) {
|
||||||
|
if _, err := ParseEnvelopedData(nil); err == nil {
|
||||||
|
t.Error("ParseEnvelopedData(nil) = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := ParseEnvelopedData([]byte{}); err == nil {
|
||||||
|
t.Error("ParseEnvelopedData(empty) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Parse_RandomGarbage_Refuses(t *testing.T) {
|
||||||
|
garbage := []byte{0x30, 0x82, 0x00, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||||
|
if _, err := ParseEnvelopedData(garbage); err == nil {
|
||||||
|
t.Error("ParseEnvelopedData(garbage) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
func genTestRSARA(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: "ra-test"},
|
||||||
|
Issuer: pkix.Name{CommonName: "ra-test"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return key, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestEnvelope hand-constructs an EnvelopedData targeting raCert that
|
||||||
|
// encrypts plaintext with the given AES-CBC algorithm + keyLen. Mirrors
|
||||||
|
// what a real SCEP client would emit (Cisco IOS / Intune Connector / etc.).
|
||||||
|
//
|
||||||
|
// Returns the raw DER bytes ready to feed into ParseEnvelopedData.
|
||||||
|
func buildTestEnvelope(t *testing.T, raCert *x509.Certificate, plaintext []byte, algOID asn1.ObjectIdentifier, keyLen int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
// 1. Generate a random symmetric key + IV.
|
||||||
|
symKey := make([]byte, keyLen)
|
||||||
|
if _, err := rand.Read(symKey); err != nil {
|
||||||
|
t.Fatalf("rand.Read symKey: %v", err)
|
||||||
|
}
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand.Read iv: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PKCS#7-pad the plaintext to a multiple of the block size.
|
||||||
|
bs := aes.BlockSize
|
||||||
|
padLen := bs - len(plaintext)%bs
|
||||||
|
padded := append([]byte{}, plaintext...)
|
||||||
|
for i := 0; i < padLen; i++ {
|
||||||
|
padded = append(padded, byte(padLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AES-CBC encrypt.
|
||||||
|
block, err := aes.NewCipher(symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes.NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
enc := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
ciphertext := make([]byte, len(padded))
|
||||||
|
enc.CryptBlocks(ciphertext, padded)
|
||||||
|
|
||||||
|
// 4. RSA PKCS#1 v1.5 encrypt the symmetric key with the RA pubkey.
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.EncryptPKCS1v15: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build the IssuerAndSerialNumber identifying the RA cert.
|
||||||
|
issuerRDN := asn1.RawValue{FullBytes: raCert.RawIssuer}
|
||||||
|
rid, err := asn1.Marshal(struct {
|
||||||
|
Issuer asn1.RawValue
|
||||||
|
SerialNumber *big.Int
|
||||||
|
}{Issuer: issuerRDN, SerialNumber: raCert.SerialNumber})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal IssuerAndSerial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build the KeyTransRecipientInfo SEQUENCE.
|
||||||
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
||||||
|
ktriBytes, err := asn1.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
RID asn1.RawValue
|
||||||
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedKey []byte
|
||||||
|
}{
|
||||||
|
Version: 0,
|
||||||
|
RID: asn1.RawValue{FullBytes: rid},
|
||||||
|
KeyEncryptionAlg: keyEncAlg,
|
||||||
|
EncryptedKey: encryptedKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal KTRI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Build the AlgorithmIdentifier with the IV as parameters
|
||||||
|
// (RFC 3565 §2.3 — IV is OCTET STRING, fed in via Parameters).
|
||||||
|
ivParam, err := asn1.Marshal(iv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal IV: %v", err)
|
||||||
|
}
|
||||||
|
contentAlg := pkix.AlgorithmIdentifier{
|
||||||
|
Algorithm: algOID,
|
||||||
|
Parameters: asn1.RawValue{FullBytes: ivParam},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Build the EncryptedContentInfo SEQUENCE.
|
||||||
|
// encryptedContent is [0] IMPLICIT OCTET STRING — the content bytes
|
||||||
|
// appear directly after the [0] tag, without an inner OCTET STRING
|
||||||
|
// wrapper.
|
||||||
|
encContent := asn1.RawValue{
|
||||||
|
Class: asn1.ClassContextSpecific,
|
||||||
|
Tag: 0,
|
||||||
|
IsCompound: false,
|
||||||
|
Bytes: ciphertext,
|
||||||
|
}
|
||||||
|
eciBytes, err := asn1.Marshal(struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
ContentEncryptionAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
EncryptedContent asn1.RawValue
|
||||||
|
}{
|
||||||
|
ContentType: OIDDataContent,
|
||||||
|
ContentEncryptionAlgorithm: contentAlg,
|
||||||
|
EncryptedContent: encContent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ECI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Build the EnvelopedData SEQUENCE.
|
||||||
|
envBytes, err := asn1.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
RecipientInfos []asn1.RawValue `asn1:"set"`
|
||||||
|
EncryptedECI asn1.RawValue
|
||||||
|
}{
|
||||||
|
Version: 0,
|
||||||
|
RecipientInfos: []asn1.RawValue{{FullBytes: ktriBytes}},
|
||||||
|
EncryptedECI: asn1.RawValue{FullBytes: eciBytes},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal EnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
return envBytes
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
// SignerInfo parser + signature verifier for SCEP PKIMessage.
|
||||||
|
//
|
||||||
|
// RFC 5652 §5 (SignedData) + RFC 8894 §3.2.1 (SCEP authenticatedAttributes).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.2.
|
||||||
|
//
|
||||||
|
// The wire shape this parses (cited from RFC 5652 §5.3):
|
||||||
|
//
|
||||||
|
// SignedData ::= SEQUENCE {
|
||||||
|
// version INTEGER,
|
||||||
|
// digestAlgorithms SET OF AlgorithmIdentifier,
|
||||||
|
// encapContentInfo EncapsulatedContentInfo,
|
||||||
|
// certificates [0] IMPLICIT SET OF CertificateChoices OPTIONAL,
|
||||||
|
// crls [1] IMPLICIT SET OF RevocationInfoChoices OPTIONAL,
|
||||||
|
// signerInfos SET OF SignerInfo -- the field this file targets
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SignerInfo ::= SEQUENCE {
|
||||||
|
// version INTEGER (1|3),
|
||||||
|
// sid SignerIdentifier, -- IssuerAndSerial for v1, SubjectKeyId for v3
|
||||||
|
// digestAlgorithm AlgorithmIdentifier,
|
||||||
|
// signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
|
||||||
|
// signatureAlgorithm AlgorithmIdentifier,
|
||||||
|
// signature OCTET STRING,
|
||||||
|
// unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
|
||||||
|
// Attribute ::= SEQUENCE { attrType OID, attrValues SET OF AttributeValue }
|
||||||
|
//
|
||||||
|
// The CMS signature is computed over the DER re-serialisation of the
|
||||||
|
// signedAttrs as a SET OF Attribute (NOT as the [0] IMPLICIT-tagged form
|
||||||
|
// it appears as in the wire). RFC 5652 §5.4 spells this out — easy to
|
||||||
|
// get wrong, every CMS implementation has hit this.
|
||||||
|
|
||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1" //nolint:gosec // SHA-1 is RFC 8894 §3.5.2 baseline; SHA-256 also accepted
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP authenticated-attribute OIDs (RFC 8894 §3.2.1.4).
|
||||||
|
var (
|
||||||
|
OIDSCEPMessageType = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2}
|
||||||
|
OIDSCEPPKIStatus = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 3}
|
||||||
|
OIDSCEPFailInfo = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 4}
|
||||||
|
OIDSCEPSenderNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5}
|
||||||
|
OIDSCEPRecipientNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 6}
|
||||||
|
OIDSCEPTransactionID = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7}
|
||||||
|
|
||||||
|
// CMS standard authenticated-attribute OIDs used by the signature
|
||||||
|
// verification (RFC 5652 §11).
|
||||||
|
OIDContentType = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
|
||||||
|
OIDMessageDigest = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
|
||||||
|
OIDSigningTime = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 5}
|
||||||
|
|
||||||
|
// CMS digest algorithm OIDs.
|
||||||
|
OIDSHA1 = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26}
|
||||||
|
OIDSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
|
||||||
|
OIDSHA512 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3}
|
||||||
|
|
||||||
|
// Signature algorithm OIDs the verifier accepts.
|
||||||
|
OIDRSAWithSHA1 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 5}
|
||||||
|
OIDRSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
|
||||||
|
OIDRSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 13}
|
||||||
|
OIDECDSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 2}
|
||||||
|
OIDECDSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 4}
|
||||||
|
|
||||||
|
// signedData CMS content type (RFC 5652 §5).
|
||||||
|
OIDSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSignerInfoVerify is returned when signature verification fails. Like
|
||||||
|
// the EnvelopedData decrypt error, the message text is intentionally
|
||||||
|
// generic so the wire response collapses to BadMessageCheck.
|
||||||
|
var ErrSignerInfoVerify = errors.New("signerInfo: signature verification failed")
|
||||||
|
|
||||||
|
// SignerInfo represents an unwrapped CMS signerInfo with its parsed
|
||||||
|
// authenticatedAttributes. Used for SCEP POPO verification.
|
||||||
|
type SignerInfo struct {
|
||||||
|
Version int
|
||||||
|
SignerCert *x509.Certificate // device's transient signing cert (from the SignedData certificates field)
|
||||||
|
AuthAttributes map[string]asn1.RawValue // keyed by attribute OID dotted-string
|
||||||
|
rawSignedAttrs []byte // DER of the [0] IMPLICIT SignedAttributes — used for re-serialisation
|
||||||
|
DigestAlgorithm asn1.ObjectIdentifier
|
||||||
|
SignatureAlgorithm asn1.ObjectIdentifier
|
||||||
|
Signature []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedData is the parsed top-level SignedData structure with the
|
||||||
|
// signers + the optional certificates the SET carries (used to look up
|
||||||
|
// the device's transient signing cert by SignerInfo.sid).
|
||||||
|
type SignedData struct {
|
||||||
|
Version int
|
||||||
|
DigestAlgorithms []pkix.AlgorithmIdentifier
|
||||||
|
EncapContentType asn1.ObjectIdentifier
|
||||||
|
EncapContent []byte // the inner content the SignedData wraps; nil if the wire used external signature
|
||||||
|
Certificates []*x509.Certificate
|
||||||
|
SignerInfos []*SignerInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedDataASN1 is the ASN.1 unmarshal target for the SignedData
|
||||||
|
// structure. Members tagged with their on-the-wire shapes.
|
||||||
|
type signedDataASN1 struct {
|
||||||
|
Version int
|
||||||
|
DigestAlgorithms []pkix.AlgorithmIdentifier `asn1:"set"`
|
||||||
|
EncapContentInfo encapContentInfoASN1
|
||||||
|
Certificates asn1.RawValue `asn1:"optional,tag:0"` // [0] IMPLICIT SET OF Certificate
|
||||||
|
CRLs asn1.RawValue `asn1:"optional,tag:1"`
|
||||||
|
SignerInfos []asn1.RawValue `asn1:"set"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encapContentInfoASN1 struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"optional,explicit,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signerInfoASN1 struct {
|
||||||
|
Version int
|
||||||
|
SID asn1.RawValue // CHOICE — IssuerAndSerial (default) or [0] SubjectKeyId
|
||||||
|
DigestAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` // [0] IMPLICIT SET OF Attribute
|
||||||
|
SignatureAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
Signature []byte
|
||||||
|
UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attributeASN1 struct {
|
||||||
|
Type asn1.ObjectIdentifier
|
||||||
|
Values asn1.RawValue `asn1:"set"` // SET OF AttributeValue — left raw; per-attr decoder handles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSignedData parses a CMS ContentInfo wrapping a SignedData and
|
||||||
|
// returns the parsed structure including any certs + signerInfos.
|
||||||
|
//
|
||||||
|
// SCEP clients put the device's transient signing cert in the
|
||||||
|
// certificates field; the handler's POPO check picks the cert matching
|
||||||
|
// each signerInfo's SID and verifies with that cert's public key.
|
||||||
|
func ParseSignedData(der []byte) (*SignedData, error) {
|
||||||
|
if len(der) == 0 {
|
||||||
|
return nil, fmt.Errorf("signedData: empty input")
|
||||||
|
}
|
||||||
|
// Try peeling the optional outer ContentInfo (SEQUENCE { OID, [0] EXPLICIT ANY }).
|
||||||
|
if peeled, ok := peelContentInfo(der, OIDSignedData); ok {
|
||||||
|
der = peeled
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw signedDataASN1
|
||||||
|
if _, err := asn1.Unmarshal(der, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("signedData: parse outer SEQUENCE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &SignedData{
|
||||||
|
Version: raw.Version,
|
||||||
|
DigestAlgorithms: raw.DigestAlgorithms,
|
||||||
|
EncapContentType: raw.EncapContentInfo.ContentType,
|
||||||
|
}
|
||||||
|
// EncapContent is [0] EXPLICIT — the [0] EXPLICIT wrapper holds an
|
||||||
|
// OCTET STRING whose Bytes are the inner content. Some encoders use
|
||||||
|
// a degenerate empty content (external-signature mode); that's fine.
|
||||||
|
if len(raw.EncapContentInfo.Content.Bytes) > 0 {
|
||||||
|
// The OCTET STRING wrapper inside [0] EXPLICIT — strip it.
|
||||||
|
var innerOctet asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(raw.EncapContentInfo.Content.Bytes, &innerOctet); err == nil && innerOctet.Tag == asn1.TagOctetString {
|
||||||
|
out.EncapContent = innerOctet.Bytes
|
||||||
|
} else {
|
||||||
|
out.EncapContent = raw.EncapContentInfo.Content.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse certificates SET. Each member is a Certificate (SEQUENCE).
|
||||||
|
if len(raw.Certificates.Bytes) > 0 {
|
||||||
|
certBytes := raw.Certificates.Bytes
|
||||||
|
for len(certBytes) > 0 {
|
||||||
|
var rv asn1.RawValue
|
||||||
|
rest, err := asn1.Unmarshal(certBytes, &rv)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rv.Class == asn1.ClassUniversal && rv.Tag == asn1.TagSequence {
|
||||||
|
if cert, err := x509.ParseCertificate(rv.FullBytes); err == nil {
|
||||||
|
out.Certificates = append(out.Certificates, cert)
|
||||||
|
}
|
||||||
|
// else: not a parseable cert (could be other CertificateChoices) — skip
|
||||||
|
}
|
||||||
|
certBytes = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse each SignerInfo + look up its SignerCert from out.Certificates.
|
||||||
|
for _, siRaw := range raw.SignerInfos {
|
||||||
|
si, err := parseSignerInfoFromRaw(siRaw, out.Certificates)
|
||||||
|
if err != nil {
|
||||||
|
// Skip individual unparseable signerInfos rather than failing
|
||||||
|
// the whole SignedData — multi-signer CMS may have one bad
|
||||||
|
// signer alongside good ones (rare in SCEP, but keep tolerant).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.SignerInfos = append(out.SignerInfos, si)
|
||||||
|
}
|
||||||
|
if len(out.SignerInfos) == 0 {
|
||||||
|
return nil, fmt.Errorf("signedData: no parseable signerInfos")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSignerInfos extracts SignerInfo records from a SignedData blob.
|
||||||
|
// Convenience wrapper around ParseSignedData when the caller only cares
|
||||||
|
// about the signers, not the certificates list.
|
||||||
|
func ParseSignerInfos(signedDataDER []byte) ([]*SignerInfo, error) {
|
||||||
|
sd, err := ParseSignedData(signedDataDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sd.SignerInfos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignerInfoFromRaw(raw asn1.RawValue, certs []*x509.Certificate) (*SignerInfo, error) {
|
||||||
|
var siRaw signerInfoASN1
|
||||||
|
if _, err := asn1.Unmarshal(raw.FullBytes, &siRaw); err != nil {
|
||||||
|
return nil, fmt.Errorf("signerInfo: parse SEQUENCE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
si := &SignerInfo{
|
||||||
|
Version: siRaw.Version,
|
||||||
|
AuthAttributes: map[string]asn1.RawValue{},
|
||||||
|
DigestAlgorithm: siRaw.DigestAlgorithm.Algorithm,
|
||||||
|
SignatureAlgorithm: siRaw.SignatureAlgorithm.Algorithm,
|
||||||
|
Signature: siRaw.Signature,
|
||||||
|
rawSignedAttrs: siRaw.SignedAttrs.Bytes, // bytes inside the [0] IMPLICIT — used for re-serialisation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk authenticated attributes (SET OF Attribute). The [0] IMPLICIT
|
||||||
|
// wrapper means siRaw.SignedAttrs.Bytes holds the SET-OF body directly
|
||||||
|
// (no extra OCTET STRING wrapper).
|
||||||
|
attrBytes := siRaw.SignedAttrs.Bytes
|
||||||
|
for len(attrBytes) > 0 {
|
||||||
|
var attr attributeASN1
|
||||||
|
rest, err := asn1.Unmarshal(attrBytes, &attr)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
si.AuthAttributes[attr.Type.String()] = attr.Values
|
||||||
|
attrBytes = rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve SignerCert by matching the SID against the certs list. SCEP
|
||||||
|
// uses IssuerAndSerial for v1; the [0] IMPLICIT SubjectKeyId form is
|
||||||
|
// v3 — accept both.
|
||||||
|
si.SignerCert = matchSignerCert(siRaw.SID, certs)
|
||||||
|
if si.SignerCert == nil {
|
||||||
|
return nil, fmt.Errorf("signerInfo: SignerCert not found in SignedData certificates")
|
||||||
|
}
|
||||||
|
return si, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchSignerCert(sid asn1.RawValue, certs []*x509.Certificate) *x509.Certificate {
|
||||||
|
// IssuerAndSerial form: SEQUENCE (no context tag) — universal class.
|
||||||
|
if sid.Class == asn1.ClassUniversal && sid.Tag == asn1.TagSequence {
|
||||||
|
var ias issuerAndSerialASN1
|
||||||
|
if _, err := asn1.Unmarshal(sid.FullBytes, &ias); err == nil {
|
||||||
|
for _, c := range certs {
|
||||||
|
if c.SerialNumber == nil || ias.SerialNumber == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ias.SerialNumber.Cmp(c.SerialNumber) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if asn1Equal(ias.Issuer.FullBytes, c.RawIssuer) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// SubjectKeyIdentifier form: [0] IMPLICIT OCTET STRING.
|
||||||
|
if sid.Class == asn1.ClassContextSpecific && sid.Tag == 0 {
|
||||||
|
ski := sid.Bytes
|
||||||
|
for _, c := range certs {
|
||||||
|
if asn1Equal(c.SubjectKeyId, ski) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asn1Equal(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySignature verifies the signerInfo's signature over the
|
||||||
|
// authenticatedAttributes (SCEP POPO).
|
||||||
|
//
|
||||||
|
// CMS signature semantics (RFC 5652 §5.4):
|
||||||
|
//
|
||||||
|
// 1. Re-serialise signedAttrs as a SET OF Attribute. The wire form is
|
||||||
|
// [0] IMPLICIT, but the signature is computed over the EXPLICIT
|
||||||
|
// SET OF re-serialisation. Easy mistake; this is the canonical CMS
|
||||||
|
// quirk every implementation hits.
|
||||||
|
// 2. Hash the re-serialised bytes with DigestAlgorithm.
|
||||||
|
// 3. Verify Signature against the hash using SignerCert.PublicKey +
|
||||||
|
// SignatureAlgorithm.
|
||||||
|
//
|
||||||
|
// Supports RSA-PKCS1v15 + ECDSA. Rejects RSA-PSS as out-of-spec for SCEP.
|
||||||
|
func (s *SignerInfo) VerifySignature() error {
|
||||||
|
if s == nil || s.SignerCert == nil {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
if len(s.rawSignedAttrs) == 0 {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-serialise as SET OF Attribute. We have rawSignedAttrs which is
|
||||||
|
// the bytes INSIDE the [0] IMPLICIT wrapper — that's the SET OF body.
|
||||||
|
// Wrap with the SET tag (0x31) + length to get the canonical form
|
||||||
|
// the signature is computed over.
|
||||||
|
signedAttrsForSig := ASN1Wrap(0x31, s.rawSignedAttrs)
|
||||||
|
|
||||||
|
// Hash with the digest algorithm.
|
||||||
|
digest, hashAlg, err := hashForOID(s.DigestAlgorithm, signedAttrsForSig)
|
||||||
|
if err != nil {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
switch pub := s.SignerCert.PublicKey.(type) {
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
if !isRSASigAlg(s.SignatureAlgorithm) {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
if err := rsa.VerifyPKCS1v15(pub, hashAlg, digest, s.Signature); err != nil {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
if !isECDSASigAlg(s.SignatureAlgorithm) {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
// crypto/ecdsa.VerifyASN1 takes the same hash, returns bool
|
||||||
|
if !ecdsa.VerifyASN1(pub, digest, s.Signature) {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashForOID(oid asn1.ObjectIdentifier, data []byte) ([]byte, crypto.Hash, error) {
|
||||||
|
switch {
|
||||||
|
case oid.Equal(OIDSHA256), oid.Equal(OIDRSAWithSHA256), oid.Equal(OIDECDSAWithSHA256):
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return h[:], crypto.SHA256, nil
|
||||||
|
case oid.Equal(OIDSHA512), oid.Equal(OIDRSAWithSHA512), oid.Equal(OIDECDSAWithSHA512):
|
||||||
|
h := sha512.Sum512(data)
|
||||||
|
return h[:], crypto.SHA512, nil
|
||||||
|
case oid.Equal(OIDSHA1), oid.Equal(OIDRSAWithSHA1):
|
||||||
|
// SHA-1 still appears in legacy SCEP clients (Cisco IOS pre-2018).
|
||||||
|
// RFC 8894 §3.5.2 advertises SHA-256 as preferred but does not ban SHA-1.
|
||||||
|
h := sha1.Sum(data) //nolint:gosec // RFC 8894 §3.5.2 baseline
|
||||||
|
return h[:], crypto.SHA1, nil
|
||||||
|
}
|
||||||
|
return nil, 0, fmt.Errorf("unsupported digest algorithm: %v", oid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRSASigAlg(oid asn1.ObjectIdentifier) bool {
|
||||||
|
return oid.Equal(OIDRSAWithSHA1) || oid.Equal(OIDRSAWithSHA256) || oid.Equal(OIDRSAWithSHA512) || oid.Equal(OIDRSAEncryption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isECDSASigAlg(oid asn1.ObjectIdentifier) bool {
|
||||||
|
return oid.Equal(OIDECDSAWithSHA256) || oid.Equal(OIDECDSAWithSHA512)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SCEP authenticated-attribute extractors -----------------------------
|
||||||
|
|
||||||
|
// GetMessageType returns the SCEP messageType value (RFC 8894 §3.2.1.4.1
|
||||||
|
// — encoded as a PrintableString containing the decimal ASCII of the
|
||||||
|
// message type integer, e.g. "19" for PKCSReq).
|
||||||
|
func (s *SignerInfo) GetMessageType() (domain.SCEPMessageType, error) {
|
||||||
|
str, err := s.attrPrintableString(OIDSCEPMessageType)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
mt, err := strconv.Atoi(str)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("messageType: parse %q as integer: %w", str, err)
|
||||||
|
}
|
||||||
|
return domain.SCEPMessageType(mt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransactionID returns the SCEP transactionID (RFC 8894 §3.2.1.4.4 —
|
||||||
|
// PrintableString chosen by the client; server MUST echo verbatim in
|
||||||
|
// CertRep).
|
||||||
|
func (s *SignerInfo) GetTransactionID() (string, error) {
|
||||||
|
return s.attrPrintableString(OIDSCEPTransactionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSenderNonce returns the 16-byte SCEP senderNonce (RFC 8894 §3.2.1.4.5
|
||||||
|
// — OCTET STRING).
|
||||||
|
func (s *SignerInfo) GetSenderNonce() ([]byte, error) {
|
||||||
|
return s.attrOctetString(OIDSCEPSenderNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageDigest returns the standard CMS messageDigest auth-attr
|
||||||
|
// (RFC 5652 §11.2). Used by the signature verification — when
|
||||||
|
// signedAttrs is present, the signature is over the re-serialised
|
||||||
|
// signedAttrs SET; the messageDigest auth-attr is what binds the
|
||||||
|
// signedAttrs to the encapContent.
|
||||||
|
func (s *SignerInfo) GetMessageDigest() ([]byte, error) {
|
||||||
|
return s.attrOctetString(OIDMessageDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrPrintableString extracts a PrintableString from the AuthAttributes
|
||||||
|
// SET-OF-Attribute-Values for the given attribute OID. Caller-side validation
|
||||||
|
// of length / charset is left to the SCEP-specific extractor.
|
||||||
|
func (s *SignerInfo) attrPrintableString(oid asn1.ObjectIdentifier) (string, error) {
|
||||||
|
rv, ok := s.AuthAttributes[oid.String()]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("auth-attr %v not present", oid)
|
||||||
|
}
|
||||||
|
// rv is the SET OF AttributeValue — typically one element. The
|
||||||
|
// first element is a PrintableString or IA5String.
|
||||||
|
if len(rv.Bytes) == 0 {
|
||||||
|
return "", fmt.Errorf("auth-attr %v: empty value", oid)
|
||||||
|
}
|
||||||
|
var inner asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||||
|
return "", fmt.Errorf("auth-attr %v: unmarshal value: %w", oid, err)
|
||||||
|
}
|
||||||
|
// PrintableString / IA5String / UTF8String all carry their bytes
|
||||||
|
// directly in inner.Bytes.
|
||||||
|
switch inner.Tag {
|
||||||
|
case asn1.TagPrintableString, asn1.TagIA5String, asn1.TagUTF8String:
|
||||||
|
return string(inner.Bytes), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("auth-attr %v: unexpected value tag %d", oid, inner.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SignerInfo) attrOctetString(oid asn1.ObjectIdentifier) ([]byte, error) {
|
||||||
|
rv, ok := s.AuthAttributes[oid.String()]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v not present", oid)
|
||||||
|
}
|
||||||
|
if len(rv.Bytes) == 0 {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v: empty value", oid)
|
||||||
|
}
|
||||||
|
var inner asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v: unmarshal value: %w", oid, err)
|
||||||
|
}
|
||||||
|
if inner.Tag != asn1.TagOctetString {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v: unexpected value tag %d (want OCTET STRING)", oid, inner.Tag)
|
||||||
|
}
|
||||||
|
return inner.Bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused warning for big.Int — referenced via issuerAndSerialASN1 in
|
||||||
|
// envelopeddata.go but the linker only sees it once per package; this keeps
|
||||||
|
// the import healthy if someone deletes envelopeddata.go's helper struct.
|
||||||
|
var _ = (*big.Int)(nil)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FuzzParseSignedData / FuzzParseSignerInfos are the panic-safety fuzzers
|
||||||
|
// for the SignedData parser path used by the SCEP RFC 8894 handler.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.5. Each parser certctl
|
||||||
|
// adds gets a Fuzz target so attacker-controlled wire bytes cannot
|
||||||
|
// crash the server (availability bug). Errors are expected for arbitrary
|
||||||
|
// inputs; only panics are bugs.
|
||||||
|
|
||||||
|
func FuzzParseSignedData(f *testing.F) {
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x30, 0x03, 0x02, 0x01, 0x00})
|
||||||
|
f.Add([]byte{0x30, 0x82, 0x05, 0x01, 0x02, 0x03})
|
||||||
|
// A short SEQUENCE that LOOKS like a ContentInfo with a signedData OID
|
||||||
|
// but is too truncated to actually decode.
|
||||||
|
f.Add([]byte{0x30, 0x0e, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02, 0xa0, 0x00})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
_, _ = ParseSignedData(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzParseSignerInfos(f *testing.F) {
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x30, 0x00})
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
_, _ = ParseSignerInfos(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzVerifySignerInfoSignature stresses the verification path with an
|
||||||
|
// arbitrary SignerInfo body (including signature, auth-attrs, cert
|
||||||
|
// reference). The verification is expected to fail for arbitrary inputs;
|
||||||
|
// the invariant the fuzzer enforces is no-panic.
|
||||||
|
//
|
||||||
|
// The test feeds the input bytes through ParseSignedData first so the
|
||||||
|
// fuzz exercises the same parse → SignerInfo extraction → verify path
|
||||||
|
// the production handler runs. Skip-on-parse-error is acceptable —
|
||||||
|
// fuzzing a parse failure adds zero value here; the parse fuzzer above
|
||||||
|
// already covers that path.
|
||||||
|
func FuzzVerifySignerInfoSignature(f *testing.F) {
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x30, 0x00})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
sd, err := ParseSignedData(data)
|
||||||
|
if err != nil || sd == nil {
|
||||||
|
return // covered by FuzzParseSignedData
|
||||||
|
}
|
||||||
|
for _, si := range sd.SignerInfos {
|
||||||
|
_ = si.VerifySignature() // invariant: no panic
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 2.2: round-trip tests for ParseSignedData +
|
||||||
|
// SignerInfo.VerifySignature + auth-attr extractors.
|
||||||
|
//
|
||||||
|
// Each test materialises a real signing cert + signs auth-attrs over a
|
||||||
|
// known content, then re-parses and verifies. Catches drift between the
|
||||||
|
// signing-side encoding and the verification-side re-serialisation
|
||||||
|
// (RFC 5652 §5.4 SET OF Attribute quirk).
|
||||||
|
|
||||||
|
func TestSignerInfo_RoundTrip_RSAWithSHA256(t *testing.T) {
|
||||||
|
signer, signerCert := genTestRSASigner(t)
|
||||||
|
signedData := buildTestSignedData(t, signer, signerCert,
|
||||||
|
domain.SCEPMessageTypePKCSReq, "txn-12345", []byte("0123456789abcdef"),
|
||||||
|
[]byte("encapsulated content (typically EnvelopedData bytes)"))
|
||||||
|
|
||||||
|
parsed, err := ParseSignedData(signedData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed.SignerInfos) != 1 {
|
||||||
|
t.Fatalf("len(SignerInfos) = %d, want 1", len(parsed.SignerInfos))
|
||||||
|
}
|
||||||
|
|
||||||
|
si := parsed.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
t.Fatalf("VerifySignature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth-attr extractors.
|
||||||
|
mt, err := si.GetMessageType()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessageType: %v", err)
|
||||||
|
}
|
||||||
|
if mt != domain.SCEPMessageTypePKCSReq {
|
||||||
|
t.Errorf("GetMessageType = %d, want %d", mt, domain.SCEPMessageTypePKCSReq)
|
||||||
|
}
|
||||||
|
tid, err := si.GetTransactionID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTransactionID: %v", err)
|
||||||
|
}
|
||||||
|
if tid != "txn-12345" {
|
||||||
|
t.Errorf("GetTransactionID = %q, want %q", tid, "txn-12345")
|
||||||
|
}
|
||||||
|
nonce, err := si.GetSenderNonce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSenderNonce: %v", err)
|
||||||
|
}
|
||||||
|
if string(nonce) != "0123456789abcdef" {
|
||||||
|
t.Errorf("GetSenderNonce = %q, want %q", nonce, "0123456789abcdef")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignerInfo_RoundTrip_ECDSAWithSHA256(t *testing.T) {
|
||||||
|
signer, signerCert := genTestECDSASigner(t)
|
||||||
|
signedData := buildTestSignedData(t, signer, signerCert,
|
||||||
|
domain.SCEPMessageTypeRenewalReq, "txn-ec-1", []byte("nonce-ec-aaaa-bbbb"),
|
||||||
|
[]byte("encap content"))
|
||||||
|
|
||||||
|
parsed, err := ParseSignedData(signedData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
si := parsed.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
t.Fatalf("VerifySignature (ECDSA): %v", err)
|
||||||
|
}
|
||||||
|
mt, err := si.GetMessageType()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessageType: %v", err)
|
||||||
|
}
|
||||||
|
if mt != domain.SCEPMessageTypeRenewalReq {
|
||||||
|
t.Errorf("GetMessageType = %d, want RenewalReq (17)", mt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignerInfo_VerifySignature_TamperedAttrs_Refuses(t *testing.T) {
|
||||||
|
signer, signerCert := genTestRSASigner(t)
|
||||||
|
signedData := buildTestSignedData(t, signer, signerCert,
|
||||||
|
domain.SCEPMessageTypePKCSReq, "txn-tamper", []byte("nonce-aaaa-bbbb"),
|
||||||
|
[]byte("content"))
|
||||||
|
|
||||||
|
parsed, err := ParseSignedData(signedData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
si := parsed.SignerInfos[0]
|
||||||
|
// Tamper with rawSignedAttrs by flipping the last byte. Re-verification
|
||||||
|
// must reject — proves the signature is bound to the auth-attr bytes.
|
||||||
|
si.rawSignedAttrs[len(si.rawSignedAttrs)-1] ^= 0x01
|
||||||
|
if err := si.VerifySignature(); !errors.Is(err, ErrSignerInfoVerify) {
|
||||||
|
t.Errorf("VerifySignature(tampered attrs) = %v, want ErrSignerInfoVerify", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSignedData_Empty_Refuses(t *testing.T) {
|
||||||
|
if _, err := ParseSignedData(nil); err == nil {
|
||||||
|
t.Error("ParseSignedData(nil) = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := ParseSignedData([]byte{}); err == nil {
|
||||||
|
t.Error("ParseSignedData(empty) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSignedData_Garbage_Refuses(t *testing.T) {
|
||||||
|
garbage := []byte{0x30, 0x82, 0x05, 0x01, 0x02, 0x03}
|
||||||
|
if _, err := ParseSignedData(garbage); err == nil {
|
||||||
|
t.Error("ParseSignedData(garbage) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
type testSigner interface {
|
||||||
|
Sign(data []byte) ([]byte, error)
|
||||||
|
DigestOID() asn1.ObjectIdentifier
|
||||||
|
SignatureOID() asn1.ObjectIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
type rsaTestSigner struct{ k *rsa.PrivateKey }
|
||||||
|
|
||||||
|
func (s *rsaTestSigner) Sign(data []byte) ([]byte, error) {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return rsa.SignPKCS1v15(rand.Reader, s.k, 0+5, h[:]) // 5 == crypto.SHA256 in crypto.Hash enum
|
||||||
|
}
|
||||||
|
func (s *rsaTestSigner) DigestOID() asn1.ObjectIdentifier { return OIDSHA256 }
|
||||||
|
func (s *rsaTestSigner) SignatureOID() asn1.ObjectIdentifier { return OIDRSAWithSHA256 }
|
||||||
|
|
||||||
|
type ecdsaTestSigner struct{ k *ecdsa.PrivateKey }
|
||||||
|
|
||||||
|
func (s *ecdsaTestSigner) Sign(data []byte) ([]byte, error) {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return ecdsa.SignASN1(rand.Reader, s.k, h[:])
|
||||||
|
}
|
||||||
|
func (s *ecdsaTestSigner) DigestOID() asn1.ObjectIdentifier { return OIDSHA256 }
|
||||||
|
func (s *ecdsaTestSigner) SignatureOID() asn1.ObjectIdentifier { return OIDECDSAWithSHA256 }
|
||||||
|
|
||||||
|
func genTestRSASigner(t *testing.T) (testSigner, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano() ^ 0xDEAD),
|
||||||
|
Subject: pkix.Name{CommonName: "device-rsa"},
|
||||||
|
Issuer: pkix.Name{CommonName: "device-rsa"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return &rsaTestSigner{k: key}, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func genTestECDSASigner(t *testing.T) (testSigner, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano() ^ 0xBEEF),
|
||||||
|
Subject: pkix.Name{CommonName: "device-ec"},
|
||||||
|
Issuer: pkix.Name{CommonName: "device-ec"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return &ecdsaTestSigner{k: key}, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestSignedData hand-constructs a CMS SignedData with one SignerInfo
|
||||||
|
// carrying SCEP authenticated attributes (messageType, transactionID,
|
||||||
|
// senderNonce, plus the standard CMS contentType + messageDigest).
|
||||||
|
//
|
||||||
|
// The signing pipeline mirrors what micromdm/scep + the ChromeOS SCEP
|
||||||
|
// client emit: the device hashes the encap content into messageDigest,
|
||||||
|
// the auth-attrs are SET-OF re-serialised, hashed, and signed.
|
||||||
|
//
|
||||||
|
// Implementation note: built directly with ASN1Wrap helpers rather than
|
||||||
|
// relying on asn1.Marshal of structs containing asn1.RawValue fields —
|
||||||
|
// asn1.Marshal of nested RawValues with mixed Class/Tag has been finicky
|
||||||
|
// and the helpers give us byte-level control that matches what's on the wire.
|
||||||
|
func buildTestSignedData(t *testing.T, signer testSigner, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// 1. messageDigest auth-attr: SHA-256 of the encap content.
|
||||||
|
contentDigest := sha256.Sum256(encapContent)
|
||||||
|
|
||||||
|
// 2. Build each auth-attr as Attribute ::= SEQUENCE { OID, SET OF Value }
|
||||||
|
// using the helpers. Marshal each value individually then wrap.
|
||||||
|
attrSetBody := buildSCEPAuthAttrs(t, contentDigest[:], messageType, transactionID, senderNonce)
|
||||||
|
|
||||||
|
// 3. Compute the signature over SET OF Attribute.
|
||||||
|
signedAttrsForSig := ASN1Wrap(0x31, attrSetBody)
|
||||||
|
sig, err := signer.Sign(signedAttrsForSig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signer.Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the SignerInfo SEQUENCE byte-by-byte.
|
||||||
|
versionBytes := []byte{0x02, 0x01, 0x01} // INTEGER 1
|
||||||
|
// SID is IssuerAndSerialNumber: SEQUENCE { Issuer (RDN), SerialNumber INTEGER }
|
||||||
|
serialDER, err := asn1.Marshal(signerCert.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal serial: %v", err)
|
||||||
|
}
|
||||||
|
sidBody := append([]byte{}, signerCert.RawIssuer...) // already in DER
|
||||||
|
sidBody = append(sidBody, serialDER...)
|
||||||
|
sidBytes := ASN1Wrap(0x30, sidBody)
|
||||||
|
|
||||||
|
// DigestAlgorithm: AlgorithmIdentifier — encode via stdlib (small struct, no nested RawValue issues).
|
||||||
|
digestAlgBytes := mustMarshal(t, pkix.AlgorithmIdentifier{Algorithm: signer.DigestOID(), Parameters: asn1.NullRawValue})
|
||||||
|
|
||||||
|
// SignedAttrs as [0] IMPLICIT SET OF — tag 0xA0 wraps the SET body.
|
||||||
|
signedAttrsImplicitBytes := ASN1Wrap(0xa0, attrSetBody)
|
||||||
|
|
||||||
|
// SignatureAlgorithm.
|
||||||
|
sigAlg := pkix.AlgorithmIdentifier{Algorithm: signer.SignatureOID()}
|
||||||
|
if signer.SignatureOID().Equal(OIDRSAWithSHA256) {
|
||||||
|
sigAlg.Parameters = asn1.NullRawValue
|
||||||
|
}
|
||||||
|
sigAlgBytes := mustMarshal(t, sigAlg)
|
||||||
|
|
||||||
|
// Signature: OCTET STRING.
|
||||||
|
sigOctetBytes := ASN1Wrap(0x04, sig)
|
||||||
|
|
||||||
|
siBody := append([]byte{}, versionBytes...)
|
||||||
|
siBody = append(siBody, sidBytes...)
|
||||||
|
siBody = append(siBody, digestAlgBytes...)
|
||||||
|
siBody = append(siBody, signedAttrsImplicitBytes...)
|
||||||
|
siBody = append(siBody, sigAlgBytes...)
|
||||||
|
siBody = append(siBody, sigOctetBytes...)
|
||||||
|
siBytes := ASN1Wrap(0x30, siBody)
|
||||||
|
|
||||||
|
// 5. Build encapContentInfo SEQUENCE { OID data, [0] EXPLICIT OCTET STRING }.
|
||||||
|
octetBytes := ASN1Wrap(0x04, encapContent) // OCTET STRING
|
||||||
|
encapContentExplicit := ASN1Wrap(0xa0, octetBytes) // [0] EXPLICIT
|
||||||
|
oidDataBytes := mustMarshal(t, OIDDataContent)
|
||||||
|
encapBody := append([]byte{}, oidDataBytes...)
|
||||||
|
encapBody = append(encapBody, encapContentExplicit...)
|
||||||
|
encapBytes := ASN1Wrap(0x30, encapBody)
|
||||||
|
|
||||||
|
// 6. certificates [0] IMPLICIT SET OF Certificate — body is one cert DER.
|
||||||
|
certsBytes := ASN1Wrap(0xa0, signerCert.Raw)
|
||||||
|
|
||||||
|
// 7. digestAlgorithms SET OF AlgorithmIdentifier (one entry).
|
||||||
|
digestAlgsBytes := ASN1Wrap(0x31, digestAlgBytes)
|
||||||
|
|
||||||
|
// 8. signerInfos SET OF SignerInfo (one entry).
|
||||||
|
signerInfosBytes := ASN1Wrap(0x31, siBytes)
|
||||||
|
|
||||||
|
// 9. Assemble SignedData SEQUENCE.
|
||||||
|
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) // version
|
||||||
|
sdBody = append(sdBody, digestAlgsBytes...)
|
||||||
|
sdBody = append(sdBody, encapBytes...)
|
||||||
|
sdBody = append(sdBody, certsBytes...)
|
||||||
|
sdBody = append(sdBody, signerInfosBytes...)
|
||||||
|
sdSeq := ASN1Wrap(0x30, sdBody)
|
||||||
|
|
||||||
|
// 10. Wrap as ContentInfo SEQUENCE { OID signedData, [0] EXPLICIT SignedData }.
|
||||||
|
contentField := ASN1Wrap(0xa0, sdSeq)
|
||||||
|
oidSignedDataDER := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
ciBody := append([]byte{}, oidSignedDataDER...)
|
||||||
|
ciBody = append(ciBody, contentField...)
|
||||||
|
return ASN1Wrap(0x30, ciBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSCEPAuthAttrs builds the SET-OF body of SCEP auth-attrs (the bytes
|
||||||
|
// inside the [0] IMPLICIT SignedAttrs wrapper). Each Attribute is a SEQUENCE
|
||||||
|
// of (OID, SET OF Value); we build them with ASN1Wrap to avoid asn1.Marshal
|
||||||
|
// nuances with nested RawValues.
|
||||||
|
func buildSCEPAuthAttrs(t *testing.T, msgDigest []byte, messageType domain.SCEPMessageType, transactionID string, senderNonce []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var out []byte
|
||||||
|
// contentType: SET OF OID = SET { OID data }
|
||||||
|
out = append(out, attrSeq(t, OIDContentType, ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
|
||||||
|
// messageDigest: SET OF OCTET STRING
|
||||||
|
out = append(out, attrSeq(t, OIDMessageDigest, ASN1Wrap(0x04, msgDigest))...)
|
||||||
|
// SCEP messageType: SET OF PrintableString (decimal ASCII)
|
||||||
|
out = append(out, attrSeq(t, OIDSCEPMessageType, ASN1Wrap(0x13, []byte(intToAscii(int(messageType)))))...)
|
||||||
|
// SCEP transactionID: SET OF PrintableString
|
||||||
|
out = append(out, attrSeq(t, OIDSCEPTransactionID, ASN1Wrap(0x13, []byte(transactionID)))...)
|
||||||
|
// SCEP senderNonce: SET OF OCTET STRING
|
||||||
|
out = append(out, attrSeq(t, OIDSCEPSenderNonce, ASN1Wrap(0x04, senderNonce))...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrSeq builds one Attribute SEQUENCE: SEQUENCE { OID, SET OF value }.
|
||||||
|
// The `value` arg is one already-encoded TLV (e.g. an OCTET STRING or
|
||||||
|
// PrintableString); attrSeq wraps it in a SET and prefixes the OID.
|
||||||
|
func attrSeq(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
oidBytes := mustMarshal(t, oid)
|
||||||
|
setOfValue := ASN1Wrap(0x31, value)
|
||||||
|
body := append([]byte{}, oidBytes...)
|
||||||
|
body = append(body, setOfValue...)
|
||||||
|
return ASN1Wrap(0x30, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshal(t *testing.T, v interface{}) []byte {
|
||||||
|
t.Helper()
|
||||||
|
out, err := asn1.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal %T: %v", v, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToAscii(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := i < 0
|
||||||
|
if neg {
|
||||||
|
i = -i
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for i > 0 {
|
||||||
|
b = append([]byte{byte('0' + i%10)}, b...)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
b = append([]byte{'-'}, b...)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -210,3 +210,92 @@ func (s *SCEPService) processEnrollment(ctx context.Context, csrPEM string, tran
|
|||||||
ChainPEM: result.ChainPEM,
|
ChainPEM: result.ChainPEM,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope processes a SCEP PKCSReq from the RFC 8894 path
|
||||||
|
// (where the handler successfully parsed an EnvelopedData + signerInfo
|
||||||
|
// instead of the MVP raw-CSR path).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.4.
|
||||||
|
//
|
||||||
|
// Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because
|
||||||
|
// RFC 8894 mandates a CertRep PKIMessage on every PKIOperation request,
|
||||||
|
// even failure cases — the handler shouldn't have to translate Go errors
|
||||||
|
// into SCEP failInfo codes; the service does that mapping.
|
||||||
|
//
|
||||||
|
// Service-side error → failInfo mapping (from the prompt's exact table):
|
||||||
|
//
|
||||||
|
// Invalid challenge password → caller returns HTTP 403, NOT a PKIMessage
|
||||||
|
// (RFC 8894 §3.3.1 silent on this; matches MVP precedent)
|
||||||
|
// CSR parse failure → BadRequest (2)
|
||||||
|
// CSR signature invalid → BadMessageCheck (1)
|
||||||
|
// Crypto policy violation → BadAlg (0)
|
||||||
|
// Issuer connector failure → BadRequest (2)
|
||||||
|
// Audit-log write failure → log + continue with success (best-effort)
|
||||||
|
//
|
||||||
|
// The challenge-password failure case returns nil to signal "let the caller
|
||||||
|
// translate to 403"; every other failure mode returns a populated envelope
|
||||||
|
// with FailInfo set so the handler can build a CertRep with pkiStatus=2.
|
||||||
|
func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense-in-depth: refuse any enrollment when no shared secret is
|
||||||
|
// configured. Mirrors PKCSReq's gate. Returning nil signals 'let the
|
||||||
|
// caller translate to HTTP 403' — the existing PKCSReq path returns
|
||||||
|
// an error string the handler matched on, but PKCSReqWithEnvelope
|
||||||
|
// returns *SCEPResponseEnvelope so we use a nil sentinel.
|
||||||
|
if s.challengePassword == "" {
|
||||||
|
s.logger.Warn("SCEP enrollment rejected: server has no challenge password configured (RFC 8894 path)",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 {
|
||||||
|
s.logger.Warn("SCEP enrollment rejected: invalid challenge password (RFC 8894 path)",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the existing processEnrollment for the actual issuance work.
|
||||||
|
// Errors mapped to SCEP failInfo per the table above.
|
||||||
|
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_pkcsreq")
|
||||||
|
if err != nil {
|
||||||
|
resp.Status = domain.SCEPStatusFailure
|
||||||
|
resp.FailInfo = mapServiceErrorToFailInfo(err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp.Status = domain.SCEPStatusSuccess
|
||||||
|
resp.Result = result
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapServiceErrorToFailInfo translates a service-layer error into the
|
||||||
|
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. The mapping mirrors
|
||||||
|
// the table in PKCSReqWithEnvelope's docblock; defaults to BadRequest
|
||||||
|
// when the error doesn't match any specific category.
|
||||||
|
func mapServiceErrorToFailInfo(err error) domain.SCEPFailInfo {
|
||||||
|
if err == nil {
|
||||||
|
return domain.SCEPFailBadRequest
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case containsAnyOf(msg, "invalid CSR PEM", "failed to parse CSR"):
|
||||||
|
return domain.SCEPFailBadRequest
|
||||||
|
case containsAnyOf(msg, "CSR signature verification failed"):
|
||||||
|
return domain.SCEPFailBadMessageCheck
|
||||||
|
case containsAnyOf(msg, "key algorithm", "key size", "algorithm not allowed", "crypto policy"):
|
||||||
|
return domain.SCEPFailBadAlg
|
||||||
|
default:
|
||||||
|
return domain.SCEPFailBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyOf(s string, needles ...string) bool {
|
||||||
|
for _, n := range needles {
|
||||||
|
if strings.Contains(s, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user