From 2798a20a2eb09900e3e9c70ebe63311029863cb3 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:14:03 -0400 Subject: [PATCH 1/3] feat(ca): allow a per-profile RSA subject-key floor down to 2048 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> --- internal/ca/keyfloor_test.go | 100 ++++++++++++++++++++++++++ internal/ca/profile.go | 35 ++++++++- internal/node/signer.go | 29 ++++++-- internal/node/signer_keyfloor_test.go | 82 +++++++++++++++++++++ 4 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 internal/ca/keyfloor_test.go create mode 100644 internal/node/signer_keyfloor_test.go diff --git a/internal/ca/keyfloor_test.go b/internal/ca/keyfloor_test.go new file mode 100644 index 0000000..8762ad1 --- /dev/null +++ b/internal/ca/keyfloor_test.go @@ -0,0 +1,100 @@ +package ca + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "testing" + "time" +) + +func TestValidateSubjectKeyMin(t *testing.T) { + rsa2048 := rsaKey(t, 2048) + rsa3072 := rsaKey(t, 3072) + rsa1024, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("GenerateKey RSA 1024: %v", err) + } + tests := []struct { + name string + key *rsa.PublicKey + min int + wantErr bool + }{ + {"2048 on a 2048 floor", &rsa2048.PublicKey, 2048, false}, + {"3072 on a 2048 floor", &rsa3072.PublicKey, 2048, false}, + {"2048 on the default floor", &rsa2048.PublicKey, 0, true}, + {"2048 on a 3072 floor", &rsa2048.PublicKey, 3072, true}, + {"1024 on a 2048 floor", &rsa1024.PublicKey, 2048, true}, + {"a floor below the lowest allowed", &rsa2048.PublicKey, 1024, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateSubjectKeyMin(tc.key, tc.min) + if tc.wantErr != (err != nil) { + t.Fatalf("ValidateSubjectKeyMin(%d bits, min %d) = %v, wantErr %v", tc.key.N.BitLen(), tc.min, err, tc.wantErr) + } + }) + } + if err := ValidateSubjectKeyMin(&p384Key(t).PublicKey, 2048); err != nil { + t.Fatalf("P-384 on a 2048 floor: %v", err) + } +} + +func TestSignHonoursProfileRSAFloor(t *testing.T) { + issuerCert, issuerKey := selfSignedIssuer(t) + subject := rsaKey(t, 2048) + now := time.Now().UTC().Truncate(time.Second) + leaf := Profile{ + Subject: pkix.Name{CommonName: "switch01.example.com"}, + NotBefore: now, + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + DNSNames: []string{"switch01.example.com"}, + } + + if _, _, err := Sign(leaf, &subject.PublicKey, issuerCert, issuerKey); err == nil { + t.Fatal("Sign of an RSA 2048 key with the default floor: want an error") + } + + leaf.MinRSAKeyBits = LowestRSASubjectKeyBits + der, _, err := Sign(leaf, &subject.PublicKey, issuerCert, issuerKey) + if err != nil { + t.Fatalf("Sign of an RSA 2048 key on a 2048 floor: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + if got := cert.PublicKey.(*rsa.PublicKey).N.BitLen(); got != 2048 { + t.Fatalf("certified key is %d bits, want 2048", got) + } + + caProfile := leaf + caProfile.IsCA = true + caProfile.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageCRLSign + caProfile.ExtKeyUsage = nil + if _, _, err := Sign(caProfile, &subject.PublicKey, issuerCert, issuerKey); err == nil { + t.Fatal("Sign of a CA certificate over an RSA 2048 key: want an error, a CA key never takes the lower floor") + } +} diff --git a/internal/ca/profile.go b/internal/ca/profile.go index bff0ded..448c65d 100644 --- a/internal/ca/profile.go +++ b/internal/ca/profile.go @@ -97,6 +97,12 @@ type Profile struct { // that lacks the issuer can fetch it to build the chain. Empty omits the // AIA caIssuers access description. IssuingCertificateURL []string + + // MinRSAKeyBits lowers the RSA subject-key floor for this certificate + // (see ValidateSubjectKeyMin). Zero means MinRSASubjectKeyBits. Only an + // end-entity certificate may go below it: Sign refuses a CA profile with a + // lower floor. + MinRSAKeyBits int } // keyUsageNames maps the config vocabulary to x509 keyUsage bits. @@ -259,6 +265,26 @@ const MinRSASubjectKeyBits = 3072 // VMCA and Microsoft AD CS generate RSA keys and expose no algorithm choice, // so rejecting RSA outright would make them impossible to subordinate. func ValidateSubjectKey(pub crypto.PublicKey) error { + return ValidateSubjectKeyMin(pub, MinRSASubjectKeyBits) +} + +// LowestRSASubjectKeyBits is the lowest RSA floor any certificate may be +// issued under. It exists for devices that cannot hold a larger key: Cisco IOS +// and IOS-XE SCEP trustpoints top out at RSA 2048. +const LowestRSASubjectKeyBits = 2048 + +// ValidateSubjectKeyMin is ValidateSubjectKey with the RSA floor set to +// minRSABits instead of MinRSASubjectKeyBits. Zero means +// MinRSASubjectKeyBits; a floor below LowestRSASubjectKeyBits is refused +// outright rather than honoured. ECDSA stays P-384 only, and a stronger RSA +// key always passes. +func ValidateSubjectKeyMin(pub crypto.PublicKey, minRSABits int) error { + if minRSABits == 0 { + minRSABits = MinRSASubjectKeyBits + } + if minRSABits < LowestRSASubjectKeyBits { + return fmt.Errorf("an RSA subject-key floor of %d bits is below the lowest allowed, %d", minRSABits, LowestRSASubjectKeyBits) + } switch k := pub.(type) { case *ecdsa.PublicKey: if k.Curve != elliptic.P384() { @@ -266,8 +292,8 @@ func ValidateSubjectKey(pub crypto.PublicKey) error { } return nil case *rsa.PublicKey: - if bits := k.N.BitLen(); bits < MinRSASubjectKeyBits { - return fmt.Errorf("subject RSA key must be at least %d bits, got %d", MinRSASubjectKeyBits, bits) + if bits := k.N.BitLen(); bits < minRSABits { + return fmt.Errorf("subject RSA key must be at least %d bits, got %d", minRSABits, bits) } return nil default: @@ -316,7 +342,10 @@ func Sign(p Profile, subjectPub crypto.PublicKey, issuer *x509.Certificate, issu if issuerSigner == nil { return nil, nil, errors.New("ca: Sign: issuerSigner is required") } - if err := ValidateSubjectKey(subjectPub); err != nil { + if p.IsCA && p.MinRSAKeyBits != 0 && p.MinRSAKeyBits < MinRSASubjectKeyBits { + return nil, nil, fmt.Errorf("ca: Sign: a CA certificate cannot take an RSA floor below %d bits", MinRSASubjectKeyBits) + } + if err := ValidateSubjectKeyMin(subjectPub, p.MinRSAKeyBits); err != nil { return nil, nil, fmt.Errorf("ca: Sign: %w", err) } sigAlg, err := SignatureAlgorithmFor(issuerSigner.Public()) diff --git a/internal/node/signer.go b/internal/node/signer.go index 9d0ba03..bfa9fe1 100644 --- a/internal/node/signer.go +++ b/internal/node/signer.go @@ -228,7 +228,7 @@ func (s *CASigner) SignSubordinate(ctx context.Context, csrDER []byte, profileNa // key. A ROOT-role node refuses unless the config carries the irreversible // leaf-issuance acknowledgement. It returns the leaf DER. func (s *CASigner) IssueLeaf(ctx context.Context, csrDER []byte, profileName string) (certDER []byte, err error) { - der, _, _, _, err := s.issueLeaf(ctx, csrDER, profileName, nil, false) + der, _, _, _, err := s.issueLeaf(ctx, csrDER, profileName, nil, false, 0) if err != nil { return nil, err } @@ -253,7 +253,7 @@ func (s *CASigner) IssueLeafWithRequestSANs(ctx context.Context, csrDER []byte, if err != nil { return nil, nil, err } - der, _, _, vcap, err := s.issueLeaf(ctx, csrDER, profileName, names, len(names) > 0) + der, _, _, vcap, err := s.issueLeaf(ctx, csrDER, profileName, names, len(names) > 0, 0) if err != nil { return nil, nil, err } @@ -328,7 +328,17 @@ func validateHostName(name string) error { // A nil or empty dnsNames leaves the profile's SANs in place, which makes this // identical to IssueLeaf plus the chain. func (s *CASigner) IssueLeafForNames(ctx context.Context, csrDER []byte, profileName string, dnsNames []string) (chainDER [][]byte, chainPEM string, err error) { - der, pemBytes, issuerCert, _, err := s.issueLeaf(ctx, csrDER, profileName, dnsNames, false) + return s.IssueLeafForNamesMinRSA(ctx, csrDER, profileName, dnsNames, 0) +} + +// IssueLeafForNamesMinRSA is IssueLeafForNames with the RSA subject-key floor +// set to minRSABits (zero means ca.MinRSASubjectKeyBits, and the floor can +// never go below ca.LowestRSASubjectKeyBits). SCEP is its only caller with a +// lower floor: each SCEP profile carries its own, because Cisco IOS and IOS-XE +// trustpoints cannot hold a key larger than RSA 2048. Every other path keeps +// the node-wide floor. +func (s *CASigner) IssueLeafForNamesMinRSA(ctx context.Context, csrDER []byte, profileName string, dnsNames []string, minRSABits int) (chainDER [][]byte, chainPEM string, err error) { + der, pemBytes, issuerCert, _, err := s.issueLeaf(ctx, csrDER, profileName, dnsNames, false, minRSABits) if err != nil { return nil, "", err } @@ -348,8 +358,8 @@ func (s *CASigner) IssueLeafForNames(ctx context.Context, csrDER []byte, profile // rather than merging: a merge would silently carry a name the caller neither // asked for nor validated onto a certificate it did prove control of. // requireOptIn marks operator-asserted names, which the profile must allow. -func (s *CASigner) issueLeaf(ctx context.Context, csrDER []byte, profileName string, dnsNames []string, requireOptIn bool) (der, pemBytes []byte, issuerCert *x509.Certificate, vcap *ca.ValidityCap, err error) { - csr, err := parseAndVerifyCSR(csrDER) +func (s *CASigner) issueLeaf(ctx context.Context, csrDER []byte, profileName string, dnsNames []string, requireOptIn bool, minRSABits int) (der, pemBytes []byte, issuerCert *x509.Certificate, vcap *ca.ValidityCap, err error) { + csr, err := parseAndVerifyCSRMin(csrDER, minRSABits) if err != nil { return nil, nil, nil, nil, err } @@ -390,6 +400,7 @@ func (s *CASigner) issueLeaf(ctx context.Context, csrDER []byte, profileName str if err != nil { return nil, nil, nil, nil, err } + p.MinRSAKeyBits = minRSABits if len(dnsNames) > 0 { p.DNSNames = dnsNames p.IPAddresses = nil @@ -463,6 +474,12 @@ func (s *CASigner) currentConfig(ctx context.Context) (*config.Config, error) { // parseAndVerifyCSR parses csrDER and verifies its self-signature. func parseAndVerifyCSR(csrDER []byte) (*x509.CertificateRequest, error) { + return parseAndVerifyCSRMin(csrDER, 0) +} + +// parseAndVerifyCSRMin is parseAndVerifyCSR with the RSA subject-key floor +// set to minRSABits (see ca.ValidateSubjectKeyMin). +func parseAndVerifyCSRMin(csrDER []byte, minRSABits int) (*x509.CertificateRequest, error) { if len(csrDER) == 0 { return nil, status.Error(codes.InvalidArgument, "node: empty CSR") } @@ -476,7 +493,7 @@ func parseAndVerifyCSR(csrDER []byte) (*x509.CertificateRequest, error) { // Reject a client's unsupported subject key as InvalidArgument here, rather // than letting ca.Sign fail deeper and surface as Internal. ca.Sign applies // the same rule; this keeps the gRPC status code accurate. - if err := ca.ValidateSubjectKey(csr.PublicKey); err != nil { + if err := ca.ValidateSubjectKeyMin(csr.PublicKey, minRSABits); err != nil { return nil, status.Errorf(codes.InvalidArgument, "node: %v", err) } return csr, nil diff --git a/internal/node/signer_keyfloor_test.go b/internal/node/signer_keyfloor_test.go new file mode 100644 index 0000000..c12c6d5 --- /dev/null +++ b/internal/node/signer_keyfloor_test.go @@ -0,0 +1,82 @@ +package node + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/CryptOS-PKI/cryptos/internal/config" +) + +func makeRSACSR(t *testing.T, cn string, bits int) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + t.Fatalf("GenerateKey RSA %d: %v", bits, err) + } + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + SignatureAlgorithm: x509.SHA256WithRSA, + }, key) + if err != nil { + t.Fatalf("CreateCertificateRequest: %v", err) + } + return der +} + +// The SCEP device profiles are the only issuance path that may certify an +// RSA 2048 key, and only when the caller names that floor. +func TestIssueLeafForNamesMinRSA(t *testing.T) { + f := newSignerFixture(t) + var closed bool + load, issuer, get := f.loaders(caProfileConfig(config.RoleIssuing), &closed) + s := NewCASigner(load, issuer, get) + ctx := context.Background() + names := []string{"switch01.example.org"} + + csr2048 := makeRSACSR(t, "switch01.example.org", 2048) + if _, _, err := s.IssueLeafForNames(ctx, csr2048, "leaf-server", names); status.Code(err) != codes.InvalidArgument { + t.Fatalf("IssueLeafForNames with an RSA 2048 key = %v, want InvalidArgument", err) + } + if _, _, err := s.IssueLeafForNamesMinRSA(ctx, csr2048, "leaf-server", names, 3072); status.Code(err) != codes.InvalidArgument { + t.Fatalf("IssueLeafForNamesMinRSA(3072) with an RSA 2048 key = %v, want InvalidArgument", err) + } + chain, _, err := s.IssueLeafForNamesMinRSA(ctx, csr2048, "leaf-server", names, 2048) + if err != nil { + t.Fatalf("IssueLeafForNamesMinRSA(2048): %v", err) + } + leaf, err := x509.ParseCertificate(chain[0]) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + if got := leaf.PublicKey.(*rsa.PublicKey).N.BitLen(); got != 2048 { + t.Fatalf("certified key is %d bits, want 2048", got) + } + if _, _, err := s.IssueLeafForNamesMinRSA(ctx, csr2048, "leaf-server", names, 1024); status.Code(err) != codes.InvalidArgument { + t.Fatalf("IssueLeafForNamesMinRSA(1024) = %v, want InvalidArgument", err) + } +} From d683f3243366ff15915cb7f4590c46223fb13796 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:14:12 -0400 Subject: [PATCH 2/3] feat(scep): serve SCEP (RFC 8894) so network devices can enrol 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> --- .github/workflows/ci-go.yml | 16 + README.md | 2 + cmd/cryptosctl/root.go | 1 + cmd/cryptosctl/scep.go | 274 ++++++++++++ cmd/cryptosctl/scep_test.go | 156 +++++++ docs/acme.md | 2 +- docs/est.md | 4 +- docs/scep.md | 179 ++++++++ internal/config/config.go | 8 + internal/config/protocols.go | 3 +- internal/config/scep.go | 246 +++++++++++ internal/config/scep_test.go | 260 +++++++++++ internal/grpc/scep.go | 167 ++++++++ internal/grpc/scep_test.go | 231 ++++++++++ internal/grpc/server.go | 5 + internal/grpc/unserved.go | 32 +- internal/grpc/unserved_test.go | 24 -- internal/init/run.go | 59 ++- internal/init/scep.go | 209 +++++++++ internal/init/scep_test.go | 106 +++++ internal/node/providers.go | 8 +- internal/node/scep_apply_test.go | 162 +++++++ internal/revocation/http.go | 10 +- internal/scep/admin.go | 295 +++++++++++++ internal/scep/admin_test.go | 175 ++++++++ internal/scep/csr.go | 114 +++++ internal/scep/doc.go | 68 +++ internal/scep/fixture_test.go | 623 +++++++++++++++++++++++++++ internal/scep/http.go | 226 ++++++++++ internal/scep/message.go | 356 ++++++++++++++++ internal/scep/names.go | 170 ++++++++ internal/scep/ra.go | 245 +++++++++++ internal/scep/server.go | 711 +++++++++++++++++++++++++++++++ internal/scep/server_test.go | 693 ++++++++++++++++++++++++++++++ internal/scep/sscep_test.go | 260 +++++++++++ internal/scep/store.go | 353 +++++++++++++++ internal/storage/etcd/etcd.go | 28 ++ 37 files changed, 6419 insertions(+), 62 deletions(-) create mode 100644 cmd/cryptosctl/scep.go create mode 100644 cmd/cryptosctl/scep_test.go create mode 100644 docs/scep.md create mode 100644 internal/config/scep.go create mode 100644 internal/config/scep_test.go create mode 100644 internal/grpc/scep.go create mode 100644 internal/grpc/scep_test.go create mode 100644 internal/init/scep.go create mode 100644 internal/init/scep_test.go create mode 100644 internal/node/scep_apply_test.go create mode 100644 internal/scep/admin.go create mode 100644 internal/scep/admin_test.go create mode 100644 internal/scep/csr.go create mode 100644 internal/scep/doc.go create mode 100644 internal/scep/fixture_test.go create mode 100644 internal/scep/http.go create mode 100644 internal/scep/message.go create mode 100644 internal/scep/names.go create mode 100644 internal/scep/ra.go create mode 100644 internal/scep/server.go create mode 100644 internal/scep/server_test.go create mode 100644 internal/scep/sscep_test.go create mode 100644 internal/scep/store.go diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 43dea5e..e940137 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -68,6 +68,22 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends swtpm + - name: Build sscep for the SCEP interoperability tests + # internal/scep runs a real SCEP client against the server and fails + # under CI when it has none. Built from the pinned release tarball, + # checksum-checked, since no prebuilt binary is published. + env: + SSCEP_VERSION: 0.10.0 + SSCEP_SHA256: 489cc8e093986776eb3f15082bf766778f707176f3cd604bf0ef1008da06b8e5 + run: | + cd "$RUNNER_TEMP" + curl -fsSL -o sscep.tar.gz "https://github.com/certnanny/sscep/archive/refs/tags/v${SSCEP_VERSION}.tar.gz" + echo "${SSCEP_SHA256} sscep.tar.gz" | sha256sum -c - + tar xzf sscep.tar.gz + cmake -S "sscep-${SSCEP_VERSION}" -B sscep-build -DCMAKE_BUILD_TYPE=Release + cmake --build sscep-build + echo "CRYPTOS_TEST_SSCEP=$RUNNER_TEMP/sscep-build/sscep" >> "$GITHUB_ENV" + - name: Run task ci run: task ci diff --git a/README.md b/README.md index e78c656..8dfcfc0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ A node keeps its clock in sync over SNTP with the servers in `network.ntp_server Issuing LDAPS and KDC certificates to Active Directory domain controllers, including `certreq` on Server Core, is covered in [`docs/active-directory.md`](docs/active-directory.md). +Network devices that cannot run ACME, such as Cisco IOS and IOS-XE trustpoints, enrol over SCEP (RFC 8894): each initial enrolment is authorized by a one-time challenge from `cryptosctl scep challenge mint`, and renewal by the device's current certificate. [`docs/scep.md`](docs/scep.md) covers switching it on, the per-profile key floor, the RA certificate and the approval queue. + ### Rebooting or powering off a node Most `config apply` changes report `requires_reboot=true`. Restart the node through its orderly shutdown rather than a hypervisor hard reset: diff --git a/cmd/cryptosctl/root.go b/cmd/cryptosctl/root.go index d2f5f17..f271cbf 100644 --- a/cmd/cryptosctl/root.go +++ b/cmd/cryptosctl/root.go @@ -81,6 +81,7 @@ func newRootCmd() *cobra.Command { newResetCmd(opts), newVersionCmd(opts), newTrustCmd(opts), + newSCEPCmd(opts), ) addDebugCommands(root, opts) return root diff --git a/cmd/cryptosctl/scep.go b/cmd/cryptosctl/scep.go new file mode 100644 index 0000000..752f7c4 --- /dev/null +++ b/cmd/cryptosctl/scep.go @@ -0,0 +1,274 @@ +package main + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// newSCEPCmd groups the SCEP administration verbs: the one-time challenges +// that authorize an initial enrolment, and the queue of enrolments held for +// approval on profiles with require_approval. +func newSCEPCmd(opts *globalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "scep", + Short: "Manage SCEP enrolment: one-time challenges and the approval queue", + } + challenge := &cobra.Command{Use: "challenge", Short: "Mint, list and revoke one-time SCEP challenges"} + challenge.AddCommand(newSCEPMintCmd(opts), newSCEPListChallengesCmd(opts), newSCEPRevokeChallengeCmd(opts)) + enrollments := &cobra.Command{Use: "enrollments", Short: "List, approve and reject SCEP enrolments waiting for approval"} + enrollments.AddCommand(newSCEPListEnrollmentsCmd(opts), newSCEPApproveCmd(opts), newSCEPRejectCmd(opts)) + cmd.AddCommand(challenge, enrollments) + return cmd +} + +func newSCEPMintCmd(opts *globalOpts) *cobra.Command { + var ( + profile string + ttl time.Duration + names []string + ) + cmd := &cobra.Command{ + Use: "mint", + Short: "Mint a single-use challenge for one device's initial enrolment", + Long: "Mint a single-use challenge for one device's initial enrolment. The challenge is shown\n" + + "once: the node keeps only its digest. Give it to the device as its enrolment password,\n" + + "for example a Cisco trustpoint's `password`, before it expires.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ttl < 0 || ttl%time.Second != 0 { + return errors.New("--ttl must be a whole number of seconds, for example 1h or 30m") + } + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + resp, err := client.MintScepChallenge(cmd.Context(), &cryptosv1.MintScepChallengeRequest{ + Profile: profile, TtlSeconds: uint32(ttl / time.Second), BoundNames: names, + }) + if err != nil { + return err + } + if opts.output != formatHuman { + return renderProto(cmd.OutOrStdout(), resp, opts.output) + } + ch := resp.GetChallenge() + w := cmd.OutOrStdout() + _, _ = fmt.Fprintf(w, "Challenge: %s\n", resp.GetChallengePassword()) + _, _ = fmt.Fprintf(w, "ID: %s\n", ch.GetId()) + _, _ = fmt.Fprintf(w, "Profile: %s\n", ch.GetProfile()) + _, _ = fmt.Fprintf(w, "Expires: %s\n", ch.GetExpiresAt().AsTime().UTC().Format(time.RFC3339)) + if len(ch.GetBoundNames()) > 0 { + _, _ = fmt.Fprintf(w, "Bound names: %s\n", strings.Join(ch.GetBoundNames(), ", ")) + } + _, err = fmt.Fprintln(cmd.ErrOrStderr(), "The challenge is shown once and works for one enrolment. The node cannot show it again.") + return err + }, + } + cmd.Flags().StringVar(&profile, "profile", "", "SCEP profile the enrolment issues from (required when several are configured)") + cmd.Flags().DurationVar(&ttl, "ttl", 0, "how long the challenge stays usable (default 1h, at most 168h)") + cmd.Flags().StringSliceVar(&names, "name", nil, "bind the challenge to this DNS name (repeatable); the request may then carry only these names") + return cmd +} + +func newSCEPListChallengesCmd(opts *globalOpts) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the challenges that are still usable", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + resp, err := client.ListScepChallenges(cmd.Context(), &cryptosv1.ListScepChallengesRequest{}) + if err != nil { + return err + } + return writeSCEPChallenges(cmd.OutOrStdout(), resp, opts.output) + }, + } +} + +func newSCEPRevokeChallengeCmd(opts *globalOpts) *cobra.Command { + var id string + cmd := &cobra.Command{ + Use: "revoke", + Short: "Withdraw an unused challenge", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if id == "" { + return errors.New("--id is required") + } + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + resp, err := client.RevokeScepChallenge(cmd.Context(), &cryptosv1.RevokeScepChallengeRequest{Id: id}) + if err != nil { + return err + } + if opts.output != formatHuman { + return renderProto(cmd.OutOrStdout(), resp, opts.output) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Revoked challenge %s (profile %s)\n", resp.GetChallenge().GetId(), resp.GetChallenge().GetProfile()) + return err + }, + } + cmd.Flags().StringVar(&id, "id", "", "id of the challenge to revoke (required)") + return cmd +} + +func newSCEPListEnrollmentsCmd(opts *globalOpts) *cobra.Command { + var profile string + cmd := &cobra.Command{ + Use: "list", + Short: "List the enrolments waiting for approval", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + resp, err := client.ListScepEnrollments(cmd.Context(), &cryptosv1.ListScepEnrollmentsRequest{Profile: profile}) + if err != nil { + return err + } + return writeSCEPEnrollments(cmd.OutOrStdout(), resp, opts.output) + }, + } + cmd.Flags().StringVar(&profile, "profile", "", "only list enrolments for this SCEP profile") + return cmd +} + +func newSCEPApproveCmd(opts *globalOpts) *cobra.Command { + var id string + cmd := &cobra.Command{ + Use: "approve", + Short: "Issue the certificate for a waiting enrolment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if id == "" { + return errors.New("--id is required") + } + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + resp, err := client.ApproveScepEnrollment(cmd.Context(), &cryptosv1.ApproveScepEnrollmentRequest{Id: id}) + if err != nil { + return err + } + if opts.output != formatHuman { + return renderProto(cmd.OutOrStdout(), resp, opts.output) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Approved enrolment %s: issued serial %s. The device collects it at its next poll.\n", + resp.GetEnrollment().GetId(), resp.GetSerialHex()) + return err + }, + } + cmd.Flags().StringVar(&id, "id", "", "id of the enrolment to approve (required)") + return cmd +} + +func newSCEPRejectCmd(opts *globalOpts) *cobra.Command { + var id, reason string + cmd := &cobra.Command{ + Use: "reject", + Short: "Refuse a waiting enrolment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if id == "" { + return errors.New("--id is required") + } + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + resp, err := client.RejectScepEnrollment(cmd.Context(), &cryptosv1.RejectScepEnrollmentRequest{Id: id, Reason: reason}) + if err != nil { + return err + } + if opts.output != formatHuman { + return renderProto(cmd.OutOrStdout(), resp, opts.output) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Rejected enrolment %s. The device's next poll is refused.\n", resp.GetEnrollment().GetId()) + return err + }, + } + cmd.Flags().StringVar(&id, "id", "", "id of the enrolment to reject (required)") + cmd.Flags().StringVar(&reason, "reason", "", "why, for the audit log (the device is not told)") + return cmd +} + +func writeSCEPChallenges(w io.Writer, resp *cryptosv1.ListScepChallengesResponse, format string) error { + if format != formatHuman { + return renderProto(w, resp, format) + } + if len(resp.GetChallenges()) == 0 { + _, err := io.WriteString(w, "(no usable challenges)\n") + return err + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "ID\tPROFILE\tEXPIRES\tBOUND_NAMES\tCREATED_BY") + for _, c := range resp.GetChallenges() { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", c.GetId(), c.GetProfile(), + c.GetExpiresAt().AsTime().UTC().Format(time.RFC3339), orDash(strings.Join(c.GetBoundNames(), ",")), orDash(c.GetCreatedByCn())) + } + return tw.Flush() +} + +func writeSCEPEnrollments(w io.Writer, resp *cryptosv1.ListScepEnrollmentsResponse, format string) error { + if format != formatHuman { + return renderProto(w, resp, format) + } + if len(resp.GetEnrollments()) == 0 { + _, err := io.WriteString(w, "(no enrolments waiting)\n") + return err + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "ID\tPROFILE\tSUBJECT\tDNS_NAMES\tKEY\tRECEIVED") + for _, e := range resp.GetEnrollments() { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", e.GetId(), e.GetProfile(), e.GetSubjectDn(), + orDash(strings.Join(e.GetDnsNames(), ",")), e.GetKeyAlg(), e.GetReceivedAt().AsTime().UTC().Format(time.RFC3339)) + } + return tw.Flush() +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/cmd/cryptosctl/scep_test.go b/cmd/cryptosctl/scep_test.go new file mode 100644 index 0000000..58c41f4 --- /dev/null +++ b/cmd/cryptosctl/scep_test.go @@ -0,0 +1,156 @@ +package main + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "strings" + "testing" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/bootstrap" + cgrpc "github.com/CryptOS-PKI/cryptos/internal/grpc" +) + +// fakeScepAdmin records what the scep verbs sent. +type fakeScepAdmin struct { + mint *cryptosv1.MintScepChallengeRequest + revoke string + list string + approve string + reject *cryptosv1.RejectScepEnrollmentRequest +} + +var testExpiry = timestamppb.New(time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + +func (f *fakeScepAdmin) MintScepChallenge(_ context.Context, req *cryptosv1.MintScepChallengeRequest, actor string) (*cryptosv1.MintScepChallengeResponse, error) { + f.mint = req + return &cryptosv1.MintScepChallengeResponse{ + ChallengePassword: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + Challenge: &cryptosv1.ScepChallenge{Id: "c1", Profile: "cisco-device", BoundNames: req.GetBoundNames(), ExpiresAt: testExpiry, CreatedByCn: actor}, + }, nil +} + +func (f *fakeScepAdmin) ListScepChallenges(context.Context, *cryptosv1.ListScepChallengesRequest) (*cryptosv1.ListScepChallengesResponse, error) { + return &cryptosv1.ListScepChallengesResponse{Challenges: []*cryptosv1.ScepChallenge{{Id: "c1", Profile: "cisco-device", ExpiresAt: testExpiry, CreatedByCn: "roundtrip-admin"}}}, nil +} + +func (f *fakeScepAdmin) RevokeScepChallenge(_ context.Context, req *cryptosv1.RevokeScepChallengeRequest) (*cryptosv1.RevokeScepChallengeResponse, error) { + f.revoke = req.GetId() + return &cryptosv1.RevokeScepChallengeResponse{Challenge: &cryptosv1.ScepChallenge{Id: req.GetId(), Profile: "cisco-device"}}, nil +} + +func (f *fakeScepAdmin) ListScepEnrollments(_ context.Context, req *cryptosv1.ListScepEnrollmentsRequest) (*cryptosv1.ListScepEnrollmentsResponse, error) { + f.list = req.GetProfile() + return &cryptosv1.ListScepEnrollmentsResponse{Enrollments: []*cryptosv1.ScepEnrollment{{ + Id: "e1", Profile: "cisco-device", SubjectDn: "CN=sw1.example.com", DnsNames: []string{"sw1.example.com"}, KeyAlg: "RSA-2048", ReceivedAt: testExpiry, + }}}, nil +} + +func (f *fakeScepAdmin) ApproveScepEnrollment(_ context.Context, req *cryptosv1.ApproveScepEnrollmentRequest) (*cryptosv1.ApproveScepEnrollmentResponse, error) { + f.approve = req.GetId() + return &cryptosv1.ApproveScepEnrollmentResponse{Enrollment: &cryptosv1.ScepEnrollment{Id: req.GetId()}, SerialHex: "1a2b"}, nil +} + +func (f *fakeScepAdmin) RejectScepEnrollment(_ context.Context, req *cryptosv1.RejectScepEnrollmentRequest) (*cryptosv1.RejectScepEnrollmentResponse, error) { + f.reject = req + return &cryptosv1.RejectScepEnrollmentResponse{Enrollment: &cryptosv1.ScepEnrollment{Id: req.GetId()}}, nil +} + +func startScepServer(t *testing.T, admin cgrpc.ScepAdmin) *testServer { + t.Helper() + return startTestServerWith(t, func(cfg *cgrpc.ServerConfig, clientCert *x509.Certificate) { + if admin != nil { + cfg.ScepAdmin = admin + } + fp := sha256.Sum256(clientCert.Raw) + trust, err := bootstrap.LoadTrust("", hex.EncodeToString(fp[:])) + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + cfg.Trust = trust + }) +} + +func TestSCEPChallengeMint(t *testing.T) { + f := &fakeScepAdmin{} + ts := startScepServer(t, f) + out, err := ts.run(t, "scep", "challenge", "mint", "--profile", "cisco-device", "--ttl", "30m", "--name", "sw1.example.com", "--name", "sw1-mgmt.example.com") + if err != nil { + t.Fatalf("mint: %v (out=%s)", err, out) + } + if f.mint.GetProfile() != "cisco-device" || f.mint.GetTtlSeconds() != 1800 || len(f.mint.GetBoundNames()) != 2 { + t.Fatalf("request = %v", f.mint) + } + for _, want := range []string{"Challenge: ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "ID: c1", "Expires: 2026-10-01T12:00:00Z", "sw1-mgmt.example.com", "shown once"} { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + if _, err := ts.run(t, "scep", "challenge", "mint", "--ttl", "1500ms"); err == nil { + t.Fatal("a TTL that is not whole seconds must be refused") + } +} + +func TestSCEPChallengeListAndRevoke(t *testing.T) { + f := &fakeScepAdmin{} + ts := startScepServer(t, f) + out, err := ts.run(t, "scep", "challenge", "list") + if err != nil || !strings.Contains(out, "c1") || !strings.Contains(out, "cisco-device") || strings.Contains(out, "ABCDEF") { + t.Fatalf("list: %v\n%s", err, out) + } + if _, err := ts.run(t, "scep", "challenge", "revoke"); err == nil { + t.Fatal("revoke without --id must fail") + } + out, err = ts.run(t, "scep", "challenge", "revoke", "--id", "c1") + if err != nil || f.revoke != "c1" || !strings.Contains(out, "Revoked challenge c1") { + t.Fatalf("revoke: %v %q\n%s", err, f.revoke, out) + } +} + +func TestSCEPEnrollmentVerbs(t *testing.T) { + f := &fakeScepAdmin{} + ts := startScepServer(t, f) + out, err := ts.run(t, "scep", "enrollments", "list", "--profile", "cisco-device") + if err != nil || f.list != "cisco-device" || !strings.Contains(out, "e1") || !strings.Contains(out, "RSA-2048") { + t.Fatalf("list: %v\n%s", err, out) + } + out, err = ts.run(t, "scep", "enrollments", "approve", "--id", "e1") + if err != nil || f.approve != "e1" || !strings.Contains(out, "serial 1a2b") { + t.Fatalf("approve: %v\n%s", err, out) + } + out, err = ts.run(t, "scep", "enrollments", "reject", "--id", "e1", "--reason", "unknown device") + if err != nil || f.reject.GetReason() != "unknown device" || !strings.Contains(out, "Rejected enrolment e1") { + t.Fatalf("reject: %v\n%s", err, out) + } +} + +func TestSCEPVerbsWhenSCEPIsOff(t *testing.T) { + ts := startScepServer(t, nil) + out, err := ts.run(t, "scep", "challenge", "mint") + if err == nil || !strings.Contains(err.Error(), "SCEP is not running") { + t.Fatalf("mint with SCEP off = %v (out=%s), want the not-running refusal", err, out) + } +} diff --git a/docs/acme.md b/docs/acme.md index e79a897..c4d2139 100644 --- a/docs/acme.md +++ b/docs/acme.md @@ -163,7 +163,7 @@ off, and leaving the block out of an `ApplyConfig` keeps what the node has. `cryptosctl status` shows what is stored against what is running until then: ```text -Protocols: ACME on (not running, reboot pending), EST off +Protocols: ACME on (not running, reboot pending), EST off, SCEP off Reboot: pending (the stored config changes take effect at the next boot) ``` diff --git a/docs/est.md b/docs/est.md index 83c0138..d33b384 100644 --- a/docs/est.md +++ b/docs/est.md @@ -3,7 +3,7 @@ EST is the enrolment protocol for clients that renew with the certificate they already hold. That is the capability neither of the other two paths offers: ACME proves control of a name but needs a challenge the client can answer, and -SCEP leans on a shared secret that has to live on every device forever. +SCEP needs an admin to mint a one-time challenge for every initial enrolment. Use EST where a fleet is provisioned once and then renews itself for years: network gear, appliances, anything with a factory or first-boot identity. @@ -225,7 +225,7 @@ off, and leaving the block out of an `ApplyConfig` keeps what the node has. `cryptosctl status` shows what is stored against what is running until then: ```text -Protocols: ACME off, EST on (not running, reboot pending) +Protocols: ACME off, EST on (not running, reboot pending), SCEP off Reboot: pending (the stored config changes take effect at the next boot) ``` diff --git a/docs/scep.md b/docs/scep.md new file mode 100644 index 0000000..a773e29 --- /dev/null +++ b/docs/scep.md @@ -0,0 +1,179 @@ +# SCEP enrolment (RFC 8894) + +SCEP is the enrolment protocol network equipment speaks. Cisco IOS and IOS-XE +trustpoints, and most switch, router, firewall and VPN platforms, cannot run +an ACME client, so SCEP is their only automated way to get a certificate and +to renew it. + +> Scope: `GetCACaps`, `GetCACert`, and `PKIOperation` with `PKCSReq`, +> `RenewalReq`, `CertPoll` (`GetCertInitial`), `GetCert` and `GetCRL`. +> `GetNextCACert` is not offered. + +## How a device is authorized + +| | Authorized by | Names it may request | +|---|---|---| +| Initial enrolment (`PKCSReq`) | A one-time challenge an admin mints for that device | Inside `allowed_identifier_suffixes`, and inside the challenge's bound names when it has any | +| Renewal (`RenewalReq`) | The certificate the device holds, issued by this CA, current and not revoked | Exactly the names on that certificate | + +There is no static shared challenge. One secret configured on every device +would let anyone who lifts it from one device enrol as any other, so each +initial enrolment needs its own challenge: + +- only its SHA-256 is stored, and it is never logged or read back; +- it is consumed by the first request that presents it, whether that request + is issued, refused or queued; +- it expires (one hour by default, seven days at most); +- it names the profile the certificate is issued from; +- it can be bound to the names the device may ask for. + +A `PKCSReq` signed by a current certificate from this CA is treated as a +renewal, with the same rules as `RenewalReq`. That is how renewal worked +before RFC 8894, and how `sscep` and Cisco IOS auto-enrol rollover still send +it. + +## Switching it on + +SCEP is off unless the `pki.scep` block is present, and a Root refuses it. + +```yaml +pki: + profiles: + - name: cisco-device + key_alg: ECDSA-P384 + validity_days: 365 + key_usage: [digital_signature, key_encipherment] + ext_key_usage: [client_auth, server_auth] + scep: + http_port: 0 # 0 shares the CRL/OCSP port (80 by default) + profiles: + - profile: cisco-device + min_rsa_key_bits: 2048 # Cisco trustpoints cannot hold a larger key + require_approval: false + allowed_identifier_suffixes: [example.com] + ra: + validity_days: 365 + rotation_overlap_days: 30 +``` + +> [!WARNING] +> Switching SCEP on or off, or changing any `pki.scep` field, takes effect at +> the next boot. `cryptosctl config apply` stores the change and answers +> `requires_reboot: true`; the listener only starts at boot. Plan the change +> for a maintenance window and reboot the node (`cryptosctl reboot`). + +`pki.scep` travels in the machine config, so `config apply`, the maintenance +install and the Fleet Manager all carry it. `cryptosctl config apply` sends +the whole config, so a YAML file without the block switches SCEP off. An API +client that leaves `pki.scep` out of the message entirely keeps the stored +block; it switches SCEP off with `enabled: false`. + +`cryptosctl status -o json` reports SCEP under `protocols`: `configured` is +the stored config, `running` is this boot, and `reboot_pending` is set when +they differ. + +### Profiles and key sizes + +Each entry in `profiles` names a non-CA profile in `pki.profiles`. Its +`min_rsa_key_bits` is the smallest RSA key it certifies over SCEP: + +- `0` means 3072, the node-wide floor; +- `2048` is the lowest allowed, for devices that cannot do better; +- stronger RSA keys and ECDSA P-384 keys are always accepted. + +The floor applies to SCEP only. The node's own CA, RA and listener keys stay +RSA 3072 or larger, or ECDSA P-384, and every other issuance path keeps the +node-wide floor. + +> [!CAUTION] +> A device's key must be RSA. The reply is encrypted to the key that signed +> the request, and SCEP key transport is RSA. A request signed with an ECDSA +> key is refused with `badAlg`. + +### Transport, paths and the RA + +SCEP is plain HTTP, as the RFC intends: the CMS envelope carries +confidentiality and integrity. It answers on `/cgi-bin/pkiclient.exe` (the +path Cisco appends) and `/scep`. With `http_port: 0` it shares the CRL/OCSP +listener when that one runs (`revocation_base_url` set), so +`http:///cgi-bin/pkiclient.exe` works as written; otherwise it binds its +own listener on that port. A non-zero `http_port` always gets its own +listener. + +Requests are encrypted to, and replies signed by, an RA certificate the node +mints from its own CA: RSA 3072, key usage `digitalSignature` and +`keyEncipherment`. The CA key only ever signs certificates. `GetCACert` +returns the CA certificate and the RA certificate +(`application/x-x509-ca-ra-cert`), so the device's trustpoint needs RA mode. + +The RA is valid for `validity_days` (365 at most) and is stored on the +encrypted state partition. `rotation_overlap_days` before it expires, the node +mints its successor. From then on `GetCACert` offers the new RA, and requests +encrypted to either RA still decrypt, so a device that cached the old one +keeps enrolling. The expired RA is dropped. The RA key is a software key on the +state partition even on a TPM node. + +`GetCACaps` advertises `AES`, `POSTPKIOperation`, `Renewal`, `SHA-256` and +`SHA-512`. DES, 3DES, MD5 and SHA-1 are refused. + +## Minting a challenge + +```sh +cryptosctl scep challenge mint --profile cisco-device --ttl 30m --name switch01.example.com +``` + +The challenge is printed once. The node cannot show it again, so a lost +challenge is revoked and a new one minted. `--name` (repeatable) binds the +challenge: the request may then carry only those names. `--profile` can be +left out when only one SCEP profile is configured. + +```sh +cryptosctl scep challenge list +cryptosctl scep challenge revoke --id +``` + +`list` shows the challenges that are still usable, never the challenge itself. + +## Holding enrolments for approval + +With `require_approval: true` on a profile, every initial enrolment from it is +answered `PENDING` and waits. The device polls (`CertPoll`) until an admin +decides: + +```sh +cryptosctl scep enrollments list +cryptosctl scep enrollments approve --id +cryptosctl scep enrollments reject --id --reason "not one of ours" +``` + +Approval checks the request again against the config in force; one that no +longer passes stays queued to be rejected. Renewals are never queued. + +## Retries and polling + +Every transaction is recorded. A retransmitted request, or a `CertPoll`, gets +the certificate already issued for its transaction, never a second one, and a +transaction ID reused with a different key is refused. A request that fails is +not recorded, so the device can try again with a fresh challenge. + +## Every decision is audited + +Each issuance, refusal and queued request is written to the audit log with +the transaction ID, the profile, the serial and the ID of the challenge that +was consumed. Minting, revoking, approving and rejecting are audited as the +admin RPCs they are. A challenge never appears in the log. + +## Limits worth knowing before you deploy + +- **DNS names only.** Every DNS name in the request, and the subject common + name, must fall inside `allowed_identifier_suffixes`. IP, email and URI + subject alternative names are refused rather than dropped. +- **Wildcards are refused.** A challenge establishes nothing about a label + space. +- **`GetCert` and `GetCRL`** need a request signed by a current certificate + from this CA. +- **The RA key is a software key** on the encrypted state partition, not a + TPM key. + +For the Cisco IOS and IOS-XE steps, see the docs site page "Enrol devices with +SCEP". diff --git a/internal/config/config.go b/internal/config/config.go index 1572004..f631410 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -303,6 +303,9 @@ type PKI struct { // // Like ACME, it is carried as Pki.est under the same rules. EST *EST `yaml:"est"` + // SCEP configures the RFC 8894 enrolment endpoint (see SCEP). Nil is + // off, the default. Unlike ACME and EST it is carried in the proto. + SCEP *SCEP `yaml:"scep"` } // EST configures the node's RFC 7030 server. @@ -607,6 +610,9 @@ func (c *Config) validate(keptSecrets bool) error { if err := validateEST(c.PKI.EST, c.PKI.Profiles, keptSecrets); err != nil { return err } + if err := validateSCEP(c.Role.Kind, c.PKI.SCEP, c.PKI.Profiles); err != nil { + return err + } if err := validateParent(c.Role.Kind, c.PKI.Parent); err != nil { return err } @@ -1144,6 +1150,7 @@ func FromProto(pb *cryptosv1.MachineConfig) (*Config, error) { } c.PKI.ACME = acmeFromProto(pb.Pki.Acme) c.PKI.EST = estFromProto(pb.Pki.Est) + c.PKI.SCEP = scepFromProto(pb.Pki.Scep) } if pb.Install != nil { c.Install.Disk = pb.Install.Disk @@ -1191,6 +1198,7 @@ func (c *Config) ToProto() *cryptosv1.MachineConfig { Acme: acmeToProto(c.PKI.ACME), Est: estToProto(c.PKI.EST), AllowUnsyncedClock: c.PKI.AllowUnsyncedClock, + Scep: scepToProto(c.PKI.SCEP), } if c.PKI.Parent != nil { pki.Parent = &cryptosv1.Parent{ diff --git a/internal/config/protocols.go b/internal/config/protocols.go index db9c8ea..4876507 100644 --- a/internal/config/protocols.go +++ b/internal/config/protocols.go @@ -25,7 +25,7 @@ import ( cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" ) -// The enrolment protocol blocks (pki.acme, pki.est) travel in MachineConfig +// The enrolment protocol blocks (pki.acme, pki.est, pki.scep) travel in MachineConfig // under one set of rules, shared by every protocol that gains a block: // // - In the proto, a block carries an explicit enabled flag. ToProto always @@ -104,6 +104,7 @@ func FromProtoOver(pb *cryptosv1.MachineConfig, prev *Config) (*Config, error) { return nil, err } } + c.KeepStoredSCEPWhenAbsent(pb, prev) return c, nil } diff --git a/internal/config/scep.go b/internal/config/scep.go new file mode 100644 index 0000000..a45f6f3 --- /dev/null +++ b/internal/config/scep.go @@ -0,0 +1,246 @@ +package config + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "errors" + "fmt" + "strings" + "time" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// SCEP configures the node's RFC 8894 server (pki.scep). Nil is off, the +// default. It follows the protocol-block rules of the api's MachineConfig: an +// explicit enabled flag on the wire, a Root refuses it, and every change takes +// effect at the next boot. +// +// There is no static challenge setting on purpose. Initial enrolment is +// authorized by one-time challenges an admin mints over the API, and renewal +// by the certificate the device already holds; one secret configured for every +// device would let anyone who lifts it from one device enrol as any other. +type SCEP struct { + // HTTPPort is the TCP port the SCEP listener binds. Zero means the node + // default, which shares the plain-HTTP CRL/OCSP listener's port. + HTTPPort uint32 `yaml:"http_port"` + // Profiles are the certificate profiles SCEP issues from. A challenge + // names one; a renewal reuses the profile of the certificate it renews. + Profiles []SCEPProfile `yaml:"profiles"` + // AllowedIdentifierSuffixes bounds the names an initial enrolment may + // request: every DNS name and the subject common name must equal, or be + // a subdomain of, one of these. Required, because a challenge proves + // nothing about control of a name. Renewal names stay pinned to the + // certificate being renewed. + AllowedIdentifierSuffixes []string `yaml:"allowed_identifier_suffixes"` + // RA tunes the RA certificate that decrypts requests and signs replies. + RA SCEPRA `yaml:"ra"` +} + +// SCEPProfile is one certificate profile SCEP may issue from. +type SCEPProfile struct { + // Profile names a non-CA profile in pki.profiles. + Profile string `yaml:"profile"` + // MinRSAKeyBits is the smallest RSA subject key accepted over SCEP for + // this profile. Zero means the node-wide 3072; 2048 is the lowest allowed, + // for devices such as Cisco IOS and IOS-XE trustpoints that cannot hold a + // larger key. Stronger RSA and ECDSA P-384 keys are always accepted. + MinRSAKeyBits uint32 `yaml:"min_rsa_key_bits"` + // RequireApproval holds every initial enrolment for an admin decision + // (PENDING). Renewals are never held. + RequireApproval bool `yaml:"require_approval"` +} + +// SCEPRA tunes the RA certificate. The key is always RSA 3072. +type SCEPRA struct { + // ValidityDays is the RA certificate lifetime. Zero means 365, which is + // also the most. + ValidityDays uint32 `yaml:"validity_days"` + // RotationOverlapDays is how long before expiry the successor is minted; + // both RAs decrypt during the overlap. Zero means 30. + RotationOverlapDays uint32 `yaml:"rotation_overlap_days"` +} + +const ( + scepDefaultRSAFloor = 3072 + scepLowestRSAFloor = 2048 + scepHighestRSAFloor = 16384 + scepDefaultRAValidity = 365 + scepMaxRAValidity = 365 + scepDefaultRAOverlap = 30 + maxTCPPort = 65535 + scepAllowlistFieldLabel = "config: pki.scep.allowed_identifier_suffixes" +) + +// Profile returns the SCEP profile with the given name, or nil. +func (s *SCEP) Profile(name string) *SCEPProfile { + if s == nil { + return nil + } + for i := range s.Profiles { + if s.Profiles[i].Profile == name { + return &s.Profiles[i] + } + } + return nil +} + +// RSAFloor is the effective RSA subject-key floor in bits. +func (p SCEPProfile) RSAFloor() int { + if p.MinRSAKeyBits == 0 { + return scepDefaultRSAFloor + } + return int(p.MinRSAKeyBits) +} + +// Validity is the effective RA certificate lifetime. +func (r SCEPRA) Validity() time.Duration { + d := r.ValidityDays + if d == 0 { + d = scepDefaultRAValidity + } + return time.Duration(d) * 24 * time.Hour +} + +// Overlap is the effective RA rotation overlap. +func (r SCEPRA) Overlap() time.Duration { + d := r.RotationOverlapDays + if d == 0 { + d = scepDefaultRAOverlap + } + return time.Duration(d) * 24 * time.Hour +} + +// validateSCEP enforces the pki.scep rules. A nil block is off and passes. +func validateSCEP(role RoleKind, s *SCEP, profiles []CertificateProfile) error { + if s == nil { + return nil + } + if role == RoleRoot { + return errors.New("config: pki.scep: must not be set on a root node; a root serves no " + + "enrolment protocol, so serve SCEP from an intermediate or issuing node") + } + if s.HTTPPort > maxTCPPort { + return fmt.Errorf("config: pki.scep.http_port: %d is not a TCP port", s.HTTPPort) + } + if len(s.Profiles) == 0 { + return errors.New("config: pki.scep.profiles: at least one profile is required when pki.scep is set") + } + seen := make(map[string]bool, len(s.Profiles)) + for i, p := range s.Profiles { + field := fmt.Sprintf("config: pki.scep.profiles[%d]", i) + if p.Profile == "" { + return fmt.Errorf("%s.profile: required", field) + } + if seen[p.Profile] { + return fmt.Errorf("%s.profile: %q appears more than once", field, p.Profile) + } + seen[p.Profile] = true + var prof *CertificateProfile + for j := range profiles { + if profiles[j].Name == p.Profile { + prof = &profiles[j] + break + } + } + if prof == nil { + return fmt.Errorf("%s.profile: no profile named %q in pki.profiles", field, p.Profile) + } + if prof.BasicConstraints.IsCA { + return fmt.Errorf("%s.profile: %q is a CA profile; SCEP issues end-entity certificates only", field, p.Profile) + } + if p.MinRSAKeyBits != 0 && (p.MinRSAKeyBits < scepLowestRSAFloor || p.MinRSAKeyBits > scepHighestRSAFloor) { + return fmt.Errorf("%s.min_rsa_key_bits: %d is out of range; use 0 for the node-wide %d, "+ + "or a value from %d to %d", field, p.MinRSAKeyBits, scepDefaultRSAFloor, scepLowestRSAFloor, scepHighestRSAFloor) + } + } + if len(s.AllowedIdentifierSuffixes) == 0 { + return errors.New(scepAllowlistFieldLabel + ": required when pki.scep is set; " + + "a one-time challenge proves nothing about control of a name") + } + for i, suffix := range s.AllowedIdentifierSuffixes { + trimmed := strings.TrimSpace(suffix) + if trimmed == "" || strings.ContainsAny(trimmed, "*/ ") { + return fmt.Errorf("%s[%d]: %q is not a DNS suffix", scepAllowlistFieldLabel, i, suffix) + } + } + if s.RA.ValidityDays > scepMaxRAValidity { + return fmt.Errorf("config: pki.scep.ra.validity_days: %d is more than the %d-day maximum", s.RA.ValidityDays, scepMaxRAValidity) + } + if s.RA.Overlap() >= s.RA.Validity() { + return fmt.Errorf("config: pki.scep.ra.rotation_overlap_days: the overlap (%s) must be shorter than the RA validity (%s)", + s.RA.Overlap(), s.RA.Validity()) + } + return nil +} + +// KeepStoredSCEPWhenAbsent applies the protocol-block rule for an apply that +// leaves pki.scep out: the stored block is kept, so a client that predates the +// block cannot switch SCEP off by omission. An explicit enabled=false has +// already become nil in FromProto and stays off. +func (c *Config) KeepStoredSCEPWhenAbsent(pb *cryptosv1.MachineConfig, prev *Config) { + if prev == nil || pb.GetPki().GetScep() != nil { + return + } + c.PKI.SCEP = prev.PKI.SCEP +} + +func scepToProto(s *SCEP) *cryptosv1.Scep { + if s == nil { + return &cryptosv1.Scep{Enabled: false} + } + pb := &cryptosv1.Scep{ + Enabled: true, + HttpPort: s.HTTPPort, + AllowedIdentifierSuffixes: s.AllowedIdentifierSuffixes, + } + for _, p := range s.Profiles { + pb.Profiles = append(pb.Profiles, &cryptosv1.ScepProfile{ + Profile: p.Profile, + MinRsaKeyBits: p.MinRSAKeyBits, + RequireApproval: p.RequireApproval, + }) + } + if s.RA != (SCEPRA{}) { + pb.Ra = &cryptosv1.ScepRa{ValidityDays: s.RA.ValidityDays, RotationOverlapDays: s.RA.RotationOverlapDays} + } + return pb +} + +func scepFromProto(pb *cryptosv1.Scep) *SCEP { + if !pb.GetEnabled() { + return nil + } + s := &SCEP{ + HTTPPort: pb.GetHttpPort(), + AllowedIdentifierSuffixes: pb.GetAllowedIdentifierSuffixes(), + RA: SCEPRA{ + ValidityDays: pb.GetRa().GetValidityDays(), + RotationOverlapDays: pb.GetRa().GetRotationOverlapDays(), + }, + } + for _, p := range pb.GetProfiles() { + s.Profiles = append(s.Profiles, SCEPProfile{ + Profile: p.GetProfile(), + MinRSAKeyBits: p.GetMinRsaKeyBits(), + RequireApproval: p.GetRequireApproval(), + }) + } + return s +} diff --git a/internal/config/scep_test.go b/internal/config/scep_test.go new file mode 100644 index 0000000..62fe726 --- /dev/null +++ b/internal/config/scep_test.go @@ -0,0 +1,260 @@ +package config + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "reflect" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +func scepTestProfiles() []CertificateProfile { + return append(acmeTestProfiles(), CertificateProfile{ + Name: "cisco-device", KeyAlg: RootKeyECDSAP384, ValidityDays: 365, + KeyUsage: []string{"digital_signature", "key_encipherment"}, ExtKeyUsage: []string{"client_auth", "server_auth"}, + }) +} + +// validSCEP is a block that must pass, so each rejection below differs from a +// passing config in exactly one way. +func validSCEP() *SCEP { + return &SCEP{ + Profiles: []SCEPProfile{ + {Profile: "cisco-device", MinRSAKeyBits: 2048}, + {Profile: "leaf-server", RequireApproval: true}, + }, + AllowedIdentifierSuffixes: []string{"example.com"}, + } +} + +// issuingConfig is validYAML turned into an issuing node, the role that +// serves enrolment protocols. +func issuingConfig(t *testing.T) *Config { + t.Helper() + cfg, err := Parse(validYAML(t)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cfg.Role.Kind = RoleIssuing + cfg.PKI.Parent = &Parent{CACertSHA256: strings.Repeat("ab", 32)} + cfg.PKI.Profiles = scepTestProfiles() + return cfg +} + +func TestValidateSCEPAccepts(t *testing.T) { + cfg := issuingConfig(t) + cfg.PKI.SCEP = validSCEP() + if err := cfg.Validate(); err != nil { + t.Fatalf("a valid SCEP block was rejected: %v", err) + } +} + +func TestValidateSCEPAbsentIsFine(t *testing.T) { + cfg := issuingConfig(t) + if cfg.PKI.SCEP != nil { + t.Fatal("SCEP must default to off") + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +// Root Mode shuts every service-plane listener, so a Root cannot carry SCEP. +func TestValidateSCEPRefusedOnRoot(t *testing.T) { + cfg, err := Parse(validYAML(t)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cfg.PKI.Profiles = scepTestProfiles() + cfg.PKI.SCEP = validSCEP() + err = cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "pki.scep") || !strings.Contains(err.Error(), "root") { + t.Fatalf("Validate on a Root with SCEP = %v, want a pki.scep root refusal", err) + } +} + +func TestValidateSCEPRejections(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*SCEP) + expect string + }{ + {"no profiles", func(s *SCEP) { s.Profiles = nil }, "pki.scep.profiles"}, + {"blank profile name", func(s *SCEP) { s.Profiles[0].Profile = "" }, "pki.scep.profiles[0].profile"}, + {"unknown profile", func(s *SCEP) { s.Profiles[0].Profile = "nope" }, "no profile named"}, + {"ca profile", func(s *SCEP) { s.Profiles[0].Profile = "sub-ca" }, "CA profile"}, + {"duplicate profile", func(s *SCEP) { s.Profiles[1].Profile = "cisco-device" }, "appears more than once"}, + {"floor below 2048", func(s *SCEP) { s.Profiles[0].MinRSAKeyBits = 1024 }, "min_rsa_key_bits"}, + {"floor absurdly high", func(s *SCEP) { s.Profiles[0].MinRSAKeyBits = 65536 }, "min_rsa_key_bits"}, + {"no allowlist", func(s *SCEP) { s.AllowedIdentifierSuffixes = nil }, "allowed_identifier_suffixes"}, + {"blank suffix", func(s *SCEP) { s.AllowedIdentifierSuffixes = []string{" "} }, "allowed_identifier_suffixes[0]"}, + {"wildcard suffix", func(s *SCEP) { s.AllowedIdentifierSuffixes = []string{"*.example.com"} }, "allowed_identifier_suffixes[0]"}, + {"ra validity over a year", func(s *SCEP) { s.RA.ValidityDays = 400 }, "pki.scep.ra.validity_days"}, + {"overlap as long as the validity", func(s *SCEP) { s.RA.ValidityDays = 30; s.RA.RotationOverlapDays = 30 }, "rotation_overlap_days"}, + {"overlap longer than the default validity", func(s *SCEP) { s.RA.RotationOverlapDays = 365 }, "rotation_overlap_days"}, + {"port out of range", func(s *SCEP) { s.HTTPPort = 70000 }, "http_port"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := issuingConfig(t) + s := validSCEP() + tc.mutate(s) + cfg.PKI.SCEP = s + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tc.expect) { + t.Fatalf("Validate = %v, want an error mentioning %q", err, tc.expect) + } + }) + } +} + +func TestSCEPDefaults(t *testing.T) { + var p SCEPProfile + if got := p.RSAFloor(); got != 3072 { + t.Fatalf("zero min_rsa_key_bits floor = %d, want 3072", got) + } + p.MinRSAKeyBits = 2048 + if got := p.RSAFloor(); got != 2048 { + t.Fatalf("floor = %d, want 2048", got) + } + var ra SCEPRA + if ra.Validity() != 365*24*time.Hour || ra.Overlap() != 30*24*time.Hour { + t.Fatalf("RA defaults = %v / %v, want 365 days / 30 days", ra.Validity(), ra.Overlap()) + } + s := validSCEP() + if s.Profile("leaf-server") == nil || s.Profile("nope") != nil { + t.Fatal("Profile lookup by name is wrong") + } +} + +func TestSCEPParsesFromYAML(t *testing.T) { + raw := string(validYAML(t)) + ` scep: + http_port: 8080 + profiles: + - profile: cisco-device + min_rsa_key_bits: 2048 + require_approval: true + allowed_identifier_suffixes: [example.com] + ra: + validity_days: 180 + rotation_overlap_days: 14 +` + if _, err := Parse([]byte(raw)); err == nil { + t.Fatal("a Root with pki.scep parsed; Parse validates and must refuse it") + } + cfg := &Config{} + dec := yaml.NewDecoder(strings.NewReader(raw)) + dec.KnownFields(true) + if err := dec.Decode(cfg); err != nil { + t.Fatalf("decode: %v", err) + } + want := &SCEP{ + HTTPPort: 8080, + Profiles: []SCEPProfile{{Profile: "cisco-device", MinRSAKeyBits: 2048, RequireApproval: true}}, + AllowedIdentifierSuffixes: []string{"example.com"}, + RA: SCEPRA{ValidityDays: 180, RotationOverlapDays: 14}, + } + if !reflect.DeepEqual(cfg.PKI.SCEP, want) { + t.Fatalf("parsed SCEP = %+v, want %+v", cfg.PKI.SCEP, want) + } +} + +func TestSCEPProtoRoundTrip(t *testing.T) { + cfg := issuingConfig(t) + cfg.PKI.SCEP = validSCEP() + cfg.PKI.SCEP.HTTPPort = 8080 + cfg.PKI.SCEP.RA = SCEPRA{ValidityDays: 180, RotationOverlapDays: 14} + + pb := cfg.ToProto() + if !pb.GetPki().GetScep().GetEnabled() { + t.Fatal("ToProto of a configured SCEP block must send enabled=true") + } + back, err := FromProto(pb) + if err != nil { + t.Fatalf("FromProto: %v", err) + } + if !reflect.DeepEqual(back.PKI.SCEP, cfg.PKI.SCEP) { + t.Fatalf("round trip = %+v, want %+v", back.PKI.SCEP, cfg.PKI.SCEP) + } +} + +// An off protocol is explicit on the wire, and enabled=false or a missing +// block both come back as nil, this package's single spelling of "off". +func TestSCEPProtoOff(t *testing.T) { + cfg := issuingConfig(t) + pb := cfg.ToProto() + if pb.GetPki().GetScep() == nil || pb.GetPki().GetScep().GetEnabled() { + t.Fatalf("ToProto of an off SCEP = %v, want an explicit enabled=false block", pb.GetPki().GetScep()) + } + pb.Pki.Scep = &cryptosv1.Scep{Enabled: false, Profiles: []*cryptosv1.ScepProfile{{Profile: "cisco-device"}}} + back, err := FromProto(pb) + if err != nil { + t.Fatalf("FromProto: %v", err) + } + if back.PKI.SCEP != nil { + t.Fatalf("enabled=false came back as %+v, want nil", back.PKI.SCEP) + } +} + +func TestKeepStoredSCEPWhenAbsent(t *testing.T) { + prev := issuingConfig(t) + prev.PKI.SCEP = validSCEP() + + pb := issuingConfig(t).ToProto() + pb.Pki.Scep = nil + next, err := FromProto(pb) + if err != nil { + t.Fatalf("FromProto: %v", err) + } + next.KeepStoredSCEPWhenAbsent(pb, prev) + if !reflect.DeepEqual(next.PKI.SCEP, prev.PKI.SCEP) { + t.Fatalf("an apply without a scep block dropped the stored one: got %+v", next.PKI.SCEP) + } + + pb.Pki.Scep = &cryptosv1.Scep{Enabled: false} + next, err = FromProto(pb) + if err != nil { + t.Fatalf("FromProto: %v", err) + } + next.KeepStoredSCEPWhenAbsent(pb, prev) + if next.PKI.SCEP != nil { + t.Fatal("enabled=false must switch SCEP off, not keep the stored block") + } + + next.KeepStoredSCEPWhenAbsent(pb, nil) +} + +func TestSCEPChangeNeedsReboot(t *testing.T) { + old := issuingConfig(t) + on := issuingConfig(t) + on.PKI.SCEP = validSCEP() + if !NeedsReboot(old, on) { + t.Fatal("switching SCEP on must need a reboot: the listener starts only at boot") + } + changed := issuingConfig(t) + changed.PKI.SCEP = validSCEP() + changed.PKI.SCEP.Profiles[0].MinRSAKeyBits = 3072 + if !NeedsReboot(on, changed) { + t.Fatal("changing a SCEP profile floor must need a reboot") + } +} diff --git a/internal/grpc/scep.go b/internal/grpc/scep.go new file mode 100644 index 0000000..8cdbb38 --- /dev/null +++ b/internal/grpc/scep.go @@ -0,0 +1,167 @@ +package grpc + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/x509" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// LocalSocketActor is the created_by_cn recorded for a challenge minted over +// the local UNIX socket, which carries no client certificate. +const LocalSocketActor = "local-socket" + +// ScepAdmin backs the SCEP administration RPCs: the one-time enrolment +// challenges and the approval queue. It is wired only when the SCEP listener +// started this boot; otherwise it is nil and every SCEP RPC answers +// FailedPrecondition, which covers maintenance mode, a Root and a node with +// SCEP switched off. Implementations return gRPC status errors. +// Implemented by internal/scep. +type ScepAdmin interface { + MintScepChallenge(ctx context.Context, req *cryptosv1.MintScepChallengeRequest, actorCN string) (*cryptosv1.MintScepChallengeResponse, error) + ListScepChallenges(ctx context.Context, req *cryptosv1.ListScepChallengesRequest) (*cryptosv1.ListScepChallengesResponse, error) + RevokeScepChallenge(ctx context.Context, req *cryptosv1.RevokeScepChallengeRequest) (*cryptosv1.RevokeScepChallengeResponse, error) + ListScepEnrollments(ctx context.Context, req *cryptosv1.ListScepEnrollmentsRequest) (*cryptosv1.ListScepEnrollmentsResponse, error) + ApproveScepEnrollment(ctx context.Context, req *cryptosv1.ApproveScepEnrollmentRequest) (*cryptosv1.ApproveScepEnrollmentResponse, error) + RejectScepEnrollment(ctx context.Context, req *cryptosv1.RejectScepEnrollmentRequest) (*cryptosv1.RejectScepEnrollmentResponse, error) +} + +var errScepNotRunning = status.Error(codes.FailedPrecondition, + "SCEP is not running on this node this boot: it is off in the machine config, the node is a Root, or the node is in maintenance mode") + +// scepGate refuses a SCEP RPC before any state is read: first when SCEP is +// not running, then when the caller is not the bootstrap admin. +func (s *Server) scepGate(ctx context.Context) error { + if s.cfg.ScepAdmin == nil { + return errScepNotRunning + } + return AuthorizeAdmin(ctx, s.cfg.Trust) +} + +// MintScepChallenge handles cryptos.v1.NodeService/MintScepChallenge. The +// challenge password goes back to the caller and nowhere else: the audit entry +// records its id, profile and bound names only. +func (s *Server) MintScepChallenge(ctx context.Context, req *cryptosv1.MintScepChallengeRequest) (*cryptosv1.MintScepChallengeResponse, error) { + if err := s.scepGate(ctx); err != nil { + return nil, err + } + resp, err := s.cfg.ScepAdmin.MintScepChallenge(ctx, req, callerCN(ctx)) + if err != nil { + return nil, err + } + ch := resp.GetChallenge() + setAuditDetail(ctx, "challenge_id", ch.GetId()) + setAuditDetail(ctx, "profile", ch.GetProfile()) + if len(ch.GetBoundNames()) > 0 { + setAuditDetail(ctx, "bound_names", strings.Join(ch.GetBoundNames(), ",")) + } + if ch.GetExpiresAt() != nil { + setAuditDetail(ctx, "expires_at", ch.GetExpiresAt().AsTime().UTC().Format("2006-01-02T15:04:05Z")) + } + return resp, nil +} + +// ListScepChallenges handles cryptos.v1.NodeService/ListScepChallenges. +func (s *Server) ListScepChallenges(ctx context.Context, req *cryptosv1.ListScepChallengesRequest) (*cryptosv1.ListScepChallengesResponse, error) { + if err := s.scepGate(ctx); err != nil { + return nil, err + } + return s.cfg.ScepAdmin.ListScepChallenges(ctx, req) +} + +// RevokeScepChallenge handles cryptos.v1.NodeService/RevokeScepChallenge. +func (s *Server) RevokeScepChallenge(ctx context.Context, req *cryptosv1.RevokeScepChallengeRequest) (*cryptosv1.RevokeScepChallengeResponse, error) { + if err := s.scepGate(ctx); err != nil { + return nil, err + } + setAuditDetail(ctx, "challenge_id", req.GetId()) + return s.cfg.ScepAdmin.RevokeScepChallenge(ctx, req) +} + +// ListScepEnrollments handles cryptos.v1.NodeService/ListScepEnrollments. +func (s *Server) ListScepEnrollments(ctx context.Context, req *cryptosv1.ListScepEnrollmentsRequest) (*cryptosv1.ListScepEnrollmentsResponse, error) { + if err := s.scepGate(ctx); err != nil { + return nil, err + } + return s.cfg.ScepAdmin.ListScepEnrollments(ctx, req) +} + +// ApproveScepEnrollment handles cryptos.v1.NodeService/ApproveScepEnrollment. +func (s *Server) ApproveScepEnrollment(ctx context.Context, req *cryptosv1.ApproveScepEnrollmentRequest) (*cryptosv1.ApproveScepEnrollmentResponse, error) { + if err := s.scepGate(ctx); err != nil { + return nil, err + } + setAuditDetail(ctx, "enrollment_id", req.GetId()) + resp, err := s.cfg.ScepAdmin.ApproveScepEnrollment(ctx, req) + if err != nil { + return nil, err + } + setAuditDetail(ctx, "profile", resp.GetEnrollment().GetProfile()) + setAuditDetail(ctx, "serial_hex", resp.GetSerialHex()) + return resp, nil +} + +// RejectScepEnrollment handles cryptos.v1.NodeService/RejectScepEnrollment. +// The reason goes to the audit log only; the client learns just a failInfo. +func (s *Server) RejectScepEnrollment(ctx context.Context, req *cryptosv1.RejectScepEnrollmentRequest) (*cryptosv1.RejectScepEnrollmentResponse, error) { + if err := s.scepGate(ctx); err != nil { + return nil, err + } + setAuditDetail(ctx, "enrollment_id", req.GetId()) + if req.GetReason() != "" { + setAuditDetail(ctx, "reason", req.GetReason()) + } + resp, err := s.cfg.ScepAdmin.RejectScepEnrollment(ctx, req) + if err != nil { + return nil, err + } + setAuditDetail(ctx, "profile", resp.GetEnrollment().GetProfile()) + return resp, nil +} + +// callerCN is the common name of the caller's client certificate, or +// LocalSocketActor on the local socket. +func callerCN(ctx context.Context) string { + p, ok := peer.FromContext(ctx) + if !ok || p == nil { + return LocalSocketActor + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok { + return LocalSocketActor + } + var leaf *x509.Certificate + if vc := tlsInfo.State.VerifiedChains; len(vc) > 0 && len(vc[0]) > 0 { + leaf = vc[0][0] + } else if pc := tlsInfo.State.PeerCertificates; len(pc) > 0 { + leaf = pc[0] + } + if leaf == nil { + return "" + } + return leaf.Subject.CommonName +} diff --git a/internal/grpc/scep_test.go b/internal/grpc/scep_test.go new file mode 100644 index 0000000..93f4cc4 --- /dev/null +++ b/internal/grpc/scep_test.go @@ -0,0 +1,231 @@ +package grpc + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/x509" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +type mockScepAdmin struct { + calls []string + actorCN string + mintReq *cryptosv1.MintScepChallengeRequest + err error +} + +func (m *mockScepAdmin) MintScepChallenge(_ context.Context, req *cryptosv1.MintScepChallengeRequest, actorCN string) (*cryptosv1.MintScepChallengeResponse, error) { + m.calls = append(m.calls, "mint") + m.mintReq, m.actorCN = req, actorCN + if m.err != nil { + return nil, m.err + } + return &cryptosv1.MintScepChallengeResponse{ + ChallengePassword: "s3cret-challenge-value", + Challenge: &cryptosv1.ScepChallenge{Id: "ch1", Profile: req.GetProfile(), BoundNames: req.GetBoundNames(), CreatedByCn: actorCN}, + }, nil +} + +func (m *mockScepAdmin) ListScepChallenges(context.Context, *cryptosv1.ListScepChallengesRequest) (*cryptosv1.ListScepChallengesResponse, error) { + m.calls = append(m.calls, "list-challenges") + return &cryptosv1.ListScepChallengesResponse{}, m.err +} + +func (m *mockScepAdmin) RevokeScepChallenge(_ context.Context, req *cryptosv1.RevokeScepChallengeRequest) (*cryptosv1.RevokeScepChallengeResponse, error) { + m.calls = append(m.calls, "revoke-challenge") + if m.err != nil { + return nil, m.err + } + return &cryptosv1.RevokeScepChallengeResponse{Challenge: &cryptosv1.ScepChallenge{Id: req.GetId()}}, nil +} + +func (m *mockScepAdmin) ListScepEnrollments(context.Context, *cryptosv1.ListScepEnrollmentsRequest) (*cryptosv1.ListScepEnrollmentsResponse, error) { + m.calls = append(m.calls, "list-enrollments") + return &cryptosv1.ListScepEnrollmentsResponse{}, m.err +} + +func (m *mockScepAdmin) ApproveScepEnrollment(_ context.Context, req *cryptosv1.ApproveScepEnrollmentRequest) (*cryptosv1.ApproveScepEnrollmentResponse, error) { + m.calls = append(m.calls, "approve") + if m.err != nil { + return nil, m.err + } + return &cryptosv1.ApproveScepEnrollmentResponse{Enrollment: &cryptosv1.ScepEnrollment{Id: req.GetId(), Profile: "cisco"}, SerialHex: "abc123"}, nil +} + +func (m *mockScepAdmin) RejectScepEnrollment(_ context.Context, req *cryptosv1.RejectScepEnrollmentRequest) (*cryptosv1.RejectScepEnrollmentResponse, error) { + m.calls = append(m.calls, "reject") + if m.err != nil { + return nil, m.err + } + return &cryptosv1.RejectScepEnrollmentResponse{Enrollment: &cryptosv1.ScepEnrollment{Id: req.GetId(), Profile: "cisco"}}, nil +} + +func serverWithScep(t *testing.T, admin ScepAdmin, adminCert *x509.Certificate) *Server { + t.Helper() + cfg := ServerConfig{Auditor: &mockAuditor{}, TLSConfig: mtlsTLSConfig(t), Trust: trustForCert(t, adminCert)} + if admin != nil { + cfg.ScepAdmin = admin + } + srv, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + return srv +} + +// callAllScep runs each SCEP admin RPC once and returns their status codes. +func callAllScep(ctx context.Context, srv *Server) map[string]codes.Code { + got := map[string]codes.Code{} + _, err := srv.MintScepChallenge(ctx, &cryptosv1.MintScepChallengeRequest{Profile: "cisco"}) + got["mint"] = status.Code(err) + _, err = srv.ListScepChallenges(ctx, &cryptosv1.ListScepChallengesRequest{}) + got["list-challenges"] = status.Code(err) + _, err = srv.RevokeScepChallenge(ctx, &cryptosv1.RevokeScepChallengeRequest{Id: "ch1"}) + got["revoke-challenge"] = status.Code(err) + _, err = srv.ListScepEnrollments(ctx, &cryptosv1.ListScepEnrollmentsRequest{}) + got["list-enrollments"] = status.Code(err) + _, err = srv.ApproveScepEnrollment(ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: "en1"}) + got["approve"] = status.Code(err) + _, err = srv.RejectScepEnrollment(ctx, &cryptosv1.RejectScepEnrollmentRequest{Id: "en1", Reason: "unknown device"}) + got["reject"] = status.Code(err) + return got +} + +// Without a SCEP listener this boot (maintenance mode, a Root, or SCEP +// switched off) every SCEP RPC is FailedPrecondition, as the contract says. +func TestScepRPCs_FailedPreconditionWhenNotRunning(t *testing.T) { + admin := authzTestCert(t) + srv := serverWithScep(t, nil, admin) + for rpc, code := range callAllScep(authzMTLSContext(admin), srv) { + if code != codes.FailedPrecondition { + t.Errorf("%s: code = %v, want FailedPrecondition", rpc, code) + } + } +} + +func TestScepRPCs_NonAdminIsDenied(t *testing.T) { + admin := authzTestCert(t) + m := &mockScepAdmin{} + srv := serverWithScep(t, m, admin) + for rpc, code := range callAllScep(authzMTLSContext(authzTestCert(t)), srv) { + if code != codes.PermissionDenied { + t.Errorf("%s: code = %v, want PermissionDenied", rpc, code) + } + } + if len(m.calls) != 0 { + t.Fatalf("the SCEP admin was reached for a non-admin caller: %v", m.calls) + } +} + +func TestScepRPCs_AdminReachesEveryOperation(t *testing.T) { + admin := authzTestCert(t) + m := &mockScepAdmin{} + srv := serverWithScep(t, m, admin) + for rpc, code := range callAllScep(authzMTLSContext(admin), srv) { + if code != codes.OK { + t.Errorf("%s: code = %v, want OK", rpc, code) + } + } + if len(m.calls) != 6 { + t.Fatalf("admin calls = %v, want all six", m.calls) + } +} + +func TestMintScepChallenge_PassesTheActorAndKeepsThePasswordOutOfTheAudit(t *testing.T) { + admin := authzTestCert(t) + m := &mockScepAdmin{} + srv := serverWithScep(t, m, admin) + details := map[string]string{} + ctx := context.WithValue(authzMTLSContext(admin), auditDetailsKey{}, details) + + resp, err := srv.MintScepChallenge(ctx, &cryptosv1.MintScepChallengeRequest{Profile: "cisco", TtlSeconds: 600, BoundNames: []string{"switch01.example.com"}}) + if err != nil { + t.Fatalf("MintScepChallenge: %v", err) + } + if resp.GetChallengePassword() == "" { + t.Fatal("the minted challenge was not returned") + } + if m.actorCN != admin.Subject.CommonName { + t.Fatalf("actor CN = %q, want %q", m.actorCN, admin.Subject.CommonName) + } + if m.mintReq.GetTtlSeconds() != 600 || len(m.mintReq.GetBoundNames()) != 1 { + t.Fatalf("request not passed through: %v", m.mintReq) + } + if details["challenge_id"] != "ch1" || details["profile"] != "cisco" || details["bound_names"] != "switch01.example.com" { + t.Fatalf("audit details = %v, want the challenge id, profile and bound names", details) + } + for k, v := range details { + if strings.Contains(v, resp.GetChallengePassword()) { + t.Fatalf("audit detail %q carries the challenge password", k) + } + } +} + +func TestMintScepChallenge_LocalSocketActor(t *testing.T) { + admin := authzTestCert(t) + m := &mockScepAdmin{} + srv := serverWithScep(t, m, admin) + if _, err := srv.MintScepChallenge(context.Background(), &cryptosv1.MintScepChallengeRequest{}); err != nil { + t.Fatalf("MintScepChallenge over the local socket: %v", err) + } + if m.actorCN != LocalSocketActor { + t.Fatalf("actor CN = %q, want %q", m.actorCN, LocalSocketActor) + } +} + +func TestScepRPCs_PassAdminErrorsThrough(t *testing.T) { + admin := authzTestCert(t) + m := &mockScepAdmin{err: status.Error(codes.NotFound, "no such enrolment")} + srv := serverWithScep(t, m, admin) + _, err := srv.ApproveScepEnrollment(authzMTLSContext(admin), &cryptosv1.ApproveScepEnrollmentRequest{Id: "missing"}) + if status.Code(err) != codes.NotFound { + t.Fatalf("code = %v, want NotFound", status.Code(err)) + } +} + +func TestApproveAndRejectScepEnrollment_RecordAuditDetails(t *testing.T) { + admin := authzTestCert(t) + m := &mockScepAdmin{} + srv := serverWithScep(t, m, admin) + + details := map[string]string{} + ctx := context.WithValue(authzMTLSContext(admin), auditDetailsKey{}, details) + if _, err := srv.ApproveScepEnrollment(ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: "en1"}); err != nil { + t.Fatalf("Approve: %v", err) + } + if details["enrollment_id"] != "en1" || details["serial_hex"] != "abc123" || details["profile"] != "cisco" { + t.Fatalf("approve audit details = %v", details) + } + + details = map[string]string{} + ctx = context.WithValue(authzMTLSContext(admin), auditDetailsKey{}, details) + if _, err := srv.RejectScepEnrollment(ctx, &cryptosv1.RejectScepEnrollmentRequest{Id: "en2", Reason: "unknown device"}); err != nil { + t.Fatalf("Reject: %v", err) + } + if details["enrollment_id"] != "en2" || details["reason"] != "unknown device" { + t.Fatalf("reject audit details = %v", details) + } +} diff --git a/internal/grpc/server.go b/internal/grpc/server.go index f78eb90..874853f 100644 --- a/internal/grpc/server.go +++ b/internal/grpc/server.go @@ -330,6 +330,11 @@ type ServerConfig struct { // there. Rebooter Rebooter + // ScepAdmin backs the SCEP administration RPCs (challenges and the + // approval queue). It is wired on the mTLS and local servers only when the + // SCEP listener started this boot; nil makes those RPCs FailedPrecondition. + ScepAdmin ScepAdmin + // Trust is the pinned bootstrap admin trust used to authorize the signing // RPCs (AuthorizeAdmin). A nil Trust means the caller could not be denied, // so it is set only alongside the signers on the authenticated servers. diff --git a/internal/grpc/unserved.go b/internal/grpc/unserved.go index c6eb4f8..ca68170 100644 --- a/internal/grpc/unserved.go +++ b/internal/grpc/unserved.go @@ -27,36 +27,10 @@ import ( cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" ) -// The SCEP and RFC 3161 TSA RPCs are in the api contract, but this build does -// not serve either protocol yet. Each answers Unimplemented until its server -// lands and replaces the stub here. - -func (s *Server) MintScepChallenge(context.Context, *cryptosv1.MintScepChallengeRequest) (*cryptosv1.MintScepChallengeResponse, error) { - return nil, errSCEPUnserved -} - -func (s *Server) ListScepChallenges(context.Context, *cryptosv1.ListScepChallengesRequest) (*cryptosv1.ListScepChallengesResponse, error) { - return nil, errSCEPUnserved -} - -func (s *Server) RevokeScepChallenge(context.Context, *cryptosv1.RevokeScepChallengeRequest) (*cryptosv1.RevokeScepChallengeResponse, error) { - return nil, errSCEPUnserved -} - -func (s *Server) ListScepEnrollments(context.Context, *cryptosv1.ListScepEnrollmentsRequest) (*cryptosv1.ListScepEnrollmentsResponse, error) { - return nil, errSCEPUnserved -} - -func (s *Server) ApproveScepEnrollment(context.Context, *cryptosv1.ApproveScepEnrollmentRequest) (*cryptosv1.ApproveScepEnrollmentResponse, error) { - return nil, errSCEPUnserved -} - -func (s *Server) RejectScepEnrollment(context.Context, *cryptosv1.RejectScepEnrollmentRequest) (*cryptosv1.RejectScepEnrollmentResponse, error) { - return nil, errSCEPUnserved -} +// The RFC 3161 TSA RPCs are in the api contract, but this build does not +// serve a time-stamp authority yet. Each answers Unimplemented until its +// server lands and replaces the stub here. func (s *Server) ListTsaCertificates(context.Context, *cryptosv1.ListTsaCertificatesRequest) (*cryptosv1.ListTsaCertificatesResponse, error) { return nil, status.Error(codes.Unimplemented, "the RFC 3161 time-stamp authority is not served by this build") } - -var errSCEPUnserved = status.Error(codes.Unimplemented, "SCEP is not served by this build") diff --git a/internal/grpc/unserved_test.go b/internal/grpc/unserved_test.go index 9f3c5f0..7a350f9 100644 --- a/internal/grpc/unserved_test.go +++ b/internal/grpc/unserved_test.go @@ -41,30 +41,6 @@ func TestUnservedRPCs_ReturnUnimplemented(t *testing.T) { } ctx := context.Background() calls := map[string]func() error{ - "MintScepChallenge": func() error { - _, err := srv.MintScepChallenge(ctx, &cryptosv1.MintScepChallengeRequest{}) - return err - }, - "ListScepChallenges": func() error { - _, err := srv.ListScepChallenges(ctx, &cryptosv1.ListScepChallengesRequest{}) - return err - }, - "RevokeScepChallenge": func() error { - _, err := srv.RevokeScepChallenge(ctx, &cryptosv1.RevokeScepChallengeRequest{}) - return err - }, - "ListScepEnrollments": func() error { - _, err := srv.ListScepEnrollments(ctx, &cryptosv1.ListScepEnrollmentsRequest{}) - return err - }, - "ApproveScepEnrollment": func() error { - _, err := srv.ApproveScepEnrollment(ctx, &cryptosv1.ApproveScepEnrollmentRequest{}) - return err - }, - "RejectScepEnrollment": func() error { - _, err := srv.RejectScepEnrollment(ctx, &cryptosv1.RejectScepEnrollmentRequest{}) - return err - }, "ListTsaCertificates": func() error { _, err := srv.ListTsaCertificates(ctx, &cryptosv1.ListTsaCertificatesRequest{}) return err diff --git a/internal/init/run.go b/internal/init/run.go index 7825a1d..06b5a07 100644 --- a/internal/init/run.go +++ b/internal/init/run.go @@ -26,6 +26,7 @@ import ( "fmt" "log" "net" + "net/http" "net/netip" "os" "os/signal" @@ -52,6 +53,7 @@ import ( "github.com/CryptOS-PKI/cryptos/internal/release" "github.com/CryptOS-PKI/cryptos/internal/reset" "github.com/CryptOS-PKI/cryptos/internal/revocation" + "github.com/CryptOS-PKI/cryptos/internal/scep" "github.com/CryptOS-PKI/cryptos/internal/storage/etcd" "github.com/CryptOS-PKI/cryptos/internal/storage/luks" "github.com/CryptOS-PKI/cryptos/internal/tpm" @@ -395,7 +397,7 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { // The enrolment listeners start once, below, from this boot's config. A // protocol switched by ApplyConfig waits for the next boot, and GetStatus // shows it configured but not running until then. - var acmeRunning, estRunning atomic.Bool + var acmeRunning, estRunning, scepRunning atomic.Bool statusProv, err := node.NewStatusProvider(node.StatusConfig{ Store: store, Role: cfg.NodeRole(), @@ -413,6 +415,8 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { return acmeRunning.Load() case cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_EST: return estRunning.Load() + case cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_SCEP: + return scepRunning.Load() default: return false } @@ -515,6 +519,14 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { } return id.GetChainDer(), nil }} + // SCEP (RFC 8894) responder, built here so the admin RPCs can be wired + // into both management listeners; its HTTP listener starts at 12e. It + // exists only when pki.scep is set in the config this boot started from + // (Validate refuses it on a Root). A failure to set it up leaves SCEP off + // for this boot, and the SCEP RPCs answer FailedPrecondition, rather than + // failing a boot that still has to serve everything else. + scepSrv, scepRAs := newSCEPServer(ctx, cfg, cli, keyLoader, issuerFunc, caSigner, revStore, revoker, logger) + // Delegated OCSP responder manager: it mints/renews a short-lived responder // certificate with this node's CA (loading the CA key only to mint/renew, // never per OCSP request) so responses are signed by the responder key, not @@ -678,6 +690,9 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { localCfg.ImageUpgrader = imageUpgrader localCfg.Rebooter = rebooter localCfg.Trust = trust + if scepSrv != nil { + localCfg.ScepAdmin = scepSrv + } _ = os.Remove(LocalSocketPath) localSrv, err := cgrpc.NewLocal(localCfg) if err != nil { @@ -729,6 +744,9 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { mtlsCfg.ImageUpgrader = imageUpgrader mtlsCfg.Rebooter = rebooter mtlsCfg.Trust = trust + if scepSrv != nil { + mtlsCfg.ScepAdmin = scepSrv + } // RemoteReset (manager-mediated decommission) is admin-authorized over // mTLS: it drives the same destructive wipe as the local Reset, so it // carries the same resetter here. The mTLS server leaves Resetter nil, so @@ -763,7 +781,14 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { if cfg.PKI.RevocationBaseURL != "" { httpAddr := fmt.Sprintf(":%d", nonzero(cfg.PKI.RevocationHTTPPort, defaultRevocationHTTPPort)) handler := revocation.NewHandler(revoker.crlFn(), revoker.ocspFn(ocspResp, ocspResponderMgr), revoker.caCertFn()) - stopHTTP, herr := revocation.Serve(ctx, httpAddr, handler) + routes := handler.Routes() + if scepSrv != nil && scepSharesRevocationListener(cfg) { + mux := http.NewServeMux() + mux.Handle("/", routes) + scepSrv.Mount(mux) + routes = mux + } + stopHTTP, herr := revocation.ServeHandler(ctx, httpAddr, routes) if herr != nil { return fmt.Errorf("init: start revocation HTTP listener on %s: %w", httpAddr, herr) } @@ -773,6 +798,11 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { _ = stopHTTP(shutdownCtx) }() log.Printf("revocation HTTP listener up: %s (base=%s)", httpAddr, cfg.PKI.RevocationBaseURL) + if scepSrv != nil && scepSharesRevocationListener(cfg) { + scepRunning.Store(true) + log.Printf("SCEP listener up: %s%s and %s%s, sharing the CRL/OCSP listener (profiles=%d)", + httpAddr, scep.PathPKIClient, httpAddr, scep.PathSCEP, len(cfg.PKI.SCEP.Profiles)) + } // Ensure the delegated OCSP responder exists before serving (best-effort: // a failure only logs; the responder is re-ensured lazily per request and @@ -880,6 +910,31 @@ func boot(ctx context.Context, shutdown *shutdownRequests) (err error) { log.Printf("EST: off in the boot config; not listening") } + // 12e. SCEP (RFC 8894) enrolment listener, when it does not share the + // CRL/OCSP listener above. Plain HTTP, as the RFC intends: the CMS + // envelope carries confidentiality and integrity. Like every enrolment + // protocol it starts only here, at boot, from the stored config. + if scepSrv != nil { + if !scepSharesRevocationListener(cfg) { + scepAddr := fmt.Sprintf(":%d", scepListenPort(cfg)) + stopSCEP, serr := scep.Serve(ctx, scepAddr, scepSrv.Routes()) + if serr != nil { + return fmt.Errorf("init: start the SCEP listener on %s: %w", scepAddr, serr) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = stopSCEP(shutdownCtx) + }() + scepRunning.Store(true) + log.Printf("SCEP listener up: %s%s and %s%s (profiles=%d)", + scepAddr, scep.PathPKIClient, scepAddr, scep.PathSCEP, len(cfg.PKI.SCEP.Profiles)) + } + go superviseSCEPRA(ctx, scepRAs) + } else { + log.Printf("SCEP: off this boot") + } + done() log.Printf("listeners up: mTLS=%s local=%s first_boot=%t", addr, LocalSocketPath, firstBoot) diff --git a/internal/init/scep.go b/internal/init/scep.go new file mode 100644 index 0000000..1ebff0a --- /dev/null +++ b/internal/init/scep.go @@ -0,0 +1,209 @@ +package init + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "fmt" + "log" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/CryptOS-PKI/cryptos/internal/ca" + "github.com/CryptOS-PKI/cryptos/internal/config" + "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/revocation" + "github.com/CryptOS-PKI/cryptos/internal/scep" +) + +// scepRAEnsureInterval is how often a running node re-checks its SCEP RA +// certificates, so a successor is minted when the overlap starts without a +// reboot. +const scepRAEnsureInterval = time.Hour + +// scepListenPort is the port the SCEP listener binds: the configured one, or +// by default the plain-HTTP CRL/OCSP port, so Cisco's default enrolment URL +// (http:///cgi-bin/pkiclient.exe) works as written. +func scepListenPort(c *config.Config) uint32 { + return nonzero(c.PKI.SCEP.HTTPPort, nonzero(c.PKI.RevocationHTTPPort, defaultRevocationHTTPPort)) +} + +// scepSharesRevocationListener reports whether SCEP is served on the CRL/OCSP +// listener rather than one of its own: when that listener runs (a revocation +// base URL is set) and both use the same port, as they do by default. +func scepSharesRevocationListener(c *config.Config) bool { + return c.PKI.SCEP != nil && c.PKI.RevocationBaseURL != "" && + scepListenPort(c) == nonzero(c.PKI.RevocationHTTPPort, defaultRevocationHTTPPort) +} + +// scepOptions maps the validated pki.scep block onto scep.Options. +func scepOptions(c *config.SCEP) scep.Options { + opts := scep.Options{AllowedSuffixes: c.AllowedIdentifierSuffixes, Logf: log.Printf} + for _, p := range c.Profiles { + opts.Profiles = append(opts.Profiles, scep.Profile{ + Name: p.Profile, + MinRSABits: p.RSAFloor(), + RequireApproval: p.RequireApproval, + }) + } + return opts +} + +// scepRAProfile is the ca.Profile for an RA certificate: an end-entity +// certificate whose key decrypts SCEP requests and signs SCEP replies. +func scepRAProfile(issuer *x509.Certificate, notBefore, notAfter time.Time) ca.Profile { + return ca.Profile{ + Subject: pkix.Name{CommonName: issuer.Subject.CommonName + " SCEP RA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + } +} + +// scepMintRA returns the RA-signing closure. The CA key is loaded for the +// signature and released straight after, as for the OCSP responder. +func scepMintRA(load node.KeyLoader, issuer node.IssuerFunc) scep.MintRAFunc { + return func(ctx context.Context, pub *rsa.PublicKey, notBefore, notAfter time.Time) ([]byte, error) { + signer, closeFn, err := load(ctx) + if err != nil { + return nil, fmt.Errorf("init: load the CA key for the SCEP RA: %w", err) + } + if closeFn != nil { + defer closeFn() + } + issuerCert, err := issuer(ctx) + if err != nil { + return nil, fmt.Errorf("init: load the issuer for the SCEP RA: %w", err) + } + if issuerCert == nil { + return nil, errors.New("init: no issuer certificate for the SCEP RA") + } + der, _, err := ca.Sign(scepRAProfile(issuerCert, notBefore, notAfter), pub, issuerCert, signer) + return der, err + } +} + +// scepIssuer issues through the CA signer with the profile's RSA floor, so +// the profile decides the extensions and the certificate is recorded for +// revocation before SCEP hands it out. +func scepIssuer(signer *node.CASigner) scep.IssueFunc { + return func(ctx context.Context, csrDER []byte, profile string, names []string, minRSABits int) ([]byte, error) { + chain, _, err := signer.IssueLeafForNamesMinRSA(ctx, csrDER, profile, names, minRSABits) + if err != nil { + return nil, err + } + return chain[0], nil + } +} + +func scepCAChain(issuer node.IssuerFunc) scep.CAChainFunc { + return func(ctx context.Context) ([]*x509.Certificate, error) { + cert, err := issuer(ctx) + if err != nil { + return nil, err + } + if cert == nil { + return nil, errors.New("init: this node has no CA certificate") + } + return []*x509.Certificate{cert}, nil + } +} + +func scepIssued(store *revocation.Store) scep.IssuedFunc { + return func(ctx context.Context, serialHex string) ([]byte, string, bool, error) { + rec, ok, err := store.GetIssued(ctx, serialHex) + if err != nil || !ok { + return nil, "", ok, err + } + return rec.DER, rec.ProfileName, true, nil + } +} + +func scepRevoked(store *revocation.Store) scep.RevokedFunc { + return func(ctx context.Context, serialHex string) (bool, error) { + _, revoked, err := store.GetRevoked(ctx, serialHex) + return revoked, err + } +} + +// newSCEPServer builds the SCEP responder for this boot, or returns nil when +// pki.scep is off or SCEP cannot be set up. The RA is ensured here, before +// any listener starts, so GetCACert has an RA to offer from the first +// request. +func newSCEPServer(ctx context.Context, cfg *config.Config, cli *clientv3.Client, load node.KeyLoader, issuer node.IssuerFunc, + signer *node.CASigner, revStore *revocation.Store, revoker *nodeRevoker, auditor scep.Auditor) (*scep.Server, *scep.RAManager) { + if cfg.PKI.SCEP == nil { + return nil, nil + } + s := cfg.PKI.SCEP + store := scep.NewStore(cli) + ras, err := scep.NewRAManager(store, scepMintRA(load, issuer), scep.IssuerCertFunc(issuer), scep.RAOptions{ + Validity: s.RA.Validity(), Overlap: s.RA.Overlap(), Logf: log.Printf, + }) + if err != nil { + log.Printf("SCEP: off this boot: %v", err) + return nil, nil + } + if err := ras.Ensure(ctx); err != nil { + log.Printf("SCEP: off this boot: the RA certificate could not be set up: %v", err) + return nil, nil + } + srv, err := scep.NewServer(store, ras, scep.Deps{ + Issue: scepIssuer(signer), + CAChain: scepCAChain(issuer), + Revoked: scepRevoked(revStore), + Issued: scepIssued(revStore), + CRL: revoker.crlFn(), + Auditor: auditor, + }, scepOptions(s)) + if err != nil { + log.Printf("SCEP: off this boot: %v", err) + return nil, nil + } + floors := make([]string, 0, len(s.Profiles)) + for _, p := range s.Profiles { + floors = append(floors, fmt.Sprintf("%s(rsa>=%d approval=%t)", p.Profile, p.RSAFloor(), p.RequireApproval)) + } + log.Printf("SCEP: ready: profiles=%v allowlist=%v RA validity=%s overlap=%s", + floors, s.AllowedIdentifierSuffixes, s.RA.Validity(), s.RA.Overlap()) + return srv, ras +} + +// superviseSCEPRA re-checks the RA certificates on a timer, so the successor +// is minted when the rotation overlap begins and an expired RA is dropped, +// without a reboot. +func superviseSCEPRA(ctx context.Context, ras *scep.RAManager) { + t := time.NewTicker(scepRAEnsureInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := ras.Ensure(ctx); err != nil { + log.Printf("SCEP: RA check failed: %v (the current RA stays in use)", err) + } + } + } +} diff --git a/internal/init/scep_test.go b/internal/init/scep_test.go new file mode 100644 index 0000000..59f0b21 --- /dev/null +++ b/internal/init/scep_test.go @@ -0,0 +1,106 @@ +package init + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "strings" + "testing" + "time" + + "github.com/CryptOS-PKI/cryptos/internal/ca" + "github.com/CryptOS-PKI/cryptos/internal/config" +) + +func scepBootConfig() *config.Config { + return &config.Config{PKI: config.PKI{SCEP: &config.SCEP{ + Profiles: []config.SCEPProfile{{Profile: "cisco-device", MinRSAKeyBits: 2048}, {Profile: "mdm"}}, + AllowedIdentifierSuffixes: []string{"example.com"}, + }}} +} + +func TestSCEPListenerPlacement(t *testing.T) { + c := scepBootConfig() + if got := scepListenPort(c); got != 80 { + t.Fatalf("default SCEP port = %d, want 80, the CRL/OCSP port", got) + } + if scepSharesRevocationListener(c) { + t.Fatal("with no revocation listener running SCEP must get its own") + } + c.PKI.RevocationBaseURL = "http://ca.example.com" + if !scepSharesRevocationListener(c) { + t.Fatal("on the same port as the CRL/OCSP listener SCEP must share it") + } + c.PKI.SCEP.HTTPPort = 8080 + if scepSharesRevocationListener(c) || scepListenPort(c) != 8080 { + t.Fatal("a separate SCEP port must get its own listener") + } + c.PKI.SCEP.HTTPPort = 0 + c.PKI.RevocationHTTPPort = 8081 + if !scepSharesRevocationListener(c) || scepListenPort(c) != 8081 { + t.Fatal("SCEP follows a moved CRL/OCSP port by default") + } +} + +func TestSCEPOptionsCarryTheFloors(t *testing.T) { + opts := scepOptions(scepBootConfig().PKI.SCEP) + if len(opts.Profiles) != 2 || opts.Profiles[0].MinRSABits != 2048 || opts.Profiles[1].MinRSABits != 3072 { + t.Fatalf("profiles = %+v, want the 2048 floor and the 3072 default", opts.Profiles) + } + if len(opts.AllowedSuffixes) != 1 { + t.Fatalf("allowlist = %v", opts.AllowedSuffixes) + } +} + +func TestSCEPMintRA(t *testing.T) { + caKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + now := time.Now().UTC() + der, _, err := ca.Sign(ca.Profile{Subject: pkix.Name{CommonName: "Node CA"}, NotBefore: now.Add(-time.Hour), NotAfter: now.AddDate(3, 0, 0), + IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign}, &caKey.PublicKey, nil, caKey) + if err != nil { + t.Fatal(err) + } + caCert, _ := x509.ParseCertificate(der) + closed := false + load := func(context.Context) (crypto.Signer, func(), error) { return caKey, func() { closed = true }, nil } + issuer := func(context.Context) (*x509.Certificate, error) { return caCert, nil } + + raKey, _ := rsa.GenerateKey(rand.Reader, 3072) + raDER, err := scepMintRA(load, issuer)(context.Background(), &raKey.PublicKey, now, now.AddDate(1, 0, 0)) + if err != nil { + t.Fatalf("mint: %v", err) + } + if !closed { + t.Fatal("the CA key was not released after signing the RA") + } + ra, _ := x509.ParseCertificate(raDER) + if ra.KeyUsage != x509.KeyUsageDigitalSignature|x509.KeyUsageKeyEncipherment || ra.IsCA || len(ra.ExtKeyUsage) != 0 { + t.Fatalf("RA usage = %v %v IsCA=%t", ra.KeyUsage, ra.ExtKeyUsage, ra.IsCA) + } + if !strings.HasSuffix(ra.Subject.CommonName, "SCEP RA") || ra.CheckSignatureFrom(caCert) != nil { + t.Fatalf("RA subject %q or signature wrong", ra.Subject.CommonName) + } +} diff --git a/internal/node/providers.go b/internal/node/providers.go index ff0b883..7c3b0d2 100644 --- a/internal/node/providers.go +++ b/internal/node/providers.go @@ -186,6 +186,12 @@ func (p *StatusProvider) protocols() ([]*cryptosv1.ProtocolStatus, bool) { Running: running(cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_EST), RebootPending: !config.Equivalent(boot.PKI.EST, stored.PKI.EST), }, + { + Protocol: cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_SCEP, + Configured: stored.PKI.SCEP != nil, + Running: running(cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_SCEP), + RebootPending: !config.Equivalent(boot.PKI.SCEP, stored.PKI.SCEP), + }, } return out, pending } @@ -312,7 +318,7 @@ func (c *ConfigStore) Apply(ctx context.Context, cfg *cryptosv1.MachineConfig) ( } } requiresReboot := config.NeedsReboot(oldCfg, parsed) - log.Printf("node: Apply: acme=%t est=%t requires_reboot=%t", parsed.PKI.ACME != nil, parsed.PKI.EST != nil, requiresReboot) + log.Printf("node: Apply: acme=%t est=%t scep=%t requires_reboot=%t", parsed.PKI.ACME != nil, parsed.PKI.EST != nil, parsed.PKI.SCEP != nil, requiresReboot) // Validate exactly what will be written: after the protocol blocks are // resolved, so a kept ACME or EST block is checked against the incoming diff --git a/internal/node/scep_apply_test.go b/internal/node/scep_apply_test.go new file mode 100644 index 0000000..21745d4 --- /dev/null +++ b/internal/node/scep_apply_test.go @@ -0,0 +1,162 @@ +package node + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "testing" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/config" +) + +var scepIssuingYAML = []byte(`apiVersion: cryptos.dev/v1alpha1 +kind: MachineConfig +metadata: {name: scep-test} +role: {kind: issuing} +network: {interface: eth0, address: 10.0.0.10/24, gateway: 10.0.0.1} +bootstrap: {admin_cert_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +pki: + root_key_alg: ECDSA-P384 + root_subject: {common_name: "SCEP Test Issuing", organization: "Test", country: "US"} + root_validity_years: 10 + path_len_constraint: 0 + parent: {ca_cert_sha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + profiles: + - name: cisco-device + key_alg: ECDSA-P384 + validity_days: 365 + key_usage: [digital_signature, key_encipherment] + ext_key_usage: [client_auth] + scep: + profiles: + - {profile: cisco-device, min_rsa_key_bits: 2048} + allowed_identifier_suffixes: [example.com] +`) + +func storedSCEP(t *testing.T, fs *config.FileStore) *config.SCEP { + t.Helper() + raw, _, ok, err := fs.Read() + if err != nil || !ok { + t.Fatalf("FileStore.Read: ok=%v err=%v", ok, err) + } + cfg, err := config.Parse(raw) + if err != nil { + t.Fatalf("parse stored config: %v", err) + } + return cfg.PKI.SCEP +} + +// SCEP travels through the proto, so a config read over the wire and applied +// back keeps it; a client that leaves the block out keeps it too; and only an +// explicit enabled=false switches it off, at the next boot. +func TestConfigStoreApply_SCEPBlockRules(t *testing.T) { + ctx := context.Background() + fs := config.NewFileStore(t.TempDir()) + if _, err := fs.Write(scepIssuingYAML); err != nil { + t.Fatalf("seed: %v", err) + } + cs := NewConfigStore(fs) + + current, err := cs.Current(ctx) + if err != nil { + t.Fatalf("Current: %v", err) + } + if !current.GetPki().GetScep().GetEnabled() { + t.Fatal("GetConfig does not report SCEP as enabled") + } + if _, err := cs.Apply(ctx, current); err != nil { + t.Fatalf("Apply (round trip): %v", err) + } + if s := storedSCEP(t, fs); s == nil || s.Profiles[0].MinRSAKeyBits != 2048 { + t.Fatalf("a round trip changed the SCEP block: %+v", s) + } + + current.Pki.Scep = nil + if _, err := cs.Apply(ctx, current); err != nil { + t.Fatalf("Apply (no scep block): %v", err) + } + if storedSCEP(t, fs) == nil { + t.Fatal("an apply that left pki.scep out switched SCEP off") + } + + current.Pki.Scep = &cryptosv1.Scep{Enabled: false} + resp, err := cs.Apply(ctx, current) + if err != nil { + t.Fatalf("Apply (scep off): %v", err) + } + if storedSCEP(t, fs) != nil { + t.Fatal("enabled=false did not switch SCEP off") + } + if !resp.GetRequiresReboot() { + t.Fatal("switching SCEP off must require a reboot: the listener starts only at boot") + } +} + +// SCEP is reported in NodeStatus.protocols like ACME and EST: configured from +// the stored config, running from this boot, and a reboot pending once an +// apply has switched it off while the listener still runs. +func TestStatusProviderReportsSCEP(t *testing.T) { + ctx := context.Background() + s, _ := newTestStore(t) + fs := config.NewFileStore(t.TempDir()) + if _, err := fs.Write(scepIssuingYAML); err != nil { + t.Fatalf("seed: %v", err) + } + cs := NewConfigStore(fs) + sp, err := NewStatusProvider(StatusConfig{ + Store: s, + Role: cryptosv1.NodeRole_NODE_ROLE_ISSUING, + BootConfig: storedConfig(t, fs), + ConfigFile: fs, + ProtocolRunning: func(p cryptosv1.ServiceProtocol) bool { + return p == cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_SCEP + }, + }) + if err != nil { + t.Fatalf("NewStatusProvider: %v", err) + } + + st, err := sp.Status(ctx) + if err != nil { + t.Fatalf("Status: %v", err) + } + if ps := protocolState(t, st, cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_SCEP); !ps.GetConfigured() || !ps.GetRunning() || ps.GetRebootPending() { + t.Fatalf("SCEP as booted = %v, want configured and running with nothing pending", ps) + } + + current, err := cs.Current(ctx) + if err != nil { + t.Fatalf("Current: %v", err) + } + current.Pki.Scep = &cryptosv1.Scep{Enabled: false} + if _, err := cs.Apply(ctx, current); err != nil { + t.Fatalf("Apply (scep off): %v", err) + } + st, err = sp.Status(ctx) + if err != nil { + t.Fatalf("Status: %v", err) + } + if ps := protocolState(t, st, cryptosv1.ServiceProtocol_SERVICE_PROTOCOL_SCEP); ps.GetConfigured() || !ps.GetRunning() || !ps.GetRebootPending() { + t.Fatalf("SCEP switched off in the store while running = %v, want not configured, running, reboot pending", ps) + } + if !st.GetConfigRebootPending() { + t.Error("config_reboot_pending is not set after SCEP was switched off") + } +} diff --git a/internal/revocation/http.go b/internal/revocation/http.go index b2de66e..a447a6f 100644 --- a/internal/revocation/http.go +++ b/internal/revocation/http.go @@ -157,7 +157,13 @@ func readOCSPRequest(r *http.Request) ([]byte, error) { // returns a stop closure that gracefully shuts the server down. A // ReadHeaderTimeout guards against slow-header clients on the unauthenticated // listener. -func Serve(_ context.Context, addr string, h *Handler) (func(context.Context) error, error) { +func Serve(ctx context.Context, addr string, h *Handler) (func(context.Context) error, error) { + return ServeHandler(ctx, addr, h.Routes()) +} + +// ServeHandler is Serve for a handler that mounts more than the revocation +// routes: SCEP shares this plain-HTTP listener when both use one port. +func ServeHandler(_ context.Context, addr string, h http.Handler) (func(context.Context) error, error) { // Bind synchronously so that when Serve returns, the socket is already // listening. Callers (e.g. the node's startup preflight) probe this // endpoint immediately, so a racy goroutine-bind would make that probe @@ -168,7 +174,7 @@ func Serve(_ context.Context, addr string, h *Handler) (func(context.Context) er return nil, fmt.Errorf("revocation: listen %s: %w", addr, err) } srv := &http.Server{ - Handler: h.Routes(), + Handler: h, ReadHeaderTimeout: 5 * time.Second, } go func() { diff --git a/internal/scep/admin.go b/internal/scep/admin.go new file mode 100644 index 0000000..1f14726 --- /dev/null +++ b/internal/scep/admin.go @@ -0,0 +1,295 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base32" + "encoding/hex" + "encoding/pem" + "fmt" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// Challenge lifetimes (MintScepChallengeRequest.ttl_seconds). +const ( + DefaultChallengeTTL = time.Hour + MaxChallengeTTL = 7 * 24 * time.Hour +) + +// challengeBytes is the randomness in a minted challenge: 160 bits, above the +// 128 the contract promises. +const challengeBytes = 20 + +// maxBoundNames bounds the names one challenge may be bound to. +const maxBoundNames = 100 + +// challengeEncoding writes challenges as upper-case base32 with no padding: +// letters and digits only, so the challenge can be typed at a device prompt. +var challengeEncoding = base32.StdEncoding.WithPadding(base32.NoPadding) + +func newID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("scep: random id: %w", err) + } + return hex.EncodeToString(b), nil +} + +// MintScepChallenge creates a single-use challenge. The challenge is returned +// here and never again; only its SHA-256 is stored, and it is never logged. +func (s *Server) MintScepChallenge(ctx context.Context, req *cryptosv1.MintScepChallengeRequest, actorCN string) (*cryptosv1.MintScepChallengeResponse, error) { + profName := req.GetProfile() + if profName == "" { + if len(s.opts.Profiles) != 1 { + return nil, status.Errorf(codes.InvalidArgument, "MintScepChallenge: profile is required when %d SCEP profiles are configured", len(s.opts.Profiles)) + } + profName = s.opts.Profiles[0].Name + } + prof := s.profile(profName) + if prof == nil { + return nil, status.Errorf(codes.InvalidArgument, "MintScepChallenge: %q is not a configured SCEP profile", profName) + } + + ttl := time.Duration(req.GetTtlSeconds()) * time.Second + if ttl == 0 { + ttl = DefaultChallengeTTL + } + if ttl > MaxChallengeTTL { + return nil, status.Errorf(codes.InvalidArgument, "MintScepChallenge: ttl_seconds %d is more than the %d-second maximum", req.GetTtlSeconds(), int(MaxChallengeTTL/time.Second)) + } + + if len(req.GetBoundNames()) > maxBoundNames { + return nil, status.Errorf(codes.InvalidArgument, "MintScepChallenge: %d bound names is more than the %d allowed", len(req.GetBoundNames()), maxBoundNames) + } + var bound []string + for i, n := range req.GetBoundNames() { + name := normalizeName(n) + if err := validateDNSName(name); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "MintScepChallenge: bound_names[%d]: %v", i, err) + } + if !suffixAllowed(name, s.opts.AllowedSuffixes) { + return nil, status.Errorf(codes.InvalidArgument, "MintScepChallenge: bound_names[%d]: %s is outside the allowed identifier suffixes", i, name) + } + bound = append(bound, name) + } + + raw := make([]byte, challengeBytes) + if _, err := rand.Read(raw); err != nil { + return nil, status.Errorf(codes.Internal, "MintScepChallenge: random: %v", err) + } + password := challengeEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(password)) + id, err := newID() + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + now := s.opts.Now().UTC() + ch := Challenge{ + ID: id, + DigestHex: hex.EncodeToString(sum[:]), + Profile: prof.Name, + BoundNames: bound, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + CreatedByCN: actorCN, + } + if err := s.store.PutChallenge(ctx, ch, ttl); err != nil { + return nil, status.Errorf(codes.Internal, "MintScepChallenge: %v", err) + } + s.opts.Logf("scep: challenge %s minted by %q for profile %q, expires %s, bound to %v", + id, actorCN, prof.Name, ch.ExpiresAt.Format(time.RFC3339), bound) + return &cryptosv1.MintScepChallengeResponse{ChallengePassword: password, Challenge: challengeProto(ch)}, nil +} + +// ListScepChallenges returns the usable challenges, soonest to expire first. +func (s *Server) ListScepChallenges(ctx context.Context, _ *cryptosv1.ListScepChallengesRequest) (*cryptosv1.ListScepChallengesResponse, error) { + all, err := s.store.ListChallenges(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "ListScepChallenges: %v", err) + } + now := s.opts.Now() + resp := &cryptosv1.ListScepChallengesResponse{} + for _, c := range all { + if now.Before(c.ExpiresAt) { + resp.Challenges = append(resp.Challenges, challengeProto(c)) + } + } + s.opts.Logf("scep: listed %d usable challenge(s)", len(resp.Challenges)) + return resp, nil +} + +// RevokeScepChallenge withdraws a usable challenge. +func (s *Server) RevokeScepChallenge(ctx context.Context, req *cryptosv1.RevokeScepChallengeRequest) (*cryptosv1.RevokeScepChallengeResponse, error) { + if req.GetId() == "" { + return nil, status.Error(codes.InvalidArgument, "RevokeScepChallenge: id is required") + } + c, found, err := s.store.DeleteChallenge(ctx, req.GetId()) + if err != nil { + return nil, status.Errorf(codes.Internal, "RevokeScepChallenge: %v", err) + } + if !found || !s.opts.Now().Before(c.ExpiresAt) { + return nil, status.Errorf(codes.NotFound, "RevokeScepChallenge: no usable challenge %q (unknown, used, revoked or expired)", req.GetId()) + } + s.opts.Logf("scep: challenge %s revoked before use (profile %q)", c.ID, c.Profile) + return &cryptosv1.RevokeScepChallengeResponse{Challenge: challengeProto(c)}, nil +} + +// ListScepEnrollments returns the waiting enrolments, oldest first. +func (s *Server) ListScepEnrollments(ctx context.Context, req *cryptosv1.ListScepEnrollmentsRequest) (*cryptosv1.ListScepEnrollmentsResponse, error) { + all, err := s.store.ListEnrollments(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "ListScepEnrollments: %v", err) + } + resp := &cryptosv1.ListScepEnrollmentsResponse{} + for _, e := range all { + if req.GetProfile() != "" && e.Profile != req.GetProfile() { + continue + } + resp.Enrollments = append(resp.Enrollments, enrollmentProto(e)) + } + s.opts.Logf("scep: listed %d waiting enrolment(s)", len(resp.Enrollments)) + return resp, nil +} + +// ApproveScepEnrollment issues a waiting enrolment. The request is checked +// again against the policy in force now; one that no longer passes stays +// queued for rejection. +func (s *Server) ApproveScepEnrollment(ctx context.Context, req *cryptosv1.ApproveScepEnrollmentRequest) (*cryptosv1.ApproveScepEnrollmentResponse, error) { + s.queueMu.Lock() + defer s.queueMu.Unlock() + + e, tx, err := s.waiting(ctx, req.GetId(), "ApproveScepEnrollment") + if err != nil { + return nil, err + } + prof := s.profile(e.Profile) + if prof == nil { + return nil, status.Errorf(codes.FailedPrecondition, "ApproveScepEnrollment: profile %q is no longer served by SCEP; reject the enrolment", e.Profile) + } + csr, err := x509.ParseCertificateRequest(e.CSRDER) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "ApproveScepEnrollment: the stored request does not parse: %v; reject the enrolment", err) + } + names, err := s.checkInitialRequest(csr, prof, nil) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "ApproveScepEnrollment: the request no longer passes: %v; reject the enrolment", err) + } + s.opts.Logf("scep: approving enrolment %s (transaction %q, profile %q, names %v)", e.ID, e.TransactionID, prof.Name, names) + der, err := s.deps.Issue(ctx, e.CSRDER, prof.Name, names, prof.MinRSABits) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "ApproveScepEnrollment: the certificate authority refused the request: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, status.Errorf(codes.Internal, "ApproveScepEnrollment: parse the issued certificate: %v", err) + } + tx.State = txIssued + tx.SerialHex = cert.SerialNumber.Text(16) + tx.UpdatedAt = s.opts.Now().UTC() + if err := s.store.FinishEnrollment(ctx, e.ID, tx); err != nil { + return nil, status.Errorf(codes.Internal, "ApproveScepEnrollment: record the outcome: %v", err) + } + s.opts.Logf("scep: enrolment %s approved, issued %s; the device collects it with its next CertPoll", e.ID, tx.SerialHex) + return &cryptosv1.ApproveScepEnrollmentResponse{Enrollment: enrollmentProto(e), SerialHex: tx.SerialHex}, nil +} + +// RejectScepEnrollment refuses a waiting enrolment; the device's next +// CertPoll is answered FAILURE. +func (s *Server) RejectScepEnrollment(ctx context.Context, req *cryptosv1.RejectScepEnrollmentRequest) (*cryptosv1.RejectScepEnrollmentResponse, error) { + s.queueMu.Lock() + defer s.queueMu.Unlock() + + e, tx, err := s.waiting(ctx, req.GetId(), "RejectScepEnrollment") + if err != nil { + return nil, err + } + tx.State = txRejected + tx.UpdatedAt = s.opts.Now().UTC() + if err := s.store.FinishEnrollment(ctx, e.ID, tx); err != nil { + return nil, status.Errorf(codes.Internal, "RejectScepEnrollment: record the outcome: %v", err) + } + s.opts.Logf("scep: enrolment %s rejected (transaction %q, profile %q): %s", e.ID, e.TransactionID, e.Profile, req.GetReason()) + return &cryptosv1.RejectScepEnrollmentResponse{Enrollment: enrollmentProto(e)}, nil +} + +// waiting loads a queued enrolment and its transaction. +func (s *Server) waiting(ctx context.Context, id, rpc string) (Enrollment, Transaction, error) { + if id == "" { + return Enrollment{}, Transaction{}, status.Errorf(codes.InvalidArgument, "%s: id is required", rpc) + } + e, found, err := s.store.GetEnrollment(ctx, id) + if err != nil { + return Enrollment{}, Transaction{}, status.Errorf(codes.Internal, "%s: %v", rpc, err) + } + if !found { + return Enrollment{}, Transaction{}, status.Errorf(codes.NotFound, "%s: no waiting enrolment %q", rpc, id) + } + tx, found, err := s.store.GetTransaction(ctx, e.TransactionID) + if err != nil { + return Enrollment{}, Transaction{}, status.Errorf(codes.Internal, "%s: %v", rpc, err) + } + if !found { + // The enrolment outlived its transaction record; rebuild the record + // so the device's poll still finds the decision. + tx = Transaction{TransactionID: e.TransactionID, Profile: e.Profile, EnrollmentID: e.ID, MessageType: MessageTypePKCSReq, CreatedAt: e.ReceivedAt} + if csr, perr := x509.ParseCertificateRequest(e.CSRDER); perr == nil { + tx.KeyID = keyID(csr.RawSubjectPublicKeyInfo) + } + } + return e, tx, nil +} + +func challengeProto(c Challenge) *cryptosv1.ScepChallenge { + return &cryptosv1.ScepChallenge{ + Id: c.ID, + Profile: c.Profile, + BoundNames: c.BoundNames, + CreatedAt: timestamppb.New(c.CreatedAt), + ExpiresAt: timestamppb.New(c.ExpiresAt), + CreatedByCn: c.CreatedByCN, + } +} + +func enrollmentProto(e Enrollment) *cryptosv1.ScepEnrollment { + var dnsNames []string + if csr, err := x509.ParseCertificateRequest(e.CSRDER); err == nil { + dnsNames = csr.DNSNames + } + return &cryptosv1.ScepEnrollment{ + Id: e.ID, + TransactionId: e.TransactionID, + Profile: e.Profile, + SubjectDn: e.SubjectDN, + DnsNames: dnsNames, + KeyAlg: e.KeyAlg, + CsrPem: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: e.CSRDER})), + ChallengeId: e.ChallengeID, + ReceivedAt: timestamppb.New(e.ReceivedAt), + } +} diff --git a/internal/scep/admin_test.go b/internal/scep/admin_test.go new file mode 100644 index 0000000..6b5d865 --- /dev/null +++ b/internal/scep/admin_test.go @@ -0,0 +1,175 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "regexp" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +func TestMintScepChallengeShape(t *testing.T) { + f := newFixture(t) + pw, ch := f.mint("cisco-device", 10*time.Minute, "SW1.Example.com.") + if !regexp.MustCompile(`^[A-Z2-7]{32}$`).MatchString(pw) { + t.Fatalf("challenge %q is not 32 base32 characters (160 bits, typeable at a device prompt)", pw) + } + if ch.GetProfile() != "cisco-device" || ch.GetCreatedByCn() != "admin.example.com" || ch.GetId() == "" { + t.Fatalf("challenge = %v", ch) + } + if len(ch.GetBoundNames()) != 1 || ch.GetBoundNames()[0] != "sw1.example.com" { + t.Fatalf("bound names = %v, want them normalized", ch.GetBoundNames()) + } + if got := ch.GetExpiresAt().AsTime().Sub(ch.GetCreatedAt().AsTime()); got != 10*time.Minute { + t.Fatalf("TTL = %s, want 10m", got) + } + pw2, _ := f.mint("cisco-device", 0) + if pw2 == pw { + t.Fatal("two mints returned the same challenge") + } + list, err := f.srv.ListScepChallenges(f.ctx, &cryptosv1.ListScepChallengesRequest{}) + if err != nil { + t.Fatal(err) + } + if len(list.GetChallenges()) != 2 { + t.Fatalf("listed %d challenges, want 2", len(list.GetChallenges())) + } + if list.GetChallenges()[0].GetId() != ch.GetId() { + t.Fatal("the list is not soonest-to-expire first") + } + if d := list.GetChallenges()[1].GetExpiresAt().AsTime().Sub(list.GetChallenges()[1].GetCreatedAt().AsTime()); d != DefaultChallengeTTL { + t.Fatalf("a zero TTL gave %s, want %s", d, DefaultChallengeTTL) + } +} + +func TestMintScepChallengeRejections(t *testing.T) { + f := newFixture(t) + for _, tc := range []struct { + name string + req *cryptosv1.MintScepChallengeRequest + }{ + {"no profile with several configured", &cryptosv1.MintScepChallengeRequest{}}, + {"unknown profile", &cryptosv1.MintScepChallengeRequest{Profile: "nope"}}, + {"ttl over seven days", &cryptosv1.MintScepChallengeRequest{Profile: "cisco-device", TtlSeconds: 604801}}, + {"bound name outside the allowlist", &cryptosv1.MintScepChallengeRequest{Profile: "cisco-device", BoundNames: []string{"sw.example.org"}}}, + {"bound name that is an IP", &cryptosv1.MintScepChallengeRequest{Profile: "cisco-device", BoundNames: []string{"10.0.0.1"}}}, + {"bound wildcard", &cryptosv1.MintScepChallengeRequest{Profile: "cisco-device", BoundNames: []string{"*.example.com"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := f.srv.MintScepChallenge(f.ctx, tc.req, "admin"); status.Code(err) != codes.InvalidArgument { + t.Fatalf("MintScepChallenge = %v, want InvalidArgument", err) + } + }) + } + if _, err := f.srv.MintScepChallenge(f.ctx, &cryptosv1.MintScepChallengeRequest{Profile: "cisco-device", TtlSeconds: 604800}, "admin"); err != nil { + t.Fatalf("a seven-day TTL is the maximum and must pass: %v", err) + } +} + +func TestMintScepChallengeSingleProfileDefault(t *testing.T) { + f := newFixtureWith(t, []Profile{{Name: "cisco-device", MinRSABits: 2048}}) + resp, err := f.srv.MintScepChallenge(f.ctx, &cryptosv1.MintScepChallengeRequest{}, "admin") + if err != nil { + t.Fatalf("MintScepChallenge with the only profile implied: %v", err) + } + if resp.GetChallenge().GetProfile() != "cisco-device" { + t.Fatalf("profile = %q", resp.GetChallenge().GetProfile()) + } +} + +func TestListAndRevokeSkipExpiredChallenges(t *testing.T) { + f := newFixture(t) + _, short := f.mint("cisco-device", time.Minute) + _, long := f.mint("cisco-device", time.Hour) + f.clock.advance(2 * time.Minute) + list, _ := f.srv.ListScepChallenges(f.ctx, &cryptosv1.ListScepChallengesRequest{}) + if len(list.GetChallenges()) != 1 || list.GetChallenges()[0].GetId() != long.GetId() { + t.Fatalf("list = %v, want only the unexpired challenge", list.GetChallenges()) + } + if _, err := f.srv.RevokeScepChallenge(f.ctx, &cryptosv1.RevokeScepChallengeRequest{Id: short.GetId()}); status.Code(err) != codes.NotFound { + t.Fatalf("revoking an expired challenge = %v, want NotFound", err) + } + if _, err := f.srv.RevokeScepChallenge(f.ctx, &cryptosv1.RevokeScepChallengeRequest{Id: long.GetId()}); err != nil { + t.Fatalf("Revoke: %v", err) + } + if _, err := f.srv.RevokeScepChallenge(f.ctx, &cryptosv1.RevokeScepChallengeRequest{Id: long.GetId()}); status.Code(err) != codes.NotFound { + t.Fatalf("revoking twice = %v, want NotFound", err) + } + if _, err := f.srv.RevokeScepChallenge(f.ctx, &cryptosv1.RevokeScepChallengeRequest{}); status.Code(err) != codes.InvalidArgument { + t.Fatalf("revoking without an id = %v, want InvalidArgument", err) + } +} + +func TestQueueRPCsOnUnknownIDs(t *testing.T) { + f := newFixture(t) + if _, err := f.srv.ApproveScepEnrollment(f.ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: "nope"}); status.Code(err) != codes.NotFound { + t.Fatalf("Approve unknown = %v", err) + } + if _, err := f.srv.RejectScepEnrollment(f.ctx, &cryptosv1.RejectScepEnrollmentRequest{Id: "nope"}); status.Code(err) != codes.NotFound { + t.Fatalf("Reject unknown = %v", err) + } + if _, err := f.srv.ApproveScepEnrollment(f.ctx, &cryptosv1.ApproveScepEnrollmentRequest{}); status.Code(err) != codes.InvalidArgument { + t.Fatalf("Approve without an id = %v", err) + } +} + +func TestChallengePasswordAttribute(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + plain, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: "sw.example.com"}, DNSNames: []string{"sw.example.com"}}, key) + if err != nil { + t.Fatal(err) + } + parse := func(der []byte) string { + csr, err := x509.ParseCertificateRequest(der) + if err != nil { + t.Fatal(err) + } + if err := csr.CheckSignature(); err != nil { + t.Fatalf("the test request does not verify: %v", err) + } + pw, err := challengePassword(csr) + if err != nil { + t.Fatalf("challengePassword: %v", err) + } + return pw + } + if pw := parse(plain); pw != "" { + t.Fatalf("a request without the attribute gave %q", pw) + } + if pw := parse(withChallenge(t, plain, "ABCDEF234567", key)); pw != "ABCDEF234567" { + t.Fatalf("challengePassword = %q", pw) + } + twice := withChallenge(t, withChallenge(t, plain, "ONE", key), "TWO", key) + csr, _ := x509.ParseCertificateRequest(twice) + if _, err := challengePassword(csr); err == nil { + t.Fatal("two challengePassword attributes must be refused") + } +} diff --git a/internal/scep/csr.go b/internal/scep/csr.go new file mode 100644 index 0000000..9a032e6 --- /dev/null +++ b/internal/scep/csr.go @@ -0,0 +1,114 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "strconv" +) + +// oidChallengePassword is the PKCS#9 challengePassword attribute (RFC 2985 +// section 5.4.1), which carries the one-time challenge in a PKCSReq. +var oidChallengePassword = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7} + +// maxChallengeLen bounds a presented challenge. Minted challenges are much +// shorter; the bound only keeps a hostile request from hashing megabytes. +const maxChallengeLen = 255 + +// challengePassword returns the challengePassword attribute of a certificate +// request, or "" when it has none. crypto/x509 drops attributes whose value is +// not a SET of SEQUENCEs, and a challengePassword is a bare DirectoryString, +// so it is read from the raw request info here. +func challengePassword(csr *x509.CertificateRequest) (string, error) { + var info struct { + Version int + Subject asn1.RawValue + PublicKeyInfo asn1.RawValue + Attributes asn1.RawValue + } + rest, err := asn1.Unmarshal(csr.RawTBSCertificateRequest, &info) + if err != nil || len(rest) != 0 { + return "", errors.New("scep: the certificate request info does not parse") + } + if info.Attributes.Class != asn1.ClassContextSpecific || info.Attributes.Tag != 0 { + return "", errors.New("scep: the certificate request has no attributes field") + } + var found string + seen := false + b := info.Attributes.Bytes + for len(b) > 0 { + var attr struct { + Type asn1.ObjectIdentifier + Values []asn1.RawValue `asn1:"set"` + } + b, err = asn1.Unmarshal(b, &attr) + if err != nil { + return "", errors.New("scep: a certificate request attribute does not parse") + } + if !attr.Type.Equal(oidChallengePassword) { + continue + } + if seen || len(attr.Values) != 1 { + return "", errors.New("scep: the certificate request carries more than one challengePassword") + } + seen = true + if rest, err := asn1.Unmarshal(attr.Values[0].FullBytes, &found); err != nil || len(rest) != 0 { + return "", errors.New("scep: the challengePassword is not a readable string") + } + if len(found) > maxChallengeLen { + return "", fmt.Errorf("scep: the challengePassword is longer than %d characters", maxChallengeLen) + } + } + return found, nil +} + +// keyDescription names a subject key the way ScepEnrollment.key_alg does, for +// example "RSA-2048" or "ECDSA-P384". +func keyDescription(pub any) string { + switch k := pub.(type) { + case *rsa.PublicKey: + return "RSA-" + strconv.Itoa(k.N.BitLen()) + case *ecdsa.PublicKey: + switch k.Curve.Params().BitSize { + case 256: + return "ECDSA-P256" + case 384: + return "ECDSA-P384" + case 521: + return "ECDSA-P521" + } + return "ECDSA" + } + return fmt.Sprintf("%T", pub) +} + +// keyID is the SHA-256 of a DER SubjectPublicKeyInfo, the handle a +// transaction is pinned to so a retransmit or a poll from another key is not +// mistaken for the original. +func keyID(spkiDER []byte) string { + sum := sha256.Sum256(spkiDER) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/scep/doc.go b/internal/scep/doc.go new file mode 100644 index 0000000..772a592 --- /dev/null +++ b/internal/scep/doc.go @@ -0,0 +1,68 @@ +// Package scep implements the server side of SCEP (RFC 8894), the enrolment +// protocol network equipment speaks: Cisco IOS and IOS-XE trustpoints, and +// most switch, router, firewall and VPN platforms that cannot run ACME. +// +// Implemented: GetCACaps, GetCACert (the CA and the RA certificate), and +// PKIOperation with PKCSReq, RenewalReq, CertPoll (GetCertInitial), GetCert +// and GetCRL. GetNextCACert is not offered. Content encryption is AES and +// signatures are SHA-256 or SHA-512 (SHA-384 is accepted too); DES, 3DES, +// MD5 and SHA-1 are refused by internal/cms. +// +// # Keys +// +// The CA key only signs certificates. Requests are encrypted to, and replies +// signed by, an RA certificate the node mints from its own CA: RSA 3072 with +// key usage digitalSignature and keyEncipherment, valid for a year, held on +// the encrypted state partition and rotated with an overlap during which the +// old and the new RA both decrypt (RAManager). The CMS work is internal/cms, +// which never holds a key: the RA key is passed in as a crypto.Signer and a +// crypto.Decrypter. +// +// # Authorization +// +// Initial enrolment (PKCSReq) is authorized by a one-time challenge an admin +// mints with MintScepChallenge. Only its SHA-256 is stored; it is single-use +// (consumed by the first request that presents it, whatever the outcome), +// expires, names the profile it issues from, and may be bound to the names it +// may request. Every name must also fall inside the configured allowlist, +// which is mandatory. Renewal (RenewalReq) is authorized by the certificate +// the device already holds: it must chain to this CA, be current and not be +// revoked, and the renewed certificate keeps its names and its profile. A +// PKCSReq signed by such a certificate is handled the same way: that is how +// renewal was done before RFC 8894 added RenewalReq, and how sscep and Cisco +// IOS rollover still do it. There is no static shared challenge. +// +// Each profile carries its own RSA key floor (2048 at the lowest, for Cisco +// trustpoints; 3072 by default), and may hold initial enrolments in a queue +// for an admin to approve or reject; the device is answered PENDING and polls. +// +// # Transactions +// +// Every transactionID is recorded with its outcome, so a retransmitted +// request or a CertPoll gets the same certificate rather than a second one, +// and a transactionID reused for a different key is refused. A request that +// fails is not recorded, so the device can try again with a fresh challenge. +// +// Issuance goes through the node's CA signer, so ca.Profile decides the +// extensions and every certificate is recorded for revocation before it is +// returned. Every decision is written to the audit log with the challenge id, +// never the challenge. +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ diff --git a/internal/scep/fixture_test.go b/internal/scep/fixture_test.go new file mode 100644 index 0000000..1a560ff --- /dev/null +++ b/internal/scep/fixture_test.go @@ -0,0 +1,623 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/ca" + "github.com/CryptOS-PKI/cryptos/internal/cms" + "github.com/CryptOS-PKI/cryptos/internal/storage/etcd" +) + +// testCA is an issuing CA for the tests, ECDSA P-384 like a real node. +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + + mu sync.Mutex + issued map[string]issuedCert + revoked map[string]bool +} + +type issuedCert struct { + der []byte + profile string +} + +func newTestCA(t *testing.T, cn string) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatalf("CA key: %v", err) + } + now := time.Now().UTC() + der, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: cn}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(5 * 365 * 24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + }, &key.PublicKey, nil, key) + if err != nil { + t.Fatalf("CA cert: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse CA cert: %v", err) + } + return &testCA{cert: cert, key: key, issued: map[string]issuedCert{}, revoked: map[string]bool{}} +} + +func (c *testCA) issue(_ context.Context, csrDER []byte, profile string, names []string, minRSABits int) ([]byte, error) { + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + return nil, err + } + now := time.Now().UTC() + der, _, err := ca.Sign(ca.Profile{ + Subject: csr.Subject, + NotBefore: now, + NotAfter: now.Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + DNSNames: names, + MinRSAKeyBits: minRSABits, + }, csr.PublicKey, c.cert, c.key) + if err != nil { + return nil, err + } + cert, _ := x509.ParseCertificate(der) + c.mu.Lock() + c.issued[cert.SerialNumber.Text(16)] = issuedCert{der: der, profile: profile} + c.mu.Unlock() + return der, nil +} + +func (c *testCA) chain(context.Context) ([]*x509.Certificate, error) { + return []*x509.Certificate{c.cert}, nil +} + +func (c *testCA) isRevoked(_ context.Context, serial string) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.revoked[serial], nil +} + +func (c *testCA) lookup(_ context.Context, serial string) ([]byte, string, bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + ic, ok := c.issued[serial] + return ic.der, ic.profile, ok, nil +} + +func (c *testCA) crl(context.Context) ([]byte, error) { + now := time.Now().UTC() + return x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), ThisUpdate: now, NextUpdate: now.Add(time.Hour), + }, c.cert, c.key) +} + +func (c *testCA) mintRA(_ context.Context, pub *rsa.PublicKey, notBefore, notAfter time.Time) ([]byte, error) { + der, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: c.cert.Subject.CommonName + " SCEP RA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + }, pub, c.cert, c.key) + return der, err +} + +// sign issues a certificate over pub directly, for a renewal signer that +// never went through SCEP. +func (c *testCA) sign(t *testing.T, pub crypto.PublicKey, profile string, names ...string) *x509.Certificate { + t.Helper() + now := time.Now().UTC() + der, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: names[0]}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(30 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + DNSNames: names, + MinRSAKeyBits: 2048, + }, pub, c.cert, c.key) + if err != nil { + t.Fatalf("sign: %v", err) + } + cert, _ := x509.ParseCertificate(der) + c.mu.Lock() + c.issued[cert.SerialNumber.Text(16)] = issuedCert{der: der, profile: profile} + c.mu.Unlock() + return cert +} + +type memAuditor struct { + mu sync.Mutex + events []*cryptosv1.AuditEvent +} + +func (a *memAuditor) Append(e *cryptosv1.AuditEvent) error { + a.mu.Lock() + defer a.mu.Unlock() + a.events = append(a.events, e) + return nil +} + +func (a *memAuditor) last() *cryptosv1.AuditEvent { + a.mu.Lock() + defer a.mu.Unlock() + if len(a.events) == 0 { + return nil + } + return a.events[len(a.events)-1] +} + +// clock is a settable time source. +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +// fixture is a running SCEP server over a test CA and an embedded etcd. +type fixture struct { + t *testing.T + ctx context.Context + ca *testCA + store *Store + ras *RAManager + srv *Server + http *httptest.Server + audit *memAuditor + clock *clock + logs *bytes.Buffer + logsMu *sync.Mutex +} + +// Profiles used across the tests: a Cisco profile at the 2048 floor, a +// default-floor profile, and one that holds enrolments for approval. +func testProfiles() []Profile { + return []Profile{ + {Name: "cisco-device", MinRSABits: 2048}, + {Name: "strict-device"}, + {Name: "approved-device", MinRSABits: 2048, RequireApproval: true}, + } +} + +// raKeys are RSA 3072 keys generated once for the whole package, so each +// fixture does not spend a second generating its RA key. Each fixture hands +// them out in order, so the RAs of one fixture never share a key. +var ( + raKeysOnce sync.Once + raKeys []*rsa.PrivateKey +) + +func testRAKeygen(t *testing.T) func() (*rsa.PrivateKey, error) { + t.Helper() + raKeysOnce.Do(func() { + for i := 0; i < 3; i++ { + k, err := rsa.GenerateKey(rand.Reader, RAKeyBits) + if err != nil { + panic(err) + } + raKeys = append(raKeys, k) + } + }) + next := 0 + return func() (*rsa.PrivateKey, error) { + k := raKeys[next%len(raKeys)] + next++ + return k, nil + } +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + return newFixtureWith(t, testProfiles()) +} + +func newFixtureWith(t *testing.T, profiles []Profile) *fixture { + t.Helper() + srv, err := etcd.Open(t.TempDir()) + if err != nil { + t.Fatalf("etcd.Open: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + cli, err := srv.Client() + if err != nil { + t.Fatalf("etcd.Client: %v", err) + } + t.Cleanup(func() { _ = cli.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + f := &fixture{t: t, ctx: ctx, ca: newTestCA(t, "Test Issuing CA"), store: NewStore(cli), audit: &memAuditor{}, + clock: &clock{now: time.Now().UTC()}, logs: &bytes.Buffer{}, logsMu: &sync.Mutex{}} + logf := func(format string, args ...any) { + f.logsMu.Lock() + defer f.logsMu.Unlock() + fmt.Fprintf(f.logs, format+"\n", args...) + } + mint := func(ctx context.Context, pub *rsa.PublicKey, notBefore, notAfter time.Time) ([]byte, error) { + return f.ca.mintRA(ctx, pub, notBefore, notAfter) + } + f.ras, err = NewRAManager(f.store, mint, func(context.Context) (*x509.Certificate, error) { return f.ca.cert, nil }, + RAOptions{Validity: 365 * 24 * time.Hour, Overlap: 30 * 24 * time.Hour, Now: f.clock.Now, Logf: logf}) + if err != nil { + t.Fatalf("NewRAManager: %v", err) + } + f.ras.keygen = testRAKeygen(t) + if err := f.ras.Ensure(ctx); err != nil { + t.Fatalf("RA Ensure: %v", err) + } + f.srv, err = NewServer(f.store, f.ras, Deps{ + Issue: f.ca.issue, CAChain: f.ca.chain, Revoked: f.ca.isRevoked, Issued: f.ca.lookup, CRL: f.ca.crl, Auditor: f.audit, + }, Options{Profiles: profiles, AllowedSuffixes: []string{"example.com"}, Now: f.clock.Now, Logf: logf}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + f.http = httptest.NewServer(f.srv.Routes()) + t.Cleanup(f.http.Close) + return f +} + +func (f *fixture) logText() string { + f.logsMu.Lock() + defer f.logsMu.Unlock() + return f.logs.String() +} + +func (f *fixture) raCert() *x509.Certificate { + ra, ok := f.ras.Current() + if !ok { + f.t.Fatal("no RA") + } + return ra.Cert +} + +func (f *fixture) mint(profile string, ttl time.Duration, bound ...string) (string, *cryptosv1.ScepChallenge) { + f.t.Helper() + resp, err := f.srv.MintScepChallenge(f.ctx, &cryptosv1.MintScepChallengeRequest{ + Profile: profile, TtlSeconds: uint32(ttl / time.Second), BoundNames: bound, + }, "admin.example.com") + if err != nil { + f.t.Fatalf("MintScepChallenge: %v", err) + } + return resp.GetChallengePassword(), resp.GetChallenge() +} + +// device is a SCEP client: an RSA key with a self-signed certificate over it. +type device struct { + key *rsa.PrivateKey + cert *x509.Certificate +} + +// newDevice returns a device with a fresh key of the given size. +func newDevice(t *testing.T, bits int) *device { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + t.Fatalf("device key: %v", err) + } + return deviceFor(t, key, "scep-client") +} + +func deviceFor(t *testing.T, key *rsa.PrivateKey, cn string) *device { + t.Helper() + now := time.Now().UTC() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("self-signed: %v", err) + } + cert, _ := x509.ParseCertificate(der) + return &device{key: key, cert: cert} +} + +// csr builds a PKCS#10 request for the device key with a challengePassword +// attribute, which crypto/x509 cannot write, so it is spliced into the request +// info and the request re-signed. +func (d *device) csr(t *testing.T, cn string, dnsNames []string, challenge string) []byte { + t.Helper() + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, DNSNames: dnsNames, SignatureAlgorithm: x509.SHA256WithRSA, + }, d.key) + if err != nil { + t.Fatalf("CreateCertificateRequest: %v", err) + } + if challenge == "" { + return der + } + return withChallenge(t, der, challenge, d.key) +} + +func withChallenge(t *testing.T, csrDER []byte, challenge string, key *rsa.PrivateKey) []byte { + t.Helper() + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + t.Fatal(err) + } + var info struct { + Version int + Subject asn1.RawValue + SPKI asn1.RawValue + Attrs asn1.RawValue + } + if _, err := asn1.Unmarshal(csr.RawTBSCertificateRequest, &info); err != nil { + t.Fatal(err) + } + attr, err := asn1.Marshal(struct { + Type asn1.ObjectIdentifier + Values []asn1.RawValue `asn1:"set"` + }{oidChallengePassword, []asn1.RawValue{{Tag: asn1.TagPrintableString, Bytes: []byte(challenge)}}}) + if err != nil { + t.Fatal(err) + } + attrs := asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: append(append([]byte(nil), info.Attrs.Bytes...), attr...)} + tbs, err := asn1.Marshal(struct { + Version int + Subject asn1.RawValue + SPKI asn1.RawValue + Attrs asn1.RawValue + }{info.Version, info.Subject, info.SPKI, attrs}) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(tbs) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + out, err := asn1.Marshal(struct { + TBS asn1.RawValue + SigAlg pkix.AlgorithmIdentifier + Sig asn1.BitString + }{asn1.RawValue{FullBytes: tbs}, pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}, Parameters: asn1.NullRawValue}, asn1.BitString{Bytes: sig, BitLength: len(sig) * 8}}) + if err != nil { + t.Fatal(err) + } + return out +} + +// msgOpts shapes one pkiMessage. +type msgOpts struct { + signer *x509.Certificate + signerKey crypto.Signer + recipient *x509.Certificate + txID string + alg cms.ContentEncryption + hash crypto.Hash +} + +// message builds a pkiMessage of type mt around content. +func (f *fixture) message(t *testing.T, mt MessageType, content []byte, o msgOpts) []byte { + t.Helper() + if o.recipient == nil { + o.recipient = f.raCert() + } + if o.alg == 0 { + o.alg = cms.AES256CBC + } + if o.hash == 0 { + o.hash = crypto.SHA256 + } + env, err := cms.Encrypt(content, []*x509.Certificate{o.recipient}, o.alg, cms.EncryptOptions{}) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + nonce := make([]byte, 16) + _, _ = rand.Read(nonce) + signer := cms.Signer{Certificate: o.signer, Key: o.signerKey, Hash: o.hash} + for _, a := range []struct { + oid asn1.ObjectIdentifier + v any + }{ + {oidMessageType, printable(strconv.Itoa(int(mt)))}, + {oidTransactionID, printable(o.txID)}, + {oidSenderNonce, nonce}, + } { + attr, err := cms.NewAttribute(a.oid, a.v) + if err != nil { + t.Fatal(err) + } + signer.Attributes = append(signer.Attributes, attr) + } + msg, err := cms.Sign(cms.OIDData, env, []cms.Signer{signer}, cms.SignOptions{Certificates: [][]byte{o.signer.Raw}}) + if err != nil { + t.Fatalf("Sign: %v", err) + } + return msg +} + +// certRep is a parsed CertRep. +type certRep struct { + status PKIStatus + failInfo FailInfo + txID string + recNonce []byte + signer *x509.Certificate + certs []*x509.Certificate + crls [][]byte +} + +// post sends a PKIOperation and parses the CertRep, decrypting it with key. +func (f *fixture) post(t *testing.T, msg []byte, key *rsa.PrivateKey, signer *x509.Certificate) certRep { + t.Helper() + resp, err := http.Post(f.http.URL+PathPKIClient+"?operation=PKIOperation", contentTypePKIMessage, bytes.NewReader(msg)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body := new(bytes.Buffer) + _, _ = body.ReadFrom(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PKIOperation status %d: %s", resp.StatusCode, body.String()) + } + if ct := resp.Header.Get("Content-Type"); ct != contentTypePKIMessage { + t.Fatalf("Content-Type = %q, want %q", ct, contentTypePKIMessage) + } + return parseCertRep(t, body.Bytes(), key, signer) +} + +func parseCertRep(t *testing.T, der []byte, key *rsa.PrivateKey, deviceCert *x509.Certificate) certRep { + t.Helper() + sd, err := cms.ParseSignedData(der) + if err != nil { + t.Fatalf("CertRep does not parse: %v", err) + } + opts := cms.VerifyOptions{} + if sd.Content == nil { + opts.Content = []byte{} + } + signers, err := sd.Verify(opts) + if err != nil { + t.Fatalf("CertRep does not verify: %v", err) + } + si := sd.SignerInfos[0] + var rep certRep + rep.signer = signers[0] + var mt, st string + if err := si.SignedAttribute(oidMessageType, &mt); err != nil || mt != "3" { + t.Fatalf("messageType = %q (%v), want 3", mt, err) + } + if err := si.SignedAttribute(oidPKIStatus, &st); err != nil { + t.Fatalf("pkiStatus: %v", err) + } + n, _ := strconv.Atoi(st) + rep.status = PKIStatus(n) + if err := si.SignedAttribute(oidTransactionID, &rep.txID); err != nil { + t.Fatalf("transactionID: %v", err) + } + if err := si.SignedAttribute(oidRecipientNonce, &rep.recNonce); err != nil { + t.Fatalf("recipientNonce: %v", err) + } + var sn []byte + if err := si.SignedAttribute(oidSenderNonce, &sn); err != nil || len(sn) != nonceLen { + t.Fatalf("senderNonce = %x (%v), want %d bytes", sn, err, nonceLen) + } + if rep.status == StatusFailure { + var fi string + if err := si.SignedAttribute(oidFailInfo, &fi); err != nil { + t.Fatalf("failInfo: %v", err) + } + n, _ := strconv.Atoi(fi) + rep.failInfo = FailInfo(n) + return rep + } + if rep.status != StatusSuccess { + if sd.Content != nil { + t.Fatal("a PENDING CertRep carries a pkcsPKIEnvelope") + } + return rep + } + env, err := cms.ParseEnvelopedData(sd.Content) + if err != nil { + t.Fatalf("CertRep envelope: %v", err) + } + plain, err := env.Decrypt(deviceCert, key) + if err != nil { + t.Fatalf("CertRep decrypt: %v", err) + } + inner, err := cms.ParseSignedData(plain) + if err != nil { + t.Fatalf("CertRep degenerate: %v", err) + } + for _, c := range inner.Certificates { + cert, err := x509.ParseCertificate(c) + if err != nil { + t.Fatal(err) + } + rep.certs = append(rep.certs, cert) + } + rep.crls = inner.CRLs + return rep +} + +// enrol sends a PKCSReq for the device and returns the CertRep. +func (f *fixture) enrol(t *testing.T, d *device, txID, challenge, cn string, dns ...string) certRep { + t.Helper() + msg := f.message(t, MessageTypePKCSReq, d.csr(t, cn, dns, challenge), msgOpts{signer: d.cert, signerKey: d.key, txID: txID}) + return f.post(t, msg, d.key, d.cert) +} + +func (f *fixture) poll(t *testing.T, d *device, txID string) certRep { + t.Helper() + ias, _ := asn1.Marshal(struct { + Issuer asn1.RawValue + Subject asn1.RawValue + }{asn1.RawValue{FullBytes: f.ca.cert.RawSubject}, asn1.RawValue{FullBytes: d.cert.RawSubject}}) + msg := f.message(t, MessageTypeCertPoll, ias, msgOpts{signer: d.cert, signerKey: d.key, txID: txID}) + return f.post(t, msg, d.key, d.cert) +} + +func wantStatus(t *testing.T, rep certRep, st PKIStatus) { + t.Helper() + if rep.status != st { + if rep.status == StatusFailure { + t.Fatalf("pkiStatus = FAILURE %s, want %s", rep.failInfo, st) + } + t.Fatalf("pkiStatus = %s, want %s", rep.status, st) + } +} + +func wantFailure(t *testing.T, rep certRep, fi FailInfo) { + t.Helper() + if rep.status != StatusFailure { + t.Fatalf("pkiStatus = %s, want FAILURE %s", rep.status, fi) + } + if rep.failInfo != fi { + t.Fatalf("failInfo = %s, want %s", rep.failInfo, fi) + } +} diff --git a/internal/scep/http.go b/internal/scep/http.go new file mode 100644 index 0000000..af39f7d --- /dev/null +++ b/internal/scep/http.go @@ -0,0 +1,226 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/CryptOS-PKI/cryptos/internal/cms" +) + +// The paths SCEP is served on. /cgi-bin/pkiclient.exe is what Cisco IOS and +// many other clients append by default; /scep is the short form. +const ( + PathPKIClient = "/cgi-bin/pkiclient.exe" + PathSCEP = "/scep" +) + +// Media types (RFC 8894 sections 4.2 to 4.4). +const ( + contentTypeCACaps = "text/plain" + contentTypeCARACert = "application/x-x509-ca-ra-cert" + contentTypePKIMessage = "application/x-pki-message" +) + +// Capabilities is the GetCACaps answer. It lists only what this server does: +// AES content encryption, SHA-256 and SHA-512 signatures, POST for +// PKIOperation, and RenewalReq. DES, 3DES, MD5 and SHA-1 are refused, and +// GetNextCACert is not offered. +var Capabilities = []string{"AES", "POSTPKIOperation", "Renewal", "SHA-256", "SHA-512"} + +// maxMessageBytes caps a PKIOperation message. The largest legitimate one is +// an encrypted PKCS#10 request with its signer certificate. +const maxMessageBytes = 1 << 20 + +// Routes returns the SCEP mux: the operations on PathPKIClient and PathSCEP. +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(PathPKIClient, s.handle) + mux.HandleFunc(PathSCEP, s.handle) + return mux +} + +// Mount adds the SCEP paths to mux, for sharing the plain-HTTP CRL/OCSP +// listener when both use one port. +func (s *Server) Mount(mux *http.ServeMux) { + mux.HandleFunc(PathPKIClient, s.handle) + mux.HandleFunc(PathSCEP, s.handle) +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + op := r.URL.Query().Get("operation") + remote := r.RemoteAddr + s.opts.Logf("scep: %s %s operation=%q from %s", r.Method, r.URL.Path, op, remote) + switch op { + case "GetCACaps": + s.handleCACaps(w, r) + case "GetCACert": + s.handleCACert(w, r) + case "PKIOperation": + s.handlePKIOperation(w, r) + case "GetNextCACert": + http.Error(w, "GetNextCACert is not offered by this server", http.StatusNotImplemented) + case "": + http.Error(w, "the operation parameter is required", http.StatusBadRequest) + default: + http.Error(w, fmt.Sprintf("unknown operation %q", op), http.StatusBadRequest) + } +} + +// handleCACaps answers GetCACaps (RFC 8894 section 3.5.2). +func (s *Server) handleCACaps(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GetCACaps takes GET", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", contentTypeCACaps) + _, _ = io.WriteString(w, strings.Join(Capabilities, "\n")+"\n") +} + +// handleCACert answers GetCACert with the CA certificate and the newest RA +// certificate in a degenerate SignedData (RFC 8894 section 4.2.1.2). It is +// unauthenticated on purpose: a device that does not yet trust this CA has to +// fetch it, and the operator checks its fingerprint out of band. +func (s *Server) handleCACert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GetCACert takes GET", http.StatusMethodNotAllowed) + return + } + body, err := s.caRACert(r.Context()) + if err != nil { + s.opts.Logf("scep: GetCACert: %v", err) + http.Error(w, "the CA certificate is unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", contentTypeCARACert) + _, _ = w.Write(body) +} + +func (s *Server) caRACert(ctx context.Context) ([]byte, error) { + chain, err := s.deps.CAChain(ctx) + if err != nil { + return nil, fmt.Errorf("load the CA chain: %w", err) + } + if len(chain) == 0 { + return nil, errors.New("this node has no CA certificate") + } + ra, ok := s.ras.Current() + if !ok { + return nil, errors.New("no RA certificate is available") + } + return cms.Degenerate([][]byte{chain[0].Raw, ra.Cert.Raw}, nil) +} + +// handlePKIOperation reads the message from the POST body or the GET message +// parameter (RFC 8894 section 4.3). +func (s *Server) handlePKIOperation(w http.ResponseWriter, r *http.Request) { + var der []byte + switch r.Method { + case http.MethodPost: + raw, err := io.ReadAll(io.LimitReader(r.Body, maxMessageBytes+1)) + if err != nil { + http.Error(w, "reading the request body failed", http.StatusBadRequest) + return + } + if len(raw) > maxMessageBytes { + http.Error(w, "the message is too large", http.StatusRequestEntityTooLarge) + return + } + der = raw + case http.MethodGet: + msg := r.URL.Query().Get("message") + if msg == "" { + http.Error(w, "PKIOperation over GET needs the message parameter", http.StatusBadRequest) + return + } + decoded, err := decodeMessageParam(msg) + if err != nil { + http.Error(w, "the message parameter is not base64", http.StatusBadRequest) + return + } + der = decoded + default: + http.Error(w, "PKIOperation takes GET or POST", http.StatusMethodNotAllowed) + return + } + if len(der) == 0 { + http.Error(w, "the message is empty", http.StatusBadRequest) + return + } + + resp, err := s.pkiOperation(r.Context(), der, r.RemoteAddr) + if err != nil { + if errors.Is(err, errMalformed) { + http.Error(w, "the message is not a SCEP pkiMessage this server can answer", http.StatusBadRequest) + return + } + s.opts.Logf("scep: PKIOperation: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", contentTypePKIMessage) + _, _ = w.Write(resp) +} + +// decodeMessageParam decodes the GET message parameter. RFC 8894 says base64; +// clients differ on padding, line breaks and whether "+" survived URL +// decoding as a space, so each of those is tolerated. +func decodeMessageParam(msg string) ([]byte, error) { + msg = strings.Map(func(r rune) rune { + switch r { + case '\r', '\n', '\t': + return -1 + case ' ': + return '+' + } + return r + }, msg) + for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { + if b, err := enc.DecodeString(msg); err == nil { + return b, nil + } + } + return nil, errors.New("not base64") +} + +// Serve starts a plain-HTTP server for h on addr and returns a stop closure. +// The listen is synchronous, so a bind failure reaches the caller. SCEP is +// plain HTTP by design: the CMS envelope carries confidentiality and +// integrity. +func Serve(_ context.Context, addr string, h http.Handler) (func(context.Context) error, error) { + lis, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("scep: listen %s: %w", addr, err) + } + srv := &http.Server{ + Handler: h, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + } + go func() { _ = srv.Serve(lis) }() + return srv.Shutdown, nil +} diff --git a/internal/scep/message.go b/internal/scep/message.go new file mode 100644 index 0000000..97a719e --- /dev/null +++ b/internal/scep/message.go @@ -0,0 +1,356 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + "strconv" + + "github.com/CryptOS-PKI/cryptos/internal/cms" +) + +// SCEP authenticated attributes (RFC 8894 section 3.2.1), under +// id-VeriSign pki attributes 2.16.840.1.113733.1.9. +var ( + oidMessageType = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2} + oidPKIStatus = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 3} + oidFailInfo = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 4} + oidSenderNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5} + oidRecipientNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 6} + oidTransactionID = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7} +) + +// Digest algorithm OIDs a SCEP signer may use (RFC 5754). The CMS layer has +// already refused anything else by the time these are read. +var ( + oidSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1} + oidSHA384 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 2} + oidSHA512 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3} + + oidAES256CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42} +) + +// MessageType is the SCEP messageType attribute (RFC 8894 section 3.2.1.2). +type MessageType int + +// The message types this server reads and writes. +const ( + MessageTypeCertRep MessageType = 3 + MessageTypeRenewalReq MessageType = 17 + MessageTypePKCSReq MessageType = 19 + MessageTypeCertPoll MessageType = 20 + MessageTypeGetCert MessageType = 21 + MessageTypeGetCRL MessageType = 22 +) + +func (m MessageType) String() string { + switch m { + case MessageTypeCertRep: + return "CertRep" + case MessageTypeRenewalReq: + return "RenewalReq" + case MessageTypePKCSReq: + return "PKCSReq" + case MessageTypeCertPoll: + return "CertPoll" + case MessageTypeGetCert: + return "GetCert" + case MessageTypeGetCRL: + return "GetCRL" + } + return "messageType(" + strconv.Itoa(int(m)) + ")" +} + +// PKIStatus is the pkiStatus attribute of a CertRep (RFC 8894 section +// 3.2.1.3). +type PKIStatus int + +// The pkiStatus values. +const ( + StatusSuccess PKIStatus = 0 + StatusFailure PKIStatus = 2 + StatusPending PKIStatus = 3 +) + +func (s PKIStatus) String() string { + switch s { + case StatusSuccess: + return "SUCCESS" + case StatusFailure: + return "FAILURE" + case StatusPending: + return "PENDING" + } + return "pkiStatus(" + strconv.Itoa(int(s)) + ")" +} + +// FailInfo is the failInfo attribute of a FAILURE CertRep (RFC 8894 section +// 3.2.1.4). +type FailInfo int + +// The failInfo values. +const ( + FailBadAlg FailInfo = 0 + FailBadMessageCheck FailInfo = 1 + FailBadRequest FailInfo = 2 + FailBadTime FailInfo = 3 + FailBadCertID FailInfo = 4 +) + +func (f FailInfo) String() string { + switch f { + case FailBadAlg: + return "badAlg" + case FailBadMessageCheck: + return "badMessageCheck" + case FailBadRequest: + return "badRequest" + case FailBadTime: + return "badTime" + case FailBadCertID: + return "badCertId" + } + return "failInfo(" + strconv.Itoa(int(f)) + ")" +} + +// nonceLen is the size of the senderNonce this server writes. RFC 8894 +// section 3.2.1.5 calls for 16 bytes. +const nonceLen = 16 + +// errMalformed marks a request that cannot be answered with a CertRep at all, +// because the attributes a reply has to echo are missing or unreadable. The +// HTTP layer answers it with 400. +var errMalformed = errors.New("scep: malformed pkiMessage") + +// request is a parsed, signature-checked SCEP pkiMessage. +type request struct { + Type MessageType + TransactionID string + SenderNonce []byte + // Signer is the certificate the message was signed with: a self-signed + // certificate over the requester's key for PKCSReq, the current + // certificate for RenewalReq. + Signer *x509.Certificate + // Hash is the signer's digest algorithm, mirrored in the reply. + Hash crypto.Hash + // Envelope is the pkcsPKIEnvelope, still encrypted. + Envelope *cms.EnvelopedData + // VerifyErr is set when the attributes were readable but the signature + // did not verify. Such a request is answered FAILURE badMessageCheck and + // nothing else is done with it. + VerifyErr error +} + +// parseRequest parses a pkiMessage (RFC 8894 section 3.2), verifies its +// signature and reads the authenticated attributes. A returned request with +// VerifyErr set carries enough to reply to; an error wrapping errMalformed +// does not. +func parseRequest(der []byte) (*request, error) { + sd, err := cms.ParseSignedData(der) + if err != nil { + return nil, fmt.Errorf("%w: %v", errMalformed, err) + } + if !sd.ContentType.Equal(cms.OIDData) || sd.Content == nil { + return nil, fmt.Errorf("%w: the SignedData does not carry id-data content", errMalformed) + } + if len(sd.SignerInfos) != 1 { + return nil, fmt.Errorf("%w: want exactly one signer, got %d", errMalformed, len(sd.SignerInfos)) + } + si := &sd.SignerInfos[0] + + req := &request{} + var msgType string + if err := si.SignedAttribute(oidMessageType, &msgType); err != nil { + return nil, fmt.Errorf("%w: messageType: %v", errMalformed, err) + } + n, err := strconv.Atoi(msgType) + if err != nil { + return nil, fmt.Errorf("%w: messageType %q is not a number", errMalformed, msgType) + } + req.Type = MessageType(n) + if err := si.SignedAttribute(oidTransactionID, &req.TransactionID); err != nil || req.TransactionID == "" { + return nil, fmt.Errorf("%w: transactionID is missing or unreadable", errMalformed) + } + if len(req.TransactionID) > maxTransactionIDLen { + return nil, fmt.Errorf("%w: transactionID is longer than %d characters", errMalformed, maxTransactionIDLen) + } + if err := si.SignedAttribute(oidSenderNonce, &req.SenderNonce); err != nil || len(req.SenderNonce) == 0 { + return nil, fmt.Errorf("%w: senderNonce is missing or unreadable", errMalformed) + } + if req.Hash, err = hashForOID(si.DigestAlgorithm.Algorithm); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformed, err) + } + + signers, verr := sd.Verify(cms.VerifyOptions{}) + if verr != nil { + req.VerifyErr = verr + req.Signer = certificateFor(sd, si) + if req.Signer == nil { + return nil, fmt.Errorf("%w: the signer certificate is not in the message: %v", errMalformed, verr) + } + return req, nil + } + req.Signer = signers[0] + + env, err := cms.ParseEnvelopedData(sd.Content) + if err != nil { + req.VerifyErr = fmt.Errorf("the pkcsPKIEnvelope does not parse: %w", err) + return req, nil + } + req.Envelope = env + return req, nil +} + +// maxTransactionIDLen bounds a transactionID. The RFC fixes no length; real +// clients send a hex or base64 digest of their key, well under this. +const maxTransactionIDLen = 256 + +// certificateFor finds the signer's certificate among those carried in the +// message, for replying to a request whose signature did not verify. +func certificateFor(sd *cms.SignedData, si *cms.SignerInfo) *x509.Certificate { + for _, der := range sd.Certificates { + c, err := x509.ParseCertificate(der) + if err != nil { + continue + } + if si.IssuerAndSerial != nil && si.IssuerAndSerial.SerialNumber.Cmp(c.SerialNumber) == 0 && + string(si.IssuerAndSerial.Issuer.FullBytes) == string(c.RawIssuer) { + return c + } + if si.SubjectKeyID != nil && string(si.SubjectKeyID) == string(c.SubjectKeyId) { + return c + } + } + return nil +} + +func hashForOID(oid asn1.ObjectIdentifier) (crypto.Hash, error) { + switch { + case oid.Equal(oidSHA256): + return crypto.SHA256, nil + case oid.Equal(oidSHA384): + return crypto.SHA384, nil + case oid.Equal(oidSHA512): + return crypto.SHA512, nil + } + return 0, fmt.Errorf("digest algorithm %s is not accepted", oid) +} + +// contentEncryptionFor mirrors the request's content encryption in the reply, +// so a client that asked for AES-256 gets AES-256 back. +func contentEncryptionFor(env *cms.EnvelopedData) cms.ContentEncryption { + if env != nil && env.ContentEncryptionAlgorithm.Algorithm.Equal(oidAES256CBC) { + return cms.AES256CBC + } + return cms.AES128CBC +} + +// reply is what a CertRep says. +type reply struct { + Status PKIStatus + FailInfo FailInfo + // Certs are the DER certificates of a SUCCESS reply; CRL is the DER CRL + // of a GetCRL SUCCESS. Exactly one is set on SUCCESS. + Certs [][]byte + CRL []byte + // Reason is for the logs and the audit trail only. RFC 8894 gives the + // client a failInfo code and nothing else. + Reason string +} + +func failure(fi FailInfo, format string, args ...any) reply { + return reply{Status: StatusFailure, FailInfo: fi, Reason: fmt.Sprintf(format, args...)} +} + +// buildCertRep writes the CertRep for req (RFC 8894 section 3.3.2), signed by +// the RA. On SUCCESS the certificates or CRL travel in a degenerate SignedData +// encrypted to the request signer; on FAILURE and PENDING there is no +// pkcsPKIEnvelope and the SignedData carries no content. +func buildCertRep(req *request, ra *raCert, rep reply) ([]byte, error) { + senderNonce := make([]byte, nonceLen) + if _, err := rand.Read(senderNonce); err != nil { + return nil, fmt.Errorf("scep: senderNonce: %w", err) + } + attrs := []struct { + oid asn1.ObjectIdentifier + val any + }{ + {oidMessageType, printable(strconv.Itoa(int(MessageTypeCertRep)))}, + {oidPKIStatus, printable(strconv.Itoa(int(rep.Status)))}, + {oidTransactionID, printable(req.TransactionID)}, + {oidSenderNonce, senderNonce}, + {oidRecipientNonce, req.SenderNonce}, + } + if rep.Status == StatusFailure { + attrs = append(attrs, struct { + oid asn1.ObjectIdentifier + val any + }{oidFailInfo, printable(strconv.Itoa(int(rep.FailInfo)))}) + } + signer := cms.Signer{Certificate: ra.Cert, Key: ra.Key, Hash: req.Hash} + for _, a := range attrs { + attr, err := cms.NewAttribute(a.oid, a.val) + if err != nil { + return nil, err + } + signer.Attributes = append(signer.Attributes, attr) + } + + var content []byte + detached := true + if rep.Status == StatusSuccess { + var crls [][]byte + if rep.CRL != nil { + crls = [][]byte{rep.CRL} + } + inner, err := cms.Degenerate(rep.Certs, crls) + if err != nil { + return nil, fmt.Errorf("scep: degenerate reply: %w", err) + } + content, err = cms.Encrypt(inner, []*x509.Certificate{req.Signer}, contentEncryptionFor(req.Envelope), cms.EncryptOptions{}) + if err != nil { + return nil, fmt.Errorf("scep: encrypt the reply: %w", err) + } + detached = false + } + return cms.Sign(cms.OIDData, content, []cms.Signer{signer}, cms.SignOptions{ + Certificates: [][]byte{ra.Cert.Raw}, + Detached: detached, + }) +} + +// printable encodes s as an ASN.1 PrintableString, the type RFC 8894 gives +// messageType, pkiStatus, failInfo and transactionID. encoding/asn1 would +// otherwise pick the string type itself. +func printable(s string) asn1.RawValue { + return asn1.RawValue{Tag: asn1.TagPrintableString, Bytes: []byte(s)} +} + +// canEncryptTo reports whether a SUCCESS reply can be encrypted to c: key +// transport here is RSA only, which is all SCEP clients use. +func canEncryptTo(c *x509.Certificate) bool { + _, ok := c.PublicKey.(*rsa.PublicKey) + return ok +} diff --git a/internal/scep/names.go b/internal/scep/names.go new file mode 100644 index 0000000..f4b52aa --- /dev/null +++ b/internal/scep/names.go @@ -0,0 +1,170 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto/x509" + "fmt" + "sort" + "strings" +) + +// maxNamesPerRequest bounds the SAN count on one request. +const maxNamesPerRequest = 100 + +// requestNames returns the names a certificate request asks for: its DNS +// SANs plus the subject common name. Anything SCEP does not issue (an IP, +// email or URI SAN) is refused rather than dropped, so a device never gets +// back less than it asked for without being told. +// +// The common name counts as a name whenever it is set: unlike EST, where a +// non-hostname CN is ignored, the SCEP allowlist bounds the subject CN too, +// because a device's trustpoint subject-name is usually its FQDN. +func requestNames(csr *x509.CertificateRequest) ([]string, error) { + if len(csr.IPAddresses) != 0 || len(csr.EmailAddresses) != 0 || len(csr.URIs) != 0 { + return nil, fmt.Errorf("the request carries an IP, email or URI subject alternative name; SCEP here issues DNS names only") + } + names := collectNames(csr.DNSNames, csr.Subject.CommonName) + if len(names) == 0 { + return nil, fmt.Errorf("the request carries no name to certify") + } + if len(names) > maxNamesPerRequest { + return nil, fmt.Errorf("the request carries %d names, more than the %d allowed", len(names), maxNamesPerRequest) + } + for _, n := range names { + if err := validateDNSName(n); err != nil { + return nil, err + } + } + return names, nil +} + +// certNames returns a certificate's names in requestNames form. +func certNames(c *x509.Certificate) []string { + return collectNames(c.DNSNames, c.Subject.CommonName) +} + +// collectNames lowercases, strips a trailing dot and de-duplicates, keeping +// first-seen order, with the common name last. +func collectNames(dnsNames []string, commonName string) []string { + seen := make(map[string]bool, len(dnsNames)+1) + out := make([]string, 0, len(dnsNames)+1) + for _, n := range append(append([]string(nil), dnsNames...), commonName) { + n = normalizeName(n) + if n == "" || seen[n] { + continue + } + seen[n] = true + out = append(out, n) + } + return out +} + +func normalizeName(n string) string { + return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(n), ".")) +} + +func sameNameSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + as, bs := append([]string(nil), a...), append([]string(nil), b...) + sort.Strings(as) + sort.Strings(bs) + for i := range as { + if as[i] != bs[i] { + return false + } + } + return true +} + +// suffixAllowed reports whether name equals or is a subdomain of a suffix, +// on a label boundary: "example.com" covers "sw1.example.com" but not +// "badexample.com". +func suffixAllowed(name string, suffixes []string) bool { + for _, suffix := range suffixes { + s := normalizeName(strings.TrimPrefix(strings.TrimSpace(suffix), ".")) + if s == "" { + continue + } + if name == s || strings.HasSuffix(name, "."+s) { + return true + } + } + return false +} + +// checkAllowlist refuses any name outside the allowlist. +func checkAllowlist(names, suffixes []string) error { + for _, n := range names { + if !suffixAllowed(n, suffixes) { + return fmt.Errorf("%s is outside the allowed identifier suffixes", n) + } + } + return nil +} + +// checkBound refuses any name a challenge was not bound to. An unbound +// challenge (no names) allows whatever the allowlist allows. +func checkBound(names, bound []string) error { + if len(bound) == 0 { + return nil + } + allowed := make(map[string]bool, len(bound)) + for _, b := range bound { + allowed[normalizeName(b)] = true + } + for _, n := range names { + if !allowed[n] { + return fmt.Errorf("%s is not one of the names the challenge is bound to", n) + } + } + return nil +} + +// validateDNSName applies the hostname rules a DNS SAN must meet. Wildcards +// are refused: a challenge establishes nothing about a label space. +func validateDNSName(name string) error { + if strings.Contains(name, "*") { + return fmt.Errorf("%q: wildcard names are not issued over SCEP", name) + } + if len(name) > 253 { + return fmt.Errorf("%q exceeds 253 characters", name) + } + labels := strings.Split(name, ".") + if len(labels) < 2 { + return fmt.Errorf("%q is not a fully qualified domain name", name) + } + for _, label := range labels { + if label == "" || len(label) > 63 { + return fmt.Errorf("%q has an empty or over-long label", name) + } + if label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("%q has a label starting or ending with a hyphen", name) + } + for i := 0; i < len(label); i++ { + c := label[i] + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + return fmt.Errorf("%q contains a character not allowed in a hostname", name) + } + } + } + return nil +} diff --git a/internal/scep/ra.go b/internal/scep/ra.go new file mode 100644 index 0000000..11113da --- /dev/null +++ b/internal/scep/ra.go @@ -0,0 +1,245 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "errors" + "fmt" + "sort" + "sync" + "time" +) + +// RAKeyBits is the RA key size. Key transport needs an RSA recipient, and the +// node's own keys stay at RSA 3072 or stronger. +const RAKeyBits = 3072 + +// raCert is one RA certificate with its key. +type raCert struct { + Cert *x509.Certificate + Key *rsa.PrivateKey +} + +// MintRAFunc signs an RA certificate over pub with the node's CA key, valid +// from notBefore to notAfter, with key usage digitalSignature and +// keyEncipherment, and returns its DER. The CA key signs the certificate and +// nothing else: the RA key decrypts requests and signs replies. +type MintRAFunc func(ctx context.Context, pub *rsa.PublicKey, notBefore, notAfter time.Time) ([]byte, error) + +// IssuerCertFunc returns the node's current CA certificate. +type IssuerCertFunc func(ctx context.Context) (*x509.Certificate, error) + +// RAManager keeps the node's RA certificates: it mints the first one, mints a +// successor when the newest enters its rotation overlap, and drops expired +// ones. During the overlap both RAs decrypt, so a device that cached the old +// RA certificate from GetCACert still enrols; GetCACert offers the newest. +// +// Keys are generated in software and stored as PKCS#8 in the node's etcd, on +// the encrypted state partition, the same way the delegated OCSP responder +// key is. They are never written in the clear. +type RAManager struct { + store *Store + mint MintRAFunc + issuer IssuerCertFunc + validity time.Duration + overlap time.Duration + now func() time.Time + logf func(string, ...any) + keygen func() (*rsa.PrivateKey, error) + + mu sync.RWMutex + ras []raCert +} + +// RAOptions configures an RAManager. +type RAOptions struct { + Validity time.Duration + Overlap time.Duration + // Now and Logf default to time.Now and discarding. + Now func() time.Time + Logf func(string, ...any) +} + +// NewRAManager returns an RAManager. Call Ensure before serving. +func NewRAManager(store *Store, mint MintRAFunc, issuer IssuerCertFunc, opts RAOptions) (*RAManager, error) { + if store == nil || mint == nil || issuer == nil { + return nil, errors.New("scep: NewRAManager: store, mint and issuer are required") + } + if opts.Validity <= 0 || opts.Overlap <= 0 || opts.Overlap >= opts.Validity { + return nil, fmt.Errorf("scep: NewRAManager: the overlap (%s) must be positive and shorter than the validity (%s)", opts.Overlap, opts.Validity) + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Logf == nil { + opts.Logf = func(string, ...any) {} + } + return &RAManager{ + store: store, mint: mint, issuer: issuer, + validity: opts.Validity, overlap: opts.Overlap, now: opts.Now, logf: opts.Logf, + keygen: func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, RAKeyBits) }, + }, nil +} + +// Ensure loads the stored RAs, drops expired ones and mints a successor when +// there is none, the newest is inside its overlap, or the newest no longer +// chains to the CA certificate (after a CA key rotation). It runs at boot and +// then periodically. +func (m *RAManager) Ensure(ctx context.Context) error { + now := m.now() + stored, err := m.store.listRAs(ctx) + if err != nil { + return err + } + var ras []raCert + for _, s := range stored { + ra, perr := parseStoredRA(s) + if perr != nil { + m.logf("scep: RA: a stored RA does not load, ignoring it: %v", perr) + continue + } + serial := ra.Cert.SerialNumber.Text(16) + if !now.Before(ra.Cert.NotAfter) { + m.logf("scep: RA: %s expired %s, removing it", serial, ra.Cert.NotAfter.UTC().Format(time.RFC3339)) + if derr := m.store.deleteRA(ctx, serial); derr != nil { + m.logf("scep: RA: removing expired %s failed: %v", serial, derr) + } + continue + } + ras = append(ras, ra) + } + sort.Slice(ras, func(i, j int) bool { return ras[i].Cert.NotAfter.After(ras[j].Cert.NotAfter) }) + + issuer, err := m.issuer(ctx) + if err != nil { + return fmt.Errorf("scep: RA: load the CA certificate: %w", err) + } + if issuer == nil { + return errors.New("scep: RA: this node has no CA certificate yet") + } + + reason := "" + switch { + case len(ras) == 0: + reason = "no RA yet" + case ras[0].Cert.NotAfter.Sub(now) <= m.overlap: + reason = fmt.Sprintf("the newest RA expires %s, inside the %s overlap", ras[0].Cert.NotAfter.UTC().Format(time.RFC3339), m.overlap) + case ras[0].Cert.CheckSignatureFrom(issuer) != nil: + reason = "the newest RA does not chain to the current CA certificate" + } + if reason != "" { + m.logf("scep: RA: minting a new RA (%s)", reason) + next, merr := m.mintRA(ctx, now) + if merr != nil { + if len(ras) == 0 { + return merr + } + m.logf("scep: RA: minting the successor failed, keeping the current RA: %v", merr) + } else if len(ras) > 0 && !next.Cert.NotAfter.After(ras[0].Cert.NotAfter) && ras[0].Cert.CheckSignatureFrom(issuer) == nil { + // The CA certificate caps the RA's notAfter, so near the CA's own + // expiry a successor cannot outlive the current RA; storing it + // would only mint again on every pass. + m.logf("scep: RA: the successor would not outlive the current RA (the CA expires %s); not storing it", + issuer.NotAfter.UTC().Format(time.RFC3339)) + } else { + if perr := m.store.putRA(ctx, next.Cert.SerialNumber.Text(16), storedRAFor(next)); perr != nil { + if len(ras) == 0 { + return perr + } + m.logf("scep: RA: storing the successor failed, keeping the current RA: %v", perr) + } else { + m.logf("scep: RA: %s minted, valid until %s", next.Cert.SerialNumber.Text(16), next.Cert.NotAfter.UTC().Format(time.RFC3339)) + ras = append([]raCert{next}, ras...) + } + } + } + + m.mu.Lock() + m.ras = ras + m.mu.Unlock() + serials := make([]string, len(ras)) + for i, r := range ras { + serials[i] = r.Cert.SerialNumber.Text(16) + } + m.logf("scep: RA: %d usable RA certificate(s), newest first: %v", len(ras), serials) + return nil +} + +func (m *RAManager) mintRA(ctx context.Context, now time.Time) (raCert, error) { + key, err := m.keygen() + if err != nil { + return raCert{}, fmt.Errorf("scep: RA: generate the key: %w", err) + } + der, err := m.mint(ctx, &key.PublicKey, now, now.Add(m.validity)) + if err != nil { + return raCert{}, fmt.Errorf("scep: RA: sign the certificate: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return raCert{}, fmt.Errorf("scep: RA: parse the certificate: %w", err) + } + return raCert{Cert: cert, Key: key}, nil +} + +// Current returns the newest RA, the one GetCACert offers. +func (m *RAManager) Current() (raCert, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + if len(m.ras) == 0 { + return raCert{}, false + } + return m.ras[0], true +} + +// All returns every usable RA, newest first: the candidates for decrypting a +// request. +func (m *RAManager) All() []raCert { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]raCert(nil), m.ras...) +} + +func storedRAFor(r raCert) storedRA { + // MarshalPKCS8PrivateKey cannot fail for an *rsa.PrivateKey. + key, _ := x509.MarshalPKCS8PrivateKey(r.Key) + return storedRA{CertDER: r.Cert.Raw, KeyPKCS8: key} +} + +func parseStoredRA(s storedRA) (raCert, error) { + cert, err := x509.ParseCertificate(s.CertDER) + if err != nil { + return raCert{}, fmt.Errorf("certificate: %w", err) + } + parsed, err := x509.ParsePKCS8PrivateKey(s.KeyPKCS8) + if err != nil { + return raCert{}, fmt.Errorf("key: %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return raCert{}, fmt.Errorf("key: want RSA, got %T", parsed) + } + if !key.PublicKey.Equal(cert.PublicKey) { + return raCert{}, errors.New("the key does not match the certificate") + } + return raCert{Cert: cert, Key: key}, nil +} diff --git a/internal/scep/server.go b/internal/scep/server.go new file mode 100644 index 0000000..907c0a4 --- /dev/null +++ b/internal/scep/server.go @@ -0,0 +1,711 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/ca" + "github.com/CryptOS-PKI/cryptos/internal/cms" +) + +// Profile is one certificate profile SCEP issues from. +type Profile struct { + // Name is the certificate profile in pki.profiles. + Name string + // MinRSABits is the RSA subject-key floor; zero means the node-wide + // ca.MinRSASubjectKeyBits. + MinRSABits int + // RequireApproval holds initial enrolments for an admin decision. + RequireApproval bool +} + +// IssueFunc issues a leaf for csrDER under profile with exactly the SAN set +// names and the given RSA floor, and returns its DER. In production it is +// node.CASigner.IssueLeafForNamesMinRSA, so the profile decides every other +// extension and the certificate is recorded for revocation before it returns. +type IssueFunc func(ctx context.Context, csrDER []byte, profile string, names []string, minRSABits int) ([]byte, error) + +// CAChainFunc returns the node's CA certificate first, then its issuers. +type CAChainFunc func(ctx context.Context) ([]*x509.Certificate, error) + +// RevokedFunc reports whether the certificate with the lowercase hex serial +// is revoked. +type RevokedFunc func(ctx context.Context, serialHex string) (bool, error) + +// IssuedFunc looks up a certificate this node issued by lowercase hex serial +// and returns its DER and the profile it was issued under. +type IssuedFunc func(ctx context.Context, serialHex string) (der []byte, profile string, ok bool, err error) + +// CRLFunc returns the node's current DER CRL. +type CRLFunc func(ctx context.Context) ([]byte, error) + +// Auditor records SCEP issuance decisions in the node's audit log. +type Auditor interface { + Append(event *cryptosv1.AuditEvent) error +} + +// Options configures a Server. +type Options struct { + Profiles []Profile + // AllowedSuffixes bounds the names an initial enrolment may request. + AllowedSuffixes []string + Now func() time.Time + Logf func(format string, args ...any) +} + +// Deps are the node facilities a Server uses. Issue, CAChain, Issued and +// CRL are required; Revoked and Auditor may be nil in tests. +type Deps struct { + Issue IssueFunc + CAChain CAChainFunc + Revoked RevokedFunc + Issued IssuedFunc + CRL CRLFunc + Auditor Auditor +} + +// Server is the SCEP responder: the HTTP operations and the admin RPCs. It +// holds no CA key; the RA key it holds only decrypts requests and signs +// replies. +type Server struct { + store *Store + ras *RAManager + deps Deps + opts Options + + // queueMu serializes the approval-queue decisions. The node is the only + // writer of its etcd, so one lock is enough to stop an approve and a + // reject (or two approves) racing on one enrolment. + queueMu sync.Mutex +} + +// NewServer validates its inputs and returns a Server. +func NewServer(store *Store, ras *RAManager, deps Deps, opts Options) (*Server, error) { + if store == nil || ras == nil { + return nil, errors.New("scep: NewServer: store and RA manager are required") + } + if deps.Issue == nil || deps.CAChain == nil || deps.Issued == nil || deps.CRL == nil { + return nil, errors.New("scep: NewServer: Issue, CAChain, Issued and CRL are required") + } + if len(opts.Profiles) == 0 { + return nil, errors.New("scep: NewServer: at least one profile is required") + } + if len(opts.AllowedSuffixes) == 0 { + return nil, errors.New("scep: NewServer: the identifier allowlist is required; a challenge proves nothing about control of a name") + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Logf == nil { + opts.Logf = func(string, ...any) {} + } + return &Server{store: store, ras: ras, deps: deps, opts: opts}, nil +} + +func (s *Server) profile(name string) *Profile { + for i := range s.opts.Profiles { + if s.opts.Profiles[i].Name == name { + return &s.opts.Profiles[i] + } + } + return nil +} + +// outcome carries what one PKIOperation decided, for the log and the audit. +type outcome struct { + // authorizedBy is "challenge" for an initial enrolment and "certificate" + // for a renewal. + authorizedBy string + profile string + challengeID string + serialHex string + names []string +} + +// pkiOperation answers one PKIOperation message with the DER CertRep. An +// error wrapping errMalformed means the message could not be answered with a +// CertRep at all. +func (s *Server) pkiOperation(ctx context.Context, der []byte, remote string) ([]byte, error) { + start := s.opts.Now() + req, err := parseRequest(der) + if err != nil { + s.opts.Logf("scep: PKIOperation from %s: unanswerable message: %v", remote, err) + return nil, err + } + s.opts.Logf("scep: PKIOperation from %s: %s transaction=%q signer=%q hash=%s", + remote, req.Type, req.TransactionID, req.Signer.Subject.String(), req.Hash) + + ra, ok := s.ras.Current() + if !ok { + return nil, errors.New("scep: no RA certificate is available") + } + var out outcome + rep := s.dispatch(ctx, req, &ra, &out) + + resp, err := buildCertRep(req, &ra, rep) + if err != nil { + return nil, err + } + s.opts.Logf("scep: PKIOperation from %s: %s transaction=%q -> %s%s profile=%q serial=%q in %s%s", + remote, req.Type, req.TransactionID, rep.Status, failSuffix(rep), out.profile, out.serialHex, + s.opts.Now().Sub(start).Round(time.Millisecond), reasonSuffix(rep)) + s.audit(req, rep, out, remote) + return resp, nil +} + +func failSuffix(rep reply) string { + if rep.Status != StatusFailure { + return "" + } + return " " + rep.FailInfo.String() +} + +func reasonSuffix(rep reply) string { + if rep.Reason == "" { + return "" + } + return " (" + rep.Reason + ")" +} + +// dispatch decides the reply. It sets *ra to the RA that decrypted the +// request, which is the one that signs the reply: the device may have cached +// the older RA during a rotation overlap and verifies with that. +func (s *Server) dispatch(ctx context.Context, req *request, ra *raCert, out *outcome) reply { + if req.VerifyErr != nil { + return failure(FailBadMessageCheck, "the message does not verify: %v", req.VerifyErr) + } + switch req.Type { + case MessageTypePKCSReq, MessageTypeRenewalReq, MessageTypeCertPoll, MessageTypeGetCert, MessageTypeGetCRL: + default: + return failure(FailBadRequest, "messageType %s is not a request this server answers", req.Type) + } + if !canEncryptTo(req.Signer) { + return failure(FailBadAlg, "the signer key is %s; the reply is encrypted to it, which needs RSA", keyDescription(req.Signer.PublicKey)) + } + content, used, rep, ok := s.decrypt(req) + if !ok { + return rep + } + *ra = used + + switch req.Type { + case MessageTypePKCSReq: + return s.pkcsReq(ctx, req, content, out) + case MessageTypeRenewalReq: + return s.renewalReq(ctx, req, content, out) + case MessageTypeCertPoll: + return s.certPoll(ctx, req, out) + case MessageTypeGetCert: + return s.getCert(ctx, req, content, out) + default: + return s.getCRL(ctx, req, content) + } +} + +// decrypt opens the pkcsPKIEnvelope with each usable RA, newest first. Only +// ErrNoRecipient moves on to the next RA; any other failure is final, and all +// of them look the same to the client. +func (s *Server) decrypt(req *request) ([]byte, raCert, reply, bool) { + for _, ra := range s.ras.All() { + plain, err := req.Envelope.Decrypt(ra.Cert, ra.Key) + switch { + case err == nil: + return plain, ra, reply{}, true + case errors.Is(err, cms.ErrNoRecipient): + continue + case errors.Is(err, cms.ErrUnsupportedAlgorithm): + return nil, raCert{}, failure(FailBadAlg, "the envelope uses an algorithm this server refuses: %v", err), false + default: + return nil, raCert{}, failure(FailBadMessageCheck, "the envelope does not decrypt"), false + } + } + return nil, raCert{}, failure(FailBadMessageCheck, "the envelope is not encrypted to a current RA certificate; fetch GetCACert again"), false +} + +// pkcsReq handles an initial enrolment, authorized by a one-time challenge. +func (s *Server) pkcsReq(ctx context.Context, req *request, csrDER []byte, out *outcome) reply { + csr, rep, ok := parseCSR(csrDER) + if !ok { + return rep + } + pw, err := challengePassword(csr) + if err != nil { + return failure(FailBadRequest, "%v", err) + } + if !selfSigned(req.Signer) { + // Before RFC 8894 added RenewalReq, a renewal was a PKCSReq signed + // with the current certificate, and clients still send it that way + // (sscep, and Cisco IOS auto-enrol rollover, which may repeat the + // original challenge). Signed by a current certificate from this CA, + // it gets exactly the RenewalReq rules, whatever challenge it + // carries. Signed by anything else, only a challenge can authorize + // it. + if _, issuedByUs := s.verifyIssuedByUs(ctx, req.Signer); issuedByUs || pw == "" { + s.opts.Logf("scep: PKCSReq %q is signed by %q; handling it as a renewal", req.TransactionID, req.Signer.Subject.String()) + return s.renew(ctx, req, csr, csrDER, out) + } + } + return s.withTransaction(ctx, req, csr, func(tx *Transaction) reply { + if pw == "" { + return failure(FailBadRequest, "the request carries no challengePassword; mint a one-time challenge for the device") + } + out.authorizedBy = "challenge" + sum := sha256.Sum256([]byte(pw)) + ch, found, err := s.store.ConsumeChallenge(ctx, hex.EncodeToString(sum[:])) + if err != nil { + s.opts.Logf("scep: consuming a challenge failed: %v", err) + return failure(FailBadRequest, "the challenge could not be checked") + } + if !found { + return failure(FailBadRequest, "the challenge is unknown, already used, revoked or expired") + } + out.challengeID = ch.ID + s.opts.Logf("scep: challenge %s consumed by transaction %q (profile %q)", ch.ID, req.TransactionID, ch.Profile) + if !s.opts.Now().Before(ch.ExpiresAt) { + return failure(FailBadRequest, "challenge %s expired at %s", ch.ID, ch.ExpiresAt.UTC().Format(time.RFC3339)) + } + + prof := s.profile(ch.Profile) + if prof == nil { + return failure(FailBadRequest, "challenge %s names profile %q, which SCEP no longer serves", ch.ID, ch.Profile) + } + out.profile = prof.Name + names, err := s.checkInitialRequest(csr, prof, ch.BoundNames) + if err != nil { + return failure(FailBadRequest, "%v", err) + } + out.names = names + tx.Profile = prof.Name + + if prof.RequireApproval { + return s.queue(ctx, req, csr, csrDER, prof, names, ch, tx) + } + return s.issueFor(ctx, csrDER, prof, names, tx, out) + }) +} + +// checkInitialRequest applies the initial-enrolment policy: DNS names only, +// every name inside the allowlist and the challenge's bound names, and the +// profile's key floor. +func (s *Server) checkInitialRequest(csr *x509.CertificateRequest, prof *Profile, bound []string) ([]string, error) { + names, err := requestNames(csr) + if err != nil { + return nil, err + } + if err := checkAllowlist(names, s.opts.AllowedSuffixes); err != nil { + return nil, err + } + if err := checkBound(names, bound); err != nil { + return nil, err + } + if err := ca.ValidateSubjectKeyMin(csr.PublicKey, prof.MinRSABits); err != nil { + return nil, fmt.Errorf("profile %q: %v", prof.Name, err) + } + return names, nil +} + +func (s *Server) queue(ctx context.Context, req *request, csr *x509.CertificateRequest, csrDER []byte, prof *Profile, names []string, ch Challenge, tx *Transaction) reply { + id, err := newID() + if err != nil { + return failure(FailBadRequest, "%v", err) + } + e := Enrollment{ + ID: id, + TransactionID: req.TransactionID, + Profile: prof.Name, + SubjectDN: csr.Subject.String(), + Names: names, + KeyAlg: keyDescription(csr.PublicKey), + CSRDER: csrDER, + ChallengeID: ch.ID, + ReceivedAt: s.opts.Now().UTC(), + } + if err := s.store.PutEnrollment(ctx, e); err != nil { + s.opts.Logf("scep: queueing transaction %q failed: %v", req.TransactionID, err) + return failure(FailBadRequest, "the enrolment could not be queued") + } + tx.State = txPending + tx.EnrollmentID = id + s.opts.Logf("scep: transaction %q queued as enrolment %s for approval (profile %q, names %v)", req.TransactionID, id, prof.Name, names) + return reply{Status: StatusPending, Reason: "held for approval as enrolment " + id} +} + +func (s *Server) issueFor(ctx context.Context, csrDER []byte, prof *Profile, names []string, tx *Transaction, out *outcome) reply { + s.opts.Logf("scep: issuing under profile %q for %v (RSA floor %d)", prof.Name, names, floorOf(prof)) + der, err := s.deps.Issue(ctx, csrDER, prof.Name, names, prof.MinRSABits) + if err != nil { + return failure(FailBadRequest, "the certificate authority refused the request: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return failure(FailBadRequest, "the issued certificate does not parse: %v", err) + } + out.serialHex = cert.SerialNumber.Text(16) + tx.State = txIssued + tx.SerialHex = out.serialHex + return reply{Status: StatusSuccess, Certs: [][]byte{der}} +} + +func floorOf(p *Profile) int { + if p.MinRSABits == 0 { + return ca.MinRSASubjectKeyBits + } + return p.MinRSABits +} + +// renewalReq handles a renewal, authorized by the certificate the device +// holds. It keeps that certificate's names and profile and is never queued. +func (s *Server) renewalReq(ctx context.Context, req *request, csrDER []byte, out *outcome) reply { + csr, rep, ok := parseCSR(csrDER) + if !ok { + return rep + } + return s.renew(ctx, req, csr, csrDER, out) +} + +// renew is the renewal path shared by RenewalReq and a PKCSReq signed with +// the current certificate. +func (s *Server) renew(ctx context.Context, req *request, csr *x509.CertificateRequest, csrDER []byte, out *outcome) reply { + out.authorizedBy = "certificate" + if rep, ok := s.verifyIssuedByUs(ctx, req.Signer); !ok { + return rep + } + return s.withTransaction(ctx, req, csr, func(tx *Transaction) reply { + serial := req.Signer.SerialNumber.Text(16) + _, profName, found, err := s.deps.Issued(ctx, serial) + if err != nil { + s.opts.Logf("scep: looking up %s for renewal failed: %v", serial, err) + return failure(FailBadRequest, "the current certificate could not be looked up") + } + if !found { + return failure(FailBadCertID, "certificate %s is not in this node's issued set", serial) + } + prof := s.profile(profName) + if prof == nil { + return failure(FailBadRequest, "certificate %s was issued under profile %q, which SCEP does not serve", serial, profName) + } + out.profile = prof.Name + tx.Profile = prof.Name + + names, err := requestNames(csr) + if err != nil { + return failure(FailBadRequest, "%v", err) + } + current := certNames(req.Signer) + if !sameNameSet(names, current) { + return failure(FailBadRequest, "a renewal must request the names of the certificate it renews (%v), not %v", current, names) + } + out.names = current + if err := ca.ValidateSubjectKeyMin(csr.PublicKey, prof.MinRSABits); err != nil { + return failure(FailBadRequest, "profile %q: %v", prof.Name, err) + } + s.opts.Logf("scep: renewing %s (%v) under profile %q", serial, current, prof.Name) + return s.issueFor(ctx, csrDER, prof, current, tx, out) + }) +} + +// withTransaction runs decide for a new transactionID, recording what it +// decided, and answers a transactionID it has already seen from the record: +// the same certificate for an issued one, PENDING for one queued or still in +// flight, FAILURE for a rejected one. A decision that fails is forgotten, so +// the device can try again with a fresh challenge. +func (s *Server) withTransaction(ctx context.Context, req *request, csr *x509.CertificateRequest, decide func(*Transaction) reply) reply { + now := s.opts.Now().UTC() + tx := Transaction{ + TransactionID: req.TransactionID, + State: txProcessing, + KeyID: keyID(csr.RawSubjectPublicKeyInfo), + SignerKeyID: keyID(req.Signer.RawSubjectPublicKeyInfo), + MessageType: req.Type, + CreatedAt: now, + UpdatedAt: now, + } + existing, claimed, err := s.store.ClaimTransaction(ctx, tx) + if err != nil { + s.opts.Logf("scep: recording transaction %q failed: %v", req.TransactionID, err) + return failure(FailBadRequest, "the transaction could not be recorded") + } + if !claimed { + s.opts.Logf("scep: transaction %q seen before (state %s); answering from the record", req.TransactionID, existing.State) + if existing.KeyID != tx.KeyID { + return failure(FailBadRequest, "transactionID %q is already in use for a different key", req.TransactionID) + } + return s.fromRecord(ctx, existing) + } + + rep := s.safeDecide(decide, &tx) + if rep.Status == StatusFailure { + if err := s.store.DeleteTransaction(ctx, req.TransactionID); err != nil { + s.opts.Logf("scep: forgetting failed transaction %q failed: %v", req.TransactionID, err) + } + return rep + } + tx.UpdatedAt = s.opts.Now().UTC() + if err := s.store.PutTransaction(ctx, tx); err != nil { + // The certificate is already issued and recorded for revocation, so + // the device still gets it; only a later retransmit or poll would + // miss the record. + s.opts.Logf("scep: recording the outcome of transaction %q failed: %v", req.TransactionID, err) + } + return rep +} + +// safeDecide runs decide and turns a panic into a failure, so a bug cannot +// leave a transaction stuck in the processing state. +func (s *Server) safeDecide(decide func(*Transaction) reply, tx *Transaction) (rep reply) { + defer func() { + if r := recover(); r != nil { + s.opts.Logf("scep: transaction %q: internal error: %v", tx.TransactionID, r) + rep = failure(FailBadRequest, "internal error") + } + }() + return decide(tx) +} + +// fromRecord answers from a stored transaction. +func (s *Server) fromRecord(ctx context.Context, tx Transaction) reply { + switch tx.State { + case txIssued: + der, _, found, err := s.deps.Issued(ctx, tx.SerialHex) + if err != nil || !found { + s.opts.Logf("scep: transaction %q: the issued certificate %s could not be loaded: found=%t err=%v", tx.TransactionID, tx.SerialHex, found, err) + return failure(FailBadCertID, "the issued certificate could not be loaded") + } + return reply{Status: StatusSuccess, Certs: [][]byte{der}, Reason: "the certificate issued for this transaction"} + case txPending, txProcessing: + return reply{Status: StatusPending, Reason: "still waiting (" + tx.State + ")"} + case txRejected: + return failure(FailBadRequest, "an administrator rejected this enrolment") + } + return failure(FailBadRequest, "transaction in unknown state %q", tx.State) +} + +// certPoll answers a CertPoll (formerly GetCertInitial, RFC 8894 section +// 3.3.3) from the transaction record. The poll must be signed by the key that +// signed the original request. +func (s *Server) certPoll(ctx context.Context, req *request, out *outcome) reply { + tx, found, err := s.store.GetTransaction(ctx, req.TransactionID) + if err != nil { + s.opts.Logf("scep: CertPoll: reading transaction %q failed: %v", req.TransactionID, err) + return failure(FailBadRequest, "the transaction could not be read") + } + if !found { + return failure(FailBadCertID, "no transaction %q", req.TransactionID) + } + if tx.SignerKeyID != keyID(req.Signer.RawSubjectPublicKeyInfo) { + return failure(FailBadRequest, "the poll is not signed by the key that made the request") + } + out.profile = tx.Profile + out.serialHex = tx.SerialHex + return s.fromRecord(ctx, tx) +} + +// issuerAndSerial is the RFC 5652 IssuerAndSerialNumber that GetCert and +// GetCRL carry. +type issuerAndSerial struct { + Issuer asn1.RawValue + SerialNumber *big.Int +} + +// getCert returns a certificate this node issued (RFC 8894 section 3.3.4). +// The requester must hold a current certificate from this CA. +func (s *Server) getCert(ctx context.Context, req *request, content []byte, out *outcome) reply { + if rep, ok := s.verifyIssuedByUs(ctx, req.Signer); !ok { + return rep + } + ias, rep, ok := s.parseIssuerAndSerial(ctx, content) + if !ok { + return rep + } + serial := ias.SerialNumber.Text(16) + der, _, found, err := s.deps.Issued(ctx, serial) + if err != nil { + s.opts.Logf("scep: GetCert: looking up %s failed: %v", serial, err) + return failure(FailBadRequest, "the certificate could not be looked up") + } + if !found { + return failure(FailBadCertID, "this node did not issue serial %s", serial) + } + out.serialHex = serial + return reply{Status: StatusSuccess, Certs: [][]byte{der}} +} + +// getCRL returns the node's current CRL (RFC 8894 section 3.3.4). The +// requester must hold a current certificate from this CA. +func (s *Server) getCRL(ctx context.Context, req *request, content []byte) reply { + if rep, ok := s.verifyIssuedByUs(ctx, req.Signer); !ok { + return rep + } + if _, rep, ok := s.parseIssuerAndSerial(ctx, content); !ok { + return rep + } + crl, err := s.deps.CRL(ctx) + if err != nil { + s.opts.Logf("scep: GetCRL: building the CRL failed: %v", err) + return failure(FailBadRequest, "the CRL is unavailable") + } + return reply{Status: StatusSuccess, CRL: crl} +} + +func (s *Server) parseIssuerAndSerial(ctx context.Context, content []byte) (issuerAndSerial, reply, bool) { + var ias issuerAndSerial + if rest, err := asn1.Unmarshal(content, &ias); err != nil || len(rest) != 0 || ias.SerialNumber == nil { + return ias, failure(FailBadRequest, "the messageData is not an IssuerAndSerialNumber"), false + } + chain, err := s.deps.CAChain(ctx) + if err != nil || len(chain) == 0 { + s.opts.Logf("scep: loading the CA chain failed: %v", err) + return ias, failure(FailBadRequest, "the CA chain is unavailable"), false + } + if !s.isOurIssuerName(ias.Issuer.FullBytes, chain[0]) { + return ias, failure(FailBadCertID, "the issuer is not this CA"), false + } + return ias, reply{}, true +} + +// isOurIssuerName reports whether name is this CA's subject. An RA's subject +// is accepted too: clients that verify replies with the RA certificate (sscep, +// given the RA as its CA certificate) name it as the issuer, and the serial is +// looked up in this node's own issued set either way. +func (s *Server) isOurIssuerName(name []byte, caCert *x509.Certificate) bool { + if string(name) == string(caCert.RawSubject) { + return true + } + for _, ra := range s.ras.All() { + if string(name) == string(ra.Cert.RawSubject) { + return true + } + } + return false +} + +// verifyIssuedByUs checks that c chains to this CA, is inside its validity +// period and is not revoked. +func (s *Server) verifyIssuedByUs(ctx context.Context, c *x509.Certificate) (reply, bool) { + chain, err := s.deps.CAChain(ctx) + if err != nil || len(chain) == 0 { + s.opts.Logf("scep: loading the CA chain failed: %v", err) + return failure(FailBadRequest, "the CA chain is unavailable"), false + } + roots := x509.NewCertPool() + for _, cert := range chain { + roots.AddCert(cert) + } + if _, err := c.Verify(x509.VerifyOptions{ + Roots: roots, + CurrentTime: s.opts.Now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + var inv x509.CertificateInvalidError + if errors.As(err, &inv) && inv.Reason == x509.Expired { + return failure(FailBadTime, "the signer certificate is outside its validity period"), false + } + return failure(FailBadRequest, "the signer certificate was not issued by this CA: %v", err), false + } + if s.deps.Revoked == nil { + return reply{}, true + } + serial := c.SerialNumber.Text(16) + revoked, err := s.deps.Revoked(ctx, serial) + if err != nil { + s.opts.Logf("scep: revocation lookup for %s failed: %v", serial, err) + return failure(FailBadRequest, "the revocation status could not be checked"), false + } + if revoked { + return failure(FailBadRequest, "the signer certificate %s is revoked", serial), false + } + return reply{}, true +} + +// selfSigned reports whether c is signed by its own key, as a device's +// throwaway PKCSReq signer is. CheckSignatureFrom would refuse it for not +// being a CA, so the signature is checked directly. +func selfSigned(c *x509.Certificate) bool { + return string(c.RawIssuer) == string(c.RawSubject) && + c.CheckSignature(c.SignatureAlgorithm, c.RawTBSCertificate, c.Signature) == nil +} + +func parseCSR(der []byte) (*x509.CertificateRequest, reply, bool) { + csr, err := x509.ParseCertificateRequest(der) + if err != nil { + return nil, failure(FailBadRequest, "the messageData is not a PKCS#10 request: %v", err), false + } + if err := csr.CheckSignature(); err != nil { + return nil, failure(FailBadMessageCheck, "the certificate request signature does not verify: %v", err), false + } + return csr, reply{}, true +} + +// audit records one PKIOperation decision. The challenge never appears: only +// the id of the challenge that was consumed. +func (s *Server) audit(req *request, rep reply, out outcome, remote string) { + if s.deps.Auditor == nil { + return + } + outcomeCode := cryptosv1.Outcome_OUTCOME_OK + if rep.Status == StatusFailure { + outcomeCode = cryptosv1.Outcome_OUTCOME_DENIED + } + details := map[string]string{ + "transaction_id": req.TransactionID, + "pki_status": rep.Status.String(), + "remote_addr": remote, + } + if rep.Status == StatusFailure { + details["fail_info"] = rep.FailInfo.String() + } + if rep.Reason != "" { + details["reason"] = rep.Reason + } + if out.profile != "" { + details["profile"] = out.profile + } + if out.authorizedBy != "" { + details["authorized_by"] = out.authorizedBy + } + if out.challengeID != "" { + details["challenge_id"] = out.challengeID + } + if out.serialHex != "" { + details["serial_hex"] = out.serialHex + } + if len(out.names) > 0 { + details["names"] = strings.Join(out.names, ",") + } + if err := s.deps.Auditor.Append(&cryptosv1.AuditEvent{ + ActorSubject: req.Signer.Subject.String(), + RpcMethod: "scep/" + req.Type.String(), + Outcome: outcomeCode, + Details: details, + }); err != nil { + s.opts.Logf("scep: writing the audit entry for transaction %q failed: %v", req.TransactionID, err) + } +} diff --git a/internal/scep/server_test.go b/internal/scep/server_test.go new file mode 100644 index 0000000..b9f2749 --- /dev/null +++ b/internal/scep/server_test.go @@ -0,0 +1,693 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "io" + "math/big" + "net" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/cms" +) + +func get(t *testing.T, f *fixture, query string) (*http.Response, []byte) { + t.Helper() + resp, err := http.Get(f.http.URL + PathPKIClient + "?" + query) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + return resp, body +} + +func TestGetCACapsAdvertisesOnlyWhatIsImplemented(t *testing.T) { + f := newFixture(t) + resp, body := get(t, f, "operation=GetCACaps") + if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/plain" { + t.Fatalf("GetCACaps: %d %q", resp.StatusCode, resp.Header.Get("Content-Type")) + } + got := strings.Fields(string(body)) + want := []string{"AES", "POSTPKIOperation", "Renewal", "SHA-256", "SHA-512"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("capabilities = %v, want %v", got, want) + } + for _, refused := range []string{"DES3", "SHA-1", "GetNextCACert", "SCEPStandard"} { + if strings.Contains(string(body), refused) { + t.Fatalf("capabilities advertise %s, which is not implemented", refused) + } + } +} + +func TestGetCACertReturnsTheCAAndTheRA(t *testing.T) { + f := newFixture(t) + resp, body := get(t, f, "operation=GetCACert") + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCACert: %d %s", resp.StatusCode, body) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/x-x509-ca-ra-cert" { + t.Fatalf("Content-Type = %q", ct) + } + sd, err := cms.ParseSignedData(body) + if err != nil { + t.Fatalf("GetCACert body: %v", err) + } + if len(sd.Certificates) != 2 || len(sd.SignerInfos) != 0 { + t.Fatalf("GetCACert carries %d certificates and %d signers, want 2 and 0", len(sd.Certificates), len(sd.SignerInfos)) + } + caCert, _ := x509.ParseCertificate(sd.Certificates[0]) + ra, _ := x509.ParseCertificate(sd.Certificates[1]) + if !caCert.Equal(f.ca.cert) { + t.Fatal("the first certificate is not the CA") + } + if ra.KeyUsage != x509.KeyUsageDigitalSignature|x509.KeyUsageKeyEncipherment || ra.IsCA { + t.Fatalf("RA key usage = %v, IsCA = %t", ra.KeyUsage, ra.IsCA) + } + if k, ok := ra.PublicKey.(*rsa.PublicKey); !ok || k.N.BitLen() != 3072 { + t.Fatalf("RA key = %T, want RSA 3072", ra.PublicKey) + } + if err := ra.CheckSignatureFrom(f.ca.cert); err != nil { + t.Fatalf("the RA is not issued by the CA: %v", err) + } +} + +func TestUnknownAndMissingOperations(t *testing.T) { + f := newFixture(t) + for q, want := range map[string]int{ + "": http.StatusBadRequest, + "operation=Nope": http.StatusBadRequest, + "operation=GetNextCACert": http.StatusNotImplemented, + "operation=PKIOperation": http.StatusBadRequest, + } { + if resp, _ := get(t, f, q); resp.StatusCode != want { + t.Errorf("GET ?%s = %d, want %d", q, resp.StatusCode, want) + } + } + resp, err := http.Post(f.http.URL+PathSCEP+"?operation=PKIOperation", contentTypePKIMessage, strings.NewReader("not a pkiMessage")) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("a malformed message = %d, want 400", resp.StatusCode) + } +} + +func TestPKCSReqIssuesWithAOneTimeChallenge(t *testing.T) { + f := newFixture(t) + pw, ch := f.mint("cisco-device", time.Hour) + d := newDevice(t, 2048) + + rep := f.enrol(t, d, "tx-issue", pw, "switch01.example.com") + wantStatus(t, rep, StatusSuccess) + if len(rep.certs) != 1 { + t.Fatalf("SUCCESS carries %d certificates, want the issued one", len(rep.certs)) + } + leaf := rep.certs[0] + if !leaf.PublicKey.(*rsa.PublicKey).Equal(&d.key.PublicKey) { + t.Fatal("the issued certificate is not for the device key") + } + if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "switch01.example.com" { + t.Fatalf("SANs = %v, want the subject name", leaf.DNSNames) + } + if rep.txID != "tx-issue" { + t.Fatalf("transactionID echo = %q", rep.txID) + } + if !rep.signer.Equal(f.raCert()) { + t.Fatal("the CertRep is not signed by the RA") + } + + listed, err := f.srv.ListScepChallenges(f.ctx, &cryptosv1.ListScepChallengesRequest{}) + if err != nil { + t.Fatal(err) + } + if len(listed.GetChallenges()) != 0 { + t.Fatal("a consumed challenge is still listed") + } + + ev := f.audit.last() + if ev.GetRpcMethod() != "scep/PKCSReq" || ev.GetDetails()["challenge_id"] != ch.GetId() || + ev.GetDetails()["serial_hex"] != leaf.SerialNumber.Text(16) || ev.GetDetails()["profile"] != "cisco-device" { + t.Fatalf("audit entry = %v", ev) + } + if strings.Contains(ev.String(), pw) || strings.Contains(f.logText(), pw) { + t.Fatal("the challenge password reached the audit log or the logs") + } +} + +func TestPKCSReqOverGET(t *testing.T) { + f := newFixture(t) + pw, _ := f.mint("cisco-device", time.Hour) + d := newDevice(t, 2048) + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "ap1.example.com", nil, pw), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-get"}) + resp, body := get(t, f, "operation=PKIOperation&message="+url.QueryEscape(base64.StdEncoding.EncodeToString(msg))) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET PKIOperation: %d %s", resp.StatusCode, body) + } + wantStatus(t, parseCertRep(t, body, d.key, d.cert), StatusSuccess) +} + +func TestPKCSReqChallengeFailures(t *testing.T) { + f := newFixture(t) + d := newDevice(t, 2048) + + t.Run("no challenge", func(t *testing.T) { + wantFailure(t, f.enrol(t, d, "tx-none", "", "sw.example.com"), FailBadRequest) + }) + t.Run("never minted", func(t *testing.T) { + wantFailure(t, f.enrol(t, d, "tx-bogus", "NOTAREALCHALLENGE", "sw.example.com"), FailBadRequest) + }) + t.Run("reused", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + wantStatus(t, f.enrol(t, d, "tx-first", pw, "sw1.example.com"), StatusSuccess) + other := newDevice(t, 2048) + wantFailure(t, f.enrol(t, other, "tx-second", pw, "sw2.example.com"), FailBadRequest) + }) + t.Run("expired", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + f.clock.advance(time.Hour + time.Second) + defer f.clock.advance(-time.Hour - time.Second) + wantFailure(t, f.enrol(t, newDevice(t, 2048), "tx-expired", pw, "sw3.example.com"), FailBadRequest) + }) + t.Run("revoked", func(t *testing.T) { + pw, ch := f.mint("cisco-device", time.Hour) + if _, err := f.srv.RevokeScepChallenge(f.ctx, &cryptosv1.RevokeScepChallengeRequest{Id: ch.GetId()}); err != nil { + t.Fatal(err) + } + wantFailure(t, f.enrol(t, newDevice(t, 2048), "tx-revoked", pw, "sw4.example.com"), FailBadRequest) + }) +} + +// A failed request is forgotten, so the same device (and the same +// transactionID, which clients derive from the key) can try again once the +// operator mints a fresh challenge. +func TestPKCSReqFailureIsNotRemembered(t *testing.T) { + f := newFixture(t) + d := newDevice(t, 2048) + wantFailure(t, f.enrol(t, d, "tx-retry", "WRONG", "sw.example.com"), FailBadRequest) + pw, _ := f.mint("cisco-device", time.Hour) + wantStatus(t, f.enrol(t, d, "tx-retry", pw, "sw.example.com"), StatusSuccess) +} + +func TestPKCSReqNamePolicy(t *testing.T) { + f := newFixture(t) + for _, tc := range []struct { + name string + bound []string + cn string + dns []string + }{ + {"outside the allowlist", nil, "sw.example.org", nil}, + {"a SAN outside the allowlist", nil, "sw.example.com", []string{"sw.evil.test"}}, + {"a suffix look-alike", nil, "sw.badexample.com", nil}, + {"not a hostname", nil, "switch01", nil}, + {"a wildcard", nil, "*.example.com", nil}, + {"outside the bound names", []string{"sw1.example.com"}, "sw2.example.com", nil}, + {"one SAN outside the bound names", []string{"sw1.example.com"}, "sw1.example.com", []string{"sw9.example.com"}}, + } { + t.Run(tc.name, func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour, tc.bound...) + wantFailure(t, f.enrol(t, newDevice(t, 2048), "tx-"+tc.name, pw, tc.cn, tc.dns...), FailBadRequest) + }) + } + t.Run("inside the bound names", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour, "sw1.example.com", "sw1-mgmt.example.com") + wantStatus(t, f.enrol(t, newDevice(t, 2048), "tx-bound-ok", pw, "sw1.example.com", "sw1-mgmt.example.com"), StatusSuccess) + }) +} + +func TestPKCSReqIPSANIsRefused(t *testing.T) { + f := newFixture(t) + pw, _ := f.mint("cisco-device", time.Hour) + d := newDevice(t, 2048) + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkixName("sw.example.com"), IPAddresses: []net.IP{net.IPv4(10, 0, 0, 1)}, SignatureAlgorithm: x509.SHA256WithRSA, + }, d.key) + if err != nil { + t.Fatal(err) + } + msg := f.message(t, MessageTypePKCSReq, withChallenge(t, der, pw, d.key), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-ip"}) + wantFailure(t, f.post(t, msg, d.key, d.cert), FailBadRequest) +} + +func TestPKCSReqKeyFloorPerProfile(t *testing.T) { + f := newFixture(t) + t.Run("2048 on the default-floor profile", func(t *testing.T) { + pw, _ := f.mint("strict-device", time.Hour) + wantFailure(t, f.enrol(t, newDevice(t, 2048), "tx-2048-strict", pw, "mdm1.example.com"), FailBadRequest) + }) + t.Run("3072 on the default-floor profile", func(t *testing.T) { + pw, _ := f.mint("strict-device", time.Hour) + wantStatus(t, f.enrol(t, newDevice(t, 3072), "tx-3072-strict", pw, "mdm2.example.com"), StatusSuccess) + }) + t.Run("3072 on the 2048 profile", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + wantStatus(t, f.enrol(t, newDevice(t, 3072), "tx-3072-cisco", pw, "sw5.example.com"), StatusSuccess) + }) + t.Run("1024 anywhere", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + wantFailure(t, f.enrol(t, newDevice(t, 1024), "tx-1024", pw, "sw6.example.com"), FailBadRequest) + }) +} + +func TestPKCSReqRetransmitIsIdempotent(t *testing.T) { + f := newFixture(t) + pw, _ := f.mint("cisco-device", time.Hour) + d := newDevice(t, 2048) + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "sw.example.com", nil, pw), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-again"}) + + first := f.post(t, msg, d.key, d.cert) + wantStatus(t, first, StatusSuccess) + second := f.post(t, msg, d.key, d.cert) + wantStatus(t, second, StatusSuccess) + if !first.certs[0].Equal(second.certs[0]) { + t.Fatal("a retransmitted PKCSReq was issued a second certificate") + } + if len(f.ca.issued) != 1 { + t.Fatalf("the CA issued %d certificates for one transaction", len(f.ca.issued)) + } + + thief := newDevice(t, 2048) + pw2, _ := f.mint("cisco-device", time.Hour) + wantFailure(t, f.enrol(t, thief, "tx-again", pw2, "sw.example.com"), FailBadRequest) +} + +func TestPKCSReqPendingThenApproved(t *testing.T) { + f := newFixture(t) + pw, ch := f.mint("approved-device", time.Hour, "sw7.example.com") + d := newDevice(t, 2048) + wantStatus(t, f.enrol(t, d, "tx-pending", pw, "sw7.example.com"), StatusPending) + wantStatus(t, f.poll(t, d, "tx-pending"), StatusPending) + + list, err := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{}) + if err != nil { + t.Fatal(err) + } + if len(list.GetEnrollments()) != 1 { + t.Fatalf("queue = %v, want one enrolment", list.GetEnrollments()) + } + e := list.GetEnrollments()[0] + if e.GetTransactionId() != "tx-pending" || e.GetProfile() != "approved-device" || e.GetChallengeId() != ch.GetId() || + e.GetKeyAlg() != "RSA-2048" || !strings.Contains(e.GetCsrPem(), "CERTIFICATE REQUEST") || !strings.Contains(e.GetSubjectDn(), "sw7.example.com") { + t.Fatalf("enrolment = %v", e) + } + if other, _ := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{Profile: "cisco-device"}); len(other.GetEnrollments()) != 0 { + t.Fatal("the profile filter does not filter") + } + + approved, err := f.srv.ApproveScepEnrollment(f.ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: e.GetId()}) + if err != nil { + t.Fatalf("Approve: %v", err) + } + rep := f.poll(t, d, "tx-pending") + wantStatus(t, rep, StatusSuccess) + if rep.certs[0].SerialNumber.Text(16) != approved.GetSerialHex() { + t.Fatal("the poll did not return the approved certificate") + } + if list, _ := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{}); len(list.GetEnrollments()) != 0 { + t.Fatal("an approved enrolment stayed in the queue") + } + if _, err := f.srv.ApproveScepEnrollment(f.ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: e.GetId()}); statusCode(err) != "NotFound" { + t.Fatalf("a second approve = %v, want NotFound", err) + } + wantStatus(t, f.enrol(t, d, "tx-pending", "", "sw7.example.com"), StatusSuccess) +} + +func TestPKCSReqPendingThenRejected(t *testing.T) { + f := newFixture(t) + pw, _ := f.mint("approved-device", time.Hour) + d := newDevice(t, 2048) + wantStatus(t, f.enrol(t, d, "tx-reject", pw, "sw8.example.com"), StatusPending) + list, _ := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{}) + if _, err := f.srv.RejectScepEnrollment(f.ctx, &cryptosv1.RejectScepEnrollmentRequest{Id: list.GetEnrollments()[0].GetId(), Reason: "not ours"}); err != nil { + t.Fatalf("Reject: %v", err) + } + wantFailure(t, f.poll(t, d, "tx-reject"), FailBadRequest) + if len(f.ca.issued) != 0 { + t.Fatal("a rejected enrolment was issued") + } +} + +// Approval re-checks the request against the policy in force, and a request +// that no longer passes stays queued for rejection. +func TestApproveRechecksThePolicy(t *testing.T) { + f := newFixture(t) + pw, _ := f.mint("approved-device", time.Hour) + d := newDevice(t, 2048) + wantStatus(t, f.enrol(t, d, "tx-recheck", pw, "sw9.example.com"), StatusPending) + list, _ := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{}) + id := list.GetEnrollments()[0].GetId() + + f.srv.opts.AllowedSuffixes = []string{"example.net"} + if _, err := f.srv.ApproveScepEnrollment(f.ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: id}); statusCode(err) != "FailedPrecondition" { + t.Fatalf("approve against a tightened allowlist = %v, want FailedPrecondition", err) + } + if list, _ := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{}); len(list.GetEnrollments()) != 1 { + t.Fatal("a refused approval took the enrolment out of the queue") + } + wantStatus(t, f.poll(t, d, "tx-recheck"), StatusPending) +} + +func TestCertPollFailures(t *testing.T) { + f := newFixture(t) + d := newDevice(t, 2048) + wantFailure(t, f.poll(t, d, "tx-unknown"), FailBadCertID) + + pw, _ := f.mint("approved-device", time.Hour) + wantStatus(t, f.enrol(t, d, "tx-owned", pw, "sw10.example.com"), StatusPending) + wantFailure(t, f.poll(t, newDevice(t, 2048), "tx-owned"), FailBadRequest) +} + +// renewer is a device holding a certificate this CA issued. +func renewer(t *testing.T, f *fixture, profile string, names ...string) *device { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return &device{key: key, cert: f.ca.sign(t, &key.PublicKey, profile, names...)} +} + +func TestRenewalReq(t *testing.T) { + f := newFixture(t) + old := renewer(t, f, "cisco-device", "sw11.example.com") + + renew := func(t *testing.T, signer *device, txID, cn string, dns ...string) certRep { + t.Helper() + next := newDevice(t, 2048) + msg := f.message(t, MessageTypeRenewalReq, next.csr(t, cn, dns, ""), msgOpts{signer: signer.cert, signerKey: signer.key, txID: txID}) + return f.post(t, msg, signer.key, signer.cert) + } + + t.Run("same names", func(t *testing.T) { + rep := renew(t, old, "tx-renew", "sw11.example.com") + wantStatus(t, rep, StatusSuccess) + if rep.certs[0].DNSNames[0] != "sw11.example.com" { + t.Fatalf("renewed names = %v", rep.certs[0].DNSNames) + } + if ev := f.audit.last(); ev.GetRpcMethod() != "scep/RenewalReq" || ev.GetDetails()["profile"] != "cisco-device" { + t.Fatalf("audit = %v", ev) + } + }) + t.Run("different names", func(t *testing.T) { + wantFailure(t, renew(t, old, "tx-renew-names", "sw11.example.com", "other.example.com"), FailBadRequest) + }) + t.Run("names outside the allowlist stay pinned, not re-checked", func(t *testing.T) { + legacy := renewer(t, f, "cisco-device", "legacy.example.org") + wantStatus(t, renew(t, legacy, "tx-renew-legacy", "legacy.example.org"), StatusSuccess) + }) + t.Run("revoked", func(t *testing.T) { + gone := renewer(t, f, "cisco-device", "sw12.example.com") + f.ca.revoked[gone.cert.SerialNumber.Text(16)] = true + wantFailure(t, renew(t, gone, "tx-renew-revoked", "sw12.example.com"), FailBadRequest) + }) + t.Run("not issued by this CA", func(t *testing.T) { + stranger := newTestCA(t, "Another CA") + key, _ := rsa.GenerateKey(rand.Reader, 2048) + d := &device{key: key, cert: stranger.sign(t, &key.PublicKey, "cisco-device", "sw13.example.com")} + wantFailure(t, renew(t, d, "tx-renew-stranger", "sw13.example.com"), FailBadRequest) + }) + t.Run("self-signed", func(t *testing.T) { + wantFailure(t, renew(t, newDevice(t, 2048), "tx-renew-self", "scep-client.example.com"), FailBadRequest) + }) + t.Run("expired", func(t *testing.T) { + f.clock.advance(60 * 24 * time.Hour) + defer f.clock.advance(-60 * 24 * time.Hour) + wantFailure(t, renew(t, old, "tx-renew-expired", "sw11.example.com"), FailBadTime) + }) + t.Run("issued under a profile SCEP does not serve", func(t *testing.T) { + web := renewer(t, f, "web-server", "www.example.com") + wantFailure(t, renew(t, web, "tx-renew-web", "www.example.com"), FailBadRequest) + }) + t.Run("key below the profile floor", func(t *testing.T) { + strict := renewer(t, f, "strict-device", "mdm3.example.com") + wantFailure(t, renew(t, strict, "tx-renew-floor", "mdm3.example.com"), FailBadRequest) + }) +} + +func TestGetCertAndGetCRL(t *testing.T) { + f := newFixture(t) + holder := renewer(t, f, "cisco-device", "sw14.example.com") + target := renewer(t, f, "cisco-device", "sw15.example.com") + + ias := func(serialHolder *x509.Certificate) []byte { + b, _ := asn1.Marshal(issuerAndSerial{Issuer: asn1.RawValue{FullBytes: f.ca.cert.RawSubject}, SerialNumber: serialHolder.SerialNumber}) + return b + } + send := func(t *testing.T, mt MessageType, signer *device, content []byte) certRep { + t.Helper() + msg := f.message(t, mt, content, msgOpts{signer: signer.cert, signerKey: signer.key, txID: "tx-" + mt.String()}) + return f.post(t, msg, signer.key, signer.cert) + } + + rep := send(t, MessageTypeGetCert, holder, ias(target.cert)) + wantStatus(t, rep, StatusSuccess) + if !rep.certs[0].Equal(target.cert) { + t.Fatal("GetCert returned the wrong certificate") + } + + unknown, _ := asn1.Marshal(issuerAndSerial{Issuer: asn1.RawValue{FullBytes: f.ca.cert.RawSubject}, SerialNumber: new(big.Int).Lsh(holder.cert.SerialNumber, 8)}) + wantFailure(t, send(t, MessageTypeGetCert, holder, unknown), FailBadCertID) + + otherIssuer, _ := asn1.Marshal(issuerAndSerial{Issuer: asn1.RawValue{FullBytes: target.cert.RawSubject}, SerialNumber: target.cert.SerialNumber}) + wantFailure(t, send(t, MessageTypeGetCert, holder, otherIssuer), FailBadCertID) + + wantFailure(t, send(t, MessageTypeGetCert, newDevice(t, 2048), ias(target.cert)), FailBadRequest) + + crl := send(t, MessageTypeGetCRL, holder, ias(target.cert)) + wantStatus(t, crl, StatusSuccess) + if len(crl.crls) != 1 { + t.Fatalf("GetCRL carries %d CRLs", len(crl.crls)) + } + if _, err := x509.ParseRevocationList(crl.crls[0]); err != nil { + t.Fatalf("GetCRL CRL: %v", err) + } + wantFailure(t, send(t, MessageTypeGetCRL, newDevice(t, 2048), ias(target.cert)), FailBadRequest) +} + +func TestMessageIntegrityFailures(t *testing.T) { + f := newFixture(t) + pw, _ := f.mint("cisco-device", time.Hour) + d := newDevice(t, 2048) + + t.Run("tampered signature", func(t *testing.T) { + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "sw.example.com", nil, pw), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-tamper"}) + msg[len(msg)-5] ^= 0xff + wantFailure(t, f.post(t, msg, d.key, d.cert), FailBadMessageCheck) + }) + t.Run("encrypted to a stranger", func(t *testing.T) { + stranger := newDevice(t, 2048) + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "sw.example.com", nil, pw), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-stranger", recipient: stranger.cert}) + wantFailure(t, f.post(t, msg, d.key, d.cert), FailBadMessageCheck) + }) + t.Run("unsupported message type", func(t *testing.T) { + msg := f.message(t, MessageType(99), []byte{0x30, 0x00}, msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-99"}) + wantFailure(t, f.post(t, msg, d.key, d.cert), FailBadRequest) + }) + t.Run("an ECDSA signer cannot be encrypted to", func(t *testing.T) { + ek, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + tmpl := &x509.Certificate{SerialNumber: d.cert.SerialNumber, Subject: pkixName("ec"), NotBefore: d.cert.NotBefore, NotAfter: d.cert.NotAfter} + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &ek.PublicKey, ek) + ec, _ := x509.ParseCertificate(der) + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "sw.example.com", nil, pw), msgOpts{signer: ec, signerKey: ek, txID: "tx-ec"}) + resp, err := http.Post(f.http.URL+PathSCEP+"?operation=PKIOperation", contentTypePKIMessage, bytes.NewReader(msg)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + wantFailure(t, parseCertRep(t, body, nil, nil), FailBadAlg) + }) + t.Run("AES-128 is mirrored", func(t *testing.T) { + pw2, _ := f.mint("cisco-device", time.Hour) + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "sw.example.com", nil, pw2), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-aes128", alg: cms.AES128CBC}) + wantStatus(t, f.post(t, msg, d.key, d.cert), StatusSuccess) + }) +} + +// During an RA rotation both RAs decrypt: a device that cached the old RA +// from GetCACert still enrols, and the reply is signed by the RA it used. +func TestRARotationOverlap(t *testing.T) { + f := newFixture(t) + oldRA := f.raCert() + + f.clock.advance(336 * 24 * time.Hour) + defer f.clock.advance(-336 * 24 * time.Hour) + if err := f.ras.Ensure(f.ctx); err != nil { + t.Fatalf("Ensure in the overlap: %v", err) + } + newRA := f.raCert() + if newRA.Equal(oldRA) { + t.Fatal("no successor RA was minted inside the overlap") + } + if n := len(f.ras.All()); n != 2 { + t.Fatalf("%d usable RAs during the overlap, want 2", n) + } + _, body := get(t, f, "operation=GetCACert") + sd, _ := cms.ParseSignedData(body) + offered, _ := x509.ParseCertificate(sd.Certificates[1]) + if !offered.Equal(newRA) { + t.Fatal("GetCACert does not offer the new RA during the overlap") + } + + d := newDevice(t, 2048) + pw, _ := f.mint("cisco-device", time.Hour) + msg := f.message(t, MessageTypePKCSReq, d.csr(t, "sw16.example.com", nil, pw), msgOpts{signer: d.cert, signerKey: d.key, txID: "tx-old-ra", recipient: oldRA}) + rep := f.post(t, msg, d.key, d.cert) + wantStatus(t, rep, StatusSuccess) + if !rep.signer.Equal(oldRA) { + t.Fatal("the reply to a request for the old RA is not signed by the old RA") + } + + if err := f.ras.Ensure(f.ctx); err != nil { + t.Fatal(err) + } + if n := len(f.ras.All()); n != 2 { + t.Fatalf("a second Ensure in the overlap left %d RAs, want 2 (no extra mint)", n) + } + + f.clock.advance(30 * 24 * time.Hour) + defer f.clock.advance(-30 * 24 * time.Hour) + if err := f.ras.Ensure(f.ctx); err != nil { + t.Fatal(err) + } + if n := len(f.ras.All()); n != 1 { + t.Fatalf("%d usable RAs after the old one expired, want 1", n) + } + d2 := newDevice(t, 2048) + pw2, _ := f.mint("cisco-device", time.Hour) + msg = f.message(t, MessageTypePKCSReq, d2.csr(t, "sw17.example.com", nil, pw2), msgOpts{signer: d2.cert, signerKey: d2.key, txID: "tx-expired-ra", recipient: oldRA}) + wantFailure(t, f.post(t, msg, d2.key, d2.cert), FailBadMessageCheck) +} + +func TestRAPersistsAcrossRestart(t *testing.T) { + f := newFixture(t) + first := f.raCert() + again, err := NewRAManager(f.store, f.ca.mintRA, func(context.Context) (*x509.Certificate, error) { return f.ca.cert, nil }, RAOptions{Validity: 365 * 24 * time.Hour, Overlap: 30 * 24 * time.Hour, Now: f.clock.Now}) + if err != nil { + t.Fatal(err) + } + again.keygen = func() (*rsa.PrivateKey, error) { t.Fatal("a restart minted a new RA"); return nil, nil } + if err := again.Ensure(f.ctx); err != nil { + t.Fatal(err) + } + ra, _ := again.Current() + if !ra.Cert.Equal(first) { + t.Fatal("a restart did not reload the stored RA") + } +} + +func pkixName(cn string) pkix.Name { return pkix.Name{CommonName: cn} } + +func statusCode(err error) string { return status.Code(err).String() } + +// A PKCSReq with no challenge, signed by a certificate this CA issued, is the +// pre-RFC 8894 renewal form that sscep and Cisco IOS rollover send. It gets +// the RenewalReq rules. +func TestPKCSReqSignedByTheCurrentCertificateIsARenewal(t *testing.T) { + f := newFixture(t) + old := renewer(t, f, "cisco-device", "sw20.example.com") + send := func(t *testing.T, signer *device, txID, cn, challenge string) certRep { + t.Helper() + next := newDevice(t, 2048) + msg := f.message(t, MessageTypePKCSReq, next.csr(t, cn, nil, challenge), msgOpts{signer: signer.cert, signerKey: signer.key, txID: txID}) + return f.post(t, msg, signer.key, signer.cert) + } + + wantStatus(t, send(t, old, "tx-legacy-renew", "sw20.example.com", ""), StatusSuccess) + if ev := f.audit.last(); ev.GetDetails()["authorized_by"] != "certificate" || ev.GetDetails()["challenge_id"] != "" { + t.Fatalf("audit = %v, want a certificate-authorized renewal", ev) + } + wantFailure(t, send(t, old, "tx-legacy-names", "sw21.example.com", ""), FailBadRequest) + + gone := renewer(t, f, "cisco-device", "sw22.example.com") + f.ca.revoked[gone.cert.SerialNumber.Text(16)] = true + wantFailure(t, send(t, gone, "tx-legacy-revoked", "sw22.example.com", ""), FailBadRequest) + + // Cisco IOS rollover may repeat the original challenge: signed by a + // current certificate it is still a renewal, names pinned, and the + // challenge is neither needed nor consumed. + pw, ch := f.mint("cisco-device", time.Hour) + wantFailure(t, send(t, old, "tx-legacy-challenge-names", "sw23.example.com", pw), FailBadRequest) + wantStatus(t, send(t, old, "tx-legacy-challenge", "sw20.example.com", pw), StatusSuccess) + if ev := f.audit.last(); ev.GetDetails()["authorized_by"] != "certificate" { + t.Fatalf("audit = %v, want a certificate-authorized renewal", ev) + } + if list, _ := f.srv.ListScepChallenges(f.ctx, &cryptosv1.ListScepChallengesRequest{}); len(list.GetChallenges()) != 1 || list.GetChallenges()[0].GetId() != ch.GetId() { + t.Fatal("a renewal consumed the challenge it carried") + } + + // Signed by some other CA's certificate, only the challenge counts. + stranger := newTestCA(t, "Manufacturer CA") + key, _ := rsa.GenerateKey(rand.Reader, 2048) + factory := &device{key: key, cert: stranger.sign(t, &key.PublicKey, "factory", "factory-id.example.net")} + wantStatus(t, send(t, factory, "tx-factory", "sw24.example.com", pw), StatusSuccess) + if ev := f.audit.last(); ev.GetDetails()["authorized_by"] != "challenge" { + t.Fatalf("audit = %v, want a challenge-authorized enrolment", ev) + } +} + +// After a CA key rotation the stored RA no longer chains to the CA, so a new +// one is minted; near the CA's own expiry, where a successor could not +// outlive the current RA, none is stored. +func TestRAFollowsTheCA(t *testing.T) { + f := newFixture(t) + before := f.raCert() + f.ca = newTestCA(t, "Test Issuing CA") + if err := f.ras.Ensure(f.ctx); err != nil { + t.Fatal(err) + } + after := f.raCert() + if after.Equal(before) || after.CheckSignatureFrom(f.ca.cert) != nil { + t.Fatal("no RA under the rotated CA key was minted") + } + + nearExpiry := time.Until(f.ca.cert.NotAfter) - 10*24*time.Hour + f.clock.advance(nearExpiry) + defer f.clock.advance(-nearExpiry) + for range 2 { + if err := f.ras.Ensure(f.ctx); err != nil { + t.Fatal(err) + } + } + if n := len(f.ras.All()); n != 1 { + t.Fatalf("%d RAs near the CA's expiry, want 1", n) + } + if !strings.Contains(f.logText(), "would not outlive the current RA") { + t.Fatal("the capped successor was not reported") + } +} diff --git a/internal/scep/sscep_test.go b/internal/scep/sscep_test.go new file mode 100644 index 0000000..f6c0f95 --- /dev/null +++ b/internal/scep/sscep_test.go @@ -0,0 +1,260 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto/x509" + "encoding/pem" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// These run a real SCEP client, sscep, and OpenSSL 3 (which writes the +// device's key and PKCS#10 request, challengePassword included, the way a +// device would) against the server. Our own client agreeing with our own +// server proves little. +// +// CRYPTOS_TEST_SSCEP and CRYPTOS_TEST_OPENSSL name the binaries when they are +// not sscep and openssl on PATH (macOS ships LibreSSL as /usr/bin/openssl). +// Without them the tests skip locally but fail under CI, so the check cannot +// silently stop running. + +type interop struct { + sscep string + openssl string + dir string + url string +} + +func newInterop(t *testing.T, f *fixture) *interop { + t.Helper() + unavailable := func(why string) { + if os.Getenv("CI") != "" { + t.Fatalf("SCEP interoperability tests cannot run under CI: %s", why) + } + t.Skipf("skipping SCEP interoperability: %s (set CRYPTOS_TEST_SSCEP and CRYPTOS_TEST_OPENSSL)", why) + } + find := func(env, def string) string { + bin := os.Getenv(env) + if bin == "" { + bin = def + } + path, err := exec.LookPath(bin) + if err != nil { + unavailable(bin + " is not on PATH") + } + return path + } + sscep := find("CRYPTOS_TEST_SSCEP", "sscep") + openssl := find("CRYPTOS_TEST_OPENSSL", "openssl") + if out, err := exec.Command(openssl, "version").CombinedOutput(); err != nil || !strings.HasPrefix(string(out), "OpenSSL 3") { + unavailable("need OpenSSL 3, have " + strings.TrimSpace(string(out))) + } + return &interop{sscep: sscep, openssl: openssl, dir: t.TempDir(), url: f.http.URL + PathPKIClient} +} + +func (i *interop) path(name string) string { return filepath.Join(i.dir, name) } + +func (i *interop) run(t *testing.T, bin string, args ...string) string { + t.Helper() + out, err := exec.Command(bin, args...).CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", filepath.Base(bin), strings.Join(args, " "), err, out) + } + return string(out) +} + +// runFail runs a command that must fail and returns its output. +func (i *interop) runFail(t *testing.T, bin string, args ...string) string { + t.Helper() + out, err := exec.Command(bin, args...).CombinedOutput() + if err == nil { + t.Fatalf("%s %s succeeded, want a failure\n%s", filepath.Base(bin), strings.Join(args, " "), out) + } + return string(out) +} + +// request writes an RSA key and a PKCS#10 request with OpenSSL, the way a +// device generates them, carrying challenge as its challengePassword. +func (i *interop) request(t *testing.T, name, cn string, bits int, challenge string) (keyFile, reqFile string) { + t.Helper() + cnf := i.path(name + ".cnf") + body := "[req]\nprompt = no\ndistinguished_name = dn\nattributes = attrs\n[dn]\nCN = " + cn + "\n[attrs]\n" + if challenge != "" { + body += "challengePassword = " + challenge + "\n" + } + if err := os.WriteFile(cnf, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + keyFile, reqFile = i.path(name+".key"), i.path(name+".csr") + i.run(t, i.openssl, "req", "-new", "-newkey", "rsa:"+strconv.Itoa(bits), "-nodes", "-sha256", + "-keyout", keyFile, "-out", reqFile, "-config", cnf) + return keyFile, reqFile +} + +func readPEMCert(t *testing.T, file string) *x509.Certificate { + t.Helper() + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(raw) + if block == nil { + t.Fatalf("%s holds no PEM", file) + } + c, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestSSCEPInterop(t *testing.T) { + f := newFixture(t) + i := newInterop(t, f) + t.Cleanup(func() { + if t.Failed() { + t.Logf("server log:\n%s", f.logText()) + } + }) + + caps := i.run(t, i.sscep, "getcaps", "-u", i.url) + for _, c := range Capabilities { + if !strings.Contains(caps, c) { + t.Fatalf("sscep getcaps does not show %s:\n%s", c, caps) + } + } + + caFile := i.path("ca.pem") + i.run(t, i.sscep, "getca", "-u", i.url, "-c", caFile) + caCert := readPEMCert(t, caFile+"-0") + raCert := readPEMCert(t, caFile+"-1") + if !caCert.Equal(f.ca.cert) || !raCert.Equal(f.raCert()) { + t.Fatal("sscep getca did not receive the CA and the RA") + } + + // sscep verifies replies with the certificate given as -c, so in RA mode + // that is the RA, as it is for other RA-mode servers. + enrol := func(t *testing.T, name, cn, challenge string, extra ...string) (keyFile, certFile, out string) { + t.Helper() + keyFile, reqFile := i.request(t, name, cn, 2048, challenge) + certFile = i.path(name + ".crt") + args := append([]string{"enroll", "-u", i.url, "-c", caFile + "-1", "-e", caFile + "-1", + "-k", keyFile, "-r", reqFile, "-l", certFile, "-E", "aes256", "-S", "sha256"}, extra...) + return keyFile, certFile, i.run(t, i.sscep, args...) + } + + var keyFile, certFile string + t.Run("PKCSReq with a one-time challenge", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + keyFile, certFile, _ = enrol(t, "sw1", "sw1.example.com", pw) + leaf := readPEMCert(t, certFile) + if leaf.Subject.CommonName != "sw1.example.com" || leaf.CheckSignatureFrom(f.ca.cert) != nil { + t.Fatalf("sscep enrolled %v", leaf.Subject) + } + }) + t.Run("a reused challenge is refused", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + enrol(t, "sw2", "sw2.example.com", pw) + k, r := i.request(t, "sw3", "sw3.example.com", 2048, pw) + i.runFail(t, i.sscep, "enroll", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", k, "-r", r, "-l", i.path("sw3.crt"), "-E", "aes256", "-S", "sha256") + }) + t.Run("SHA-512 and AES-128", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + k, r := i.request(t, "sw4", "sw4.example.com", 2048, pw) + i.run(t, i.sscep, "enroll", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", k, "-r", r, "-l", i.path("sw4.crt"), "-E", "aes128", "-S", "sha512") + }) + t.Run("SHA-1 and 3DES are refused", func(t *testing.T) { + pw, _ := f.mint("cisco-device", time.Hour) + k, r := i.request(t, "sw5", "sw5.example.com", 2048, pw) + i.runFail(t, i.sscep, "enroll", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", k, "-r", r, "-l", i.path("sw5.crt"), "-E", "3des", "-S", "sha1") + }) + t.Run("GetCert", func(t *testing.T) { + leaf := readPEMCert(t, certFile) + out := i.path("got.crt") + i.run(t, i.sscep, "getcert", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", keyFile, "-l", certFile, + "-s", leaf.SerialNumber.String(), "-w", out) + if !readPEMCert(t, out).Equal(leaf) { + t.Fatal("sscep getcert returned a different certificate") + } + }) + t.Run("GetCRL", func(t *testing.T) { + out := i.path("crl.pem") + i.run(t, i.sscep, "getcrl", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", keyFile, "-l", certFile, "-w", out) + if st, err := os.Stat(out); err != nil || st.Size() == 0 { + t.Fatalf("sscep getcrl wrote nothing: %v", err) + } + }) + t.Run("renewal signed by the current certificate", func(t *testing.T) { + newKey, newReq := i.request(t, "sw1-renew", "sw1.example.com", 2048, "") + out := i.path("sw1-renew.crt") + i.run(t, i.sscep, "enroll", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", newKey, "-r", newReq, + "-K", keyFile, "-O", certFile, "-l", out, "-E", "aes256", "-S", "sha256") + renewed := readPEMCert(t, out) + if renewed.Subject.CommonName != "sw1.example.com" || renewed.Equal(readPEMCert(t, certFile)) { + t.Fatal("sscep renewal did not return a new certificate for the same name") + } + // sscep renews with a PKCSReq signed by the current certificate, the + // pre-RFC 8894 form; it must get the renewal rules, not need a + // challenge. + if ev := f.audit.last(); ev.GetDetails()["authorized_by"] != "certificate" || ev.GetDetails()["profile"] != "cisco-device" { + t.Fatalf("the renewal was not authorized by the current certificate: %v", ev) + } + }) + t.Run("PENDING, then approved while sscep polls", func(t *testing.T) { + pw, _ := f.mint("approved-device", time.Hour) + k, r := i.request(t, "sw6", "sw6.example.com", 2048, pw) + out := i.path("sw6.crt") + cmd := exec.Command(i.sscep, "enroll", "-u", i.url, "-c", caFile+"-1", "-e", caFile+"-1", "-k", k, "-r", r, "-l", out, + "-E", "aes256", "-S", "sha256", "-t", "1", "-T", "60") + var buf strings.Builder + cmd.Stdout, cmd.Stderr = &buf, &buf + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + var id string + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline) && id == ""; time.Sleep(200 * time.Millisecond) { + list, _ := f.srv.ListScepEnrollments(f.ctx, &cryptosv1.ListScepEnrollmentsRequest{}) + if len(list.GetEnrollments()) == 1 { + id = list.GetEnrollments()[0].GetId() + } + } + if id == "" { + _ = cmd.Process.Kill() + t.Fatalf("sscep's request never reached the queue:\n%s", buf.String()) + } + time.Sleep(1500 * time.Millisecond) + if _, err := f.srv.ApproveScepEnrollment(f.ctx, &cryptosv1.ApproveScepEnrollmentRequest{Id: id}); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("sscep did not collect the approved certificate: %v\n%s", err, buf.String()) + } + if readPEMCert(t, out).Subject.CommonName != "sw6.example.com" { + t.Fatal("the polled certificate is not the approved one") + } + }) +} diff --git a/internal/scep/store.go b/internal/scep/store.go new file mode 100644 index 0000000..f983ec3 --- /dev/null +++ b/internal/scep/store.go @@ -0,0 +1,353 @@ +package scep + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/CryptOS-PKI/cryptos/internal/storage/etcd" +) + +// Challenge is a stored one-time challenge. It holds the SHA-256 of the +// challenge, never the challenge itself. +type Challenge struct { + ID string `json:"id"` + DigestHex string `json:"digest_hex"` + Profile string `json:"profile"` + BoundNames []string `json:"bound_names,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + CreatedByCN string `json:"created_by_cn,omitempty"` +} + +// Transaction states. +const ( + // txProcessing is a request being handled right now; a concurrent + // retransmit is told PENDING and polls. + txProcessing = "processing" + txIssued = "issued" + txPending = "pending" + txRejected = "rejected" +) + +// Transaction records one SCEP transactionID, so a retransmitted request or a +// CertPoll gets the same answer as the original. +type Transaction struct { + TransactionID string `json:"transaction_id"` + State string `json:"state"` + // KeyID is the SHA-256 of the requested SubjectPublicKeyInfo: a + // retransmit must ask for the same key. + KeyID string `json:"key_id"` + // SignerKeyID is the SHA-256 of the request signer's public key: a + // CertPoll must be signed by the same key. + SignerKeyID string `json:"signer_key_id"` + MessageType MessageType `json:"message_type"` + Profile string `json:"profile"` + SerialHex string `json:"serial_hex,omitempty"` + EnrollmentID string `json:"enrollment_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Enrollment is an initial enrolment held for an admin decision. +type Enrollment struct { + ID string `json:"id"` + TransactionID string `json:"transaction_id"` + Profile string `json:"profile"` + SubjectDN string `json:"subject_dn"` + Names []string `json:"names"` + KeyAlg string `json:"key_alg"` + CSRDER []byte `json:"csr_der"` + ChallengeID string `json:"challenge_id"` + ReceivedAt time.Time `json:"received_at"` +} + +// storedRA is one persisted RA certificate and its key. +type storedRA struct { + CertDER []byte `json:"cert_der"` + KeyPKCS8 []byte `json:"key_pkcs8"` +} + +// Store persists SCEP state in the node's embedded etcd, which lives on the +// encrypted state partition. +type Store struct { + cli *clientv3.Client +} + +// NewStore returns a Store backed by cli. +func NewStore(cli *clientv3.Client) *Store { + return &Store{cli: cli} +} + +func transactionKey(tid string) string { + sum := sha256.Sum256([]byte(tid)) + return etcd.PrefixSCEPTransactions + hex.EncodeToString(sum[:]) +} + +// PutChallenge stores c under a lease of ttl, so it expires unused without a +// sweeper. +func (s *Store) PutChallenge(ctx context.Context, c Challenge, ttl time.Duration) error { + secs := int64(ttl / time.Second) + if secs < 1 { + secs = 1 + } + lease, err := s.cli.Grant(ctx, secs) + if err != nil { + return fmt.Errorf("scep: PutChallenge: grant lease: %w", err) + } + buf, err := json.Marshal(c) + if err != nil { + return fmt.Errorf("scep: PutChallenge: marshal: %w", err) + } + digestKey := etcd.PrefixSCEPChallengeDigests + c.DigestHex + resp, err := s.cli.Txn(ctx). + If(clientv3.Compare(clientv3.CreateRevision(digestKey), "=", 0)). + Then( + clientv3.OpPut(etcd.PrefixSCEPChallenges+c.ID, string(buf), clientv3.WithLease(lease.ID)), + clientv3.OpPut(digestKey, c.ID, clientv3.WithLease(lease.ID)), + ).Commit() + if err != nil { + return fmt.Errorf("scep: PutChallenge: txn: %w", err) + } + if !resp.Succeeded { + return errors.New("scep: PutChallenge: a challenge with the same digest already exists") + } + return nil +} + +// ConsumeChallenge atomically removes the challenge whose SHA-256 is +// digestHex and returns it. ok is false when no such challenge exists, which +// covers never minted, already consumed, revoked and lease-expired alike. The +// delete is the check, so two requests presenting one challenge cannot both +// have it. +func (s *Store) ConsumeChallenge(ctx context.Context, digestHex string) (Challenge, bool, error) { + digestKey := etcd.PrefixSCEPChallengeDigests + digestHex + idResp, err := s.cli.Get(ctx, digestKey) + if err != nil { + return Challenge{}, false, fmt.Errorf("scep: ConsumeChallenge: get: %w", err) + } + if len(idResp.Kvs) == 0 { + return Challenge{}, false, nil + } + chKey := etcd.PrefixSCEPChallenges + string(idResp.Kvs[0].Value) + resp, err := s.cli.Txn(ctx). + If( + clientv3.Compare(clientv3.CreateRevision(digestKey), ">", 0), + clientv3.Compare(clientv3.CreateRevision(chKey), ">", 0), + ). + Then(clientv3.OpGet(chKey), clientv3.OpDelete(chKey), clientv3.OpDelete(digestKey)). + Commit() + if err != nil { + return Challenge{}, false, fmt.Errorf("scep: ConsumeChallenge: txn: %w", err) + } + if !resp.Succeeded { + return Challenge{}, false, nil + } + kvs := resp.Responses[0].GetResponseRange().GetKvs() + if len(kvs) == 0 { + return Challenge{}, false, nil + } + var c Challenge + if err := json.Unmarshal(kvs[0].Value, &c); err != nil { + return Challenge{}, false, fmt.Errorf("scep: ConsumeChallenge: unmarshal: %w", err) + } + return c, true, nil +} + +// ListChallenges returns every stored challenge, soonest to expire first. +func (s *Store) ListChallenges(ctx context.Context) ([]Challenge, error) { + out, err := listJSON[Challenge](ctx, s.cli, etcd.PrefixSCEPChallenges) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) }) + return out, nil +} + +// DeleteChallenge removes the challenge with the given ID and returns it; ok +// is false when there is none. +func (s *Store) DeleteChallenge(ctx context.Context, id string) (Challenge, bool, error) { + chKey := etcd.PrefixSCEPChallenges + id + resp, err := s.cli.Txn(ctx). + If(clientv3.Compare(clientv3.CreateRevision(chKey), ">", 0)). + Then(clientv3.OpGet(chKey), clientv3.OpDelete(chKey)). + Commit() + if err != nil { + return Challenge{}, false, fmt.Errorf("scep: DeleteChallenge: txn: %w", err) + } + if !resp.Succeeded { + return Challenge{}, false, nil + } + var c Challenge + if err := json.Unmarshal(resp.Responses[0].GetResponseRange().GetKvs()[0].Value, &c); err != nil { + return Challenge{}, false, fmt.Errorf("scep: DeleteChallenge: unmarshal: %w", err) + } + if _, err := s.cli.Delete(ctx, etcd.PrefixSCEPChallengeDigests+c.DigestHex); err != nil { + return Challenge{}, false, fmt.Errorf("scep: DeleteChallenge: delete the digest index: %w", err) + } + return c, true, nil +} + +// ClaimTransaction writes t unless its transactionID is already recorded. +// claimed reports whether t was written; when it was not, existing is the +// record already there. +func (s *Store) ClaimTransaction(ctx context.Context, t Transaction) (existing Transaction, claimed bool, err error) { + buf, err := json.Marshal(t) + if err != nil { + return Transaction{}, false, fmt.Errorf("scep: ClaimTransaction: marshal: %w", err) + } + key := transactionKey(t.TransactionID) + resp, err := s.cli.Txn(ctx). + If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)). + Then(clientv3.OpPut(key, string(buf))). + Else(clientv3.OpGet(key)). + Commit() + if err != nil { + return Transaction{}, false, fmt.Errorf("scep: ClaimTransaction: txn: %w", err) + } + if resp.Succeeded { + return Transaction{}, true, nil + } + if err := json.Unmarshal(resp.Responses[0].GetResponseRange().GetKvs()[0].Value, &existing); err != nil { + return Transaction{}, false, fmt.Errorf("scep: ClaimTransaction: unmarshal: %w", err) + } + return existing, false, nil +} + +// GetTransaction returns the record for a transactionID. +func (s *Store) GetTransaction(ctx context.Context, tid string) (Transaction, bool, error) { + return getJSON[Transaction](ctx, s.cli, transactionKey(tid)) +} + +// PutTransaction writes t. +func (s *Store) PutTransaction(ctx context.Context, t Transaction) error { + return putJSON(ctx, s.cli, transactionKey(t.TransactionID), t) +} + +// DeleteTransaction removes the record for a transactionID. A request that +// failed is not remembered, so the device can try again with a new challenge. +func (s *Store) DeleteTransaction(ctx context.Context, tid string) error { + if _, err := s.cli.Delete(ctx, transactionKey(tid)); err != nil { + return fmt.Errorf("scep: DeleteTransaction: %w", err) + } + return nil +} + +// PutEnrollment writes e. +func (s *Store) PutEnrollment(ctx context.Context, e Enrollment) error { + return putJSON(ctx, s.cli, etcd.PrefixSCEPEnrollments+e.ID, e) +} + +// GetEnrollment returns the waiting enrolment with the given ID. +func (s *Store) GetEnrollment(ctx context.Context, id string) (Enrollment, bool, error) { + return getJSON[Enrollment](ctx, s.cli, etcd.PrefixSCEPEnrollments+id) +} + +// ListEnrollments returns the waiting enrolments, oldest first. +func (s *Store) ListEnrollments(ctx context.Context) ([]Enrollment, error) { + out, err := listJSON[Enrollment](ctx, s.cli, etcd.PrefixSCEPEnrollments) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].ReceivedAt.Before(out[j].ReceivedAt) }) + return out, nil +} + +// FinishEnrollment takes an enrolment out of the queue and records its +// transaction's outcome in one step, so a poll never sees one without the +// other. +func (s *Store) FinishEnrollment(ctx context.Context, id string, t Transaction) error { + buf, err := json.Marshal(t) + if err != nil { + return fmt.Errorf("scep: FinishEnrollment: marshal: %w", err) + } + if _, err := s.cli.Txn(ctx).Then( + clientv3.OpDelete(etcd.PrefixSCEPEnrollments+id), + clientv3.OpPut(transactionKey(t.TransactionID), string(buf)), + ).Commit(); err != nil { + return fmt.Errorf("scep: FinishEnrollment: txn: %w", err) + } + return nil +} + +func (s *Store) listRAs(ctx context.Context) ([]storedRA, error) { + return listJSON[storedRA](ctx, s.cli, etcd.PrefixSCEPRA) +} + +func (s *Store) putRA(ctx context.Context, serialHex string, r storedRA) error { + return putJSON(ctx, s.cli, etcd.PrefixSCEPRA+serialHex, r) +} + +func (s *Store) deleteRA(ctx context.Context, serialHex string) error { + if _, err := s.cli.Delete(ctx, etcd.PrefixSCEPRA+serialHex); err != nil { + return fmt.Errorf("scep: delete RA %s: %w", serialHex, err) + } + return nil +} + +func putJSON[T any](ctx context.Context, cli *clientv3.Client, key string, v T) error { + buf, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("scep: marshal %s: %w", key, err) + } + if _, err := cli.Put(ctx, key, string(buf)); err != nil { + return fmt.Errorf("scep: put %s: %w", key, err) + } + return nil +} + +func getJSON[T any](ctx context.Context, cli *clientv3.Client, key string) (T, bool, error) { + var out T + resp, err := cli.Get(ctx, key) + if err != nil { + return out, false, fmt.Errorf("scep: get %s: %w", key, err) + } + if len(resp.Kvs) == 0 { + return out, false, nil + } + if err := json.Unmarshal(resp.Kvs[0].Value, &out); err != nil { + return out, false, fmt.Errorf("scep: unmarshal %s: %w", key, err) + } + return out, true, nil +} + +func listJSON[T any](ctx context.Context, cli *clientv3.Client, prefix string) ([]T, error) { + resp, err := cli.Get(ctx, prefix, clientv3.WithPrefix()) + if err != nil { + return nil, fmt.Errorf("scep: list %s: %w", prefix, err) + } + out := make([]T, 0, len(resp.Kvs)) + for _, kv := range resp.Kvs { + var v T + if err := json.Unmarshal(kv.Value, &v); err != nil { + return nil, fmt.Errorf("scep: unmarshal %s: %w", kv.Key, err) + } + out = append(out, v) + } + return out, nil +} diff --git a/internal/storage/etcd/etcd.go b/internal/storage/etcd/etcd.go index cbe4d50..96493e0 100644 --- a/internal/storage/etcd/etcd.go +++ b/internal/storage/etcd/etcd.go @@ -160,6 +160,34 @@ const ( // by PrefixACMENonces + the nonce. The value is empty and each key // carries a lease so nonces expire without a sweeper. PrefixACMENonces = "/cryptos/acme/nonces/" + + // PrefixSCEPChallenges stores one SCEP one-time challenge per mint, keyed + // by PrefixSCEPChallenges + challenge ID. Value is a JSON scep.Challenge + // holding the challenge's SHA-256, never the challenge. The key carries a + // lease of the challenge TTL, so an unused challenge expires on its own. + PrefixSCEPChallenges = "/cryptos/scep/challenges/" + + // PrefixSCEPChallengeDigests indexes the challenges by digest, keyed by + // PrefixSCEPChallengeDigests + hex SHA-256 of the challenge. Value is the + // challenge ID, under the same lease. + PrefixSCEPChallengeDigests = "/cryptos/scep/challenge-digests/" + + // PrefixSCEPTransactions stores one record per SCEP transaction, keyed by + // PrefixSCEPTransactions + hex SHA-256 of the transactionID. Value is a + // JSON scep.Transaction: its state and, once issued, the serial, so a + // retransmit or a CertPoll returns the same certificate. + PrefixSCEPTransactions = "/cryptos/scep/transactions/" + + // PrefixSCEPEnrollments stores one initial enrolment waiting for an admin + // decision, keyed by PrefixSCEPEnrollments + enrolment ID. Value is a JSON + // scep.Enrollment. + PrefixSCEPEnrollments = "/cryptos/scep/enrollments/" + + // PrefixSCEPRA stores one SCEP RA certificate and its key per RA, keyed + // by PrefixSCEPRA + hex serial. Value is a JSON scep.storedRA with the + // RSA 3072 key as PKCS#8, on the encrypted state partition like the + // delegated OCSP responder key. + PrefixSCEPRA = "/cryptos/scep/ra/" ) // Server is a running embedded etcd. Not safe for concurrent Open/Close. From bcd25e8c346bee0adb24be1908697664f3d05fa5 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:49:52 -0400 Subject: [PATCH 3/3] test(scep): cover the clock gate on SCEP issuance 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> --- docs/scep.md | 3 +++ docs/time-sync.md | 5 ++++- internal/init/scep_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/scep.md b/docs/scep.md index a773e29..fb0115e 100644 --- a/docs/scep.md +++ b/docs/scep.md @@ -174,6 +174,9 @@ admin RPCs they are. A challenge never appears in the log. from this CA. - **The RA key is a software key** on the encrypted state partition, not a TPM key. +- **The clock gate applies.** While a configured time source has not synced + this boot, issuance is refused, the same as for ACME and EST, and the device + gets a `badRequest` failure. See [`time-sync.md`](time-sync.md). For the Cisco IOS and IOS-XE steps, see the docs site page "Enrol devices with SCEP". diff --git a/docs/time-sync.md b/docs/time-sync.md index 697e257..9fb1ba2 100644 --- a/docs/time-sync.md +++ b/docs/time-sync.md @@ -58,12 +58,15 @@ bad or spoofed server can do. While a time source is configured or leased but the clock has not synced yet this boot, the node refuses to sign certificates: subordinate CA signing and -every leaf path, including ACME and EST. The call fails with +every leaf path, including ACME, EST and SCEP. The call fails with `FailedPrecondition` before the CA key is loaded. The gate opens at the first good sync and stays open for the rest of the boot, even if a later poll fails. - A node with **no time source** is never gated. - **CRL and OCSP** are never gated. A stale-looking CRL is better than none. +- The node's own **service certificates** are not gated either: the OCSP + responder, the EST server and the SCEP RA certificates are minted when their + listeners start, often before a first sync, so those listeners can come up. - `pki.allow_unsynced_clock: true` lifts the gate. Every certificate signed that way is logged as a warning. Leave it off in production. diff --git a/internal/init/scep_test.go b/internal/init/scep_test.go index 59f0b21..acbafca 100644 --- a/internal/init/scep_test.go +++ b/internal/init/scep_test.go @@ -31,6 +31,9 @@ import ( "testing" "time" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/CryptOS-PKI/cryptos/internal/ca" "github.com/CryptOS-PKI/cryptos/internal/config" ) @@ -104,3 +107,30 @@ func TestSCEPMintRA(t *testing.T) { t.Fatalf("RA subject %q or signature wrong", ra.Subject.CommonName) } } + +// SCEP issuance goes through the same clock-gated CA signer as ACME and EST, +// so an unsynced clock refuses it. The RA certificate is minted with the CA +// key directly, like the OCSP responder and EST server certificates, and is +// not gated: SCEP must be able to offer an RA from the first boot. +func TestSCEPIssuanceHonoursTheClockGate(t *testing.T) { + g := newGateFixture(t, unsyncedEngine(t)) + _, err := scepIssuer(g.signer)(g.ctx, testCSR(t), "leaf", []string{"router.example.com"}, 0) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("SCEP issuance on an unsynced clock = %v, want FailedPrecondition", err) + } + + caKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + now := time.Now().UTC() + der, _, err := ca.Sign(ca.Profile{Subject: pkix.Name{CommonName: "Node CA"}, NotBefore: now.Add(-time.Hour), NotAfter: now.AddDate(3, 0, 0), + IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign}, &caKey.PublicKey, nil, caKey) + if err != nil { + t.Fatal(err) + } + caCert, _ := x509.ParseCertificate(der) + load := func(context.Context) (crypto.Signer, func(), error) { return caKey, func() {}, nil } + issuer := func(context.Context) (*x509.Certificate, error) { return caCert, nil } + raKey, _ := rsa.GenerateKey(rand.Reader, 3072) + if _, err := scepMintRA(load, issuer)(g.ctx, &raKey.PublicKey, now, now.AddDate(1, 0, 0)); err != nil { + t.Fatalf("RA mint on an unsynced clock: %v", err) + } +}