From 4e021fe00dadc57722a024c209f6bec085d56197 Mon Sep 17 00:00:00 2001 From: Yannik Date: Mon, 20 Apr 2026 10:52:30 +0000 Subject: [PATCH] api: add GET /intermediate.crt for AIA caIssuers Adds a new endpoint that returns the CA's issuing intermediate certificate as a single DER-encoded X.509 certificate for use as an Authority Information Access (AIA) caIssuers URI. RFC 5280 Section 4.2.2.1 permits HTTP caIssuers URIs to point to a single DER certificate as specified by RFC 2585 Section 3. For that single-certificate representation, RFC 5280 recommends Content-Type application/pkix-cert, the media type registered by RFC 2585 Section 4.1. The existing /intermediates.pem endpoint serves PEM on the HTTPS listener only. Neither property is suitable for TLS subscriber certificate AIA use: PEM over AIA has been flagged as a compliance issue by browser root programs (see Mozilla bug 1637093), and the CA/Browser Forum Baseline Requirements Section 7.1.2.7.7 describe id-ad-caIssuers as an HTTP URL of the issuing CA certificate. This lets clients fetch the issuer before they can validate the CA's TLS certificate. The endpoint is mounted on both the secure and insecure muxes, mirroring /crl. The insecure server now starts for this endpoint when insecureAddress is configured and the CA has an issuer certificate. --- api/api.go | 20 ++++++++++++++++++++ api/api_test.go | 37 +++++++++++++++++++++++++++++++++++++ ca/ca.go | 21 +++++++++++++++++---- ca/ca_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/api/api.go b/api/api.go index 09d2c83fb..b831ec79f 100644 --- a/api/api.go +++ b/api/api.go @@ -339,6 +339,7 @@ func Route(r Router) { r.MethodFunc("GET", "/roots.pem", RootsPEM) r.MethodFunc("GET", "/intermediates", Intermediates) r.MethodFunc("GET", "/intermediates.pem", IntermediatesPEM) + r.MethodFunc("GET", "/intermediate.crt", IntermediateCert) r.MethodFunc("GET", "/federation", Federation) // SSH CA @@ -511,6 +512,25 @@ func IntermediatesPEM(w http.ResponseWriter, r *http.Request) { } } +// IntermediateCert returns the CA's issuing intermediate certificate as a +// single DER-encoded X.509 certificate for use as an Authority Information +// Access (AIA) caIssuers URI. RFC 5280 Section 4.2.2.1 permits HTTP +// caIssuers URIs to point to a single DER certificate as specified by RFC +// 2585 Section 3; RFC 5280 Section 4.2.2.1 and RFC 2585 Section 4.1 use +// Content-Type application/pkix-cert for that representation. +func IntermediateCert(w http.ResponseWriter, r *http.Request) { + intermediates := mustAuthority(r.Context()).GetIntermediateCertificates() + if len(intermediates) == 0 { + render.Error(w, r, errs.NotImplemented("error getting intermediate: method not implemented")) + return + } + + w.Header().Set("Content-Type", "application/pkix-cert") + if _, err := w.Write(intermediates[0].Raw); err != nil { + log.Error(w, r, err) + } +} + // Federation returns all the public certificates in the federation. func Federation(w http.ResponseWriter, r *http.Request) { federated, err := mustAuthority(r.Context()).GetFederation() diff --git a/api/api_test.go b/api/api_test.go index 15794bc1b..db648c866 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1718,6 +1718,43 @@ func TestIntermediates(t *testing.T) { } } +func TestIntermediateCert(t *testing.T) { + ca, err := minica.New() + require.NoError(t, err) + + getRequest := func(t *testing.T, crt []*x509.Certificate) *http.Request { + mockMustAuthority(t, &mockAuthority{ + ret1: crt, + }) + return httptest.NewRequest("GET", "/intermediate.crt", http.NoBody) + } + + type args struct { + crts []*x509.Certificate + } + tests := []struct { + name string + args args + wantStatusCode int + wantContentType string + wantBody []byte + }{ + {"ok", args{[]*x509.Certificate{ca.Intermediate}}, http.StatusOK, "application/pkix-cert", ca.Intermediate.Raw}, + {"ok multiple returns first", args{[]*x509.Certificate{ca.Intermediate, ca.Root}}, http.StatusOK, "application/pkix-cert", ca.Intermediate.Raw}, + {"fail", args{}, http.StatusNotImplemented, "application/json", mustJSON(t, errs.NotImplemented("not implemented"))}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := getRequest(t, tt.args.crts) + IntermediateCert(w, r) + assert.Equal(t, tt.wantStatusCode, w.Result().StatusCode) + assert.Equal(t, tt.wantContentType, w.Result().Header.Get("Content-Type")) + assert.Equal(t, tt.wantBody, w.Body.Bytes()) + }) + } +} + func TestIntermediatesPEM(t *testing.T) { ca, err := minica.New() require.NoError(t, err) diff --git a/ca/ca.go b/ca/ca.go index 3f0704a0a..81ea601a5 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -248,6 +248,14 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { insecureMux.Get("/crl", api.CRL) insecureMux.Get("/1.0/crl", api.CRL) + // Mount the AIA issuer endpoint to the insecure mux. For TLS subscriber + // certificates, CA/Browser Forum Baseline Requirements Section 7.1.2.7.7 + // describe id-ad-caIssuers as an HTTP URL of the issuing CA certificate; + // this lets clients fetch the issuer before they can validate the CA's + // TLS certificate. + insecureMux.Get("/intermediate.crt", api.IntermediateCert) + insecureMux.Get("/1.0/intermediate.crt", api.IntermediateCert) + // Add ACME api endpoints in /acme and /1.0/acme dns := cfg.DNSNames[0] u, err := url.Parse("https://" + cfg.Address) @@ -385,10 +393,9 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { return ca, nil } -// shouldServeInsecureServer returns whether or not the insecure -// server should also be started. This is (currently) only the case -// if the insecure address has been configured AND when a SCEP -// provisioner is configured or when a CRL is configured. +// shouldServeInsecureServer returns whether the insecure server should also be +// started. This requires an insecure address and at least one endpoint intended +// for HTTP: SCEP, CRL, or the AIA issuer certificate. func (ca *CA) shouldServeInsecureServer() bool { switch { case ca.config.InsecureAddress == "": @@ -397,11 +404,17 @@ func (ca *CA) shouldServeInsecureServer() bool { return true case ca.config.CRL.IsEnabled(): return true + case ca.shouldServeAIAIssuerEndpoint(): + return true default: return false } } +func (ca *CA) shouldServeAIAIssuerEndpoint() bool { + return len(ca.auth.GetIntermediateCertificates()) > 0 +} + // buildContext builds the server base context. func buildContext(a *authority.Authority, scepAuthority *scep.Authority, acmeDB acme.DB, acmeLinker acme.Linker) context.Context { ctx := authority.NewContext(context.Background(), a) diff --git a/ca/ca_test.go b/ca/ca_test.go index d61170551..c94feb79e 100644 --- a/ca/ca_test.go +++ b/ca/ca_test.go @@ -24,6 +24,7 @@ import ( "github.com/smallstep/assert" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/authority" + authorityconfig "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/errs" "go.step.sm/crypto/jose" @@ -76,6 +77,51 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func TestCA_shouldServeInsecureServer(t *testing.T) { + config, err := authority.LoadConfiguration("testdata/ca.json") + assert.FatalError(t, err) + config.InsecureAddress = "" + + ca, err := New(config) + assert.FatalError(t, err) + assert.False(t, ca.shouldServeSCEPEndpoints()) + assert.False(t, ca.config.CRL.IsEnabled()) + assert.True(t, ca.shouldServeAIAIssuerEndpoint()) + assert.False(t, ca.shouldServeInsecureServer()) + + ca.config.InsecureAddress = "127.0.0.1:8080" + assert.True(t, ca.shouldServeInsecureServer()) + + authWithoutIntermediate := newTestAuthorityWithoutIntermediate(t) + caWithoutPublicHTTP := &CA{ + auth: authWithoutIntermediate, + config: &authorityconfig.Config{ + InsecureAddress: "127.0.0.1:8080", + }, + } + assert.False(t, caWithoutPublicHTTP.shouldServeAIAIssuerEndpoint()) + assert.False(t, caWithoutPublicHTTP.shouldServeInsecureServer()) +} + +func newTestAuthorityWithoutIntermediate(t *testing.T) *authority.Authority { + t.Helper() + + root, err := pemutil.ReadCertificate("testdata/secrets/root_ca.crt") + assert.FatalError(t, err) + signer, err := keyutil.GenerateDefaultSigner() + assert.FatalError(t, err) + + auth, err := authority.NewEmbedded( + authority.WithX509RootCerts(root), + authority.WithX509SignerFunc(func() ([]*x509.Certificate, crypto.Signer, error) { + return nil, signer, nil + }), + ) + assert.FatalError(t, err) + + return auth +} + func TestCASign(t *testing.T) { pub, priv, err := keyutil.GenerateDefaultKeyPair() assert.FatalError(t, err)