From c85690b3fbdbcd3c04402a131f55344e84b668fe Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 28 Dec 2023 15:43:43 -0800 Subject: [PATCH 1/5] Generate fingerprints for CSRs This commit allows to generate fingerprints for CSR files to the `step certificate fingerprint` command. --- command/certificate/fingerprint.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/command/certificate/fingerprint.go b/command/certificate/fingerprint.go index f8cb63f51..db412c7b7 100644 --- a/command/certificate/fingerprint.go +++ b/command/certificate/fingerprint.go @@ -24,7 +24,7 @@ func fingerprintCommand() cli.Command { [**--bundle**] [**--roots**=] [**--servername**=] [**--format**=] [**--sha1**] [**--insecure**]`, Description: `**step certificate fingerprint** reads a certificate and prints to STDOUT the -certificate SHA256 of the raw certificate. +certificate SHA256 of the raw certificate or certificate signing request. If contains multiple certificates (i.e., it is a certificate "bundle") the fingerprint of the first certificate in the bundle will be @@ -55,6 +55,12 @@ Get the fingerprints for a remote certificate with its intermediate: $ step certificate fingerprint --bundle https://smallstep.com e2c4f12edfc1816cc610755d32e6f45d5678ba21ecda1693bb5b246e3c48c03d 25847d668eb4f04fdd40b12b6b0740c567da7d024308eb6c2c96fe41d9de218d +''' + +Get the fingerprint for a CSR using base64-url without padding encoding: +''' +$ step certificate fingerprint --format base64-url-raw hello.csr +PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw '''`, Flags: []cli.Flag{ cli.StringFlag{ @@ -128,7 +134,15 @@ func fingerprintAction(ctx *cli.Context) error { default: certs, err = pemutil.ReadCertificateBundle(crtFile) if err != nil { - return err + // Fallback to parse a CSR + csr, csrErr := pemutil.ReadCertificateRequest(crtFile) + if csrErr != nil { + return err + } + // We will only need the raw the generate a fingerprint. + certs = []*x509.Certificate{ + {Raw: csr.Raw}, + } } } From 4616c58b2efbae935e244c98bc1280debf0828c8 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 28 Dec 2023 17:14:42 -0800 Subject: [PATCH 2/5] Allow to add confirmation claims to tokens This commit allows passing confirmation claims to tokens to tie the tokens with a provided CSR or SSH public key. The confirmation claim is implemented in the token command as well as the com commands that uses a given CSR or ssh public key. Those are: - step ca token - step ca sign - step ssh certificate --sign Fixes smallstep/certificates#1637 --- command/ca/sign.go | 2 +- command/ca/token.go | 48 +++++++++++++++++++++- command/ssh/certificate.go | 68 ++++++++++++++++--------------- flags/flags.go | 15 +++++++ token/options.go | 40 ++++++++++++++++++ token/options_test.go | 23 +++++++++++ token/testdata/ssh-key.pub | 1 + token/testdata/test.csr | 8 ++++ token/token.go | 4 ++ utils/cautils/certificate_flow.go | 58 ++++++++++++++++++++++++-- utils/cautils/token_flow.go | 7 +++- utils/cautils/token_generator.go | 16 ++++++++ 12 files changed, 250 insertions(+), 40 deletions(-) create mode 100644 token/testdata/ssh-key.pub create mode 100644 token/testdata/test.csr 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...) } From 3eb25647ba2614c8754c73e92b9dc666e5a823b3 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 23 Jul 2024 18:56:17 -0700 Subject: [PATCH 3/5] Remove SSH cfn support and use x5rt#S256 property --- command/ca/token.go | 21 ++++++++++++++------- command/certificate/fingerprint.go | 2 +- flags/flags.go | 18 ++++++++---------- token/options.go | 20 ++++++++------------ token/options_test.go | 2 +- utils/cautils/certificate_flow.go | 15 ++++++++------- utils/cautils/token_generator.go | 11 ++--------- 7 files changed, 42 insertions(+), 47 deletions(-) diff --git a/command/ca/token.go b/command/ca/token.go index cd2d540fe..1df9aec1f 100644 --- a/command/ca/token.go +++ b/command/ca/token.go @@ -30,7 +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**=] +[**--cnf**=] [**--cnf-file**=] [**--ssh**] [**--host**] [**--principal**=] [**--k8ssa-token-path**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Description: `**step ca token** command generates a one-time token granting access to the @@ -86,6 +86,13 @@ 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 a given CSR fingerprint: +''' +$ step certificate fingerprint --format base64-url-raw internal.csr +PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw +$ step ca token --cnf PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw internal.smallstep.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 @@ -200,8 +207,8 @@ multiple principals.`, flags.SSHPOPKey, flags.NebulaCert, flags.NebulaKey, + flags.Confirmation, flags.ConfirmationFile, - flags.ConfirmationKid, cli.StringFlag{ Name: "key", Usage: `The private key used to sign the JWT. This is usually downloaded from @@ -258,7 +265,7 @@ func tokenAction(ctx *cli.Context) error { principals := ctx.StringSlice("principal") // confirmation claims cnfFile := ctx.String("cnf-file") - cnfKid := ctx.String("cnf-kid") + cnf := ctx.String("cnf") switch { case isSSH && len(sans) > 0: @@ -271,8 +278,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") + case cnfFile != "" && cnf != "": + return errs.IncompatibleFlagWithFlag(ctx, "cnf-file", "cnf") } // Default token type is always a 'Sign' token. @@ -337,8 +344,8 @@ func tokenAction(ctx *cli.Context) error { } tokenOpts = append(tokenOpts, cautils.WithCertificateRequest(csr)) } - } else if cnfKid != "" { - tokenOpts = append(tokenOpts, cautils.WithConfirmationKid(cnfKid)) + } else if cnf != "" { + tokenOpts = append(tokenOpts, cautils.WithConfirmationFingerprint(cnf)) } // --san and --type revoke are incompatible. Revocation tokens do not support SANs. diff --git a/command/certificate/fingerprint.go b/command/certificate/fingerprint.go index db412c7b7..a272ac53a 100644 --- a/command/certificate/fingerprint.go +++ b/command/certificate/fingerprint.go @@ -57,7 +57,7 @@ e2c4f12edfc1816cc610755d32e6f45d5678ba21ecda1693bb5b246e3c48c03d 25847d668eb4f04fdd40b12b6b0740c567da7d024308eb6c2c96fe41d9de218d ''' -Get the fingerprint for a CSR using base64-url without padding encoding: +Get the fingerprint for a CSR using base64-url encoding without padding: ''' $ step certificate fingerprint --format base64-url-raw hello.csr PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw diff --git a/flags/flags.go b/flags/flags.go index a6c6c1d23..a344d0a8d 100644 --- a/flags/flags.go +++ b/flags/flags.go @@ -379,19 +379,17 @@ be stored in the 'sshpop' header.`, be stored in the 'nebula' header.`, } + // Confirmation is a cli.Flag used to add a confirmation claim in the token. + Confirmation = cli.StringFlag{ + Name: "cnf", + Usage: `The of the CSR to restrict this token for.`, + } + // 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. + // tokens. It will add a confirmation kid with the fingerprint of the CSR. 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.`, + Usage: `The CSR to restrict this token for.`, } // Team is a cli.Flag used to pass the team ID. diff --git a/token/options.go b/token/options.go index 423478b5b..4a8daac33 100644 --- a/token/options.go +++ b/token/options.go @@ -20,7 +20,6 @@ import ( "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. @@ -87,30 +86,27 @@ func WithSSH(v interface{}) Options { }) } -// WithConfirmationKid returns an Options function that sets the cnf claim with -// the given kid. -func WithConfirmationKid(kid string) Options { +// WithConfirmationFingerprint returns an Options function that sets the cnf +// claim with the given CSR fingerprint. +func WithConfirmationFingerprint(fp string) Options { return func(c *Claims) error { c.Set(ConfirmationClaim, map[string]string{ - "kid": kid, + "x5rt#S256": fp, }) 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. +// WithFingerprint returns an Options function that the cnf claims with +// "x5rt#S256" representing the fingerprint of the CSR 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) + return fmt.Errorf("unsupported fingerprint for %T", v) } kid, err := fingerprint.New(data, crypto.SHA256, fingerprint.Base64RawURLFingerprint) @@ -118,7 +114,7 @@ func WithFingerprint(v interface{}) Options { return err } c.Set(ConfirmationClaim, map[string]string{ - "kid": kid, + "x5rt#S256": kid, }) return nil } diff --git a/token/options_test.go b/token/options_test.go index 9878b3c89..f25ab144a 100644 --- a/token/options_test.go +++ b/token/options_test.go @@ -86,7 +86,7 @@ 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}, + {"WithConfirmationFingerprint ok", WithConfirmationFingerprint("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}, diff --git a/utils/cautils/certificate_flow.go b/utils/cautils/certificate_flow.go index eef56ee3e..9b4f5000b 100644 --- a/utils/cautils/certificate_flow.go +++ b/utils/cautils/certificate_flow.go @@ -37,10 +37,10 @@ type CertificateFlow struct { } type flowContext struct { - DisableCustomSANs bool - SSHPublicKey ssh.PublicKey - CertificateRequest *x509.CertificateRequest - ConfirmationKid string + DisableCustomSANs bool + SSHPublicKey ssh.PublicKey + CertificateRequest *x509.CertificateRequest + ConfirmationFingerprint string } // sharedContext is used to share information between commands. @@ -78,10 +78,11 @@ func WithCertificateRequest(cr *x509.CertificateRequest) Option { }) } -// WithConfirmationKid sets the confirmation kid used in the request. -func WithConfirmationKid(kid string) Option { +// WithConfirmationFingerprint sets the confirmation fingerprint used in the +// request. +func WithConfirmationFingerprint(fp string) Option { return newFuncFlowOption(func(fo *flowContext) { - fo.ConfirmationKid = kid + fo.ConfirmationFingerprint = fp }) } diff --git a/utils/cautils/token_generator.go b/utils/cautils/token_generator.go index 009851bce..71d1b3853 100644 --- a/utils/cautils/token_generator.go +++ b/utils/cautils/token_generator.go @@ -102,8 +102,8 @@ func (t *TokenGenerator) SignToken(sub string, sans []string, opts ...token.Opti // 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)) + } else if sharedContext.ConfirmationFingerprint != "" { + opts = append(opts, token.WithConfirmationFingerprint(sharedContext.ConfirmationFingerprint)) } return t.Token(sub, opts...) @@ -124,13 +124,6 @@ func (t *TokenGenerator) SignSSHToken(sub, certType string, principals []string, 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...) } From 2f0500a6337520a46298383f005fd2aa6e5c0138 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Jul 2024 11:28:26 -0700 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Herman Slatman --- command/certificate/fingerprint.go | 2 +- token/options.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/command/certificate/fingerprint.go b/command/certificate/fingerprint.go index a272ac53a..491777ded 100644 --- a/command/certificate/fingerprint.go +++ b/command/certificate/fingerprint.go @@ -139,7 +139,7 @@ func fingerprintAction(ctx *cli.Context) error { if csrErr != nil { return err } - // We will only need the raw the generate a fingerprint. + // We will only need the raw DER bytes to generate a fingerprint. certs = []*x509.Certificate{ {Raw: csr.Raw}, } diff --git a/token/options.go b/token/options.go index 4a8daac33..6bdd0ff47 100644 --- a/token/options.go +++ b/token/options.go @@ -99,7 +99,7 @@ func WithConfirmationFingerprint(fp string) Options { // WithFingerprint returns an Options function that the cnf claims with // "x5rt#S256" representing the fingerprint of the CSR -func WithFingerprint(v interface{}) Options { +func WithFingerprint(v any) Options { return func(c *Claims) error { var data []byte switch vv := v.(type) { From 8a2d36e2df74f4fa8f9bf649910e675a89e0fe39 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Jul 2024 11:51:57 -0700 Subject: [PATCH 5/5] Fix linter warning --- command/ssh/certificate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/command/ssh/certificate.go b/command/ssh/certificate.go index f0272121d..973779e71 100644 --- a/command/ssh/certificate.go +++ b/command/ssh/certificate.go @@ -313,7 +313,7 @@ func certificateAction(ctx *cli.Context) error { if err != nil { return errors.Wrap(err, "error parsing ssh public key") } - if len(sshPrivKeyFile) > 0 { + if sshPrivKeyFile != "" { if priv, err = pemutil.Read(sshPrivKeyFile); err != nil { return errors.Wrap(err, "error parsing private key") }