From 9e2d6932f631f945740fbb2cb7de67da581926f5 Mon Sep 17 00:00:00 2001 From: Ian Howell Date: Thu, 15 Aug 2019 16:28:55 -0500 Subject: [PATCH] Add document command and its first subcommand This adds the "airshipctl document" tree of commands, as well as one of its leaf commands, "generate masterpassphrase". Change-Id: I2365ebe67b38ebbbe4873c6e1beb6407f0e000eb --- cmd/document/document.go | 20 ++++ cmd/document/secret/generate/generate.go | 20 ++++ .../secret/generate/masterpassphrase.go | 26 +++++ cmd/document/secret/secret.go | 21 ++++ cmd/root.go | 9 +- .../rootCmd-with-defaults.golden | 2 +- pkg/secret/passphrases.go | 98 ++++++++++++++++ pkg/secret/passphrases_test.go | 109 ++++++++++++++++++ pkg/secret/rngsource.go | 33 ++++++ 9 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 cmd/document/document.go create mode 100644 cmd/document/secret/generate/generate.go create mode 100644 cmd/document/secret/generate/masterpassphrase.go create mode 100644 cmd/document/secret/secret.go create mode 100644 pkg/secret/passphrases.go create mode 100644 pkg/secret/passphrases_test.go create mode 100644 pkg/secret/rngsource.go diff --git a/cmd/document/document.go b/cmd/document/document.go new file mode 100644 index 000000000..d0fd0b7a5 --- /dev/null +++ b/cmd/document/document.go @@ -0,0 +1,20 @@ +package document + +import ( + "github.com/spf13/cobra" + + "opendev.org/airship/airshipctl/cmd/document/secret" + "opendev.org/airship/airshipctl/pkg/environment" +) + +// NewDocumentCommand creates a new command for managing airshipctl documents +func NewDocumentCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command { + documentRootCmd := &cobra.Command{ + Use: "document", + Short: "manages deployment documents", + } + + documentRootCmd.AddCommand(secret.NewSecretCommand(rootSettings)) + + return documentRootCmd +} diff --git a/cmd/document/secret/generate/generate.go b/cmd/document/secret/generate/generate.go new file mode 100644 index 000000000..d6cdc8047 --- /dev/null +++ b/cmd/document/secret/generate/generate.go @@ -0,0 +1,20 @@ +package generate + +import ( + "github.com/spf13/cobra" + + "opendev.org/airship/airshipctl/pkg/environment" +) + +// NewGenerateCommand creates a new command for generating secret information +func NewGenerateCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command { + generateRootCmd := &cobra.Command{ + Use: "generate", + // TODO(howell): Make this more expressive + Short: "generates various secrets", + } + + generateRootCmd.AddCommand(NewGenerateMasterPassphraseCommand(rootSettings)) + + return generateRootCmd +} diff --git a/cmd/document/secret/generate/masterpassphrase.go b/cmd/document/secret/generate/masterpassphrase.go new file mode 100644 index 000000000..dbf8171b2 --- /dev/null +++ b/cmd/document/secret/generate/masterpassphrase.go @@ -0,0 +1,26 @@ +package generate + +import ( + "fmt" + + "github.com/spf13/cobra" + + "opendev.org/airship/airshipctl/pkg/environment" + "opendev.org/airship/airshipctl/pkg/secret" +) + +// NewGenerateMasterPassphraseCommand creates a new command for generating secret information +func NewGenerateMasterPassphraseCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command { + masterPassphraseCmd := &cobra.Command{ + Use: "masterpassphrase", + // TODO(howell): Make this more expressive + Short: "generates a secure master passphrase", + Run: func(cmd *cobra.Command, args []string) { + engine := secret.NewPassphraseEngine(nil) + masterPassphrase := engine.GeneratePassphrase() + fmt.Fprintln(cmd.OutOrStdout(), masterPassphrase) + }, + } + + return masterPassphraseCmd +} diff --git a/cmd/document/secret/secret.go b/cmd/document/secret/secret.go new file mode 100644 index 000000000..d8ef1f82d --- /dev/null +++ b/cmd/document/secret/secret.go @@ -0,0 +1,21 @@ +package secret + +import ( + "github.com/spf13/cobra" + + "opendev.org/airship/airshipctl/cmd/document/secret/generate" + "opendev.org/airship/airshipctl/pkg/environment" +) + +// NewSecretCommand creates a new command for managing airshipctl secrets +func NewSecretCommand(rootSettings *environment.AirshipCTLSettings) *cobra.Command { + secretRootCmd := &cobra.Command{ + Use: "secret", + // TODO(howell): Make this more expressive + Short: "manages secrets", + } + + secretRootCmd.AddCommand(generate.NewGenerateCommand(rootSettings)) + + return secretRootCmd +} diff --git a/cmd/root.go b/cmd/root.go index 8d892b805..d56ee115f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "opendev.org/airship/airshipctl/cmd/bootstrap" "opendev.org/airship/airshipctl/cmd/completion" + "opendev.org/airship/airshipctl/cmd/document" "opendev.org/airship/airshipctl/pkg/environment" "opendev.org/airship/airshipctl/pkg/log" ) @@ -51,16 +52,10 @@ func AddDefaultAirshipCTLCommands(cmd *cobra.Command, settings *environment.Airs cmd.AddCommand(argo.NewCommand()) cmd.AddCommand(bootstrap.NewBootstrapCommand(settings)) cmd.AddCommand(completion.NewCompletionCommand()) + cmd.AddCommand(document.NewDocumentCommand(settings)) cmd.AddCommand(kubectl.NewDefaultKubectlCommand()) // Should we use cmd.OutOrStdout? cmd.AddCommand(kubeadm.NewKubeadmCommand(os.Stdin, os.Stdout, os.Stderr)) - kustomizeCmd, _, err := cmd.Find([]string{"kubectl", "kustomize"}) - if err != nil { - log.Fatalf("Unable to find subcommand '%s'", err.Error()) - } - - cmd.AddCommand(kustomizeCmd) - return cmd } diff --git a/cmd/testdata/TestRootGoldenOutput/rootCmd-with-defaults.golden b/cmd/testdata/TestRootGoldenOutput/rootCmd-with-defaults.golden index 842dd8722..02acb4859 100644 --- a/cmd/testdata/TestRootGoldenOutput/rootCmd-with-defaults.golden +++ b/cmd/testdata/TestRootGoldenOutput/rootCmd-with-defaults.golden @@ -7,10 +7,10 @@ Available Commands: argo argo is the command line interface to Argo bootstrap bootstraps airshipctl completion Generate autocompletions script for the specified shell (bash or zsh) + document manages deployment documents help Help about any command kubeadm kubeadm: easily bootstrap a secure Kubernetes cluster kubectl kubectl controls the Kubernetes cluster manager - kustomize Build a kustomization target from a directory or a remote url. version Show the version number of airshipctl Flags: diff --git a/pkg/secret/passphrases.go b/pkg/secret/passphrases.go new file mode 100644 index 000000000..7bb133af9 --- /dev/null +++ b/pkg/secret/passphrases.go @@ -0,0 +1,98 @@ +package secret + +import ( + "math/rand" + "strings" +) + +const ( + defaultLength = 24 + + asciiLowers = "abcdefghijklmnopqrstuvwxyz" + asciiUppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + asciiNumbers = "0123456789" + asciiSymbols = "@#&-+=?" +) + +var ( + // pool is the complete collection of characters that can be used for a + // passphrase + pool []byte +) + +func init() { + pool = append(pool, []byte(asciiLowers)...) + pool = append(pool, []byte(asciiUppers)...) + pool = append(pool, []byte(asciiNumbers)...) + pool = append(pool, []byte(asciiSymbols)...) +} + +// PassphraseEngine is used to generate secure random passphrases +type PassphraseEngine struct { + rng *rand.Rand + pool []byte +} + +// NewPassphraseEngine creates an PassphraseEngine using src. If src is nil, +// the returned PassphraseEngine will use the default Source +func NewPassphraseEngine(src rand.Source) *PassphraseEngine { + if src == nil { + src = &Source{} + } + return &PassphraseEngine{ + rng: rand.New(src), + pool: pool, + } +} + +// GeneratePassphrase returns a secure random string of length 24, +// containing at least one of each from the following sets: +// [0-9] +// [a-z] +// [A-Z] +// [@#&-+=?] +func (e *PassphraseEngine) GeneratePassphrase() string { + return e.GeneratePassphraseN(defaultLength) +} + +// GeneratePassphraseN returns a secure random string containing at least +// one of each from the following sets. Its length will be max(length, 24) +// [0-9] +// [a-z] +// [A-Z] +// [@#&-+=?] +func (e *PassphraseEngine) GeneratePassphraseN(length int) string { + if length < defaultLength { + length = defaultLength + } + var passPhrase string + for !e.isValidPassphrase(passPhrase) { + var sb strings.Builder + for i := 0; i < length; i++ { + randIndex := e.rng.Intn(len(e.pool)) + randChar := e.pool[randIndex] + sb.WriteString(string(randChar)) + } + passPhrase = sb.String() + } + return passPhrase +} + +func (e *PassphraseEngine) isValidPassphrase(passPhrase string) bool { + if len(passPhrase) < defaultLength { + return false + } + + charSets := []string{ + asciiLowers, + asciiUppers, + asciiNumbers, + asciiSymbols, + } + for _, charSet := range charSets { + if !strings.ContainsAny(passPhrase, charSet) { + return false + } + } + return true +} diff --git a/pkg/secret/passphrases_test.go b/pkg/secret/passphrases_test.go new file mode 100644 index 000000000..aeb1bdf25 --- /dev/null +++ b/pkg/secret/passphrases_test.go @@ -0,0 +1,109 @@ +package secret_test + +import ( + "math/rand" + "strings" + "testing" + + "opendev.org/airship/airshipctl/pkg/secret" +) + +const ( + asciiLowers = "abcdefghijklmnopqrstuvwxyz" + asciiUppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + asciiNumbers = "0123456789" + asciiSymbols = "@#&-+=?" + + defaultLength = 24 +) + +func TestDeterministicGenerateValidPassphrase(t *testing.T) { + testSource := rand.NewSource(42) + engine := secret.NewPassphraseEngine(testSource) + + // pre-calculated for rand.NewSource(42) + expectedPassphrases := []string{ + "erx&97vfqd7LN3HJ?t@oPhds", + "##Xeuvf5Njy@hNWSaRoleFkf", + "jB=kirg7acIt-=fx1Fb-tZ+7", + "eOS#W8yoAljSThPL2oT&aUZu", + "vlaQqKr-jXSCJfXYnvGik3b1", + "rBKtHZkOmFUM75?c2UWiZjdh", + "9g?QV?w6BCWN2EKAc+dZ-Jun", + "X@IIyqAg7Mz@Wm8eRE6KMEf3", + "7JpQkLd3ufhj4bLB8S=ipjNP", + "XC?bDaHTa3mrBYLMG@#B=Q0B", + } + + for i, expected := range expectedPassphrases { + actual := engine.GeneratePassphrase() + if expected != actual { + t.Errorf("Call #%d to engine.GeneratePassphrase() should have returned %s, got %s", + i, expected, actual) + } + } +} + +func TestNondeterministicGenerateValidPassphrase(t *testing.T) { + // Due to the nondeterminism of random number generators, this + // functionality is impossible to fully test. Let's just generate + // enough passphrases that we can be confident in the randomness. + // Fortunately, Go is pretty fast, so we can do upward of 100,000 of + // these without slowing down the test too much + engine := secret.NewPassphraseEngine(nil) + for i := 0; i < 100000; i++ { + passphrase := engine.GeneratePassphrase() + if !isValid(passphrase) { + t.Errorf("The engine generated an invalid password: %s", passphrase) + } + } +} + +func TestGenerateValidPassphraseN(t *testing.T) { + testSource := rand.NewSource(42) + engine := secret.NewPassphraseEngine(testSource) + tests := []struct { + inputLegth int + expectedLength int + }{ + { + inputLegth: 10, + expectedLength: defaultLength, + }, + { + inputLegth: -5, + expectedLength: defaultLength, + }, + { + inputLegth: 30, + expectedLength: 30, + }, + } + + for _, tt := range tests { + passphrase := engine.GeneratePassphraseN(tt.inputLegth) + if len(passphrase) != tt.expectedLength { + t.Errorf(`Passphrase "%s" should have length %d, got %d\n`, + passphrase, len(passphrase), tt.expectedLength) + } + } +} + +func isValid(passphrase string) bool { + if len(passphrase) < defaultLength { + return false + } + + charSets := []string{ + asciiLowers, + asciiUppers, + asciiNumbers, + asciiSymbols, + } + for _, charSet := range charSets { + if !strings.ContainsAny(passphrase, charSet) { + return false + } + } + return true +} diff --git a/pkg/secret/rngsource.go b/pkg/secret/rngsource.go new file mode 100644 index 000000000..bcc9b4d3b --- /dev/null +++ b/pkg/secret/rngsource.go @@ -0,0 +1,33 @@ +package secret + +import ( + crypto "crypto/rand" + "encoding/binary" + "math/rand" + + "opendev.org/airship/airshipctl/pkg/log" +) + +// Source implements rand.Source +type Source struct{} + +var _ rand.Source = &Source{} + +// Uint64 returns a secure random uint64 in the range [0, 1<<64]. It will fail +// if an error is returned from the system's secure random numer generator +func (s *Source) Uint64() uint64 { + var value uint64 + err := binary.Read(crypto.Reader, binary.BigEndian, &value) + if err != nil { + log.Fatalf("could not generate a random number: %s", err.Error()) + } + return value +} + +// Int63 returns a secure random int64 in the range [0, 1<<63] +func (s *Source) Int63() int64 { + return int64(s.Uint64() & ^(uint64(1 << 63))) +} + +// Seed does nothing, since Source will use the crypto library +func (s *Source) Seed(_ int64) {}