Skip to content

Commit 4616c58

Browse files
committed
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
1 parent c85690b commit 4616c58

File tree

12 files changed

+250
-40
lines changed

12 files changed

+250
-40
lines changed

command/ca/sign.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func signCertificateAction(ctx *cli.Context) error {
175175
}
176176

177177
// certificate flow unifies online and offline flows on a single api
178-
flow, err := cautils.NewCertificateFlow(ctx)
178+
flow, err := cautils.NewCertificateFlow(ctx, cautils.WithCertificateRequest(csr))
179179
if err != nil {
180180
return err
181181
}

command/ca/token.go

+47-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66

7+
"github.com/pkg/errors"
78
"github.com/smallstep/certificates/api"
89
"github.com/smallstep/certificates/pki"
910
"github.com/smallstep/cli/flags"
@@ -12,6 +13,8 @@ import (
1213
"github.com/urfave/cli"
1314
"go.step.sm/cli-utils/command"
1415
"go.step.sm/cli-utils/errs"
16+
"go.step.sm/crypto/pemutil"
17+
"golang.org/x/crypto/ssh"
1518
)
1619

1720
func tokenCommand() cli.Command {
@@ -27,6 +30,7 @@ func tokenCommand() cli.Command {
2730
[**--output-file**=<file>] [**--kms**=uri] [**--key**=<file>] [**--san**=<SAN>] [**--offline**]
2831
[**--revoke**] [**--x5c-cert**=<file>] [**--x5c-key**=<file>] [**--x5c-insecure**]
2932
[**--sshpop-cert**=<file>] [**--sshpop-key**=<file>]
33+
[**--cnf-file**=<file>] [**--cnf-kid**=<fingerprint>]
3034
[**--ssh**] [**--host**] [**--principal**=<name>] [**--k8ssa-token-path**=<file>]
3135
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
3236
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
8286
$ step ca token --not-before 30m --not-after 35m internal.example.com
8387
'''
8488
89+
Get a new token with a confirmation claim to enforce the use of a given CSR:
90+
'''
91+
step ca token --cnf-file internal.csr internal.smallstep.com
92+
'''
93+
8594
Get a new token signed with the given private key, the public key must be
8695
configured in the certificate authority:
8796
'''
@@ -133,6 +142,11 @@ Get a new token for an SSH host certificate:
133142
$ step ca token my-remote.hostname --ssh --host
134143
'''
135144
145+
Get a new token with a confirmation claim to enforce the use of a given public key:
146+
'''
147+
step ca token --ssh --host --cnf-file internal.pub internal.smallstep.com
148+
'''
149+
136150
Generate a renew token and use it in a renew after expiry request:
137151
'''
138152
$ TOKEN=$(step ca token --x5c-cert internal.crt --x5c-key internal.key --renew internal.example.com)
@@ -186,6 +200,8 @@ multiple principals.`,
186200
flags.SSHPOPKey,
187201
flags.NebulaCert,
188202
flags.NebulaKey,
203+
flags.ConfirmationFile,
204+
flags.ConfirmationKid,
189205
cli.StringFlag{
190206
Name: "key",
191207
Usage: `The private key <file> used to sign the JWT. This is usually downloaded from
@@ -240,6 +256,9 @@ func tokenAction(ctx *cli.Context) error {
240256
isSSH := ctx.Bool("ssh")
241257
isHost := ctx.Bool("host")
242258
principals := ctx.StringSlice("principal")
259+
// confirmation claims
260+
cnfFile := ctx.String("cnf-file")
261+
cnfKid := ctx.String("cnf-kid")
243262

244263
switch {
245264
case isSSH && len(sans) > 0:
@@ -252,6 +271,8 @@ func tokenAction(ctx *cli.Context) error {
252271
return errs.RequiredWithFlag(ctx, "host", "ssh")
253272
case !isSSH && len(principals) > 0:
254273
return errs.RequiredWithFlag(ctx, "principal", "ssh")
274+
case cnfFile != "" && cnfKid != "":
275+
return errs.IncompatibleFlagWithFlag(ctx, "cnf-file", "cnf-kid")
255276
}
256277

257278
// Default token type is always a 'Sign' token.
@@ -295,6 +316,31 @@ func tokenAction(ctx *cli.Context) error {
295316
}
296317
}
297318

319+
// Add options to create a confirmation claim if a CSR or SSH public key is
320+
// passed.
321+
var tokenOpts []cautils.Option
322+
if cnfFile != "" {
323+
in, err := utils.ReadFile(cnfFile)
324+
if err != nil {
325+
return err
326+
}
327+
if isSSH {
328+
sshPub, _, _, _, err := ssh.ParseAuthorizedKey(in)
329+
if err != nil {
330+
return errors.Wrap(err, "error parsing ssh public key")
331+
}
332+
tokenOpts = append(tokenOpts, cautils.WithSSHPublicKey(sshPub))
333+
} else {
334+
csr, err := pemutil.ParseCertificateRequest(in)
335+
if err != nil {
336+
return errors.Wrap(err, "error parsing certificate request")
337+
}
338+
tokenOpts = append(tokenOpts, cautils.WithCertificateRequest(csr))
339+
}
340+
} else if cnfKid != "" {
341+
tokenOpts = append(tokenOpts, cautils.WithConfirmationKid(cnfKid))
342+
}
343+
298344
// --san and --type revoke are incompatible. Revocation tokens do not support SANs.
299345
if typ == cautils.RevokeType && len(sans) > 0 {
300346
return errs.IncompatibleFlagWithFlag(ctx, "san", "revoke")
@@ -327,7 +373,7 @@ func tokenAction(ctx *cli.Context) error {
327373
return err
328374
}
329375
} else {
330-
token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter)
376+
token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter, tokenOpts...)
331377
if err != nil {
332378
return err
333379
}

command/ssh/certificate.go

+35-33
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,41 @@ func certificateAction(ctx *cli.Context) error {
267267
}
268268
}
269269

270-
flow, err := cautils.NewCertificateFlow(ctx)
270+
var (
271+
sshPub ssh.PublicKey
272+
pub, priv interface{}
273+
flowOptions []cautils.Option
274+
)
275+
276+
if isSign {
277+
in, err := utils.ReadFile(keyFile)
278+
if err != nil {
279+
return err
280+
}
281+
282+
sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in)
283+
if err != nil {
284+
return errors.Wrap(err, "error parsing ssh public key")
285+
}
286+
if len(sshPrivKeyFile) > 0 {
287+
if priv, err = pemutil.Read(sshPrivKeyFile); err != nil {
288+
return errors.Wrap(err, "error parsing private key")
289+
}
290+
}
291+
flowOptions = append(flowOptions, cautils.WithSSHPublicKey(sshPub))
292+
} else {
293+
pub, priv, err = keyutil.GenerateDefaultKeyPair()
294+
if err != nil {
295+
return err
296+
}
297+
298+
sshPub, err = ssh.NewPublicKey(pub)
299+
if err != nil {
300+
return errors.Wrap(err, "error creating public key")
301+
}
302+
}
303+
304+
flow, err := cautils.NewCertificateFlow(ctx, flowOptions...)
271305
if err != nil {
272306
return err
273307
}
@@ -353,38 +387,6 @@ func certificateAction(ctx *cli.Context) error {
353387
identityKey = key
354388
}
355389

356-
var sshPub ssh.PublicKey
357-
var pub, priv interface{}
358-
359-
if isSign {
360-
// Use public key supplied as input.
361-
in, err := utils.ReadFile(keyFile)
362-
if err != nil {
363-
return err
364-
}
365-
366-
sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in)
367-
if err != nil {
368-
return errors.Wrap(err, "error parsing ssh public key")
369-
}
370-
if len(sshPrivKeyFile) > 0 {
371-
if priv, err = pemutil.Read(sshPrivKeyFile); err != nil {
372-
return errors.Wrap(err, "error parsing private key")
373-
}
374-
}
375-
} else {
376-
// Generate keypair
377-
pub, priv, err = keyutil.GenerateDefaultKeyPair()
378-
if err != nil {
379-
return err
380-
}
381-
382-
sshPub, err = ssh.NewPublicKey(pub)
383-
if err != nil {
384-
return errors.Wrap(err, "error creating public key")
385-
}
386-
}
387-
388390
var sshAuPub ssh.PublicKey
389391
var sshAuPubBytes []byte
390392
var auPub, auPriv interface{}

flags/flags.go

+15
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,21 @@ be stored in the 'sshpop' header.`,
379379
be stored in the 'nebula' header.`,
380380
}
381381

382+
// ConfirmationFile is a cli.Flag used to add a confirmation claim in the
383+
// tokens. It will add a confirmation kid with the fingerprint of the CSR or
384+
// an SSH public key.
385+
ConfirmationFile = cli.StringFlag{
386+
Name: "cnf-file",
387+
Usage: `The CSR or SSH public key <file> to restrict this token for.`,
388+
}
389+
390+
// ConfirmationKid is a cli.Flag used to add a confirmation claim in the
391+
// token.
392+
ConfirmationKid = cli.StringFlag{
393+
Name: "cnf-kid",
394+
Usage: `The <fingerprint> of the CSR or SSH public key to restrict this token for.`,
395+
}
396+
382397
// Team is a cli.Flag used to pass the team ID.
383398
Team = cli.StringFlag{
384399
Name: "team",

token/options.go

+40
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package token
22

33
import (
44
"bytes"
5+
"crypto"
56
"crypto/ecdh"
67
"crypto/ecdsa"
78
"crypto/ed25519"
@@ -15,9 +16,11 @@ import (
1516

1617
"github.com/pkg/errors"
1718
nebula "github.com/slackhq/nebula/cert"
19+
"go.step.sm/crypto/fingerprint"
1820
"go.step.sm/crypto/jose"
1921
"go.step.sm/crypto/pemutil"
2022
"go.step.sm/crypto/x25519"
23+
"golang.org/x/crypto/ssh"
2124
)
2225

2326
// Options is a function that set claims.
@@ -84,6 +87,43 @@ func WithSSH(v interface{}) Options {
8487
})
8588
}
8689

90+
// WithConfirmationKid returns an Options function that sets the cnf claim with
91+
// the given kid.
92+
func WithConfirmationKid(kid string) Options {
93+
return func(c *Claims) error {
94+
c.Set(ConfirmationClaim, map[string]string{
95+
"kid": kid,
96+
})
97+
return nil
98+
}
99+
}
100+
101+
// WithFingerprint returns an Options function that the cnf claims with the kid
102+
// representing the fingerprint of the certificate request or the ssh public
103+
// key.
104+
func WithFingerprint(v interface{}) Options {
105+
return func(c *Claims) error {
106+
var data []byte
107+
switch vv := v.(type) {
108+
case *x509.CertificateRequest:
109+
data = vv.Raw
110+
case ssh.PublicKey:
111+
data = vv.Marshal()
112+
default:
113+
return fmt.Errorf("unsupported fingerprint for %T", vv)
114+
}
115+
116+
kid, err := fingerprint.New(data, crypto.SHA256, fingerprint.Base64RawURLFingerprint)
117+
if err != nil {
118+
return err
119+
}
120+
c.Set(ConfirmationClaim, map[string]string{
121+
"kid": kid,
122+
})
123+
return nil
124+
}
125+
}
126+
87127
// WithValidity validates boundary inputs and sets the 'nbf' (NotBefore) and
88128
// 'exp' (expiration) options.
89129
func WithValidity(notBefore, expiration time.Time) Options {

token/options_test.go

+23
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import (
1616
"github.com/stretchr/testify/assert"
1717
"github.com/stretchr/testify/require"
1818
"go.step.sm/crypto/jose"
19+
"go.step.sm/crypto/pemutil"
1920
"go.step.sm/crypto/x25519"
21+
"golang.org/x/crypto/ssh"
2022
)
2123

2224
func TestOptions(t *testing.T) {
@@ -35,6 +37,11 @@ func TestOptions(t *testing.T) {
3537
p256ECDHSigner, err := p256Signer.ECDH()
3638
require.NoError(t, err)
3739

40+
testCSR, err := pemutil.ReadCertificateRequest("testdata/test.csr")
41+
require.NoError(t, err)
42+
43+
testSSH := mustReadSSHPublicKey(t, "testdata/ssh-key.pub")
44+
3845
wrongNebulaContentsFilename := "testdata/ca.crt"
3946

4047
emptyFile, err := os.CreateTemp(tempDir, "empty-file")
@@ -79,6 +86,10 @@ func TestOptions(t *testing.T) {
7986
{"WithNebulaCurve25519Cert empty file fail", WithNebulaCert(emptyFile.Name(), nil), empty, true},
8087
{"WithNebulaCurve25519Cert invalid content fail", WithNebulaCert(c25519CertFilename, nil), empty, true},
8188
{"WithNebulaCurve25519Cert mismatching key fail", WithNebulaCert(c25519CertFilename, p256Signer), empty, true},
89+
{"WithConfirmationKid ok", WithConfirmationKid("my-kid"), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "my-kid"}}}, false},
90+
{"WithFingerprint csr ok", WithFingerprint(testCSR), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "ak6j6CwuZbd_mOQ-pNOUwhpmtSN0mY0xrLvaQL4J5l8"}}}, false},
91+
{"WithFingerprint ssh ok", WithFingerprint(testSSH), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "hpTQOoB7fIRxTp-FhXCIm94mGBv7_dzr_5SxLn1Pnwk"}}}, false},
92+
{"WithFingerprint fail", WithFingerprint("unexpected type"), empty, true},
8293
}
8394

8495
for _, tt := range tests {
@@ -96,6 +107,18 @@ func TestOptions(t *testing.T) {
96107
}
97108
}
98109

110+
func mustReadSSHPublicKey(t *testing.T, filename string) ssh.PublicKey {
111+
t.Helper()
112+
113+
b, err := os.ReadFile(filename)
114+
require.NoError(t, err)
115+
116+
pub, _, _, _, err := ssh.ParseAuthorizedKey(b)
117+
require.NoError(t, err)
118+
119+
return pub
120+
}
121+
99122
func serializeAndWriteNebulaCert(t *testing.T, tempDir string, cert *nebula.NebulaCertificate) (string, []byte) {
100123
file, err := os.CreateTemp(tempDir, "nebula-test-cert-*")
101124
require.NoError(t, err)

token/testdata/ssh-key.pub

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF14RP3HJkO1yoZHjo9t/4bJgyJGiSPxhm6FApa3VtG1 [email protected]

token/testdata/test.csr

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-----BEGIN CERTIFICATE REQUEST-----
2+
MIIBBDCBqwIBADAbMRkwFwYDVQQDDBB0ZXN0QGV4YW1wbGUuY29tMFkwEwYHKoZI
3+
zj0CAQYIKoZIzj0DAQcDQgAEPj0tlICeGPiz361yM+AGlZmDK+N/cT0SVloozOQH
4+
1ljdNbookliEX8eRnFnelZRaql1KhrVOXhfwBmd/eGhti6AuMCwGCSqGSIb3DQEJ
5+
DjEfMB0wGwYDVR0RBBQwEoEQdGVzdEBleGFtcGxlLmNvbTAKBggqhkjOPQQDAgNI
6+
ADBFAiEA4WuukEVIFJQHNqlZVsWtsWsSVLNRCxBBJfH7/+txNw4CIGyK3eo5MDvR
7+
DepPHVRF16/b+iW/4HgAgIC90+5Q4IrL
8+
-----END CERTIFICATE REQUEST-----

token/token.go

+4
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const SANSClaim = "sans"
3232
// StepClaim is the property name for a JWT claim the stores the custom information in the certificate.
3333
const StepClaim = "step"
3434

35+
// ConfirmationClaim is the property name for a JWT claim that stores a JSON
36+
// object used as Proof-Of-Possession.
37+
const ConfirmationClaim = "cnf"
38+
3539
// Token interface which all token types should attempt to implement.
3640
type Token interface {
3741
SignedString(sigAlg string, priv interface{}) (string, error)

0 commit comments

Comments
 (0)