mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
530593507b
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).
667 lines
26 KiB
Go
667 lines
26 KiB
Go
//go:build integration
|
|
|
|
// SCEP RFC 8894 + Intune master prompt §10.2 + §13 acceptance
|
|
// (deploy/test/ integration variant). Closed in the 2026-04-29
|
|
// audit-closure bundle (Phase I).
|
|
//
|
|
// What this test does:
|
|
//
|
|
// - Boots ON TOP OF the live docker-compose.test.yml stack (the
|
|
// standard integration-test prerequisite — see integration_test.go
|
|
// for the same precedent). The compose file mounts a deterministic
|
|
// Connector signing-cert PEM into the certctl container and sets
|
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_ENABLED=true +
|
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_CONNECTOR_CERT_PATH +
|
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE.
|
|
// - Re-derives the matching deterministic ECDSA private key on the
|
|
// test side (same sha256-seeded PRNG approach as
|
|
// internal/scep/intune/golden_helper_test.go::generateGoldenTrustAnchor)
|
|
// so the test can mint valid challenges that the running certctl
|
|
// container will accept.
|
|
// - Builds a real PKCSReq PKIMessage and POSTs it to
|
|
// /scep/e2eintune/pkiclient.exe?operation=PKIOperation over HTTPS.
|
|
// - Decodes the CertRep response and asserts pkiStatus = SUCCESS for
|
|
// a well-formed enrollment + FAILURE+badRequest for the
|
|
// rate-limited 4th attempt (cap=3 by default; 4th call exceeds).
|
|
//
|
|
// Skip conditions:
|
|
//
|
|
// - INTEGRATION env var not set (matches the convention in
|
|
// integration_test.go::TestMain).
|
|
// - The compose stack hasn't been brought up with the Intune env
|
|
// vars — the test detects this by probing
|
|
// /scep/e2eintune?operation=GetCACaps and skipping if the route
|
|
// returns 404.
|
|
//
|
|
// CI runs this in the same job that already runs integration_test.go;
|
|
// the docker-compose.test.yml addition + the fixture trust anchor PEM
|
|
// land in the same commit so a fresh `make integration-test` works
|
|
// without operator intervention.
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/asn1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// e2eintuneSeed is the deterministic seed for the integration-test
|
|
// trust anchor key. MUST stay byte-identical to the seed in
|
|
// internal/scep/intune/golden_helper_test.go::goldenFixtureSeed if you
|
|
// want one regen pass to cover both fixtures; today the strings are
|
|
// kept distinct so a future change to the unit-level seed doesn't
|
|
// silently invalidate the integration-test trust anchor (the operator
|
|
// has to consciously regenerate both).
|
|
var e2eintuneSeed = []byte("scep-intune-integration-test-fixture-seed-v1-do-not-change-without-regenerating-deploy-test-fixtures")
|
|
|
|
// e2eintunePathID is the SCEP profile name the docker-compose.test.yml
|
|
// configures for this test. Picked to be unambiguous in compose env
|
|
// vars and route grep ("e2eintune" is highly unlikely to clash with a
|
|
// real operator profile name).
|
|
const e2eintunePathID = "e2eintune"
|
|
|
|
// e2eintuneAudience MUST match
|
|
// CERTCTL_SCEP_PROFILE_E2EINTUNE_INTUNE_AUDIENCE in
|
|
// docker-compose.test.yml (or the host the test server is reachable at
|
|
// when CERTCTL_TEST_SERVER_URL is overridden).
|
|
const e2eintuneAudience = "https://localhost:8443/scep/e2eintune"
|
|
|
|
// TestSCEPIntuneEnrollment_Integration runs the full PKCSReq path
|
|
// against the live docker-compose certctl container. Asserts the
|
|
// CertRep wire shape is SUCCESS for a well-formed enrollment.
|
|
func TestSCEPIntuneEnrollment_Integration(t *testing.T) {
|
|
requireIntuneIntegrationStack(t)
|
|
|
|
now := time.Now()
|
|
connectorKey, _ := generateE2EIntuneTrustAnchor(t)
|
|
cli := newTestClient()
|
|
|
|
// 1. Mint a valid challenge signed by the deterministic Connector key.
|
|
challenge := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, "integration-nonce-001"))
|
|
|
|
// 2. Build the PKIMessage with the challenge embedded.
|
|
pkiMessage := buildE2EIntunePKIMessage(t, cli, "integration-txn-001", challenge, "device-integration-001.example.com")
|
|
|
|
// 3. POST + assert SUCCESS.
|
|
body := postE2EIntuneOp(t, cli, pkiMessage)
|
|
if got, want := decodeE2EPKIStatus(t, body), "0"; got != want {
|
|
// "0" is the SCEP SUCCESS pkiStatus per RFC 8894 §3.3.2.1.
|
|
t.Fatalf("integration enrollment: pkiStatus = %q, want %q (SUCCESS)", got, want)
|
|
}
|
|
}
|
|
|
|
// TestSCEPIntuneEnrollment_RateLimited_Integration drives 4
|
|
// PKIMessages for the same (Subject, Issuer) past the documented
|
|
// cap=3 default. The 4th MUST be rejected with FAILURE+badRequest.
|
|
func TestSCEPIntuneEnrollment_RateLimited_Integration(t *testing.T) {
|
|
requireIntuneIntegrationStack(t)
|
|
|
|
connectorKey, _ := generateE2EIntuneTrustAnchor(t)
|
|
cli := newTestClient()
|
|
now := time.Now()
|
|
|
|
// First 3 enrollments succeed (cap=3 → ≤3 in 24h).
|
|
for i := 0; i < 3; i++ {
|
|
nonce := fmt.Sprintf("integration-rate-allow-%d", i)
|
|
ch := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, nonce))
|
|
txn := fmt.Sprintf("integration-rate-txn-%d", i)
|
|
msg := buildE2EIntunePKIMessage(t, cli, txn, ch, "device-rate-001.example.com")
|
|
body := postE2EIntuneOp(t, cli, msg)
|
|
if got := decodeE2EPKIStatus(t, body); got != "0" {
|
|
t.Fatalf("integration rate-limited test: attempt %d/3 SHOULD succeed, got pkiStatus=%q", i+1, got)
|
|
}
|
|
}
|
|
|
|
// 4th attempt for the same (Subject, Issuer) MUST be rate-limited.
|
|
tripCh := signE2EIntuneChallenge(t, connectorKey, e2eIntuneClaim(now, "integration-rate-deny-4"))
|
|
tripMsg := buildE2EIntunePKIMessage(t, cli, "integration-rate-txn-deny", tripCh, "device-rate-001.example.com")
|
|
body := postE2EIntuneOp(t, cli, tripMsg)
|
|
status := decodeE2EPKIStatus(t, body)
|
|
if status != "2" {
|
|
// "2" is FAILURE per RFC 8894 §3.3.2.1.
|
|
t.Fatalf("integration rate-limited 4th attempt: pkiStatus = %q, want %q (FAILURE)", status, "2")
|
|
}
|
|
}
|
|
|
|
// requireIntuneIntegrationStack short-circuits the test when the
|
|
// integration stack hasn't been started OR hasn't been configured
|
|
// with the e2eintune profile (the operator only enabled the legacy
|
|
// integration_test.go set, not this one). Saves a confusing failure
|
|
// chain the first time someone runs the integration suite without
|
|
// the new compose env vars.
|
|
func requireIntuneIntegrationStack(t *testing.T) {
|
|
t.Helper()
|
|
|
|
cli := newTestClient()
|
|
resp, err := cli.http.Get(serverURL + "/scep/" + e2eintunePathID + "?operation=GetCACaps")
|
|
if err != nil {
|
|
t.Skipf("integration stack not reachable at %s: %v — start docker-compose.test.yml first", serverURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
t.Skipf("/scep/%s not configured — see deploy/docker-compose.test.yml for the e2eintune profile env vars", e2eintunePathID)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Skipf("/scep/%s GetCACaps returned %d — Intune profile may not be enabled in compose env", e2eintunePathID, resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if !strings.Contains(string(body), "SCEPStandard") {
|
|
t.Skipf("/scep/%s GetCACaps body=%q does NOT advertise SCEPStandard — Intune profile may be misconfigured", e2eintunePathID, string(body))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Deterministic trust-anchor key generation. MUST match what the
|
|
// docker-compose.test.yml mounts as the Connector trust anchor PEM.
|
|
// =============================================================================
|
|
|
|
// generateE2EIntuneTrustAnchor returns a deterministic ECDSA P-256
|
|
// keypair + cert. The committed
|
|
// deploy/test/fixtures/intune_trust_anchor.pem MUST be the same cert
|
|
// (re-run with `go test -tags integration -run='^TestRegenerateE2EIntuneFixture$' -update-fixture
|
|
// ./deploy/test/...` to refresh after a seed change).
|
|
func generateE2EIntuneTrustAnchor(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate) {
|
|
t.Helper()
|
|
prng := newE2EDeterministicReader(e2eintuneSeed)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), prng)
|
|
if err != nil {
|
|
t.Fatalf("deterministic ecdsa.GenerateKey: %v", err)
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "intune-connector-integration-fixture"},
|
|
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
NotAfter: time.Date(2055, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
}
|
|
der, err := x509.CreateCertificate(prng, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("deterministic CreateCertificate: %v", err)
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
t.Fatalf("ParseCertificate: %v", err)
|
|
}
|
|
return key, cert
|
|
}
|
|
|
|
// signE2EIntuneChallenge builds a JWT-shape ES256 challenge using the
|
|
// deterministic Connector key. Mirrors
|
|
// internal/api/handler/scep_intune_e2e_test.go::signIntuneChallengeES256
|
|
// but lives in the integration_test package (no shared imports across
|
|
// internal/ and deploy/test/).
|
|
func signE2EIntuneChallenge(t *testing.T, key *ecdsa.PrivateKey, payload map[string]any) string {
|
|
t.Helper()
|
|
hdr, _ := json.Marshal(map[string]string{"alg": "ES256", "typ": "JWT"})
|
|
pl, _ := json.Marshal(payload)
|
|
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
|
base64.RawURLEncoding.EncodeToString(pl)
|
|
h := sha256.Sum256([]byte(signingInput))
|
|
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
|
|
if err != nil {
|
|
t.Fatalf("ecdsa.Sign: %v", err)
|
|
}
|
|
rb, sb := r.Bytes(), s.Bytes()
|
|
sig := make([]byte, 64)
|
|
copy(sig[32-len(rb):], rb)
|
|
copy(sig[64-len(sb):], sb)
|
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
// e2eIntuneClaim returns the v1 challenge payload shape that matches
|
|
// a CSR with CN=device-integration-001.example.com (or whatever CN the
|
|
// caller passes to buildE2EIntunePKIMessage).
|
|
func e2eIntuneClaim(now time.Time, nonce string) map[string]any {
|
|
return map[string]any{
|
|
"iss": "intune-connector-integration-fixture",
|
|
"sub": "device-guid-integration-001",
|
|
"aud": e2eintuneAudience,
|
|
"iat": now.Add(-1 * time.Minute).Unix(),
|
|
"exp": now.Add(59 * time.Minute).Unix(),
|
|
"nonce": nonce,
|
|
"device_name": "device-integration-001.example.com",
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// PKIMessage builder. Mirrors the in-tree handler test's helpers but
|
|
// stripped down for the integration test's hermetic needs (single profile,
|
|
// AES-256-CBC content encryption, fixture RA cert fetched from /scep/<pathID>?operation=GetCACert).
|
|
// =============================================================================
|
|
|
|
// buildE2EIntunePKIMessage fetches the running container's RA cert via
|
|
// GetCACert (which doubles as the cert clients encrypt the CSR's
|
|
// content-encryption key to per RFC 8894 §3.2.2), builds an
|
|
// EnvelopedData around an AES-256-CBC-encrypted CSR, then wraps the
|
|
// EnvelopedData in a SignedData with a transient signerInfo signature.
|
|
func buildE2EIntunePKIMessage(t *testing.T, cli *testClient, transactionID, challengePassword, csrCN string) []byte {
|
|
t.Helper()
|
|
|
|
// Fetch the RA cert from GetCACert.
|
|
resp, err := cli.http.Get(serverURL + "/scep/" + e2eintunePathID + "?operation=GetCACert")
|
|
if err != nil {
|
|
t.Fatalf("GetCACert: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raCertBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read GetCACert: %v", err)
|
|
}
|
|
raCert, err := parseGetCACertForE2EIntune(raCertBytes)
|
|
if err != nil {
|
|
t.Fatalf("parse RA cert: %v", err)
|
|
}
|
|
|
|
// Build a transient device key + cert (the CSR's signer + the
|
|
// signerInfo's signer; production devices often use one key for
|
|
// both).
|
|
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("device key: %v", err)
|
|
}
|
|
deviceCert := selfSignedRSACertForE2EIntune(t, deviceKey, "device-transient-integration")
|
|
|
|
csrDER := buildE2EIntuneCSR(t, deviceKey, csrCN, challengePassword)
|
|
|
|
symKey := bytes.Repeat([]byte{0x42}, 32) // AES-256
|
|
iv := make([]byte, aes.BlockSize)
|
|
if _, err := rand.Read(iv); err != nil {
|
|
t.Fatalf("rand iv: %v", err)
|
|
}
|
|
ciphertext := aesCBCEncryptForE2EIntune(t, symKey, iv, csrDER)
|
|
|
|
rsaPub, ok := raCert.PublicKey.(*rsa.PublicKey)
|
|
if !ok {
|
|
t.Fatalf("RA cert public key is %T, want *rsa.PublicKey", raCert.PublicKey)
|
|
}
|
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, symKey)
|
|
if err != nil {
|
|
t.Fatalf("rsa encrypt symKey: %v", err)
|
|
}
|
|
|
|
envelopedData := buildEnvelopedDataForE2EIntune(t, raCert, encryptedKey, iv, ciphertext)
|
|
signedData := buildSignedDataForE2EIntune(t, deviceKey, deviceCert, transactionID, envelopedData)
|
|
return signedData
|
|
}
|
|
|
|
// postE2EIntuneOp POSTs the PKIMessage to the running certctl container
|
|
// and returns the raw response body. Fails the test on non-200 because
|
|
// every RFC 8894 PKIOperation MUST return a CertRep PKIMessage even on
|
|
// failure — anything other than 200 means the handler choked.
|
|
func postE2EIntuneOp(t *testing.T, cli *testClient, pkiMessage []byte) []byte {
|
|
t.Helper()
|
|
url := serverURL + "/scep/" + e2eintunePathID + "?operation=PKIOperation"
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(pkiMessage))
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-pki-message")
|
|
resp, err := cli.http.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("post PKIOperation: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("POST PKIOperation: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep on every PKIOperation including failures", resp.StatusCode, string(body))
|
|
}
|
|
return body
|
|
}
|
|
|
|
// decodeE2EPKIStatus extracts the SCEP pkiStatus auth-attribute from
|
|
// a CertRep PKIMessage. Returns the printable-string value ("0" =
|
|
// SUCCESS, "2" = FAILURE, "3" = PENDING per RFC 8894 §3.3.2.1).
|
|
//
|
|
// This is a minimal CMS SignedData walker — we don't pull in the
|
|
// internal/pkcs7 package because deploy/test/ is intentionally a
|
|
// stand-alone package. The walker hunts for the OID
|
|
// 2.16.840.1.113733.1.9.3 (id-attribute-pkiStatus, RFC 8894 §3.3.2.1)
|
|
// and returns its first SET-member value as a string.
|
|
func decodeE2EPKIStatus(t *testing.T, certRepDER []byte) string {
|
|
t.Helper()
|
|
// pkiStatus OID is 2.16.840.1.113733.1.9.3 → DER:
|
|
// 06 0a 60 86 48 01 86 f8 45 01 09 03
|
|
// Search the certRep DER for this byte pattern; the next 2 bytes
|
|
// after the OID land in the auth-attr's SET ("31 ?? ..."), and the
|
|
// pkiStatus value is a PrintableString inside.
|
|
pkiStatusOID := []byte{0x06, 0x0a, 0x60, 0x86, 0x48, 0x01, 0x86, 0xf8, 0x45, 0x01, 0x09, 0x03}
|
|
idx := bytes.Index(certRepDER, pkiStatusOID)
|
|
if idx < 0 {
|
|
t.Fatalf("decodeE2EPKIStatus: pkiStatus OID not found in CertRep (body len=%d)", len(certRepDER))
|
|
}
|
|
// After the OID DER (12 bytes), expect SET (0x31) of length L,
|
|
// then PrintableString (0x13) of length M, then the M chars.
|
|
cursor := idx + len(pkiStatusOID)
|
|
if cursor+4 >= len(certRepDER) {
|
|
t.Fatalf("decodeE2EPKIStatus: truncated DER after pkiStatus OID")
|
|
}
|
|
if certRepDER[cursor] != 0x31 {
|
|
t.Fatalf("decodeE2EPKIStatus: expected SET tag 0x31 after OID, got 0x%02x", certRepDER[cursor])
|
|
}
|
|
// Skip SET tag + length byte.
|
|
cursor += 2
|
|
if certRepDER[cursor] != 0x13 {
|
|
t.Fatalf("decodeE2EPKIStatus: expected PrintableString tag 0x13, got 0x%02x", certRepDER[cursor])
|
|
}
|
|
strLen := int(certRepDER[cursor+1])
|
|
cursor += 2
|
|
return string(certRepDER[cursor : cursor+strLen])
|
|
}
|
|
|
|
// =============================================================================
|
|
// Deterministic PRNG. Replicates the sha256-counter pattern from
|
|
// internal/scep/intune/golden_helper_test.go::deterministicReader so
|
|
// the integration test can derive the SAME ECDSA key bytes from the
|
|
// same seed. No shared imports across the internal/ and deploy/test/
|
|
// boundaries.
|
|
// =============================================================================
|
|
|
|
type e2eDeterministicReader struct {
|
|
mu sync.Mutex
|
|
state []byte
|
|
cursor int
|
|
buf []byte
|
|
}
|
|
|
|
func newE2EDeterministicReader(seed []byte) *e2eDeterministicReader {
|
|
return &e2eDeterministicReader{state: append([]byte(nil), seed...)}
|
|
}
|
|
|
|
func (d *e2eDeterministicReader) Read(p []byte) (int, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
for n := 0; n < len(p); {
|
|
if d.cursor >= len(d.buf) {
|
|
h := sha256.Sum256(append(d.state, e2eByteCounter(len(p)+n)...))
|
|
d.buf = h[:]
|
|
d.cursor = 0
|
|
d.state = d.buf
|
|
}
|
|
c := copy(p[n:], d.buf[d.cursor:])
|
|
n += c
|
|
d.cursor += c
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func e2eByteCounter(i int) []byte {
|
|
out := make([]byte, 8)
|
|
for k := 0; k < 8; k++ {
|
|
out[k] = byte(i >> (8 * k))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// =============================================================================
|
|
// CMS / SCEP byte builders. Stripped-down equivalents of
|
|
// internal/pkcs7/{enveloped,signedinfo}.go for the integration test's
|
|
// hermetic needs. Distinct names from the in-tree helpers (no import
|
|
// crossing internal/ → deploy/test/).
|
|
// =============================================================================
|
|
|
|
func parseGetCACertForE2EIntune(body []byte) (*x509.Certificate, error) {
|
|
// Try raw DER first.
|
|
if cert, err := x509.ParseCertificate(body); err == nil {
|
|
return cert, nil
|
|
}
|
|
// Try PEM fallback.
|
|
if block, _ := pem.Decode(body); block != nil && block.Type == "CERTIFICATE" {
|
|
return x509.ParseCertificate(block.Bytes)
|
|
}
|
|
// Try PKCS#7 SignedData certs-only.
|
|
type signedData struct {
|
|
Version int
|
|
DigestAlgorithms asn1.RawValue
|
|
ContentInfo asn1.RawValue
|
|
Certificates asn1.RawValue `asn1:"optional,implicit,tag:0"`
|
|
}
|
|
var outer struct {
|
|
ContentType asn1.ObjectIdentifier
|
|
Content asn1.RawValue `asn1:"explicit,tag:0"`
|
|
}
|
|
if _, err := asn1.Unmarshal(body, &outer); err == nil {
|
|
var sd signedData
|
|
if _, err := asn1.Unmarshal(outer.Content.Bytes, &sd); err == nil {
|
|
if cert, err := x509.ParseCertificate(sd.Certificates.Bytes); err == nil {
|
|
return cert, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("could not parse GetCACert response (len=%d)", len(body))
|
|
}
|
|
|
|
func selfSignedRSACertForE2EIntune(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate {
|
|
t.Helper()
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
|
Subject: pkix.Name{CommonName: cn},
|
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("CreateCertificate: %v", err)
|
|
}
|
|
cert, _ := x509.ParseCertificate(der)
|
|
return cert
|
|
}
|
|
|
|
func buildE2EIntuneCSR(t *testing.T, key *rsa.PrivateKey, cn, challengePassword string) []byte {
|
|
t.Helper()
|
|
tmpl := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: cn},
|
|
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: %v", err)
|
|
}
|
|
return der
|
|
}
|
|
|
|
func aesCBCEncryptForE2EIntune(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
|
|
}
|
|
|
|
// asn1WrapForE2EIntune wraps body in an ASN.1 TLV with the given tag
|
|
// and a definite-length encoding. Mirrors the in-tree
|
|
// internal/pkcs7.ASN1Wrap helper but stays inside this package (no
|
|
// cross-package import).
|
|
func asn1WrapForE2EIntune(tag byte, body []byte) []byte {
|
|
var lenBytes []byte
|
|
switch {
|
|
case len(body) < 128:
|
|
lenBytes = []byte{byte(len(body))}
|
|
case len(body) < 256:
|
|
lenBytes = []byte{0x81, byte(len(body))}
|
|
case len(body) < 65536:
|
|
lenBytes = []byte{0x82, byte(len(body) >> 8), byte(len(body))}
|
|
default:
|
|
lenBytes = []byte{0x83, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
|
|
}
|
|
out := append([]byte{tag}, lenBytes...)
|
|
return append(out, body...)
|
|
}
|
|
|
|
// OIDs used in the integration-test PKIMessage builders.
|
|
var (
|
|
oidRSAEncryptionE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1}
|
|
oidAES256CBCE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42}
|
|
oidSHA256E2E = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
|
|
oidRSAWithSHA256E2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
|
|
oidContentTypeE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
|
|
oidMessageDigestE2E = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
|
|
oidSCEPMessageTypeE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2}
|
|
oidSCEPTransactionE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7}
|
|
oidSCEPSenderNonceE2E = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5}
|
|
)
|
|
|
|
func buildEnvelopedDataForE2EIntune(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte) []byte {
|
|
t.Helper()
|
|
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 := asn1WrapForE2EIntune(0x30, risBody)
|
|
|
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: oidRSAEncryptionE2E, Parameters: asn1.NullRawValue}
|
|
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
|
|
if err != nil {
|
|
t.Fatalf("marshal keyEncAlg: %v", err)
|
|
}
|
|
encryptedKeyBytes := asn1WrapForE2EIntune(0x04, encryptedKey)
|
|
|
|
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
|
ktriBody = append(ktriBody, risBytes...)
|
|
ktriBody = append(ktriBody, keyEncAlgBytes...)
|
|
ktriBody = append(ktriBody, encryptedKeyBytes...)
|
|
ktriBytes := asn1WrapForE2EIntune(0x30, ktriBody)
|
|
recipientInfosBytes := asn1WrapForE2EIntune(0x31, ktriBytes)
|
|
|
|
ivOctet := asn1WrapForE2EIntune(0x04, iv)
|
|
contentAlg := pkix.AlgorithmIdentifier{
|
|
Algorithm: oidAES256CBCE2E,
|
|
Parameters: asn1.RawValue{FullBytes: ivOctet},
|
|
}
|
|
contentAlgBytes, err := asn1.Marshal(contentAlg)
|
|
if err != nil {
|
|
t.Fatalf("marshal contentAlg: %v", err)
|
|
}
|
|
|
|
encContentField := asn1WrapForE2EIntune(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 := asn1WrapForE2EIntune(0x30, eciBody)
|
|
|
|
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
|
envBody = append(envBody, recipientInfosBytes...)
|
|
envBody = append(envBody, eciBytes...)
|
|
innerEnvBytes := asn1WrapForE2EIntune(0x30, envBody)
|
|
|
|
// Wrap in a ContentInfo: SEQ { OID envelopedData, [0] EXPLICIT inner }.
|
|
envelopedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}
|
|
contentInfoBody := append([]byte{}, envelopedDataOID...)
|
|
contentInfoBody = append(contentInfoBody, asn1WrapForE2EIntune(0xa0, innerEnvBytes)...)
|
|
return asn1WrapForE2EIntune(0x30, contentInfoBody)
|
|
}
|
|
|
|
func buildSignedDataForE2EIntune(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, transactionID string, encapContent []byte) []byte {
|
|
t.Helper()
|
|
contentDigest := sha256.Sum256(encapContent)
|
|
|
|
var attrSetBody []byte
|
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidContentTypeE2E, asn1WrapForE2EIntune(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}))...) // envelopedData
|
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidMessageDigestE2E, asn1WrapForE2EIntune(0x04, contentDigest[:]))...)
|
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPMessageTypeE2E, asn1WrapForE2EIntune(0x13, []byte("19")))...) // PKCSReq=19
|
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPTransactionE2E, asn1WrapForE2EIntune(0x13, []byte(transactionID)))...)
|
|
attrSetBody = append(attrSetBody, attrSeqHelperE2E(t, oidSCEPSenderNonceE2E, asn1WrapForE2EIntune(0x04, []byte("0123456789abcdef")))...)
|
|
|
|
signedAttrsForSig := asn1WrapForE2EIntune(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)
|
|
}
|
|
|
|
versionBytes := []byte{0x02, 0x01, 0x01}
|
|
serialDER, _ := asn1.Marshal(signerCert.SerialNumber)
|
|
sidBody := append([]byte{}, signerCert.RawIssuer...)
|
|
sidBody = append(sidBody, serialDER...)
|
|
sidBytes := asn1WrapForE2EIntune(0x30, sidBody)
|
|
|
|
digestAlg := pkix.AlgorithmIdentifier{Algorithm: oidSHA256E2E, Parameters: asn1.NullRawValue}
|
|
digestAlgBytes, _ := asn1.Marshal(digestAlg)
|
|
|
|
signedAttrsImplicit := asn1WrapForE2EIntune(0xa0, attrSetBody)
|
|
|
|
sigAlg := pkix.AlgorithmIdentifier{Algorithm: oidRSAWithSHA256E2E, Parameters: asn1.NullRawValue}
|
|
sigAlgBytes, _ := asn1.Marshal(sigAlg)
|
|
sigOctet := asn1WrapForE2EIntune(0x04, sig)
|
|
|
|
signerInfoBody := append([]byte{}, versionBytes...)
|
|
signerInfoBody = append(signerInfoBody, sidBytes...)
|
|
signerInfoBody = append(signerInfoBody, digestAlgBytes...)
|
|
signerInfoBody = append(signerInfoBody, signedAttrsImplicit...)
|
|
signerInfoBody = append(signerInfoBody, sigAlgBytes...)
|
|
signerInfoBody = append(signerInfoBody, sigOctet...)
|
|
signerInfoBytes := asn1WrapForE2EIntune(0x30, signerInfoBody)
|
|
signerInfosSet := asn1WrapForE2EIntune(0x31, signerInfoBytes)
|
|
|
|
digestAlgsSet := asn1WrapForE2EIntune(0x31, digestAlgBytes)
|
|
|
|
envelopedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x03}
|
|
innerContent := asn1WrapForE2EIntune(0xa0, encapContent)
|
|
encapContentInfo := asn1WrapForE2EIntune(0x30, append(envelopedDataOID, innerContent...))
|
|
|
|
signerCertWrapped := asn1WrapForE2EIntune(0xa0, signerCert.Raw)
|
|
|
|
sdBody := append([]byte{}, versionBytes...)
|
|
sdBody = append(sdBody, digestAlgsSet...)
|
|
sdBody = append(sdBody, encapContentInfo...)
|
|
sdBody = append(sdBody, signerCertWrapped...)
|
|
sdBody = append(sdBody, signerInfosSet...)
|
|
innerSDBytes := asn1WrapForE2EIntune(0x30, sdBody)
|
|
|
|
signedDataOID := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
|
contentInfoBody := append([]byte{}, signedDataOID...)
|
|
contentInfoBody = append(contentInfoBody, asn1WrapForE2EIntune(0xa0, innerSDBytes)...)
|
|
return asn1WrapForE2EIntune(0x30, contentInfoBody)
|
|
}
|
|
|
|
func attrSeqHelperE2E(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
|
t.Helper()
|
|
oidBytes, err := asn1.Marshal(oid)
|
|
if err != nil {
|
|
t.Fatalf("marshal oid: %v", err)
|
|
}
|
|
valueSet := asn1WrapForE2EIntune(0x31, value)
|
|
body := append(oidBytes, valueSet...)
|
|
return asn1WrapForE2EIntune(0x30, body)
|
|
}
|