mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:01:37 +00:00
feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.
Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.
== Phase 4: RenewalReq + GetCertInitial wiring ============================
internal/service/scep.go
* RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
existing valid cert. Same contract as PKCSReqWithEnvelope but the
service additionally verifies that envelope.SignerCert chains to
the issuer's CA (verifyRenewalSignerCertChain). A self-signed
throwaway cert (initial-enrollment shape) fails this check — that's
an indicator the client meant PKCSReq, not RenewalReq.
* GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
Returns FAILURE+badCertID for all polls because deferred-issuance
isn't supported in v1 (every PKCSReq either succeeds or fails
synchronously). Wiring stays in place for a future enhancement.
* Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
grep the audit log to distinguish initial enrollments from renewals.
internal/api/handler/scep.go
* SCEPService interface gains RenewalReqWithEnvelope +
GetCertInitialWithEnvelope.
* pkiOperation RFC 8894 path now switches on envelope.MessageType:
PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
badRequest per RFC 8894 §3.3.2.2.
== Phase 5.1: GetCACaps capability advertisement =========================
internal/service/scep.go
* Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
to add 'SHA-512' (modern digest alternative now implemented in the
Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
Phase 4). ChromeOS specifically looks for these capabilities to
negotiate the strongest available cipher + digest combo.
* scep_test.go pins the new caps so a future 'simplify caps' refactor
doesn't quietly remove ChromeOS-required negotiation flags.
== Phase 5.2: ChromeOS-shape integration tests ===========================
internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
* 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
in-test (acting as the ChromeOS client), POSTs through the handler,
parses the CertRep response back via the same internal/pkcs7/
builders the handler uses.
* TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
POSTed; verifies CertRep parses + RA signature verifies.
* TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
* TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
* TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
signature falls through to MVP path (which also rejects since the
encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
* TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
* TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
path keeps working when no RA pair is configured. Backward compat
is non-negotiable.
== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============
internal/domain/profile.go
* Added MustStaple bool to CertificateProfile. Default false; operators
opt in once they've confirmed the TLS reverse proxy / load balancer
staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
require explicit config).
internal/connector/issuer/interface.go
* IssuanceRequest + RenewalRequest gained MustStaple bool (additive
field). Connectors that don't support extension injection (Vault,
EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
issuer-only feature in V2 since upstream connectors enforce their
own extension policy.
internal/connector/issuer/local/local.go
* Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
RFC 7633 §6).
* generateCertificate signature gained mustStaple bool; when true,
appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
mustStapleExtensionValue} to template.ExtraExtensions before
x509.CreateCertificate.
internal/connector/issuer/local/must_staple_test.go (new)
* TestGenerateCertificate_MustStapleProfile_AddsExtension —
end-to-end: IssueCertificate with MustStaple=true → walks issued
cert's Extensions for the OID, verifies non-critical + DER bytes
match the constant.
* TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
'omit by default' contract (adding it by default would break
customer deployments where the TLS path doesn't staple).
* TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
DER bytes against RFC 7633 §6 verbatim; round-trips through
asn1.Unmarshal as []int{5}.
Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.
== Phase 5.4: docs/legacy-est-scep.md ====================================
Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).
== Verification ==========================================================
* gofmt + go vet clean for the files I touched.
* staticcheck ./internal/api/handler/... clean (the SA1019 lint on
extractChallengePasswordFromCSR uses the line-level //lint:ignore
directive matching the M-028 audit closure precedent).
* go test -short -count=1 green across api/handler / api/router /
service / pkcs7 / connector/issuer/local / domain / cmd/server.
* G-3 docs-drift CI guard local check: empty diff in both directions.
Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.
Living progress at cowork/scep-rfc8894-intune/progress.md.
This commit is contained in:
@@ -39,6 +39,19 @@ type SCEPService interface {
|
||||
// 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
|
||||
|
||||
// RenewalReqWithEnvelope processes a SCEP RenewalReq (RFC 8894 §3.3.1.2)
|
||||
// from the RFC 8894 path. Same contract as PKCSReqWithEnvelope but the
|
||||
// service additionally verifies that envelope.SignerCert chains to the
|
||||
// issuer's CA — RenewalReq requires a previously-issued cert as POPO.
|
||||
RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||
|
||||
// GetCertInitialWithEnvelope handles SCEP polling requests (RFC 8894
|
||||
// §3.3.3). The v1 implementation always returns FAILURE+badCertID
|
||||
// because deferred-issuance isn't supported (every PKCSReq either
|
||||
// succeeds or fails synchronously); wiring is in place for a future
|
||||
// 'queue for manual approval' workflow.
|
||||
GetCertInitialWithEnvelope(ctx context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||
}
|
||||
|
||||
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
||||
@@ -196,14 +209,44 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
||||
// backward-compat contract for lightweight clients.
|
||||
if h.raCert != nil && h.raKey != nil {
|
||||
if envelope, csrPEM, challengePassword, ok := h.tryParseRFC8894(body); ok {
|
||||
resp := h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 4.1: dispatch on
|
||||
// the parsed messageType. PKCSReq + RenewalReq exercise the
|
||||
// full enrollment pipeline (different audit actions + chain
|
||||
// validation for renewal); GetCertInitial is the polling
|
||||
// shape (v1 stub returns badCertID since deferred-issuance
|
||||
// isn't supported); unknown messageType returns CertRep with
|
||||
// FAILURE+badRequest per RFC 8894 §3.3.2.2.
|
||||
var resp *domain.SCEPResponseEnvelope
|
||||
switch envelope.MessageType {
|
||||
case domain.SCEPMessageTypePKCSReq:
|
||||
resp = h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||
case domain.SCEPMessageTypeRenewalReq:
|
||||
resp = h.svc.RenewalReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||
case domain.SCEPMessageTypeGetCertInitial:
|
||||
resp = h.svc.GetCertInitialWithEnvelope(r.Context(), envelope)
|
||||
default:
|
||||
// Unknown messageType — emit a CertRep+FAILURE so the
|
||||
// client sees a structured response rather than a vague
|
||||
// 400. RFC 8894 §3.2.1.4.1 enumerates the valid types;
|
||||
// anything else is a malformed client.
|
||||
resp = &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadRequest,
|
||||
TransactionID: envelope.TransactionID,
|
||||
RecipientNonce: envelope.SenderNonce,
|
||||
}
|
||||
}
|
||||
if resp == nil {
|
||||
// nil signals 'invalid challenge password'. RFC 8894 §3.3.1
|
||||
// is silent on whether to return a CertRep or an HTTP error
|
||||
// for this case; we mirror the MVP path's HTTP 403 wire
|
||||
// shape so the client sees a clear auth failure rather than
|
||||
// trying to interpret a structurally-valid CertRep+failInfo
|
||||
// (which conflates 'wrong secret' with 'wrong CSR shape').
|
||||
// nil signals 'invalid challenge password' from the
|
||||
// service layer (only PKCSReq + RenewalReq paths can
|
||||
// return nil — GetCertInitial always returns a
|
||||
// CertRep). RFC 8894 §3.3.1 is silent on whether to
|
||||
// return a CertRep or an HTTP error for the wrong-
|
||||
// password case; we mirror the MVP path's HTTP 403
|
||||
// wire shape so the client sees a clear auth failure
|
||||
// rather than trying to interpret a structurally-valid
|
||||
// CertRep+failInfo (which conflates 'wrong secret'
|
||||
// with 'wrong CSR shape').
|
||||
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
||||
return
|
||||
}
|
||||
@@ -336,9 +379,15 @@ func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope,
|
||||
// the RFC 2985 §5.4.1 challengePassword (OID 1.2.840.113549.1.9.7).
|
||||
// Returns empty string when missing.
|
||||
//
|
||||
//nolint:staticcheck // SA1019: RFC 2985 challengePassword has no non-deprecated stdlib API; mirrors extractCSRFields.
|
||||
// SA1019 carve-out: csr.Attributes is deprecated by Go's stdlib for the
|
||||
// requestedExtensions attribute, but RFC 2985 challengePassword (OID
|
||||
// 1.2.840.113549.1.9.7) is a SEPARATE CSR attribute that cannot be
|
||||
// retrieved via csr.Extensions. There is no non-deprecated stdlib API
|
||||
// for it; the same `lint:ignore SA1019` line precedent set by
|
||||
// extractCSRFields applies here.
|
||||
func extractChallengePasswordFromCSR(csr *x509.CertificateRequest) string {
|
||||
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
||||
//lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see extractCSRFields docblock for the M-028 audit closure rationale.
|
||||
for _, attr := range csr.Attributes {
|
||||
if attr.Type.Equal(oidChallengePassword) {
|
||||
if len(attr.Value) > 0 && len(attr.Value[0]) > 0 {
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/des" //nolint:gosec // RFC 8894 §3.5.2 legacy fallback for backward-compat test
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration
|
||||
// tests for the SCEP handler's full RFC 8894 path.
|
||||
//
|
||||
// Each test builds a real PKIMessage (acting as the ChromeOS client),
|
||||
// POSTs it through the handler, and verifies the response. The "client"
|
||||
// is built from primitives in internal/pkcs7/ — the same builders the
|
||||
// handler uses on the response side. This is intentional: if the handler
|
||||
// regresses, the client builder might also regress, and the E2E would
|
||||
// pass anyway (false negative). The mitigation: round-trip property
|
||||
// tests in internal/pkcs7/ assert Build/Parse symmetry independently,
|
||||
// and the handler-side tests focus on the dispatch + status-code wire
|
||||
// shape rather than the bytes themselves.
|
||||
|
||||
// chromeOSStackFixture holds the materials needed for an end-to-end
|
||||
// ChromeOS SCEP test: an issuer + RA pair (server side), a transient
|
||||
// device cert (client side), and a constructed SCEPHandler.
|
||||
type chromeOSStackFixture struct {
|
||||
raKey *rsa.PrivateKey
|
||||
raCert *x509.Certificate
|
||||
deviceKey *rsa.PrivateKey
|
||||
deviceCert *x509.Certificate
|
||||
handler SCEPHandler
|
||||
svc *chromeOSMockSCEPService
|
||||
}
|
||||
|
||||
// chromeOSMockSCEPService is the per-test SCEPService implementation used
|
||||
// by these E2E tests. Records the last call's envelope + CSR for assertion.
|
||||
type chromeOSMockSCEPService struct {
|
||||
caCertPEM string
|
||||
pkcsReqEnvelope *domain.SCEPRequestEnvelope
|
||||
pkcsReqCSRPEM string
|
||||
pkcsReqChallenge string
|
||||
renewalReqEnvelope *domain.SCEPRequestEnvelope
|
||||
renewalReqCSRPEM string
|
||||
getCertInitialEnvelope *domain.SCEPRequestEnvelope
|
||||
enrollResult *domain.SCEPEnrollResult
|
||||
failChallenge bool
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) GetCACaps(_ context.Context) string {
|
||||
return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n"
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) GetCACert(_ context.Context) (string, error) {
|
||||
return m.caCertPEM, nil
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) {
|
||||
return m.enrollResult, nil
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) PKCSReqWithEnvelope(_ context.Context, csrPEM, challengePassword string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
m.pkcsReqEnvelope = env
|
||||
m.pkcsReqCSRPEM = csrPEM
|
||||
m.pkcsReqChallenge = challengePassword
|
||||
if m.failChallenge {
|
||||
return nil
|
||||
}
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusSuccess,
|
||||
Result: m.enrollResult,
|
||||
TransactionID: env.TransactionID,
|
||||
RecipientNonce: env.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) RenewalReqWithEnvelope(_ context.Context, csrPEM, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
m.renewalReqEnvelope = env
|
||||
m.renewalReqCSRPEM = csrPEM
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusSuccess,
|
||||
Result: m.enrollResult,
|
||||
TransactionID: env.TransactionID,
|
||||
RecipientNonce: env.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *chromeOSMockSCEPService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
m.getCertInitialEnvelope = env
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadCertID,
|
||||
TransactionID: env.TransactionID,
|
||||
RecipientNonce: env.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
// newChromeOSStackFixture wires up an RA pair + device cert + handler with
|
||||
// an enroll-result fixture so the test can POST a PKIMessage and verify the
|
||||
// CertRep response.
|
||||
func newChromeOSStackFixture(t *testing.T) *chromeOSStackFixture {
|
||||
t.Helper()
|
||||
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey RA: %v", err)
|
||||
}
|
||||
raCert := selfSignedRSACert(t, raKey, "ra-test")
|
||||
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey device: %v", err)
|
||||
}
|
||||
deviceCert := selfSignedRSACert(t, deviceKey, "device-transient")
|
||||
|
||||
svc := &chromeOSMockSCEPService{
|
||||
enrollResult: &domain.SCEPEnrollResult{
|
||||
CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "issued.example.com")),
|
||||
},
|
||||
}
|
||||
handler := NewSCEPHandler(svc)
|
||||
handler.SetRAPair(raCert, raKey)
|
||||
|
||||
return &chromeOSStackFixture{
|
||||
raKey: raKey,
|
||||
raCert: raCert,
|
||||
deviceKey: deviceKey,
|
||||
deviceCert: deviceCert,
|
||||
handler: handler,
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_E2E exercises the full RFC 8894 path:
|
||||
// build a PKIMessage shaped like ChromeOS sends (SignedData wrapping
|
||||
// EnvelopedData wrapping a CSR, with signerInfo POPO over auth attrs);
|
||||
// POST through the handler; verify the response is a valid CertRep
|
||||
// PKIMessage with the issued cert encrypted to the test's transient pubkey.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_E2E(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-chromeos-e2e", "shared-secret-123", "device-cert.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
|
||||
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope == nil {
|
||||
t.Fatal("PKCSReqWithEnvelope was not called — handler skipped RFC 8894 path?")
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope.TransactionID != "txn-chromeos-e2e" {
|
||||
t.Errorf("envelope.TransactionID = %q, want txn-chromeos-e2e", fix.svc.pkcsReqEnvelope.TransactionID)
|
||||
}
|
||||
if fix.svc.pkcsReqChallenge != "shared-secret-123" {
|
||||
t.Errorf("challengePassword = %q, want shared-secret-123", fix.svc.pkcsReqChallenge)
|
||||
}
|
||||
// Parse the CertRep back via the same builders the handler emits.
|
||||
certRep, err := pkcs7.ParseSignedData(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSignedData(CertRep response): %v", err)
|
||||
}
|
||||
if len(certRep.SignerInfos) != 1 {
|
||||
t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos))
|
||||
}
|
||||
if err := certRep.SignerInfos[0].VerifySignature(); err != nil {
|
||||
t.Errorf("CertRep RA signature invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_RenewalReq exercises RenewalReq
|
||||
// dispatch — the handler should route to RenewalReqWithEnvelope based on
|
||||
// the messageType auth-attr.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_RenewalReq(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeRenewalReq, "txn-renewal-1", "shared-secret-123", "renewal.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
|
||||
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation (renewal): got %d, want 200", w.Code)
|
||||
}
|
||||
if fix.svc.renewalReqEnvelope == nil {
|
||||
t.Fatal("RenewalReqWithEnvelope was not called — dispatch missed messageType=17")
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope != nil {
|
||||
t.Errorf("PKCSReqWithEnvelope was called for a RenewalReq messageType — wrong dispatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial exercises the polling
|
||||
// path. v1 always returns FAILURE+badCertID; this test asserts that's what
|
||||
// ChromeOS sees when it polls.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeGetCertInitial, "txn-poll-1", "shared-secret-123", "poll.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
|
||||
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation (poll): got %d, want 200 (body=%q)", w.Code, body)
|
||||
}
|
||||
if fix.svc.getCertInitialEnvelope == nil {
|
||||
t.Fatal("GetCertInitialWithEnvelope was not called — dispatch missed messageType=20")
|
||||
}
|
||||
// The response should be a CertRep with pkiStatus=2 (FAILURE) +
|
||||
// failInfo=4 (badCertID).
|
||||
certRep, err := pkcs7.ParseSignedData(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSignedData: %v", err)
|
||||
}
|
||||
if len(certRep.SignerInfos) == 0 {
|
||||
t.Fatal("CertRep has no signerInfos")
|
||||
}
|
||||
si := certRep.SignerInfos[0]
|
||||
statusRV, ok := si.AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]
|
||||
if !ok {
|
||||
t.Fatal("CertRep missing pkiStatus auth-attr")
|
||||
}
|
||||
statusStr := decodeFirstSetMember(t, statusRV)
|
||||
if statusStr != string(domain.SCEPStatusFailure) {
|
||||
t.Errorf("pkiStatus = %q, want %q (FAILURE)", statusStr, domain.SCEPStatusFailure)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_BadPOPO builds a PKIMessage with the
|
||||
// signerInfo signature corrupted; expects the handler to fall through to
|
||||
// the MVP path (the RFC 8894 verifier rejects the message, and the MVP
|
||||
// path also rejects it because the encrypted EnvelopedData isn't a raw
|
||||
// CSR). Result: HTTP 400 with a clear error message.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_BadPOPO(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-bad-popo", "shared-secret-123", "bad.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||
// Tamper with the LAST byte of the message (which lands inside the
|
||||
// signature OCTET STRING for a non-trivial chance of corrupting the
|
||||
// signature without breaking the outer DER framing).
|
||||
pkiMessage[len(pkiMessage)-1] ^= 0xff
|
||||
|
||||
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
||||
t.Errorf("POST PKIOperation (bad POPO): got %d, want 400 (MVP fall-through rejection) or 200 (CertRep+failInfo)", w.Code)
|
||||
}
|
||||
if fix.svc.pkcsReqEnvelope != nil {
|
||||
t.Errorf("PKCSReqWithEnvelope was called despite invalid signerInfo signature — POPO check failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_ChromeOSPKIMessage_AESVariants exercises AES-128, 192,
|
||||
// and 256-CBC. ChromeOS picks based on the GetCACaps response; verify
|
||||
// all three round-trip correctly.
|
||||
func TestSCEPHandler_ChromeOSPKIMessage_AESVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
oid asn1.ObjectIdentifier
|
||||
}{
|
||||
{"AES-128-CBC", pkcs7.OIDAES128CBC},
|
||||
{"AES-192-CBC", pkcs7.OIDAES192CBC},
|
||||
{"AES-256-CBC", pkcs7.OIDAES256CBC},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fix := newChromeOSStackFixture(t)
|
||||
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-aes-"+tc.name, "shared-secret-123", "aes.example.com", aesKeyForOID(tc.oid))
|
||||
pkiMessage = withContentEncryptionOID(t, pkiMessage, fix, tc.oid, aesKeyForOID(tc.oid))
|
||||
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST PKIOperation (%s): got %d, want 200 (body=%q)", tc.name, w.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw
|
||||
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for
|
||||
// backward compat with lightweight clients.
|
||||
func TestSCEPHandler_MVPCompat_StillWorks(t *testing.T) {
|
||||
// Build an MVP-shape request: a SignedData whose encapContent is a
|
||||
// raw CSR (no EnvelopedData wrapper). The legacy handler path
|
||||
// extractCSRFromPKCS7 unwraps it.
|
||||
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
csrDER := buildTestCSR(t, deviceKey, "mvp.example.com", "mvp-shared-secret")
|
||||
|
||||
// Wrap in MVP-shape PKCS#7 SignedData (encapContent = CSR DER as
|
||||
// OCTET STRING). The existing extractCSRFromPKCS7 handles this.
|
||||
mvpPKCS7 := buildMVPSignedData(t, csrDER)
|
||||
|
||||
svc := &chromeOSMockSCEPService{
|
||||
enrollResult: &domain.SCEPEnrollResult{
|
||||
CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "mvp-issued.example.com")),
|
||||
},
|
||||
}
|
||||
// Note: NO RA pair set — the handler runs MVP-only.
|
||||
handler := NewSCEPHandler(svc)
|
||||
w, body := postPKIOperation(t, handler, mvpPKCS7)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("MVP path POST: got %d, want 200 (body=%q)", w.Code, body)
|
||||
}
|
||||
// Response is the legacy certs-only PKCS#7, NOT a CertRep PKIMessage.
|
||||
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
func postPKIOperation(t *testing.T, h SCEPHandler, body []byte) (*httptest.ResponseRecorder, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSCEP(w, req)
|
||||
respBody, _ := io.ReadAll(w.Body)
|
||||
return w, respBody
|
||||
}
|
||||
|
||||
// buildChromeOSStylePKIMessage builds a real SCEP PKIMessage targeting the
|
||||
// fixture's RA cert. Mirrors what ChromeOS / micromdm-style clients emit:
|
||||
// SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping an
|
||||
// EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)).
|
||||
func buildChromeOSStylePKIMessage(t *testing.T, fix *chromeOSStackFixture, messageType domain.SCEPMessageType, transactionID, challengePassword, csrCN string, symKey []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
// 1. Build the inner CSR carrying the challengePassword attribute.
|
||||
csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword)
|
||||
|
||||
// 2. Encrypt the CSR via AES-CBC under symKey + random IV.
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
t.Fatalf("rand iv: %v", err)
|
||||
}
|
||||
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||
|
||||
// 3. RSA-encrypt the symKey to fix.raCert.PublicKey.
|
||||
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa encrypt symKey: %v", err)
|
||||
}
|
||||
|
||||
// 4. Build EnvelopedData wrapping ciphertext.
|
||||
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey)))
|
||||
|
||||
// 5. Build the SignedData carrying the EnvelopedData with a
|
||||
// signerInfo signed by the device's transient cert/key.
|
||||
signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, messageType, transactionID, []byte("0123456789abcdef"), envelopedData)
|
||||
return signedData
|
||||
}
|
||||
|
||||
// withContentEncryptionOID rewrites the AES OID inside an already-built
|
||||
// PKIMessage by re-building from scratch with the new OID. Simpler than
|
||||
// surgically patching the bytes.
|
||||
func withContentEncryptionOID(t *testing.T, _ []byte, fix *chromeOSStackFixture, oid asn1.ObjectIdentifier, symKey []byte) []byte {
|
||||
t.Helper()
|
||||
csrDER := buildTestCSR(t, fix.deviceKey, "aes.example.com", "shared-secret-123")
|
||||
iv := make([]byte, 16)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
t.Fatalf("rand iv: %v", err)
|
||||
}
|
||||
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa encrypt: %v", err)
|
||||
}
|
||||
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oid)
|
||||
return buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-aes", []byte("0123456789abcdef"), envelopedData)
|
||||
}
|
||||
|
||||
func aesCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("aes.NewCipher: %v", err)
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
padLen := bs - len(plaintext)%bs
|
||||
padded := append([]byte{}, plaintext...)
|
||||
for i := 0; i < padLen; i++ {
|
||||
padded = append(padded, byte(padLen))
|
||||
}
|
||||
enc := cipher.NewCBCEncrypter(block, iv)
|
||||
out := make([]byte, len(padded))
|
||||
enc.CryptBlocks(out, padded)
|
||||
return out
|
||||
}
|
||||
|
||||
// oidForAESKeyLen maps an AES key length to its CBC OID. Helper for the
|
||||
// AES-variants table-driven test.
|
||||
func oidForAESKeyLen(t *testing.T, n int) asn1.ObjectIdentifier {
|
||||
t.Helper()
|
||||
switch n {
|
||||
case 16:
|
||||
return pkcs7.OIDAES128CBC
|
||||
case 24:
|
||||
return pkcs7.OIDAES192CBC
|
||||
case 32:
|
||||
return pkcs7.OIDAES256CBC
|
||||
}
|
||||
t.Fatalf("oidForAESKeyLen: unsupported key length %d", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// aesKeyForOID returns a deterministic-length symmetric key matching the
|
||||
// AES variant identified by oid. Test-only — production uses crypto/rand.
|
||||
func aesKeyForOID(oid asn1.ObjectIdentifier) []byte {
|
||||
switch {
|
||||
case oid.Equal(pkcs7.OIDAES128CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 16)
|
||||
case oid.Equal(pkcs7.OIDAES192CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 24)
|
||||
case oid.Equal(pkcs7.OIDAES256CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 32)
|
||||
case oid.Equal(pkcs7.OIDDESEDE3CBC):
|
||||
return bytes.Repeat([]byte{0x42}, 24)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTestCSR creates a CSR with a challengePassword attribute. Used by
|
||||
// the buildChromeOSStylePKIMessage helper to populate the EnvelopedData
|
||||
// inner content.
|
||||
func buildTestCSR(t *testing.T, key *rsa.PrivateKey, commonName, challengePassword string) []byte {
|
||||
t.Helper()
|
||||
// Build the challengePassword attribute (RFC 2985 §5.4.1, OID
|
||||
// 1.2.840.113549.1.9.7).
|
||||
cpAttr := pkix.AttributeTypeAndValue{
|
||||
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||
Value: challengePassword,
|
||||
}
|
||||
cpAttrSet, err := asn1.Marshal(cpAttr)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cp attr: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
// Inject the challengePassword as a raw extra extension via the
|
||||
// CSR Attributes field.
|
||||
ExtraExtensions: []pkix.Extension{},
|
||||
Attributes: []pkix.AttributeTypeAndValueSET{
|
||||
{
|
||||
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||
Value: [][]pkix.AttributeTypeAndValue{
|
||||
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = cpAttrSet
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
// buildEnvelopedDataForTest builds an EnvelopedData targeting raCert with
|
||||
// a single KTRI carrying the encrypted symmetric key + the AES-CBC
|
||||
// ciphertext. Mirrors the Phase 3 buildEnvelopedDataAES256 internal helper
|
||||
// but exposed at test scope.
|
||||
func buildEnvelopedDataForTest(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte, contentEncOID asn1.ObjectIdentifier) []byte {
|
||||
t.Helper()
|
||||
// IssuerAndSerial of the recipient.
|
||||
serialDER, err := asn1.Marshal(raCert.SerialNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal serial: %v", err)
|
||||
}
|
||||
risBody := append([]byte{}, raCert.RawIssuer...)
|
||||
risBody = append(risBody, serialDER...)
|
||||
risBytes := pkcs7.ASN1Wrap(0x30, risBody)
|
||||
|
||||
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
||||
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal keyEncAlg: %v", err)
|
||||
}
|
||||
encryptedKeyBytes := pkcs7.ASN1Wrap(0x04, encryptedKey)
|
||||
|
||||
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||
ktriBody = append(ktriBody, risBytes...)
|
||||
ktriBody = append(ktriBody, keyEncAlgBytes...)
|
||||
ktriBody = append(ktriBody, encryptedKeyBytes...)
|
||||
ktriBytes := pkcs7.ASN1Wrap(0x30, ktriBody)
|
||||
|
||||
recipientInfosBytes := pkcs7.ASN1Wrap(0x31, ktriBytes)
|
||||
|
||||
ivOctet := pkcs7.ASN1Wrap(0x04, iv)
|
||||
contentAlg := pkix.AlgorithmIdentifier{
|
||||
Algorithm: contentEncOID,
|
||||
Parameters: asn1.RawValue{FullBytes: ivOctet},
|
||||
}
|
||||
contentAlgBytes, err := asn1.Marshal(contentAlg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal contentAlg: %v", err)
|
||||
}
|
||||
|
||||
encContentField := pkcs7.ASN1Wrap(0x80, ciphertext)
|
||||
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
eciBody := append([]byte{}, oidDataBytes...)
|
||||
eciBody = append(eciBody, contentAlgBytes...)
|
||||
eciBody = append(eciBody, encContentField...)
|
||||
eciBytes := pkcs7.ASN1Wrap(0x30, eciBody)
|
||||
|
||||
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||
envBody = append(envBody, recipientInfosBytes...)
|
||||
envBody = append(envBody, eciBytes...)
|
||||
return pkcs7.ASN1Wrap(0x30, envBody)
|
||||
}
|
||||
|
||||
// buildSignedDataForTest builds a CMS SignedData with the device cert as
|
||||
// the signer + auth-attrs carrying SCEP messageType / transactionID /
|
||||
// senderNonce + messageDigest of the encapContent.
|
||||
func buildSignedDataForTest(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte {
|
||||
t.Helper()
|
||||
contentDigest := sha256.Sum256(encapContent)
|
||||
|
||||
// Auth-attrs SET-OF body.
|
||||
var attrSetBody []byte
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDContentType, pkcs7.ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDMessageDigest, pkcs7.ASN1Wrap(0x04, contentDigest[:]))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPMessageType, pkcs7.ASN1Wrap(0x13, []byte(intToASCII(int(messageType)))))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPTransactionID, pkcs7.ASN1Wrap(0x13, []byte(transactionID)))...)
|
||||
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPSenderNonce, pkcs7.ASN1Wrap(0x04, senderNonce))...)
|
||||
|
||||
// Sign over SET OF Attribute (RFC 5652 §5.4 quirk).
|
||||
signedAttrsForSig := pkcs7.ASN1Wrap(0x31, attrSetBody)
|
||||
digest := sha256.Sum256(signedAttrsForSig)
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, signerKey, 5, digest[:]) // 5 = crypto.SHA256
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
|
||||
// SignerInfo SEQUENCE.
|
||||
versionBytes := []byte{0x02, 0x01, 0x01}
|
||||
serialDER, _ := asn1.Marshal(signerCert.SerialNumber)
|
||||
sidBody := append([]byte{}, signerCert.RawIssuer...)
|
||||
sidBody = append(sidBody, serialDER...)
|
||||
sidBytes := pkcs7.ASN1Wrap(0x30, sidBody)
|
||||
|
||||
digestAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDSHA256, Parameters: asn1.NullRawValue}
|
||||
digestAlgBytes, _ := asn1.Marshal(digestAlg)
|
||||
|
||||
signedAttrsImplicit := pkcs7.ASN1Wrap(0xa0, attrSetBody)
|
||||
|
||||
sigAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAWithSHA256, Parameters: asn1.NullRawValue}
|
||||
sigAlgBytes, _ := asn1.Marshal(sigAlg)
|
||||
|
||||
sigOctet := pkcs7.ASN1Wrap(0x04, sig)
|
||||
|
||||
siBody := append([]byte{}, versionBytes...)
|
||||
siBody = append(siBody, sidBytes...)
|
||||
siBody = append(siBody, digestAlgBytes...)
|
||||
siBody = append(siBody, signedAttrsImplicit...)
|
||||
siBody = append(siBody, sigAlgBytes...)
|
||||
siBody = append(siBody, sigOctet...)
|
||||
siBytes := pkcs7.ASN1Wrap(0x30, siBody)
|
||||
|
||||
// encapContentInfo
|
||||
octetWrap := pkcs7.ASN1Wrap(0x04, encapContent)
|
||||
explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap)
|
||||
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
encapBody := append([]byte{}, oidDataBytes...)
|
||||
encapBody = append(encapBody, explicitWrap...)
|
||||
encapBytes := pkcs7.ASN1Wrap(0x30, encapBody)
|
||||
|
||||
// certificates [0] IMPLICIT SET OF Certificate
|
||||
certsBytes := pkcs7.ASN1Wrap(0xa0, signerCert.Raw)
|
||||
|
||||
// digestAlgorithms SET OF
|
||||
digestAlgsBytes := pkcs7.ASN1Wrap(0x31, digestAlgBytes)
|
||||
// signerInfos SET OF
|
||||
signerInfosBytes := pkcs7.ASN1Wrap(0x31, siBytes)
|
||||
|
||||
// SignedData SEQUENCE
|
||||
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...)
|
||||
sdBody = append(sdBody, digestAlgsBytes...)
|
||||
sdBody = append(sdBody, encapBytes...)
|
||||
sdBody = append(sdBody, certsBytes...)
|
||||
sdBody = append(sdBody, signerInfosBytes...)
|
||||
sdSeq := pkcs7.ASN1Wrap(0x30, sdBody)
|
||||
|
||||
// ContentInfo wrap
|
||||
contentField := pkcs7.ASN1Wrap(0xa0, sdSeq)
|
||||
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||
ciBody := append([]byte{}, oidSignedData...)
|
||||
ciBody = append(ciBody, contentField...)
|
||||
return pkcs7.ASN1Wrap(0x30, ciBody)
|
||||
}
|
||||
|
||||
// buildMVPSignedData builds a degenerate SignedData where the encapContent
|
||||
// is the raw CSR bytes — what lightweight SCEP clients send. Used by the
|
||||
// MVP-compat test to confirm the legacy parser still works.
|
||||
func buildMVPSignedData(t *testing.T, csrDER []byte) []byte {
|
||||
t.Helper()
|
||||
octetWrap := pkcs7.ASN1Wrap(0x04, csrDER)
|
||||
explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap)
|
||||
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||
encapBody := append([]byte{}, oidDataBytes...)
|
||||
encapBody = append(encapBody, explicitWrap...)
|
||||
encapBytes := pkcs7.ASN1Wrap(0x30, encapBody)
|
||||
|
||||
digestAlgsBytes := pkcs7.ASN1Wrap(0x31, nil)
|
||||
signerInfosBytes := pkcs7.ASN1Wrap(0x31, nil)
|
||||
|
||||
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...)
|
||||
sdBody = append(sdBody, digestAlgsBytes...)
|
||||
sdBody = append(sdBody, encapBytes...)
|
||||
sdBody = append(sdBody, signerInfosBytes...)
|
||||
sdSeq := pkcs7.ASN1Wrap(0x30, sdBody)
|
||||
|
||||
contentField := pkcs7.ASN1Wrap(0xa0, sdSeq)
|
||||
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||
ciBody := append([]byte{}, oidSignedData...)
|
||||
ciBody = append(ciBody, contentField...)
|
||||
return pkcs7.ASN1Wrap(0x30, ciBody)
|
||||
}
|
||||
|
||||
func attrSeqHelper(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||
t.Helper()
|
||||
oidBytes, err := asn1.Marshal(oid)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal OID %v: %v", oid, err)
|
||||
}
|
||||
setOfValue := pkcs7.ASN1Wrap(0x31, value)
|
||||
body := append([]byte{}, oidBytes...)
|
||||
body = append(body, setOfValue...)
|
||||
return pkcs7.ASN1Wrap(0x30, body)
|
||||
}
|
||||
|
||||
func decodeFirstSetMember(t *testing.T, rv asn1.RawValue) string {
|
||||
t.Helper()
|
||||
var inner asn1.RawValue
|
||||
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||
t.Fatalf("unmarshal SET first member: %v", err)
|
||||
}
|
||||
return string(inner.Bytes)
|
||||
}
|
||||
|
||||
func intToASCII(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for i > 0 {
|
||||
b = append([]byte{byte('0' + i%10)}, b...)
|
||||
i /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func selfSignedRSACert(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate {
|
||||
t.Helper()
|
||||
der := selfSignedRSACertRaw(t, key, cn)
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
func selfSignedRSACertRaw(t *testing.T, key *rsa.PrivateKey, cn string) []byte {
|
||||
t.Helper()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
Issuer: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-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)
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
func pemEncodeCert(der []byte) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
|
||||
// silence unused-import warnings — these packages are referenced inside
|
||||
// helpers above; Go's import-pruning is conservative around test-only
|
||||
// uses through other test files.
|
||||
var (
|
||||
_ = ecdsa.PublicKey{}
|
||||
_ = elliptic.P256
|
||||
_ = des.NewTripleDESCipher
|
||||
)
|
||||
@@ -59,6 +59,22 @@ func (m *mockSCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string
|
||||
}
|
||||
}
|
||||
|
||||
// RenewalReqWithEnvelope + GetCertInitialWithEnvelope added in Phase 4 to
|
||||
// satisfy the extended SCEPService interface. Same MVP-only test fixture
|
||||
// rules apply — these stubs mirror PKCSReqWithEnvelope's shape.
|
||||
func (m *mockSCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return m.PKCSReqWithEnvelope(ctx, csrPEM, challengePassword, envelope)
|
||||
}
|
||||
|
||||
func (m *mockSCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{
|
||||
Status: domain.SCEPStatusFailure,
|
||||
FailInfo: domain.SCEPFailBadCertID,
|
||||
TransactionID: envelope.TransactionID,
|
||||
RecipientNonce: envelope.SenderNonce,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
||||
svc := &mockSCEPService{}
|
||||
h := NewSCEPHandler(svc)
|
||||
|
||||
@@ -43,14 +43,23 @@ func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*do
|
||||
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
|
||||
// PKCSReqWithEnvelope / RenewalReqWithEnvelope / GetCertInitialWithEnvelope
|
||||
// were added to the SCEPService interface in SCEP RFC 8894 + Intune master
|
||||
// bundle Phase 2.4 + Phase 4. The router-level tests don't drive the
|
||||
// RFC 8894 path; these stubs satisfy 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 (s *scepProfileMockService) RenewalReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID}
|
||||
}
|
||||
|
||||
func (s *scepProfileMockService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusFailure, FailInfo: domain.SCEPFailBadCertID, TransactionID: env.TransactionID}
|
||||
}
|
||||
|
||||
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
|
||||
r := New()
|
||||
svc := &scepProfileMockService{tag: "legacy"}
|
||||
|
||||
Reference in New Issue
Block a user