Skip to content

Commit 797ae59

Browse files
authored
Merge pull request #1092 from smallstep/fix-1637
Add handling of `cnf` claim
2 parents 7e969c8 + 8a2d36e commit 797ae59

File tree

13 files changed

+263
-42
lines changed

13 files changed

+263
-42
lines changed

command/ca/sign.go

Lines changed: 1 addition & 1 deletion
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

Lines changed: 54 additions & 1 deletion
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**=<fingerprint>] [**--cnf-file**=<file>]
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,18 @@ 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 a given CSR fingerprint:
90+
'''
91+
$ step certificate fingerprint --format base64-url-raw internal.csr
92+
PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw
93+
$ step ca token --cnf PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw internal.smallstep.com
94+
'''
95+
96+
Get a new token with a confirmation claim to enforce the use of a given CSR:
97+
'''
98+
step ca token --cnf-file internal.csr internal.smallstep.com
99+
'''
100+
85101
Get a new token signed with the given private key, the public key must be
86102
configured in the certificate authority:
87103
'''
@@ -133,6 +149,11 @@ Get a new token for an SSH host certificate:
133149
$ step ca token my-remote.hostname --ssh --host
134150
'''
135151
152+
Get a new token with a confirmation claim to enforce the use of a given public key:
153+
'''
154+
step ca token --ssh --host --cnf-file internal.pub internal.smallstep.com
155+
'''
156+
136157
Generate a renew token and use it in a renew after expiry request:
137158
'''
138159
$ TOKEN=$(step ca token --x5c-cert internal.crt --x5c-key internal.key --renew internal.example.com)
@@ -186,6 +207,8 @@ multiple principals.`,
186207
flags.SSHPOPKey,
187208
flags.NebulaCert,
188209
flags.NebulaKey,
210+
flags.Confirmation,
211+
flags.ConfirmationFile,
189212
cli.StringFlag{
190213
Name: "key",
191214
Usage: `The private key <file> used to sign the JWT. This is usually downloaded from
@@ -240,6 +263,9 @@ func tokenAction(ctx *cli.Context) error {
240263
isSSH := ctx.Bool("ssh")
241264
isHost := ctx.Bool("host")
242265
principals := ctx.StringSlice("principal")
266+
// confirmation claims
267+
cnfFile := ctx.String("cnf-file")
268+
cnf := ctx.String("cnf")
243269

244270
switch {
245271
case isSSH && len(sans) > 0:
@@ -252,6 +278,8 @@ func tokenAction(ctx *cli.Context) error {
252278
return errs.RequiredWithFlag(ctx, "host", "ssh")
253279
case !isSSH && len(principals) > 0:
254280
return errs.RequiredWithFlag(ctx, "principal", "ssh")
281+
case cnfFile != "" && cnf != "":
282+
return errs.IncompatibleFlagWithFlag(ctx, "cnf-file", "cnf")
255283
}
256284

257285
// Default token type is always a 'Sign' token.
@@ -295,6 +323,31 @@ func tokenAction(ctx *cli.Context) error {
295323
}
296324
}
297325

326+
// Add options to create a confirmation claim if a CSR or SSH public key is
327+
// passed.
328+
var tokenOpts []cautils.Option
329+
if cnfFile != "" {
330+
in, err := utils.ReadFile(cnfFile)
331+
if err != nil {
332+
return err
333+
}
334+
if isSSH {
335+
sshPub, _, _, _, err := ssh.ParseAuthorizedKey(in)
336+
if err != nil {
337+
return errors.Wrap(err, "error parsing ssh public key")
338+
}
339+
tokenOpts = append(tokenOpts, cautils.WithSSHPublicKey(sshPub))
340+
} else {
341+
csr, err := pemutil.ParseCertificateRequest(in)
342+
if err != nil {
343+
return errors.Wrap(err, "error parsing certificate request")
344+
}
345+
tokenOpts = append(tokenOpts, cautils.WithCertificateRequest(csr))
346+
}
347+
} else if cnf != "" {
348+
tokenOpts = append(tokenOpts, cautils.WithConfirmationFingerprint(cnf))
349+
}
350+
298351
// --san and --type revoke are incompatible. Revocation tokens do not support SANs.
299352
if typ == cautils.RevokeType && len(sans) > 0 {
300353
return errs.IncompatibleFlagWithFlag(ctx, "san", "revoke")
@@ -327,7 +380,7 @@ func tokenAction(ctx *cli.Context) error {
327380
return err
328381
}
329382
} else {
330-
token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter)
383+
token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter, tokenOpts...)
331384
if err != nil {
332385
return err
333386
}

command/certificate/fingerprint.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func fingerprintCommand() cli.Command {
2424
[**--bundle**] [**--roots**=<root-bundle>] [**--servername**=<servername>]
2525
[**--format**=<format>] [**--sha1**] [**--insecure**]`,
2626
Description: `**step certificate fingerprint** reads a certificate and prints to STDOUT the
27-
certificate SHA256 of the raw certificate.
27+
certificate SHA256 of the raw certificate or certificate signing request.
2828
2929
If <crt-file> contains multiple certificates (i.e., it is a certificate
3030
"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:
5555
$ step certificate fingerprint --bundle https://smallstep.com
5656
e2c4f12edfc1816cc610755d32e6f45d5678ba21ecda1693bb5b246e3c48c03d
5757
25847d668eb4f04fdd40b12b6b0740c567da7d024308eb6c2c96fe41d9de218d
58+
'''
59+
60+
Get the fingerprint for a CSR using base64-url encoding without padding:
61+
'''
62+
$ step certificate fingerprint --format base64-url-raw hello.csr
63+
PJLNhtQoBE1yGN_ZKzr4Y2U5pyqIGiyyszkoz2raDOw
5864
'''`,
5965
Flags: []cli.Flag{
6066
cli.StringFlag{
@@ -128,7 +134,15 @@ func fingerprintAction(ctx *cli.Context) error {
128134
default:
129135
certs, err = pemutil.ReadCertificateBundle(crtFile)
130136
if err != nil {
131-
return err
137+
// Fallback to parse a CSR
138+
csr, csrErr := pemutil.ReadCertificateRequest(crtFile)
139+
if csrErr != nil {
140+
return err
141+
}
142+
// We will only need the raw DER bytes to generate a fingerprint.
143+
certs = []*x509.Certificate{
144+
{Raw: csr.Raw},
145+
}
132146
}
133147
}
134148

command/ssh/certificate.go

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,43 @@ func certificateAction(ctx *cli.Context) error {
296296
}
297297
}
298298

299-
flow, err := cautils.NewCertificateFlow(ctx)
299+
var (
300+
sshPub ssh.PublicKey
301+
pub, priv interface{}
302+
flowOptions []cautils.Option
303+
)
304+
305+
if isSign {
306+
// Use public key supplied as input.
307+
in, err := utils.ReadFile(keyFile)
308+
if err != nil {
309+
return err
310+
}
311+
312+
sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in)
313+
if err != nil {
314+
return errors.Wrap(err, "error parsing ssh public key")
315+
}
316+
if sshPrivKeyFile != "" {
317+
if priv, err = pemutil.Read(sshPrivKeyFile); err != nil {
318+
return errors.Wrap(err, "error parsing private key")
319+
}
320+
}
321+
flowOptions = append(flowOptions, cautils.WithSSHPublicKey(sshPub))
322+
} else {
323+
// Generate keypair
324+
pub, priv, err = keyutil.GenerateKeyPair(kty, curve, size)
325+
if err != nil {
326+
return err
327+
}
328+
329+
sshPub, err = ssh.NewPublicKey(pub)
330+
if err != nil {
331+
return errors.Wrap(err, "error creating public key")
332+
}
333+
}
334+
335+
flow, err := cautils.NewCertificateFlow(ctx, flowOptions...)
300336
if err != nil {
301337
return err
302338
}
@@ -382,38 +418,6 @@ func certificateAction(ctx *cli.Context) error {
382418
identityKey = key
383419
}
384420

385-
var sshPub ssh.PublicKey
386-
var pub, priv interface{}
387-
388-
if isSign {
389-
// Use public key supplied as input.
390-
in, err := utils.ReadFile(keyFile)
391-
if err != nil {
392-
return err
393-
}
394-
395-
sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in)
396-
if err != nil {
397-
return errors.Wrap(err, "error parsing ssh public key")
398-
}
399-
if sshPrivKeyFile != "" {
400-
if priv, err = pemutil.Read(sshPrivKeyFile); err != nil {
401-
return errors.Wrap(err, "error parsing private key")
402-
}
403-
}
404-
} else {
405-
// Generate keypair
406-
pub, priv, err = keyutil.GenerateKeyPair(kty, curve, size)
407-
if err != nil {
408-
return err
409-
}
410-
411-
sshPub, err = ssh.NewPublicKey(pub)
412-
if err != nil {
413-
return errors.Wrap(err, "error creating public key")
414-
}
415-
}
416-
417421
var sshAuPub ssh.PublicKey
418422
var sshAuPubBytes []byte
419423
var auPub, auPriv interface{}

flags/flags.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,19 @@ be stored in the 'sshpop' header.`,
379379
be stored in the 'nebula' header.`,
380380
}
381381

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

token/options.go

Lines changed: 36 additions & 0 deletions
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,6 +16,7 @@ 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"
@@ -84,6 +86,40 @@ func WithSSH(v interface{}) Options {
8486
})
8587
}
8688

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

token/options_test.go

Lines changed: 23 additions & 0 deletions
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+
{"WithConfirmationFingerprint ok", WithConfirmationFingerprint("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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF14RP3HJkO1yoZHjo9t/4bJgyJGiSPxhm6FApa3VtG1 [email protected]

token/testdata/test.csr

Lines changed: 8 additions & 0 deletions
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-----

0 commit comments

Comments
 (0)