diff --git a/agent/config/config.go b/agent/config/config.go index b87d278133..56702d0f4b 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -170,7 +170,8 @@ type Config struct { WindowConnectedTime time.Duration `yaml:"window-connected-time"` - Setup Setup `yaml:"-"` + Setup Setup `yaml:"-"` + Encryption Encryption `yaml:"-"` } // ConfigFileDoesNotExistError error is returned from Get method if configuration file is expected, @@ -335,7 +336,7 @@ func get(args []string, cfg *Config, l *logrus.Entry) (string, error) { //nolint return configFileF, err } l.Infof("Loading configuration file %s.", configFileF) - fileCfg, err := loadFromFile(configFileF) + fileCfg, err := loadFromFile(configFileF, &cfg.Encryption) if err != nil { return configFileF, err } @@ -365,6 +366,10 @@ func Application(cfg *Config) (*kingpin.Application, *string) { configFileF := app.Flag("config-file", "Configuration file path [PMM_AGENT_CONFIG_FILE]"). Envar("PMM_AGENT_CONFIG_FILE").PlaceHolder("").String() + app.Flag("config-file-key-file", "Path to the key file used to encrypt/decrypt the configuration file"). + Envar("PMM_AGENT_CONFIG_FILE_KEY_FILE").StringVar(&cfg.Encryption.KeyFile) + app.Flag("config-file-key-password", "Password for the key file (if required)"). + Envar("PMM_AGENT_CONFIG_FILE_KEY_PASSWORD").StringVar(&cfg.Encryption.KeyFilePassword) app.Flag("id", "ID of this pmm-agent [PMM_AGENT_ID]"). Envar("PMM_AGENT_ID").StringVar(&cfg.ID) @@ -526,7 +531,7 @@ func Application(cfg *Config) (*kingpin.Application, *string) { // As a special case, if file does not exist, it returns ConfigFileDoesNotExistError. // Other errors are returned if file exists, but configuration can't be loaded due to permission problems, // YAML parsing problems, etc. -func loadFromFile(path string) (*Config, error) { +func loadFromFile(path string, enc *Encryption) (*Config, error) { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return nil, ConfigFileDoesNotExistError(path) } @@ -535,6 +540,15 @@ func loadFromFile(path string) (*Config, error) { if err != nil { return nil, err } + + encryptionEnabled := enc != nil && len(enc.KeyFile) > 0 && len(b) > 0 + if encryptionEnabled { + b, err = enc.Decrypt(b) + if err != nil { + return nil, err + } + } + cfg := &Config{} if err = yaml.Unmarshal(b, cfg); err != nil { //nolint:musttag // false positive return nil, err @@ -556,6 +570,14 @@ func SaveToFile(path string, cfg *Config, comment string) error { } res = append(res, "---\n"...) res = append(res, b...) + encryptionEnabled := cfg != nil && len(cfg.Encryption.KeyFile) > 0 + if encryptionEnabled { + res, err = cfg.Encryption.Encrypt(res) + if err != nil { + return err + } + } + return os.WriteFile(path, res, 0o640) //nolint:gosec } diff --git a/agent/config/config_test.go b/agent/config/config_test.go index b80210d54d..bf168a2bad 100644 --- a/agent/config/config_test.go +++ b/agent/config/config_test.go @@ -52,13 +52,13 @@ func TestLoadFromFile(t *testing.T) { name := writeConfig(t, &Config{ID: "agent-id"}) t.Cleanup(func() { removeConfig(t, name) }) - cfg, err := loadFromFile(name) + cfg, err := loadFromFile(name, nil) require.NoError(t, err) assert.Equal(t, &Config{ID: "agent-id"}, cfg) }) t.Run("NotExist", func(t *testing.T) { - cfg, err := loadFromFile("not-exist.yaml") + cfg, err := loadFromFile("not-exist.yaml", nil) assert.Equal(t, ConfigFileDoesNotExistError("not-exist.yaml"), err) assert.Nil(t, cfg) }) @@ -68,7 +68,7 @@ func TestLoadFromFile(t *testing.T) { require.NoError(t, os.Chmod(name, 0o000)) t.Cleanup(func() { removeConfig(t, name) }) - cfg, err := loadFromFile(name) + cfg, err := loadFromFile(name, nil) require.IsType(t, (*os.PathError)(nil), err) assert.Equal(t, "open", err.(*os.PathError).Op) //nolint:errorlint require.EqualError(t, err.(*os.PathError).Err, "permission denied") //nolint:errorlint @@ -80,7 +80,7 @@ func TestLoadFromFile(t *testing.T) { require.NoError(t, os.WriteFile(name, []byte(`not YAML`), 0o666)) //nolint:gosec t.Cleanup(func() { removeConfig(t, name) }) - cfg, err := loadFromFile(name) + cfg, err := loadFromFile(name, nil) require.IsType(t, (*yaml.TypeError)(nil), err) require.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `not YAML` into config.Config") assert.Nil(t, cfg) diff --git a/agent/config/encryption.go b/agent/config/encryption.go new file mode 100644 index 0000000000..4aab6096e9 --- /dev/null +++ b/agent/config/encryption.go @@ -0,0 +1,122 @@ +package config + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/pem" + "io" + "os" + + "github.com/pkg/errors" + "github.com/youmark/pkcs8" +) + +type Encryption struct { + KeyFile string + KeyFilePassword string +} + +const gcmNonceSize = 12 + +func (enc Encryption) Encrypt(plain []byte) ([]byte, error) { + priv, err := enc.readKeyFile() + if err != nil { + return nil, errors.Wrap(err, "unable to get RSA key from KeyFile") + } + + aesKey := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, aesKey); err != nil { + return nil, errors.Wrap(err, "unable to generate AES key") + } + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, errors.Wrap(err, "unable to init AES") + } + gcm, err := cipher.NewGCMWithNonceSize(block, gcmNonceSize) + if err != nil { + return nil, errors.Wrap(err, "unable to init GCM") + } + nonce := make([]byte, gcmNonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, errors.Wrap(err, "unable to generate nonce") + } + + ciphertext := gcm.Seal(nil, nonce, plain, nil) + + wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &priv.PublicKey, aesKey, nil) + if err != nil { + return nil, errors.Wrap(err, "unable to RSA-wrap AES key") + } + + out := make([]byte, 0, len(wrappedKey)+len(nonce)+len(ciphertext)) + out = append(out, wrappedKey...) + out = append(out, nonce...) + out = append(out, ciphertext...) + return out, nil +} + +func (enc Encryption) Decrypt(in []byte) ([]byte, error) { + priv, err := enc.readKeyFile() + if err != nil { + return nil, errors.Wrap(err, "unable to get RSA key from KeyFile") + } + + k := priv.PublicKey.Size() + if len(in) < k+gcmNonceSize+1 { + return nil, errors.New("ciphertext too short") + } + + wrappedKey := in[:k] + nonce := in[k : k+gcmNonceSize] + ciphertext := in[k+gcmNonceSize:] + + aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, wrappedKey, nil) + if err != nil { + return nil, errors.Wrap(err, "unable to RSA-unwrap AES key") + } + if len(aesKey) != 32 { + return nil, errors.Errorf("unexpected AES key length: %d", len(aesKey)) + } + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, errors.Wrap(err, "unable to init AES") + } + gcm, err := cipher.NewGCMWithNonceSize(block, gcmNonceSize) + if err != nil { + return nil, errors.Wrap(err, "unable to init GCM") + } + + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.Wrap(err, "unable to decrypt (wrong key or data tampered)") + } + return plain, nil +} + +func (enc Encryption) readKeyFile() (*rsa.PrivateKey, error) { + f, err := os.ReadFile(enc.KeyFile) + if err != nil { + return nil, errors.Wrap(err, "unable to read KeyFile") + } + + block, _ := pem.Decode(f) + if block == nil { + return nil, errors.New("no valid private key found in a KeyFile") + } + + k, err := pkcs8.ParsePKCS8PrivateKey(block.Bytes, []byte(enc.KeyFilePassword)) + if err != nil { + return nil, errors.Wrap(err, "unable to parse private key") + } + + rsaKey, ok := k.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + return rsaKey, nil +} diff --git a/agent/config/encryption_test.go b/agent/config/encryption_test.go new file mode 100644 index 0000000000..a86b396ba8 --- /dev/null +++ b/agent/config/encryption_test.go @@ -0,0 +1,116 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const rsaKey = ` +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAL7XQZoBKba6RYXp +WsmX8kaeX4OBsNyqUrho3eh85jdEpfAYwzlKWNy3aboDre85ugwMEt5yMFOXL96r +9KnFrp4KpEacfJJdcoAPncAIslb0anwhhXOoc9ZIgz7AyJMKUN7PQSC4VpSP/86w +CShq5cEyRPbs4CwnQ5yYtlqCWQi1AgMBAAECgYA3EuXSrN0953mi0JorrVb0vEWy +LN4+gETJBTJtIoZJkt0UcgD86pDEeYXgcaljbVRcn6teWLPLm8jryNIdoHfoknIB +crf6vemmlP80Lpw2cdg46Q9lcleGTwJGOd+R2QSWJLV7kPrhhR+wIw6m7TDHvhGU +yx9AY4GrAMSrf4wTbQJBAN+3KuNssRBRZXKBDMuCAwgr8hSTNmjivRGmmATv3MGF +cCey/PbvQQ1jPRViSUysmFumOTE59GlcmB6TQQeUXF8CQQDaYZWIuGuAenI4pz+w +BR8JyJs5N52/YmsxTe2XShqksiiyvaHxNJ1mvSVEorPqEwfMwwFLI+cq0rh7YwZd +QLNrAkAV5QlPhL23iR/SmwqziB/f1t00Ykv66+XxKkrKgOcsEXEukXfsevH063d4 +9kuSM3odziDezns7LJK+u06r/TslAkEAjowiStNuwLestVRe2ywMnZtHz2qBWvsI +U2+1xgqGJ7lvnXTxL3yTvgt7NzkpTYLMlZk4z+6Ip8hSyZ/S+K4SLwJAPO8sd5U8 +mfvGLl15b1BI4o86X4r+HC4Nwb33i4eVBM06YBYbxSKUu9E9lWYiU1wCgPA7+T75 +TF1yxL9OKxrMpQ== +-----END PRIVATE KEY----- +` + +const rsaPasswordKey = ` +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIC5TBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQ1vE1Ke5aqPfS+DEA +U0f79QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEKoYunBmlc2MEbT2 +aaTRrNcEggKAKAmW5WL80flLmh+llCu4claeq5PT3DfrhUvL0UkdXDVV70aIfOMr +C7d28usIcfbguEtyPA72oWraNpot0u8z6SxVEZ0lFz4tk4eG8II1vLuzSXsswM0q +JKBGwbkwPVFIq21pK1vmfvWpA6gTTd7QU4YKInq1eXuoxHkdDC37ryXEUx/tEd+s +Gd54Zs1QXh2k0jSzTaOLlUaez9ADT1Nk9pS9Fj2aUSns7xXVXKYMYxpEYkUfd7xK +mowC0q0L/av9Xoj+EI5H0f9CdbufMpCe9GPAPShEEkV2feZsuigneAAYiIyAOMUh +T954t9M/rsSN5jAAZ/syaC6bDblx+nL6hDUcQjLyUJah7GRGwt5xexgMtfZccS8n +dp0DNG1gJjhK1QaP0BNBslMGlrbEoIkMn3MbmKVwnmKK/AM9kqalJV45RAogXpTG +lMhJ2bTpgx0CfIzalVEWc3eo7E0tf+Q//OHOptCx/u/sjeO5ZScyYt4SCiRsPYbJ +MjRQd3w3rQj5dW8f7ewDOf1xjueJ6q9sNL0f1OXgmCwq6MUbA2EpOMorETuZaATy +pLhDt8QNTp75NfENGbR+Uk+CoezmxS6KKGEG6P7SAvXnBHY4TGPNQq+pxaWxu6PB ++AS+K3t64XElJT99IB6rGjjpODQRQhk3IiX7hsJpG3KsTi2/sG/ZUkHJ5p7SDiK8 +/q7EECYgQoqkQ4CFXbF1+Nz0fVIkX3FfRgJtKeuxNnjsY/CXnb1PERendg+6U0sp +e60uBuuy8TnXdkC3MIZyuc0iuZkw+XXdgZctv3bcAmkMDIcm0wNX5248N6tWcBUb +9j2emDUrENFV+8s8F+Mkj0mpoB+lre7Tww== +-----END ENCRYPTED PRIVATE KEY----- +` + +func writeKey(t *testing.T, keyname, key string) string { + t.Helper() + path := filepath.Join(t.TempDir(), keyname) + require.NoError(t, os.WriteFile(path, []byte(key), 0o600)) + return path +} + +func TestEncryption(t *testing.T) { + + t.Run("Encrypted", func(t *testing.T) { + key := writeKey(t, "key", rsaKey) + enc := Encryption{ + KeyFile: key, + } + configfilef := writeConfig(t, &Config{ID: "agent-id", Encryption: enc}) + cfg, err := loadFromFile(configfilef, &enc) + require.NoError(t, err) + assert.Equal(t, &Config{ID: "agent-id"}, cfg) + + }) + + t.Run("EncryptedPassword", func(t *testing.T) { + key := writeKey(t, "key", rsaPasswordKey) + enc := Encryption{ + KeyFile: key, + KeyFilePassword: "abcdefgh", + } + configfilef := writeConfig(t, &Config{ID: "agent-id", Encryption: enc}) + cfg, err := loadFromFile(configfilef, &enc) + require.NoError(t, err) + assert.Equal(t, &Config{ID: "agent-id"}, cfg) + + }) + + t.Run("EncryptedWrongPassword", func(t *testing.T) { + key := writeKey(t, "key", rsaPasswordKey) + configfilef := writeConfig(t, &Config{ID: "agent-id", Encryption: Encryption{ + KeyFile: key, + KeyFilePassword: "abcdefgh", + }}) + + cfg, err := loadFromFile(configfilef, &Encryption{ + KeyFile: key, + KeyFilePassword: "hgfedcba", + }) + require.EqualError(t, err, "unable to get RSA key from KeyFile: unable to parse private key: pkcs8: incorrect password") + assert.Nil(t, cfg) + }) + + t.Run("EncryptedWrongKey", func(t *testing.T) { + key1 := writeKey(t, "key1", rsaPasswordKey) + key2 := writeKey(t, "key2", rsaKey) + + configfilef := writeConfig(t, &Config{ID: "agent-id", Encryption: Encryption{ + KeyFile: key2, + }}) + cfg, err := loadFromFile(configfilef, &Encryption{ + KeyFile: key1, + KeyFilePassword: "abcdefgh", + }) + require.EqualError(t, err, "unable to RSA-unwrap AES key: crypto/rsa: decryption error") + assert.Nil(t, cfg) + }) + +}