9 Commits

Author SHA1 Message Date
shankar0123 75097909e9 2026-05-05 18:18:29 +00:00
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00
shankar0123 43075a1b5c EST RFC 7030 hardening master bundle Phases 5-7: end-to-end serverkeygen
+ profile-driven csrattrs + admin observability with per-status
counters + reload-trust endpoint.

Phase 5 — RFC 7030 §4.4 server-driven key generation:
- internal/pkcs7/envelopeddata_builder.go is the inverse of the
  existing parser/decryptor: AES-256-CBC content cipher + RSA PKCS#1
  v1.5 keyTrans + per-call random IV. Round-trip pinned in test
  (BuildEnvelopedData → ParseEnvelopedData → Decrypt returns the
  original plaintext byte-for-byte).
- ESTService.SimpleServerKeygen runs the full §4.4 flow: parse client
  CSR → require RSA pubkey for keyTrans → resolve per-profile
  algorithm (RSA-2048 default; honors AllowedKeyAlgorithms) → in-
  memory keygen → re-build CSR with server pubkey → run existing
  issuer pipeline → marshal PKCS#8 → CMS-EnvelopedData wrap to a
  synthetic recipient cert wrapping the device's CSR-supplied pubkey
  → zeroize plaintext + PKCS#8 bytes → return CertPEM + ChainPEM
  + EncryptedKey. Typed sentinels ErrServerKeygenRequiresKey-
  Encipherment / ErrServerKeygenUnsupportedAlgorithm /
  ErrServerKeygenDisabled.
- ESTHandler.ServerKeygen + ServerKeygenMTLS emit RFC 7030 §4.4.2
  multipart/mixed with random per-response boundary; per-profile
  SetServerKeygenEnabled gate returns 404 when off (defense in depth
  even if the route was registered).
- New routes POST /.well-known/est/[<PathID>/]serverkeygen +
  /.well-known/est-mtls/<PathID>/serverkeygen; openapi.yaml +
  openapi-parity guard updated.

Phase 6 — Real csrattrs implementation:
- New CertificateProfile.RequiredCSRAttributes []string + migration
  000022_certificate_profiles_csrattrs.up.sql. The migration also
  lands the previously-unwired must_staple column (closes the 5.6
  follow-up loop where the field shipped at the domain + service
  layer but the postgres scan/insert/update never persisted it).
- domain.EKUStringToOID + AttributeStringToOID lookup tables: id-kp-*
  EKUs (RFC 5280 §4.2.1.12) + RFC 5280 DN attributes + RFC 2985
  PKCS#10 attributes + Microsoft Intune device-serial OID.
- ESTService.GetCSRAttrs replaces the v2.0.x nil/204 stub with a
  profile-derived SEQUENCE OF OID ASN.1 marshal. Unknown EKU /
  attribute strings dropped + warning-logged so a typo doesn't take
  down the entire endpoint.

Phase 7 — Admin observability + counters + reload-trust:
- internal/service/est_counters.go: estCounterTab (sync/atomic; 12
  named labels) + ESTStatsSnapshot per-profile shape +
  ESTService.Stats(now) zero-allocation accessor + ReloadTrust()
  SIGHUP-equivalent + SetESTAdminMetadata setter.
- Counter ticks wired into processEnrollment + SimpleServerKeygen at
  every success/failure leg.
- internal/api/handler/admin_est.go mirrors AdminSCEPIntune verbatim:
  Profiles + ReloadTrust handlers + AdminESTServiceImpl. Both
  endpoints admin-gated (M-008 triplet pinned + admin_est.go added
  to AdminGatedHandlers).
- New routes GET /api/v1/admin/est/profiles + POST /api/v1/admin/
  est/reload-trust; openapi.yaml documented; openapi-parity guard
  reproduced clean.
- cmd/server/main.go grows estServices map populated by the per-
  profile EST loop + handed to AdminEST. New MTLSTrust() +
  HasMTLSTrust() accessors on ESTHandler so main.go can pull the
  trust holder for the admin-metadata wire-up.
- Per-profile counter isolation regression test
  (internal/service/est_profile_counter_isolation_test.go) proves
  a future shared-counter refactor would fail at compile-time
  pointer-identity check.

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean across
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
service/pkcs7/domain/cmd/server, go test -short -count=1 green
for every non-postgres package. G-3 docs-drift guard reproduced
locally clean (Phases 5-7 added zero new env vars; Phase 1
already documented per-profile SERVER_KEYGEN_ENABLED).

Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
8-13 (GUI ESTAdminPage / CLI+MCP / libest e2e / bulk revocation /
docs/est.md / release prep) remain — post-2.1.0 work.
2026-04-29 23:57:45 +00:00
shankar0123 7b40361bc4 lint(scep): fix CI lint failures in Phase 3 commit (b540d44)
Three lint issues from golangci-lint that didn't fire locally because I
ran 'go vet' but not 'staticcheck' before commit (the recent crypto/signer
QF1008 incident pattern repeating — must run staticcheck before
committing per CLAUDE.md::pre-commit-verification-gate; landing this
fixup, then will run staticcheck on every future SCEP-bundle commit).

internal/pkcs7/envelopeddata.go:78
  * ST1022: 'comment on exported var ErrEnvelopedDataDecrypt should be of
    the form "ErrEnvelopedDataDecrypt ..."' — staticcheck enforces the
    Go-doc convention that var/const docs start with the symbol name.
    Renamed the leading 'Sentinel decryption error.' to
    'ErrEnvelopedDataDecrypt is the sentinel decryption error.'

internal/pkcs7/certrep_test.go:246-247
  * U1000: 'func nowMinus1Hour is unused' / 'func nowPlus30Days is unused'
    — left-over helpers from a previous draft of selfSignedCertPEM that
    inlined the time math. Removed both.

Verified with  — clean. Tests still
green (handler 79.0% / service 73.2% / pkcs7 80.5%).

Restores green CI on the lint job for the Phase 3 push.
2026-04-29 12:50:46 +00:00
shankar0123 b540d4421e feat(scep): CertRep PKIMessage response builder (RFC 8894 §3.3.2)
SCEP RFC 8894 + Intune master bundle — Phase 3 of 14.

Implements the SCEP CertRep response builder + wires it into the handler's
RFC 8894 path. After this commit, certctl emits proper CertRep PKIMessage
responses (signed by the RA key, with EnvelopedData encrypting the issued
cert chain to the device's transient signing cert) for both success and
failure outcomes — RFC 8894 §3.3 mandates a PKIMessage response on every
PKIOperation request, including failure cases that carry pkiStatus=2 +
failInfo.

internal/pkcs7/certrep.go (new, ~370 LoC)
  * BuildCertRepPKIMessage: assembles the full ContentInfo → SignedData →
    {certs, signerInfo, encapContent} structure per RFC 8894 §3.3.2 +
    RFC 5652 §5+§6.
  * Success path: encrypts the issued cert chain (PKCS#7 certs-only)
    INSIDE an EnvelopedData targeting req.SignerCert (the device's
    transient cert, NOT the RA cert — response goes back to the device
    encrypted with its public key). AES-256-CBC + random 16-byte IV +
    PKCS#7 padding + RSA PKCS#1v1.5 keyTrans.
  * Failure path: encapContent is empty (no EnvelopedData); the failInfo
    auth-attr is populated.
  * Pending path: encapContent is empty; client polls via GetCertInitial.
  * Auth-attr ordering matches micromdm/scep for byte-level wire-format
    diffing (DER SET-OF normalises order anyway, but matching the
    reference implementation makes audit + manual inspection easier).
  * senderNonce is freshly generated from crypto/rand on every call.
  * RA key signs the canonical SET OF Attribute re-serialisation (RFC
    5652 §5.4 quirk every CMS implementation hits — wire form is [0]
    IMPLICIT but the signature is computed over EXPLICIT SET OF).
  * Helper functions: buildCertRepAuthAttrs, buildSignerInfoCertRep,
    signCertRep, buildEncapContentInfo, buildEnvelopedDataAES256, all
    constructed via this package's existing ASN1Wrap primitives (avoids
    asn1.Marshal nuances with nested RawValues — same pattern Phase 2
    settled on).

internal/pkcs7/signedinfo.go (1-line tweak)
  * ParseSignedData no longer refuses when SignerInfos is empty. The
    degenerate certs-only SignedData form (RFC 8894 §3.5.1 GetCACert
    response, RFC 7030 EST cacerts, AND now the encrypted certs-only
    inner content of the CertRep EnvelopedData) is structurally valid
    with zero signers. Caller decides whether the lack of signers is
    an error in their context.

internal/pkcs7/certrep_test.go (new, ~230 LoC)
  * TestBuildCertRepPKIMessage_Success_RoundTrip — full pipeline
    round-trip: build → ParseSignedData → VerifySignature → auth-attr
    extractors → ParseEnvelopedData(encapContent) → Decrypt with device
    key → ParseSignedData(innerCertsOnly) → assert issued cert CN.
    Catches drift between the build-side encoding and the parse-side
    decoding.
  * TestBuildCertRepPKIMessage_Failure_NoEncapContent — pkiStatus=2 +
    failInfo populated; encapContent empty.
  * TestBuildCertRepPKIMessage_FreshSenderNonceEachCall — pins the
    'never reuse senderNonce' invariant from RFC 8894 §3.2.1.4.5
    (replay defense).
  * TestBuildCertRepPKIMessage_RejectsNonRSADeviceCert — pins the
    RSA-only requirement on the device's transient cert (KTRI requires
    RSA pubkey for keyTrans encryption).
  * TestBuildCertRepPKIMessage_NilArgs_Refuses.

internal/pkcs7/certrep_fuzz_test.go (new, ~150 LoC)
  * FuzzBuildCertRepPKIMessage — varies transactionID + senderNonce +
    signerCert; asserts no panic. When build succeeds for the success
    path, asserts round-trip soundness (output parses back via
    ParseSignedData). 6s seed-corpus run hit no panics.

internal/api/handler/scep.go
  * pkiOperation now emits writeCertRepPKIMessage for the RFC 8894
    path (both success AND failure). MVP path keeps writeSCEPResponse
    for backward compat with lightweight clients.
  * tryParseRFC8894 extended to extract the RFC 2985 §5.4.1
    challengePassword attribute from the recovered CSR, so the
    service-layer's challenge-password gate can run on the RFC 8894
    path the same way it does on the MVP path. Returns
    (envelope, csrPEM, challengePassword, ok) — was 3-tuple before.
  * extractChallengePasswordFromCSR helper mirrors the MVP path's
    extractCSRFields logic; same staticcheck SA1019 carve-out for
    the deprecated csr.Attributes API (RFC 2985 challengePassword
    has no non-deprecated stdlib API per the M-028 audit closure).
  * writeCertRepPKIMessage helper wraps pkcs7.BuildCertRepPKIMessage;
    on build failure (programmer/config bug) returns HTTP 500 rather
    than try a fallback PKIMessage that might re-trigger the same bug.

Verification:
  * gofmt + go vet clean across pkcs7 / api/handler.
  * go test -short -count=1 green across pkcs7 / api/handler /
    api/router / service / cmd/server.
  * Coverage: pkcs7 80.5% (was 78.4% before Phase 3). Handler/service
    held steady.
  * Fuzz seed-corpus (6s): FuzzBuildCertRepPKIMessage — no panic;
    round-trip soundness invariant held for every successful build.

Phase 3 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 12:46:30 +00:00
shankar0123 a546a1bbef feat(scep): EnvelopedData decrypt + signerInfo POPO verify (RFC 8894 §3.2)
SCEP RFC 8894 + Intune master bundle — Phase 2 of 14.

Implements the new RFC 8894 PKIMessage parse path: EnvelopedData parser
+ decryptor, signerInfo parser + signature verifier, handler dispatch
that tries the RFC 8894 path FIRST and falls through to the legacy MVP
raw-CSR path on any parse failure. Backward compat with lightweight SCEP
clients is preserved by design — no behavior change for any existing
deploy that doesn't set CERTCTL_SCEP_RA_*.

internal/pkcs7/envelopeddata.go (new, ~330 LoC)
  * ParseEnvelopedData: parses CMS EnvelopedData per RFC 5652 §6.1, with
    optional outer ContentInfo unwrapping. Handles SET OF RecipientInfo
    + IssuerAndSerial form rid (RFC 8894 §3.2.2).
  * EnvelopedData.Decrypt: RSA PKCS#1 v1.5 key-trans + AES-CBC (128/192/
    256) or DES-EDE3-CBC content decryption with **constant-time PKCS#7
    padding strip** (no branch on padding-byte values; closes the
    padding-oracle leak surface). Recipient mismatch is BadMessageCheck
    per RFC 8894 §3.3.2.2 (NOT BadCertID); every failure mode returns
    the same ErrEnvelopedDataDecrypt sentinel to close timing-leak legs
    of Bleichenbacher attacks.
  * Equivalent to micromdm/scep's cryptoutil/cryptoutil.go::DecryptPKCS-
    Envelope (cited in code comments; not vendored — fuzz-target
    ownership stays in this sub-package per the operating rule).

internal/pkcs7/signedinfo.go (new, ~370 LoC)
  * ParseSignedData / ParseSignerInfos: parses CMS SignedData per RFC
    5652 §5.3. Resolves each SignerInfo's SID (IssuerAndSerial v1 OR
    [0] SubjectKeyId v3) against the SignedData certificates SET to
    pluck the device's transient signing cert.
  * SignerInfo.VerifySignature: re-serialises signedAttrs as the
    canonical SET OF Attribute (the RFC 5652 §5.4 quirk every CMS
    implementation hits — wire form is [0] IMPLICIT but the signature
    is over EXPLICIT SET OF). Hashes with SHA-1/SHA-256/SHA-512 +
    verifies via RSA PKCS1v15 or ECDSA per the cert's pubkey type.
  * Auth-attr extractors: GetMessageType (PrintableString-decimal),
    GetTransactionID, GetSenderNonce, GetMessageDigest. SCEP attr OIDs
    pinned (RFC 8894 §3.2.1.4).

internal/pkcs7/{envelopeddata,signedinfo}_fuzz_test.go (new)
  * FuzzParseEnvelopedData / FuzzParseSignedData / FuzzParseSignerInfos
    / FuzzVerifySignerInfoSignature — every parser certctl adds gets a
    panic-safety fuzzer (the fuzz-target-ownership rule from
    cowork/CLAUDE.md::Operating Rules). Local 5s runs hit ~270k
    executions per parser without panic. Errors are expected for
    arbitrary inputs; only panics are bugs.

internal/pkcs7/{envelopeddata,signedinfo}_test.go (new)
  * Round-trip tests that materialise real RSA/ECDSA pairs, hand-build
    the wire bytes, parse + decrypt + verify, and assert plaintext /
    auth-attr equality. The build helpers use this package's ASN1Wrap
    primitives directly (asn1.Marshal of structs containing nested
    asn1.RawValue is finicky for mixed Class/Tag); gives byte-level
    control matching what real SCEP clients emit.
  * Negative tests: tampered ciphertext / tampered auth-attrs / wrong
    RA / wrong key / mismatched recipients / random garbage all return
    the appropriate sentinel error without panic.

internal/service/scep.go
  * PKCSReqWithEnvelope: RFC 8894 envelope-aware variant. Returns
    *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because RFC
    8894 §3.3 mandates a CertRep PKIMessage on every response, even
    failures — the handler shouldn't translate Go errors into SCEP
    failInfo codes. Returns nil to signal 'invalid challenge password'
    so the caller can translate to HTTP 403 (matches MVP path's wire
    shape; RFC 8894 §3.3.1 is silent on this case).
  * mapServiceErrorToFailInfo: exact mapping table from the prompt
    (CSR parse → BadRequest, CSR sig → BadMessageCheck, crypto policy
    → BadAlg, default → BadRequest).

internal/api/handler/scep.go
  * SCEPService interface gains PKCSReqWithEnvelope.
  * SCEPHandler now optionally carries an RA cert + key pair. SetRAPair
    upgrades the handler to the RFC 8894 path; without that call the
    handler stays MVP-only (the v2.0.x behavior).
  * pkiOperation: tries the RFC 8894 path FIRST when the RA pair is
    set. tryParseRFC8894 helper does the full pipeline (ParseSignedData
    → VerifySignature → extract auth-attrs → ParseEnvelopedData → Decrypt
    → x509.ParseCertificateRequest the recovered bytes). On any failure
    it falls through to the legacy extractCSRFromPKCS7 MVP path —
    backward compat is non-negotiable.
  * Phase 2 emits the legacy certs-only response on RFC 8894 success;
    Phase 3 (next commit) swaps in writeCertRepPKIMessage with the
    proper status / failInfo / nonce-echo wire shape.

cmd/server/main.go
  * Per-profile loop now calls loadSCEPRAPair after preflight to load
    the cert + key + inject via SetRAPair. crypto + crypto/tls imports
    added.
  * loadSCEPRAPair helper: tls.X509KeyPair-based parse + leaf cert
    extraction. Failures here indicate TOCTOU between preflight + load.

internal/api/handler/scep_handler_test.go +
internal/api/router/router_scep_profiles_test.go
  * mockSCEPService / scepProfileMockService gain PKCSReqWithEnvelope
    stubs to satisfy the extended interface. Existing test cases
    unchanged (they exercise the MVP path; RA pair is unset).

Verification:
  * gofmt + go vet clean for the files I touched.
  * go test -short -count=1 green across pkcs7 / api/handler /
    api/router / service / cmd/server.
  * Coverage: pkcs7 78.4% (was 100% — drops because new code includes
    paths the round-trip tests don't yet hit, like decryption alg
    fall-through and v3 SubjectKeyId SID matching).
  * Fuzz-target seed-corpus runs (5s each, ~270k execs/parser): no
    panic. Pre-merge fuzz-time bumps to 30s per the prompt's
    verification gate.

Phase 2 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 12:36:27 +00:00
shankar0123 95d0d85391 Bundle Q (Coverage Audit Closure): property-based pilot + hygiene — L-001/L-002/L-003/L-004/I-001 closed
Five small closures wrapping the Low-tier and Info-tier audit findings.

Q.1 — cmd/cli round-out (L-001 closed)
======================================
cmd/cli/dispatch_test.go: ~30 dispatch tests across handleCerts /
handleAgents / handleJobs / handleImport / handleStatus. httptest.NewTLSServer
mocks the API; cli.NewClient(_, _, _, _, true) constructs an
insecure-skip-verify client. Each test pins the missing-args usage-print
path AND the happy-path delegation. Result: 7.1% -> 63.5% coverage
(gate: >=30%).

Q.2 — awssm round-out (L-002 closed)
======================================
internal/connector/discovery/awssm/awssm_edge_test.go: New() default
constructor, extractKeyInfo (ECDSA/Ed25519/unknown — was RSA-only),
processSecret filter arms (NamePrefix mismatch / TagFilter mismatch /
empty-value / GetSecretValue error), realSMClient stub-contract pin
(ListSecrets / GetSecretValue / NewRealSMClient), and EmailAddresses
SAN extraction. Result: 78.2% -> 96.0% coverage (gate: >=85%).

Q.3 — Property-based testing pilot (L-003 closed)
======================================
gopter@v0.2.11 added to go.mod (test-only).

internal/crypto/encryption_property_test.go:
- TestProperty_EncryptDecryptRoundTrip — 50 successful tests,
  DecryptIfKeySet(EncryptIfKeySet(x, k), k) == x
- TestProperty_WrongPassphraseRejected — 30 successful tests,
  AEAD never returns nil-error AND bytes-equal plaintext under
  wrong passphrase
Both skipped under -short to keep developer loop fast (PBKDF2 600k
rounds × 50 iters ≈ 15s on -race CI).

internal/pkcs7/length_property_test.go:
- TestProperty_ASN1LengthRoundTrip — three sub-properties:
  decodeLength(encode(x)) == x for x ∈ [0, 2³¹−1]; short-form
  invariant (length<128 → 1 byte == length); long-form invariant
  (length>=128 → high bit set + N bytes follow). 500 successful
  tests in <10ms.

Q.4 — Architecture diagram multi-agent update (L-004 closed)
======================================
docs/qa-test-guide.md::Architecture: ASCII diagram updated to show
'certctl-agent (×N)' + callout explaining seed_demo.sql provisions
12 agent rows (1 active, 2 retired, 9 reserved/sentinel) for Parts
04, 05, 55 + FSM coverage. Operators running parallel-agent topologies
guided to AGENT_COUNT=N + 'make qa-stats'.

Q.5 — Test-naming CI guard (I-001 closed)
======================================
.github/workflows/ci.yml: Test-naming convention guard added after
the QA-doc seed-count drift guard. Greps for func Test<X>( missing
the <X>_<Scenario> suffix. Prints first 20 non-conformant as
::warning:: annotations. continue-on-error: true (informational).
Excludes TestMain + TestProperty_*. Promotion to hard-fail tracked
as I-001-extended.

Verification
======================================
- python3 yaml.safe_load on ci.yml: OK
- go vet ./cmd/cli/... ./internal/connector/discovery/awssm/...
  ./internal/crypto/... ./internal/pkcs7/...: clean
- go test -short -count=1 across all four packages: PASS
- go test -count=1 (full property tests): PASS
  - crypto 15.4s (50 + 30 × 600k PBKDF2)
  - pkcs7 5ms

Audit deliverables
======================================
- gap-backlog.md: strikethroughs on L-001/L-002/L-003/L-004/I-001
  with per-finding closure note
- closure-plan.md: ticks Bundle Q [x] with per-item breakdown

Closes: L-001, L-002, L-003, L-004, I-001
Bundle: Q (Property-Based + Hygiene)
2026-04-27 18:36:47 +00:00
shankar0123 1c099071d1 fix(bundle-4): EST/SCEP Attack Surface Hardening — 3 audit findings closed
Closes 3 findings (1 High + 1 Medium + 1 Low) from
/Users/shankar/Desktop/cowork/comprehensive-audit-2026-04-25/.

Bundle 4 hardens the only attack surface reachable by an anonymous network
attacker in certctl: the unauthenticated EST + SCEP enrollment endpoints.

Findings closed:

  - H-004 (High): Hand-rolled ASN.1 parser had no fuzz target.
    The audit's original framing pointed at internal/pkcs7/, but recon
    confirmed that package is an ASN.1 ENCODER (BuildCertsOnlyPKCS7,
    ASN1Wrap*, ASN1EncodeLength) — not a parser. The actual hand-rolled
    PKCS#7 PARSING reachable via anonymous network is in
    internal/api/handler/scep.go::extractCSRFromPKCS7 +
    parseSignedDataForCSR. Added native go fuzz targets:
      * internal/api/handler/scep_fuzz_test.go::FuzzExtractCSRFromPKCS7
      * internal/api/handler/scep_fuzz_test.go::FuzzParseSignedDataForCSR
      * internal/pkcs7/pkcs7_fuzz_test.go::FuzzPEMToDERChain (defense-in-depth)
      * internal/pkcs7/pkcs7_fuzz_test.go::FuzzASN1EncodeLength (defense-in-depth)
    Local 15s fuzz session: 150k execs on FuzzExtractCSRFromPKCS7,
    937k on FuzzPEMToDERChain, 925k on FuzzASN1EncodeLength — zero panics.

  - M-021 (Medium): EST TLS-Unique channel binding (RFC 7030 §3.2.3).
    Added internal/api/handler/est.go::verifyESTTransport — defense-in-depth
    TLS pre-conditions (r.TLS != nil; HandshakeComplete; TLS ≥ 1.2).
    The full §3.2.3 channel binding only applies when EST mTLS is in use;
    certctl does not currently support EST mTLS, so the §3.2.3 requirement
    is moot today. RFC 9266 (TLS 1.3 tls-exporter) and EST mTLS are
    documented as deferred follow-ups in the verifyESTTransport doc comment.

  - L-005 (Low): EST/SCEP issuer-binding fail-loud at startup.
    Pre-Bundle-4 cmd/server/main.go validated that CERTCTL_EST_ISSUER_ID and
    CERTCTL_SCEP_ISSUER_ID existed in the registry but did NOT validate the
    issuer TYPE could emit a CA cert. An operator binding EST to an ACME
    issuer (whose GetCACertPEM returns explicit error) booted successfully
    and only failed at first /est/cacerts request. Post-Bundle-4: new
    preflightEnrollmentIssuer helper calls GetCACertPEM(ctx) at startup
    with a 10s timeout. Failure logs the connector error + the candidate
    issuer types and os.Exit(1).

Tests added/modified:
  - internal/api/handler/est_transport_test.go (new) — 5 verifyESTTransport
    table cases covering plaintext-rejected, incomplete-handshake-rejected,
    TLS 1.0 rejected, TLS 1.2/1.3 accepted
  - cmd/server/preflight_test.go (new) — TestPreflightEnrollmentIssuer
    covering nil-connector, error-from-issuer, empty-PEM, valid cases
  - internal/api/handler/est_handler_test.go (modified) — 7 POST sites
    now stamp r.TLS to satisfy the new transport pre-condition
  - internal/integration/negative_test.go (modified) — setupTestServer
    wraps the test handler with a fake-TLS-state injector so the EST
    handler receives r.TLS != nil; production paths still rely on the
    real TLS listener

Threat model reference: TB-11 (EST/SCEP client ↔ Server) per
cowork/comprehensive-audit-2026-04-25/threat-model.md.
Standards: RFC 7030 §3.2.3, RFC 8894 §3, RFC 5652, RFC 9266 (deferred).
2026-04-25 21:14:41 +00:00
shankar0123 bcefb11e65 feat(M51): add SCEP server (RFC 8894) for MDM and network device enrollment
Implements Simple Certificate Enrollment Protocol with single-endpoint
operation-based dispatch (GetCACaps, GetCACert, PKIOperation), PKCS#7
SignedData CSR extraction with fallback for raw/base64 CSR, challenge
password authentication via CSR attributes, and shared internal/pkcs7
package extracted from EST handler to eliminate code duplication.

24 new tests (11 service + 13 handler) plus 5 shared pkcs7 package tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:47:18 -04:00