Skip to content

Commit

Permalink
Encrypt all files containing secrets
Browse files Browse the repository at this point in the history
Fallback files and downloaded secrets will now be encrypted before touching the filesystem.
PBKDF2 is used against the (token, project, config) to generate the key.
AES-GCM is used with a random salt to encrypt the file.
  • Loading branch information
Piccirello committed Jan 18, 2020
1 parent 13ffe71 commit 78d6d0b
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 14 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/spf13/cobra v0.0.5
github.com/spf13/pflag v1.0.5 // indirect
go.mongodb.org/mongo-driver v1.1.2 // indirect
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5
golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c // indirect
gopkg.in/gookit/color.v1 v1.1.6
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d
Expand Down
20 changes: 18 additions & 2 deletions pkg/cmd/enclave_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strings"

"github.com/DopplerHQ/cli/pkg/configuration"
"github.com/DopplerHQ/cli/pkg/crypto"
"github.com/DopplerHQ/cli/pkg/http"
"github.com/DopplerHQ/cli/pkg/models"
"github.com/DopplerHQ/cli/pkg/printer"
Expand Down Expand Up @@ -230,9 +231,23 @@ $ doppler enclave secrets download --format=env --no-file`,
filePath = filepath.Join(".", "doppler.env")
}

err := ioutil.WriteFile(filePath, body, 0600)
utils.LogDebug("Encrypting Enclave secrets")
passphrase := fmt.Sprintf("%s:%s:%s", localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value)
if cmd.Flags().Changed("passphrase") {
passphrase = cmd.Flag("passphrase").Value.String()
if passphrase == "" {
utils.HandleError(errors.New("invalid passphrase"))
}
}

encryptedBody, err := crypto.Encrypt(passphrase, body)
if err != nil {
utils.HandleError(err, "Unable to encrypt your secrets. No file has been written.")
}

err = ioutil.WriteFile(filePath, []byte(encryptedBody), 0600)
if err != nil {
utils.HandleError(err, "Unable to save file")
utils.HandleError(err, "Unable to write the secrets file")
}

if !silent {
Expand Down Expand Up @@ -269,6 +284,7 @@ func init() {
secretsDownloadCmd.Flags().StringP("project", "p", "", "enclave project (e.g. backend)")
secretsDownloadCmd.Flags().StringP("config", "c", "", "enclave config (e.g. dev)")
secretsDownloadCmd.Flags().String("format", "json", "output format. one of [json, env]")
secretsDownloadCmd.Flags().String("passphrase", "", "passphrase to use for encrypting the secrets file. by default the passphrase is computed using your current configuration.")
secretsDownloadCmd.Flags().Bool("no-file", false, "print the response to stdout; don't save to a file")
secretsDownloadCmd.Flags().Bool("silent", false, "do not output the response")
secretsCmd.AddCommand(secretsDownloadCmd)
Expand Down
62 changes: 51 additions & 11 deletions pkg/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"time"

"github.com/DopplerHQ/cli/pkg/configuration"
"github.com/DopplerHQ/cli/pkg/crypto"
"github.com/DopplerHQ/cli/pkg/http"
"github.com/DopplerHQ/cli/pkg/models"
"github.com/DopplerHQ/cli/pkg/utils"
Expand Down Expand Up @@ -60,6 +61,7 @@ doppler run --token=123 -- printenv`,
fallbackPath = utils.GetFilePath(cmd.Flag("fallback").Value.String(), "")
} else {
fallbackPath = defaultFallbackFile(localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value)

if enableFallback && !utils.Exists(DefaultFallbackDir) {
err := os.Mkdir(DefaultFallbackDir, 0700)
if err != nil && exitOnWriteFailure {
Expand All @@ -70,17 +72,29 @@ doppler run --token=123 -- printenv`,
if fallbackPath == "" {
utils.HandleError(errors.New("invalid fallback file path"), "")
}
absFallbackPath, err := filepath.Abs(fallbackPath)
if err == nil {
fallbackPath = absFallbackPath
}

passphrase := fmt.Sprintf("%s:%s:%s", localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value)
if cmd.Flags().Changed("passphrase") {
passphrase = cmd.Flag("passphrase").Value.String()
if passphrase == "" {
utils.HandleError(errors.New("invalid passphrase"))
}
}

if !enableFallback {
flags := []string{"fallback", "fallback-only", "fallback-readonly", "no-exit-on-write-failure"}
flags := []string{"fallback", "fallback-only", "fallback-readonly", "no-exit-on-write-failure", "passphrase"}
for _, flag := range flags {
if cmd.Flags().Changed(flag) {
utils.Log(fmt.Sprintf("Warning: --%s has no effect when the fallback file is disabled", flag))
}
}
}

secrets := getSecrets(cmd, localConfig, enableFallback, fallbackPath, fallbackReadonly, fallbackOnly, exitOnWriteFailure)
secrets := getSecrets(cmd, localConfig, enableFallback, fallbackPath, fallbackReadonly, fallbackOnly, exitOnWriteFailure, passphrase)

env := os.Environ()
excludedKeys := []string{"PATH", "PS1", "HOME"}
Expand Down Expand Up @@ -167,17 +181,17 @@ var runCleanCmd = &cobra.Command{
},
}

func getSecrets(cmd *cobra.Command, localConfig models.ScopedOptions, enableFallback bool, fallbackPath string, fallbackReadonly bool, fallbackOnly bool, exitOnWriteFailure bool) map[string]string {
func getSecrets(cmd *cobra.Command, localConfig models.ScopedOptions, enableFallback bool, fallbackPath string, fallbackReadonly bool, fallbackOnly bool, exitOnWriteFailure bool, passphrase string) map[string]string {
fetchSecrets := !(enableFallback && fallbackOnly)
if !fetchSecrets {
return readFallbackFile(fallbackPath)
return readFallbackFile(fallbackPath, localConfig, passphrase)
}

response, httpErr := http.DownloadSecrets(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value, true)
if httpErr != (http.Error{}) {
if enableFallback {
utils.LogDebug("Failed to fetch secrets from the API")
return readFallbackFile(fallbackPath)
fmt.Println("Failed to fetch secrets from the API")
return readFallbackFile(fallbackPath, localConfig, passphrase)
}
utils.HandleError(httpErr.Unwrap(), httpErr.Message)
}
Expand All @@ -187,15 +201,21 @@ func getSecrets(cmd *cobra.Command, localConfig models.ScopedOptions, enableFall
if err != nil {
if enableFallback {
utils.LogDebug("Failed to parse the API response")
return readFallbackFile(fallbackPath)
return readFallbackFile(fallbackPath, localConfig, passphrase)
}
utils.HandleError(err, "Unable to parse API response")
}

writeFallbackFile := enableFallback && !fallbackReadonly
if writeFallbackFile {
utils.LogDebug("Encrypting Enclave secrets")
encryptedResponse, err := crypto.Encrypt(passphrase, response)
if err != nil {
utils.HandleError(err, "Unable to encrypt your secrets. No fallback file has been written.")
}

utils.LogDebug(fmt.Sprintf("Writing to fallback file %s", fallbackPath))
err := ioutil.WriteFile(fallbackPath, response, 0600)
err = ioutil.WriteFile(fallbackPath, []byte(encryptedResponse), 0600)
if err != nil {
utils.LogDebug("Failed to write to fallback file")
if exitOnWriteFailure {
Expand Down Expand Up @@ -232,7 +252,7 @@ func writeFailureMessage() []string {
return msg
}

func readFallbackFile(path string) map[string]string {
func readFallbackFile(path string, localConfig models.ScopedOptions, passphrase string) map[string]string {
utils.Log("Reading secrets from fallback file " + path)

if _, err := os.Stat(path); err != nil {
Expand All @@ -248,7 +268,26 @@ func readFallbackFile(path string) map[string]string {
utils.HandleError(err, "Unable to read fallback file")
}

secrets, err := parseSecrets(response)
utils.LogDebug("Decrypting fallback file")
decryptedSecrets, err := crypto.Decrypt(passphrase, response)
if err != nil {
var msg []string
msg = append(msg, "")
msg = append(msg, color.Green.Render("Why did decryption fail?"))
msg = append(msg, "The most common cause of decryption failure is using an incorrect passphrase.")
msg = append(msg, "By default, the passphrase consists of your doppler token, enclave project, and enclave config.")
msg = append(msg, "")
msg = append(msg, color.Green.Render("What should I do now?"))
msg = append(msg, "Ensure you're using the same scope that you used when creating the fallback file.")
msg = append(msg, "Alternatively, specify the same token, project, and config using the appropriate flags (e.g. --project).")
msg = append(msg, "")
msg = append(msg, "Run 'doppler run --help' for more info.")
msg = append(msg, "")

utils.HandleError(err, "Unable to decrypt the fallback file", strings.Join(msg, "\n"))
}

secrets, err := parseSecrets([]byte(decryptedSecrets))
if err != nil {
utils.HandleError(err, "Unable to parse fallback file")
}
Expand All @@ -263,7 +302,7 @@ func parseSecrets(response []byte) (map[string]string, error) {
}

func defaultFallbackFile(project string, config string) string {
fileName := fmt.Sprintf(".run-%s.json", utils.Hash(fmt.Sprintf("%s:%s", project, config)))
fileName := fmt.Sprintf(".run-%s.json", crypto.Hash(fmt.Sprintf("%s:%s", project, config)))
return filepath.Join(DefaultFallbackDir, fileName)
}

Expand All @@ -273,6 +312,7 @@ func init() {
runCmd.Flags().StringP("project", "p", "", "enclave project (e.g. backend)")
runCmd.Flags().StringP("config", "c", "", "enclave config (e.g. dev)")
runCmd.Flags().String("fallback", "", "write secrets to this file after connecting to Doppler. secrets will be read from this file if subsequent connections are unsuccessful.")
runCmd.Flags().String("passphrase", "", "passphrase to use for encrypting the fallback file. by default the passphrase is computed using your current configuration.")
runCmd.Flags().Bool("no-fallback", false, "do not read or write a fallback file")
runCmd.Flags().Bool("fallback-readonly", false, "do not create or modify the fallback file")
runCmd.Flags().Bool("fallback-only", false, "do not request secrets from Doppler. all secrets will be read from the fallback file")
Expand Down
114 changes: 114 additions & 0 deletions pkg/crypto/aes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
Copyright © 2020 Doppler <support@doppler.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// From https://gist.github.com/tscholl2/dc7dc15dc132ea70a98e8542fefffa28

package crypto

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"

"golang.org/x/crypto/pbkdf2"
)

func deriveKey(passphrase string, salt []byte) ([]byte, []byte, error) {
if salt == nil {
salt = make([]byte, 8)
// http://www.ietf.org/rfc/rfc2898.txt
// Salt.
_, err := rand.Read(salt)
if err != nil {
return nil, nil, err
}
}

return pbkdf2.Key([]byte(passphrase), salt, 50000, 32, sha256.New), salt, nil
}

// Encrypt plaintext with a passphrase; uses pbkdf2 for key deriv and aes-gcm for encryption
func Encrypt(passphrase string, plaintext []byte) (string, error) {
key, salt, err := deriveKey(passphrase, nil)
if err != nil {
return "", err
}

iv := make([]byte, 12)
// http://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
// Section 8.2
_, err = rand.Read(iv)
if err != nil {
return "", err
}

b, err := aes.NewCipher(key)
if err != nil {
return "", err
}

aesgcm, err := cipher.NewGCM(b)
if err != nil {
return "", err
}

data := aesgcm.Seal(nil, iv, plaintext, nil)
return hex.EncodeToString(salt) + "-" + hex.EncodeToString(iv) + "-" + hex.EncodeToString(data), nil
}

// Decrypt ciphertext with a passphrase
func Decrypt(passphrase string, ciphertext []byte) (string, error) {
arr := strings.Split(string(ciphertext), "-")
salt, err := hex.DecodeString(arr[0])
if err != nil {
return "", err
}

iv, err := hex.DecodeString(arr[1])
if err != nil {
return "", err
}

data, err := hex.DecodeString(arr[2])
if err != nil {
return "", err
}

key, _, err := deriveKey(passphrase, salt)
if err != nil {
return "", err
}

b, err := aes.NewCipher(key)
if err != nil {
return "", err
}

aesgcm, err := cipher.NewGCM(b)
if err != nil {
return "", err
}

data, err = aesgcm.Open(nil, iv, data, nil)
if err != nil {
return "", err
}

return string(data), nil
}
3 changes: 2 additions & 1 deletion pkg/utils/crypto.go → pkg/crypto/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils

package crypto

import (
"crypto/sha256"
Expand Down

0 comments on commit 78d6d0b

Please sign in to comment.