- `pulumi config ls` now does not prompt for a passphrase if there are secrets, instead ******'s are shown. `--show-secrets` can be passed to force decryption. The behavior of `pulumi config ls <key>` is unchanged, if the key is secure, we will prompt for a passphrase. - `pulumi config secret <key>` now prompts for the passphrase and verifies it before asking for the secret value. Fixes #465
155 lines
4.3 KiB
Go
155 lines
4.3 KiB
Go
package cmd
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
cryptorand "crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/pulumi/pulumi/pkg/resource/config"
|
|
"github.com/pulumi/pulumi/pkg/util/contract"
|
|
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const aes256GCMKeyBytes = 32
|
|
|
|
// blindingDecrypter is a config.ValueDecrypter that instead of decrypting data, just returns "********", it can
|
|
// be used when you want to display configuration information to a user but don't want to prompt for a password
|
|
// so secrets will not be decrypted.
|
|
type blindingDecrypter struct{}
|
|
|
|
func (b blindingDecrypter) DecryptValue(ciphertext string) (string, error) {
|
|
return "********", nil
|
|
}
|
|
|
|
// This implements to config.EncrypterDecrypter interface but panics if any methods are called.
|
|
type panicCrypter struct{}
|
|
|
|
func (p panicCrypter) EncryptValue(plaintext string) (string, error) {
|
|
panic("attempt to encrypt value")
|
|
}
|
|
|
|
func (p panicCrypter) DecryptValue(ciphertext string) (string, error) {
|
|
panic("attempt to decrypt value")
|
|
}
|
|
|
|
// symmetricCrypter encrypts and decrypts values using AES-256-GCM. The nonce is stored with the value itself as a pair of base64 values
|
|
// separated by a colon and a version tag `v1` is prepended.
|
|
type symmetricCrypter struct {
|
|
key []byte
|
|
}
|
|
|
|
func (s symmetricCrypter) EncryptValue(value string) (string, error) {
|
|
secret, nonce := encryptAES256GCGM(value, s.key)
|
|
|
|
return fmt.Sprintf("v1:%s:%s", base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(secret)), nil
|
|
}
|
|
|
|
func (s symmetricCrypter) DecryptValue(value string) (string, error) {
|
|
vals := strings.Split(value, ":")
|
|
|
|
if len(vals) != 3 {
|
|
return "", errors.New("bad value")
|
|
}
|
|
|
|
if vals[0] != "v1" {
|
|
return "", errors.New("unknown value version")
|
|
}
|
|
|
|
nonce, err := base64.StdEncoding.DecodeString(vals[1])
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "bad value")
|
|
}
|
|
|
|
enc, err := base64.StdEncoding.DecodeString(vals[2])
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "bad value")
|
|
}
|
|
|
|
return decryptAES256GCM(enc, s.key, nonce)
|
|
}
|
|
|
|
// given a passphrase and an encryption state, construct a config.ValueEncrypterDecrypter from it. Our encryption
|
|
// state value is a version tag followed by version specific state information. Presently, we only have one version
|
|
// we support (`v1`) which is AES-256-GCM using a key derived from a passphrase using 1,000,000 iterations of PDKDF2
|
|
// using SHA256.
|
|
func symmetricCrypterFromPhraseAndState(phrase string, state string) (config.ValueEncrypterDecrypter, error) {
|
|
splits := strings.SplitN(state, ":", 3)
|
|
if len(splits) != 3 {
|
|
return nil, errors.New("malformed state value")
|
|
}
|
|
|
|
if splits[0] != "v1" {
|
|
return nil, errors.New("unknown state version")
|
|
}
|
|
|
|
salt, err := base64.StdEncoding.DecodeString(splits[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
key := keyFromPassPhrase(phrase, salt, aes256GCMKeyBytes)
|
|
|
|
decrypter := symmetricCrypter{key: key}
|
|
|
|
decrypted, err := decrypter.DecryptValue(state[indexN(state, ":", 2)+1:])
|
|
|
|
if err != nil || decrypted != "pulumi" {
|
|
return nil, errors.New("incorrect passphrase")
|
|
}
|
|
|
|
return symmetricCrypter{key: key}, nil
|
|
}
|
|
|
|
func indexN(s string, substr string, n int) int {
|
|
contract.Require(n > 0, "n")
|
|
scratch := s
|
|
|
|
for i := n; i > 0; i-- {
|
|
idx := strings.Index(scratch, substr)
|
|
if i == -1 {
|
|
return -1
|
|
}
|
|
|
|
scratch = scratch[idx+1:]
|
|
}
|
|
|
|
return len(s) - (len(scratch) + len(substr))
|
|
}
|
|
|
|
// encryptAES256GCGM returns the ciphertext and the generated nonce
|
|
func encryptAES256GCGM(plaintext string, key []byte) ([]byte, []byte) {
|
|
contract.Requiref(len(key) == aes256GCMKeyBytes, "key", "AES-256-GCM needs a 32 byte key")
|
|
|
|
nonce := make([]byte, 12)
|
|
|
|
_, err := cryptorand.Read(nonce)
|
|
contract.Assertf(err == nil, "could not read from system random source")
|
|
|
|
block, err := aes.NewCipher(key)
|
|
contract.Assert(err == nil)
|
|
|
|
aesgcm, err := cipher.NewGCM(block)
|
|
contract.Assert(err == nil)
|
|
|
|
msg := aesgcm.Seal(nil, nonce, []byte(plaintext), nil)
|
|
|
|
return msg, nonce
|
|
}
|
|
|
|
func decryptAES256GCM(ciphertext []byte, key []byte, nonce []byte) (string, error) {
|
|
contract.Requiref(len(key) == aes256GCMKeyBytes, "key", "AES-256-GCM needs a 32 byte key")
|
|
|
|
block, err := aes.NewCipher(key)
|
|
contract.Assert(err == nil)
|
|
|
|
aesgcm, err := cipher.NewGCM(block)
|
|
contract.Assert(err == nil)
|
|
|
|
msg, err := aesgcm.Open(nil, nonce, ciphertext, nil)
|
|
|
|
return string(msg), err
|
|
}
|