From 9b527d59f9dd1647695decfeaf2183de75768674 Mon Sep 17 00:00:00 2001 From: LeeFred3042U <109694901+LeeFred3042U@users.noreply.github.com> Date: Thu, 14 May 2026 20:21:40 +0530 Subject: [PATCH] Allow step-cli to bootstrap intermediate CA certificates Extend `step ca bootstrap --install` to support installing intermediate CA certificates in addition to root certificates. Fixes #2391 --- api/api.go | 30 ++++++++++++++-- api/api_test.go | 67 ++++++++++++++++++++++++++++++++++ authority/root.go | 14 ++++++++ authority/root_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) diff --git a/api/api.go b/api/api.go index 09d2c83fb..fe2cf02e7 100644 --- a/api/api.go +++ b/api/api.go @@ -43,6 +43,7 @@ type Authority interface { AuthorizeRenewToken(ctx context.Context, ott string) (*x509.Certificate, error) GetTLSOptions() *config.TLSOptions Root(shasum string) (*x509.Certificate, error) + Intermediate(shasum string) (*x509.Certificate, error) SignWithContext(ctx context.Context, cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) Renew(peer *x509.Certificate) ([]*x509.Certificate, error) RenewContext(ctx context.Context, peer *x509.Certificate, pk crypto.PublicKey) ([]*x509.Certificate, error) @@ -227,6 +228,12 @@ type RootResponse struct { RootPEM Certificate `json:"ca"` } +// IntermediateResponse is the response object that returns the PEM of an +// intermediate certificate. +type IntermediateResponse struct { + IntermediatePEM Certificate `json:"ca"` +} + // ProvisionersResponse is the response object that returns the list of // provisioners. type ProvisionersResponse struct { @@ -340,6 +347,7 @@ func Route(r Router) { r.MethodFunc("GET", "/intermediates", Intermediates) r.MethodFunc("GET", "/intermediates.pem", IntermediatesPEM) r.MethodFunc("GET", "/federation", Federation) + r.MethodFunc("GET", "/intermediate/{sha}", Intermediate) // SSH CA r.MethodFunc("POST", "/ssh/sign", SSHSign) @@ -360,6 +368,25 @@ func Route(r Router) { r.MethodFunc("GET", "/ssh/get-hosts", SSHGetHosts) } +// normalizeFingerprint lowercases the input and strips any dashes so that +// callers can pass fingerprints in the common colon-/dash-separated form. +func normalizeFingerprint(sha string) string { + return strings.ToLower(strings.ReplaceAll(sha, "-", "")) +} + +// Intermediate is an HTTP handler that using the SHA256 from the URL, returns +// the intermediate certificate for the given SHA256. +func Intermediate(w http.ResponseWriter, r *http.Request) { + sum := normalizeFingerprint(chi.URLParam(r, "sha")) + cert, err := mustAuthority(r.Context()).Intermediate(sum) + if err != nil { + render.Error(w, r, errs.NotFoundErr(err, errs.WithMessage("intermediate certificate with fingerprint %q was not found", sum))) + return + } + + render.JSON(w, r, &IntermediateResponse{IntermediatePEM: Certificate{cert}}) +} + // Version is an HTTP handler that returns the version of the server. func Version(w http.ResponseWriter, r *http.Request) { v := mustAuthority(r.Context()).Version() @@ -377,8 +404,7 @@ func Health(w http.ResponseWriter, r *http.Request) { // Root is an HTTP handler that using the SHA256 from the URL, returns the root // certificate for the given SHA256. func Root(w http.ResponseWriter, r *http.Request) { - sha := chi.URLParam(r, "sha") - sum := strings.ToLower(strings.ReplaceAll(sha, "-", "")) + sum := normalizeFingerprint(chi.URLParam(r, "sha")) // Load root certificate with the cert, err := mustAuthority(r.Context()).Root(sum) if err != nil { diff --git a/api/api_test.go b/api/api_test.go index 15794bc1b..16cf983c5 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -10,6 +10,7 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" @@ -197,6 +198,7 @@ type mockAuthority struct { authorizeRenewToken func(ctx context.Context, ott string) (*x509.Certificate, error) getTLSOptions func() *authority.TLSOptions root func(shasum string) (*x509.Certificate, error) + intermediate func(shasum string) (*x509.Certificate, error) signWithContext func(ctx context.Context, cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) renew func(cert *x509.Certificate) ([]*x509.Certificate, error) rekey func(oldCert *x509.Certificate, pk crypto.PublicKey) ([]*x509.Certificate, error) @@ -260,6 +262,13 @@ func (m *mockAuthority) Root(shasum string) (*x509.Certificate, error) { return m.ret1.(*x509.Certificate), m.err } +func (m *mockAuthority) Intermediate(shasum string) (*x509.Certificate, error) { + if m.intermediate != nil { + return m.intermediate(shasum) + } + return m.ret1.(*x509.Certificate), m.err +} + func (m *mockAuthority) SignWithContext(ctx context.Context, cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) { if m.signWithContext != nil { return m.signWithContext(ctx, cr, opts, signOpts...) @@ -1758,3 +1767,61 @@ func TestIntermediatesPEM(t *testing.T) { }) } } + +func TestIntermediate(t *testing.T) { + ca, err := minica.New() + require.NoError(t, err) + + intermediateFP := fmt.Sprintf("%x", sha256.Sum256(ca.Intermediate.Raw)) + + tests := []struct { + name string + sha string + mockAuth *mockAuthority + wantStatusCode int + wantBody []byte + }{ + { + name: "ok", + sha: intermediateFP, + mockAuth: &mockAuthority{ + intermediate: func(sum string) (*x509.Certificate, error) { + if sum == intermediateFP { + return ca.Intermediate, nil + } + return nil, errs.NotFound("certificate with fingerprint %s was not found", sum) + }, + }, + wantStatusCode: http.StatusOK, + wantBody: mustJSON(t, IntermediateResponse{IntermediatePEM: Certificate{ca.Intermediate}}), + }, + { + name: "not found", + sha: "0000000000000000000000000000000000000000000000000000000000000000", + mockAuth: &mockAuthority{ + intermediate: func(sum string) (*x509.Certificate, error) { + return nil, errs.NotFound("certificate with fingerprint %s was not found", sum) + }, + }, + wantStatusCode: http.StatusNotFound, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockMustAuthority(t, tt.mockAuth) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/intermediate/"+tt.sha, http.NoBody) + + // Use a chi router so that URLParam works correctly. + router := chi.NewRouter() + router.Get("/intermediate/{sha}", Intermediate) + router.ServeHTTP(w, r) + + assert.Equal(t, tt.wantStatusCode, w.Result().StatusCode) + if tt.wantBody != nil { + assert.Equal(t, tt.wantBody, w.Body.Bytes()) + } + }) + } +} diff --git a/authority/root.go b/authority/root.go index 0a6ee639c..ae7962b29 100644 --- a/authority/root.go +++ b/authority/root.go @@ -1,7 +1,10 @@ package authority import ( + "crypto/sha256" "crypto/x509" + "fmt" + "strings" "github.com/smallstep/certificates/errs" ) @@ -20,6 +23,17 @@ func (a *Authority) Root(sum string) (*x509.Certificate, error) { return crt, nil } +// Intermediate returns the certificate corresponding to the given SHA sum +// argument, from the list of intermediate certificates configured in the CA. +func (a *Authority) Intermediate(sum string) (*x509.Certificate, error) { + for _, crt := range a.intermediateX509Certs { + if strings.EqualFold(fmt.Sprintf("%x", sha256.Sum256(crt.Raw)), sum) { + return crt, nil + } + } + return nil, errs.NotFound("certificate with fingerprint %s was not found", sum) +} + // GetRootCertificate returns the server root certificate. func (a *Authority) GetRootCertificate() *x509.Certificate { return a.rootX509Certs[0] diff --git a/authority/root_test.go b/authority/root_test.go index a0811dd2b..33b1180f8 100644 --- a/authority/root_test.go +++ b/authority/root_test.go @@ -1,9 +1,11 @@ package authority import ( + "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "errors" + "fmt" "net/http" "reflect" "testing" @@ -49,6 +51,86 @@ func TestRoot(t *testing.T) { } } +func TestAuthority_Intermediate(t *testing.T) { + ca, err := minica.New() + require.NoError(t, err) + + // Create a second intermediate certificate for multi-cert testing. + signer, err := keyutil.GenerateDefaultSigner() + require.NoError(t, err) + secondIntermediate, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "Second Intermediate CA"}, + PublicKey: signer.Public(), + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + }) + require.NoError(t, err) + + // Pre-compute fingerprints. + firstFP := fmt.Sprintf("%x", sha256.Sum256(ca.Intermediate.Raw)) + secondFP := fmt.Sprintf("%x", sha256.Sum256(secondIntermediate.Raw)) + + tests := []struct { + name string + intermediates []*x509.Certificate + sum string + want *x509.Certificate + wantErr bool + errCode int + }{ + { + name: "found", + intermediates: []*x509.Certificate{ca.Intermediate}, + sum: firstFP, + want: ca.Intermediate, + }, + { + name: "not found", + intermediates: []*x509.Certificate{ca.Intermediate}, + sum: "0000000000000000000000000000000000000000000000000000000000000000", + wantErr: true, + errCode: http.StatusNotFound, + }, + { + name: "empty list", + intermediates: []*x509.Certificate{}, + sum: firstFP, + wantErr: true, + errCode: http.StatusNotFound, + }, + { + name: "multiple intermediates - select first", + intermediates: []*x509.Certificate{ca.Intermediate, secondIntermediate}, + sum: firstFP, + want: ca.Intermediate, + }, + { + name: "multiple intermediates - select second", + intermediates: []*x509.Certificate{ca.Intermediate, secondIntermediate}, + sum: secondFP, + want: secondIntermediate, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &Authority{ + intermediateX509Certs: tt.intermediates, + } + got, err := a.Intermediate(tt.sum) + if tt.wantErr { + require.Error(t, err) + var sc render.StatusCodedError + require.ErrorAs(t, err, &sc) + require.Equal(t, tt.errCode, sc.StatusCode()) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} + func TestAuthority_GetRootCertificate(t *testing.T) { cert, err := pemutil.ReadCertificate("testdata/certs/root_ca.crt") if err != nil {