Files
certctl/internal/api/handler/scep_chromeos_test.go
T
shankar0123 b33b843908 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.
2026-04-29 13:16:09 +00:00

704 lines
26 KiB
Go

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
)