Skip to content

feat(scep): serve SCEP so network devices can enrol a trustpoint - #288

Merged
Bugs5382 merged 3 commits into
mainfrom
feat/185-scep-server
Sep 30, 2026
Merged

Bugs5382 merged 3 commits into
mainfrom
feat/185-scep-server

Conversation

@Bugs5382

@Bugs5382 Bugs5382 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Network equipment cannot run ACME. Cisco IOS and IOS-XE, and most switch, router, firewall and VPN platforms, enrol a trustpoint over SCEP. This is the node side of SCEP (RFC 8894), slices 3 and 4 of #185, on the internal/cms primitives from #285 and the contract from CryptOS-PKI/api#117.

  • Operations: GetCACaps (advertises exactly AES, POSTPKIOperation, Renewal, SHA-256, SHA-512), GetCACert (the CA plus the RA certificate, application/x-x509-ca-ra-cert), and PKIOperation over GET and POST with PKCSReq, RenewalReq, CertPoll (GetCertInitial), GetCert and GetCRL. Served on /cgi-bin/pkiclient.exe and /scep.
  • Initial enrolment: a one-time challenge minted by MintScepChallenge (or cryptosctl scep challenge mint). Only its SHA-256 is stored; it is single-use (consumed by the first request that presents it, whatever the outcome), has a TTL (one hour by default, seven days at most), names its profile, and can be bound to names. Every name, the subject CN included, must be inside the mandatory allowed_identifier_suffixes; IP, email and URI SANs and wildcards are refused.
  • Renewal: authorized by the device's current certificate (chains to this CA, current, not revoked), pinned to its names and to the profile it was issued under, never queued.
  • Key floor: min_rsa_key_bits per SCEP profile, 2048 at the lowest, 3072 by default, through a new ca.Profile.MinRSAKeyBits so ca.Sign stays the one place the rule lives. A CA certificate can never take a lower floor, and every other issuance path keeps 3072.
  • PENDING queue: require_approval on a profile answers PENDING; ListScepEnrollments, ApproveScepEnrollment (re-checks against the policy in force and leaves a failing request queued) and RejectScepEnrollment, plus cryptosctl scep enrollments list|approve|reject.
  • Transactions: every transactionID is recorded, so a retransmit or a CertPoll gets the same certificate, never a second; a transactionID reused with a different key is refused. A failed request is forgotten, so the device can retry with a fresh challenge.
  • RA: RSA 3072, digitalSignature and keyEncipherment, minted by the node's CA, valid for ra.validity_days (365 at most), stored on the encrypted state partition like the OCSP responder key. rotation_overlap_days before expiry a successor is minted; GetCACert then offers the new RA, both decrypt, and a reply is signed by the RA the request was encrypted to. A new RA is also minted after a CA key rotation. The node re-checks hourly.
  • Config and boot: pki.scep maps both ways through the proto, is refused on a Root by Validate, keeps the stored block when an apply leaves it out, and needs a reboot (it is not a hot field). The listener starts only at boot, and by default shares the CRL/OCSP listener's port so http://<node>/cgi-bin/pkiclient.exe works as written. NodeStatus.protocols reports SCEP configured, running and reboot-pending.
  • Admin RPCs: the six NodeService SCEP RPCs, bootstrap-admin only over mTLS and on the local socket, FailedPrecondition when SCEP is not running this boot (maintenance, a Root, or off).
  • Audit and logs: every SCEP decision goes to the audit log (scep/<messageType>, with the transaction, profile, serial, names, failInfo and the id of the consumed challenge); mints, revokes, approvals and rejections are audited as RPCs with their ids and reasons. The challenge never reaches a log or the audit trail, which the tests assert.

Design calls:

  • A PKCSReq signed by a current certificate from this CA is a renewal, with the RenewalReq rules, whatever challenge it carries. sscep 0.10.0 never sends RenewalReq, and Cisco IOS rollover sends the pre-RFC 8894 form, sometimes repeating its stored challenge; without this, neither could renew.
  • GetCert and GetCRL accept the RA's name as the issuer as well as the CA's, because clients that verify replies with the RA (sscep given the RA as -c) name it there; the serial is looked up in this node's issued set either way.
  • The RA key is a software key on the encrypted state partition, also on a TPM node. feat(tpm): hold an RSA CA key in the TPM #282 (RSA keys in the TPM) is not merged; moving the RA into the TPM is a follow-up once it is.
  • Only the newest RA is offered in GetCACert, as the api contract says; older ones keep decrypting until they expire.
  • The SCEP port defaults to the CRL/OCSP port and shares that listener when it runs, because the Cisco default URL has no port.
  • FAILURE and PENDING replies carry no content (a SignedData with no eContent), per RFC 8894 section 3.3.2.
  • A device's key must be RSA, because the reply is encrypted to the request signer and SCEP key transport is RSA; an ECDSA signer gets badAlg before anything is consumed.

Closes #185

Rebased onto current main (after #305, #271, #309 and #307), with a DCO sign-off on each commit. It carries three commits: the per-profile RSA floor, the SCEP server, and a test-and-docs commit for the clock gate. The one conflict was internal/config/config.go, where #271's AllowUnsyncedClock and this PR's Scep sit side by side in ToProto; both stay. #271's time-sync status sits beside the protocol status in NodeStatus, and SCEP's ProtocolRunning entry sits next to ACME and EST, so both are kept. cryptos#291 (the header relicensing) is not merged yet, so the new files keep the header main's task license checks today; #291 restamps them with the rest of the tree.

Clock gate (#271). SCEP issuance goes through the same node.CASigner as ACME and EST, and that signer has the clock gate wired before the SCEP server is built, so while a configured time source has not synced this boot, a SCEP enrolment gets a badRequest failure. The SCEP RA certificate is minted with the CA key directly, like the OCSP responder and EST server certificates, and is not gated, so SCEP can offer an RA from the first boot. TestSCEPIssuanceHonoursTheClockGate pins both (checked by mutation: opening the gate fails it). docs/time-sync.md now lists SCEP with ACME and EST and names the service certificates the gate leaves alone, and docs/scep.md lists the gate under its limits.

Base. main; #285 is merged.

api pin. github.com/CryptOS-PKI/api is on api main at 81494db (carried by main since #286), which has CryptOS-PKI/api#104, #115 and #117; the wip/185-api-integration pin is gone and that branch can go. The six SCEP admin RPCs replace their Unimplemented stubs in internal/grpc/unserved.go. ListTsaCertificates (CryptOS-PKI/api#120) stays there, unserved, until the RFC 3161 TSA lands.

Companion docs: CryptOS-PKI/docs#40 ("Enrol devices with SCEP"). It merged into docs/stub-pages, which reached docs main through CryptOS-PKI/docs#47, so the page is already published with an "Arrives with cryptos#288" note. This PR also adds docs/scep.md, a README paragraph, and fixes one docs/est.md sentence that described SCEP as a shared secret.

Verification

  • Lint clean (golangci-lint run ./... v2.12.2, also GOOS=linux; gofmt; go vet; actionlint)
  • Tests pass (task ci after the rebase; earlier -race -count=2 on internal/scep)
  • Build succeeds (task build)
  • Documentation updated

How this was verified

The floor, config, apply, RPC and status tests came first and failed before their code; the internal/scep suite was written alongside the handler and checked by mutation. New tests:

  • internal/scep: every operation and every failure against an embedded etcd and a test CA, driven by a small SCEP client built on internal/cms: GetCACaps content; GetCACert shape and RA usage; PKCSReq over POST and GET; challenge missing, never minted, reused, expired and revoked; a failure that is forgotten so a retry works; names outside the allowlist, a look-alike suffix, a non-FQDN CN, a wildcard, outside the bound names, an IP SAN; the key floor (2048 refused on a 3072 profile, 3072 accepted everywhere, 1024 refused); retransmit returning the same certificate and a reused transactionID with another key refused; PENDING then approved, then rejected, and approval re-checking a tightened allowlist; CertPoll unknown and from another key; RenewalReq with the same names, other names, revoked, foreign CA, self-signed, expired (badTime), a non-SCEP profile, a key under the floor; the legacy PKCSReq renewal; GetCert and GetCRL with their refusals; a tampered signature, an envelope for a stranger, an unknown messageType, an ECDSA signer (badAlg), AES-128 mirrored; RA rotation overlap (successor minted, GetCACert offers it, the old RA still decrypts and signs its reply, no extra mint, dropped after expiry), RA reload across a restart, a new RA after a CA rotation and none stored near the CA's expiry; the mint, list and revoke rules; the challengePassword attribute parser. Mutation checks: making challenges reusable, dropping the bound-name check, ignoring the floor, skipping the transaction record, decrypting with the newest RA only, skipping the expiry check, unpinning renewal names and ignoring revocation each fail the suite.
  • sscep interop (internal/scep/sscep_test.go): sscep 0.10.0 against the server with keys and requests from OpenSSL 3 (challengePassword in the request, as a device writes it): getcaps, getca (CA and RA), enrol with a challenge, a reused challenge refused, SHA-512 with AES-128, SHA-1 with 3DES refused, getcert, getcrl, renewal signed by the issued certificate, and a PENDING enrolment that sscep polls until it is approved. It skips locally without the binaries (CRYPTOS_TEST_SSCEP, CRYPTOS_TEST_OPENSSL) and fails under CI; ci-go.yml builds sscep from the pinned, checksum-checked v0.10.0 tarball.
  • internal/config (block rules, Root refusal, floors, RA bounds, YAML, proto round trip, enabled=false, absent keeps, reboot), internal/node (apply round trip, absent keeps, switch-off requires a reboot; status reports SCEP next to ACME and EST, with a pending reboot after a switch-off; the signer floor), internal/grpc (all six RPCs: FailedPrecondition when not running, PermissionDenied for a non-admin, admin errors passed through, audit details without the challenge, the local-socket actor), internal/init (listener placement, profile mapping, the RA certificate), cmd/cryptosctl (every scep verb against a test server, and the not-running refusal).
  • task license (golic dry run): 0 of 387 files to change. The pre-push hook passed.
  • On macOS the TPM-simulator tests need GOTOOLCHAIN=go1.26.6 with the 26.5 SDK, and internal/grpc's socket test a short TMPDIR; both are local-environment limits.

Still owed, on a real node (the Linux box with the C9300X): boot an issuing node with pki.scep and a Cisco profile at min_rsa_key_bits: 2048, check cryptosctl status -o json, mint a challenge, and run the IOS-XE trustpoint authenticate and enroll with an RSA 2048 key, then a rollover renewal, a PENDING profile and a revoked-device refusal.

Merge order and overlaps. #285, #286, #271 and #305 are merged; this is rebased on main after them. The SCEP block follows #286's protocol rules: an absent block is kept through FromProtoOver, the SCEP status entry sits in its protocol list (so cryptosctl status prints SCEP with no change to the command), and config_reboot_pending covers SCEP because it is not a hot field. The Root refusal stays in validateSCEP. Smaller overlaps: #282 (ci-go.yml, run.go, README), #279 (internal/grpc/server.go) and #277 (docs/est.md, README).


Before merging: add a closing comment summarizing what was actually done in this PR
(not just the checked boxes).

@Bugs5382 Bugs5382 added this to the v1.0.0 milestone Sep 30, 2026
@Bugs5382 Bugs5382 added the enhancement New feature (feat). Minor version bump. label Sep 30, 2026
@Bugs5382 Bugs5382 self-assigned this Sep 30, 2026
@Bugs5382
Bugs5382 marked this pull request as ready for review September 30, 2026 07:19
@Bugs5382
Bugs5382 changed the base branch from feat/185-cms-primitives to main September 30, 2026 10:01
@Bugs5382 Bugs5382 linked an issue Sep 30, 2026 that may be closed by this pull request
@Bugs5382
Bugs5382 force-pushed the feat/185-scep-server branch from 1af1756 to 33ee102 Compare September 30, 2026 14:30
@Bugs5382
Bugs5382 force-pushed the feat/185-scep-server branch from 33ee102 to a8b08ce Compare September 30, 2026 16:33
Cisco IOS and IOS-XE SCEP trustpoints cannot hold an RSA key larger than
2048 bits, so the SCEP device profiles need a lower floor than the
node-wide 3072. ca.Profile gains MinRSAKeyBits, validated by
ValidateSubjectKeyMin: zero keeps 3072, 2048 is the lowest accepted, and a
CA certificate can never take a lower floor. CASigner gains
IssueLeafForNamesMinRSA for the one caller that passes one; every other
path keeps the node-wide floor.

Refs #185

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Adds internal/scep, the node side of SCEP on the internal/cms primitives:
GetCACaps, GetCACert (the CA and an RA certificate), and PKIOperation with
PKCSReq, RenewalReq, CertPoll, GetCert and GetCRL.

- Initial enrolment is authorized by a one-time challenge minted with
  MintScepChallenge: stored as a SHA-256, single-use, with a TTL, naming
  its profile and optionally bound to names inside the mandatory
  allowlist. Renewal is authorized by the device's current certificate
  and keeps its names and profile; a PKCSReq signed by such a certificate
  is treated as a renewal, which is how sscep and Cisco IOS rollover send
  it.
- Each profile has its own RSA floor (2048 at the lowest) and an optional
  PENDING queue, approved or rejected through the six admin RPCs, which
  replace their Unimplemented stubs.
- Every transactionID is recorded, so a retransmit or CertPoll returns the
  same certificate.
- The RA is RSA 3072 (digitalSignature, keyEncipherment), minted by the
  node's CA, valid a year, held on the encrypted state partition and
  rotated with an overlap during which both RAs decrypt.
- pki.scep is carried in the machine config under the same protocol-block
  rules as ACME and EST (an absent block in an apply keeps the stored
  one), refused on a Root, and read only at boot; the listener shares the
  CRL/OCSP port by default. NodeStatus.protocols reports it next to ACME
  and EST.
- cryptosctl gains scep challenge mint|list|revoke and scep enrollments
  list|approve|reject.
- Every decision is audited with the challenge id, never the challenge.

Refs #185

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
SCEP issues through the same clock-gated CA signer as ACME and EST, so a
node whose time source has not synced refuses the enrolment. Pin that
with a test, and pin that the RA certificate is minted without the gate,
like the OCSP responder and EST server certificates, so SCEP can offer
an RA from the first boot.

List SCEP beside ACME and EST in the signing-gate docs, note the service
certificates the gate leaves alone, and add the gate to the SCEP limits.

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
@Bugs5382
Bugs5382 force-pushed the feat/185-scep-server branch from a8b08ce to bcd25e8 Compare September 30, 2026 16:51
@Bugs5382

Copy link
Copy Markdown
Contributor Author

Merging for the v0.1.0 test run: CI green and mergeable at merge time.

@Bugs5382
Bugs5382 merged commit 434e7cf into main Sep 30, 2026
@Bugs5382
Bugs5382 deleted the feat/185-scep-server branch September 30, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature (feat). Minor version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(scep): serve SCEP so network devices can enrol a trustpoint

1 participant