You've already forked step-ca-cli
mirror of
https://github.com/smallstep/cli.git
synced 2026-10-24 11:19:19 +03:00
525 lines
14 KiB
Go
525 lines
14 KiB
Go
package ca
|
|
|
|
import (
|
|
"crypto"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/smallstep/cli/exec"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/smallstep/certificates/authority/provisioner"
|
|
"github.com/smallstep/cli/command"
|
|
"github.com/smallstep/cli/crypto/pki"
|
|
"github.com/smallstep/cli/crypto/randutil"
|
|
"github.com/smallstep/cli/errs"
|
|
"github.com/smallstep/cli/flags"
|
|
"github.com/smallstep/cli/jose"
|
|
"github.com/smallstep/cli/token"
|
|
"github.com/smallstep/cli/token/provision"
|
|
"github.com/smallstep/cli/ui"
|
|
"github.com/smallstep/cli/utils"
|
|
"github.com/urfave/cli"
|
|
)
|
|
|
|
type provisionersSelect struct {
|
|
Name string
|
|
Issuer string
|
|
Provisioner provisioner.Interface
|
|
}
|
|
|
|
func tokenCommand() cli.Command {
|
|
return cli.Command{
|
|
Name: "token",
|
|
Action: command.ActionFunc(tokenAction),
|
|
Usage: "generate an OTT granting access to the CA",
|
|
UsageText: `**step ca token** <subject>
|
|
[--**kid**=<kid>] [--**issuer**=<issuer>] [**--ca-url**=<uri>] [**--root**=<file>]
|
|
[**--not-before**=<time|duration>] [**--not-after**=<time|duration>]
|
|
[**--password-file**=<file>] [**--output-file**=<file>] [**--key**=<file>]
|
|
[**--san**=<SAN>] [**--offline**]`,
|
|
Description: `**step ca token** command generates a one-time token granting access to the
|
|
certificates authority.
|
|
|
|
## POSITIONAL ARGUMENTS
|
|
|
|
<subject>
|
|
: The Common Name, DNS Name, or IP address that will be set by the certificate authority.
|
|
When there are no additional Subject Alternative Names configured (via the
|
|
--san flag), the subject will be added as the only element of the 'sans' claim
|
|
on the token.
|
|
|
|
## EXAMPLES
|
|
|
|
Most of the following examples assumes that **--ca-url** and **--root** are
|
|
set using environment variables or the default configuration file in
|
|
<$STEPPATH/config/defaults.json>.
|
|
|
|
Get a new token for a DNS. Because there are no Subject Alternative Names
|
|
configured (via the '--san' flag), the 'sans' claim of the token will have a
|
|
default value of ['internal.example.com']:
|
|
'''
|
|
$ step ca token internal.example.com
|
|
'''
|
|
|
|
Get a new token for an IP address. Because there are no Subject Alternative Names
|
|
configured (via the '--san' flag), the 'sans' claim of the token will have a
|
|
default value of ['192.168.10.10']:
|
|
'''
|
|
$ step ca token 192.168.10.10
|
|
'''
|
|
|
|
Get a new token with custom Subject Alternative Names. The value of the 'sans'
|
|
claim of the token will be ['1.1.1.1', 'hello.example.com'] - 'foobar' will not
|
|
be in the 'sans' claim unless explicitly configured via the '--sans' flag:
|
|
'''
|
|
$ step ca token foobar --san 1.1.1.1 --san hello.example.com
|
|
'''
|
|
|
|
Get a new token that expires in 30 minutes:
|
|
'''
|
|
$ step ca token --not-after 30m internal.example.com
|
|
'''
|
|
|
|
Get a new token that becomes valid in 30 minutes and expires 5 minutes after that:
|
|
'''
|
|
$ step ca token --not-before 30m --not-after 35m internal.example.com
|
|
'''
|
|
|
|
Get a new token signed with the given private key, the public key must be
|
|
configured in the certificate authority:
|
|
'''
|
|
$ step ca token internal.smallstep.com --key token.key
|
|
'''
|
|
|
|
Get a new token for a specific provisioner kid, ca-url and root:
|
|
'''
|
|
$ step ca token internal.example.com \
|
|
--kid 4vn46fbZT68Uxfs9LBwHkTvrjEvxQqx-W8nnE-qDjts \
|
|
--ca-url https://ca.example.com \
|
|
--root /path/to/root_ca.crt
|
|
'''
|
|
|
|
Get a new token using the simple offline mode, requires the configuration
|
|
files, certificates, and keys created with **step ca init**:
|
|
'''
|
|
$ step ca token internal.example.com --offline
|
|
'''
|
|
|
|
Get a new token using the offline mode with all the parameters:
|
|
'''
|
|
$ step ca token internal.example.com \
|
|
--offline \
|
|
--kid 4vn46fbZT68Uxfs9LBwHkTvrjEvxQqx-W8nnE-qDjts \
|
|
--issuer you@example.com \
|
|
--key provisioner.key \
|
|
--ca-url https://ca.example.com \
|
|
--root /path/to/root_ca.crt
|
|
'''
|
|
|
|
`,
|
|
Flags: []cli.Flag{
|
|
provisionerKidFlag,
|
|
provisionerIssuerFlag,
|
|
caURLFlag,
|
|
rootFlag,
|
|
notBeforeFlag,
|
|
notAfterFlag,
|
|
cli.StringSliceFlag{
|
|
Name: "san",
|
|
Usage: `Add DNS or IP Address Subjective Alternative Names (SANs) that the token is
|
|
authorized to request. A certificate signing request using this token must match
|
|
the complete set of subjective alternative names in the token 1:1. Use the '--san'
|
|
flag multiple times to configure multiple SANs.`,
|
|
},
|
|
cli.StringFlag{
|
|
Name: "key",
|
|
Usage: `The private key <file> used to sign the JWT. This is usually downloaded from
|
|
the certificate authority.`,
|
|
},
|
|
passwordFileFlag,
|
|
cli.StringFlag{
|
|
Name: "output-file",
|
|
Usage: "The destination <file> of the generated one-time token.",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "offline",
|
|
Usage: `Creates a token without contacting the certificate authority. Offline mode
|
|
requires the flags <--ca-config> or <--kid>, <--issuer>, <--key>, <--ca-url>, and <--root>.`,
|
|
},
|
|
caConfigFlag,
|
|
flags.Force,
|
|
},
|
|
}
|
|
}
|
|
|
|
func tokenAction(ctx *cli.Context) error {
|
|
if err := errs.NumberOfArguments(ctx, 1); err != nil {
|
|
return err
|
|
}
|
|
|
|
subject := ctx.Args().Get(0)
|
|
outputFile := ctx.String("output-file")
|
|
offline := ctx.Bool("offline")
|
|
sans := ctx.StringSlice("san")
|
|
|
|
caURL := ctx.String("ca-url")
|
|
if len(caURL) == 0 {
|
|
return errs.RequiredFlag(ctx, "ca-url")
|
|
}
|
|
|
|
root := ctx.String("root")
|
|
if len(root) == 0 {
|
|
root = pki.GetRootCAPath()
|
|
if _, err := os.Stat(root); err != nil {
|
|
return errs.RequiredFlag(ctx, "root")
|
|
}
|
|
}
|
|
|
|
// parse times or durations
|
|
notBefore, ok := flags.ParseTimeOrDuration(ctx.String("not-before"))
|
|
if !ok {
|
|
return errs.InvalidFlagValue(ctx, "not-before", ctx.String("not-before"), "")
|
|
}
|
|
notAfter, ok := flags.ParseTimeOrDuration(ctx.String("not-after"))
|
|
if !ok {
|
|
return errs.InvalidFlagValue(ctx, "not-after", ctx.String("not-after"), "")
|
|
}
|
|
|
|
var err error
|
|
var token string
|
|
if offline {
|
|
token, err = offlineTokenFlow(ctx, subject, sans)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
token, err = newTokenFlow(ctx, subject, sans, caURL, root, notBefore, notAfter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(outputFile) > 0 {
|
|
return utils.WriteFile(outputFile, []byte(token), 0600)
|
|
}
|
|
fmt.Println(token)
|
|
return nil
|
|
}
|
|
|
|
// parseAudience creates the ca audience url from the ca-url
|
|
func parseAudience(ctx *cli.Context) (string, error) {
|
|
caURL := ctx.String("ca-url")
|
|
if len(caURL) == 0 {
|
|
return "", errs.RequiredFlag(ctx, "ca-url")
|
|
}
|
|
|
|
audience, err := url.Parse(caURL)
|
|
if err != nil {
|
|
return "", errs.InvalidFlagValue(ctx, "ca-url", caURL, "")
|
|
}
|
|
switch strings.ToLower(audience.Scheme) {
|
|
case "https", "":
|
|
audience.Scheme = "https"
|
|
audience = audience.ResolveReference(&url.URL{Path: "/1.0/sign"})
|
|
return audience.String(), nil
|
|
default:
|
|
return "", errs.InvalidFlagValue(ctx, "ca-url", caURL, "")
|
|
}
|
|
}
|
|
|
|
// generateToken generates a provisioning or bootstrap token with the given
|
|
// parameters.
|
|
func generateToken(sub string, sans []string, kid, iss, aud, root string, notBefore, notAfter time.Time, jwk *jose.JSONWebKey) (string, error) {
|
|
// A random jwt id will be used to identify duplicated tokens
|
|
jwtID, err := randutil.Hex(64) // 256 bits
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tokOptions := []token.Options{
|
|
token.WithJWTID(jwtID),
|
|
token.WithKid(kid),
|
|
token.WithIssuer(iss),
|
|
token.WithAudience(aud),
|
|
}
|
|
if len(root) > 0 {
|
|
tokOptions = append(tokOptions, token.WithRootCA(root))
|
|
}
|
|
|
|
// If there are no SANs then add the 'subject' (common-name) as the only SAN.
|
|
if len(sans) == 0 {
|
|
sans = []string{sub}
|
|
}
|
|
|
|
tokOptions = append(tokOptions, token.WithSANS(sans))
|
|
if !notBefore.IsZero() || !notAfter.IsZero() {
|
|
if notBefore.IsZero() {
|
|
notBefore = time.Now()
|
|
}
|
|
if notAfter.IsZero() {
|
|
notAfter = notBefore.Add(token.DefaultValidity)
|
|
}
|
|
tokOptions = append(tokOptions, token.WithValidity(notBefore, notAfter))
|
|
}
|
|
|
|
tok, err := provision.New(sub, tokOptions...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return tok.SignedString(jwk.Algorithm, jwk.Key)
|
|
}
|
|
|
|
// newTokenFlow implements the common flow used to generate a token
|
|
func newTokenFlow(ctx *cli.Context, subject string, sans []string, caURL, root string, notBefore, notAfter time.Time) (string, error) {
|
|
// Get audience from ca-url
|
|
audience, err := parseAudience(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
provisioners, err := pki.GetProvisioners(caURL, root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
p, err := provisionerPrompt(ctx, provisioners)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// With OIDC just run step oauth
|
|
if p, ok := p.(*provisioner.OIDC); ok {
|
|
out, err := exec.Step("oauth", "--oidc", "--bare",
|
|
"--provider", p.ConfigurationEndpoint,
|
|
"--client-id", p.ClientID, "--client-secret", p.ClientSecret)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// JWK provisioner
|
|
prov, ok := p.(*provisioner.JWK)
|
|
if !ok {
|
|
return "", errors.Errorf("unknown provisioner type %T", p)
|
|
}
|
|
|
|
kid := prov.Key.KeyID
|
|
issuer := prov.Name
|
|
|
|
var opts []jose.Option
|
|
if passwordFile := ctx.String("password-file"); len(passwordFile) != 0 {
|
|
opts = append(opts, jose.WithPasswordFile(passwordFile))
|
|
}
|
|
|
|
var jwk *jose.JSONWebKey
|
|
if keyFile := ctx.String("key"); len(keyFile) == 0 {
|
|
// Get private key from CA
|
|
encrypted, err := pki.GetProvisionerKey(caURL, root, kid)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Add template with check mark
|
|
opts = append(opts, jose.WithUIOptions(
|
|
ui.WithPromptTemplates(ui.PromptTemplates()),
|
|
))
|
|
|
|
decrypted, err := jose.Decrypt("Please enter the password to decrypt the provisioner key", []byte(encrypted), opts...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
jwk = new(jose.JSONWebKey)
|
|
if err := json.Unmarshal(decrypted, jwk); err != nil {
|
|
return "", errors.Wrap(err, "error unmarshalling provisioning key")
|
|
}
|
|
} else {
|
|
// Get private key from given key file
|
|
jwk, err = jose.ParseKey(keyFile, opts...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return generateToken(subject, sans, kid, issuer, audience, root, notBefore, notAfter, jwk)
|
|
}
|
|
|
|
// offlineTokenFlow generates a provisioning token using either
|
|
// 1. static configuration from ca.json (created with `step ca init`)
|
|
// 2. input from command line flags
|
|
// These two options are mutually exclusive and priority is given to ca.json.
|
|
func offlineTokenFlow(ctx *cli.Context, subject string, sans []string) (string, error) {
|
|
caConfig := ctx.String("ca-config")
|
|
if caConfig == "" {
|
|
return "", errs.InvalidFlagValue(ctx, "ca-config", "", "")
|
|
}
|
|
|
|
notBefore, notAfter, err := parseValidity(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Using the offline CA
|
|
if utils.FileExists(caConfig) {
|
|
offlineCA, err := newOfflineCA(caConfig)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return offlineCA.GenerateToken(ctx, subject, sans, notBefore, notAfter)
|
|
}
|
|
|
|
kid := ctx.String("kid")
|
|
issuer := ctx.String("issuer")
|
|
keyFile := ctx.String("key")
|
|
passwordFile := ctx.String("password-file")
|
|
|
|
// Require issuer and keyFile if ca.json does not exists.
|
|
// kid can be passed or created using jwk.Thumbprint.
|
|
switch {
|
|
case len(issuer) == 0:
|
|
return "", errs.RequiredWithFlag(ctx, "offline", "issuer")
|
|
case len(keyFile) == 0:
|
|
return "", errs.RequiredWithFlag(ctx, "offline", "key")
|
|
}
|
|
|
|
// Get audience from ca-url
|
|
audience, err := parseAudience(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Get root from argument or default location
|
|
root := ctx.String("root")
|
|
if len(root) == 0 {
|
|
root = pki.GetRootCAPath()
|
|
if utils.FileExists(root) {
|
|
return "", errs.RequiredFlag(ctx, "root")
|
|
}
|
|
}
|
|
|
|
// Parse key
|
|
var opts []jose.Option
|
|
if len(passwordFile) != 0 {
|
|
opts = append(opts, jose.WithPasswordFile(passwordFile))
|
|
}
|
|
jwk, err := jose.ParseKey(keyFile, opts...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Get the kid if it's not passed as an argument
|
|
if len(kid) == 0 {
|
|
hash, err := jwk.Thumbprint(crypto.SHA256)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "error generating JWK thumbprint")
|
|
}
|
|
kid = base64.RawURLEncoding.EncodeToString(hash)
|
|
}
|
|
|
|
return generateToken(subject, sans, kid, issuer, audience, root, notBefore, notAfter, jwk)
|
|
}
|
|
|
|
func provisionerPrompt(ctx *cli.Context, provisioners provisioner.List) (provisioner.Interface, error) {
|
|
// Filter by type
|
|
provisioners = provisionerFilter(provisioners, func(p provisioner.Interface) bool {
|
|
return p.GetType() == provisioner.TypeJWK || p.GetType() == provisioner.TypeOIDC
|
|
})
|
|
|
|
if len(provisioners) == 0 {
|
|
return nil, errors.New("cannot create a new token: the CA does not have any provisioner configured")
|
|
}
|
|
|
|
// Filter by kid
|
|
if kid := ctx.String("kid"); len(kid) != 0 {
|
|
provisioners = provisionerFilter(provisioners, func(p provisioner.Interface) bool {
|
|
switch p := p.(type) {
|
|
case *provisioner.JWK:
|
|
return p.Key.KeyID == kid
|
|
case *provisioner.OIDC:
|
|
return p.ClientID == kid
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
if len(provisioners) == 0 {
|
|
return nil, errs.InvalidFlagValue(ctx, "kid", kid, "")
|
|
}
|
|
}
|
|
|
|
// Filter by issuer (provisioner name)
|
|
if issuer := ctx.String("issuer"); len(issuer) != 0 {
|
|
provisioners = provisionerFilter(provisioners, func(p provisioner.Interface) bool {
|
|
return p.GetName() == issuer
|
|
})
|
|
if len(provisioners) == 0 {
|
|
return nil, errs.InvalidFlagValue(ctx, "issuer", issuer, "")
|
|
}
|
|
}
|
|
|
|
if len(provisioners) == 1 {
|
|
var id, name string
|
|
switch p := provisioners[0].(type) {
|
|
case *provisioner.JWK:
|
|
name = p.Name
|
|
id = p.Key.KeyID
|
|
case *provisioner.OIDC:
|
|
name = p.Name
|
|
id = p.ClientID
|
|
default:
|
|
return nil, errors.Errorf("unknown provisioner type %T", p)
|
|
}
|
|
|
|
// Prints provisioner used
|
|
if err := ui.PrintSelected("Key ID", id+" ("+name+")"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return provisioners[0], nil
|
|
}
|
|
|
|
var items []*provisionersSelect
|
|
for _, prov := range provisioners {
|
|
switch p := prov.(type) {
|
|
case *provisioner.JWK:
|
|
items = append(items, &provisionersSelect{
|
|
Name: p.Key.KeyID + " (" + p.Name + ")",
|
|
Issuer: p.Name,
|
|
Provisioner: p,
|
|
})
|
|
case *provisioner.OIDC:
|
|
items = append(items, &provisionersSelect{
|
|
Name: p.ClientID + " (" + p.Name + ")",
|
|
Issuer: p.Name,
|
|
Provisioner: p,
|
|
})
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
|
|
i, _, err := ui.Select("What provisioner key do you want to use?", items, ui.WithSelectTemplates(ui.NamedSelectTemplates("Key ID")))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return items[i].Provisioner, nil
|
|
}
|
|
|
|
// provisionerFilter returns a slice of provisioners that pass the given filter.
|
|
func provisionerFilter(provisioners provisioner.List, f func(provisioner.Interface) bool) provisioner.List {
|
|
var result provisioner.List
|
|
for _, p := range provisioners {
|
|
if f(p) {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|