diff --git a/Makefile b/Makefile index 71fbcd832..0533b1d7b 100644 --- a/Makefile +++ b/Makefile @@ -157,13 +157,6 @@ race: .PHONY: test race -integrate: integration - -integration: build - $Q $(CGO_OVERRIDE) gotestsum -- -tags=integration ./integration/... - -.PHONY: integrate integration - ######################################### # Linting ######################################### diff --git a/cmd/step/main.go b/cmd/step/main.go index f43ef4cba..c2cbeee2c 100644 --- a/cmd/step/main.go +++ b/cmd/step/main.go @@ -1,48 +1,12 @@ package main import ( - "errors" - "fmt" - "io" "os" - "reflect" - "regexp" - "strings" - "time" - - "github.com/urfave/cli" "github.com/smallstep/certificates/ca" - "github.com/smallstep/cli-utils/command" "github.com/smallstep/cli-utils/step" - "github.com/smallstep/cli-utils/ui" - "github.com/smallstep/cli-utils/usage" - "go.step.sm/crypto/jose" - "go.step.sm/crypto/pemutil" - - "github.com/smallstep/cli/command/version" - "github.com/smallstep/cli/internal/plugin" - "github.com/smallstep/cli/utils" - - // Enabled cas interfaces. - _ "github.com/smallstep/certificates/cas/cloudcas" - _ "github.com/smallstep/certificates/cas/softcas" - _ "github.com/smallstep/certificates/cas/stepcas" - // Enabled commands - _ "github.com/smallstep/cli/command/api" - _ "github.com/smallstep/cli/command/base64" - _ "github.com/smallstep/cli/command/beta" - _ "github.com/smallstep/cli/command/ca" - _ "github.com/smallstep/cli/command/certificate" - _ "github.com/smallstep/cli/command/completion" - _ "github.com/smallstep/cli/command/context" - _ "github.com/smallstep/cli/command/crl" - _ "github.com/smallstep/cli/command/crypto" - _ "github.com/smallstep/cli/command/fileserver" - _ "github.com/smallstep/cli/command/oauth" - _ "github.com/smallstep/cli/command/path" - _ "github.com/smallstep/cli/command/ssh" + "github.com/smallstep/cli/internal/cmd" ) // Version is set by an LDFLAG at build time representing the git tag or commit @@ -53,143 +17,15 @@ var Version = "N/A" // the time of build var BuildTime = "N/A" +// AppName is the name of the binary. Defaults to "step" if not set. +var AppName = "" + func init() { step.Set("Smallstep CLI", Version, BuildTime) ca.UserAgent = step.Version() + cmd.SetName(AppName) } func main() { - // initialize step environment. - if err := step.Init(); err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) - } - - defer panicHandler() - - // create new instance of app - app := newApp(os.Stdout, os.Stderr) - - if err := app.Run(os.Args); err != nil { - var messenger interface { - Message() string - } - if errors.As(err, &messenger) { - if os.Getenv("STEPDEBUG") == "1" { - fmt.Fprintf(os.Stderr, "%+v\n\n%s", err, messenger.Message()) - } else { - fmt.Fprintln(os.Stderr, messenger.Message()) - fmt.Fprintln(os.Stderr, "Re-run with STEPDEBUG=1 for more info.") - } - } else { - if os.Getenv("STEPDEBUG") == "1" { - fmt.Fprintf(os.Stderr, "%+v\n", err) - } else { - fmt.Fprintln(os.Stderr, err) - } - } - //nolint:gocritic // ignore exitAfterDefer error because the defer is required for recovery. - os.Exit(1) - } -} - -func newApp(stdout, stderr io.Writer) *cli.App { - // Define default file writers and prompters for go.step.sm/crypto - pemutil.WriteFile = utils.WriteFile - pemutil.PromptPassword = func(msg string) ([]byte, error) { - return ui.PromptPassword(msg) - } - jose.PromptPassword = func(msg string) ([]byte, error) { - return ui.PromptPassword(msg) - } - - // Override global framework components - cli.VersionPrinter = func(c *cli.Context) { - version.Command(c) - } - cli.AppHelpTemplate = usage.AppHelpTemplate - cli.SubcommandHelpTemplate = usage.SubcommandHelpTemplate - cli.CommandHelpTemplate = usage.CommandHelpTemplate - cli.HelpPrinter = usage.HelpPrinter - cli.FlagNamePrefixer = usage.FlagNamePrefixer - cli.FlagStringer = stringifyFlag - - // Configure cli app - app := cli.NewApp() - app.Name = "step" - app.HelpName = "step" - app.Usage = "plumbing for distributed systems" - app.Version = step.Version() - app.Commands = command.Retrieve() - app.Flags = append(app.Flags, cli.HelpFlag) - app.EnableBashCompletion = true - app.Copyright = fmt.Sprintf("(c) 2018-%d Smallstep Labs, Inc.", time.Now().Year()) - - // Flag of custom configuration flag - app.Flags = append(app.Flags, cli.StringFlag{ - Name: "config", - Usage: "path to the config file to use for CLI flags", - }) - - // Action runs on `step` or `step ` if the command is not enabled. - app.Action = func(ctx *cli.Context) error { - args := ctx.Args() - if name := args.First(); name != "" { - if file, err := plugin.LookPath(name); err == nil { - return plugin.Run(ctx, file) - } - if u := plugin.GetURL(name); u != "" { - //nolint:staticcheck // this is a top level error - capitalization is ok - return fmt.Errorf("The plugin %q was not found on this system.\nDownload it from %s", name, u) - } - return cli.ShowCommandHelp(ctx, name) - } - return cli.ShowAppHelp(ctx) - } - - // All non-successful output should be written to stderr - app.Writer = stdout - app.ErrWriter = stderr - - return app -} - -func panicHandler() { - if r := recover(); r != nil { - if os.Getenv("STEPDEBUG") == "1" { - fmt.Fprintf(os.Stderr, "%s\n", step.Version()) - fmt.Fprintf(os.Stderr, "Release Date: %s\n\n", step.ReleaseDate()) - panic(r) - } - - fmt.Fprintln(os.Stderr, "Something unexpected happened.") - fmt.Fprintln(os.Stderr, "If you want to help us debug the problem, please run:") - fmt.Fprintf(os.Stderr, "STEPDEBUG=1 %s\n", strings.Join(os.Args, " ")) - fmt.Fprintln(os.Stderr, "and send the output to info@smallstep.com") - os.Exit(2) - } -} - -func flagValue(f cli.Flag) reflect.Value { - fv := reflect.ValueOf(f) - for fv.Kind() == reflect.Ptr { - fv = reflect.Indirect(fv) - } - return fv -} - -var placeholderString = regexp.MustCompile(`<.*?>`) - -func stringifyFlag(f cli.Flag) string { - fv := flagValue(f) - usg := fv.FieldByName("Usage").String() - placeholder := placeholderString.FindString(usg) - if placeholder == "" { - switch f.(type) { - case cli.BoolFlag, cli.BoolTFlag: - default: - placeholder = "" - } - } - return cli.FlagNamePrefixer(fv.FieldByName("Name").String(), placeholder) + "\t" + usg + os.Exit(cmd.Run()) } diff --git a/docs/local-development.md b/docs/local-development.md index 435c69e11..e10458314 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -52,14 +52,6 @@ Run the unit tests: make test ``` -#### Integration Tests - -Run the integration tests: - -``` -make integration -``` - #### And coding style tests The currently enabled linters are defined in our shared [golangci-lint config](https://raw.githubusercontent.com/smallstep/workflows/master/.golangci.yml) diff --git a/go.mod b/go.mod index a96d8f14a..ac8eff22d 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/manifoldco/promptui v0.9.0 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 + github.com/rogpeppe/go-internal v1.13.1 github.com/slackhq/nebula v1.9.5 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 github.com/smallstep/certificates v0.28.3 @@ -137,6 +138,7 @@ require ( golang.org/x/sync v0.15.0 // indirect golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.33.0 // indirect google.golang.org/api v0.234.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect diff --git a/go.sum b/go.sum index aee5f98d3..db846f426 100644 --- a/go.sum +++ b/go.sum @@ -474,6 +474,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.234.0 h1:d3sAmYq3E9gdr2mpmiWGbm9pHsA/KJmyiLkwKfHBqU4= google.golang.org/api v0.234.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg= diff --git a/integration/README.md b/integration/README.md deleted file mode 100644 index ade7c25af..000000000 --- a/integration/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Step CLI Integration Tests - -## How To Run - -Run all the integration tests: -``` -make integration -``` - -Run only integration tests that match a regex: -``` -go test -tags=integration ./integration/... -run -``` diff --git a/integration/certificate_sign_test.go b/integration/certificate_sign_test.go deleted file mode 100644 index 7a94e6b18..000000000 --- a/integration/certificate_sign_test.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "fmt" - "testing" -) - -var testdata = "testdata" - -type CertificateSignCmd struct { - name string - command CLICommand - csr string - issuerCrt string - issuerKey string - pass string -} - -func (k CertificateSignCmd) setPass(pass string) CertificateSignCmd { - return CertificateSignCmd{k.name, k.command, k.csr, k.issuerCrt, k.issuerKey, pass} -} - -func (k CertificateSignCmd) fail(t *testing.T, expected string) { - k.command.fail(t, k.name, expected, "") -} - -func (k CertificateSignCmd) failNoPass(t *testing.T, expected string) { - k.command.fail(t, k.name, expected, "") -} - -func NewCertificateSignCmd(name, csr, crt, key string) CertificateSignCmd { - csrFile := fmt.Sprintf("%s/%s", testdata, csr) - crtFile := fmt.Sprintf("%s/%s", testdata, crt) - keyFile := fmt.Sprintf("%s/%s", testdata, key) - command := NewCLICommand().setCommand(fmt.Sprintf("step certificate sign %s %s %s", - csrFile, crtFile, keyFile)) - return CertificateSignCmd{name, command, csrFile, crtFile, keyFile, "password"} -} - -func TestCertificateSign(t *testing.T) { - NewCertificateSignCmd("bad-sig", "certificate-create-bad-sig.csr", "intermediate_ca.crt", "intermediate_ca_key").failNoPass(t, "Certificate Request has invalid signature: crypto/rsa: verification error\n") - //NewKeypairCmd("success", "foo.csr", "intermediate_ca.crt", "intermediate_ca_key").setPass("pass").test(t) -} diff --git a/integration/certificate_test.go b/integration/certificate_test.go new file mode 100644 index 000000000..e8b4a493d --- /dev/null +++ b/integration/certificate_test.go @@ -0,0 +1,132 @@ +package integration + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/minica" + "go.step.sm/crypto/pemutil" +) + +func TestCertificateSignCommand(t *testing.T) { + signer, err := keyutil.GenerateDefaultSigner() + require.NoError(t, err) + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: "test"}}, signer) + require.NoError(t, err) + csr, err := x509.ParseCertificateRequest(csrBytes) + require.NoError(t, err) + caSigner, err := keyutil.GenerateDefaultSigner() + require.NoError(t, err) + tmpl := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-ca"}, + SerialNumber: big.NewInt(1), + IsCA: true, + MaxPathLen: 1, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + caCertBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, caSigner.Public(), caSigner) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caCertBytes) + require.NoError(t, err) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/certificate/sign.txtar"}, + Setup: func(e *testscript.Env) error { + _, err := pemutil.Serialize(csr, pemutil.WithFilename(filepath.Join(e.Cd, "test.csr"))) + require.NoError(t, err) + _, err = pemutil.Serialize(caCert, pemutil.WithFilename(filepath.Join(e.Cd, "cacert.pem"))) + require.NoError(t, err) + _, err = pemutil.Serialize(caSigner, pemutil.WithFilename(filepath.Join(e.Cd, "cakey.pem"))) + require.NoError(t, err) + + return nil + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_certificate": checkCertificate, + }, + }) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/certificate/sign-bad-csr.txtar"}, + Setup: func(e *testscript.Env) error { + err := os.WriteFile(filepath.Join(e.Cd, "bad.csr"), []byte("bogus"), 0644) + require.NoError(t, err) + _, err = pemutil.Serialize(caCert, pemutil.WithFilename(filepath.Join(e.Cd, "cacert.pem"))) + require.NoError(t, err) + _, err = pemutil.Serialize(caSigner, pemutil.WithFilename(filepath.Join(e.Cd, "cakey.pem"))) + require.NoError(t, err) + + return nil + }, + }) +} + +func TestCertificateVerifyCommand(t *testing.T) { + ca, err := minica.New(minica.WithName("TestCertificateVerify")) + require.NoError(t, err) + signer, err := keyutil.GenerateDefaultSigner() + require.NoError(t, err) + tmpl := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-cert"}, + PublicKey: signer.Public(), + } + crt, err := ca.Sign(tmpl) + require.NoError(t, err) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/certificate/verify.txtar"}, + Setup: func(e *testscript.Env) error { + _, err := pemutil.Serialize(crt, pemutil.WithFilename(filepath.Join(e.Cd, "test.crt"))) + require.NoError(t, err) + _, err = pemutil.Serialize(ca.Intermediate, pemutil.WithFilename(filepath.Join(e.Cd, "intermediate.pem"))) + require.NoError(t, err) + + return nil + }, + }) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/certificate/verify-bad-pem.txtar"}, + Setup: func(e *testscript.Env) error { + err := os.WriteFile(filepath.Join(e.Cd, "bad.pem"), []byte("bogus"), 0644) + require.NoError(t, err) + + return nil + }, + }) +} + +func TestCertificateFingerprintCommand(t *testing.T) { + b, err := os.ReadFile("./testdata/intermediate_ca.crt") + require.NoError(t, err) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/certificate/fingerprint.txtar"}, + Setup: func(e *testscript.Env) error { + err := os.WriteFile(filepath.Join(e.Cd, "intermediate_ca.crt"), b, 0600) + require.NoError(t, err) + + return nil + }, + }) +} + +func checkCertificate(ts *testscript.TestScript, neg bool, args []string) { + contents := ts.ReadFile("stdout") // directly reads from stdout of the previously executed command + bundle, err := pemutil.ParseCertificateBundle([]byte(contents)) + ts.Check(err) + + if len(bundle) != 1 { + ts.Fatalf("expected 1 certificate; got %d", len(bundle)) + } +} diff --git a/integration/certificate_verify_test.go b/integration/certificate_verify_test.go deleted file mode 100644 index b3b2aeb77..000000000 --- a/integration/certificate_verify_test.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "fmt" - "testing" -) - -type CertificateVerifyCmd struct { - name string - command CLICommand - crt string - host string - roots string -} - -func (k CertificateVerifyCmd) fail(t *testing.T, expected string) { - k.command.fail(t, k.name, expected, "") -} - -func NewCertificateVerifyCmd(name, crt string) CertificateVerifyCmd { - testdata := "testdata" - crtFile := fmt.Sprintf("./%s/%s", testdata, crt) - command := NewCLICommand().setCommand(fmt.Sprintf("step certificate verify %s", - crtFile)) - return CertificateVerifyCmd{name: name, command: command, crt: crt} -} - -func TestCertificateVerify(t *testing.T) { - NewCertificateVerifyCmd("bad-pem", "bad-pem.crt").fail(t, "./testdata/bad-pem.crt contains an invalid PEM block\n") - //NewKeypairCmd("success", "foo.csr", "intermediate_ca.crt", "intermediate_ca_key").setPass("pass").test(t) -} diff --git a/integration/command.go b/integration/command.go deleted file mode 100644 index a6486a7f7..000000000 --- a/integration/command.go +++ /dev/null @@ -1,151 +0,0 @@ -//nolint:unused // will be fixed with new integration tests -package integration - -import ( - "bytes" - "errors" - "fmt" - "io" - "os/exec" - "regexp" - "strings" - "testing" - - "github.com/ThomasRooney/gexpect" - "github.com/smallstep/assert" -) - -// CleanOutput returns the output from the cursor character. -func CleanOutput(str string) string { - if i := strings.Index(str, "?25h"); i > 0 { - return str[i+4:] - } - return str -} - -// Command executes a shell command. -func Command(command string) *exec.Cmd { - return exec.Command("sh", "-c", command) -} - -// ExitError converts an error to an exec.ExitError. -func ExitError(err error) (*exec.ExitError, bool) { - var ee *exec.ExitError - if errors.As(err, &ee) { - return ee, true - } - return nil, false -} - -// Output executes a shell command and returns output from stdout. -func Output(command string) ([]byte, error) { - return Command(command).Output() -} - -// CombinedOutput executes a shell command and returns combined output from -// stdout and stderr. -func CombinedOutput(command string) ([]byte, error) { - return Command(command).CombinedOutput() -} - -// WithStdin executes a shell command with a provided reader used for stdin. -func WithStdin(command string, r io.Reader) ([]byte, error) { - cmd := Command(command) - cmd.Stdin = r - return cmd.Output() -} - -// CLICommand represents a command-line command to execute. -type CLICommand struct { - command string - arguments string - flags map[string]string - stdin io.Reader -} - -// CLIOutput represents the output from executing a CLICommand. -type CLIOutput struct { - //nolint:unused // ignore unused field - stdout, stderr, combined string -} - -// NewCLICommand generates a new CLICommand. -func NewCLICommand() CLICommand { - return CLICommand{"", "", make(map[string]string), nil} -} - -func (c CLICommand) setFlag(flag, value string) CLICommand { - flags := make(map[string]string) - for k, v := range c.flags { - flags[k] = v - } - flags[flag] = value - return CLICommand{c.command, c.arguments, flags, c.stdin} -} - -func (c CLICommand) setCommand(command string) CLICommand { - return CLICommand{command, c.arguments, c.flags, c.stdin} -} - -func (c CLICommand) setArguments(arguments string) CLICommand { - return CLICommand{c.command, arguments, c.flags, c.stdin} -} - -func (c CLICommand) setStdin(stdin string) CLICommand { - return CLICommand{c.command, c.arguments, c.flags, strings.NewReader(stdin)} -} - -func (c CLICommand) cmd() string { - flags := "" - for key, value := range c.flags { - if strings.Contains(value, " ") { - value = "\"" + value + "\"" - } - flags += fmt.Sprintf("--%s %s ", key, value) - } - return fmt.Sprintf("%s %s %s", c.command, c.arguments, flags) -} - -func (c CLICommand) run() (CLIOutput, error) { - var stdout, stderr, combined bytes.Buffer - cmd := Command(c.cmd()) - cmd.Stdout = io.MultiWriter(&stdout, &combined) - cmd.Stderr = io.MultiWriter(&stderr, &combined) - cmd.Stdin = c.stdin - err := cmd.Run() - return CLIOutput{stdout.String(), stderr.String(), combined.String()}, err -} - -func (c CLICommand) spawn() (*gexpect.ExpectSubprocess, error) { - return gexpect.Spawn(c.cmd()) -} - -func (c CLICommand) test(t *testing.T, name, expected string, msg ...interface{}) { - t.Run(name, func(t *testing.T) { - out, err := c.run() - assert.FatalError(t, err, fmt.Sprintf("`%s`: returned error '%s'\n\nOutput:\n%s", c.cmd(), err, out.combined)) - assert.Equals(t, out.combined, expected, msg...) - }) -} - -func (c CLICommand) fail(t *testing.T, name string, expected interface{}, msg ...interface{}) { - _ = msg - t.Run(name, func(t *testing.T) { - out, err := c.run() - if assert.NotNil(t, err) { - assert.Equals(t, err.Error(), "exit status 1", msg...) - } - switch v := expected.(type) { - case string: - assert.Equals(t, expected, out.stderr, msg...) - case *regexp.Regexp: - re := expected.(*regexp.Regexp) - if !re.MatchString(out.stderr) { - t.Errorf("Error message did not match regex:\n Regex: %s\n\n Output:\n%s", re.String(), out.stderr) - } - default: - t.Errorf("unexpected type %T", v) - } - assert.Equals(t, "", out.stdout, msg...) - }) -} diff --git a/integration/crypto_test.go b/integration/crypto_test.go new file mode 100644 index 000000000..1b2822146 --- /dev/null +++ b/integration/crypto_test.go @@ -0,0 +1,575 @@ +package integration + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + gojose "github.com/go-jose/go-jose/v3" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/jose" + "go.step.sm/crypto/keyutil" +) + +func TestCryptoJWKCommand(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwk-create.txtar"}, // defaults and generic failures + Setup: func(e *testscript.Env) error { + return os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_jwk": checkKeyPair, + }, + }) +} + +func TestCryptoJWKCreateRSACommand(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwk-create-rsa.txtar"}, // RSA generation + Setup: func(e *testscript.Env) error { + return os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_jwk": checkKeyPair, + "check_jwk_without_password": checkKeyPairWithoutPassword, + }, + }) +} + +func TestCryptoJWKCreateECCommand(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwk-create-ec.txtar"}, // EC generation + Setup: func(e *testscript.Env) error { + return os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_jwk": checkKeyPair, + "check_jwk_without_password": checkKeyPairWithoutPassword, + }, + }) +} + +func TestCryptoJWKCreateOKPCommand(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwk-create-okp.txtar"}, // OKP generation + Setup: func(e *testscript.Env) error { + return os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_jwk": checkKeyPair, + "check_jwk_without_password": checkKeyPairWithoutPassword, + }, + }) +} + +func TestCryptoJWKCreateOctCommand(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwk-create-oct.txtar"}, // oct generation + Setup: func(e *testscript.Env) error { + return os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_jwk": checkKeyPair, + "check_jwk_without_password": checkKeyPairWithoutPassword, + }, + }) +} + +func TestCryptoJWTCommand(t *testing.T) { + p256JWK, p256Bytes := readKey(t, "./testdata/p256.pem") // TODO(hs): can/must we get rid of these, and generate them on start of test? + rsaJWK, rsaBytes := readKey(t, "./testdata/rsa2048.pem") + noUseBytes := readBytes(t, "./testdata/jwk-no-use.json") + noAlgBytes := readBytes(t, "./testdata/jwk-no-alg.json") + badKeyBytes := readBytes(t, "./testdata/bad-key.json") + p256PubJSONBytes := readBytes(t, "./testdata/jwk-pGoLJDgF5fgTNnB47SKMnVUzVNdu6MF0.pub.json") + p256PubPemBytes := readBytes(t, "./testdata/p256.pem.pub") + twopemsBytes := readBytes(t, "./testdata/twopems.pem") + badHeaderBytes := readBytes(t, "./testdata/badheader.pem") + encP256Bytes := readBytes(t, "./testdata/es256-enc.pem") + jwks, jwksBytes := readKeySet(t, "./testdata/jwks.json") + ed25519JWK, ed25519JSONBytes := generateJWK(t, "OKP", "Ed25519") + + jwtJSON := readBytes(t, "./testdata/jwt-json-serialization.json") + jwtFlattenedJSON := readBytes(t, "./testdata/jwt-json-serialization-flattened.json") + jwtMultiJSON := readBytes(t, "./testdata/jwt-json-serialization-multi.json") + + now := time.Now() + p256Token := createToken(t, p256JWK, now) + rsaToken := createToken(t, rsaJWK, now) + ed25519Token := createToken(t, ed25519JWK, now) + jwksToken := createToken(t, &jwks.Key("1")[0], now) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwt-sign.txtar"}, + Setup: func(e *testscript.Env) error { + // set some additional environment variables required for token creation + e.Vars = append(e.Vars, + fmt.Sprintf("NBF=%d", now.Add(-1*time.Minute).Unix()), + fmt.Sprintf("EXP=%d", now.Add(1*time.Minute).Unix()), + fmt.Sprintf("IAT=%d", now.Unix()), + fmt.Sprintf("EXPIRY_IN_THE_PAST=%d", now.Add(-30*time.Second).Unix()), + ) + + // write the (existing) keys to the (temporary) test directory + err := os.WriteFile(filepath.Join(e.Cd, "p256.pem"), p256Bytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "rsa.pem"), rsaBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "ed25519.json"), ed25519JSONBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "nouse.json"), noUseBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "noalg.json"), noAlgBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "badkey.json"), badKeyBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "p256.pub.json"), p256PubJSONBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "p256.pub.pem"), p256PubPemBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "twopems.pem"), twopemsBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "badheader.pem"), badHeaderBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "encp256.pem"), encP256Bytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "jwks.json"), jwksBytes, 0600) + require.NoError(t, err) + + return nil + }, + }) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwt-verify.txtar"}, + Setup: func(e *testscript.Env) error { + // write the (existing) keys to the (temporary) test directory + err := os.WriteFile(filepath.Join(e.Cd, "p256.pem"), p256Bytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "p256token.txt"), []byte(p256Token), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "rsa.pem"), rsaBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "rsatoken.txt"), []byte(rsaToken), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "ed25519.json"), ed25519JSONBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "ed25519token.txt"), []byte(ed25519Token), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "jwks.json"), jwksBytes, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "jwkstoken.txt"), []byte(jwksToken), 0600) + require.NoError(t, err) + + // write fake / invalid tokens to the (temporary) test directory + invalidSignature := ed25519Token[:len(ed25519Token)-5] + "12345" + err = os.WriteFile(filepath.Join(e.Cd, "incomplete-signature.txt"), []byte(invalidSignature), 0600) + require.NoError(t, err) + parts := strings.Split(ed25519Token, ".") + err = os.WriteFile(filepath.Join(e.Cd, "invalid-header.txt"), []byte(createFakeToken(t, "foo", parts[1], parts[2])), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "invalid-header-json.txt"), []byte(createFakeToken(t, "[42]", "bar", "deadbeef")), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "invalid-header-changed-attribute.txt"), []byte(createFakeToken(t, `{"kty":"EC","alg":"ES256","xxx":"yyy"}`, parts[1], parts[2])), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "invalid-header-bad-json.txt"), []byte(createFakeToken(t, `{"kty":"EC","alg":"ES256","}`, parts[1], parts[2])), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "invalid-payload.txt"), []byte(createFakeToken(t, parts[0], "foo", parts[2])), 0600) + require.NoError(t, err) + + // write tokens created by OpenSSL + exp := now.Add(1 * time.Minute).Unix() + validOpenSSLToken := createTokenUsingOpenSSL(t, `{"typ": "JWT", "alg": "RS256"}`, fmt.Sprintf(`{"iss": "TestIssuer", "aud": "TestAudience", "exp": %d}`, exp), "./testdata/rsa2048.pem") + err = os.WriteFile(filepath.Join(e.Cd, "ossltoken.txt"), []byte(validOpenSSLToken), 0600) + require.NoError(t, err) + expiredOpenSSLToken := createTokenUsingOpenSSL(t, `{"typ": "JWT", "alg": "RS256"}`, `{"iss": "TestIssuer", "aud": "TestAudience", "exp": 0}`, "./testdata/rsa2048.pem") + err = os.WriteFile(filepath.Join(e.Cd, "expired-ossltoken.txt"), []byte(expiredOpenSSLToken), 0600) + require.NoError(t, err) + noExpiryOpenSSLToken := createTokenUsingOpenSSL(t, `{"typ": "JWT", "alg": "RS256"}`, `{"iss": "TestIssuer", "aud": "TestAudience"}`, "./testdata/rsa2048.pem") + err = os.WriteFile(filepath.Join(e.Cd, "no-expiry-ossltoken.txt"), []byte(noExpiryOpenSSLToken), 0600) + require.NoError(t, err) + zeroNotBeforeOpenSSLToken := createTokenUsingOpenSSL(t, `{"typ": "JWT", "alg": "RS256"}`, `{"iss": "TestIssuer", "aud": "TestAudience", "nbf": 0}`, "./testdata/rsa2048.pem") + err = os.WriteFile(filepath.Join(e.Cd, "zero-not-before-ossltoken.txt"), []byte(zeroNotBeforeOpenSSLToken), 0600) + require.NoError(t, err) + + // write data for JSON serialization errors + err = os.WriteFile(filepath.Join(e.Cd, "jwt-json-serialization.json"), jwtJSON, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "jwt-json-serialization-flattened.json"), jwtFlattenedJSON, 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "jwt-json-serialization-multi.json"), jwtMultiJSON, 0600) + require.NoError(t, err) + + return nil + }, + }) + + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/jwt-inspect.txtar"}, + Setup: func(e *testscript.Env) error { + err := os.WriteFile(filepath.Join(e.Cd, "token.txt"), []byte(p256Token), 0600) + require.NoError(t, err) + + return nil + }, + }) +} + +func TestCryptoKeyPair(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/keypair.txtar"}, + Setup: func(e *testscript.Env) error { + return os.WriteFile(filepath.Join(e.Cd, "password.txt"), []byte("password"), 0600) + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_key_pair": checkKeyPair, + }, + }) +} + +func TestCryptoOTP(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/otp.txtar"}, + Setup: func(e *testscript.Env) error { + secret := "UPCTJYT7MUR4RWOUJ3TGTUB43IYCBJ76" + err := os.WriteFile(filepath.Join(e.Cd, "secret.txt"), []byte(secret), 0600) + require.NoError(t, err) + code, err := totp.GenerateCode(secret, time.Now()) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "code.txt"), []byte(code), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "invalid.txt"), []byte("aaaaaa"), 0600) + require.NoError(t, err) + urlSecret := "EW32D2CFTAIRTEAWTRQZZXAITVA4U6K4" + url := fmt.Sprintf("otpauth://totp/example.com:foo@example.com?algorithm=SHA1&digits=6&issuer=example.com&period=30&secret=%s", urlSecret) + err = os.WriteFile(filepath.Join(e.Cd, "urlsecret.txt"), []byte(urlSecret), 0600) + require.NoError(t, err) + key, err := otp.NewKeyFromURL(url) + require.NoError(t, err) + urlCode, err := totp.GenerateCode(key.Secret(), time.Now()) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(e.Cd, "urlcode.txt"), []byte(urlCode), 0600) + require.NoError(t, err) + return nil + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_otp": checkOTP, + }, + }) +} + +func checkOTP(ts *testscript.TestScript, neg bool, args []string) { + out := strings.TrimSpace(ts.ReadFile(args[0])) + + length, err := strconv.Atoi(args[1]) + ts.Check(err) + + if out == "" { + ts.Fatalf("expected OTP not be empty") + } + + if length != -1 { + if len(out) != length { + ts.Fatalf("expected OTP to be %d characters long; got %d", length, len(out)) + } + } + + if strings.HasPrefix(out, "otpauth://") { + key, err := otp.NewKeyFromURL(out) + ts.Check(err) + + switch { + case key.Type() != "totp": + ts.Fatalf("expected OTP to be type totp; got %s", key.Type()) + case key.Issuer() != "example.com": + ts.Fatalf("expected issuer to be example.com; got %s", key.Issuer()) + case key.AccountName() != "foo@example.com": + ts.Fatalf("expected account name to be foo@example.com; got %s", key.AccountName()) + case len(key.Secret()) != 32: + ts.Fatalf("expected secret to be 32 bytes; got %d", len(key.Secret())) + } + } +} + +func TestCryptoHelp(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/crypto/help.txtar"}, + }) +} + +// checkKeyPair checks that the public/private key pair provided as filenames in +// the first and second argument is valid. It always uses the password "password". +// Other validations are delegated to the checkKeyDetails function. +func checkKeyPair(ts *testscript.TestScript, _ bool, args []string) { + if len(args) < 4 { + ts.Fatalf("expected at least 4 arguments, got %d", len(args)) + } + + pub, err := jose.ParseKey([]byte(ts.ReadFile(args[0]))) + ts.Check(err) + priv, err := jose.ParseKey([]byte(ts.ReadFile(args[1])), jose.WithPassword([]byte("password"))) + ts.Check(err) + + checkKeyDetails(ts, pub, priv, args) +} + +// checkKeyPair checks that the public/private key pair provided as filenames in +// the first and second argument is valid. It assumes no password is set on the file. +// Other validations are delegated to the checkKeyDetails function. +func checkKeyPairWithoutPassword(ts *testscript.TestScript, _ bool, args []string) { + if len(args) < 4 { + ts.Fatalf("expected at least 4 arguments, got %d", len(args)) + } + + pub, err := jose.ParseKey([]byte(ts.ReadFile(args[0]))) + ts.Check(err) + priv, err := jose.ParseKey([]byte(ts.ReadFile(args[1]))) + ts.Check(err) + + checkKeyDetails(ts, pub, priv, args) +} + +// checkKeyDetails checks that the public/private key pair is valid. It performs +// the following checks: +// +// - Compare the public and private key SHA-1 thumbprints to verify they match +// - The type of the key that was created +// - For RSA keys, the key size is the expected size, and using the expected algorithm +// - For EC keys, the key curve is the expected curve, and using the expected algorithm +// - For OKP keys, the key curve is the expected curve, and using the expected algorithm +// - For oct keys, the key parts are of the expected type, and using the expected algorithm +func checkKeyDetails(ts *testscript.TestScript, pub, priv *jose.JSONWebKey, args []string) { + keyType := strings.ToUpper(args[2]) + if keyType == "OCT" { + if _, ok := pub.Key.([]byte); !ok { + ts.Fatalf("expected public key %s to be a byte slice; got %T", args[0], pub.Key) + } + if _, ok := priv.Key.([]byte); !ok { + ts.Fatalf("expected private key %s to be a byte slice; got %T", args[0], pub.Key) + } + } else { + pubHash, err := pub.Thumbprint(crypto.SHA1) + ts.Check(err) + privHash, err := priv.Thumbprint(crypto.SHA1) + ts.Check(err) + + if !bytes.Equal(pubHash, privHash) { + ts.Fatalf("%s and %s have different thumbprints", args[0], args[1]) + } + } + + switch { + case keyType == "RSA": + if !strings.HasPrefix(pub.Algorithm, "RS") && !strings.HasPrefix(pub.Algorithm, "PS") { + ts.Fatalf("expected RSA algorithm for RSA key, got %q", pub.Algorithm) + } + + expectedLength, err := strconv.Atoi(args[3]) + ts.Check(err) + + length, err := keyLength(pub) + ts.Check(err) + + if length != expectedLength { + ts.Fatalf("key length mismatch: expected %d, got %d", expectedLength, length) + } + + if len(args) > 4 { + expectedAlgorithm := args[4] + if !strings.EqualFold(pub.Algorithm, expectedAlgorithm) { + ts.Fatalf("key algorithm mismatch: expected %s, got %s", expectedAlgorithm, pub.Algorithm) + } + } + + return + case keyType == "OKP": + if !strings.HasPrefix(pub.Algorithm, "EdDSA") { + ts.Fatalf("expected EC algorithm for EC key, got %q", pub.Algorithm) + } + + if crv := strings.ToUpper(args[3]); crv != "ED25519" { + ts.Fatalf("unexpected OKP curve %q", args[3]) + } + case keyType == "OCT": + if !strings.HasPrefix(pub.Algorithm, "HS") && !strings.HasPrefix(pub.Algorithm, "A") && pub.Algorithm != "dir" { + ts.Fatalf("expected oct algorithm for oct key, got %q", pub.Algorithm) + } + + expectedAlgorithm := args[3] + if !strings.EqualFold(pub.Algorithm, expectedAlgorithm) { + ts.Fatalf("key algorithm mismatch: expected %s, got %s", expectedAlgorithm, pub.Algorithm) + } + case strings.HasPrefix(keyType, "EC"): + if !strings.HasPrefix(pub.Algorithm, "ES") && !strings.HasPrefix(pub.Algorithm, "ECDH") { + ts.Fatalf("expected EC algorithm for EC key, got %q", pub.Algorithm) + } + + if len(args) > 4 { + expectedAlgorithm := args[4] + if !strings.EqualFold(pub.Algorithm, expectedAlgorithm) { + ts.Fatalf("key algorithm mismatch: expected %s, got %s", expectedAlgorithm, pub.Algorithm) + } + } + + kc, err := keyCurve(pub) + ts.Check(err) + + switch crv := strings.ToUpper(args[3]); crv { + case "P-256": + if kc != elliptic.P256() { + ts.Fatalf("expected P-256 curve, got %q", kc) + } + case "P-384": + if kc != elliptic.P384() { + ts.Fatalf("expected P-384 curve, got %q", kc) + } + case "P-521": + if kc != elliptic.P521() { + ts.Fatalf("expected P-521 curve, got %q", kc) + } + default: + ts.Fatalf("unknown curve %q", crv) + } + default: + ts.Fatalf("unknown key format %q", args[2]) + } +} + +func keyLength(jwk *jose.JSONWebKey) (int, error) { + switch key := jwk.Key.(type) { + case []byte: + return len(key) * 8, nil + case *rsa.PrivateKey: + return key.N.BitLen(), nil + case *rsa.PublicKey: + return key.N.BitLen(), nil + case *ecdsa.PrivateKey: + return key.Params().BitSize, nil + case *ecdsa.PublicKey: + return key.Params().BitSize, nil + default: + return 0, fmt.Errorf("unsupported key type: %T", key) + } +} + +func keyCurve(jwk *jose.JSONWebKey) (elliptic.Curve, error) { + switch key := jwk.Key.(type) { + case *ecdsa.PrivateKey: + return key.Curve, nil + case *ecdsa.PublicKey: + return key.Curve, nil + default: + return nil, fmt.Errorf("unsupported key type: %T", key) + } +} + +func createToken(t *testing.T, jwk *jose.JSONWebKey, now time.Time) string { + t.Helper() + + c := &jose.Claims{ + Issuer: "TestIssuer", + Subject: "TestSubject", + Audience: jose.Audience([]string{"TestAudience"}), + Expiry: jose.UnixNumericDate(now.Add(1 * time.Minute).Unix()), + NotBefore: jose.UnixNumericDate(now.Add(-1 * time.Minute).Unix()), + IssuedAt: jose.UnixNumericDate(now.Unix()), + ID: "test-id", + } + + so := new(jose.SignerOptions).WithType("JWT").WithHeader("kid", jwk.KeyID) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk.Key, + }, so) + require.NoError(t, err) + + payload := make(map[string]any) + raw, err := jose.Signed(signer).Claims(c).Claims(payload).CompactSerialize() + require.NoError(t, err) + + return raw +} + +func readBytes(t *testing.T, file string) []byte { + t.Helper() + + b, err := os.ReadFile(file) + require.NoError(t, err) + + return b +} + +func readKey(t *testing.T, file string) (*jose.JSONWebKey, []byte) { + t.Helper() + + b := readBytes(t, file) + jwk, err := jose.ParseKey(b) + require.NoError(t, err) + + return jwk, b +} + +func readKeySet(t *testing.T, file string) (*gojose.JSONWebKeySet, []byte) { + t.Helper() + + b := readBytes(t, file) + var jwks gojose.JSONWebKeySet + err := json.Unmarshal(b, &jwks) + require.NoError(t, err) + + return &jwks, b +} + +func generateJWK(t *testing.T, kty, crv string) (*jose.JSONWebKey, []byte) { + t.Helper() + + pk, err := keyutil.GenerateKey(kty, crv, 0) + require.NoError(t, err) + + jwk := jose.JSONWebKey{ + Key: pk, + KeyID: fmt.Sprintf("kid-%s-%s", kty, crv), + //Algorithm: string(jose.ES256), + Use: "sig", // use for signature + } + + b, err := jwk.MarshalJSON() + require.NoError(t, err) + + return &jwk, b +} + +func createFakeToken(t *testing.T, header, payload, signature string) string { + t.Helper() + + header = base64.RawURLEncoding.EncodeToString([]byte(header)) + payload = base64.RawURLEncoding.EncodeToString([]byte(payload)) + return strings.Join([]string{header, payload, signature}, ".") +} + +func createTokenUsingOpenSSL(t *testing.T, header, payload, key string) string { + t.Helper() + + cmd := fmt.Sprintf("./openssl-jwt.sh -a RS256 -k %s '%s' '%s'", key, header, payload) + jwt, err := exec.Command("bash", "-c", cmd).CombinedOutput() + require.NoError(t, err) + return string(jwt) +} diff --git a/integration/help_quality_test.go b/integration/help_quality_test.go deleted file mode 100644 index c30173872..000000000 --- a/integration/help_quality_test.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "encoding/json" - "fmt" - "os" - "sort" - "strings" - "testing" - - "github.com/smallstep/assert" - "github.com/smallstep/cli-utils/usage" -) - -func TestHelpQuality(t *testing.T) { - cmd := NewCLICommand().setCommand("../bin/step help").setFlag("html", "./html").setFlag("report", "") - cmd.run() - - raw, _ := os.ReadFile("./html/report.json") - var report *usage.Report - json.Unmarshal([]byte(raw), &report) - - expectations := make(map[string]usage.Section) - expectations["COMMANDS"] = usage.Section{Name: "COMMANDS", Words: 0, Lines: 0} - expectations["COPYRIGHT"] = usage.Section{Name: "COPYRIGHT", Words: 5, Lines: 1} - expectations["DESCRIPTION"] = usage.Section{Name: "DESCRIPTION", Words: 8, Lines: 1} - expectations["EXAMPLES"] = usage.Section{Name: "EXAMPLES", Words: 10, Lines: 1} - expectations["EXIT CODES"] = usage.Section{Name: "EXIT CODES", Words: 12, Lines: 1} - expectations["ONLINE"] = usage.Section{Name: "ONLINE", Words: 7, Lines: 1} - expectations["OPTIONS"] = usage.Section{Name: "OPTIONS", Words: 6, Lines: 2} - expectations["POSITIONAL ARGUMENTS"] = usage.Section{Name: "POSITIONAL ARGUMENTS", Words: 6, Lines: 2} - expectations["PRINTING"] = usage.Section{Name: "PRINTING", Words: 23, Lines: 1} - expectations["SECURITY CONSIDERATIONS"] = usage.Section{Name: "SECURITY CONSIDERATIONS", Words: 220, Lines: 25} - expectations["STANDARDS"] = usage.Section{Name: "STANDARDS", Words: 45, Lines: 10} - expectations["USAGE"] = usage.Section{Name: "USAGE", Words: 2, Lines: 1} - expectations["VERSION"] = usage.Section{Name: "VERSION", Words: 3, Lines: 1} - - t.Run("Headlines consistency", func(t *testing.T) { - var headlines []string - for _, top := range report.Report { - for _, section := range top.Sections { - headlines = append(headlines, section.Name) - } - } - sort.Strings(headlines) - - grouped := make(map[string]int) - for _, line := range headlines { - grouped[line]++ - } - - for item, count := range grouped { - fmt.Printf("%s: %d\n", item, count) - - // Let's say only upper case considered headlines - if strings.ToUpper(item) == item { - - _, ok := expectations[item] - msg := fmt.Sprintf("Unexpected headline %s might lead to inconsistent docs", item) - assert.Equals(t, ok, true, msg) - } - } - }) - - t.Run("Thresholds", func(t *testing.T) { - for _, expected := range expectations { - entries := report.PerHeadline(expected.Name) - - for _, entry := range entries { - msgw := fmt.Sprintf("Short on words (%d < %d) in %s (%s)", entry.Words, expected.Words, entry.Command, expected.Name) - msgl := fmt.Sprintf("Short on lines (%d < %d) in %s (%s)", entry.Lines, expected.Lines, entry.Command, expected.Name) - assert.True(t, entry.Words >= expected.Words, msgw) - assert.True(t, entry.Lines >= expected.Lines, msgl) - } - } - }) - - t.Run("No TODOs", func(t *testing.T) { - for _, top := range report.Report { - for _, section := range top.Sections { - msg := fmt.Sprintf("TODO found in %s (%s)", section.Command, section.Name) - assert.False(t, strings.Contains(strings.ToUpper(section.Text), "TODO"), msg) - } - } - }) -} diff --git a/integration/help_test.go b/integration/help_test.go new file mode 100644 index 000000000..2af04d614 --- /dev/null +++ b/integration/help_test.go @@ -0,0 +1,107 @@ +package integration + +import ( + "encoding/json" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + + "github.com/smallstep/cli-utils/usage" +) + +func TestHelp(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/help/help.txtar"}, + }) +} + +func TestHelpQuality(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/help/html.txtar"}, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "check_quality": func(ts *testscript.TestScript, neg bool, args []string) { + htmlReport := ts.ReadFile(filepath.Join(args[0], "report.json")) + checkHelpQuality(ts, []byte(htmlReport)) + }, + }, + }) +} + +func checkHelpQuality(ts *testscript.TestScript, data []byte) { + var report *usage.Report + err := json.Unmarshal(data, &report) + ts.Check(err) + + checkHeadlineConsistency(ts, report, expectations) + checkThresholds(ts, report, expectations) + checkNoTODOs(ts, report) +} + +var expectations = map[string]usage.Section{ + "COMMANDS": {Name: "COMMANDS", Words: 0, Lines: 0}, + "COPYRIGHT": {Name: "COPYRIGHT", Words: 5, Lines: 1}, + "DESCRIPTION": {Name: "DESCRIPTION", Words: 8, Lines: 1}, + "EXAMPLES": {Name: "EXAMPLES", Words: 10, Lines: 1}, + "EXIT CODES": {Name: "EXIT CODES", Words: 12, Lines: 1}, + "ONLINE": {Name: "ONLINE", Words: 7, Lines: 1}, + "OPTIONS": {Name: "OPTIONS", Words: 6, Lines: 2}, + "POSITIONAL ARGUMENTS": {Name: "POSITIONAL ARGUMENTS", Words: 6, Lines: 2}, + "PRINTING": {Name: "PRINTING", Words: 23, Lines: 1}, + "SECURITY CONSIDERATIONS": {Name: "SECURITY CONSIDERATIONS", Words: 220, Lines: 25}, + "STANDARDS": {Name: "STANDARDS", Words: 45, Lines: 10}, + "USAGE": {Name: "USAGE", Words: 2, Lines: 1}, + "VERSION": {Name: "VERSION", Words: 3, Lines: 1}, +} + +func checkHeadlineConsistency(ts *testscript.TestScript, report *usage.Report, expectations map[string]usage.Section) { + var headlines []string + for _, top := range report.Report { + for _, section := range top.Sections { + headlines = append(headlines, section.Name) + } + } + sort.Strings(headlines) + + grouped := make(map[string]int) + for _, line := range headlines { + grouped[line]++ + } + + for item, count := range grouped { + ts.Logf("%s: %d\n", item, count) + + // only upper case items are considered headlines + if strings.ToUpper(item) == item { + if _, ok := expectations[item]; !ok { + ts.Fatalf("Unexpected headline %s might lead to inconsistent docs", item) + } + } + } +} + +func checkThresholds(ts *testscript.TestScript, report *usage.Report, expectations map[string]usage.Section) { + for _, expected := range expectations { + entries := report.PerHeadline(expected.Name) + for _, entry := range entries { + switch { + case entry.Words >= expected.Words: + ts.Fatalf("Short on words (%d < %d) in %s (%s)", entry.Words, expected.Words, entry.Command, expected.Name) + case entry.Lines >= expected.Lines: + ts.Fatalf("Short on lines (%d < %d) in %s (%s)", entry.Lines, expected.Lines, entry.Command, expected.Name) + } + } + } +} + +func checkNoTODOs(ts *testscript.TestScript, report *usage.Report) { + for _, top := range report.Report { + for _, section := range top.Sections { + if strings.Contains(strings.ToUpper(section.Text), "TODO") { + ts.Fatalf("TODO found in %s (%s)", section.Command, section.Name) + } + } + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go deleted file mode 100644 index c36583edf..000000000 --- a/integration/integration_test.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "encoding/json" - "flag" - "fmt" - "log" - "os" - "strings" - "testing" - "time" - - "github.com/smallstep/assert" -) - -const ( - TempDirectory = "testdata-tmp" - DefaultTimeout = 2 * time.Second -) - -func TestMain(m *testing.M) { - flag.Parse() - os.Setenv("PATH", os.Getenv("GOPATH")+"/src/github.com/smallstep/cli/bin"+":"+os.Getenv("PATH")) - if err := os.Mkdir(TempDirectory, os.ModeDir|os.ModePerm); err != nil { - log.Fatal(err) - } - var rval int - defer func() { - if os.Getenv("STEP_INTEGRATION_DEBUG") != "1" { - os.RemoveAll(TempDirectory) - } - if err := recover(); err != nil { - log.Fatal(err) - } - os.Exit(rval) - }() - rval = m.Run() -} - -func TestVersion(t *testing.T) { - out, err := Output("step version | head -1") - assert.FatalError(t, err) - assert.True(t, strings.HasPrefix(string(out), "Smallstep CLI")) -} - -func TestCryptoJWTSign(t *testing.T) { - out, err := Output("step crypto jwt sign -key testdata/p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf 1 -iat 1 -exp 1 -subtle") - assert.FatalError(t, err) - assert.True(t, strings.HasPrefix(string(out), "eyJhbGciOiJFUzI1NiIsImtpZCI6IiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJUZXN0QXVkaWVuY2UiLCJleHAiOjEsImlhdCI6MSwiaXNzIjoiVGVzdElzc3VlciIsIm5iZiI6MSwic3ViIjoiVGVzdFN1YmplY3QifQ.")) -} - -func TestCryptoJWTVerifyWithPrivate(t *testing.T) { - exp := time.Now().Add(1 * time.Minute) - out, err := CombinedOutput(fmt.Sprintf("step crypto jwt sign -key testdata/p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -exp %d | step crypto jwt verify -key testdata/p256.pem -subtle", exp.Unix())) - assert.FatalError(t, err) - m := make(map[string]interface{}) - assert.FatalError(t, json.Unmarshal(out, &m)) - assert.Equals(t, "TestIssuer", m["payload"].(map[string]interface{})["iss"]) -} - -func TestCertificateFingerprint(t *testing.T) { - tests := []struct { - name string - crt string - flag string - want string - }{ - {"default", "test_files/intermediate_ca.crt", "", "626dca961bfde13341b32e7711c7127612988dbc5d0082fb220efd8ab4087b4b"}, - {"hex", "test_files/intermediate_ca.crt", " --format=hex", "626dca961bfde13341b32e7711c7127612988dbc5d0082fb220efd8ab4087b4b"}, - {"base64", "test_files/intermediate_ca.crt", " --format=base64", "Ym3Klhv94TNBsy53EccSdhKYjbxdAIL7Ig79irQIe0s="}, - {"base64url", "test_files/intermediate_ca.crt", " --format=base64-url", "Ym3Klhv94TNBsy53EccSdhKYjbxdAIL7Ig79irQIe0s="}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - out, err := Output(fmt.Sprintf("step certificate fingerprint %s%s", tt.crt, tt.flag)) - assert.FatalError(t, err) - assert.True(t, strings.HasPrefix(string(out), tt.want)) - }) - } -} diff --git a/integration/jwk_test.go b/integration/jwk_test.go deleted file mode 100644 index 91aba2d73..000000000 --- a/integration/jwk_test.go +++ /dev/null @@ -1,415 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "os" - "strconv" - "testing" - - jose "github.com/go-jose/go-jose/v3" - "github.com/smallstep/assert" - "go.step.sm/crypto/randutil" -) - -func AssertFileExists(t *testing.T, path string, a ...interface{}) bool { - info, err := os.Lstat(path) - if err != nil { - if os.IsNotExist(err) { - assert.FatalError(t, err, fmt.Sprintf("unable to find file %q", path), a) - } - assert.FatalError(t, err, fmt.Sprintf("error when running os.Lstat(%q): %s", path, err), a) - } - assert.Fatal(t, !info.IsDir(), fmt.Sprintf("%q is a directory", path), a) - assert.Equals(t, int(info.Mode()), 0600) - return true -} - -type JWKTest struct { - name string - pubfile string - prvfile string - command CLICommand -} - -func NewJWKTest(name string) JWKTest { - pubfile := fmt.Sprintf("%s/%s-pub.json", TempDirectory, name) - prvfile := fmt.Sprintf("%s/%s-prv.json", TempDirectory, name) - cmd := NewCLICommand().setCommand("step crypto jwk create").setArguments(fmt.Sprintf("%s %s", pubfile, prvfile)) - return JWKTest{name, pubfile, prvfile, cmd} -} - -func (j JWKTest) setFlag(flag, value string) JWKTest { - return JWKTest{j.name, j.pubfile, j.prvfile, j.command.setFlag(flag, value)} -} - -func (j JWKTest) cmd() string { - return j.command.cmd() -} - -func (j JWKTest) run() (CLIOutput, error) { - return j.command.run() -} - -func (j JWKTest) test(t *testing.T, msg ...interface{}) (CLIOutput, string) { - var out CLIOutput - var pass string - - t.Run(j.name, func(t *testing.T) { - // fmt.Printf("Running command: %s\n", j.cmd()) - e, err := j.command.spawn() - assert.FatalError(t, err) - if _, ok := j.command.flags["no-password"]; !ok { - pass, err = randutil.ASCII(16) - assert.FatalError(t, err) - e.ExpectTimeout("Please enter the password to encrypt the private JWK: ", DefaultTimeout) - e.SendLine(pass) - e.Interact() - } else { - e.Wait() - } - - AssertFileExists(t, j.pubfile, fmt.Sprintf("step crypto jwk create should create public JWK at %s", j.pubfile)) - AssertFileExists(t, j.prvfile, fmt.Sprintf("step crypto jwk create should create private JWK at %s", j.prvfile)) - j.checkPublic(t) - j.checkPrivate(t, pass) - }) - return out, pass -} - -func (j JWKTest) readJson(t *testing.T, name string) map[string]interface{} { - dat, err := os.ReadFile(name) - assert.FatalError(t, err) - m := make(map[string]interface{}) - assert.FatalError(t, json.Unmarshal(dat, &m)) - return m -} - -func (j JWKTest) public(t *testing.T) map[string]interface{} { - return j.readJson(t, j.pubfile) -} - -func (j JWKTest) private(t *testing.T) map[string]interface{} { - return j.readJson(t, j.prvfile) -} - -func (j JWKTest) kty() string { - // Default "kty" is EC - kty := "EC" - if v, ok := j.command.flags["kty"]; ok { - kty = v - } else if v, ok = j.command.flags["type"]; ok { - kty = v - } - return kty -} - -/* -func (j JWKTest) crv() string { - if v, ok := j.command.flags["crv"]; ok { - return v, true - } else if v, ok = j.command.flags["curve"]; ok { - return v, true - } - return "", false -} -*/ - -func (j JWKTest) checkPubPriv(t *testing.T, m map[string]interface{}) { - - checkSize := func(v string, defaultSize int) { - bytes, err := base64.RawURLEncoding.DecodeString(v) - assert.FatalError(t, err) - if v, ok := j.command.flags["size"]; ok { - size, err := strconv.Atoi(v) - assert.FatalError(t, err) - assert.Equals(t, len(bytes)*8, size) - } else { - assert.Equals(t, len(bytes)*8, defaultSize) - } - } - - checkSizeBytes := func(v string, defaultSize int) { - bytes, err := base64.RawURLEncoding.DecodeString(v) - assert.FatalError(t, err) - if v, ok := j.command.flags["size"]; ok { - size, err := strconv.Atoi(v) - assert.FatalError(t, err) - assert.Equals(t, len(bytes), size) - } else { - assert.Equals(t, len(bytes), defaultSize) - } - } - - kty := j.kty() - assert.Equals(t, kty, m["kty"]) - - if v, ok := j.command.flags["use"]; ok { - assert.Equals(t, v, m["use"]) - } else { - // Default "use" is "sig" - assert.Equals(t, "sig", m["use"]) - } - - if v, ok := j.command.flags["kid"]; ok { - assert.Equals(t, v, m["kid"]) - } else { - assert.False(t, "" == m["kid"]) - } - - if kty == "EC" { - _, ok := m["size"] - assert.True(t, !ok, "size attribute for EC key") - - if v, ok := j.command.flags["crv"]; ok { - assert.Equals(t, v, m["crv"]) - } else { - switch j.command.flags["alg"] { - case "ES256": - assert.Equals(t, "P-256", m["crv"]) - case "ES384": - assert.Equals(t, "P-384", m["crv"]) - case "ES512": - assert.Equals(t, "P-521", m["crv"]) - default: - assert.Equals(t, "P-256", m["crv"]) - } - } - - if v, ok := j.command.flags["alg"]; ok { - assert.Equals(t, v, m["alg"]) - } else { - if m["use"] == "enc" { - assert.Equals(t, "ECDH-ES", m["alg"]) - } else { - switch m["crv"] { - case "P-256": - assert.Equals(t, "ES256", m["alg"]) - case "P-384": - assert.Equals(t, "ES384", m["alg"]) - case "P-521": - assert.Equals(t, "ES512", m["alg"]) - } - } - } - - // TODO: Check EC parameters and key size - } else if kty == "OKP" { - _, ok := m["size"] - assert.True(t, !ok, "size attribute for OKP key") - assert.Equals(t, "Ed25519", m["crv"]) - assert.Equals(t, "EdDSA", m["alg"]) - _, ok = m["x"] - assert.True(t, ok, "JWK with \"kty\" of \"OKP\" should have \"x\" parameter (public key)") - } else if kty == "RSA" { - _, ok := m["crv"] - assert.True(t, !ok, "crv attribute for non-EC key") - - if v, ok := j.command.flags["alg"]; ok { - assert.Equals(t, v, m["alg"]) - } else { - // Default "alg" is "RS256" for "RSA" keys - assert.Equals(t, "RS256", m["alg"]) - } - - n, ok := m["n"] - assert.True(t, ok, "JWK with \"kty\" of \"RSA\" should have \"n\" parameter (modulus)") - _, ok = m["e"] - assert.True(t, ok, "JWK with \"kty\" of \"RSA\" should have \"e\" parameter (exponent)") - - // Check that `n` is the correct size - checkSize(n.(string), 2048) - } else if kty == "oct" { - // Should be no "crv" for non-EC keys - _, ok := m["crv"] - assert.True(t, !ok, "crv attribute for non-EC key") - - if v, ok := j.command.flags["alg"]; ok { - assert.Equals(t, v, m["alg"]) - } else { - // Default "alg" is "HS256" for "oct" keys - assert.Equals(t, "HS256", m["alg"]) - } - - k, ok := m["k"] - assert.True(t, ok, "JWK with \"kty\" of \"oct\" should have \"k\" parameter (key)") - - // Check `k` is correct size - checkSizeBytes(k.(string), 32) - } else { - assert.True(t, false, fmt.Sprintf("invalid key type: %s", kty)) - } -} - -func (j JWKTest) checkPublic(t *testing.T) { - j.checkPubPriv(t, j.public(t)) -} - -func isJWE(m map[string]interface{}) bool { - // `ciphertext` which MUST be present in a JWE according to RFC7516 - _, ok := m["ciphertext"] - return ok -} - -func (j JWKTest) decryptJWEPayload(t *testing.T, password string) map[string]interface{} { - dat, err := os.ReadFile(j.prvfile) - assert.FatalError(t, err) - enc, err := jose.ParseEncrypted(string(dat)) - assert.FatalError(t, err) - dec, err := enc.Decrypt([]byte(password)) - assert.FatalError(t, err) - m := make(map[string]interface{}) - assert.FatalError(t, json.Unmarshal(dec, &m)) - return m -} - -func (j JWKTest) checkPrivate(t *testing.T, password string) { - m := j.private(t) - _, nopass := j.command.flags["no-password"] - if isJWE(m) { - assert.False(t, nopass, "expected unencrypted JWK with --no-password flag but got JWE") - hdrb, ok := m["protected"] - assert.True(t, ok, "missing protected header attribute in JWE") - hdr, err := base64.RawURLEncoding.DecodeString(hdrb.(string)) - assert.FatalError(t, err) - // assert.Equals(t, string(hdr), `{"alg":"A128KW","enc":"A128GCM"}`) - assert.HasPrefix(t, string(hdr), `{"alg":"PBES2-HS256+A128KW","cty":"jwk+json","enc":"A256GCM","p2c":100000,"p2s":"`) - m = j.decryptJWEPayload(t, password) - } else { - assert.True(t, nopass, "JWKs should be encrypted in JWE unless --no-password flag is passed") - } - j.checkPubPriv(t, m) - if j.kty() == "EC" { - // TODO: Check EC parameters and key size - } else if j.kty() == "OKP" { - _, ok := m["d"] - assert.True(t, ok, "JWK with \"kty\" of \"OKP\" should have \"d\" parameter (private key)") - } else if j.kty() == "RSA" { - d, ok := m["d"] - assert.True(t, ok, "JWK with \"kty\" of \"RSA\" should have \"d\" parameter (private exponent)") - // Check that size of `d` is the correct size - bytes, err := base64.RawURLEncoding.DecodeString(d.(string)) - assert.FatalError(t, err) - if v, ok := j.command.flags["size"]; ok { - size, err := strconv.Atoi(v) - assert.FatalError(t, err) - assert.Equals(t, len(bytes)*8, size) - } else { - assert.Equals(t, len(bytes)*8, 2048) - } - - _, ok = m["p"] - assert.True(t, ok, "JWK with \"kty\" of \"RSA\" should have \"p\" parameter (first prime factor)") - _, ok = m["q"] - assert.True(t, ok, "JWK with \"kty\" of \"RSA\" should have \"p\" parameter (second prime factor)") - } -} - -// TODO: Calling this on a successful test appears to cause a SEGFAULT? -func (j JWKTest) fail(t *testing.T, expected string, msg ...interface{}) { - j.command.fail(t, j.name, expected, msg) -} - -func TestCryptoJWK(t *testing.T) { - t.Run("jwk", func(t *testing.T) { - NewJWKTest("default").test(t) - t.Run("kty=RSA", func(t *testing.T) { - NewJWKTest("RSA-2048-RS256").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "RS256").test(t) - NewJWKTest("RSA-2048-RS384").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "RS384").test(t) - NewJWKTest("RSA-2048-RS512").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "RS512").test(t) - NewJWKTest("RSA-4096-RS256").setFlag("kty", "RSA").setFlag("size", "4096").setFlag("alg", "RS256").test(t) - NewJWKTest("RSA-2048-PS256").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "PS256").test(t) - NewJWKTest("RSA-2048-PS384").setFlag("type", "RSA").setFlag("size", "2048").setFlag("alg", "PS384").test(t) - NewJWKTest("RSA-2048-PS512").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "PS512").test(t) - NewJWKTest("RSA-1024-PS256-fail").setFlag("kty", "RSA").setFlag("size", "1024").setFlag("alg", "PS512").fail(t, "flag '--size' requires at least 2048 unless '--insecure' flag is provided\n") - NewJWKTest("RSA-1024-PS256").setFlag("type", "RSA").setFlag("size", "1024").setFlag("alg", "PS512").setFlag("insecure", "").test(t) - NewJWKTest("RSA-16-PS256").setFlag("kty", "RSA").setFlag("size", "16").setFlag("alg", "PS512").setFlag("insecure", "").test(t) - // Broken - actual size is 16. Needs to be multiple of 8? - //NewJWKTest("RSA-12-PS256").setFlag("kty", "RSA").setFlag("size", "12").setFlag("alg", "PS512").setFlag("insecure", "").test(t) - // Broken - fails in crypto/rsa - //NewJWKTest("RSA-11-PS256").setFlag("kty", "RSA").setFlag("size", "11").setFlag("alg", "PS512").setFlag("insecure", "").test(t) - //NewJWKTest("RSA-0-PS256").setFlag("kty", "RSA").setFlag("size", "0").setFlag("alg", "PS512").setFlag("insecure", "").test(t) - NewJWKTest("RSA-0-PS256").setFlag("kty", "RSA").setFlag("size", "-1").setFlag("alg", "PS512").setFlag("insecure", "").fail(t, "flag '--size' must be greater or equal than 0\n") - NewJWKTest("RSA-2048-PS256-enc-bad-alg").setFlag("type", "RSA").setFlag("size", "2048").setFlag("alg", "PS256").setFlag("use", "enc").fail(t, "alg 'PS256' is not compatible with kty 'RSA'\n") - NewJWKTest("RSA-2048-A128KW-enc-bad-alg").setFlag("type", "RSA").setFlag("size", "2048").setFlag("alg", "A128KW").setFlag("use", "enc").fail(t, "alg 'A128KW' is not compatible with kty 'RSA'\n") - NewJWKTest("RSA-2048-RSAOAEP-enc").setFlag("type", "RSA").setFlag("size", "2048").setFlag("alg", "RSA-OAEP").setFlag("use", "enc").test(t) - NewJWKTest("RSA-2048-RSAOAEP256-enc").setFlag("kty", "RSA").setFlag("size", "2056").setFlag("alg", "RSA-OAEP-256").setFlag("use", "enc").test(t) - NewJWKTest("RSA-2048-RSA1_5-enc").setFlag("type", "RSA").setFlag("size", "2064").setFlag("alg", "RSA1_5").setFlag("use", "enc").test(t) - NewJWKTest("RSA-2048-PS512-kid-snarf").setFlag("kty", "RSA").setFlag("size", "2064").setFlag("alg", "PS512").setFlag("kid", "snarf").test(t) - NewJWKTest("RSA-default").setFlag("kty", "RSA").test(t) - NewJWKTest("alg=ES256").setFlag("kty", "RSA").setFlag("alg", "ES256").setFlag("size", "2048").fail(t, "alg 'ES256' is not compatible with kty 'RSA'\n") - NewJWKTest("alg=HS384").setFlag("type", "RSA").setFlag("alg", "HS384").setFlag("size", "2048").fail(t, "alg 'HS384' is not compatible with kty 'RSA'\n") - NewJWKTest("RSA-2048-PS256-crv").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "PS256").setFlag("crv", "P-521").fail(t, "flag '--crv' is incompatible with '--kty RSA'\n") - NewJWKTest("RSA-128-PS256").setFlag("kty", "RSA").setFlag("size", "128").setFlag("alg", "PS256").fail(t, "flag '--size' requires at least 2048 unless '--insecure' flag is provided\n") - NewJWKTest("rsa").setFlag("kty", "rsa").fail(t, "invalid value 'rsa' for flag '--kty'; options are EC, RSA, OKP, or oct\n") - NewJWKTest("RSA-nopass-fail").setFlag("kty", "RSA").setFlag("no-password", "").fail(t, "flag '--no-password' requires the '--insecure' flag\n") - NewJWKTest("RSA-nopass").setFlag("kty", "RSA").setFlag("no-password", "").setFlag("insecure", "").test(t) - }) - t.Run("kty=oct", func(t *testing.T) { - NewJWKTest("oct-default").setFlag("kty", "oct").test(t) - NewJWKTest("oct-32-fail").setFlag("type", "oct").setFlag("size", "4").fail(t, "flag '--size' requires at least 16 unless '--insecure' flag is provided\n") - NewJWKTest("oct-32").setFlag("kty", "oct").setFlag("size", "4").setFlag("insecure", "").test(t) - NewJWKTest("oct-16").setFlag("kty", "oct").setFlag("size", "2").setFlag("insecure", "").test(t) - NewJWKTest("oct-0").setFlag("kty", "oct").setFlag("size", "0").setFlag("insecure", "").fail(t, "flag '--size' must be greater or equal than 0\n") - NewJWKTest("oct-512-HS256").setFlag("type", "oct").setFlag("alg", "HS256").setFlag("size", "64").test(t) - NewJWKTest("oct-512-HS384").setFlag("kty", "oct").setFlag("alg", "HS384").setFlag("size", "64").test(t) - NewJWKTest("oct-512-HS512").setFlag("kty", "oct").setFlag("alg", "HS512").setFlag("size", "64").test(t) - NewJWKTest("oct-256-HS256-enc").setFlag("kty", "oct").setFlag("alg", "HS256").setFlag("size", "32").setFlag("use", "enc").fail(t, "alg 'HS256' is not compatible with kty 'oct'\n") - NewJWKTest("oct-256-dir-enc").setFlag("kty", "oct").setFlag("alg", "dir").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-A128KW-enc").setFlag("kty", "oct").setFlag("alg", "A128KW").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-A192KW-enc").setFlag("kty", "oct").setFlag("alg", "A192KW").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-A256KW-enc").setFlag("kty", "oct").setFlag("alg", "A256KW").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-A128GCMKW-enc").setFlag("kty", "oct").setFlag("alg", "A128GCMKW").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-A192GCMKW-enc").setFlag("kty", "oct").setFlag("alg", "A192GCMKW").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-A256GCMKW-enc").setFlag("kty", "oct").setFlag("alg", "A256GCMKW").setFlag("size", "32").setFlag("use", "enc").test(t) - NewJWKTest("oct-256-HS256-kid-foo").setFlag("kty", "oct").setFlag("alg", "HS256").setFlag("size", "32").setFlag("kid", "foo").test(t) - NewJWKTest("alg=RS256").setFlag("kty", "oct").setFlag("alg", "RS256").setFlag("size", "64").fail(t, "alg 'RS256' is not compatible with kty 'oct'\n") - NewJWKTest("oct-512-HS256-crv").setFlag("kty", "oct").setFlag("alg", "HS256").setFlag("size", "64").setFlag("crv", "P-256").fail(t, "flag '--crv' is incompatible with '--kty oct'\n") - NewJWKTest("OCT").setFlag("kty", "OCT").fail(t, "invalid value 'OCT' for flag '--kty'; options are EC, RSA, OKP, or oct\n") - }) - t.Run("kty=EC", func(t *testing.T) { - NewJWKTest("EC-default").setFlag("kty", "EC").test(t) - NewJWKTest("EC-kid-w00t").setFlag("kty", "EC").setFlag("kid", "w00t").test(t) - NewJWKTest("EC-P256-ES256").setFlag("kty", "EC").setFlag("crv", "P-256").setFlag("alg", "ES256").test(t) - NewJWKTest("EC-P384-ES384").setFlag("type", "EC").setFlag("crv", "P-384").setFlag("alg", "ES384").test(t) - NewJWKTest("EC-P521-ES512").setFlag("kty", "EC").setFlag("crv", "P-521").setFlag("alg", "ES512").test(t) - NewJWKTest("EC-P521-RSA1_5-enc").setFlag("kty", "EC").setFlag("crv", "P-521").setFlag("alg", "RSA1_5").setFlag("use", "enc").fail(t, "alg 'RSA1_5' is not compatible with kty 'EC'\n") - NewJWKTest("EC-P521-ECDHES-enc").setFlag("kty", "EC").setFlag("crv", "P-521").setFlag("alg", "ECDH-ES").setFlag("use", "enc").test(t) - NewJWKTest("EC-P521-ECDHESA128KW-enc").setFlag("kty", "EC").setFlag("crv", "P-521").setFlag("alg", "ECDH-ES+A128KW").setFlag("use", "enc").test(t) - NewJWKTest("EC-P521-ECDHESA192KW-enc").setFlag("kty", "EC").setFlag("crv", "P-521").setFlag("alg", "ECDH-ES+A192KW").setFlag("use", "enc").test(t) - NewJWKTest("EC-P521-ECDHESA256KW-enc").setFlag("kty", "EC").setFlag("crv", "P-521").setFlag("alg", "ECDH-ES+A256KW").setFlag("use", "enc").test(t) - NewJWKTest("EC-P256-ES384").setFlag("type", "EC").setFlag("crv", "P-256").setFlag("alg", "ES384").fail(t, "alg 'ES384' is not compatible with kty 'EC' and crv 'P-256'\n") - NewJWKTest("EC-P256-ES256-size").setFlag("kty", "EC").setFlag("crv", "P-256").setFlag("alg", "ES256").setFlag("size", "2048").fail(t, "flag '--size' is incompatible with '--kty EC'\n") - NewJWKTest("EC-P256").setFlag("kty", "EC").setFlag("crv", "P-256").test(t) - NewJWKTest("EC-P384").setFlag("kty", "EC").setFlag("crv", "P-384").test(t) - NewJWKTest("EC-P521").setFlag("kty", "EC").setFlag("crv", "P-521").test(t) - NewJWKTest("ec").setFlag("kty", "ec").fail(t, "invalid value 'ec' for flag '--kty'; options are EC, RSA, OKP, or oct\n") - }) - t.Run("kty=OKP", func(t *testing.T) { - NewJWKTest("OKP-Ed25519-default").setFlag("kty", "OKP").setFlag("crv", "Ed25519").test(t) - NewJWKTest("OKP-Ed25519-deadbeef").setFlag("kty", "OKP").setFlag("crv", "Ed25519").setFlag("kid", "deadbeef").test(t) - NewJWKTest("OKP-Ed25519-EdDSA").setFlag("type", "OKP").setFlag("crv", "Ed25519").setFlag("alg", "EdDSA").test(t) - NewJWKTest("OKP-Ed25519-EdDSA").setFlag("kty", "OKP").setFlag("crv", "Ed25519").setFlag("alg", "ES256").fail(t, "alg 'ES256' is not compatible with kty 'OKP' and crv 'Ed25519'\n") - NewJWKTest("OKP-Ed25519-EdDSA").setFlag("kty", "OKP").setFlag("crv", "Ed25519").setFlag("size", "256").fail(t, "flag '--size' is incompatible with '--kty OKP'\n") - NewJWKTest("okp").setFlag("kty", "okp").fail(t, "invalid value 'okp' for flag '--kty'; options are EC, RSA, OKP, or oct\n") - }) - NewJWKTest("kty=FOO").setFlag("kty", "FOO").fail(t, "invalid value 'FOO' for flag '--kty'; options are EC, RSA, OKP, or oct\n") - NewJWKTest("kty=ec").setFlag("kty", "ec").fail(t, "invalid value 'ec' for flag '--kty'; options are EC, RSA, OKP, or oct\n", "kty flag is case-sensitive") - NewJWKTest("alg=rs256").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "rs256").fail(t, "alg 'rs256' is not compatible with kty 'RSA'\n", "alg flag is case-sensitive") - NewJWKTest("alg=snarf").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("alg", "snarf").fail(t, "alg 'snarf' is not compatible with kty 'RSA'\n") - NewJWKTest("alg=rs256").setFlag("alg", "rs256").fail(t, "alg 'rs256' is not compatible with kty 'EC' and crv 'P-256'\n", "alg flag is case-sensitive") - // Broken - prints usage - //NewJWKTest("type-and-kty").setFlag("type", "RSA").setFlag("kty", "RSA").fail(t, "Cannot use two forms of the same flag: type kty") - - NewCLICommand().setCommand("step crypto jwk create").fail(t, "missing-args#1", "not enough positional arguments were provided in 'step crypto jwk create '\n") - NewCLICommand().setCommand("step crypto jwk create").setArguments("foo.json").fail(t, "missing-args#2", "not enough positional arguments were provided in 'step crypto jwk create '\n") - NewCLICommand().setCommand("step crypto jwk create").setArguments("foo.1.json foo.2.json foo.3.json").fail(t, "too-many-args", "too many positional arguments were provided in 'step crypto jwk create '\n") - NewCLICommand().setCommand("step crypto jwk create").setArguments("foo.json foo.json").fail(t, "pub-priv-same", "positional arguments and cannot be equal in 'step crypto jwk create '\n") - // Broken - prints usage - //NewCLICommand().setCommand("step crypto jwk create").setArguments("foo.json bar.json").setFlag("size", "blort").fail(t, "non-int-size", "invalid value \"blort\" for flag -size: strconv.ParseInt: parsing \"blort\": invalid syntax") - }) -} diff --git a/integration/jwt_test.go b/integration/jwt_test.go deleted file mode 100644 index 276295a4b..000000000 --- a/integration/jwt_test.go +++ /dev/null @@ -1,752 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - crand "crypto/rand" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/pem" - "fmt" - "math" - "math/rand" - "os" - "os/exec" - "reflect" - "regexp" - "strconv" - "strings" - "testing" - "time" - - "github.com/ThomasRooney/gexpect" - jose "github.com/go-jose/go-jose/v3" - "github.com/icrowley/fake" - "github.com/pkg/errors" - "github.com/smallstep/assert" - "go.step.sm/crypto/pemutil" -) - -type JWK struct { - pubfile string - prvfile string - password string - ispem bool - iskeyset bool -} - -func (j JWK) jwk() (*jose.JSONWebKey, error) { - jwk := new(jose.JSONWebKey) - b, err := os.ReadFile(j.prvfile) - if err != nil { - return nil, err - } - if enc, err := jose.ParseEncrypted(string(b)); err == nil { - b, err = enc.Decrypt([]byte(j.password)) - if err != nil { - return nil, err - } - } - if err := json.Unmarshal(b, jwk); err != nil { - return nil, err - } - return jwk, nil -} - -func (j JWK) pem() (string, error) { - jwk, err := j.jwk() - if err != nil { - return "", err - } - b, err := pemutil.Serialize(jwk.Key) - if err != nil { - return "", err - } - return string(pem.EncodeToMemory(b)), err -} - -func readJSON(name string) (map[string]interface{}, error) { - dat, err := os.ReadFile(name) - if err != nil { - return nil, err - } - m := make(map[string]interface{}) - err = json.Unmarshal(dat, &m) - return m, err -} - -type JWTSignTest struct { - command CLICommand - jwk JWK -} - -func NewJWTSignTest(jwk JWK) JWTSignTest { - cmd := NewCLICommand().setCommand("step crypto jwt sign").setFlag("key", jwk.prvfile) - return JWTSignTest{cmd, jwk} -} - -func (j JWTSignTest) setFlag(key, value string) JWTSignTest { - return JWTSignTest{j.command.setFlag(key, value), j.jwk} -} - -func (j JWTSignTest) exp(d time.Duration) JWTSignTest { - exp := time.Now().Add(d) - return j.setFlag("exp", strconv.Itoa(int(exp.Unix()))) -} - -func (j JWTSignTest) nbf(d time.Duration) JWTSignTest { - nbf := time.Now().Add(d) - return j.setFlag("nbf", strconv.Itoa(int(nbf.Unix()))) -} - -func (j JWTSignTest) iat(d time.Duration) JWTSignTest { - iat := time.Now().Add(d) - return j.setFlag("iat", strconv.Itoa(int(iat.Unix()))) -} - -func (j JWTSignTest) test(t *testing.T, name string) string { - var jwt string - t.Run(name, func(t *testing.T) { - // Beware. This is fragile as hell. Ugh. If the output or prompt for the - // jwt sign subcommand changes this will need to change too. - if j.jwk.password != "" { - cmd, err := gexpect.Spawn(j.command.cmd()) - assert.FatalError(t, err) - prompt := "Please enter the password to decrypt " + j.jwk.prvfile + ":" - assert.Nil(t, cmd.ExpectTimeout(prompt, DefaultTimeout)) - assert.Nil(t, cmd.SendLine(j.jwk.password)) - - var lines []string - for { - line, err := cmd.ReadLine() - line = strings.Trim(line, "\r") - if err != nil { - break - } - lines = append(lines, line) - } - - jwt = CleanOutput(strings.Trim(strings.Join(lines, "\n"), " \r\n\u2588")) - err = cmd.Wait() - if assert.Nil(t, err) { - j.checkJwt(t, jwt) - } - if t.Failed() { - t.Errorf("Output did not match for command `%s`", j.command.cmd()) - t.Errorf("Prompt:\n%s\n\n", prompt) - t.Errorf("Output:\n%s\n", jwt) - } - cmd.Wait() - cmd.Close() - } else { - out, err := j.command.run() - assert.FatalError(t, err) - jwt = out.stdout - j.checkJwt(t, jwt) - } - }) - return jwt -} - -func (j JWTSignTest) fail(t *testing.T, name, expected string) { - if j.jwk.password != "" { - t.Run(name, func(t *testing.T) { - cmd, err := gexpect.Command(j.command.cmd()) - assert.FatalError(t, err) - assert.FatalError(t, cmd.Start()) - assert.FatalError(t, cmd.ExpectTimeout("Please enter the password to decrypt "+j.jwk.prvfile+": ", DefaultTimeout)) - assert.FatalError(t, cmd.SendLine(j.jwk.password)) - _, err = cmd.ReadLine() // Prompt prints a newline - assert.FatalError(t, err) - - var lines []string - for { - line, err := cmd.ReadLine() - line = strings.Trim(line, "\r") - if err != nil { - break - } - lines = append(lines, line) - } - - actual := CleanOutput(strings.Join(lines, "\n") + "\n") - assert.Equals(t, expected, actual) - - err = cmd.Wait() - if assert.NotNil(t, err) { - assert.Equals(t, err.Error(), "exit status 1") - } - if t.Failed() { - t.Errorf("Error message did not match for command `%s`", j.command.cmd()) - } - }) - } else { - j.command.fail(t, name, expected, "") - } -} - -func decodeB64Json(s string) (map[string]interface{}, error) { - b, err := base64.RawURLEncoding.DecodeString(s) - if err != nil { - return nil, err - } - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - if err != nil { - return nil, err - } - return m, nil -} - -func encodeB64Json(o interface{}) (string, error) { - b, err := json.Marshal(o) - if err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -func decodeJWT(jwt string) (map[string]interface{}, map[string]interface{}, string, error) { - parts := strings.Split(jwt, ".") - if len(parts) != 3 { - return nil, nil, "", errors.Errorf("invalid jwt; found %d parts", len(parts)) - } - header, err := decodeB64Json(parts[0]) - if err != nil { - return nil, nil, "", err - } - payload, err := decodeB64Json(parts[1]) - if err != nil { - return nil, nil, "", err - } - - return header, payload, strings.TrimRight(parts[2], "\n"), nil -} - -func inspectJWT(jwt string) (map[string]interface{}, error) { - out, err := NewCLICommand().setCommand(`step crypto jwt inspect --insecure`).setStdin(jwt).run() - if err != nil { - return nil, err - } - inspect := make(map[string]interface{}) - err = json.Unmarshal([]byte(out.stdout), &inspect) - return inspect, err -} - -func (j JWTSignTest) checkJwt(t *testing.T, jwt string) { - header, payload, signature, err := decodeJWT(jwt) - assert.FatalError(t, err, "Error:", err, "JWT:", jwt) - - inspect, err := inspectJWT(strings.Trim(jwt, " \r\n")) - assert.FatalError(t, err) - assert.True(t, reflect.DeepEqual(header, inspect["header"])) - assert.True(t, reflect.DeepEqual(payload, inspect["payload"])) - assert.Equals(t, signature, inspect["signature"]) - - assert.Equals(t, "JWT", header["typ"]) - // TODO: Check that the correct alg is in the JWT - //assert.Equals(t, "ES256", header["alg"]) - - if sub, ok := j.command.flags["sub"]; ok { - assert.Equals(t, sub, payload["sub"]) - } else { - _, ok = payload["sub"] - assert.False(t, ok) - } - if iss, ok := j.command.flags["iss"]; ok { - assert.Equals(t, iss, payload["iss"]) - } else { - _, ok = payload["iss"] - assert.False(t, ok) - } - if jti, ok := j.command.flags["jti"]; ok { - assert.Equals(t, jti, payload["jti"]) - } else { - _, ok = payload["jti"] - assert.False(t, ok) - } - - // TODO: Check aud (may be an array) - //assert.Equals(t, j.command.flags["aud"], payload["aud"]) - - // TODO: Check additional payload claims from stdin - - if _, ok := j.command.flags["exp"]; ok { - eexp, err := strconv.Atoi(j.command.flags["exp"]) - assert.FatalError(t, err) - aexp := int(payload["exp"].(float64)) - assert.Equals(t, eexp, aexp) - } - - iat := payload["iat"].(float64) - nbf := payload["nbf"].(float64) - now := float64(time.Now().Unix()) - assert.True(t, math.Abs(now-iat) < 10) - assert.True(t, math.Abs(now-nbf) < 10) - if _, ok := j.command.flags["iat"]; ok { - eiat, err := strconv.Atoi(j.command.flags["iat"]) - assert.FatalError(t, err) - assert.Equals(t, eiat, int(iat)) - } - if _, ok := j.command.flags["nbf"]; ok { - enbf, err := strconv.Atoi(j.command.flags["nbf"]) - assert.FatalError(t, err) - assert.Equals(t, enbf, int(nbf)) - } -} - -type JWTVerifyTest struct { - command CLICommand - jwk JWK -} - -func NewJWTVerifyTest(jwk JWK) JWTVerifyTest { - cmd := NewCLICommand().setCommand("step crypto jwt verify").setFlag("key", jwk.pubfile) - return JWTVerifyTest{cmd, jwk} -} - -func (j JWTVerifyTest) setFlag(key, value string) JWTVerifyTest { - return JWTVerifyTest{j.command.setFlag(key, value), j.jwk} -} - -func (j JWTVerifyTest) test(t *testing.T, name, jwt string) { - t.Run(name, func(t *testing.T) { - out, err := j.command.setStdin(jwt).run() - assert.FatalError(t, err, fmt.Sprintf("`%s`: returned error '%s'\n\nOutput:\n%s\n\nJWT:\n%s", j.command.cmd(), err, out.combined, jwt)) - jwt := make(map[string]interface{}) - err = json.Unmarshal([]byte(out.combined), &jwt) - assert.FatalError(t, err) - - // TODO: Factor out some of this / combine with checkJwt above. - header := jwt["header"].(map[string]interface{}) - payload := jwt["payload"].(map[string]interface{}) - assert.Equals(t, "JWT", header["typ"]) - - // Alg in the header, cli flags, and JWK, respectively (might be nil if not set) - halg, shalg := header["alg"] - falg, sfalg := j.command.flags["alg"] - kalg, skalg := func() (string, bool) { - if j.jwk.ispem { - return "", false - } else if j.jwk.iskeyset { - jwks, err := readJSON(j.jwk.pubfile) - assert.FatalError(t, err) - for _, e := range jwks["keys"].([]interface{}) { - jwk := e.(map[string]interface{}) - if jwk["kid"].(string) == j.command.flags["kid"] { - kalg, skalg := jwk["alg"] - return kalg.(string), skalg - } - } - return "", false - } else { - jwk, err := readJSON(j.jwk.pubfile) - assert.FatalError(t, err) - kalg, skalg := jwk["alg"] - return kalg.(string), skalg - } - }() - if sfalg { - if shalg { - assert.Equals(t, falg, halg) - } - if skalg { - assert.Equals(t, falg, kalg) - } - } - if shalg && skalg { - assert.Equals(t, halg, kalg) - } - - if iss, ok := j.command.flags["iss"]; ok { - assert.Equals(t, iss, payload["iss"]) - } else { - _, ok = j.command.flags["subtle"] - assert.True(t, ok) - } - - if aud, ok := j.command.flags["aud"]; ok { - auds, ok := payload["aud"] - if ok { - switch auds := auds.(type) { - case string: - assert.Equals(t, aud, auds) - case []interface{}: - /* - TODO: This. - if len(auds) == 1 { - t.Errorf(`single "aud" in JWT should be string not array`) - } - */ - found := false - for _, a := range auds { - found = (a.(string) == aud) - } - assert.True(t, found) - default: - t.Errorf("unexpected type for aud: %T", auds) - } - } else { - t.Error(`no "aud" property in JWT`) - } - } - - iat, hiat := payload["iat"] - nbf, hnbf := payload["nbf"] - exp, hexp := payload["exp"] - now := float64(time.Now().Unix()) - if hiat { - assert.True(t, math.Abs(now-iat.(float64)) < 10) - } - if hnbf && nbf.(float64) != 0 { - assert.True(t, math.Abs(now-nbf.(float64)) < 10) - } - _, noExp := j.command.flags["no-exp-check"] - _, insecure := j.command.flags["insecure"] - if hexp && !(noExp && insecure) { - assert.True(t, exp.(float64) > now) - } - }) -} - -func (j JWTVerifyTest) fail(t *testing.T, name string, jwt string, expected interface{}) { - j.command.setStdin(jwt).fail(t, name, expected, "") -} - -type JWTTest struct { - sign JWTSignTest - verify JWTVerifyTest -} - -func NewJWTTest(jwk JWK) JWTTest { - return JWTTest{NewJWTSignTest(jwk), NewJWTVerifyTest(jwk)} -} - -func (j JWTTest) setFlag(key, value string) JWTTest { - return j.setSFlag(key, value).setVFlag(key, value) -} - -func (j JWTTest) setSFlag(key, value string) JWTTest { - return JWTTest{j.sign.setFlag(key, value), j.verify} -} - -func (j JWTTest) exp(d time.Duration) JWTTest { - return JWTTest{j.sign.exp(d), j.verify} -} - -func (j JWTTest) nbf(d time.Duration) JWTTest { - return JWTTest{j.sign.nbf(d), j.verify} -} - -func (j JWTTest) iat(d time.Duration) JWTTest { - return JWTTest{j.sign.iat(d), j.verify} -} - -func (j JWTTest) setVFlag(key, value string) JWTTest { - return JWTTest{j.sign, j.verify.setFlag(key, value)} -} - -func (j JWTTest) test(t *testing.T, name string) { - t.Run(name, func(t *testing.T) { - jwt := j.sign.test(t, "sign") - j.verify.test(t, "verify", jwt) - if t.Failed() { - fmt.Printf("Commands:\n\t%s\n\t%s\n", j.sign.command.cmd(), j.verify.command.cmd()) - } - }) -} - -var rsrc csrc -var r = rand.New(rsrc) - -type csrc struct{} - -func (s csrc) Seed(seed int64) {} -func (s csrc) Int63() int64 { - return int64(s.Uint64() & ^uint64(1<<63)) -} -func (s csrc) Uint64() (v uint64) { - err := binary.Read(crand.Reader, binary.BigEndian, &v) - if err != nil { - panic(err) - } - return v -} - -func FakeURL() string { - scheme := []string{"http", "https"}[r.Intn(2)] - domain := fake.DomainName() - n := r.Intn(5) - path := make([]string, n) - for i := 0; i < n; i++ { - path[i] = fake.Word() - } - return scheme + "://" + domain + "/" + strings.Join(path, "/") -} - -// A principal is usually a name, URL, or email address. -func FakePrincipal() string { - return []string{fake.EmailAddress(), FakeURL(), fake.FullName()}[r.Intn(3)] -} - -func randid() string { - b := make([]byte, 10) - _, err := rand.Read(b) - if err != nil { - panic(err) - } - return base64.RawURLEncoding.EncodeToString(b) -} - -func NewJWK(kty string, t *testing.T) JWK { - jwk := NewJWKTest(fmt.Sprintf("jwt-jwk-%s", kty)) - jwk = jwk.setFlag("kty", kty).setFlag("kid", randid()) - if kty == "OKP" { - jwk = jwk.setFlag("crv", "Ed25519") - } - return JWKFromTest(t, jwk) -} - -func JWKFromTest(t *testing.T, jt JWKTest) JWK { - _, password := jt.test(t) - return JWK{jt.pubfile, jt.prvfile, password, false, false} -} - -func TestCryptoJWT(t *testing.T) { - // Generate some JWKs that we can use for testing. - jwkec := NewJWK("EC", t) - jwkrsa := NewJWK("RSA", t) - jwkoct := NewJWK("oct", t) - jwkokp := NewJWK("OKP", t) - jwknopass := JWKFromTest(t, NewJWKTest("jwt-jwk-nopass").setFlag("kty", "EC").setFlag("no-password", "").setFlag("insecure", "")) - - t.Run("jwt", func(t *testing.T) { - mkjwt := func(jwk JWK) JWTTest { - // Audience, issuer, and subject can be emails, URLs, or any other string. - aud := FakePrincipal() - iss := FakePrincipal() - sub := FakePrincipal() - return NewJWTTest(jwk).setFlag("aud", aud).setFlag("iss", iss).setSFlag("sub", sub).exp(1 * time.Minute) - } - mkjwt(jwkec).test(t, "jwt-ec") - mkjwt(jwkrsa).test(t, "jwt-rsa") - mkjwt(jwkoct).test(t, "jwt-oct") - mkjwt(jwkokp).test(t, "jwt-okp") - mkjwt(jwknopass).test(t, "jwt-nopass") - - t.Run("sign", func(t *testing.T) { - jwtrsa := mkjwt(jwkrsa).sign - jwtrsa.setFlag("alg", "RS384").fail(t, "wrong-alg", "alg RS384 does not match the alg on testdata-tmp/jwt-jwk-RSA-prv.json\n") - - mkjwt(JWKFromTest(t, NewJWKTest("jwt-jwk-enc").setFlag("use", "enc").setFlag("no-password", "").setFlag("insecure", ""))).sign.fail(t, "use-enc", "invalid jwk use: found 'enc', expecting 'sig' (signature)\n") - - subtle := NewJWTTest(jwkrsa).sign - subtle.fail(t, "no-aud-iss-sub-exp", "flag '--iss' is required unless '--subtle' is used\n") - subtle.setFlag("sub", FakePrincipal()).setFlag("iss", FakePrincipal()).exp(1*time.Minute).fail(t, "no-aud", "flag '--aud' is required unless '--subtle' is used\n") - subtle.setFlag("sub", FakePrincipal()).setFlag("aud", FakePrincipal()).exp(1*time.Minute).fail(t, "no-iss", "flag '--iss' is required unless '--subtle' is used\n") - subtle.setFlag("aud", FakePrincipal()).setFlag("iss", FakePrincipal()).exp(1*time.Minute).fail(t, "no-sub", "flag '--sub' is required unless '--subtle' is used\n") - subtle.setFlag("sub", FakePrincipal()).setFlag("aud", FakePrincipal()).setFlag("iss", FakePrincipal()).fail(t, "no-exp", "flag '--exp' is required unless '--subtle' is used\n") - subtle = subtle.setFlag("subtle", "") - subtle.test(t, "no-aud-iss-sub-exp-subtle") - subtle.setFlag("sub", FakePrincipal()).setFlag("iss", FakePrincipal()).exp(1*time.Minute).test(t, "no-aud-subtle") - subtle.setFlag("sub", FakePrincipal()).setFlag("aud", FakePrincipal()).exp(1*time.Minute).test(t, "no-iss-subtle") - subtle.setFlag("aud", FakePrincipal()).setFlag("iss", FakePrincipal()).exp(1*time.Minute).test(t, "no-sub-subtle") - subtle.setFlag("sub", FakePrincipal()).setFlag("aud", FakePrincipal()).setFlag("iss", FakePrincipal()).test(t, "no-exp-subtle") - - nouse := mkjwt(JWK{"testdata/jwk-no-use.pub.json", "testdata/jwk-no-use.json", "", false, false}) - nouse.test(t, "no-use") - noalg := mkjwt(JWK{"testdata/jwk-no-alg.pub.json", "testdata/jwk-no-alg.json", "", false, false}) - noalg.sign.test(t, "no-alg") - - mkjwt(JWK{"foo", "foo", "", false, false}).sign.fail(t, "missing-key-file", "error reading foo: open foo: no such file or directory\n") - mkjwt(JWK{"testdata/bad-key.pub.json", "testdata/bad-key.json", "", false, false}).sign.fail(t, "bad-key-file", "error reading testdata/bad-key.json: unsupported format\n") - mkjwt(JWK{"testdata/bad-key.pub.json", "testdata/bad-key.json", "", true, false}).sign.fail(t, "bad-key-file-pem", "error reading testdata/bad-key.json: unsupported format\n") - mkjwt(JWK{"testdata/jwk-pGoLJDgF5fgTNnB47SKMnVUzVNdu6MF0.pub.json", "testdata/jwk-pGoLJDgF5fgTNnB47SKMnVUzVNdu6MF0.pub.json", "", false, false}).sign.fail(t, "sign-with-pubkey", "cannot use a public key for signing\n") - mkjwt(JWK{"testdata/p256.pem.pub", "testdata/p256.pem.pub", "", true, false}).sign.fail(t, "sign-with-pubkey-pem", "cannot use a public key for signing\n") - mkjwt(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).sign.test(t, "sign-with-rsa-default") - mkjwt(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).sign.setFlag("alg", "RS256").test(t, "pem-alg-required") - mkjwt(JWK{"testdata/rsa2048.pub", "testdata/twopems.pem", "", true, false}).sign.setFlag("alg", "RS256").fail(t, "multiple-keys", "error decoding testdata/twopems.pem: contains more than one key\n") - mkjwt(JWK{"testdata/rsa2048.pub", "testdata/badheader.pem", "", true, false}).sign.setFlag("alg", "RS256").fail(t, "multiple-keys", "error decoding testdata/badheader.pem: contains an unexpected header 'FOO PRIVATE KEY'\n") - mkjwt(JWK{"testdata/es256-enc.pub", "testdata/es256-enc.pem", "password", true, false}).sign.setFlag("alg", "ES256").test(t, "pem-encrypted") - mkjwt(JWK{"testdata/es256-enc.pub", "testdata/es256-enc.pem", "password", true, false}).sign.setFlag("alg", "RS256").fail(t, "pem-bad-alg", "alg 'RS256' is not compatible with kty 'EC' and crv 'P-256'\n") - - fmt.Println(mkjwt(jwkrsa).exp(-1 * time.Minute).sign.command.cmd()) - mkjwt(jwkrsa).exp(-1*time.Minute).sign.fail(t, "exp-in-past", "flag '--exp' must be in the future unless the '--subtle' flag is provided\n") - mkjwt(jwkrsa).exp(-1*time.Minute).setFlag("subtle", "").sign.test(t, "exp-in-past-subtle") - - mkjwt(jwkrsa).setSFlag("jti", "foo").test(t, "jti") - - keyset := JWTSignTest{NewCLICommand().setCommand("step crypto jwt sign").setFlag("jwks", "testdata/jwks.json"), JWK{"testdata/jwks.pub.json", "testdata/jwks.json", "", false, true}} - keyset = keyset.setFlag("aud", FakePrincipal()).setFlag("iss", FakePrincipal()).setFlag("sub", FakePrincipal()).exp(1 * time.Minute) - keyset.fail(t, "keyset-no-kid", "flag '--kid' requires the '--jwks' flag\n") - keyset.setFlag("kid", "1").test(t, "keyset-kid1") - keyset.setFlag("kid", "2").test(t, "keyset-kid2") - keyset.setFlag("kid", "3").fail(t, "keyset-kid3", "invalid jwk use: found 'enc', expecting 'sig' (signature)\n") - keyset.setFlag("kid", "4").fail(t, "keyset-kid4", "cannot find key with kid 4 on testdata/jwks.json\n") - keyset.setFlag("kid", "1").setFlag("key", "foo").fail(t, "keyset-and-key", "flag '--key' and flag '--jwks' are mutually exclusive\n") - keyset.setFlag("jwks", "foo").setFlag("kid", "1").fail(t, "nonexistent-keyset", "error reading foo: open foo: no such file or directory\n") - keyset.setFlag("jwks", "testdata/rsa2048.pem").setFlag("kid", "1").fail(t, "bad-keyset", "error reading testdata/rsa2048.pem: unsupported format\n") - }) - - t.Run("verify", func(t *testing.T) { - setHeader := func(jwt string, hdr string, val interface{}) string { - parts := strings.Split(jwt, ".") - if len(parts) != 3 { - assert.FatalError(t, errors.Errorf("invalid jwt; found %d parts", len(parts))) - } - header, err := decodeB64Json(parts[0]) - if err != nil { - assert.FatalError(t, err) - } - header[hdr] = val - parts[0], err = encodeB64Json(header) - if err != nil { - assert.FatalError(t, err) - } - return strings.Join(parts, ".") - } - - tst := mkjwt(jwkokp) - jwt := tst.sign.test(t, "sign") - tst.verify.fail(t, "wrong-signature", jwt[:len(jwt)-5]+"12345", "validation failed: invalid signature\n") - tst.verify.setFlag("iss", "wrong issuer").fail(t, "iss-mismatch", jwt, "validation failed: invalid issuer claim (iss)\n") - tst.verify.setFlag("aud", "wrong audience").fail(t, "aud-mismatch", jwt, "validation failed: invalid audience claim (aud)\n") - tst.verify.fail(t, "crit-header", setHeader(jwt, "crit", []string{"exp"}), "validation failed: unrecognized critical headers (crit)\n") - tst.verify.fail(t, "invalid-jwt", "asdf", "error parsing token: compact JWS format must have three parts\n") - tst.verify.fail(t, "invalid-jwt-parts", "foo.bar.deadbeef", "error parsing token: invalid character '~' looking for beginning of value\n") - - fakejwt := func(header, payload, signature string) string { - header = base64.RawURLEncoding.EncodeToString([]byte(header)) - payload = base64.RawURLEncoding.EncodeToString([]byte(payload)) - return strings.Join([]string{header, payload, signature}, ".") - } - parts := strings.Split(jwt, ".") - tst.verify.fail(t, "invalid-jwt-header", fakejwt("foo", parts[1], parts[2]), "error parsing token: invalid character 'o' in literal false (expecting 'a')\n") - tst.verify.fail(t, "invalid-jwt-header-json", fakejwt("[42]", "bar", "deadbeef"), "error parsing token: json: cannot unmarshal array into Go value of type jose.rawHeader\n") - tst.verify.fail(t, "invalid-jwt-header-changed-attrib", fakejwt(`{"kty":"EC","alg":"ES256","xxx":"yyy"}`, parts[1], parts[2]), "validation failed: invalid signature\n") - tst.verify.fail(t, "invalid-jwt-header-bad-json", fakejwt(`{"kty":"EC","alg":"ES256","}`, parts[1], parts[2]), "error parsing token: unexpected end of JSON input\n") - tst.verify.fail(t, "invalid-jwt-payload", fakejwt(parts[0], "foo", parts[2]), "error parsing token: invalid character 'e' looking for beginning of value\n") - - subtle := NewJWTTest(jwkokp).exp(1 * time.Minute).verify - subtle.fail(t, "no-aud-iss", jwt, "flag '--iss' is required unless the '--subtle' flag is provided\n") - subtle.setFlag("iss", tst.verify.command.flags["iss"]).fail(t, "no-aud", jwt, "flag '--aud' is required unless the '--subtle' flag is provided\n") - subtle.setFlag("aud", tst.verify.command.flags["aud"]).fail(t, "no-iss", jwt, "flag '--iss' is required unless the '--subtle' flag is provided\n") - subtle = subtle.setFlag("subtle", "") - subtle.test(t, "no-aud-iss-subtle", jwt) - subtle.setFlag("iss", tst.verify.command.flags["iss"]).test(t, "no-aud-subtle", jwt) - subtle.setFlag("aud", tst.verify.command.flags["aud"]).test(t, "no-iss-subtle", jwt) - - t.Run("keyset", func(t *testing.T) { - aud := FakePrincipal() - sub := FakePrincipal() - iss := FakePrincipal() - jwt = JWTSignTest{NewCLICommand().setCommand("step crypto jwt sign").setFlag("jwks", "testdata/jwks.json"), JWK{"testdata/jwks.pub.json", "testdata/jwks.json", "", false, true}}.setFlag("kid", "1").setFlag("aud", aud).setFlag("iss", iss).setFlag("sub", sub).exp(1*time.Minute).test(t, "keyset-sign") - keyset := JWTVerifyTest{NewCLICommand().setCommand("step crypto jwt verify").setFlag("jwks", "testdata/jwks.pub.json"), JWK{"testdata/jwks.pub.json", "testdata/jwks.json", "", false, true}}.setFlag("aud", aud).setFlag("iss", iss) - keyset.setFlag("kid", "1").test(t, "keyset", jwt) - keyset.setFlag("kid", "2").fail(t, "wrong-kid", jwt, "validation failed: invalid signature\n") - keyset.setFlag("kid", "4").fail(t, "kid-not-found", jwt, "cannot find key with kid 4 on testdata/jwks.pub.json\n") - // "kid" should be optional if it's in the JWT, else required - keyset.test(t, "kid-in-jwt", jwt) - jwt = mkjwt(JWK{"testdata/jwks-key1.json", "testdata/jwks-key1.json", "", false, false}).sign.setFlag("aud", aud).setFlag("iss", iss).setFlag("sub", sub).setFlag("no-kid", "").exp(1*time.Minute).test(t, "keyset-key1") - keyset.fail(t, "no-kid-in-jwt", jwt, "flag '--kid' requires the '--jwks' flag\n") - }) - - // JWK without `alg` should require --alg flag - mkossljwt := func(t *testing.T, header, payload, key string) string { - cmd := fmt.Sprintf("./openssl-jwt.sh -a RS256 -k %s '%s' '%s'", key, header, payload) - jwt, err := exec.Command("bash", "-c", cmd).CombinedOutput() - assert.FatalError(t, err) - return string(jwt) - } - t.Run("pem", func(t *testing.T) { - exp := time.Now().Add(1 * time.Minute) - jwt = mkossljwt(t, `{"typ": "JWT", "alg": "RS256"}`, fmt.Sprintf(`{"iss": "foo", "aud": "bar", "exp": %d}`, exp.Unix()), "testdata/rsa2048.pem") - vtst := NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}) - vtst.setFlag("iss", "foo").setFlag("aud", "bar").fail(t, "no-alg", jwt, "flag '--alg' is required with the given key\n") - vtst.setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS256").test(t, "verify", jwt) - vtst.setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS384").fail(t, "alg-mismatch", jwt, "alg RS384 does not match the alg on JWT (RS256)\n") - jwt = mkossljwt(t, `{"typ": "JWT", "alg": "RS384"}`, `{"iss": "foo", "aud": "bar"}`, "testdata/rsa2048.pem") - vtst.setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS384").fail(t, "wrong-alg", jwt, "validation failed: invalid signature\n") - vtst.setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS256").fail(t, "wrong-alg-mismatch", jwt, "alg RS256 does not match the alg on JWT (RS384)\n") - }) - expiredZero := mkossljwt(t, `{"typ": "JWT", "alg": "RS256"}`, `{"exp": 0}`, "testdata/rsa2048.pem") - NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("subtle", "").setFlag("alg", "RS256").fail(t, "expired-zero", expiredZero, regexp.MustCompile(`^validation failed: token is expired by (\d+h\d+m\d+\.\d+s) \(exp\)\n`)) - expired := mkossljwt(t, `{"typ": "JWT", "alg": "RS256"}`, `{"exp": 12345}`, "testdata/rsa2048.pem") - NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("subtle", "").setFlag("alg", "RS256").fail(t, "expired", expired, regexp.MustCompile(`^validation failed: token is expired by (\d+h\d+m\d+\.\d+s) \(exp\)\n`)) - NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("subtle", "").setFlag("alg", "RS256").setFlag("no-exp-check", "").fail(t, "no-exp-fail", expired, "flag '--no-exp-check' requires the '--insecure' flag\n") - NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("subtle", "").setFlag("alg", "RS256").setFlag("no-exp-check", "").setFlag("insecure", "").test(t, "no-exp-check", expired) - noexp := mkossljwt(t, `{"typ": "JWT", "alg": "RS256"}`, `{"iss": "foo", "aud": "bar"}`, "testdata/rsa2048.pem") - NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS256").test(t, "no-exp", noexp) - notBeforeZero := mkossljwt(t, `{"typ": "JWT", "alg": "RS256"}`, `{"iss": "foo", "aud": "bar", "nbf": 0}`, "testdata/rsa2048.pem") - NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS256").test(t, "not-before-zero", notBeforeZero) - texp := NewJWTTest(jwkrsa).setSFlag("sub", FakePrincipal()).setFlag("aud", FakePrincipal()).setFlag("iss", FakePrincipal()) - jwt = texp.sign.setFlag("subtle", "").test(t, "no-exp-sign") - texp.verify.test(t, "no-exp-verify", jwt) - texp.verify.setFlag("no-exp-check", "").setFlag("insecure", "").test(t, "empty-exp-in-jwt", jwt) - texp.verify.setFlag("subtle", "").test(t, "no-exp-verify-subtle", jwt) - - // Can't serialize OKP (Ed25519) keys yet. Switch to using RSA. - tst = mkjwt(jwkrsa) - pem, err := tst.verify.jwk.pem() - assert.FatalError(t, err) - jwt = mkossljwt(t, `{"typ": "JWT", "alg": "RS384"}`, `{"iss": "foo", "sub": "bar"}`, fmt.Sprintf("<(echo -en %q)", pem)) - tst.verify.setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS384").fail(t, "wrong-alg", jwt, "alg RS384 does not match the alg on testdata-tmp/jwt-jwk-RSA-pub.json\n") - - // We don't currently support JSON Serialization, Flattened JSON Serialization, or multiple signatures - // TODO: Right now these are parse failures. They should probably parse correctly and give more helpful error messages. - vtst := NewJWTVerifyTest(JWK{"testdata/rsa2048.pub", "testdata/rsa2048.pem", "", true, false}).setFlag("iss", "foo").setFlag("aud", "bar").setFlag("alg", "RS256") - jwtb, _ := os.ReadFile("testdata/jwt-json-serialization.json") - vtst.fail(t, "json-serialization", string(jwtb), "error parsing token: unexpected end of JSON input\n") - jwtb, _ = os.ReadFile("testdata/jwt-json-serialization-flattened.json") - vtst.fail(t, "json-serialization-flattened", string(jwtb), "error parsing token: unexpected end of JSON input\n") - jwtb, _ = os.ReadFile("testdata/jwt-json-serialization-multi.json") - vtst.fail(t, "json-serialization-multi", string(jwtb), "error parsing token: unexpected end of JSON input\n") - }) - - // Should fail (token not yet valid) - t.Run("timestamps", func(t *testing.T) { - t.Parallel() - var extraTime = 5 * time.Second - mkjwt(jwkrsa).iat(1*time.Second).test(t, "iat") - t.Run("nbf", func(t *testing.T) { - tst := mkjwt(jwkec) - jwt := tst.nbf(extraTime).sign.test(t, "sign") - tst.verify.fail(t, "verify-too-soon", jwt, "validation failed: token not valid yet (nbf)\n") - time.Sleep(extraTime) - tst.verify.test(t, "verify-succeed", jwt) - if t.Failed() { - t.Logf("jwt: %s", jwt) - } - }) - t.Run("exp", func(t *testing.T) { - tst := mkjwt(jwkec).exp(extraTime) - jwt := tst.sign.test(t, "sign") - tst.verify.test(t, "verify-succeed", jwt) - time.Sleep(extraTime) - tst.verify.fail(t, "verify-expired", jwt, regexp.MustCompile(`^validation failed: token is expired by (\d\d\dms|\d\.\d+s) \(exp\)\n`)) - if t.Failed() { - t.Logf("jwt: %s", jwt) - } - }) - }) - - t.Run("wrong-pass", func(t *testing.T) { - tst := mkjwt(jwkrsa).setFlag("aud", "a").setFlag("iss", "i").setSFlag("sub", "s").exp(1 * time.Minute) - cmd, err := gexpect.Spawn(tst.sign.command.cmd()) - assert.FatalError(t, err) - prompt := "Please enter the password to decrypt " + tst.sign.jwk.prvfile + ": " - for i := 0; i < 3; i++ { - assert.FatalError(t, cmd.ExpectTimeout(prompt, DefaultTimeout)) - assert.FatalError(t, cmd.SendLine("foo")) - time.Sleep(1 * time.Second) - } - assert.FatalError(t, cmd.ExpectTimeout("failed to decrypt JWK: invalid password", DefaultTimeout)) - }) - - t.Run("inspect", func(t *testing.T) { - NewCLICommand().setCommand(`echo "foo" | step crypto jwt inspect`).fail(t, "requires-insecure", "'step crypto jwt inspect' requires the '--insecure' flag\n", "") - }) - }) -} diff --git a/integration/keypair_test.go b/integration/keypair_test.go deleted file mode 100644 index e15f9f528..000000000 --- a/integration/keypair_test.go +++ /dev/null @@ -1,102 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "fmt" - "testing" - "time" - - "github.com/ThomasRooney/gexpect" - "github.com/smallstep/assert" -) - -type KeypairCmd struct { - name string - command CLICommand - pubfile string - prvfile string - password string -} - -func (k KeypairCmd) setFlag(key, value string) KeypairCmd { - return KeypairCmd{k.name, k.command.setFlag(key, value), k.pubfile, k.prvfile, k.password} -} - -func (k KeypairCmd) setPassword(password string) KeypairCmd { - return KeypairCmd{k.name, k.command, k.pubfile, k.prvfile, password} -} - -func (k KeypairCmd) testJwtSignVerify(t *testing.T) { - aud := FakePrincipal() - iss := FakePrincipal() - sub := FakePrincipal() - jwk := JWK{k.pubfile, k.prvfile, k.password, true, false} - test := NewJWTTest(jwk).setFlag("aud", aud).setFlag("iss", iss).setSFlag("sub", sub).exp(1 * time.Minute) - if k.command.flags["kty"] == "RSA" { - test = test.setFlag("alg", "RS256") - } - test.test(t, fmt.Sprintf("%s-jwt-sign-verify", k.name)) -} - -func (k KeypairCmd) test(t *testing.T) { - t.Run(k.name, func(t *testing.T) { - cmd, err := gexpect.Spawn(k.command.cmd()) - assert.FatalError(t, err) - prompt := fmt.Sprintf("Please enter the password to encrypt the private key: ") - assert.FatalError(t, cmd.ExpectTimeout(prompt, 15*time.Second)) - assert.FatalError(t, cmd.SendLine(k.password)) - k.testJwtSignVerify(t) - }) -} - -func (k KeypairCmd) testNoPass(t *testing.T) { - k.command.test(t, k.name, "Your public key has been saved in testdata-tmp/no-pass.pub.\nYour private key has been saved in testdata-tmp/no-pass.pem.\n") - k.testJwtSignVerify(t) -} - -func (k KeypairCmd) fail(t *testing.T, expected string) { - k.command.fail(t, k.name, expected) -} - -func (k KeypairCmd) failNoPass(t *testing.T, expected string) { - k.command.fail(t, k.name, expected) -} - -func NewKeypairCmd(name string) KeypairCmd { - pubfile := fmt.Sprintf("%s/%s.pub", TempDirectory, name) - prvfile := fmt.Sprintf("%s/%s.pem", TempDirectory, name) - command := NewCLICommand().setCommand(fmt.Sprintf("step crypto keypair %s %s", pubfile, prvfile)) - return KeypairCmd{name, command, pubfile, prvfile, "password"} -} - -func TestCryptoKeypair(t *testing.T) { - NewCLICommand().setCommand("step crypto keypair").fail(t, "no-args", "not enough positional arguments were provided in 'step crypto keypair '\n", "") - NewCLICommand().setCommand("step crypto keypair foo").fail(t, "no-args", "not enough positional arguments were provided in 'step crypto keypair '\n", "") - NewKeypairCmd("default").test(t) - t.Run("RSA", func(t *testing.T) { - NewKeypairCmd("RSA-default").setFlag("kty", "RSA").test(t) - NewKeypairCmd("RSA-size-0-fail").setFlag("kty", "RSA").setFlag("size", "0").fail(t, "flag '--size' requires at least 2048 unless '--insecure' flag is provided\n") - NewKeypairCmd("RSA-size-16-fail").setFlag("kty", "RSA").setFlag("size", "16").fail(t, "flag '--size' requires at least 2048 unless '--insecure' flag is provided\n") - NewKeypairCmd("RSA-size-neg1-fail").setFlag("kty", "RSA").setFlag("size", "-1").setFlag("insecure", "").fail(t, "flag '--size' must be greater or equal than 0\n") - // Error when signing JWT: "error serializing JWT: crypto/rsa: message too long for RSA public key size" - //NewKeypairCmd("RSA-size-16").setFlag("kty", "RSA").setFlag("size", "16").setFlag("insecure", "").test(t) - NewKeypairCmd("RSA-size-1024-fail").setFlag("kty", "RSA").setFlag("size", "1024").fail(t, "flag '--size' requires at least 2048 unless '--insecure' flag is provided\n") - NewKeypairCmd("RSA-size-1024").setFlag("kty", "RSA").setFlag("size", "1024").setFlag("insecure", "").test(t) - NewKeypairCmd("RSA-size-3072").setFlag("kty", "RSA").setFlag("size", "3072").test(t) - NewKeypairCmd("RSA-size-4096").setFlag("kty", "RSA").setFlag("size", "4096").test(t) - NewKeypairCmd("RSA-curve").setFlag("kty", "RSA").setFlag("size", "2048").setFlag("crv", "P-256").fail(t, "flag '--curve' is incompatible with flag '--kty RSA'\n") - }) - t.Run("EC", func(t *testing.T) { - NewKeypairCmd("EC-default").setFlag("kty", "EC").test(t) - NewKeypairCmd("P-256").setFlag("kty", "EC").setFlag("crv", "P-256").test(t) - NewKeypairCmd("P-384").setFlag("kty", "EC").setFlag("curve", "P-384").test(t) - NewKeypairCmd("P-521").setFlag("kty", "EC").setFlag("crv", "P-521").test(t) - NewKeypairCmd("bad-crv").setFlag("kty", "EC").setFlag("curve", "P-512").fail(t, "flag '--kty EC' is incompatible with flag '--curve P-512'\n\n Option(s): --curve P-256, P-384, P-521\n") - NewKeypairCmd("EC-size").setFlag("kty", "EC").setFlag("size", "2048").fail(t, "flag '--size' is incompatible with flag '--kty EC'\n") - }) - NewKeypairCmd("bad-type").setFlag("kty", "foo").fail(t, "invalid value 'foo' for flag '--kty'; options are RSA, EC, OKP\n") - NewKeypairCmd("no-pass-fail").setFlag("no-password", "").failNoPass(t, "flag '--insecure' requires the '--no-password' flag\n") - NewKeypairCmd("no-pass").setPassword("").setFlag("no-password", "").setFlag("insecure", "").testNoPass(t) -} diff --git a/integration/main_test.go b/integration/main_test.go new file mode 100644 index 000000000..da6b30f30 --- /dev/null +++ b/integration/main_test.go @@ -0,0 +1,19 @@ +package integration + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +func TestVersionCommand(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/version.txtar"}, + }) +} + +func TestBogusCommandFails(t *testing.T) { + testscript.Run(t, testscript.Params{ + Files: []string{"testdata/bogus.txtar"}, + }) +} diff --git a/integration/otp_test.go b/integration/otp_test.go deleted file mode 100644 index 15cfaba61..000000000 --- a/integration/otp_test.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build integration -// +build integration - -package integration - -import ( - "fmt" - "strings" - "testing" - "time" - - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" - "github.com/smallstep/assert" -) - -const ( - totpSecretFile = "testdata/totp.secret" - totpSecret = "UPCTJYT7MUR4RWOUJ3TGTUB43IYCBJ76" - totpUrlFile = "testdata/totp.url" - totpUrl = "otpauth://totp/example.com:foo@example.com?algorithm=SHA1&digits=6&issuer=example.com&period=30&secret=EW32D2CFTAIRTEAWTRQZZXAITVA4U6K4" -) - -func mkotp(subcommand string, flags map[string]string) CLICommand { - return CLICommand{fmt.Sprintf("step crypto otp %s", subcommand), "", flags, nil} -} - -func TestCryptoOtp(t *testing.T) { - c := mkotp("generate", map[string]string{"issuer": "example.com", "account": "foo@example.com"}) - t.Run("generate", func(t *testing.T) { - out, err := c.run() - assert.Nil(t, err) - assert.Equals(t, len(strings.TrimSuffix(out.combined, "\n")), 32) - }) - - c = mkotp("generate", map[string]string{"issuer": "example.com", "account": "foo@example.com", "url": ""}) - t.Run("generate-url", func(t *testing.T) { - out, err := c.run() - assert.Nil(t, err) - assert.True(t, strings.HasPrefix(out.combined, "otpauth://")) - key, err := otp.NewKeyFromURL(out.combined) - assert.Nil(t, err) - assert.Equals(t, key.Type(), "totp") - assert.Equals(t, key.Issuer(), "example.com") - assert.Equals(t, key.AccountName(), "foo@example.com") - assert.True(t, len(key.Secret()) == 32) - }) - - c = mkotp("verify", map[string]string{"secret": totpSecretFile}) - t.Run("verify", func(t *testing.T) { - code, err := totp.GenerateCode(totpSecret, time.Now()) - assert.Nil(t, err) - out, err := c.setStdin(code).run() - assert.Nil(t, err) - assert.Equals(t, "Enter Passcode: ok\n", out.combined) - out, err = c.setStdin("foo").run() - assert.NotNil(t, err) - assert.Equals(t, "Enter Passcode: fail\n", out.combined) - }) - - c = mkotp("verify", map[string]string{"secret": totpUrlFile}) - t.Run("verify-url", func(t *testing.T) { - key, err := otp.NewKeyFromURL(totpUrl) - assert.FatalError(t, err) - code, err := totp.GenerateCode(key.Secret(), time.Now()) - assert.Nil(t, err) - out, err := c.setStdin(code).run() - assert.Nil(t, err) - assert.Equals(t, "Enter Passcode: ok\n", out.combined) - out, err = c.setStdin("foo").run() - assert.NotNil(t, err) - assert.Equals(t, "Enter Passcode: fail\n", out.combined) - }) -} diff --git a/integration/shared_test.go b/integration/shared_test.go new file mode 100644 index 000000000..0985f5aba --- /dev/null +++ b/integration/shared_test.go @@ -0,0 +1,16 @@ +package integration + +import ( + "os" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + + "github.com/smallstep/cli/internal/cmd" +) + +func TestMain(m *testing.M) { + os.Exit(testscript.RunMain(m, map[string]func() int{ + "step": cmd.Run, // main entrypoint name + })) +} diff --git a/integration/testdata/bogus.txtar b/integration/testdata/bogus.txtar new file mode 100644 index 000000000..246e7c8a1 --- /dev/null +++ b/integration/testdata/bogus.txtar @@ -0,0 +1,2 @@ +! exec step bogus +stderr 'No help topic for ''bogus''' \ No newline at end of file diff --git a/integration/testdata/certificate/fingerprint.txtar b/integration/testdata/certificate/fingerprint.txtar new file mode 100644 index 000000000..88a9a0910 --- /dev/null +++ b/integration/testdata/certificate/fingerprint.txtar @@ -0,0 +1,11 @@ +exec step certificate fingerprint intermediate_ca.crt +stdout '626dca961bfde13341b32e7711c7127612988dbc5d0082fb220efd8ab4087b4b' + +exec step certificate fingerprint intermediate_ca.crt --format=hex +stdout '626dca961bfde13341b32e7711c7127612988dbc5d0082fb220efd8ab4087b4b' + +exec step certificate fingerprint intermediate_ca.crt --format=base64 +stdout 'Ym3Klhv94TNBsy53EccSdhKYjbxdAIL7Ig79irQIe0s=' + +exec step certificate fingerprint intermediate_ca.crt --format=base64-url +stdout 'Ym3Klhv94TNBsy53EccSdhKYjbxdAIL7Ig79irQIe0s=' \ No newline at end of file diff --git a/integration/testdata/certificate/sign-bad-csr.txtar b/integration/testdata/certificate/sign-bad-csr.txtar new file mode 100644 index 000000000..b15d9c9f7 --- /dev/null +++ b/integration/testdata/certificate/sign-bad-csr.txtar @@ -0,0 +1,2 @@ +! exec step certificate sign bad.csr cacert.pem cakey.pem +stderr 'error parsing bad.csr: error parsing certificate request as DER format' \ No newline at end of file diff --git a/integration/testdata/certificate/sign.txtar b/integration/testdata/certificate/sign.txtar new file mode 100644 index 000000000..8d13373e9 --- /dev/null +++ b/integration/testdata/certificate/sign.txtar @@ -0,0 +1,2 @@ +exec step certificate sign test.csr cacert.pem cakey.pem +check_certificate \ No newline at end of file diff --git a/integration/testdata/certificate/verify-bad-pem.txtar b/integration/testdata/certificate/verify-bad-pem.txtar new file mode 100644 index 000000000..813fff6ea --- /dev/null +++ b/integration/testdata/certificate/verify-bad-pem.txtar @@ -0,0 +1,2 @@ +! exec step certificate verify bad.pem +stderr 'bad.pem contains an invalid PEM block' \ No newline at end of file diff --git a/integration/testdata/certificate/verify.txtar b/integration/testdata/certificate/verify.txtar new file mode 100644 index 000000000..ae3496496 --- /dev/null +++ b/integration/testdata/certificate/verify.txtar @@ -0,0 +1 @@ +exec step certificate verify test.crt --roots intermediate.pem \ No newline at end of file diff --git a/integration/testdata/crypto/help.txtar b/integration/testdata/crypto/help.txtar new file mode 100644 index 000000000..c923fd9eb --- /dev/null +++ b/integration/testdata/crypto/help.txtar @@ -0,0 +1,2 @@ +exec step help crypto +stdout 'cryptographic primitives that balances completeness and safety' \ No newline at end of file diff --git a/integration/testdata/crypto/jwk-create-ec.txtar b/integration/testdata/crypto/jwk-create-ec.txtar new file mode 100644 index 000000000..2453a8717 --- /dev/null +++ b/integration/testdata/crypto/jwk-create-ec.txtar @@ -0,0 +1,68 @@ +# EC defaults +exec step crypto jwk create --password-file password.txt --kty EC ec-defaults.pub ec-defaults.priv +check_jwk ec-defaults.pub ec-defaults.priv ECDSA P-256 + + +# EC with kid +exec step crypto jwk create --password-file password.txt --kty EC --kid w00t ec-kid.pub ec-kid.priv +check_jwk ec-kid.pub ec-kid.priv ECDSA P-256 + + +# EC P-256 +exec step crypto jwk create --password-file password.txt --kty EC --crv P-256 --alg ES256 ec-p256.pub ec-p256.priv +check_jwk ec-p256.pub ec-p256.priv ECDSA P-256 + + +# EC P-384 +exec step crypto jwk create --password-file password.txt --kty EC --crv P-384 --alg ES384 ec-p384.pub ec-p384.priv +check_jwk ec-p384.pub ec-p384.priv ECDSA P-384 + + +# EC P-521 +exec step crypto jwk create --password-file password.txt --kty EC --crv P-521 --alg ES512 ec-p521.pub ec-p521.priv +check_jwk ec-p521.pub ec-p521.priv ECDSA P-521 + + +# EC RSA1_5 fails +! exec step crypto jwk create --password-file password.txt --kty EC --crv P-256 --alg RSA1_5 fail.pub fail.priv +stderr 'alg ''RSA1_5'' is not compatible with kty ''EC''' + + +# ECDHES enc +exec step crypto jwk create --password-file password.txt --kty EC --crv P-256 --alg ECDH-ES --use enc ecdhes.pub ecdhes.priv +check_jwk ecdhes.pub ecdhes.priv ECDSA P-256 ECDH-ES + + +# ECDHES A128KW +exec step crypto jwk create --password-file password.txt --kty EC --crv P-521 --alg ECDH-ES+A128KW --use enc ecdhes-a128kw.pub ecdhes-a128kw.priv +check_jwk ecdhes-a128kw.pub ecdhes-a128kw.priv ECDSA P-521 ECDH-ES+A128KW + + +# ECDHES A192KW +exec step crypto jwk create --password-file password.txt --kty EC --crv P-521 --alg ECDH-ES+A192KW --use enc ecdhes-a192kw.pub ecdhes-a192kw.priv +check_jwk ecdhes-a192kw.pub ecdhes-a192kw.priv ECDSA P-521 ECDH-ES+A192KW + + +# ECDHES A256KW +exec step crypto jwk create --password-file password.txt --kty EC --crv P-521 --alg ECDH-ES+A256KW --use enc ecdhes-a256kw.pub ecdhes-a256kw.priv +check_jwk ecdhes-a256kw.pub ecdhes-a256kw.priv ECDSA P-521 ECDH-ES+A256KW + + +# EC P256 ES384 fails +! exec step crypto jwk create --password-file password.txt --kty EC --crv P-256 --alg ES384 fail.pub fail.priv +stderr 'alg ''ES384'' is not compatible with kty ''EC'' and crv ''P-256''' + + +# EC P256 size fails +! exec step crypto jwk create --password-file password.txt --kty EC --crv P-256 --alg ES256 --size 2048 fail.pub fail.priv +stderr 'flag ''--size'' is incompatible with ''--kty EC''' + + +# EC P256 without password +exec step crypto jwk create --no-password --insecure --kty EC ec-no-pass.pub ec-no-pass.priv +check_jwk_without_password ec-no-pass.pub ec-no-pass.priv ECDSA P-256 + + +# EC P256 without password without insecure fails +! exec step crypto jwk create --no-password --kty EC fail.pub fail.priv +stderr 'flag ''--no-password'' requires the ''--insecure'' flag' \ No newline at end of file diff --git a/integration/testdata/crypto/jwk-create-oct.txtar b/integration/testdata/crypto/jwk-create-oct.txtar new file mode 100644 index 000000000..733d5e048 --- /dev/null +++ b/integration/testdata/crypto/jwk-create-oct.txtar @@ -0,0 +1,98 @@ +# oct defaults +exec step crypto jwk create --password-file password.txt --kty oct oct-defaults.pub oct-defaults.priv +check_jwk oct-defaults.pub oct-defaults.priv oct HS256 + + +# oct too small without insecure fails +! exec step crypto jwk create --password-file password.txt --size 4 --kty oct fail.pub fail.priv +stderr 'flag ''--size'' requires at least 16 unless ''--insecure'' flag is provided' + + +# oct small size with insecure +exec step crypto jwk create --password-file password.txt --kty oct --size 4 --insecure oct-small.pub oct-small.priv +check_jwk oct-small.pub oct-small.priv oct HS256 + + +# oct size 0 with insecure fails +! exec step crypto jwk create --password-file password.txt --size 0 --insecure --kty oct fail.pub fail.priv +stderr 'must be greater than or equal to 0' + + +# oct HS256 +exec step crypto jwk create --password-file password.txt --kty oct --alg HS256 --size 64 oct-hs256.pub oct-hs256.priv +check_jwk oct-hs256.pub oct-hs256.priv oct HS256 + + +# oct HS384 +exec step crypto jwk create --password-file password.txt --kty oct --alg HS384 --size 64 oct-hs384.pub oct-hs384.priv +check_jwk oct-hs384.pub oct-hs384.priv oct HS384 + + +# oct HS512 +exec step crypto jwk create --password-file password.txt --kty oct --alg HS512 --size 64 oct-hs512.pub oct-hs512.priv +check_jwk oct-hs512.pub oct-hs512.priv oct HS512 + + +# oct HS256 with enc use fails +! exec step crypto jwk create --password-file password.txt --kty oct --alg HS256 --size 32 --use enc fail.pub fail.priv +stderr 'alg ''HS256'' is not compatible with kty ''oct''' + + +# oct enc dir +exec step crypto jwk create --password-file password.txt --kty oct --alg dir --size 64 --use enc oct-enc-dir.pub oct-enc-dir.priv +check_jwk oct-enc-dir.pub oct-enc-dir.priv oct dir + + +# oct A128KW +exec step crypto jwk create --password-file password.txt --kty oct --alg A128KW --size 32 --use enc oct-a128kw.pub oct-a128kw.priv +check_jwk oct-a128kw.pub oct-a128kw.priv oct A128KW + + +# oct A192KW +exec step crypto jwk create --password-file password.txt --kty oct --alg A192KW --size 32 --use enc oct-a192kw.pub oct-a192kw.priv +check_jwk oct-a192kw.pub oct-a192kw.priv oct A192KW + + +# oct A256KW +exec step crypto jwk create --password-file password.txt --kty oct --alg A256KW --size 32 --use enc oct-a256kw.pub oct-a256kw.priv +check_jwk oct-a256kw.pub oct-a256kw.priv oct A256KW + + +# oct A128GCMKW +exec step crypto jwk create --password-file password.txt --kty oct --alg A128GCMKW --size 32 --use enc oct-a128gcmkw.pub oct-a128gcmkw.priv +check_jwk oct-a128gcmkw.pub oct-a128gcmkw.priv oct A128GCMKW + + +# oct A192GCMKW +exec step crypto jwk create --password-file password.txt --kty oct --alg A192GCMKW --size 32 --use enc oct-a192gcmkw.pub oct-a192gcmkw.priv +check_jwk oct-a192gcmkw.pub oct-a192gcmkw.priv oct A192GCMKW + + +# oct A256GCMKW +exec step crypto jwk create --password-file password.txt --kty oct --alg A256GCMKW --size 32 --use enc oct-a256gcmkw.pub oct-a256gcmkw.priv +check_jwk oct-a256gcmkw.pub oct-a256gcmkw.priv oct A256GCMKW + + +# oct 256 HS256 +exec step crypto jwk create --password-file password.txt --kty oct --alg HS256 --size 32 --kid foo oct-256-hs256.pub oct-256-hs256.priv +check_jwk oct-256-hs256.pub oct-256-hs256.priv oct HS256 + + +# oct with RSA algorithm fails +! exec step crypto jwk create --password-file password.txt --kty oct --alg RS256 --size 32 fail.pub fail.priv +stderr 'alg ''RS256'' is not compatible with kty ''oct''' + + +# oct with curve fails +! exec step crypto jwk create --password-file password.txt --kty oct --alg HS256 --size 32 --curve P-256 fail.pub fail.priv +stderr 'flag ''--crv'' is incompatible with ''--kty oct''' + + +# oct without password +exec step crypto jwk create --no-password --insecure --kty oct oct-no-pass.pub oct-no-pass.priv +check_jwk_without_password oct-no-pass.pub oct-no-pass.priv oct HS256 + + +# oct without password without insecure fails +! exec step crypto jwk create --no-password --kty oct fail.pub fail.priv +stderr 'flag ''--no-password'' requires the ''--insecure'' flag' \ No newline at end of file diff --git a/integration/testdata/crypto/jwk-create-okp.txtar b/integration/testdata/crypto/jwk-create-okp.txtar new file mode 100644 index 000000000..624f2fbd5 --- /dev/null +++ b/integration/testdata/crypto/jwk-create-okp.txtar @@ -0,0 +1,43 @@ +# OKP defaults +exec step crypto jwk create --password-file password.txt --kty OKP defaults.pub defaults.priv +check_jwk defaults.pub defaults.priv OKP Ed25519 + + +# OKP with curve +exec step crypto jwk create --password-file password.txt --kty OKP --crv Ed25519 crv.pub crv.priv +check_jwk crv.pub crv.priv OKP Ed25519 + + +# OKP with curve and KID +exec step crypto jwk create --password-file password.txt --kty OKP --crv Ed25519 --kid keyid keyid.pub keyid.priv +check_jwk keyid.pub keyid.priv OKP Ed25519 + + +# OKP with alg +exec step crypto jwk create --password-file password.txt --kty OKP --alg EdDSA alg.pub alg.priv +check_jwk alg.pub alg.priv OKP Ed25519 + + +# OKP with wrong arg fails +! exec step crypto jwk create --password-file password.txt --kty OKP --alg ES256 fail.pub fail.priv +stderr 'alg ''ES256'' is not compatible with kty ''OKP'' and crv ''Ed25519''' + + +# OKP with size flag fails +! exec step crypto jwk create --password-file password.txt --kty OKP --size 256 fail.pub fail.priv +stderr 'flag ''--size'' is incompatible with ''--kty OKP''' + + +# bad key type +! exec step crypto jwk create --password-file password.txt --kty okp fail.pub fail.priv +stderr 'invalid value ''okp'' for flag ''--kty''; options are EC, RSA, OKP, or oct' + + +# OKP without password +exec step crypto jwk create --no-password --insecure --kty OKP okp-no-pass.pub okp-no-pass.priv +check_jwk_without_password okp-no-pass.pub okp-no-pass.priv OKP Ed25519 + + +# OKP without password without insecure fails +! exec step crypto jwk create --no-password --kty OKP fail.pub fail.priv +stderr 'flag ''--no-password'' requires the ''--insecure'' flag' \ No newline at end of file diff --git a/integration/testdata/crypto/jwk-create-rsa.txtar b/integration/testdata/crypto/jwk-create-rsa.txtar new file mode 100644 index 000000000..0fc66e861 --- /dev/null +++ b/integration/testdata/crypto/jwk-create-rsa.txtar @@ -0,0 +1,128 @@ +# RSA defaults +exec step crypto jwk create --password-file password.txt --kty RSA rsa-defaults.pub rsa-defaults.priv +check_jwk rsa-defaults.pub rsa-defaults.priv RSA 2048 RS256 + + +# RSA 2048, RS256 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RS256 rsa-2048-rs256.pub rsa-2048-rs256.priv +check_jwk rsa-2048-rs256.pub rsa-2048-rs256.priv RSA 2048 RS256 + + +# RSA 2048, RS384 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RS384 rsa-2048-rs384.pub rsa-2048-rs384.priv +check_jwk rsa-2048-rs384.pub rsa-2048-rs384.priv RSA 2048 RS384 + + +# RSA 2048, RS512 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RS512 rsa-2048-rs512.pub rsa-2048-rs512.priv +check_jwk rsa-2048-rs512.pub rsa-2048-rs512.priv RSA 2048 RS512 + + +# RSA 4096, RS256 +exec step crypto jwk create --password-file password.txt --kty RSA --size 4096 --alg RS256 rsa-4096-rs256.pub rsa-4096-rs256.priv +check_jwk rsa-4096-rs256.pub rsa-4096-rs256.priv RSA 4096 RS256 + + +# RSA 4096, RS384 +exec step crypto jwk create --password-file password.txt --kty RSA --size 4096 --alg RS384 rsa-4096-rs384.pub rsa-4096-rs384.priv +check_jwk rsa-4096-rs384.pub rsa-4096-rs384.priv RSA 4096 RS384 + + +# RSA 4096, RS512 +exec step crypto jwk create --password-file password.txt --kty RSA --size 4096 --alg RS512 rsa-4096-rs512.pub rsa-4096-rs512.priv +check_jwk rsa-4096-rs512.pub rsa-4096-rs512.priv RSA 4096 RS512 + + +# RSA 2048, PS256 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg PS256 rsa-2048-ps256.pub rsa-2048-ps256.priv +check_jwk rsa-2048-ps256.pub rsa-2048-ps256.priv RSA 2048 PS256 + + +# RSA 2048, PS384 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg PS384 rsa-2048-ps384.pub rsa-2048-ps384.priv +check_jwk rsa-2048-ps384.pub rsa-2048-ps384.priv RSA 2048 PS384 + + +# RSA 2048, PS512 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg PS512 rsa-2048-ps512.pub rsa-2048-ps512.priv +check_jwk rsa-2048-ps512.pub rsa-2048-ps512.priv RSA 2048 PS512 + + +# RSA 1024, PS256 fails +! exec step crypto jwk create --password-file password.txt --kty RSA --size 1024 --alg PS256 fail.pub fail.priv +stderr 'flag ''--size'' requires at least 2048 unless ''--insecure'' flag is provided' + + +# RSA 1024, PS256 with insecure flag; skipped on Go < 1.24, because small keys were supported on those +[go1.24] ! exec step crypto jwk create --password-file password.txt --kty RSA --size 1024 --alg PS256 rsa-1024-ps256.pub rsa-1024-ps256.priv --insecure +[go1.24] stderr 'the size of the RSA key should be at least 2048 bits' + + +# RSA 0, PS256 +! exec step crypto jwk create --password-file password.txt --kty RSA --size 0 --alg PS256 --insecure fail.pub fail.priv +stderr 'flag ''--size'' must be greater than or equal to 0' + + +# RSA 2048, bad alg +! exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg BADALG fail.pub fail.priv +stderr 'alg ''BADALG'' is not compatible with kty ''RSA''' + + +# RSA 2048, bad alg with enc +! exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg PS256 --use enc fail.pub fail.priv +stderr 'alg ''PS256'' is not compatible with kty ''RSA''' + + +# RSA 2048, RSA-OAEP +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RSA-OAEP --use enc rsa-2048-rsaoaep.pub rsa-2048-rsaoaep.priv +check_jwk rsa-2048-rsaoaep.pub rsa-2048-rsaoaep.priv RSA 2048 RSA-OAEP + + +# RSA 2048, RSA-OAEP-256 +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RSA-OAEP-256 --use enc rsa-2048-rsaoaep256.pub rsa-2048-rsaoaep256.priv +check_jwk rsa-2048-rsaoaep256.pub rsa-2048-rsaoaep256.priv RSA 2048 RSA-OAEP-256 + + +# RSA 2048, bad alg +! exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg ES256 fail.pub fail.priv +stderr 'alg ''ES256'' is not compatible with kty ''RSA''' + + +# RSA 2048, bad alg +! exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg HS384 fail.pub fail.priv +stderr 'alg ''HS384'' is not compatible with kty ''RSA''' + + +# no password without insecure flag +! exec step crypto jwk create --kty RSA --size 2048 --alg RS256 fail.pub fail.priv --no-password +stderr 'flag ''--no-password'' requires the ''--insecure'' flag' + + +# no password with insecure flag +exec step crypto jwk create --kty RSA --size 2048 --alg RS256 nopass.pub nopass.priv --no-password --insecure +check_jwk nopass.pub nopass.priv RSA 2048 RS256 + + +# RSA 2048, RSA1_5 enc +exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RSA1_5 --use enc rsa-2048-rsa15.pub rsa-2048-rsa15.priv +check_jwk rsa-2048-rsa15.pub rsa-2048-rsa15.priv RSA 2048 RSA1_5 + + +# RSA 2048, PS512 with kid +exec step crypto jwk create --kty RSA --size 2048 --alg PS512 --kid snarf kid.pub kid.priv --no-password --insecure +check_jwk kid.pub kid.priv RSA 2048 PS512 + + +# RSA 2048, with curve +! exec step crypto jwk create --password-file password.txt --kty RSA --size 2048 --alg RS256 --crv P-256 fail.pub fail.priv +stderr 'flag ''--crv'' is incompatible with ''--kty RSA''' + + +# OKP without password +exec step crypto jwk create --no-password --insecure --kty RSA rsa-no-pass.pub rsa-no-pass.priv +check_jwk_without_password rsa-no-pass.pub rsa-no-pass.priv RSA 2048 RS256 + + +# OKP without password without insecure fails +! exec step crypto jwk create --no-password --kty RSA fail.pub fail.priv +stderr 'flag ''--no-password'' requires the ''--insecure'' flag' \ No newline at end of file diff --git a/integration/testdata/crypto/jwk-create.txtar b/integration/testdata/crypto/jwk-create.txtar new file mode 100644 index 000000000..afe79149e --- /dev/null +++ b/integration/testdata/crypto/jwk-create.txtar @@ -0,0 +1,43 @@ +# defaults +exec step crypto jwk create --password-file password.txt defaults.pub defaults.priv +check_jwk defaults.pub defaults.priv ECDSA P-256 + + +# bad RSA key type +! exec step crypto jwk create --kty rsa --size 2048 --alg HS384 fail.pub fail.priv +stderr 'invalid value ''rsa'' for flag ''--kty''; options are EC, RSA, OKP, or oct' + + +# bad EC key type +! exec step crypto jwk create --kty ec fail.pub fail.priv +stderr 'invalid value ''ec'' for flag ''--kty''; options are EC, RSA, OKP, or oct' + + +# bad oct key type +! exec step crypto jwk create --kty OCT fail.pub fail.priv +stderr 'invalid value ''OCT'' for flag ''--kty''; options are EC, RSA, OKP, or oct' + + +# bad OKP key type +! exec step crypto jwk create --kty okp fail.pub fail.priv +stderr 'invalid value ''okp'' for flag ''--kty''; options are EC, RSA, OKP, or oct' + + +# no positional args +! exec step crypto jwk create +stderr 'not enough positional arguments were provided in ''step crypto jwk create ''' + + +# not enough positional args +! exec step crypto jwk create fail.priv +stderr 'not enough positional arguments were provided in ''step crypto jwk create ''' + + +# too many positional args +! exec step crypto jwk create fail.pub fail.priv fail +stderr 'too many positional arguments were provided in ''step crypto jwk create ''' + + +# same positional args +! exec step crypto jwk create fail.priv fail.priv +stderr 'positional arguments and cannot be equal in ''step crypto jwk create ''' diff --git a/integration/testdata/crypto/jwt-inspect.txtar b/integration/testdata/crypto/jwt-inspect.txtar new file mode 100644 index 000000000..34e2241ad --- /dev/null +++ b/integration/testdata/crypto/jwt-inspect.txtar @@ -0,0 +1,16 @@ +# inspect +stdin token.txt +exec step crypto jwt inspect --insecure +stdout 'ES256' + +# inspect fails without insecure flag +exec echo foo +stdin stdout +! exec step crypto jwt inspect +stderr '''step crypto jwt inspect'' requires the ''--insecure'' flag' + +# inspect fails for invalid token +exec echo foo +stdin stdout +! exec step crypto jwt inspect --insecure +stderr 'error parsing token: compact JWS format must have three parts' \ No newline at end of file diff --git a/integration/testdata/crypto/jwt-sign.txtar b/integration/testdata/crypto/jwt-sign.txtar new file mode 100644 index 000000000..9d87b6a6f --- /dev/null +++ b/integration/testdata/crypto/jwt-sign.txtar @@ -0,0 +1,152 @@ +# P-256 sign +exec step crypto jwt sign -key p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJFUzI1NiIsImtpZCI6Ii1pZ1pNalRCdkhFRG02bjkxQkgwT0k4ZUhqQko2b0I3UlpIZFA0RE81U0EiLCJ0eXAiOiJKV1QifQ' + + +# P-256 sign with subtle flag +exec step crypto jwt sign -key p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf 1 -iat 1 -exp 1 -subtle +stdout 'eyJhbGciOiJFUzI1NiIsImtpZCI6Ii1pZ1pNalRCdkhFRG02bjkxQkgwT0k4ZUhqQko2b0I3UlpIZFA0RE81U0EiLCJ0eXAiOiJKV1QifQ' + + +# P-256 sign fails with JSON public key +! exec step crypto jwt sign -key p256.pub.json -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'cannot use a public key for signing' + + +# P-256 sign fails with PEM public key +! exec step crypto jwt sign -key p256.pub.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'cannot use a public key for signing' + + +# P-256 sign fails with PEM with multiple keys +! exec step crypto jwt sign -key twopems.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'error decoding twopems.pem: contains more than one PEM encoded block' + + +# P-256 sign fails with PEM with bad header +! exec step crypto jwt sign -key badheader.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'error decoding badheader.pem: contains an unexpected header ''FOO PRIVATE KEY''' + + +# P-256 sign with encrypted key +exec step crypto jwt sign -key encp256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP -password-file password.txt +stdout 'eyJhbGciOiJFUzI1NiIsImtpZCI6IkZhU3R4ZmFMYllVLVFaRHV6S0hWeGRONGppTzdNUTE3OGNWTEwydDBtSVkiLCJ0eXAiOiJKV1QifQ' + +# P-256 sign fails with encrypted key and wrong password +! exec step crypto jwt sign -key encp256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP -password-file encp256.pem +stderr 'error decrypting encp256.pem: x509: decryption password incorrect' + + +# P-256 sign with expiry in the past fails without subtle +! exec step crypto jwt sign -key p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXPIRY_IN_THE_PAST +stderr 'flag ''--exp'' must be in the future unless the ''--subtle'' flag is provided' + + +# P-256 sign with expiry in the past with subtle +exec step crypto jwt sign -key p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXPIRY_IN_THE_PAST -subtle +stdout 'eyJhbGciOiJFUzI1NiIsImtpZCI6Ii1pZ1pNalRCdkhFRG02bjkxQkgwT0k4ZUhqQko2b0I3UlpIZFA0RE81U0EiLCJ0eXAiOiJKV1QifQ' + + +# RSA sign +exec step crypto jwt sign -key rsa.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJSUzI1NiIsImtpZCI6InRvUVBfZV9UaU5fdHNJUlJaeVdnTkNhU2R1OFBrLW9VUExZcWhCSE5JdTQiLCJ0eXAiOiJKV1QifQ' + + +# RSA sign with subtle flag +exec step crypto jwt sign -key rsa.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf 1 -iat 1 -exp 1 -subtle +stdout 'eyJhbGciOiJSUzI1NiIsImtpZCI6InRvUVBfZV9UaU5fdHNJUlJaeVdnTkNhU2R1OFBrLW9VUExZcWhCSE5JdTQiLCJ0eXAiOiJKV1QifQ' + + +# RSA sign without issuer, audience, nor subject +exec step crypto jwt sign -key rsa.pem -nbf $NBF -iat $IAT -exp $EXP -subtle +stdout 'eyJhbGciOiJSUzI1NiIsImtpZCI6InRvUVBfZV9UaU5fdHNJUlJaeVdnTkNhU2R1OFBrLW9VUExZcWhCSE5JdTQiLCJ0eXAiOiJKV1QifQ' + + +# RSA sign fails without issuer +! exec step crypto jwt sign -key rsa.pem -nbf $NBF -iat $IAT -exp $EXP +stderr 'flag ''--iss'' is required unless ''--subtle'' is used' + + +# RSA sign fails without audience +! exec step crypto jwt sign -key rsa.pem -iss TestIssuer -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'flag ''--aud'' is required unless ''--subtle'' is used' + + +# RSA sign fails without issuer +! exec step crypto jwt sign -key rsa.pem -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'flag ''--iss'' is required unless ''--subtle'' is used' + + +# RSA sign fails without subject +! exec step crypto jwt sign -key rsa.pem -iss TestIssuer -aud TestAudience -nbf $NBF -iat $IAT -exp $EXP +stderr 'flag ''--sub'' is required unless ''--subtle'' is used' + + +# RSA sign fails without expiry +! exec step crypto jwt sign -key rsa.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT +stderr 'flag ''--exp'' is required unless ''--subtle'' is used' + + +# JWK without use +exec step crypto jwt sign -key nouse.json -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9' + + +# JWK without alg +exec step crypto jwt sign -key noalg.json -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9' + + +# Non existing key +! exec step crypto jwt sign -key none.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'error reading none.pem: open none.pem: no such file or directory' + + +# Bad key format +! exec step crypto jwt sign -key badkey.json -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'error reading badkey.json: unsupported format' + + +# Sign with JWKS and KID 1 +exec step crypto jwt sign -jwks jwks.json -kid 1 -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJFUzI1NiIsImtpZCI6IjEiLCJ0eXAiOiJKV1QifQ' + + +# Sign with JWKS and KID 2 +exec step crypto jwt sign -jwks jwks.json -kid 2 -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjIiLCJ0eXAiOiJKV1QifQ' + + +# Sign with JWKS and KID 3 fails +! exec step crypto jwt sign -jwks jwks.json -kid 3 -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'invalid jwk use' + + +# Sign with JWKS and KID 4 fails +! exec step crypto jwt sign -jwks jwks.json -kid 4 -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'cannot find key with kid 4 on jwks.json' + + +# Sign with JWKS without KID fails +! exec step crypto jwt sign -jwks jwks.json -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'flag ''--kid'' requires the ''--jwks'' flag' + + +# Sign with JWKS and key fails +! exec step crypto jwt sign -jwks jwks.json -key p256.pem -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'flag ''--key'' and flag ''--jwks'' are mutually exclusive' + + +# Sign with non-existing JWKS fails +! exec step crypto jwt sign -jwks nojwks.json -kid 1 -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'error reading nojwks.json: open nojwks.json: no such file or directory' + + +# Sign with unsupported format fails +! exec step crypto jwt sign -jwks rsa.pem -kid 1 -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stderr 'error reading rsa.pem: unsupported format' + + +# Sign with Ed25519 +exec step crypto jwt sign -key ed25519.json -iss TestIssuer -aud TestAudience -sub TestSubject -nbf $NBF -iat $IAT -exp $EXP +stdout 'eyJhbGciOiJFZERTQSIsImtpZCI6ImtpZC1PS1AtRWQyNTUxOSIsInR5cCI6IkpXVCJ9' diff --git a/integration/testdata/crypto/jwt-verify.txtar b/integration/testdata/crypto/jwt-verify.txtar new file mode 100644 index 000000000..0ba3ca708 --- /dev/null +++ b/integration/testdata/crypto/jwt-verify.txtar @@ -0,0 +1,176 @@ +# P-256 verification +stdin p256token.txt +exec step crypto jwt verify -key p256.pem -iss TestIssuer -aud TestAudience + + +# P-256 verify fails with RS256 alg +stdin p256token.txt +! exec step crypto jwt verify -key p256.pem -alg RS256 -iss TestIssuer -aud TestAudience +stderr 'alg ''RS256'' is not compatible with kty ''EC'' and crv ''P-256''' + + +# P-256 verify fail with RSA384 alg +stdin p256token.txt +! exec step crypto jwt verify -key p256.pem -alg RS384 -iss TestIssuer -aud TestAudience +stderr 'alg ''RS384'' is not compatible with kty ''EC'' and crv ''P-256''' + + +# RSA verification +stdin rsatoken.txt +exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience + + +# RSA verification fails without alg +stdin rsatoken.txt +! exec step crypto jwt verify -key rsa.pem -iss TestIssuer -aud TestAudience +stderr 'flag ''--alg'' is required with the given key' + +# Ed25519 verification +stdin ed25519token.txt +exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience + + +# Ed25519 verification fails with invalid token +exec echo 'invalid token' +stdin stdout +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'error parsing token: compact JWS format must have three parts' + + +# Ed25519 verification fails with invalid signature +stdin incomplete-signature.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'validation failed: invalid signature' + + +# Ed25519 verification fails with wrong issuer +stdin ed25519token.txt +! exec step crypto jwt verify -key ed25519.json -iss WrongIssuer -aud TestAudience +stderr 'validation failed: invalid issuer claim' + + +# Ed25519 verification fails with wrong audience +stdin ed25519token.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud WrongAudience +stderr 'validation failed: invalid audience claim' + + +# Ed25519 verification fails with invalid data +stdin invalid-header.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'error parsing token: invalid character ''o'' in literal false' + + +# Ed25519 verification fails with invalid JSON +stdin invalid-header-json.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'error parsing token: json: cannot unmarshal array into Go value of type jose.rawHeader' + + +# Ed25519 verification fails with changed attribute +stdin invalid-header-changed-attribute.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'validation failed: invalid signature' + + +# Ed25519 verification fails with bad header JSON +stdin invalid-header-bad-json.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'error parsing token: unexpected end of JSON input' + + +# Ed25519 verification fails with invalid payload +stdin invalid-payload.txt +! exec step crypto jwt verify -key ed25519.json -iss TestIssuer -aud TestAudience +stderr 'error parsing token: invalid character ''e'' looking for beginning of value' + + +# Verify with JWKS and KID 1 +stdin jwkstoken.txt +exec step crypto jwt verify -jwks jwks.json -kid 1 -iss TestIssuer -aud TestAudience + + +# Verify with JWKS and wrong KID 2 +stdin jwkstoken.txt +! exec step crypto jwt verify -jwks jwks.json -kid 2 -iss TestIssuer -aud TestAudience +stderr 'validation failed: invalid signature' + + +# Verify with JWKS and non-existing KID 4 +stdin jwkstoken.txt +! exec step crypto jwt verify -jwks jwks.json -kid 4 -iss TestIssuer -aud TestAudience +stderr 'cannot find key with kid 4 on jwks.json' + + +# Verify with JWKS, KID is optional when set in the JWT +stdin jwkstoken.txt +exec step crypto jwt verify -jwks jwks.json -iss TestIssuer -aud TestAudience + + +# Verify token created by OpenSSL +stdin ossltoken.txt +exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience + + +# Verify token created by OpenSSL fails with wrong issuer +stdin ossltoken.txt +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss WrongIssuer -aud TestAudience +stderr 'validation failed: invalid issuer claim' + + +# Verify token created by OpenSSL fails with wrong audience +stdin ossltoken.txt +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud WrongAudience +stderr 'validation failed: invalid audience claim' + + +# Verify token created by OpenSSL fails with wrong alg +stdin ossltoken.txt +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience -alg RS384 +stderr 'alg RS384 does not match the alg on JWT' + + +# Verify token created by OpenSSL fails for expired token +stdin expired-ossltoken.txt +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience +stderr 'token is expired by' + + +# Verify token created by OpenSSL fails for expired token without no-exp +stdin expired-ossltoken.txt +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience --no-exp-check +stderr 'flag ''--no-exp-check'' requires the ''--insecure'' flag' + + +# Verify token created by OpenSSL for expired token succeeds with insecure flag +stdin expired-ossltoken.txt +exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience --no-exp-check --insecure + + +# Verify token created by OpenSSL without expiry succeeds +stdin no-expiry-ossltoken.txt +exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience + + +# Verify token created by OpenSSL without nbf succeeds +stdin zero-not-before-ossltoken.txt +exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience + + +# Verify unsupported JSON serialized token +stdin jwt-json-serialization.json +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience --no-exp-check +stderr 'error parsing token: unexpected end of JSON input' + + +# Verify unsupported JSON serialized token +stdin jwt-json-serialization-flattened.json +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience --no-exp-check +stderr 'error parsing token: unexpected end of JSON input' + + +# Verify unsupported JSON serialized token +stdin jwt-json-serialization-multi.json +! exec step crypto jwt verify -key rsa.pem -alg RS256 -iss TestIssuer -aud TestAudience --no-exp-check +stderr 'error parsing token: unexpected end of JSON input' + diff --git a/integration/testdata/crypto/keypair.txtar b/integration/testdata/crypto/keypair.txtar new file mode 100644 index 000000000..d1b70e26f --- /dev/null +++ b/integration/testdata/crypto/keypair.txtar @@ -0,0 +1,112 @@ +# This file contains multiple test cases for the "step crypto keypair" +# command. Splitting the test cases in different files sometimes resulted +# in timeouts that I haven't the root cause for (yet). + +# defaults +exec step crypto keypair --password-file password.txt key.pub key.priv +check_key_pair key.pub key.priv ECDSA P-256 + + +# no args +! exec step crypto keypair +stderr 'not enough positional arguments were provided in ''step crypto keypair ''' + + +# single arg +! exec step crypto keypair rsa-single.pub +stderr 'not enough positional arguments were provided in ''step crypto keypair ''' + + +# invalid key type +! exec step crypto keypair --kty foo error.pub error.priv +stderr 'invalid value ''foo'' for flag ''--kty''; options are RSA, EC, OK' + + +# no-password without insecure +! exec step crypto keypair --no-password error.pub error.priv +stderr 'flag ''--no-password'' requires the ''--insecure'' flag' + + +# no-password with insecure +exec step crypto keypair --no-password --insecure no-pass.pub no-pass.priv +check_key_pair key.pub key.priv ECDSA P-256 + + +# RSA defaults +exec step crypto keypair --password-file password.txt --kty RSA rsa-key.pub rsa-key.priv +check_key_pair rsa-key.pub rsa-key.priv RSA 2048 + + +# RSA size 1024 with insecure flag +exec step crypto keypair --password-file password.txt --kty RSA --size 1024 --insecure rsa-1024.pub rsa-1024.priv +check_key_pair rsa-1024.pub rsa-1024.priv RSA 1024 + + +# RSA size 3072 +exec step crypto keypair --password-file password.txt --kty RSA --size 3072 rsa-3072.pub rsa-3072.priv +check_key_pair rsa-3072.pub rsa-3072.priv RSA 3072 + + +# RSA size 4096 +exec step crypto keypair --password-file password.txt --kty RSA --size 4096 rsa-4096.pub rsa-4096.priv +check_key_pair rsa-4096.pub rsa-4096.priv RSA 4096 + + +# RSA size 0 +! exec step crypto keypair --kty RSA --size 0 rsa-error.pub rsa-error.priv +stderr 'flag ''--size'' requires at least 2048 unless ''--insecure'' flag is provided' + + +# RSA size 16 without insecure flag +! exec step crypto keypair --kty RSA --size 16 rsa-error.pub rsa-error.priv +stderr 'flag ''--size'' requires at least 2048 unless ''--insecure'' flag is provided' + + +# RSA negative size +! exec step crypto keypair --kty RSA --size -1 --insecure rsa-error.pub rsa-error.priv +stderr 'flag ''--size'' must be greater than or equal to 0' + + +# RSA size 16 with insecure flag; skipped on Go < 1.24, because small keys were supported on those +[go1.24] ! exec step crypto keypair --password-file password.txt --kty RSA --size 16 --insecure rsa-error.pub rsa-error.priv +[go1.24] stderr 'error generating RSA key: rsa: key too small' + + +# RSA size 1024 without insecure flag +! exec step crypto keypair --kty RSA --size 1024 rsa-error.pub rsa-error.priv +stderr 'flag ''--size'' requires at least 2048 unless ''--insecure'' flag is provided' + + +# RSA with EC curve +! exec step crypto keypair --kty RSA --size 2048 --crv P-256 rsa-error.pub rsa-error.priv +stderr 'flag ''--curve'' is incompatible with flag ''--kty RSA''' + + +# EC defaults +exec step crypto keypair --password-file password.txt --kty EC ec-key.pub ec-key.priv +check_key_pair ec-key.pub ec-key.priv EC P-256 + + +# EC P-256 +exec step crypto keypair --password-file password.txt --kty EC --crv P-256 ec-256.pub ec-256.priv +check_key_pair ec-256.pub ec-256.priv EC P-256 + + +# EC P-384 +exec step crypto keypair --password-file password.txt --kty EC --crv P-384 ec-384.pub ec-384.priv +check_key_pair ec-384.pub ec-384.priv EC P-384 + + +# EC P-521 +exec step crypto keypair --password-file password.txt --kty EC --crv P-521 ec-521.pub ec-521.priv +check_key_pair ec-521.pub ec-521.priv EC P-521 + + +# EC bad curve +! exec step crypto keypair --kty EC --crv P-512 ec-error.pub ec-error.priv +stderr 'flag ''--kty EC'' is incompatible with flag ''--curve P-512''' + + +# EC with RSA size +! exec step crypto keypair --kty EC --size 2048 ec-error.pub ec-error.priv +stderr 'flag ''--size'' is incompatible with flag ''--kty EC''' \ No newline at end of file diff --git a/integration/testdata/crypto/otp.txtar b/integration/testdata/crypto/otp.txtar new file mode 100644 index 000000000..a42f9a8ac --- /dev/null +++ b/integration/testdata/crypto/otp.txtar @@ -0,0 +1,37 @@ +# generate +exec step crypto otp generate --issuer example.com --account foo@example.com +cp stdout stdout.txt +check_otp stdout.txt 32 + + +# generate with URL +exec step crypto otp generate --issuer example.com --account foo@example.com --url +cp stdout stdout.txt +check_otp stdout.txt -1 + + +# verify ok +stdin code.txt +exec step crypto otp verify --secret secret.txt +stdout 'ok' + + +# verify fails without code +! exec step crypto otp verify --secret secret.txt +stderr 'error while validating TOTP' + + +# verify fails with invalid code +stdin invalid.txt +! exec step crypto otp verify --secret secret.txt +stdout 'fail' + + +# verify with URL +stdin urlcode.txt +exec step crypto otp verify --secret urlsecret.txt + + +# verify with URL fails without code +! exec step crypto otp verify --secret urlsecret.txt +stderr 'error while validating TOTP' diff --git a/integration/testdata/help/help.txtar b/integration/testdata/help/help.txtar new file mode 100644 index 000000000..2c00e9eb4 --- /dev/null +++ b/integration/testdata/help/help.txtar @@ -0,0 +1,2 @@ +exec step --help +stdout 'plumbing for distributed systems' \ No newline at end of file diff --git a/integration/testdata/help/html.txtar b/integration/testdata/help/html.txtar new file mode 100644 index 000000000..5c9c9db8f --- /dev/null +++ b/integration/testdata/help/html.txtar @@ -0,0 +1,2 @@ +exec step help --html=./html --report +check_quality ./html \ No newline at end of file diff --git a/integration/testdata/totp.secret b/integration/testdata/totp.secret deleted file mode 100644 index 82089873c..000000000 --- a/integration/testdata/totp.secret +++ /dev/null @@ -1 +0,0 @@ -UPCTJYT7MUR4RWOUJ3TGTUB43IYCBJ76 diff --git a/integration/testdata/totp.url b/integration/testdata/totp.url deleted file mode 100644 index 2b95e8fd7..000000000 --- a/integration/testdata/totp.url +++ /dev/null @@ -1 +0,0 @@ -otpauth://totp/example.com:foo@example.com?algorithm=SHA1&digits=6&issuer=example.com&period=30&secret=EW32D2CFTAIRTEAWTRQZZXAITVA4U6K4 diff --git a/integration/testdata/version.txtar b/integration/testdata/version.txtar new file mode 100644 index 000000000..964c63718 --- /dev/null +++ b/integration/testdata/version.txtar @@ -0,0 +1,2 @@ +exec step version +stdout 'Smallstep CLI/0000000-dev' \ No newline at end of file diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 000000000..6e42c187a --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "reflect" + "regexp" + "strings" + "time" + + "github.com/urfave/cli" + + "github.com/smallstep/cli-utils/command" + "github.com/smallstep/cli-utils/step" + "github.com/smallstep/cli-utils/ui" + "github.com/smallstep/cli-utils/usage" + "github.com/smallstep/cli/command/version" + "github.com/smallstep/cli/internal/plugin" + "github.com/smallstep/cli/utils" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" + + // Enabled cas interfaces. + _ "github.com/smallstep/certificates/cas/cloudcas" + _ "github.com/smallstep/certificates/cas/softcas" + _ "github.com/smallstep/certificates/cas/stepcas" + + // Enabled commands + _ "github.com/smallstep/cli/command/api" + _ "github.com/smallstep/cli/command/base64" + _ "github.com/smallstep/cli/command/beta" + _ "github.com/smallstep/cli/command/ca" + _ "github.com/smallstep/cli/command/certificate" + _ "github.com/smallstep/cli/command/completion" + _ "github.com/smallstep/cli/command/context" + _ "github.com/smallstep/cli/command/crl" + _ "github.com/smallstep/cli/command/crypto" + _ "github.com/smallstep/cli/command/fileserver" + _ "github.com/smallstep/cli/command/oauth" + _ "github.com/smallstep/cli/command/path" + _ "github.com/smallstep/cli/command/ssh" +) + +func Run() int { + // initialize step environment. + if err := step.Init(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return 1 + } + + defer panicHandler() + + // create new instance of app + app := newApp(os.Stdout, os.Stderr) + + if err := app.Run(os.Args); err != nil { + var messenger interface { + Message() string + } + if errors.As(err, &messenger) { + if os.Getenv("STEPDEBUG") == "1" { + fmt.Fprintf(os.Stderr, "%+v\n\n%s", err, messenger.Message()) + } else { + fmt.Fprintln(os.Stderr, messenger.Message()) + fmt.Fprintln(os.Stderr, "Re-run with STEPDEBUG=1 for more info.") + } + } else { + if os.Getenv("STEPDEBUG") == "1" { + fmt.Fprintf(os.Stderr, "%+v\n", err) + } else { + fmt.Fprintln(os.Stderr, err) + } + } + + return 1 + } + + return 0 +} + +var ( + stepAppName = "step" +) + +func SetName(appName string) { + if appName != "" { + stepAppName = appName + } +} + +func newApp(stdout, stderr io.Writer) *cli.App { + // Define default file writers and prompters for go.step.sm/crypto + pemutil.WriteFile = utils.WriteFile + pemutil.PromptPassword = func(msg string) ([]byte, error) { + return ui.PromptPassword(msg) + } + jose.PromptPassword = func(msg string) ([]byte, error) { + return ui.PromptPassword(msg) + } + + // Override global framework components + cli.VersionPrinter = func(c *cli.Context) { + version.Command(c) + } + cli.AppHelpTemplate = usage.AppHelpTemplate + cli.SubcommandHelpTemplate = usage.SubcommandHelpTemplate + cli.CommandHelpTemplate = usage.CommandHelpTemplate + cli.HelpPrinter = usage.HelpPrinter + cli.FlagNamePrefixer = usage.FlagNamePrefixer + cli.FlagStringer = stringifyFlag + + // Configure cli app + app := cli.NewApp() + app.Name = stepAppName // "step" by default + app.HelpName = stepAppName // "step" by default + app.Usage = "plumbing for distributed systems" + app.Version = step.Version() + app.Commands = command.Retrieve() + app.Flags = append(app.Flags, cli.HelpFlag) + app.EnableBashCompletion = true + app.Copyright = fmt.Sprintf("(c) 2018-%d Smallstep Labs, Inc.", time.Now().Year()) + + // Flag of custom configuration flag + app.Flags = append(app.Flags, cli.StringFlag{ + Name: "config", + Usage: "path to the config file to use for CLI flags", + }) + + // Action runs on `step` or `step ` if the command is not enabled. + app.Action = func(ctx *cli.Context) error { + args := ctx.Args() + if name := args.First(); name != "" { + if file, err := plugin.LookPath(name); err == nil { + return plugin.Run(ctx, file) + } + if u := plugin.GetURL(name); u != "" { + //nolint:staticcheck // this is a top level error - capitalization is ok + return fmt.Errorf("The plugin %q was not found on this system.\nDownload it from %s", name, u) + } + return cli.ShowCommandHelp(ctx, name) + } + return cli.ShowAppHelp(ctx) + } + + // All non-successful output should be written to stderr + app.Writer = stdout + app.ErrWriter = stderr + + return app +} + +func panicHandler() { + if r := recover(); r != nil { + if os.Getenv("STEPDEBUG") == "1" { + fmt.Fprintf(os.Stderr, "%s\n", step.Version()) + fmt.Fprintf(os.Stderr, "Release Date: %s\n\n", step.ReleaseDate()) + panic(r) + } + + fmt.Fprintln(os.Stderr, "Something unexpected happened.") + fmt.Fprintln(os.Stderr, "If you want to help us debug the problem, please run:") + fmt.Fprintf(os.Stderr, "STEPDEBUG=1 %s\n", strings.Join(os.Args, " ")) + fmt.Fprintln(os.Stderr, "and send the output to info@smallstep.com") + os.Exit(2) + } +} + +func flagValue(f cli.Flag) reflect.Value { + fv := reflect.ValueOf(f) + for fv.Kind() == reflect.Ptr { + fv = reflect.Indirect(fv) + } + return fv +} + +var placeholderString = regexp.MustCompile(`<.*?>`) + +func stringifyFlag(f cli.Flag) string { + fv := flagValue(f) + usg := fv.FieldByName("Usage").String() + placeholder := placeholderString.FindString(usg) + if placeholder == "" { + switch f.(type) { + case cli.BoolFlag, cli.BoolTFlag: + default: + placeholder = "" + } + } + return cli.FlagNamePrefixer(fv.FieldByName("Name").String(), placeholder) + "\t" + usg +} diff --git a/cmd/step/main_test.go b/internal/cmd/root_test.go similarity index 98% rename from cmd/step/main_test.go rename to internal/cmd/root_test.go index 82979e39d..207d4f8a2 100644 --- a/cmd/step/main_test.go +++ b/internal/cmd/root_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes"