diff --git a/command/ca/sign.go b/command/ca/sign.go index fa5f3c75e..a5505c306 100644 --- a/command/ca/sign.go +++ b/command/ca/sign.go @@ -175,7 +175,7 @@ func signCertificateAction(ctx *cli.Context) error { } // certificate flow unifies online and offline flows on a single api - flow, err := cautils.NewCertificateFlow(ctx) + flow, err := cautils.NewCertificateFlow(ctx, cautils.WithCertificateRequest(csr)) if err != nil { return err } diff --git a/command/ca/token.go b/command/ca/token.go index 8212b6319..399b74a5a 100644 --- a/command/ca/token.go +++ b/command/ca/token.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/pkg/errors" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/pki" "github.com/smallstep/cli/flags" @@ -12,6 +13,8 @@ import ( "github.com/urfave/cli" "go.step.sm/cli-utils/command" "go.step.sm/cli-utils/errs" + "go.step.sm/crypto/pemutil" + "golang.org/x/crypto/ssh" ) func tokenCommand() cli.Command { @@ -27,6 +30,7 @@ func tokenCommand() cli.Command { [**--output-file**=] [**--kms**=uri] [**--key**=] [**--san**=] [**--offline**] [**--revoke**] [**--x5c-cert**=] [**--x5c-key**=] [**--x5c-insecure**] [**--sshpop-cert**=] [**--sshpop-key**=] +[**--cnf-file**=] [**--cnf-kid**=] [**--ssh**] [**--host**] [**--principal**=] [**--k8ssa-token-path**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Description: `**step ca token** command generates a one-time token granting access to the @@ -82,6 +86,11 @@ Get a new token that becomes valid in 30 minutes and expires 5 minutes after tha $ step ca token --not-before 30m --not-after 35m internal.example.com ''' +Get a new token with a confirmation claim to enforce the use of a given CSR: +''' +step ca token --cnf-file internal.csr internal.smallstep.com +''' + Get a new token signed with the given private key, the public key must be configured in the certificate authority: ''' @@ -133,6 +142,11 @@ Get a new token for an SSH host certificate: $ step ca token my-remote.hostname --ssh --host ''' +Get a new token with a confirmation claim to enforce the use of a given public key: +''' +step ca token --ssh --host --cnf-file internal.pub internal.smallstep.com +''' + Generate a renew token and use it in a renew after expiry request: ''' $ TOKEN=$(step ca token --x5c-cert internal.crt --x5c-key internal.key --renew internal.example.com) @@ -186,6 +200,8 @@ multiple principals.`, flags.SSHPOPKey, flags.NebulaCert, flags.NebulaKey, + flags.ConfirmationFile, + flags.ConfirmationKid, cli.StringFlag{ Name: "key", Usage: `The private key used to sign the JWT. This is usually downloaded from @@ -240,6 +256,9 @@ func tokenAction(ctx *cli.Context) error { isSSH := ctx.Bool("ssh") isHost := ctx.Bool("host") principals := ctx.StringSlice("principal") + // confirmation claims + cnfFile := ctx.String("cnf-file") + cnfKid := ctx.String("cnf-kid") switch { case isSSH && len(sans) > 0: @@ -252,6 +271,8 @@ func tokenAction(ctx *cli.Context) error { return errs.RequiredWithFlag(ctx, "host", "ssh") case !isSSH && len(principals) > 0: return errs.RequiredWithFlag(ctx, "principal", "ssh") + case cnfFile != "" && cnfKid != "": + return errs.IncompatibleFlagWithFlag(ctx, "cnf-file", "cnf-kid") } // Default token type is always a 'Sign' token. @@ -295,6 +316,31 @@ func tokenAction(ctx *cli.Context) error { } } + // Add options to create a confirmation claim if a CSR or SSH public key is + // passed. + var tokenOpts []cautils.Option + if cnfFile != "" { + in, err := utils.ReadFile(cnfFile) + if err != nil { + return err + } + if isSSH { + sshPub, _, _, _, err := ssh.ParseAuthorizedKey(in) + if err != nil { + return errors.Wrap(err, "error parsing ssh public key") + } + tokenOpts = append(tokenOpts, cautils.WithSSHPublicKey(sshPub)) + } else { + csr, err := pemutil.ParseCertificateRequest(in) + if err != nil { + return errors.Wrap(err, "error parsing certificate request") + } + tokenOpts = append(tokenOpts, cautils.WithCertificateRequest(csr)) + } + } else if cnfKid != "" { + tokenOpts = append(tokenOpts, cautils.WithConfirmationKid(cnfKid)) + } + // --san and --type revoke are incompatible. Revocation tokens do not support SANs. if typ == cautils.RevokeType && len(sans) > 0 { return errs.IncompatibleFlagWithFlag(ctx, "san", "revoke") @@ -327,7 +373,7 @@ func tokenAction(ctx *cli.Context) error { return err } } else { - token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter) + token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter, tokenOpts...) if err != nil { return err } diff --git a/command/ssh/certificate.go b/command/ssh/certificate.go index bf28ec7ce..b40d640fc 100644 --- a/command/ssh/certificate.go +++ b/command/ssh/certificate.go @@ -267,7 +267,41 @@ func certificateAction(ctx *cli.Context) error { } } - flow, err := cautils.NewCertificateFlow(ctx) + var ( + sshPub ssh.PublicKey + pub, priv interface{} + flowOptions []cautils.Option + ) + + if isSign { + in, err := utils.ReadFile(keyFile) + if err != nil { + return err + } + + sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in) + if err != nil { + return errors.Wrap(err, "error parsing ssh public key") + } + if len(sshPrivKeyFile) > 0 { + if priv, err = pemutil.Read(sshPrivKeyFile); err != nil { + return errors.Wrap(err, "error parsing private key") + } + } + flowOptions = append(flowOptions, cautils.WithSSHPublicKey(sshPub)) + } else { + pub, priv, err = keyutil.GenerateDefaultKeyPair() + if err != nil { + return err + } + + sshPub, err = ssh.NewPublicKey(pub) + if err != nil { + return errors.Wrap(err, "error creating public key") + } + } + + flow, err := cautils.NewCertificateFlow(ctx, flowOptions...) if err != nil { return err } @@ -353,38 +387,6 @@ func certificateAction(ctx *cli.Context) error { identityKey = key } - var sshPub ssh.PublicKey - var pub, priv interface{} - - if isSign { - // Use public key supplied as input. - in, err := utils.ReadFile(keyFile) - if err != nil { - return err - } - - sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in) - if err != nil { - return errors.Wrap(err, "error parsing ssh public key") - } - if len(sshPrivKeyFile) > 0 { - if priv, err = pemutil.Read(sshPrivKeyFile); err != nil { - return errors.Wrap(err, "error parsing private key") - } - } - } else { - // Generate keypair - pub, priv, err = keyutil.GenerateDefaultKeyPair() - if err != nil { - return err - } - - sshPub, err = ssh.NewPublicKey(pub) - if err != nil { - return errors.Wrap(err, "error creating public key") - } - } - var sshAuPub ssh.PublicKey var sshAuPubBytes []byte var auPub, auPriv interface{} diff --git a/flags/flags.go b/flags/flags.go index dccce4995..7d422c9b0 100644 --- a/flags/flags.go +++ b/flags/flags.go @@ -379,6 +379,21 @@ be stored in the 'sshpop' header.`, be stored in the 'nebula' header.`, } + // ConfirmationFile is a cli.Flag used to add a confirmation claim in the + // tokens. It will add a confirmation kid with the fingerprint of the CSR or + // an SSH public key. + ConfirmationFile = cli.StringFlag{ + Name: "cnf-file", + Usage: `The CSR or SSH public key to restrict this token for.`, + } + + // ConfirmationKid is a cli.Flag used to add a confirmation claim in the + // token. + ConfirmationKid = cli.StringFlag{ + Name: "cnf-kid", + Usage: `The of the CSR or SSH public key to restrict this token for.`, + } + // Team is a cli.Flag used to pass the team ID. Team = cli.StringFlag{ Name: "team", diff --git a/token/options.go b/token/options.go index 3643dffd5..423478b5b 100644 --- a/token/options.go +++ b/token/options.go @@ -2,6 +2,7 @@ package token import ( "bytes" + "crypto" "crypto/ecdh" "crypto/ecdsa" "crypto/ed25519" @@ -15,9 +16,11 @@ import ( "github.com/pkg/errors" nebula "github.com/slackhq/nebula/cert" + "go.step.sm/crypto/fingerprint" "go.step.sm/crypto/jose" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x25519" + "golang.org/x/crypto/ssh" ) // Options is a function that set claims. @@ -84,6 +87,43 @@ func WithSSH(v interface{}) Options { }) } +// WithConfirmationKid returns an Options function that sets the cnf claim with +// the given kid. +func WithConfirmationKid(kid string) Options { + return func(c *Claims) error { + c.Set(ConfirmationClaim, map[string]string{ + "kid": kid, + }) + return nil + } +} + +// WithFingerprint returns an Options function that the cnf claims with the kid +// representing the fingerprint of the certificate request or the ssh public +// key. +func WithFingerprint(v interface{}) Options { + return func(c *Claims) error { + var data []byte + switch vv := v.(type) { + case *x509.CertificateRequest: + data = vv.Raw + case ssh.PublicKey: + data = vv.Marshal() + default: + return fmt.Errorf("unsupported fingerprint for %T", vv) + } + + kid, err := fingerprint.New(data, crypto.SHA256, fingerprint.Base64RawURLFingerprint) + if err != nil { + return err + } + c.Set(ConfirmationClaim, map[string]string{ + "kid": kid, + }) + return nil + } +} + // WithValidity validates boundary inputs and sets the 'nbf' (NotBefore) and // 'exp' (expiration) options. func WithValidity(notBefore, expiration time.Time) Options { diff --git a/token/options_test.go b/token/options_test.go index 8c3303d16..9878b3c89 100644 --- a/token/options_test.go +++ b/token/options_test.go @@ -16,7 +16,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x25519" + "golang.org/x/crypto/ssh" ) func TestOptions(t *testing.T) { @@ -35,6 +37,11 @@ func TestOptions(t *testing.T) { p256ECDHSigner, err := p256Signer.ECDH() require.NoError(t, err) + testCSR, err := pemutil.ReadCertificateRequest("testdata/test.csr") + require.NoError(t, err) + + testSSH := mustReadSSHPublicKey(t, "testdata/ssh-key.pub") + wrongNebulaContentsFilename := "testdata/ca.crt" emptyFile, err := os.CreateTemp(tempDir, "empty-file") @@ -79,6 +86,10 @@ func TestOptions(t *testing.T) { {"WithNebulaCurve25519Cert empty file fail", WithNebulaCert(emptyFile.Name(), nil), empty, true}, {"WithNebulaCurve25519Cert invalid content fail", WithNebulaCert(c25519CertFilename, nil), empty, true}, {"WithNebulaCurve25519Cert mismatching key fail", WithNebulaCert(c25519CertFilename, p256Signer), empty, true}, + {"WithConfirmationKid ok", WithConfirmationKid("my-kid"), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "my-kid"}}}, false}, + {"WithFingerprint csr ok", WithFingerprint(testCSR), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "ak6j6CwuZbd_mOQ-pNOUwhpmtSN0mY0xrLvaQL4J5l8"}}}, false}, + {"WithFingerprint ssh ok", WithFingerprint(testSSH), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "hpTQOoB7fIRxTp-FhXCIm94mGBv7_dzr_5SxLn1Pnwk"}}}, false}, + {"WithFingerprint fail", WithFingerprint("unexpected type"), empty, true}, } for _, tt := range tests { @@ -96,6 +107,18 @@ func TestOptions(t *testing.T) { } } +func mustReadSSHPublicKey(t *testing.T, filename string) ssh.PublicKey { + t.Helper() + + b, err := os.ReadFile(filename) + require.NoError(t, err) + + pub, _, _, _, err := ssh.ParseAuthorizedKey(b) + require.NoError(t, err) + + return pub +} + func serializeAndWriteNebulaCert(t *testing.T, tempDir string, cert *nebula.NebulaCertificate) (string, []byte) { file, err := os.CreateTemp(tempDir, "nebula-test-cert-*") require.NoError(t, err) diff --git a/token/testdata/ssh-key.pub b/token/testdata/ssh-key.pub new file mode 100644 index 000000000..17ec459b9 --- /dev/null +++ b/token/testdata/ssh-key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF14RP3HJkO1yoZHjo9t/4bJgyJGiSPxhm6FApa3VtG1 mariano@overlook.local diff --git a/token/testdata/test.csr b/token/testdata/test.csr new file mode 100644 index 000000000..95cf8e755 --- /dev/null +++ b/token/testdata/test.csr @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBBDCBqwIBADAbMRkwFwYDVQQDDBB0ZXN0QGV4YW1wbGUuY29tMFkwEwYHKoZI +zj0CAQYIKoZIzj0DAQcDQgAEPj0tlICeGPiz361yM+AGlZmDK+N/cT0SVloozOQH +1ljdNbookliEX8eRnFnelZRaql1KhrVOXhfwBmd/eGhti6AuMCwGCSqGSIb3DQEJ +DjEfMB0wGwYDVR0RBBQwEoEQdGVzdEBleGFtcGxlLmNvbTAKBggqhkjOPQQDAgNI +ADBFAiEA4WuukEVIFJQHNqlZVsWtsWsSVLNRCxBBJfH7/+txNw4CIGyK3eo5MDvR +DepPHVRF16/b+iW/4HgAgIC90+5Q4IrL +-----END CERTIFICATE REQUEST----- diff --git a/token/token.go b/token/token.go index b8b225d97..770f939b7 100644 --- a/token/token.go +++ b/token/token.go @@ -32,6 +32,10 @@ const SANSClaim = "sans" // StepClaim is the property name for a JWT claim the stores the custom information in the certificate. const StepClaim = "step" +// ConfirmationClaim is the property name for a JWT claim that stores a JSON +// object used as Proof-Of-Possession. +const ConfirmationClaim = "cnf" + // Token interface which all token types should attempt to implement. type Token interface { SignedString(sigAlg string, priv interface{}) (string, error) diff --git a/utils/cautils/certificate_flow.go b/utils/cautils/certificate_flow.go index 81fe2caf0..8c7bc7b9d 100644 --- a/utils/cautils/certificate_flow.go +++ b/utils/cautils/certificate_flow.go @@ -27,6 +27,7 @@ import ( "go.step.sm/crypto/keyutil" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" + "golang.org/x/crypto/ssh" ) // CertificateFlow manages the flow to retrieve a new certificate. @@ -35,16 +36,65 @@ type CertificateFlow struct { offline bool } +type flowContext struct { + DisableCustomSANs bool + SSHPublicKey ssh.PublicKey + CertificateRequest *x509.CertificateRequest + ConfirmationKid string +} + // sharedContext is used to share information between commands. -var sharedContext = struct { - DisableCustomSANs bool -}{} +var sharedContext flowContext + +type funcFlowOption struct { + f func(fo *flowContext) +} + +func (ffo *funcFlowOption) apply(fo *flowContext) { + ffo.f(fo) +} + +func newFuncFlowOption(f func(fo *flowContext)) *funcFlowOption { + return &funcFlowOption{ + f: f, + } +} + +type Option interface { + apply(fo *flowContext) +} + +// WithSSHPublicKey sets the SSH public key used in the request. +func WithSSHPublicKey(key ssh.PublicKey) Option { + return newFuncFlowOption(func(fo *flowContext) { + fo.SSHPublicKey = key + }) +} + +// WithCertificateRequest sets the X509 certificate request used in the request. +func WithCertificateRequest(cr *x509.CertificateRequest) Option { + return newFuncFlowOption(func(fo *flowContext) { + fo.CertificateRequest = cr + }) +} + +// WithConfirmationKid sets the confirmation kid used in the request. +func WithConfirmationKid(kid string) Option { + return newFuncFlowOption(func(fo *flowContext) { + fo.ConfirmationKid = kid + }) +} // NewCertificateFlow initializes a cli flow to get a new certificate. -func NewCertificateFlow(ctx *cli.Context) (*CertificateFlow, error) { +func NewCertificateFlow(ctx *cli.Context, opts ...Option) (*CertificateFlow, error) { var err error var offlineClient *OfflineCA + // Add options to the shared context + for _, opt := range opts { + opt.apply(&sharedContext) + } + offline := ctx.Bool("offline") if offline { caConfig := ctx.String("ca-config") diff --git a/utils/cautils/token_flow.go b/utils/cautils/token_flow.go index 8d6a1dd97..185d91538 100644 --- a/utils/cautils/token_flow.go +++ b/utils/cautils/token_flow.go @@ -85,7 +85,12 @@ func (e *ACMETokenError) Error() string { } // NewTokenFlow implements the common flow used to generate a token -func NewTokenFlow(ctx *cli.Context, tokType int, subject string, sans []string, caURL, root string, notBefore, notAfter time.Time, certNotBefore, certNotAfter provisioner.TimeDuration) (string, error) { +func NewTokenFlow(ctx *cli.Context, tokType int, subject string, sans []string, caURL, root string, notBefore, notAfter time.Time, certNotBefore, certNotAfter provisioner.TimeDuration, opts ...Option) (string, error) { + // Apply options to shared context + for _, opt := range opts { + opt.apply(&sharedContext) + } + // Get audience from ca-url audience, err := parseAudience(ctx, tokType) if err != nil { diff --git a/utils/cautils/token_generator.go b/utils/cautils/token_generator.go index 465dbcc18..6290126ba 100644 --- a/utils/cautils/token_generator.go +++ b/utils/cautils/token_generator.go @@ -98,6 +98,14 @@ func (t *TokenGenerator) SignToken(sub string, sans []string, opts ...token.Opti sans = []string{sub} } opts = append(opts, token.WithSANS(sans)) + + // Tie certificate request to the token used in the JWK and X5C provisioners + if sharedContext.CertificateRequest != nil { + opts = append(opts, token.WithFingerprint(sharedContext.CertificateRequest)) + } else if sharedContext.ConfirmationKid != "" { + opts = append(opts, token.WithConfirmationKid(sharedContext.ConfirmationKid)) + } + return t.Token(sub, opts...) } @@ -115,6 +123,14 @@ func (t *TokenGenerator) SignSSHToken(sub, certType string, principals []string, ValidAfter: notBefore, ValidBefore: notAfter, })}, opts...) + + // Tie SSH public key to the token used in the JWK and X5C provisioners + if sharedContext.SSHPublicKey != nil { + opts = append(opts, token.WithFingerprint(sharedContext.SSHPublicKey)) + } else if sharedContext.ConfirmationKid != "" { + opts = append(opts, token.WithConfirmationKid(sharedContext.ConfirmationKid)) + } + return t.Token(sub, opts...) }