Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions cmd/cryptosctl/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func newRootCmd() *cobra.Command {
newResetCmd(opts),
newVersionCmd(opts),
newTrustCmd(opts),
newSCEPCmd(opts),
)
addDebugCommands(root, opts)
return root
Expand Down
274 changes: 274 additions & 0 deletions cmd/cryptosctl/scep.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading