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)