Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v15] tsh svid issue for issuing SPIFFE SVIDs #39115

Merged
merged 11 commits into from
Mar 8, 2024
3 changes: 0 additions & 3 deletions lib/auth/machineid/machineidv1/workload_identity_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,6 @@ func (wis *WorkloadIdentityService) SignX509SVIDs(ctx context.Context, req *pb.S
if err != nil {
return nil, trace.Wrap(err)
}
if err := authCtx.AuthorizeAdminAction(); err != nil {
return nil, trace.Wrap(err)
}

// Fetch info that will be needed for all SPIFFE SVIDs requested
clusterName, err := wis.cache.GetClusterName()
Expand Down
4 changes: 4 additions & 0 deletions tool/tsh/common/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
// Device Trust commands.
deviceCmd := newDeviceCommand(app)

workloadIdentityCmd := newSVIDCommands(app)

if runtime.GOOS == constants.WindowsOS {
bench.Hidden()
}
Expand Down Expand Up @@ -1498,6 +1500,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = onKubectlCommand(&cf, args, args[idx:])
case headlessApprove.FullCommand():
err = onHeadlessApprove(&cf)
case workloadIdentityCmd.issue.FullCommand():
err = workloadIdentityCmd.issue.run(&cf)
default:
// Handle commands that might not be available.
switch {
Expand Down
217 changes: 217 additions & 0 deletions tool/tsh/common/workload_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

package common

import (
"bytes"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path"
"time"

"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"google.golang.org/protobuf/types/known/durationpb"

"github.com/gravitational/teleport"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/native"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/services"
)

type svidCommands struct {
issue *svidIssueCommand
}

func newSVIDCommands(app *kingpin.Application) svidCommands {
cmd := app.Command("svid", "Manage Teleport Workload Identity SVIDs.")
cmds := svidCommands{
issue: newSVIDIssueCommand(cmd),
}
return cmds
}

const (
// Based on the default paths listed in
// https://github.com/spiffe/spiffe-helper/blob/v0.7.0/README.md
svidPEMPath = "svid.pem"
svidKeyPEMPath = "svid_key.pem"
svidTrustBundlePEMPath = "svid_bundle.pem"

svidTypeX509 = "x509"
)

type svidIssueCommand struct {
*kingpin.CmdClause
svidSPIFFEIDPath string
svidType string
svidDNSSANs []string
svidIPSANs []string
svidTTL time.Duration
outputDirectory string
}

func newSVIDIssueCommand(parent *kingpin.CmdClause) *svidIssueCommand {
cmd := &svidIssueCommand{
CmdClause: parent.Command("issue", "Issues a SPIFFE SVID using Teleport Workload Identity and writes it to a local directory."),
}
cmd.Arg("path", "Path to use for the SVID SPIFFE ID. Must have a preceding '/'.").
Required().
StringVar(&cmd.svidSPIFFEIDPath)
cmd.Flag("type", "Type of the SVID to issue (x509). Defaults to x509.").
Default(svidTypeX509).
EnumVar(&cmd.svidType, svidTypeX509)
cmd.Flag("output", "Path to the directory to write the SVID into.").
Required().
StringVar(&cmd.outputDirectory)
cmd.Flag("dns-san", "DNS SANs to include in the SVID. By default, none are included.").
StringsVar(&cmd.svidDNSSANs)
cmd.Flag("ip-san", "IP SANs to include in the SVID. By default, none are included.").
StringsVar(&cmd.svidIPSANs)
cmd.Flag("svid-ttl", "Sets the time to live for the SVID.").
Default("1h").
DurationVar(&cmd.svidTTL)
return cmd
}

func (c *svidIssueCommand) run(cf *CLIConf) error {
ctx := cf.Context
// Validate flags
if c.svidType != svidTypeX509 {
return trace.BadParameter("unsupported SVID type: %v", c.svidType)
}

tc, err := makeClient(cf)
if err != nil {
return trace.Wrap(err)
}
tc.AllowHeadless = true

return client.RetryWithRelogin(ctx, tc, func() error {
clusterClient, err := tc.ConnectToCluster(ctx)
if err != nil {
return trace.Wrap(err)
}
defer clusterClient.Close()

// Generate keypair to use in SVID
privateKey, err := native.GenerateRSAPrivateKey()
if err != nil {
return trace.Wrap(err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public())
if err != nil {
return trace.Wrap(err)
}

res, err := clusterClient.AuthClient.WorkloadIdentityServiceClient().
SignX509SVIDs(ctx,
&machineidv1pb.SignX509SVIDsRequest{
Svids: []*machineidv1pb.SVIDRequest{
{
SpiffeIdPath: c.svidSPIFFEIDPath,
PublicKey: pubBytes,
DnsSans: c.svidDNSSANs,
IpSans: c.svidIPSANs,
Ttl: durationpb.New(c.svidTTL),
},
},
},
)
if err != nil {
return trace.Wrap(err)
}
if len(res.Svids) != 1 {
return trace.BadParameter("expected 1 SVID, got %v", len(res.Svids))
}

// Write private key
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return trace.Wrap(err)
}
keyPath := path.Join(c.outputDirectory, svidKeyPEMPath)
err = os.WriteFile(
keyPath,
pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privBytes,
}),
teleport.FileMaskOwnerOnly,
)
if err != nil {
return trace.Wrap(err)
}

// Write SVID
svidPath := path.Join(c.outputDirectory, svidPEMPath)
err = os.WriteFile(
svidPath,
pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: res.Svids[0].Certificate,
}),
teleport.FileMaskOwnerOnly,
)
if err != nil {
return trace.Wrap(err)
}

// Write trust bundle
caRes, err := clusterClient.AuthClient.GetCertAuthorities(
ctx, types.SPIFFECA, false,
)
if err != nil {
return trace.Wrap(err)
}
trustBundleBytes := &bytes.Buffer{}
for _, ca := range caRes {
for _, cert := range services.GetTLSCerts(ca) {
// Values are already PEM encoded, so we just append to the buffer
if _, err := trustBundleBytes.Write(cert); err != nil {
return trace.Wrap(err, "writing trust bundle to buffer")
}
}
}
trustBundlePath := path.Join(c.outputDirectory, svidTrustBundlePEMPath)
err = os.WriteFile(
trustBundlePath,
trustBundleBytes.Bytes(),
teleport.FileMaskOwnerOnly,
)
if err != nil {
return trace.Wrap(err)
}

fmt.Fprintf(
cf.Stdout(),
"SVID %q issued. Files written to: \n - %s\n - %s\n - %s\n",
res.Svids[0].SpiffeId,
keyPath,
svidPath,
trustBundlePath,
)

return nil
})
}
108 changes: 108 additions & 0 deletions tool/tsh/common/workload_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

package common

import (
"context"
"crypto/x509"
"encoding/pem"
"net"
"os"
"path"
"testing"

"github.com/stretchr/testify/require"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment"
"github.com/gravitational/teleport/lib/service/servicecfg"
)

func TestWorkloadIdentityIssue(t *testing.T) {
experiment.SetEnabled(true)
t.Cleanup(func() {
experiment.SetEnabled(false)
})
ctx := context.Background()

role, err := types.NewRole("spiffe-issuer", types.RoleSpecV6{
Allow: types.RoleConditions{
SPIFFE: []*types.SPIFFERoleCondition{
{
Path: "/*",
IPSANs: []string{"0.0.0.0/0"},
DNSSANs: []string{"*"},
},
},
},
})
require.NoError(t, err)
s := newTestSuite(t, withRootConfigFunc(func(cfg *servicecfg.Config) {
// reconfig the user to use the new role instead of the default ones
// User is the second bootstrap resource.
user, ok := cfg.Auth.BootstrapResources[1].(types.User)
require.True(t, ok)
user.AddRole(role.GetName())
cfg.Auth.BootstrapResources[1] = user
cfg.Auth.BootstrapResources = append(cfg.Auth.BootstrapResources, role)
}),
)

homeDir, _ := mustLogin(t, s)
temp := t.TempDir()
err = Run(
ctx,
[]string{
"svid",
"issue",
"--output", temp,
"--svid-ttl", "10m",
"--dns-san", "example.com",
"--dns-san", "foo.example.com",
"--ip-san", "10.0.0.1",
"--ip-san", "10.1.0.1",
"/foo/bar",
},
setHomePath(homeDir),
)
require.NoError(t, err)

certPEM, err := os.ReadFile(path.Join(temp, "svid.pem"))
require.NoError(t, err)
certBlock, _ := pem.Decode(certPEM)
cert, err := x509.ParseCertificate(certBlock.Bytes)
require.NoError(t, err)
require.Equal(t, "example.com", cert.DNSNames[0])
require.Equal(t, "foo.example.com", cert.DNSNames[1])
require.Equal(t, net.IP{10, 0, 0, 1}, cert.IPAddresses[0])
require.Equal(t, net.IP{10, 1, 0, 1}, cert.IPAddresses[1])
require.Equal(t, "spiffe://root/foo/bar", cert.URIs[0].String())

keyPEM, err := os.ReadFile(path.Join(temp, "svid_key.pem"))
require.NoError(t, err)
keyBlock, _ := pem.Decode(keyPEM)
_, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
require.NoError(t, err)

bundlePEM, err := os.ReadFile(path.Join(temp, "svid_bundle.pem"))
require.NoError(t, err)
bundleBlock, _ := pem.Decode(bundlePEM)
_, err = x509.ParseCertificate(bundleBlock.Bytes)
require.NoError(t, err)
}