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..fb0115e --- /dev/null +++ b/docs/scep.md @@ -0,0 +1,182 @@ +# 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. +- **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/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/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..acbafca --- /dev/null +++ b/internal/init/scep_test.go @@ -0,0 +1,136 @@ +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" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "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) + } +} + +// 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) + } +} 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/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) + } +} 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.