From f447b18e4dc830e54dbf202daf8e96648c6f4796 Mon Sep 17 00:00:00 2001 From: Gareth Kirwan Date: Thu, 14 Nov 2024 17:59:19 +0700 Subject: [PATCH] Config: Add -edit and encryption upgrade to cmd/config This simplifies the handling for encryption prompts by moving it to a field on config, allowing us to simplify all the places were were passing around config Also moves password entry to being secure (echo-off) --- cmd/config/config.go | 136 +++++++++++++++++++------------ config/config.go | 31 +++---- config/config_encryption.go | 49 +++++------ config/config_encryption_test.go | 9 +- config/config_test.go | 4 +- config/config_types.go | 8 +- go.mod | 1 + go.sum | 2 + 8 files changed, 136 insertions(+), 104 deletions(-) diff --git a/cmd/config/config.go b/cmd/config/config.go index b4223dc0c36..82db53a25be 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "os" @@ -18,73 +19,106 @@ func main() { defaultCfgFile := config.DefaultFilePath() - var in, out, key string + var in, out, keyStr string + var inplace bool + fs := flag.NewFlagSet("config", flag.ExitOnError) fs.Usage = func() { usage(fs) } - fs.StringVar(&in, "infile", defaultCfgFile, "The config input file to process") - fs.StringVar(&out, "outfile", "[infile].out", "The config output file") - fs.StringVar(&key, "key", "", "The key to use for AES encryption") - _ = fs.Parse(os.Args[1:]) + fs.StringVar(&in, "in", defaultCfgFile, "The config input file to process") + fs.StringVar(&out, "out", "[in].out", "The config output file") + fs.BoolVar(&inplace, "edit", false, "Edit; Save result to the original file") + fs.StringVar(&keyStr, "key", "", "The key to use for AES encryption") + + cmd, args := parseCommand(os.Args[1:]) + if cmd == "" { + usage(fs) + os.Exit(2) + } - if out == "[infile].out" { + if err := fs.Parse(args); err != nil { + fatal(err.Error()) + } + + if inplace { + out = in + } else if out == "[in].out" { out = in + ".out" } - switch parseCommand(fs) { + key := []byte(keyStr) + var err error + switch cmd { case "upgrade": - upgradeFile(in, out) + err = upgradeFile(in, out, key) case "decrypt": - encryptWrapper(in, out, key, decryptFile) + err = encryptWrapper(in, out, key, false, decryptFile) case "encrypt": - encryptWrapper(in, out, key, encryptFile) + err = encryptWrapper(in, out, key, true, encryptFile) + } + + if err != nil { + fatal(err.Error()) } fmt.Println("Success! File written to " + out) } -func upgradeFile(in, out string) { - if config.IsFileEncrypted(in) { - fatal("Cannot upgrade an encrypted file. Please decrypt first") +func upgradeFile(in, out string, key []byte) error { + c := &config.Config{ + EncryptionKeyPrompt: func(_ bool) ([]byte, error) { + if len(key) != 0 { + return key, nil + } + return config.PromptForConfigKey(false) + }, } - c := &config.Config{} + if err := c.ReadConfigFromFile(in, true); err != nil { - fatal(err.Error()) - } - if err := c.SaveConfigToFile(out); err != nil { - fatal(err.Error()) + return err } + + return c.SaveConfigToFile(out) } -func encryptWrapper(in, out, key string, fn func(in string, key []byte) []byte) { - if key == "" { - key = getKey() +type encryptFunc func(string, []byte) ([]byte, error) + +func encryptWrapper(in, out string, key []byte, confirmKey bool, fn encryptFunc) error { + if len(key) == 0 { + var err error + if key, err = config.PromptForConfigKey(confirmKey); err != nil { + return err + } + } + outData, err := fn(in, key) + if err != nil { + return err } - outData := fn(in, []byte(key)) if err := file.Write(out, outData); err != nil { - fatal("Unable to write output file " + out + "; Error: " + err.Error()) + return fmt.Errorf("Unable to write output file %s; Error: %w", out, err) } + return nil } -func encryptFile(in string, key []byte) []byte { +func encryptFile(in string, key []byte) ([]byte, error) { if config.IsFileEncrypted(in) { - fatal("File is already encrypted") + return nil, errors.New("File is already encrypted") } outData, err := config.EncryptConfigFile(readFile(in), key) if err != nil { - fatal("Unable to encrypt config data. Error: " + err.Error()) + return nil, fmt.Errorf("Unable to encrypt config data. Error: %w", err) } - return outData + return outData, nil } -func decryptFile(in string, key []byte) []byte { +func decryptFile(in string, key []byte) ([]byte, error) { if !config.IsFileEncrypted(in) { - fatal("File is already decrypted") + return nil, errors.New("File is already decrypted") } outData, err := config.DecryptConfigFile(readFile(in), key) if err != nil { - fatal("Unable to decrypt config data. Error: " + err.Error()) + return nil, fmt.Errorf("Unable to decrypt config data. Error: %w", err) } - return outData + return outData, nil } func readFile(in string) []byte { @@ -95,37 +129,31 @@ func readFile(in string) []byte { return fileData } -func getKey() string { - result, err := config.PromptForConfigKey(false) - if err != nil { - fatal("Unable to obtain encryption/decryption key: " + err.Error()) - } - return string(result) -} - func fatal(msg string) { fmt.Fprintln(os.Stderr, msg) os.Exit(2) } -// parseCommand will return the single non-flag parameter -// If none is provided, too many, or unrecognised, usage() will be called and exit 1 -func parseCommand(fs *flag.FlagSet) string { - switch fs.NArg() { +// parseCommand will return the single non-flag parameter from os.Args, and return the remaining args +// If none is provided, too many, usage() will be called and exit 1 +func parseCommand(a []string) (string, []string) { + cmd, rem := []string{}, []string{} + for _, s := range a { + if slices.Contains(commands, s) { + cmd = append(cmd, s) + } else { + rem = append(rem, s) + } + } + switch len(cmd) { case 0: fmt.Fprintln(os.Stderr, "No command provided") - case 1: - command := fs.Arg(0) - if slices.Contains(commands, command) { - return command - } - fmt.Fprintln(os.Stderr, "Unknown command provided: "+command) + case 1: // + return cmd[0], rem default: - fmt.Fprintln(os.Stderr, "Too many commands provided: "+strings.Join(fs.Args(), " ")) + fmt.Fprintln(os.Stderr, "Too many commands provided: "+strings.Join(cmd, ", ")) } - usage(fs) - os.Exit(2) - return "" + return "", nil } // usage prints command usage and exits 1 @@ -133,7 +161,7 @@ func usage(fs *flag.FlagSet) { //nolint:dupword // deliberate duplication of commands fmt.Fprintln(os.Stderr, ` Usage: -config [arguments] +config [arguments] The commands are: encrypt encrypt infile and write to outfile diff --git a/config/config.go b/config/config.go index 1754a73ef82..bbea14107ba 100644 --- a/config/config.go +++ b/config/config.go @@ -1486,7 +1486,7 @@ func (c *Config) ReadConfigFromFile(path string, dryrun bool) error { } defer f.Close() - if err := c.readConfig(f, func() ([]byte, error) { return PromptForConfigKey(false) }); err != nil { + if err := c.readConfig(f); err != nil { return err } @@ -1497,19 +1497,17 @@ func (c *Config) ReadConfigFromFile(path string, dryrun bool) error { return c.saveWithEncryptPrompt(path) } -type keyProvider func() ([]byte, error) - // readConfig loads config from a io.Reader into the config object // versions manager will upgrade/downgrade if appropriate // If encrypted, prompts for encryption key -func (c *Config) readConfig(d io.Reader, keyProvider keyProvider) error { +func (c *Config) readConfig(d io.Reader) error { j, err := io.ReadAll(d) if err != nil { return err } if IsEncrypted(j) { - if j, err = c.decryptConfig(j, keyProvider); err != nil { + if j, err = c.decryptConfig(j); err != nil { return err } } @@ -1537,14 +1535,17 @@ func (c *Config) saveWithEncryptPrompt(path string) error { } // decryptConfig reads encrypted configuration and requests key from provider -func (c *Config) decryptConfig(j []byte, keyProvider keyProvider) ([]byte, error) { +func (c *Config) decryptConfig(j []byte) ([]byte, error) { for range maxAuthFailures { - key, err := keyProvider() + f := c.EncryptionKeyProvider + if f == nil { + f = PromptForConfigKey + } + key, err := f(false) if err != nil { log.Errorf(log.ConfigMgr, "PromptForConfigKey err: %s", err) continue } - d, err := c.decryptConfigData(j, key) if err != nil { log.Errorln(log.ConfigMgr, "Could not decrypt and deserialise data with given key. Invalid password?", err) @@ -1575,13 +1576,12 @@ func (c *Config) SaveConfigToFile(configPath string) error { } } }() - return c.Save(provider, func() ([]byte, error) { return PromptForConfigKey(true) }) + return c.Save(provider) } -// Save saves your configuration to the writer as a JSON object -// with encryption, if configured +// Save saves your configuration to the writer as a JSON object with encryption, if configured // If there is an error when preparing the data to store, the writer is never requested -func (c *Config) Save(writerProvider func() (io.Writer, error), keyProvider func() ([]byte, error)) error { +func (c *Config) Save(writerProvider func() (io.Writer, error)) error { payload, err := json.MarshalIndent(c, "", " ") if err != nil { return err @@ -1590,8 +1590,11 @@ func (c *Config) Save(writerProvider func() (io.Writer, error), keyProvider func if c.EncryptConfig == fileEncryptionEnabled { // Ensure we have the key from session or from user if len(c.sessionDK) == 0 { - var key []byte - key, err = keyProvider() + f := c.EncryptionKeyProvider + if f == nil { + f = PromptForConfigKey + } + key, err := f(true) if err != nil { return err } diff --git a/config/config_encryption.go b/config/config_encryption.go index 92547a8c434..f91c99bc543 100644 --- a/config/config_encryption.go +++ b/config/config_encryption.go @@ -9,11 +9,13 @@ import ( "fmt" "io" "os" + "syscall" "github.com/buger/jsonparser" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/crypto" "golang.org/x/crypto/scrypt" + "golang.org/x/term" ) const ( @@ -42,48 +44,39 @@ func promptForConfigEncryption() (bool, error) { return common.YesOrNo(input), nil } -// Unencrypted provides the default key provider implementation for unencrypted files -func Unencrypted() ([]byte, error) { - return nil, errors.New("encryption key was requested, no key provided") -} - // PromptForConfigKey asks for configuration key -// if initialSetup is true, the password needs to be repeated -func PromptForConfigKey(initialSetup bool) ([]byte, error) { - var cryptoKey []byte - - for { - fmt.Println("Please enter in your password: ") - pwPrompt := func(i *[]byte) error { - _, err := fmt.Scanln(i) - return err - } +func PromptForConfigKey(confirmKey bool) ([]byte, error) { + for i := 0; i < 3; i++ { + fmt.Print("Please enter encryption key: ") + key, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() - var p1 []byte - err := pwPrompt(&p1) if err != nil { return nil, err } - if !initialSetup { - cryptoKey = p1 - break + if len(key) == 0 { + continue } - var p2 []byte - fmt.Println("Please re-enter your password: ") - err = pwPrompt(&p2) + if !confirmKey { + return key, nil + } + + fmt.Print("Please re-enter key: ") + conf, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { return nil, err } - if bytes.Equal(p1, p2) { - cryptoKey = p1 - break + if bytes.Equal(key, conf) { + return key, nil } - fmt.Println("Passwords did not match, please try again.") + fmt.Println("Keys did not match, please try again.") } - return cryptoKey, nil + return nil, errors.New("No key entered") } // EncryptConfigFile encrypts configuration data that is parsed in with a key diff --git a/config/config_encryption_test.go b/config/config_encryption_test.go index c87aba8eeef..8fd6b2eabdb 100644 --- a/config/config_encryption_test.go +++ b/config/config_encryption_test.go @@ -261,17 +261,18 @@ func TestReadConfigWithPrompt(t *testing.T) { func TestReadEncryptedConfigFromReader(t *testing.T) { t.Parallel() - c := &Config{} - keyProvider := func() ([]byte, error) { return []byte("pass"), nil } + c := &Config{ + EncryptionKeyProvider: func(_ bool) ([]byte, error) { return []byte("pass"), nil }, + } // Encrypted conf for: `{"name":"test"}` with key `pass` confBytes := []byte{84, 72, 79, 82, 83, 45, 72, 65, 77, 77, 69, 82, 126, 71, 67, 84, 126, 83, 79, 126, 83, 65, 76, 84, 89, 126, 246, 110, 128, 3, 30, 168, 172, 160, 198, 176, 136, 62, 152, 155, 253, 176, 16, 48, 52, 246, 44, 29, 151, 47, 217, 226, 178, 12, 218, 113, 248, 172, 195, 232, 136, 104, 9, 199, 20, 4, 71, 4, 253, 249} - err := c.readConfig(bytes.NewReader(confBytes), keyProvider) + err := c.readConfig(bytes.NewReader(confBytes)) require.NoError(t, err) assert.Equal(t, "test", c.Name) // Change the salt confBytes[20] = 0 - err = c.readConfig(bytes.NewReader(confBytes), keyProvider) + err = c.readConfig(bytes.NewReader(confBytes)) require.ErrorIs(t, err, errDecryptFailed) } diff --git a/config/config_test.go b/config/config_test.go index b071f8d6c24..42fc2d7b3a2 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1564,11 +1564,11 @@ func TestReadConfigFromReader(t *testing.T) { t.Parallel() c := &Config{} confString := `{"name":"test"}` - err := c.readConfig(strings.NewReader(confString), Unencrypted) + err := c.readConfig(strings.NewReader(confString)) require.NoError(t, err) assert.Equal(t, "test", c.Name) - err = c.readConfig(strings.NewReader("{}"), Unencrypted) + err = c.readConfig(strings.NewReader("{}")) require.NoError(t, err, "Reading a config shorter than encryptionPrefix should not error EOF") } diff --git a/config/config_types.go b/config/config_types.go index 4951b066560..f1f8cd8403a 100644 --- a/config/config_types.go +++ b/config/config_types.go @@ -119,10 +119,14 @@ type Config struct { Cryptocurrencies *currency.Currencies `json:"cryptocurrencies,omitempty"` SMS *base.SMSGlobalConfig `json:"smsGlobal,omitempty"` // encryption session values - storedSalt []byte - sessionDK []byte + storedSalt []byte + sessionDK []byte + EncryptionKeyProvider EncryptionKeyProvider `json:"-"` } +// EncryptionKeyProvider is a function config can use to prompt the user for an encryption key +type EncryptionKeyProvider func(confirmKey bool) ([]byte, error) + // OrderManager holds settings used for the order manager type OrderManager struct { Enabled *bool `json:"enabled"` diff --git a/go.mod b/go.mod index d0cc3285208..74e9dd5689b 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/volatiletech/null v8.0.0+incompatible golang.org/x/crypto v0.28.0 golang.org/x/net v0.30.0 + golang.org/x/term v0.25.0 golang.org/x/text v0.19.0 golang.org/x/time v0.7.0 google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38 diff --git a/go.sum b/go.sum index 5855f98a686..8bca7ee7518 100644 --- a/go.sum +++ b/go.sum @@ -312,6 +312,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=