mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:12:04 +00:00
feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.
Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.
== Phase 4: RenewalReq + GetCertInitial wiring ============================
internal/service/scep.go
* RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
existing valid cert. Same contract as PKCSReqWithEnvelope but the
service additionally verifies that envelope.SignerCert chains to
the issuer's CA (verifyRenewalSignerCertChain). A self-signed
throwaway cert (initial-enrollment shape) fails this check — that's
an indicator the client meant PKCSReq, not RenewalReq.
* GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
Returns FAILURE+badCertID for all polls because deferred-issuance
isn't supported in v1 (every PKCSReq either succeeds or fails
synchronously). Wiring stays in place for a future enhancement.
* Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
grep the audit log to distinguish initial enrollments from renewals.
internal/api/handler/scep.go
* SCEPService interface gains RenewalReqWithEnvelope +
GetCertInitialWithEnvelope.
* pkiOperation RFC 8894 path now switches on envelope.MessageType:
PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
badRequest per RFC 8894 §3.3.2.2.
== Phase 5.1: GetCACaps capability advertisement =========================
internal/service/scep.go
* Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
to add 'SHA-512' (modern digest alternative now implemented in the
Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
Phase 4). ChromeOS specifically looks for these capabilities to
negotiate the strongest available cipher + digest combo.
* scep_test.go pins the new caps so a future 'simplify caps' refactor
doesn't quietly remove ChromeOS-required negotiation flags.
== Phase 5.2: ChromeOS-shape integration tests ===========================
internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
* 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
in-test (acting as the ChromeOS client), POSTs through the handler,
parses the CertRep response back via the same internal/pkcs7/
builders the handler uses.
* TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
POSTed; verifies CertRep parses + RA signature verifies.
* TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
* TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
* TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
signature falls through to MVP path (which also rejects since the
encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
* TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
* TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
path keeps working when no RA pair is configured. Backward compat
is non-negotiable.
== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============
internal/domain/profile.go
* Added MustStaple bool to CertificateProfile. Default false; operators
opt in once they've confirmed the TLS reverse proxy / load balancer
staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
require explicit config).
internal/connector/issuer/interface.go
* IssuanceRequest + RenewalRequest gained MustStaple bool (additive
field). Connectors that don't support extension injection (Vault,
EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
issuer-only feature in V2 since upstream connectors enforce their
own extension policy.
internal/connector/issuer/local/local.go
* Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
RFC 7633 §6).
* generateCertificate signature gained mustStaple bool; when true,
appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
mustStapleExtensionValue} to template.ExtraExtensions before
x509.CreateCertificate.
internal/connector/issuer/local/must_staple_test.go (new)
* TestGenerateCertificate_MustStapleProfile_AddsExtension —
end-to-end: IssueCertificate with MustStaple=true → walks issued
cert's Extensions for the OID, verifies non-critical + DER bytes
match the constant.
* TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
'omit by default' contract (adding it by default would break
customer deployments where the TLS path doesn't staple).
* TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
DER bytes against RFC 7633 §6 verbatim; round-trips through
asn1.Unmarshal as []int{5}.
Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.
== Phase 5.4: docs/legacy-est-scep.md ====================================
Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).
== Verification ==========================================================
* gofmt + go vet clean for the files I touched.
* staticcheck ./internal/api/handler/... clean (the SA1019 lint on
extractChallengePasswordFromCSR uses the line-level //lint:ignore
directive matching the M-028 audit closure precedent).
* go test -short -count=1 green across api/handler / api/router /
service / pkcs7 / connector/issuer/local / domain / cmd/server.
* G-3 docs-drift CI guard local check: empty diff in both directions.
Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.
Living progress at cowork/scep-rfc8894-intune/progress.md.
This commit is contained in:
@@ -201,6 +201,167 @@ becomes a compliance failure:
|
||||
- https://www.pcisecuritystandards.org/news_events/
|
||||
- https://nvlpubs.nist.gov/nistpubs/SpecialPublications/ (SP 800-52 revisions)
|
||||
|
||||
## SCEP RFC 8894 native implementation (post-2026-04-29)
|
||||
|
||||
Prior to this bundle, certctl's SCEP server parsed `PKCS#7 SignedData` and
|
||||
treated the encapsulated content as a raw `PKCS#10 CSR` (the file-internal
|
||||
"MVP" comment at `internal/api/handler/scep.go:217` flagged this). That
|
||||
worked for lightweight MDM agents but failed against ChromeOS and most
|
||||
production MDM clients which expect full RFC 8894 wire format:
|
||||
`SignedData` wrapping an `EnvelopedData` encrypting the CSR to the RA
|
||||
cert's public key, with `signerInfo` POPO over the auth-attrs.
|
||||
|
||||
The new RFC 8894 path runs FIRST; on any parse failure it falls through
|
||||
to the legacy MVP raw-CSR path so existing operators see no behavior
|
||||
change for their lightweight clients.
|
||||
|
||||
### Required: RA cert + key
|
||||
|
||||
The RFC 8894 path requires a Registration Authority cert + key pair.
|
||||
Clients encrypt their CSR to the RA cert's public key (RFC 8894 §3.2.2);
|
||||
the certctl server uses the RA key to decrypt and to sign the outbound
|
||||
CertRep PKIMessage signerInfo (RFC 8894 §3.3.2).
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA certificate. **Required when `CERTCTL_SCEP_ENABLED=true`.** |
|
||||
| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded RA private key matching `CERTCTL_SCEP_RA_CERT_PATH`. File MUST be mode `0600` (preflight refuses world-readable). |
|
||||
|
||||
Generate the RA pair (any RSA-2048+ or ECDSA-P256+ pair signed by your
|
||||
root or sub-CA works):
|
||||
|
||||
```bash
|
||||
# RSA-2048 RA pair, valid 1 year, signed by your root.
|
||||
openssl req -new -newkey rsa:2048 -nodes -keyout ra.key -out ra.csr \
|
||||
-subj "/CN=corp-ca-RA"
|
||||
openssl x509 -req -in ra.csr -days 365 \
|
||||
-CA root.crt -CAkey root.key -CAcreateserial \
|
||||
-extfile <(printf "extendedKeyUsage=emailProtection,1.3.6.1.5.5.7.3.4") \
|
||||
-out ra.crt
|
||||
|
||||
chmod 0600 ra.key # required — preflight rejects world-readable keys
|
||||
chmod 0644 ra.crt
|
||||
mv ra.key ra.crt /etc/certctl/scep/
|
||||
|
||||
export CERTCTL_SCEP_ENABLED=true
|
||||
export CERTCTL_SCEP_RA_CERT_PATH=/etc/certctl/scep/ra.crt
|
||||
export CERTCTL_SCEP_RA_KEY_PATH=/etc/certctl/scep/ra.key
|
||||
export CERTCTL_SCEP_CHALLENGE_PASSWORD=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
The startup preflight in `cmd/server/main.go::preflightSCEPRACertKey`
|
||||
validates: file existence, key file mode 0600, cert/key match, cert
|
||||
non-expired, RSA-or-ECDSA public-key algorithm. Failures `os.Exit(1)`
|
||||
with a structured log line identifying the offending profile.
|
||||
|
||||
### Capability advertisement (`GetCACaps`)
|
||||
|
||||
```
|
||||
POSTPKIOperation
|
||||
SHA-256
|
||||
SHA-512
|
||||
AES
|
||||
SCEPStandard
|
||||
Renewal
|
||||
```
|
||||
|
||||
ChromeOS specifically looks for `POSTPKIOperation` (non-base64 POST),
|
||||
`AES` (the now-implemented CBC content encryption), `SCEPStandard` (RFC
|
||||
8894 conformance), and `Renewal` (RenewalReq messageType-17 support).
|
||||
Older Cisco IOS clients also accept `SHA-256` and `SHA-512` per RFC 8894
|
||||
§3.5.2.
|
||||
|
||||
### Supported messageTypes
|
||||
|
||||
| Type | RFC 8894 § | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `PKCSReq` (19) | §3.3.1 | Initial enrollment. Signer cert is the device's transient self-signed key. |
|
||||
| `RenewalReq` (17) | §3.3.1.2 | Re-enrollment. Signer cert MUST be a previously-issued cert from this issuer; service-side `verifyRenewalSignerCertChain` enforces. |
|
||||
| `GetCertInitial` (20) | §3.3.3 | Polling for pending requests. v1 returns `FAILURE+badCertID` because deferred-issuance isn't supported (every PKCSReq either succeeds or fails synchronously). |
|
||||
| `CertRep` (3) | §3.3.2 | Server response — never inbound. |
|
||||
|
||||
### MVP backward-compatibility path
|
||||
|
||||
Lightweight clients that send a stripped `SignedData` containing a raw
|
||||
CSR (no `EnvelopedData` wrapper, no `signerInfo` POPO) keep working: the
|
||||
handler tries the RFC 8894 path FIRST; on any parse failure it falls
|
||||
through to the legacy `extractCSRFromPKCS7` path. The legacy path uses
|
||||
the CSR's `challengePassword` attribute the same way as the RFC 8894
|
||||
path. Operators with existing lightweight-client deploys see zero
|
||||
behavior change.
|
||||
|
||||
### Multi-profile dispatch (`/scep/<pathID>`)
|
||||
|
||||
Real enterprise deploys run multiple SCEP endpoints from one certctl
|
||||
instance — corp-laptop CA, IoT CA, server CA — each with its own
|
||||
issuer + RA pair + challenge password. Configure via:
|
||||
|
||||
```
|
||||
CERTCTL_SCEP_PROFILES=corp,iot,server
|
||||
CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID=iss-corp-laptop
|
||||
CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID=prof-corp-tls
|
||||
CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD=...
|
||||
CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH=/etc/certctl/scep/corp-ra.crt
|
||||
CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH=/etc/certctl/scep/corp-ra.key
|
||||
# ... per profile name in CERTCTL_SCEP_PROFILES
|
||||
```
|
||||
|
||||
The router exposes `/scep/corp`, `/scep/iot`, `/scep/server`. The legacy
|
||||
`/scep` root remains for the single-profile flat-env-var case (when
|
||||
`CERTCTL_SCEP_PROFILES` is unset). Per-profile preflight validates each
|
||||
RA pair independently; failures log the offending PathID.
|
||||
|
||||
### ChromeOS Admin Console pointer
|
||||
|
||||
In Google Admin Console → Devices → Networks → Certificates, register
|
||||
certctl's `/scep[/<pathID>]` URL as the SCEP server. Enter the challenge
|
||||
password from `CERTCTL_SCEP_CHALLENGE_PASSWORD` (or per-profile
|
||||
`CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD`). ChromeOS pulls
|
||||
`GetCACert` first to retrieve the RA cert, then enrolls via
|
||||
PKIOperation.
|
||||
|
||||
### RA cert rotation
|
||||
|
||||
The RA cert is loaded once at startup and persisted in the handler's
|
||||
struct field; rotation requires a server restart (mirrors the
|
||||
`CERTCTL_TLS_CERT_PATH` precedent in `cmd/server/tls.go`). The
|
||||
recommended cadence is annual rotation with a 30-day overlap during
|
||||
which both old + new RA certs are listed in `GetCACert`'s response (set
|
||||
the cert chain accordingly in your sub-CA hierarchy).
|
||||
|
||||
### Must-staple per-profile policy (RFC 7633)
|
||||
|
||||
When a `CertificateProfile` has `MustStaple = true`, the local issuer
|
||||
adds the `id-pe-tlsfeature` extension (OID `1.3.6.1.5.5.7.1.24`,
|
||||
non-critical, value `SEQUENCE OF INTEGER {5}`) to every issued cert.
|
||||
Browsers + modern TLS libraries that see this extension fail-closed on
|
||||
missing OCSP stapling responses — defense against revocation-bypass via
|
||||
OCSP blackholing.
|
||||
|
||||
**Default policy:** `false`. Operators opt in once they've confirmed the
|
||||
TLS reverse proxy / load balancer staples OCSP responses. NGINX,
|
||||
HAProxy, Envoy all support stapling but it requires explicit config —
|
||||
turning must-staple on without verifying the TLS path will hard-fail
|
||||
browsers.
|
||||
|
||||
Recommended for: Intune-deployed device certs (modern TLS clients);
|
||||
SCEP profiles serving general / legacy clients (ChromeOS, IoT) should
|
||||
stay `false` until the TLS path is verified.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **Audit:** every enrollment emits an `audit_event` row with action
|
||||
`scep_pkcsreq` (initial) or `scep_renewalreq` (renewal); operators
|
||||
can grep the audit log to distinguish.
|
||||
- **Body-size cap:** `http.MaxBytesReader` middleware caps request
|
||||
bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are
|
||||
typically <50KB so the default cap is generous.
|
||||
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
|
||||
plane; there is no plaintext fallback.
|
||||
- **Forward reference:** for Microsoft Intune deployments specifically,
|
||||
see [`scep-intune.md`](scep-intune.md) (the doc Phase 11 of the
|
||||
master bundle ships).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only
|
||||
|
||||
Reference in New Issue
Block a user