Skip to content

Commit c957358

Browse files
authored
Allow a few global defaults to be pulled from the CA (#1377)
- min-encryption-password-length - provisioner Enforce min-encryption-password-length, if set, in the 'step ssh certificate' command. Add flags.FirstStringOf returns value of first defined flag in input list
1 parent b1cff60 commit c957358

File tree

5 files changed

+172
-31
lines changed

5 files changed

+172
-31
lines changed

command/ssh/certificate.go

+25-10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto"
66
"crypto/rand"
77
"crypto/x509"
8+
"fmt"
89
"net/url"
910
"os"
1011
"strings"
@@ -44,7 +45,8 @@ func certificateCommand() cli.Command {
4445
[**--console**] [**--no-password**] [**--insecure**] [**--force**] [**--x5c-cert**=<file>]
4546
[**--x5c-key**=<file>] [**--k8ssa-token-path**=<file>] [**--no-agent**]
4647
[**--kty**=<key-type>] [**--curve**=<curve>] [**--size**=<size>]
47-
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
48+
[**--min-password-length**=<length>] [**--ca-url**=<uri>]
49+
[**--root**=<file>] [**--context**=<name>]`,
4850

4951
Description: `**step ssh certificate** command generates an SSH key pair and creates a
5052
certificate using [step certificates](https://github.com/smallstep/certificates).
@@ -202,6 +204,11 @@ $ step ssh certificate --kty OKP --curve Ed25519 mariano@work id_ed25519
202204
Name: "no-agent",
203205
Usage: "Do not add the generated certificate and associated private key to the SSH agent.",
204206
},
207+
cli.IntFlag{
208+
Name: "min-password-length",
209+
Usage: "Set minimum required length for password used to encrypt private key. The default value is '0'. Values <=0 are interpreted as if no minimum value is set.",
210+
Value: 0,
211+
},
205212
flags.CaConfig,
206213
flags.CaURL,
207214
flags.Root,
@@ -240,6 +247,7 @@ func certificateAction(ctx *cli.Context) error {
240247
noPassword := ctx.Bool("no-password")
241248
insecure := ctx.Bool("insecure")
242249
sshPrivKeyFile := ctx.String("private-key")
250+
minPasswordLength := ctx.Int("min-password-length")
243251
validAfter, validBefore, err := flags.ParseTimeDuration(ctx)
244252
if err != nil {
245253
return err
@@ -258,6 +266,8 @@ func certificateAction(ctx *cli.Context) error {
258266
switch {
259267
case noPassword && !insecure:
260268
return errs.RequiredInsecureFlag(ctx, "no-password")
269+
case noPassword && minPasswordLength > 0:
270+
return errs.IncompatibleFlagWithFlag(ctx, "no-password", "min-password-length")
261271
case noPassword && passwordFile != "":
262272
return errs.IncompatibleFlagWithFlag(ctx, "no-password", "password-file")
263273
case token != "" && provisionerPasswordFile != "":
@@ -456,42 +466,47 @@ func certificateAction(ctx *cli.Context) error {
456466
// Private key (with password unless --no-password --insecure)
457467
opts := []pemutil.Options{
458468
pemutil.WithOpenSSH(true),
459-
pemutil.ToFile(keyFile, 0600),
469+
pemutil.ToFile(keyFile, 0o600),
460470
}
461471
switch {
462472
case noPassword && insecure:
463473
case passwordFile != "":
464-
opts = append(opts, pemutil.WithPasswordFile(passwordFile))
474+
opts = append(opts, pemutil.WithMinLengthPasswordFile(passwordFile, minPasswordLength))
465475
default:
466-
opts = append(opts, pemutil.WithPasswordPrompt("Please enter the password to encrypt the private key", func(s string) ([]byte, error) {
467-
return ui.PromptPassword(s, ui.WithValidateNotEmpty())
476+
prompt := "Please enter the password to encrypt the private key"
477+
if minPasswordLength > 0 {
478+
prompt = fmt.Sprintf("%s (must be at least %d characters)", prompt, minPasswordLength)
479+
}
480+
opts = append(opts, pemutil.WithPasswordPrompt(prompt, func(s string) ([]byte, error) {
481+
return ui.PromptPassword(s, ui.WithValidateNotEmpty(), ui.WithMinLength(minPasswordLength))
468482
}))
469483
}
484+
470485
_, err = pemutil.Serialize(priv, opts...)
471486
if err != nil {
472487
return err
473488
}
474489

475-
if err := utils.WriteFile(pubFile, marshalPublicKey(sshPub, subject), 0644); err != nil {
490+
if err := utils.WriteFile(pubFile, marshalPublicKey(sshPub, subject), 0o644); err != nil {
476491
return err
477492
}
478493
}
479494

480495
// Write certificate
481-
if err := utils.WriteFile(crtFile, marshalPublicKey(resp.Certificate, subject), 0644); err != nil {
496+
if err := utils.WriteFile(crtFile, marshalPublicKey(resp.Certificate, subject), 0o644); err != nil {
482497
return err
483498
}
484499

485500
// Write Add User keys and certs
486501
if isAddUser && resp.AddUserCertificate != nil {
487502
id := provisioner.SanitizeSSHUserPrincipal(subject) + "-provisioner"
488-
if _, err := pemutil.Serialize(auPriv, pemutil.WithOpenSSH(true), pemutil.ToFile(baseName+"-provisioner", 0600)); err != nil {
503+
if _, err := pemutil.Serialize(auPriv, pemutil.WithOpenSSH(true), pemutil.ToFile(baseName+"-provisioner", 0o600)); err != nil {
489504
return err
490505
}
491-
if err := utils.WriteFile(baseName+"-provisioner.pub", marshalPublicKey(sshAuPub, id), 0644); err != nil {
506+
if err := utils.WriteFile(baseName+"-provisioner.pub", marshalPublicKey(sshAuPub, id), 0o644); err != nil {
492507
return err
493508
}
494-
if err := utils.WriteFile(baseName+"-provisioner-cert.pub", marshalPublicKey(resp.AddUserCertificate, id), 0644); err != nil {
509+
if err := utils.WriteFile(baseName+"-provisioner-cert.pub", marshalPublicKey(resp.AddUserCertificate, id), 0o644); err != nil {
495510
return err
496511
}
497512
}

flags/flags.go

+18
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,21 @@ func parseCaURL(ctx *cli.Context, caURL string) (string, error) {
670670

671671
return fmt.Sprintf("%s://%s", u.Scheme, u.Host), nil
672672
}
673+
674+
// FirstStringOf returns the value of the first defined flag from the input list.
675+
// If no defined flags, returns first flag with non-empty default value.
676+
func FirstStringOf(ctx *cli.Context, flags ...string) string {
677+
// Return first defined flag.
678+
for _, f := range flags {
679+
if ctx.IsSet(f) {
680+
return ctx.String(f)
681+
}
682+
}
683+
// Return first non-empty, default, flag value.
684+
for _, f := range flags {
685+
if val := ctx.String(f); val != "" {
686+
return val
687+
}
688+
}
689+
return ""
690+
}

flags/flags_test.go

+75
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,78 @@ func TestParseFingerprintFormat(t *testing.T) {
230230
})
231231
}
232232
}
233+
234+
func TestFirstStringOf(t *testing.T) {
235+
getAppSet := func() (*cli.App, *flag.FlagSet) {
236+
app := &cli.App{}
237+
set := flag.NewFlagSet("contrive", 0)
238+
return app, set
239+
}
240+
tests := []struct {
241+
name string
242+
getContext func() *cli.Context
243+
inputs []string
244+
want string
245+
}{
246+
{
247+
name: "no-flags-empty",
248+
getContext: func() *cli.Context {
249+
app, set := getAppSet()
250+
//_ = set.String("ca-url", "", "")
251+
return cli.NewContext(app, set, nil)
252+
},
253+
inputs: []string{"foo", "bar"},
254+
want: "",
255+
},
256+
{
257+
name: "return-first-set-flag",
258+
getContext: func() *cli.Context {
259+
app, set := getAppSet()
260+
_ = set.String("foo", "", "")
261+
_ = set.String("bar", "", "")
262+
_ = set.String("baz", "", "")
263+
ctx := cli.NewContext(app, set, nil)
264+
ctx.Set("bar", "test1")
265+
ctx.Set("baz", "test2")
266+
return ctx
267+
},
268+
inputs: []string{"foo", "bar", "baz"},
269+
want: "test1",
270+
},
271+
{
272+
name: "return-first-default-flag",
273+
getContext: func() *cli.Context {
274+
app, set := getAppSet()
275+
_ = set.String("foo", "", "")
276+
_ = set.String("bar", "", "")
277+
_ = set.String("baz", "test1", "")
278+
ctx := cli.NewContext(app, set, nil)
279+
return ctx
280+
},
281+
inputs: []string{"foo", "bar", "baz"},
282+
want: "test1",
283+
},
284+
{
285+
name: "all-empty",
286+
getContext: func() *cli.Context {
287+
app, set := getAppSet()
288+
_ = set.String("foo", "", "")
289+
_ = set.String("bar", "", "")
290+
_ = set.String("baz", "", "")
291+
ctx := cli.NewContext(app, set, nil)
292+
return ctx
293+
},
294+
inputs: []string{"foo", "bar", "baz"},
295+
want: "",
296+
},
297+
}
298+
for _, tt := range tests {
299+
t.Run(tt.name, func(t *testing.T) {
300+
ctx := tt.getContext()
301+
val := FirstStringOf(ctx, tt.inputs...)
302+
if val != tt.want {
303+
t.Errorf("expected %v, but got %v", tt.want, val)
304+
}
305+
})
306+
}
307+
}

utils/cautils/bootstrap.go

+52-19
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ import (
2525
)
2626

2727
type bootstrapAPIResponse struct {
28-
CaURL string `json:"url"`
29-
Fingerprint string `json:"fingerprint"`
30-
RedirectURL string `json:"redirect-url"`
28+
CaURL string `json:"url"`
29+
Fingerprint string `json:"fingerprint"`
30+
RedirectURL string `json:"redirect-url"`
31+
Provisioner string `json:"provisioner"`
32+
MinPasswordLength int `json:"min-password-length"`
3133
}
3234

3335
// UseContext returns true if contexts should be used, false otherwise.
@@ -55,6 +57,20 @@ type bootstrapOption func(bc *bootstrapContext)
5557
type bootstrapContext struct {
5658
defaultContextName string
5759
redirectURL string
60+
provisioner string
61+
minPasswordLength int
62+
}
63+
64+
func withProvisioner(provisioner string) bootstrapOption {
65+
return func(bc *bootstrapContext) {
66+
bc.provisioner = provisioner
67+
}
68+
}
69+
70+
func withMinPasswordLength(minLength int) bootstrapOption {
71+
return func(bc *bootstrapContext) {
72+
bc.minPasswordLength = minLength
73+
}
5874
}
5975

6076
func withDefaultContextValues(context string) bootstrapOption {
@@ -70,10 +86,12 @@ func withRedirectURL(r string) bootstrapOption {
7086
}
7187

7288
type bootstrapConfig struct {
73-
CA string `json:"ca-url"`
74-
Fingerprint string `json:"fingerprint"`
75-
Root string `json:"root"`
76-
Redirect string `json:"redirect-url"`
89+
CA string `json:"ca-url"`
90+
Fingerprint string `json:"fingerprint"`
91+
Root string `json:"root"`
92+
Redirect string `json:"redirect-url,omitempty"`
93+
Provisioner string `json:"provisioner,omitempty"`
94+
MinPasswordLength int `json:"min-password-length,omitempty"`
7795
}
7896

7997
func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOption) error {
@@ -126,16 +144,16 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt
126144
rootFile := pki.GetRootCAPath()
127145
configFile := step.DefaultsFile()
128146

129-
if err = os.MkdirAll(filepath.Dir(rootFile), 0700); err != nil {
147+
if err = os.MkdirAll(filepath.Dir(rootFile), 0o700); err != nil {
130148
return errs.FileError(err, rootFile)
131149
}
132150

133-
if err = os.MkdirAll(filepath.Dir(configFile), 0700); err != nil {
151+
if err = os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {
134152
return errs.FileError(err, configFile)
135153
}
136154

137155
// Serialize root
138-
_, err = pemutil.Serialize(resp.RootPEM.Certificate, pemutil.ToFile(rootFile, 0600))
156+
_, err = pemutil.Serialize(resp.RootPEM.Certificate, pemutil.ToFile(rootFile, 0o600))
139157
if err != nil {
140158
return err
141159
}
@@ -148,12 +166,19 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt
148166
}
149167

150168
// Serialize defaults.json
151-
b, err := json.MarshalIndent(bootstrapConfig{
169+
bootConf := bootstrapConfig{
152170
CA: caURL,
153171
Fingerprint: fingerprint,
154172
Root: pki.GetRootCAPath(),
155173
Redirect: bc.redirectURL,
156-
}, "", " ")
174+
}
175+
if bc.minPasswordLength > 0 {
176+
bootConf.MinPasswordLength = bc.minPasswordLength
177+
}
178+
if bc.provisioner != "" {
179+
bootConf.Provisioner = bc.provisioner
180+
}
181+
b, err := json.MarshalIndent(bootConf, "", " ")
157182
if err != nil {
158183
return errors.Wrap(err, "error marshaling defaults.json")
159184
}
@@ -162,7 +187,7 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt
162187
ctx.Set("fingerprint", fingerprint)
163188
ctx.Set("root", rootFile)
164189

165-
if err := utils.WriteFile(configFile, b, 0644); err != nil {
190+
if err := utils.WriteFile(configFile, b, 0o644); err != nil {
166191
return err
167192
}
168193

@@ -171,12 +196,12 @@ func bootstrap(ctx *cli.Context, caURL, fingerprint string, opts ...bootstrapOpt
171196
if step.Contexts().Enabled() {
172197
profileDefaultsFile := step.ProfileDefaultsFile()
173198

174-
if err := os.MkdirAll(filepath.Dir(profileDefaultsFile), 0700); err != nil {
199+
if err := os.MkdirAll(filepath.Dir(profileDefaultsFile), 0o700); err != nil {
175200
return errs.FileError(err, profileDefaultsFile)
176201
}
177202

178203
if _, err := os.Stat(profileDefaultsFile); os.IsNotExist(err) {
179-
if err := os.WriteFile(profileDefaultsFile, []byte("{}"), 0600); err != nil {
204+
if err := os.WriteFile(profileDefaultsFile, []byte("{}"), 0o600); err != nil {
180205
return errs.FileError(err, profileDefaultsFile)
181206
}
182207
ui.Printf("The profile configuration has been saved in %s.\n", profileDefaultsFile)
@@ -254,9 +279,17 @@ func BootstrapTeamAuthority(ctx *cli.Context, team, teamAuthority string) error
254279
r.RedirectURL = "https://smallstep.com/app/teams/sso/success"
255280
}
256281

257-
return bootstrap(ctx, r.CaURL, r.Fingerprint,
258-
withDefaultContextValues(teamAuthority+"."+team),
259-
withRedirectURL(r.RedirectURL))
282+
bootOpts := []bootstrapOption{
283+
withDefaultContextValues(teamAuthority + "." + team),
284+
withRedirectURL(r.RedirectURL),
285+
}
286+
if r.Provisioner != "" {
287+
bootOpts = append(bootOpts, withProvisioner(r.Provisioner))
288+
}
289+
if r.MinPasswordLength > 0 {
290+
bootOpts = append(bootOpts, withMinPasswordLength(r.MinPasswordLength))
291+
}
292+
return bootstrap(ctx, r.CaURL, r.Fingerprint, bootOpts...)
260293
}
261294

262295
// BootstrapAuthority bootstraps an authority using only the caURL and fingerprint.
@@ -268,7 +301,7 @@ func BootstrapAuthority(ctx *cli.Context, caURL, fingerprint string) (err error)
268301
}
269302
}
270303

271-
var opts = []bootstrapOption{
304+
opts := []bootstrapOption{
272305
withDefaultContextValues(caHostname),
273306
}
274307

utils/cautils/token_flow.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ func OfflineTokenFlow(ctx *cli.Context, typ int, subject string, sans []string,
212212
}
213213

214214
kid := ctx.String("kid")
215-
issuer := ctx.String("issuer")
215+
issuer := flags.FirstStringOf(ctx, "provisioner", "issuer")
216216

217217
// Require issuer and keyFile if ca.json does not exists.
218218
// kid can be passed or created using jwk.Thumbprint.
@@ -326,7 +326,7 @@ func provisionerPrompt(ctx *cli.Context, provisioners provisioner.List) (provisi
326326
}
327327

328328
// Filter by issuer (provisioner name)
329-
if issuer := ctx.String("issuer"); issuer != "" {
329+
if issuer := flags.FirstStringOf(ctx, "provisioner", "issuer"); issuer != "" {
330330
provisioners = provisionerFilter(provisioners, func(p provisioner.Interface) bool {
331331
return p.GetName() == issuer
332332
})

0 commit comments

Comments
 (0)