diff --git a/backend/local/backend.go b/backend/local/backend.go index abb4d37c9ff3..e3223db9467d 100644 --- a/backend/local/backend.go +++ b/backend/local/backend.go @@ -37,6 +37,14 @@ type Local struct { CLI cli.Ui CLIColor *colorstring.Colorize + // PasswordFilePath is the location of a password file + // used to encrypt / decrypt state, when provided + PasswordFilePath string + + // If true, then state should be written out encrypted, otherwise written + // out as cleartext or decrypted + Seal bool + // The State* paths are set from the backend config, and may be left blank // to use the defaults. If the actual paths for the local backend state are // needed, use the StatePaths method. @@ -113,6 +121,12 @@ func NewWithBackend(backend backend.Backend) *Local { Default: "", }, + "seal": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "workspace_dir": &schema.Schema{ Type: schema.TypeString, Optional: true, @@ -411,6 +425,64 @@ func (b *Local) Colorize() *colorstring.Colorize { } } +func (b *Local) schemaConfigure(ctx context.Context) error { + d := schema.FromContextBackendConfig(ctx) + + passwordFilePathRaw, ok := d.GetOk("password_file_path") + if ok { + passwordFilePath := passwordFilePathRaw.(string) + if passwordFilePath == "" { + log.Printf("[WARN] Configured password file path is %v", passwordFilePath) + return fmt.Errorf("configured password_file_path is empty") + } + log.Printf("[INFO] Using password file; will encrypte state: %v\n", passwordFilePath) + b.PasswordFilePath = passwordFilePath + } else { + log.Printf("[INFO] Not using pasword file, so not decrypting state\n") + } + + seal, ok := d.GetOk("seal") + if ok { + value, ok := seal.(bool) + if ok { + b.Seal = value + } else { + log.Printf("[INFO] Seal flag not boolean; will not write sealed state\n") + } + } else { + log.Printf("[INFO] Seal flag not set; will not write sealed state\n") + } + + // Set the path if it is set + pathRaw, ok := d.GetOk("path") + if ok { + path := pathRaw.(string) + if path == "" { + return fmt.Errorf("configured path is empty") + } + + b.StatePath = path + b.StateOutPath = path + } + + if raw, ok := d.GetOk("workspace_dir"); ok { + path := raw.(string) + if path != "" { + b.StateWorkspaceDir = path + } + } + + // Legacy name, which ConflictsWith workspace_dir + if raw, ok := d.GetOk("environment_dir"); ok { + path := raw.(string) + if path != "" { + b.StateWorkspaceDir = path + } + } + + return nil +} + // StatePaths returns the StatePath, StateOutPath, and StateBackupPath as // configured from the CLI. func (b *Local) StatePaths(name string) (string, string, string) { diff --git a/state/backup.go b/state/backup.go index 047258f4dba4..621aa166ca90 100644 --- a/state/backup.go +++ b/state/backup.go @@ -11,9 +11,11 @@ import ( // // If Path exists, it will be overwritten. type BackupState struct { - mu sync.Mutex - Real State - Path string + mu sync.Mutex + Real State + Path string + PasswordFilePath string + Seal bool done bool } @@ -74,7 +76,11 @@ func (s *BackupState) backup() error { // purposes, but we don't need a backup or lock if the state is empty, so // skip this with a nil state. if state != nil { - ls := &LocalState{Path: s.Path} + ls := &LocalState{ + Path: s.Path, + PasswordFilePath: s.PasswordFilePath, + Seal: s.Seal, + } if err := ls.WriteState(state); err != nil { return err } diff --git a/state/local.go b/state/local.go index 100feea1fdd1..a4b399fa0d1c 100644 --- a/state/local.go +++ b/state/local.go @@ -25,6 +25,14 @@ type LocalState struct { Path string PathOut string + // PasswordFilePath is the location of a password file + // used to encrypt / decrypt state, when provided + PasswordFilePath string + + // If true, then state should be written out encrypted, otherwise written + // out as cleartext or decrypted + Seal bool + // the file handle corresponding to PathOut stateFileOut *os.File @@ -99,7 +107,7 @@ func (s *LocalState) WriteState(state *terraform.State) error { s.state.Serial++ } - if err := terraform.WriteState(s.state, s.stateFileOut); err != nil { + if err := terraform.WriteSealedState(s.state, s.stateFileOut, s.PasswordFilePath, s.Seal); err != nil { return err } @@ -160,7 +168,7 @@ func (s *LocalState) RefreshState() error { reader = s.stateFileOut } - state, err := terraform.ReadState(reader) + state, err := terraform.ReadSealedState(reader, s.PasswordFilePath) // if there's no state we just assign the nil return value if err != nil && err != terraform.ErrNoState { return err diff --git a/terraform/seal.go b/terraform/seal.go new file mode 100644 index 000000000000..8f28776cb0a2 --- /dev/null +++ b/terraform/seal.go @@ -0,0 +1,222 @@ +package terraform + +import ( + "bufio" + "bytes" + aes "crypto/aes" + cipher "crypto/cipher" + "crypto/hmac" + srand "crypto/rand" + "crypto/sha256" + "encoding/base32" + "fmt" + "io" + "io/ioutil" + "log" + + home "github.com/mitchellh/go-homedir" + + pbkdf2 "golang.org/x/crypto/pbkdf2" +) + +const sealPrefix = "!seal!" +const keyGenerationIterations = 3 * 4096 +const keySize = 32 +const currentVersion = "001" + +// Format of sealed state is: +// !seal!!!! + +// WriteSealedState writes state in encrypted form onto the destination +// if rawPasswordFilePath is not nil +func WriteSealedState(d *State, dst io.Writer, rawPasswordFilePath string, encrypt bool) error { + if !encrypt { + return WriteState(d, dst) + } + if rawPasswordFilePath == "" { + return WriteState(d, dst) + } + password, err := readPassword(rawPasswordFilePath) + if err != nil { + return err + } + io.WriteString(dst, sealPrefix) + // fmt.Fprintf(dst, "%v!", currentVersion) + writeField(dst, []byte(currentVersion)) + return writeSealedStateV001(d, dst, password) +} + +func writeSealedStateV001(d *State, dst io.Writer, password []byte) error { + salt := make([]byte, keySize) + _, err := srand.Read(salt) + if err != nil { + return fmt.Errorf("Could not generate salt for encryption: %v", err) + } + if err := writeField(dst, salt); err != nil { + return fmt.Errorf("Could not write salt: %v", err) + } + key := generateKey(password, salt) + base32Encoder := base32.NewEncoder(base32.StdEncoding, dst) + defer base32Encoder.Close() + dec, err := serializeState(d) + if err != nil { + return fmt.Errorf("Could not serialize state: %v", err) + } + enc, err := encrypt(dec, key) + if err != nil { + return fmt.Errorf("Could not encrypt state: %v", err) + } + mac := sign(enc, key) + if err := writeField(dst, mac); err != nil { + return fmt.Errorf("Could not write HMAC signature: %v", err) + } + if _, err := base32Encoder.Write(enc); err != nil { + return fmt.Errorf("Could not encode encrypted state: %v", err) + } + return err +} + +// ReadSealedState reads state in encrypted from from the source +// if rawPasswordFilePath is not nil +func ReadSealedState(src io.Reader, rawPasswordFilePath string) (*State, error) { + if rawPasswordFilePath == "" { + log.Printf("[INFO] No password_file_path; not decrypting state") + return ReadState(src) + } + password, err := readPassword(rawPasswordFilePath) + if err != nil { + return nil, err + } + bufSrc := bufio.NewReader(src) + header, err := bufSrc.Peek(len(sealPrefix)) + if err != nil || string(header) != sealPrefix { + // assume not a sealed file, default to just reading state + log.Printf("[INFO] State not encrypted; no header found") + return ReadState(bufSrc) + } + // we assume we read the prefix, so from here on out it must be a well-formed + // sealed state + _, err = bufSrc.Discard(len(sealPrefix)) + if err != nil { + return nil, fmt.Errorf("Could not discard sealed header: %v", err) + } + rawVersion, err := readField(bufSrc) + if err != nil { + return nil, fmt.Errorf("Could not read seal version: %v", err) + } + version := string(rawVersion) + // Add additional version checks here + switch version { + case currentVersion: + return readSealedStateV001(password, bufSrc) + default: + return nil, fmt.Errorf("Seal version not recognized: %v", version) + } +} + +func readSealedStateV001(password []byte, bufSrc *bufio.Reader) (*State, error) { + salt, err := readField(bufSrc) + if err != nil { + return nil, fmt.Errorf("Could not decode salt: %v", err) + } + expectedMac, err := readField(bufSrc) + key := generateKey(password, salt) + base32Decoder := base32.NewDecoder(base32.StdEncoding, bufSrc) + enc, err := ioutil.ReadAll(base32Decoder) + if err != nil { + return nil, fmt.Errorf("Could not decode sealed state: %v", err) + } + actualMac := sign(enc, key) + if !hmac.Equal(expectedMac, actualMac) { + return nil, fmt.Errorf("HMAC signature not matched") + } + dec, err := decrypt(enc, key) + if err != nil { + return nil, fmt.Errorf("Could not decrypt state:%v", err) + } + buffer := bytes.NewBuffer(dec) + return ReadState(buffer) +} + +func writeField(dst io.Writer, rawData []byte) error { + base32Data := base32.StdEncoding.EncodeToString(rawData) + if _, err := io.WriteString(dst, base32Data); err != nil { + return fmt.Errorf("Could not write base32 encoded field: %v", err) + } + if _, err := io.WriteString(dst, "!"); err != nil { + return fmt.Errorf("Could not write field delimiter: %v", err) + } + return nil +} + +func readField(bufSrc *bufio.Reader) ([]byte, error) { + rawData, err := bufSrc.ReadBytes('!') + if err != nil { + return nil, fmt.Errorf("No delimiter found, could not read data: %v", err) + } + // trim the '!' off the end, and convert to string + base32Data := string(rawData[0 : len(rawData)-1]) + return base32.StdEncoding.DecodeString(base32Data) +} + +func serializeState(d *State) ([]byte, error) { + var buffer bytes.Buffer + err := WriteState(d, &buffer) + return buffer.Bytes(), err +} + +func deserializeState(data []byte) (*State, error) { + buffer := bytes.NewBuffer(data) + state, err := ReadState(buffer) + return state, err +} + +func sign(msg []byte, key []byte) []byte { + mac := hmac.New(sha256.New, key) + mac.Write(msg) + return mac.Sum(nil) +} + +func encrypt(dec []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("Could not create cipher for encryption: %v", err) + } + iv := make([]byte, aes.BlockSize) + stream := cipher.NewOFB(block, iv) + var buffer bytes.Buffer + encryptedDst := &cipher.StreamWriter{S: stream, W: &buffer} + if _, err := encryptedDst.Write(dec); err != nil { + return nil, fmt.Errorf("Could not encrypt: %v", err) + } + return buffer.Bytes(), nil +} + +func decrypt(enc []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("Could not create cipher for encryption: %v", err) + } + iv := make([]byte, aes.BlockSize) + stream := cipher.NewOFB(block, iv) + buffer := bytes.NewBuffer(enc) + decryptedSrc := &cipher.StreamReader{S: stream, R: buffer} + return ioutil.ReadAll(decryptedSrc) +} + +func readPassword(rawPasswordFilePath string) ([]byte, error) { + passwordFilePath, err := home.Expand(rawPasswordFilePath) + log.Printf("[INFO] Using password file %v", passwordFilePath) + if err != nil { + return nil, fmt.Errorf("Could not expand file path: %v", err) + } + password, err := ioutil.ReadFile(passwordFilePath) + if err != nil { + return nil, fmt.Errorf("Password file could not be read: %v", err) + } + return password, nil +} + +func generateKey(password []byte, salt []byte) []byte { + return pbkdf2.Key(password, salt, keyGenerationIterations, keySize, sha256.New) +} diff --git a/terraform/state_test.go b/terraform/state_test.go index 5dc5ee333f7f..de41e072b4de 100644 --- a/terraform/state_test.go +++ b/terraform/state_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io/ioutil" "os" "reflect" "sort" @@ -1670,6 +1671,78 @@ func TestReadWriteState(t *testing.T) { } } +func TestReadWriteSealedState(t *testing.T) { + state := &State{ + Serial: 9, + Lineage: "5d1ad1a1-4027-4665-a908-dbe6adff11d8", + Remote: &RemoteState{ + Type: "http", + Config: map[string]string{ + "url": "http://my-cool-server.com/", + }, + }, + Modules: []*ModuleState{ + &ModuleState{ + Path: rootModulePath, + Dependencies: []string{ + "aws_instance.bar", + }, + Resources: map[string]*ResourceState{ + "foo": &ResourceState{ + Primary: &InstanceState{ + ID: "bar", + Ephemeral: EphemeralState{ + ConnInfo: map[string]string{ + "type": "ssh", + "user": "root", + "password": "supersecret", + }, + }, + }, + }, + }, + }, + }, + } + state.init() + + passwordFile, err := ioutil.TempFile("", "password_file") + if err != nil { + t.Fatalf("Could not create tempfile: %v", err) + } + passwordFilePath := passwordFile.Name() + defer os.Remove(passwordFilePath) + passwordFile.WriteString("a password") + if _, err = passwordFile.Seek(0, 0); err != nil { + t.Fatalf("Could seek to beginning of file: %v", err) + } + + buf := new(bytes.Buffer) + if err := WriteSealedState(state, buf, passwordFilePath, true); err != nil { + t.Fatalf("err: %s", err) + } + + // Verify that the version and serial are set + if state.Version != StateVersion { + t.Fatalf("bad version number: %d", state.Version) + } + + actual, err := ReadSealedState(buf, passwordFilePath) + if err != nil { + t.Fatalf("err: %s", err) + } + + // ReadState should not restore sensitive information! + mod := state.RootModule() + mod.Resources["foo"].Primary.Ephemeral = EphemeralState{} + mod.Resources["foo"].Primary.Ephemeral.init() + + if !reflect.DeepEqual(actual, state) { + t.Logf("expected:\n%#v", state) + t.Fatalf("got:\n%#v", actual) + } +} + func TestReadStateNewVersion(t *testing.T) { type out struct { Version int diff --git a/vendor/golang.org/x/crypto/pbkdf2/pbkdf2.go b/vendor/golang.org/x/crypto/pbkdf2/pbkdf2.go new file mode 100644 index 000000000000..593f6530084f --- /dev/null +++ b/vendor/golang.org/x/crypto/pbkdf2/pbkdf2.go @@ -0,0 +1,77 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package pbkdf2 implements the key derivation function PBKDF2 as defined in RFC +2898 / PKCS #5 v2.0. + +A key derivation function is useful when encrypting data based on a password +or any other not-fully-random data. It uses a pseudorandom function to derive +a secure encryption key based on the password. + +While v2.0 of the standard defines only one pseudorandom function to use, +HMAC-SHA1, the drafted v2.1 specification allows use of all five FIPS Approved +Hash Functions SHA-1, SHA-224, SHA-256, SHA-384 and SHA-512 for HMAC. To +choose, you can pass the `New` functions from the different SHA packages to +pbkdf2.Key. +*/ +package pbkdf2 // import "golang.org/x/crypto/pbkdf2" + +import ( + "crypto/hmac" + "hash" +) + +// Key derives a key from the password, salt and iteration count, returning a +// []byte of length keylen that can be used as cryptographic key. The key is +// derived based on the method described as PBKDF2 with the HMAC variant using +// the supplied hash function. +// +// For example, to use a HMAC-SHA-1 based PBKDF2 key derivation function, you +// can get a derived key for e.g. AES-256 (which needs a 32-byte key) by +// doing: +// +// dk := pbkdf2.Key([]byte("some password"), salt, 4096, 32, sha1.New) +// +// Remember to get a good random salt. At least 8 bytes is recommended by the +// RFC. +// +// Using a higher iteration count will increase the cost of an exhaustive +// search but will also make derivation proportionally slower. +func Key(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte { + prf := hmac.New(h, password) + hashLen := prf.Size() + numBlocks := (keyLen + hashLen - 1) / hashLen + + var buf [4]byte + dk := make([]byte, 0, numBlocks*hashLen) + U := make([]byte, hashLen) + for block := 1; block <= numBlocks; block++ { + // N.B.: || means concatenation, ^ means XOR + // for each block T_i = U_1 ^ U_2 ^ ... ^ U_iter + // U_1 = PRF(password, salt || uint(i)) + prf.Reset() + prf.Write(salt) + buf[0] = byte(block >> 24) + buf[1] = byte(block >> 16) + buf[2] = byte(block >> 8) + buf[3] = byte(block) + prf.Write(buf[:4]) + dk = prf.Sum(dk) + T := dk[len(dk)-hashLen:] + copy(U, T) + + // U_n = PRF(password, U_(n-1)) + for n := 2; n <= iter; n++ { + prf.Reset() + prf.Write(U) + U = U[:0] + U = prf.Sum(U) + for x := range U { + T[x] ^= U[x] + } + } + } + return dk[:keyLen] +} diff --git a/vendor/vendor.json b/vendor/vendor.json index 40d3646b74d6..cf4e62bb9b4b 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -2551,6 +2551,12 @@ "revision": "9de5f2eaf759b4c4550b3db39fed2e9e5f86f45c", "revisionTime": "2018-02-11T11:39:43Z" }, + { + "checksumSHA1": "1MGpGDQqnUoRpv7VEcQrXOBydXE=", + "path": "golang.org/x/crypto/pbkdf2", + "revision": "13931e22f9e72ea58bb73048bc752b48c6d4d4ac", + "revisionTime": "2018-01-11T11:10:38Z" + }, { "checksumSHA1": "ZK4HWtg3hJzayz0RRcc6qHpkums=", "path": "golang.org/x/crypto/ssh", diff --git a/website/docs/backends/types/local.html.md b/website/docs/backends/types/local.html.md index 280f4118c3e1..2fcbccbb457d 100644 --- a/website/docs/backends/types/local.html.md +++ b/website/docs/backends/types/local.html.md @@ -13,12 +13,32 @@ description: |- The local backend stores state on the local filesystem, locks that state using system APIs, and performs operations locally. +The local backend supports the concept of _sealing_ or writing state files (and backups) in an encrypted form using password-based encryption. Both `password_file_path` and `seal` must be set properly for encryption to occur, but can be altered individually to transition to / from an encrypted state. + +Sealing local state is useful for scenarios where its appropriate to share state through version control (e.g., committed to a git repository). While sharing state through version control does not offer any locking protections against conccurrent execution, it does provide a basic security mechanism for small teams to share essential state. Every team should evaluate for itself whether doing so meets the team's security requirements. The implementation of this feature is analagous to and inspired by _vaults_ in [Ansible](http://docs.ansible.com/ansible/latest/vault.html). For many scenarios, if the use of Ansible vaults are acceptable, then using Terraform with sealed state should also be acceptable. + +Transitioning to encrypted state can proceed with these steps: +* Set `password_file_path` in configuration +* Set `seal = true` in configuration +* Run `terraform init` to reinitialize the backend (any other command will prompt to do so anyway) +* Run `terraform apply` to apply any changes and write an encrypted state and an encrypted backup + +Once state has been encrypted, it can be decrypted with a similar series of steps: +* Leave `password_file_path` set in configuration +* Set `seal = false` in configuration +* Run `terraform init` to reinitialize the backend (any other command will prompt to do so anyway) +* Run `terraform apply` to apply any changes and write a decrypted state and a decrypted backup + +Note that because `password_file_path` supports referencing the home directory via `~`, then it's possible for keys to reside in a location separate from where actual Terraform configurations reside. Teams may find it useful to distribute keys separately through external, secure means, but require all team members to store such keys in identical locations so that backend configuration can reference the same path for all team members. + ## Example Configuration ```hcl terraform { backend "local" { path = "relative/path/to/terraform.tfstate" + password_file_path = "~/.terraform/my_password.txt" + seal = true } } ``` @@ -41,3 +61,5 @@ The following configuration options are supported: * `path` - (Optional) The path to the `tfstate` file. This defaults to "terraform.tfstate" relative to the root module by default. +* `password_file_path` - (Optional) The path to a file containing the password to use if state should be written encrypted. The file is read as bytes, and thus can be text or other data suitable for use as a password. If not present, then state will not be encrypted when written. The use of `~` to reference the home directory is supported. +* `seal` - (Optional) A boolean indicating whether to write state in encrypted form or not. A value of `true` will write state in encrypted form, a value of `false` will write state in cleartext or decrypted form. The default is `false`. If present but no `password_file_path` is present, then has no effect. \ No newline at end of file