Skip to content

Add support for creating API tokens backed by KMS signer (WIP) #1399

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

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cmd/step/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ import (
"regexp"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAppHasAllCommands(t *testing.T) {
app := newApp(&bytes.Buffer{}, &bytes.Buffer{})
require.NotNil(t, app)

require.Equal(t, "step", app.Name)
require.Equal(t, "step", app.HelpName)
assert.Equal(t, "step", app.Name)
assert.Equal(t, "step", app.HelpName)

var names = make([]string, 0, len(app.Commands))
for _, c := range app.Commands {
names = append(names, c.Name)
}
require.Equal(t, []string{

assert.ElementsMatch(t, []string{
"help", "api", "path", "base64", "fileserver",
"certificate", "completion", "context", "crl",
"crypto", "oauth", "version", "ca", "beta", "ssh",
Expand All @@ -42,5 +44,5 @@ func TestAppRuns(t *testing.T) {
require.Empty(t, stderr.Bytes())

output := ansiRegex.ReplaceAllString(stdout.String(), "")
require.Contains(t, output, "step -- plumbing for distributed systems")
assert.Contains(t, output, "step -- plumbing for distributed systems")
}
185 changes: 163 additions & 22 deletions command/api/token/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,32 @@

import (
"bytes"
"context"
"crypto"
"crypto/tls"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path"

"github.com/google/uuid"
"github.com/urfave/cli"

"github.com/smallstep/certificates/ca"
"github.com/smallstep/cli-utils/errs"
"github.com/smallstep/cli-utils/ui"
"go.step.sm/crypto/pemutil"
"go.step.sm/crypto/randutil"
"go.step.sm/crypto/tpm"
"go.step.sm/crypto/tpm/tss2"

"github.com/smallstep/cli/flags"
"github.com/smallstep/cli/internal/cryptoutil"
"github.com/smallstep/cli/internal/httptransport"
)

func createCommand() cli.Command {
Expand All @@ -28,6 +41,8 @@
Flags: []cli.Flag{
apiURLFlag,
audienceFlag,
flags.PasswordFile,
tpmDeviceFlag,
},
Description: `**step ca api token create** creates a new token for connecting to the Smallstep API.

Expand All @@ -43,14 +58,29 @@
: File to read the private key (PEM format).

## EXAMPLES
Use a certificate to get a new API token:
Use a certificate and team ID to get a new API token:
'''
$ step api token create ff98be70-7cc3-4df5-a5db-37f5d3c96e23 internal.crt internal.key
'''

Get a token using the team slug:
'''
$ step api token create teamfoo internal.crt internal.key
$ step api token create team-foo internal.crt internal.key
'''

Use a certificate with a private key backed by a TPM to get a new API token:
'''
$ step api token create team-tpm ecdsa-chain.crt 'tpmkms:name=test-ecdsa'
'''

Use a certificate with a private key backed by a TPM simulator to get a new API token:
'''
$ step api token create team-tpm-simulator ecdsa-chain.crt 'tpmkms:name=test-ecdsa;device=/path/to/tpmsimulator.sock'
'''

Use a certificate and a TSS2 PEM encoded private key to get a new API token:
'''
$ step api token create team-tss2 ecdsa-chain.crt ecdsa.tss2.pem --tpm-device /dev/tpmrm0
'''
`,
}
Expand All @@ -73,54 +103,65 @@
return err
}

args := ctx.Args()

teamID := args.Get(0)
crtFile := args.Get(1)
keyFile := args.Get(2)
var (
args = ctx.Args()
teamID = args.Get(0)
crtFile = args.Get(1)
keyFile = args.Get(2)
passwordFile = ctx.String("password-file")
apiURLFlag = ctx.String("api-url")
audience = ctx.String("audience")
tpmDevice = ctx.String("tpm-device")
)

Check warning on line 115 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L106-L115

Added lines #L106 - L115 were not covered by tests

parsedURL, err := url.Parse(ctx.String("api-url"))
parsedURL, err := url.Parse(apiURLFlag)

Check warning on line 117 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L117

Added line #L117 was not covered by tests
if err != nil {
return err
}
parsedURL.Path = path.Join(parsedURL.Path, "api/auth")
apiURL := parsedURL.String()

clientCert, err := tls.LoadX509KeyPair(crtFile, keyFile)
clientCert, err := createClientCertificate(crtFile, keyFile, passwordFile, tpmDevice)

Check warning on line 124 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L124

Added line #L124 was not covered by tests
if err != nil {
return err
}
b := &bytes.Buffer{}
r := &createTokenReq{

b := new(bytes.Buffer)
r := createTokenReq{

Check warning on line 130 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L129-L130

Added lines #L129 - L130 were not covered by tests
Bundle: clientCert.Certificate,
Audience: ctx.String("audience"),
Audience: audience,

Check warning on line 132 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L132

Added line #L132 was not covered by tests
}

Check warning on line 134 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L134

Added line #L134 was not covered by tests
if err := uuid.Validate(teamID); err != nil {
r.TeamSlug = teamID
} else {
r.TeamID = teamID
}
err = json.NewEncoder(b).Encode(r)
if err != nil {
return err
}

post, err := http.NewRequest("POST", apiURL, b)
if err != nil {
if err := json.NewEncoder(b).Encode(r); err != nil {

Check warning on line 141 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L141

Added line #L141 was not covered by tests
return err
}
post.Header.Set("Content-Type", "application/json")
transport := http.DefaultTransport.(*http.Transport).Clone()

transport := httptransport.New()

Check warning on line 145 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L145

Added line #L145 was not covered by tests
transport.TLSClientConfig = &tls.Config{
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return &clientCert, nil
return clientCert, nil

Check warning on line 148 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L148

Added line #L148 was not covered by tests
},
MinVersion: tls.VersionTLS12,
}
client := http.Client{
Transport: transport,
}
resp, err := client.Do(post)

req, err := http.NewRequest("POST", apiURL, b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", ca.UserAgent) // this is set to step.Version() during init; i.e. "Smallstep CLI/vX.X.X (os/arch)"
req.Header.Set(requestIDHeader, newRequestID())

resp, err := client.Do(req)

Check warning on line 164 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L155-L164

Added lines #L155 - L164 were not covered by tests
if err != nil {
return err
}
Expand All @@ -143,3 +184,103 @@

return nil
}

// requestIDHeader is the header name used for propagating request IDs from
// the client to the server and back again.
const requestIDHeader = "X-Request-Id"

// newRequestID generates a new random UUIDv4 request ID. If it fails,
// the request ID will be the empty string.
func newRequestID() string {
requestID, err := randutil.UUIDv4()
if err != nil {
return ""
}

Check warning on line 198 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L194-L198

Added lines #L194 - L198 were not covered by tests

return requestID

Check warning on line 200 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L200

Added line #L200 was not covered by tests
}

func createClientCertificate(crtFile, keyFile, passwordFile, tpmDevice string) (*tls.Certificate, error) {
certs, err := pemutil.ReadCertificateBundle(crtFile)
if err != nil {
return nil, fmt.Errorf("failed reading %q: %w", crtFile, err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pemutil already returns a wrapped error, if we wrap it again, we will get:

failed reading "foo.crt": error parsing foo.crt: open foo.crt: no such file or directory

If we want a nicer message, we can fix crypto or use something like:

func maybeUnwrap(err error) error {
	if wrapped := errors.Unwrap(err); wrapped != nil {
		return wrapped
	}
	return err
}

}

Check warning on line 207 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L203-L207

Added lines #L203 - L207 were not covered by tests

var certificates = make([][]byte, len(certs))
for i, c := range certs {
certificates[i] = c.Raw
}

Check warning on line 212 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L209-L212

Added lines #L209 - L212 were not covered by tests

pk, err := getPrivateKey(keyFile, passwordFile, tpmDevice)
if err != nil {
return nil, fmt.Errorf("failed reading key from %q: %w", keyFile, err)
}

Check warning on line 217 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L214-L217

Added lines #L214 - L217 were not covered by tests

if _, ok := pk.(crypto.Signer); !ok {
return nil, fmt.Errorf("private key type %T read from %q cannot be used as a signer", pk, keyFile)
}

Check warning on line 221 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L219-L221

Added lines #L219 - L221 were not covered by tests

return &tls.Certificate{
Certificate: certificates,
Leaf: certs[0],
PrivateKey: pk,
}, nil

Check warning on line 227 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L223-L227

Added lines #L223 - L227 were not covered by tests
}

func getPrivateKey(keyFile, passwordFile, tpmDevice string) (crypto.PrivateKey, error) {
if cryptoutil.IsKMS(keyFile) {
signer, err := cryptoutil.CreateSigner(keyFile, keyFile)
if err != nil {
return nil, fmt.Errorf("failed creating signer: %w", err)
}

Check warning on line 235 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L230-L235

Added lines #L230 - L235 were not covered by tests

return signer, nil

Check warning on line 237 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L237

Added line #L237 was not covered by tests
}

b, err := os.ReadFile(keyFile)
if err != nil {
return nil, err
}

Check warning on line 243 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L240-L243

Added lines #L240 - L243 were not covered by tests

// detect the type of the PEM file. if it's a TSS2 PEM file, pemutil
// can't be used to create a private key, as it does not support this
// type. Support could be added, but it could require some additional
// options, such as specifying the TPM device that backs the TSS2
// signer.
p, _ := pem.Decode(b)
if p.Type != "TSS2 PRIVATE KEY" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just added this smallstep/crypto#743 to avoid this condition.

var opts []pemutil.Options
if passwordFile != "" {
opts = append(opts, pemutil.WithPasswordFile(passwordFile))
}

Check warning on line 255 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L250-L255

Added lines #L250 - L255 were not covered by tests

pk, err := pemutil.Parse(b, opts...)
if err != nil {
return nil, fmt.Errorf("failed parsing PEM: %w", err)
}

Check warning on line 260 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L257-L260

Added lines #L257 - L260 were not covered by tests

return pk, nil

Check warning on line 262 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L262

Added line #L262 was not covered by tests
}

key, err := tss2.ParsePrivateKey(p.Bytes)
if err != nil {
return nil, fmt.Errorf("failed creating TSS2 private key: %w", err)
}

Check warning on line 268 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L265-L268

Added lines #L265 - L268 were not covered by tests

var tpmOpts = []tpm.NewTPMOption{}
if tpmDevice != "" {
tpmOpts = append(tpmOpts, tpm.WithDeviceName(tpmDevice))
}

Check warning on line 273 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L270-L273

Added lines #L270 - L273 were not covered by tests

t, err := tpm.New(tpmOpts...)
if err != nil {
return nil, fmt.Errorf("failed initializing TPM: %w", err)
}

Check warning on line 278 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L275-L278

Added lines #L275 - L278 were not covered by tests

signer, err := tpm.CreateTSS2Signer(context.Background(), t, key)
if err != nil {
return nil, fmt.Errorf("failed creating TSS2 signer: %w", err)
}

Check warning on line 283 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L280-L283

Added lines #L280 - L283 were not covered by tests

return signer, nil

Check warning on line 285 in command/api/token/create.go

View check run for this annotation

Codecov / codecov/patch

command/api/token/create.go#L285

Added line #L285 was not covered by tests
}
4 changes: 4 additions & 0 deletions command/api/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ var (
Name: "audience",
Usage: "Request a token for an audience other than the API Gateway",
}
tpmDeviceFlag = cli.StringFlag{
Name: "tpm-device",
Usage: "(Optional) path to TPM device (e.g. /dev/tpmrm0)",
}
)
26 changes: 26 additions & 0 deletions internal/httptransport/httptransport.go
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test that compares this with the one in the standard library?

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Package httptransport implements initialization of [http.Transport] instances and related
// functionality.
package httptransport

import (
"net"
"net/http"
"time"
)

// New returns a reference to an [http.Transport] that's initialized just like the
// [http.DefaultTransport] is by the standard library.
func New() *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}

Check warning on line 25 in internal/httptransport/httptransport.go

View check run for this annotation

Codecov / codecov/patch

internal/httptransport/httptransport.go#L13-L25

Added lines #L13 - L25 were not covered by tests
}