diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index b284284218a9d..9b99ec45887c7 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -926,6 +926,9 @@ func (h *Handler) bindDefaultEndpoints() { h.GET(OIDCJWKWURI, h.WithLimiter(h.jwksOIDC)) h.GET("/webapi/thumbprint", h.WithLimiter(h.thumbprint)) + // SPIFFE Federation Trust Bundle + h.GET("/webapi/spiffe/bundle.json", h.WithLimiter(h.getSPIFFEBundle)) + // DiscoveryConfig CRUD h.GET("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigList)) h.POST("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigCreate)) diff --git a/lib/web/spiffe.go b/lib/web/spiffe.go new file mode 100644 index 0000000000000..a47e938c51387 --- /dev/null +++ b/lib/web/spiffe.go @@ -0,0 +1,93 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "net/http" + "time" + + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + "github.com/spiffe/go-spiffe/v2/spiffeid" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/tlsca" +) + +// getSPIFFEBundle returns the SPIFFE-compatible trust bundle which allows other +// trust domains to federate with this Teleport cluster. +// +// Mounted at /webapi/spiffe/bundle.json +// +// Must abide by the standard for a "https_web" profile as described in +// https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Federation.md#5-serving-and-consuming-a-spiffe-bundle-endpoint +func (h *Handler) getSPIFFEBundle(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (any, error) { + cn, err := h.GetAccessPoint().GetClusterName() + if err != nil { + return nil, trace.Wrap(err, "fetching cluster name") + } + + td, err := spiffeid.TrustDomainFromString(cn.GetClusterName()) + if err != nil { + return nil, trace.Wrap(err, "creating trust domain") + } + + bundle := spiffebundle.New(td) + // The refresh hint indicates how often a federated trust domain should + // check for updates to the bundle. This should be a low value to ensure + // that CA rotations are picked up quickly. Since we're leveraging + // https_web, it's not critical for a federated trust domain to catch + // all phases of the rotation - however, if we support https_spiffe in + // future, we may need to consider a lower value or enforcing a wait + // period during rotations equivalent to the refresh hint. + bundle.SetRefreshHint(5 * time.Minute) + // TODO(noah): + // For now, we omit the SequenceNumber field. This is only a SHOULD not a + // MUST per the spec. To add this, we will add a sequence number to the + // cert authority and increment it on every update. + + const loadKeysFalse = false + spiffeCA, err := h.GetAccessPoint().GetCertAuthority(r.Context(), types.CertAuthID{ + Type: types.SPIFFECA, + DomainName: cn.GetClusterName(), + }, loadKeysFalse) + if err != nil { + return nil, trace.Wrap(err, "fetching SPIFFE CA") + } + + for _, certPEM := range services.GetTLSCerts(spiffeCA) { + cert, err := tlsca.ParseCertificatePEM(certPEM) + if err != nil { + return nil, trace.Wrap(err, "parsing certificate") + } + bundle.AddX509Authority(cert) + } + + bundleBytes, err := bundle.Marshal() + if err != nil { + return nil, trace.Wrap(err, "marshaling bundle") + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err = w.Write(bundleBytes); err != nil { + h.logger.DebugContext(h.cfg.Context, "Failed to write SPIFFE bundle response", "error", err) + } + return nil, nil +} diff --git a/lib/web/spiffe_test.go b/lib/web/spiffe_test.go new file mode 100644 index 0000000000000..7d04c80ad7465 --- /dev/null +++ b/lib/web/spiffe_test.go @@ -0,0 +1,71 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "context" + "crypto/x509" + "testing" + + "github.com/gravitational/roundtrip" + "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/tlsca" +) + +func TestGetSPIFFEBundle(t *testing.T) { + ctx := context.Background() + env := newWebPack(t, 1) + authServer := env.server.Auth() + cn, err := authServer.GetClusterName() + require.NoError(t, err) + ca, err := authServer.GetCertAuthority(ctx, types.CertAuthID{ + Type: types.SPIFFECA, + DomainName: cn.GetClusterName(), + }, false) + require.NoError(t, err) + + var wantCACerts []*x509.Certificate + for _, certPem := range services.GetTLSCerts(ca) { + cert, err := tlsca.ParseCertificatePEM(certPem) + require.NoError(t, err) + wantCACerts = append(wantCACerts, cert) + } + + clt, err := client.NewWebClient(env.proxies[0].webURL.String(), roundtrip.HTTPClient(client.NewInsecureWebClient())) + require.NoError(t, err) + + res, err := clt.Get(ctx, clt.Endpoint("webapi", "spiffe", "bundle.json"), nil) + require.NoError(t, err) + + td, err := spiffeid.TrustDomainFromString(cn.GetClusterName()) + require.NoError(t, err) + gotBundle, err := spiffebundle.Read(td, res.Reader()) + require.NoError(t, err) + + require.Len(t, gotBundle.X509Authorities(), len(wantCACerts)) + for _, caCert := range wantCACerts { + require.True(t, gotBundle.HasX509Authority(caCert), "certificate not found in bundle") + } +}