mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:41:39 +00:00
444942eab8
Closes the eleven gaps identified in the pre-v2.1.0 audit of the SCEP
RFC 8894 + Intune master bundle (cowork/scep-bundle-gap-closure-prompt.md).
Constitutional rule from cowork/CLAUDE.md::Operating Rules — 'Always
take the complete path, not the easy path' — drove this closure: each
gap was a load-bearing wire that crossed multiple layers (config →
validator → service wire-up → tests → docs) and shipping the bundle
without them would have produced lying-field footguns where operator-
visible config options stored values without affecting behavior.
WHAT LANDS:
Phase A — Clock-skew tolerance (master prompt §15 hazard closure)
internal/scep/intune/challenge.go: ValidateChallenge migrated from
positional args to ValidateOptions{} struct; new ClockSkewTolerance
field with default 0 (strict). 24 call sites updated mechanically.
Asymmetric application: now+tolerance >= iat AND now-tolerance < exp.
internal/config/config.go: SCEPIntuneProfileConfig.ClockSkewTolerance
default 60s + Validate() refusal when >= ChallengeValidity.
cmd/server/main.go: SetIntuneIntegration signature extended;
per-profile env-var loader honors CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
internal/service/scep.go: intuneClockSkew field + IntuneStatsSnapshot
surfaces clock_skew_tolerance_ns. web/src/api/types.ts mirrors.
4 new tests in challenge_test.go covering accept-within-tolerance,
reject-beyond-tolerance, accept-expired-within-tolerance,
negative-treated-as-zero defensive normalization.
docs/scep-intune.md updated with the new env var + time-bounds rule.
Phase B — unknown-version-rejected golden test
internal/scep/intune/golden_helper_test.go: goldenUnknownVersionPayload
helper + signGoldenChallengeAny generic signer.
challenge_golden_test.go: TestGoldenChallenge_UnknownVersionRejected
uses an in-process ECDSA fixture (the on-disk PEM was generated with
a Go-stdlib version that produces different ecdsa.GenerateKey bytes
from the current call). TestRegenerateGoldenFixtures emits the new
unknown_version fixture file too.
Phase C — Two named Intune e2e tests
internal/api/handler/scep_intune_e2e_test.go:
TestSCEPIntuneEnrollment_RateLimited_E2E (cap=2 + 3 attempts; 3rd
returns FAILURE+badRequest with rate_limited counter ticked)
TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E (rotate
on-disk PEM + holder.Reload(); old-key challenge fails with
badMessageCheck; signature_invalid counter ticked)
intuneE2EFixture struct extended with trustHolder + trustPath fields
so tests can rotate.
Phase D — Four new ChromeOS hermetic tests (10 total now)
internal/api/handler/scep_chromeos_test.go:
_RAKeyMismatch — PKIMessage encrypted to wrong RA cert; handler
rejects without reaching service.
_3DESBackwardCompat — RFC 8894 §3.5.2 legacy fallback verified.
_RSACSR + _ECDSACSR — explicit matrix-pair pinning.
buildTestECDSACSR helper for ECDSA P-256 CSR construction;
tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES-CBC;
assertChromeOSPositiveCertRep shared assertion.
Phase E — Per-profile counter isolation test
internal/api/handler/scep_profile_counter_isolation_test.go:
TestSCEPHandler_PerProfileIntuneCountersIsolated wires two
SCEPService instances + drives distinct PKIMessages + asserts
counter isolation. Guards against a future cmd/server/main.go
refactor that shares a *intuneCounterTab across profiles.
buildPerProfileIntuneFixture parameterized helper.
Phase F — Server-boot regression tests
cmd/server/preflight_scep_intune_test.go: 3 named tests covering
disabled-backward-compat, broken-config-with-PathID, expired-cert
refusal. preflightSCEPIntuneTrustAnchor signature extended with
pathID arg so error messages carry PathID= for operator log-grep.
Phase G — docs/connectors.md
Four new subsections under §EST/SCEP Integration: multi-profile
dispatch + mTLS sibling route + Intune Connector dispatcher + SCEP
probe in network scanner. Each has a one-paragraph operator
explanation + an env-var or endpoint table.
Phase H — Coverage uplift
internal/service/scep_probe_persist_test.go: 5 unit tests on
persistProbeResult (nil-safe + nil-repo-safe + repo-error swallow +
nil-logger guard) + ListRecentSCEPProbes (empty-slice-not-nil + repo
pass-through) + describeCertAlgorithm (RSA/ECDSA/QF1008-nil-curve
defensive branch/Ed25519/DSA/empty). CI gates (service ≥70, handler
≥75) PASS at 70.9% / 79.3%.
Phase I — deploy/test integration variant
deploy/test/scep_intune_e2e_test.go (//go:build integration):
TestSCEPIntuneEnrollment_Integration + _RateLimited_Integration
against the live docker-compose certctl container. Skip-when-
stack-missing semantics so sandbox + CI both work.
deploy/docker-compose.test.yml: new e2eintune SCEP profile env
vars + bind-mount of deploy/test/fixtures/.
deploy/test/fixtures/README.md: documents the deterministic trust
anchor regeneration recipe.
VERIFICATION (sandbox):
gofmt -d — clean for all changed files
staticcheck — clean for intune + handler + config + service +
cmd/server packages
go vet — clean for the same packages
go test -short — green for intune (95.3% cov), service (70.9%),
handler (79.3%), config (94.0%), cmd/server (boot
path; my preflight tests cover the directly-
testable function), pkcs7 (80.5% informational)
DEFERRED (per closure prompt §7 out-of-scope):
- V3-Pro Conditional Access gating + Microsoft Graph integration
- Standalone certctl-scan CLI binary
- OCSP rate-limiting, OCSP stapling, delta CRLs
Spec preserved at cowork/scep-bundle-gap-closure-prompt.md;
journal at cowork/scep-rfc8894-intune/progress.md (audit-closure
section appended).
881 lines
34 KiB
Go
881 lines
34 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_ChromeOSPKIMessage_RAKeyMismatch — closure-bundle
|
|
// gap M-1 / acceptance D.1 (cowork/scep-bundle-gap-closure-prompt.md).
|
|
// Build a PKIMessage encrypted to a freshly-generated RA cert whose
|
|
// matching private key the server does NOT have. The handler MUST
|
|
// reject (RFC 8894 path can't decrypt → falls through; MVP path can't
|
|
// either because the EnvelopedData isn't a raw CSR). Assert no
|
|
// PKCSReqWithEnvelope was reached. Closes the documented threat that
|
|
// an attacker who swaps the RA cert in transit gets a polite error
|
|
// rather than information leak about the underlying issuer.
|
|
func TestSCEPHandler_ChromeOSPKIMessage_RAKeyMismatch(t *testing.T) {
|
|
fix := newChromeOSStackFixture(t)
|
|
|
|
// Build a PKIMessage targeting an UNRELATED RA cert (different key).
|
|
// The server's handler still has fix.raKey, so decryption MUST fail.
|
|
bogusRAKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("rsa.GenerateKey bogus RA: %v", err)
|
|
}
|
|
bogusRACert := selfSignedRSACert(t, bogusRAKey, "ra-bogus-not-on-server")
|
|
bogusFix := &chromeOSStackFixture{
|
|
raKey: bogusRAKey,
|
|
raCert: bogusRACert,
|
|
deviceKey: fix.deviceKey,
|
|
deviceCert: fix.deviceCert,
|
|
}
|
|
pkiMessage := buildChromeOSStylePKIMessage(t, bogusFix, domain.SCEPMessageTypePKCSReq, "txn-ra-mismatch", "shared-secret-123", "ra-mismatch.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
|
|
|
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
|
// RFC 8894 path returns FAILURE+badMessageCheck CertRep (200), MVP
|
|
// fall-through returns 400. Either is acceptable — what we MUST
|
|
// see is "the issuer never received the CSR."
|
|
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
|
t.Errorf("POST PKIOperation (RA-key mismatch): got %d, want 400 (MVP fall-through) or 200 (CertRep+failInfo)", w.Code)
|
|
}
|
|
if fix.svc.pkcsReqEnvelope != nil {
|
|
t.Error("PKCSReqWithEnvelope was reached despite the RA-cert/key mismatch — decrypt-failure leaked through to the service")
|
|
}
|
|
}
|
|
|
|
// TestSCEPHandler_ChromeOSPKIMessage_3DESBackwardCompat — closure-bundle
|
|
// gap M-1 / acceptance D.2. RFC 8894 §3.5.2 names DES-EDE3-CBC
|
|
// (1.2.840.113549.3.7) as a "supported but discouraged" content-encryption
|
|
// algorithm for backward compat with older Cisco IOS / Apple legacy
|
|
// clients. Verify the parser accepts this OID + the handler reaches
|
|
// the service with a decoded CSR.
|
|
func TestSCEPHandler_ChromeOSPKIMessage_3DESBackwardCompat(t *testing.T) {
|
|
fix := newChromeOSStackFixture(t)
|
|
tdesKey := aesKeyForOID(pkcs7.OIDDESEDE3CBC) // 24 bytes (3DES K1||K2||K3)
|
|
|
|
csrDER := buildTestCSR(t, fix.deviceKey, "tdes.example.com", "shared-secret-123")
|
|
|
|
iv := make([]byte, des.BlockSize) // 8 bytes for 3DES
|
|
if _, err := rand.Read(iv); err != nil {
|
|
t.Fatalf("rand iv: %v", err)
|
|
}
|
|
ciphertext := tripleDESCBCEncrypt(t, tdesKey, iv, csrDER)
|
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), tdesKey)
|
|
if err != nil {
|
|
t.Fatalf("rsa encrypt 3des key: %v", err)
|
|
}
|
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, pkcs7.OIDDESEDE3CBC)
|
|
pkiMessage := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-3des", []byte("0123456789abcdef"), envelopedData)
|
|
|
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST PKIOperation (3DES legacy): got %d, want 200 (RFC 8894 §3.5.2 backward-compat) — body=%q", w.Code, body)
|
|
}
|
|
if fix.svc.pkcsReqEnvelope == nil {
|
|
t.Fatal("PKCSReqWithEnvelope was NOT reached — 3DES decrypt path didn't make it to the service")
|
|
}
|
|
}
|
|
|
|
// TestSCEPHandler_ChromeOSPKIMessage_RSACSR — closure-bundle gap M-1 /
|
|
// acceptance D.4. Pins the "RSA CSR" matrix corner explicitly so a
|
|
// future helper refactor that quietly drops the RSA path doesn't
|
|
// disappear from the test count without a counter dropping. The
|
|
// shared positive-flow assertions live in
|
|
// assertChromeOSPositiveCertRep so the matrix-pair {RSA, ECDSA} stays
|
|
// readable.
|
|
func TestSCEPHandler_ChromeOSPKIMessage_RSACSR(t *testing.T) {
|
|
fix := newChromeOSStackFixture(t)
|
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-rsa-csr", "shared-secret-123", "rsa-csr.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
|
assertChromeOSPositiveCertRep(t, fix, pkiMessage)
|
|
}
|
|
|
|
// TestSCEPHandler_ChromeOSPKIMessage_ECDSACSR — closure-bundle gap M-1
|
|
// / acceptance D.3. The CSR's keypair is ECDSA P-256; the device's
|
|
// transient signerInfo identity stays RSA (matches what real ChromeOS
|
|
// + Intune-managed devices commonly emit — device identity is a
|
|
// long-lived RSA key, the new cert can be ECDSA). Verifies the
|
|
// handler doesn't choke on the inner CSR's algorithm even when the
|
|
// outer SignerInfo is RSA-SHA256.
|
|
func TestSCEPHandler_ChromeOSPKIMessage_ECDSACSR(t *testing.T) {
|
|
fix := newChromeOSStackFixture(t)
|
|
|
|
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
|
}
|
|
csrDER := buildTestECDSACSR(t, csrKey, "ecdsa-csr.example.com", "shared-secret-123")
|
|
|
|
symKey := aesKeyForOID(pkcs7.OIDAES256CBC)
|
|
iv := make([]byte, aes.BlockSize)
|
|
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 symKey: %v", err)
|
|
}
|
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, pkcs7.OIDAES256CBC)
|
|
pkiMessage := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-ecdsa-csr", []byte("0123456789abcdef"), envelopedData)
|
|
assertChromeOSPositiveCertRep(t, fix, pkiMessage)
|
|
}
|
|
|
|
// assertChromeOSPositiveCertRep is the shared positive-flow assertion
|
|
// helper for the {RSA, ECDSA} CSR matrix tests. Asserts HTTP 200 +
|
|
// content-type + the service-level mock saw the envelope.
|
|
func assertChromeOSPositiveCertRep(t *testing.T, fix *chromeOSStackFixture, pkiMessage []byte) {
|
|
t.Helper()
|
|
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 reached — handler dispatched to MVP path or rejected the message")
|
|
}
|
|
}
|
|
|
|
// buildTestECDSACSR mirrors buildTestCSR but for an ECDSA P-256
|
|
// signing key. Closure-bundle Phase D helper. The CSR carries the
|
|
// challengePassword attribute the same way the RSA helper does.
|
|
func buildTestECDSACSR(t *testing.T, key *ecdsa.PrivateKey, commonName, challengePassword string) []byte {
|
|
t.Helper()
|
|
tmpl := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: commonName},
|
|
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}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
|
if err != nil {
|
|
t.Fatalf("CreateCertificateRequest (ECDSA): %v", err)
|
|
}
|
|
return der
|
|
}
|
|
|
|
// tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES — used by the
|
|
// 3DES backward-compat test. PKCS#7 padding to 8-byte blocks.
|
|
func tripleDESCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte {
|
|
t.Helper()
|
|
block, err := des.NewTripleDESCipher(key) //nolint:gosec // RFC 8894 §3.5.2 legacy backward-compat test fixture
|
|
if err != nil {
|
|
t.Fatalf("des.NewTripleDESCipher: %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
|
|
}
|
|
|
|
// 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
|
|
)
|