Repository navigation
feat(scep): serve SCEP so network devices can enrol a trustpoint - #288
Merged
Merged
Conversation
2 tasks done
Bugs5382
marked this pull request as ready for review
September 30, 2026 07:19
Bugs5382
force-pushed
the
feat/185-scep-server
branch
from
September 30, 2026 14:30
1af1756 to
33ee102
Compare
Bugs5382
force-pushed
the
feat/185-scep-server
branch
from
September 30, 2026 16:33
33ee102 to
a8b08ce
Compare
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
force-pushed
the
feat/185-scep-server
branch
from
September 30, 2026 16:51
a8b08ce to
bcd25e8
Compare
Contributor
Author
|
Merging for the v0.1.0 test run: CI green and mergeable at merge time. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/cmsprimitives from #285 and the contract from CryptOS-PKI/api#117.GetCACaps(advertises exactlyAES,POSTPKIOperation,Renewal,SHA-256,SHA-512),GetCACert(the CA plus the RA certificate,application/x-x509-ca-ra-cert), andPKIOperationover GET and POST withPKCSReq,RenewalReq,CertPoll(GetCertInitial),GetCertandGetCRL. Served on/cgi-bin/pkiclient.exeand/scep.MintScepChallenge(orcryptosctl 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 mandatoryallowed_identifier_suffixes; IP, email and URI SANs and wildcards are refused.min_rsa_key_bitsper SCEP profile, 2048 at the lowest, 3072 by default, through a newca.Profile.MinRSAKeyBitssoca.Signstays the one place the rule lives. A CA certificate can never take a lower floor, and every other issuance path keeps 3072.require_approvalon a profile answersPENDING;ListScepEnrollments,ApproveScepEnrollment(re-checks against the policy in force and leaves a failing request queued) andRejectScepEnrollment, pluscryptosctl scep enrollments list|approve|reject.CertPollgets 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.digitalSignatureandkeyEncipherment, minted by the node's CA, valid forra.validity_days(365 at most), stored on the encrypted state partition like the OCSP responder key.rotation_overlap_daysbefore expiry a successor is minted;GetCACertthen 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.pki.scepmaps both ways through the proto, is refused on a Root byValidate, 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 sohttp://<node>/cgi-bin/pkiclient.exeworks as written.NodeStatus.protocolsreports SCEP configured, running and reboot-pending.NodeServiceSCEP RPCs, bootstrap-admin only over mTLS and on the local socket,FailedPreconditionwhen SCEP is not running this boot (maintenance, a Root, or off).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:
PKCSReqsigned by a current certificate from this CA is a renewal, with theRenewalReqrules, whatever challenge it carries. sscep 0.10.0 never sendsRenewalReq, and Cisco IOS rollover sends the pre-RFC 8894 form, sometimes repeating its stored challenge; without this, neither could renew.GetCertandGetCRLaccept 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.GetCACert, as the api contract says; older ones keep decrypting until they expire.badAlgbefore 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 wasinternal/config/config.go, where #271'sAllowUnsyncedClockand this PR'sScepsit side by side inToProto; both stay. #271's time-sync status sits beside the protocol status inNodeStatus, and SCEP'sProtocolRunningentry 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 headermain'stask licensechecks today; #291 restamps them with the rest of the tree.Clock gate (#271). SCEP issuance goes through the same
node.CASigneras 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 abadRequestfailure. 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.TestSCEPIssuanceHonoursTheClockGatepins both (checked by mutation: opening the gate fails it).docs/time-sync.mdnow lists SCEP with ACME and EST and names the service certificates the gate leaves alone, anddocs/scep.mdlists the gate under its limits.Base.
main; #285 is merged.api pin.
github.com/CryptOS-PKI/apiis on apimainat81494db(carried bymainsince #286), which has CryptOS-PKI/api#104, #115 and #117; thewip/185-api-integrationpin is gone and that branch can go. The six SCEP admin RPCs replace theirUnimplementedstubs ininternal/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 docsmainthrough CryptOS-PKI/docs#47, so the page is already published with an "Arrives with cryptos#288" note. This PR also addsdocs/scep.md, a README paragraph, and fixes onedocs/est.mdsentence that described SCEP as a shared secret.Verification
golangci-lint run ./...v2.12.2, alsoGOOS=linux;gofmt;go vet;actionlint)task ciafter the rebase; earlier-race -count=2oninternal/scep)task build)How this was verified
The floor, config, apply, RPC and status tests came first and failed before their code; the
internal/scepsuite 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 oninternal/cms:GetCACapscontent;GetCACertshape and RA usage;PKCSReqover 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;PENDINGthen approved, then rejected, and approval re-checking a tightened allowlist;CertPollunknown and from another key;RenewalReqwith the same names, other names, revoked, foreign CA, self-signed, expired (badTime), a non-SCEP profile, a key under the floor; the legacyPKCSReqrenewal;GetCertandGetCRLwith 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,GetCACertoffers 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.internal/scep/sscep_test.go): sscep 0.10.0 against the server with keys and requests from OpenSSL 3 (challengePasswordin 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 aPENDINGenrolment 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.ymlbuilds 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(everyscepverb 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.GOTOOLCHAIN=go1.26.6with the 26.5 SDK, andinternal/grpc's socket test a shortTMPDIR; both are local-environment limits.Still owed, on a real node (the Linux box with the C9300X): boot an issuing node with
pki.scepand a Cisco profile atmin_rsa_key_bits: 2048, checkcryptosctl status -o json, mint a challenge, and run the IOS-XE trustpointauthenticateandenrollwith an RSA 2048 key, then a rollover renewal, aPENDINGprofile and a revoked-device refusal.Merge order and overlaps. #285, #286, #271 and #305 are merged; this is rebased on
mainafter them. The SCEP block follows #286's protocol rules: an absent block is kept throughFromProtoOver, the SCEP status entry sits in its protocol list (socryptosctl statusprints SCEP with no change to the command), andconfig_reboot_pendingcovers SCEP because it is not a hot field. The Root refusal stays invalidateSCEP. 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).