From 88012c4d961aa462bca26783780cd9793666e3ff Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Fri, 26 Apr 2019 12:00:35 -0700 Subject: [PATCH] Enable "cloud" and "local" secrets managers across the system We move the implementations of our secrets managers in to `pkg/secrets` (which is where the base64 one lives) and wire their use up during deserialization. It's a little unfortunate that for the passphrase based secrets manager, we have to require `PULUMI_CONFIG_PASSPHRASE` when constructing it from state, but we can make more progress with the changes as they are now, and I think we can come up with some ways to mitigate this problem a bit (at least make it only a problem for cases where you are trying to take a stack reference to another stack that is managed with local encryption). --- cmd/crypto.go | 2 +- cmd/crypto_http.go | 83 +---------------- cmd/crypto_local.go | 115 +++-------------------- pkg/resource/stack/deployment.go | 23 +++-- pkg/secrets/b64/manager.go | 4 +- pkg/secrets/passphrase/manager.go | 148 ++++++++++++++++++++++++++++++ pkg/secrets/service/manager.go | 130 ++++++++++++++++++++++++++ 7 files changed, 317 insertions(+), 188 deletions(-) create mode 100644 pkg/secrets/passphrase/manager.go create mode 100644 pkg/secrets/service/manager.go diff --git a/cmd/crypto.go b/cmd/crypto.go index 05f87d68a..6aced175d 100644 --- a/cmd/crypto.go +++ b/cmd/crypto.go @@ -45,7 +45,7 @@ func getStackDencrypter(s backend.Stack) (config.Decrypter, error) { func getStackSecretsManager(s backend.Stack) (secrets.Manager, error) { switch stack := s.(type) { case httpstate.Stack: - return newCloudSecretsManager(stack), nil + return newCloudSecretsManager(stack) case filestate.Stack: return newLocalSecretsManager(s.Ref().Name(), stackConfigFile) } diff --git a/cmd/crypto_http.go b/cmd/crypto_http.go index ea5b823a0..0620fb199 100644 --- a/cmd/crypto_http.go +++ b/cmd/crypto_http.go @@ -14,90 +14,15 @@ package cmd import ( - "context" - "encoding/base64" - - "github.com/pulumi/pulumi/pkg/util/contract" + "github.com/pulumi/pulumi/pkg/secrets/service" "github.com/pulumi/pulumi/pkg/backend/httpstate" - "github.com/pulumi/pulumi/pkg/backend/httpstate/client" - "github.com/pulumi/pulumi/pkg/resource/config" "github.com/pulumi/pulumi/pkg/secrets" ) -// cloudCrypter is an encrypter/decrypter that uses the Pulumi cloud to encrypt/decrypt a stack's secrets. -type cloudCrypter struct { - client *client.Client - stack client.StackIdentifier -} - -func newCloudCrypter(client *client.Client, stack client.StackIdentifier) config.Crypter { - return &cloudCrypter{client: client, stack: stack} -} - -func (c *cloudCrypter) EncryptValue(plaintext string) (string, error) { - ciphertext, err := c.client.EncryptValue(context.Background(), c.stack, []byte(plaintext)) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -func (c *cloudCrypter) DecryptValue(cipherstring string) (string, error) { - ciphertext, err := base64.StdEncoding.DecodeString(cipherstring) - if err != nil { - return "", err - } - plaintext, err := c.client.DecryptValue(context.Background(), c.stack, ciphertext) - if err != nil { - return "", err - } - return string(plaintext), nil -} - -type cloudSecretsManagerState struct { - URL string `json:"url,omitempty"` - Owner string `json:"owner"` - Project string `json:"project"` - Stack string `json:"stack"` -} - -var _ secrets.Manager = &cloudSecretsManager{} - -type cloudSecretsManager struct { - state cloudSecretsManagerState - crypter config.Crypter -} - -func (sm *cloudSecretsManager) Type() string { - return "pulumi" -} - -func (sm *cloudSecretsManager) State() interface{} { - return sm.state -} - -func (sm *cloudSecretsManager) Decrypter() (config.Decrypter, error) { - contract.Assert(sm.crypter != nil) - return sm.crypter, nil -} - -func (sm *cloudSecretsManager) Encrypter() (config.Encrypter, error) { - contract.Assert(sm.crypter != nil) - return sm.crypter, nil -} - -func newCloudSecretsManager(s httpstate.Stack) secrets.Manager { - b := s.Backend().(httpstate.Backend) +func newCloudSecretsManager(s httpstate.Stack) (secrets.Manager, error) { + client := s.Backend().(httpstate.Backend).Client() id := s.StackIdentifier() - return &cloudSecretsManager{ - state: cloudSecretsManagerState{ - URL: b.CloudURL(), - Owner: id.Owner, - Project: id.Project, - Stack: id.Stack, - }, - crypter: newCloudCrypter(b.Client(), id), - } + return service.NewCloudSecretsManager(client, id) } diff --git a/cmd/crypto_local.go b/cmd/crypto_local.go index 188ae688f..417f1c06f 100644 --- a/cmd/crypto_local.go +++ b/cmd/crypto_local.go @@ -18,12 +18,12 @@ import ( "encoding/base64" "fmt" "os" - "strings" "github.com/pkg/errors" "github.com/pulumi/pulumi/pkg/resource/config" "github.com/pulumi/pulumi/pkg/secrets" + "github.com/pulumi/pulumi/pkg/secrets/passphrase" "github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/util/cmdutil" "github.com/pulumi/pulumi/pkg/util/contract" @@ -37,21 +37,20 @@ func readPassphrase(prompt string) (string, error) { return cmdutil.ReadConsoleNoEcho(prompt) } -// symmetricCrypter gets the right value encrypter/decrypter for this project. -func symmetricCrypter(stackName tokens.QName, configFile string) (config.Crypter, string, error) { +func newLocalSecretsManager(stackName tokens.QName, configFile string) (secrets.Manager, error) { contract.Assertf(stackName != "", "stackName %s", "!= \"\"") if configFile == "" { f, err := workspace.DetectProjectStackPath(stackName) if err != nil { - return nil, "", err + return nil, err } configFile = f } info, err := workspace.LoadProjectStack(configFile) if err != nil { - return nil, "", err + return nil, err } // If we have a salt, we can just use it. @@ -59,28 +58,28 @@ func symmetricCrypter(stackName tokens.QName, configFile string) (config.Crypter phrase, phraseErr := readPassphrase("Enter your passphrase to unlock config/secrets\n" + " (set PULUMI_CONFIG_PASSPHRASE to remember)") if phraseErr != nil { - return nil, "", phraseErr + return nil, phraseErr } - crypter, crypterErr := symmetricCrypterFromPhraseAndState(phrase, info.EncryptionSalt) - if crypterErr != nil { - return nil, "", crypterErr + sm, smerr := passphrase.NewLocalSecretsManager(phrase, info.EncryptionSalt) + if smerr != nil { + return nil, smerr } - return crypter, info.EncryptionSalt, nil + return sm, nil } // Here, the stack does not have an EncryptionSalt, so we will get a passphrase and create one phrase, err := readPassphrase("Enter your passphrase to protect config/secrets") if err != nil { - return nil, "", err + return nil, err } confirm, err := readPassphrase("Re-enter your passphrase to confirm") if err != nil { - return nil, "", err + return nil, err } if phrase != confirm { - return nil, "", errors.New("passphrases do not match") + return nil, errors.New("passphrases do not match") } // Produce a new salt. @@ -96,95 +95,9 @@ func symmetricCrypter(stackName tokens.QName, configFile string) (config.Crypter // Now store the result and save it. info.EncryptionSalt = fmt.Sprintf("v1:%s:%s", base64.StdEncoding.EncodeToString(salt), msg) if err = info.Save(configFile); err != nil { - return nil, "", err - } - - return crypter, info.EncryptionSalt, nil -} - -// given a passphrase and an encryption state, construct a Crypter 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.Crypter, 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 } - decrypter := config.NewSymmetricCrypterFromPassphrase(phrase, salt) - decrypted, err := decrypter.DecryptValue(state[indexN(state, ":", 2)+1:]) - if err != nil || decrypted != "pulumi" { - return nil, errors.New("incorrect passphrase") - } - - return decrypter, 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)) -} - -type localSecretsManagerState struct { - Salt string `json:"salt"` -} - -var _ secrets.Manager = &localSecretsManager{} - -type localSecretsManager struct { - state localSecretsManagerState - crypter config.Crypter -} - -func (sm *localSecretsManager) Type() string { - return "passphrase" -} - -func (sm *localSecretsManager) State() interface{} { - return sm.state -} - -func (sm *localSecretsManager) Decrypter() (config.Decrypter, error) { - contract.Assert(sm.crypter != nil) - return sm.crypter, nil -} - -func (sm *localSecretsManager) Encrypter() (config.Encrypter, error) { - contract.Assert(sm.crypter != nil) - return sm.crypter, nil -} - -func newLocalSecretsManager(stackName tokens.QName, configFile string) (secrets.Manager, error) { - crypter, state, err := symmetricCrypter(stackName, configFile) - if err != nil { - return nil, err - } - - return &localSecretsManager{ - crypter: crypter, - state: localSecretsManagerState{ - Salt: state, - }, - }, nil + // Finally, build the full secrets manager from the state we just saved + return passphrase.NewLocalSecretsManager(phrase, info.EncryptionSalt) } diff --git a/pkg/resource/stack/deployment.go b/pkg/resource/stack/deployment.go index d6448f5d8..4630b708f 100644 --- a/pkg/resource/stack/deployment.go +++ b/pkg/resource/stack/deployment.go @@ -19,6 +19,8 @@ import ( "fmt" "reflect" + "github.com/pulumi/pulumi/pkg/secrets/service" + "github.com/blang/semver" "github.com/pkg/errors" "github.com/pulumi/pulumi/pkg/apitype" @@ -28,6 +30,7 @@ import ( "github.com/pulumi/pulumi/pkg/resource/deploy" "github.com/pulumi/pulumi/pkg/secrets" "github.com/pulumi/pulumi/pkg/secrets/b64" + "github.com/pulumi/pulumi/pkg/secrets/passphrase" "github.com/pulumi/pulumi/pkg/util/contract" "github.com/pulumi/pulumi/pkg/workspace" ) @@ -193,16 +196,24 @@ func DeserializeDeploymentV3(deployment apitype.DeploymentV3) (*deploy.Snapshot, var secretsManager secrets.Manager if deployment.SecretsProviders != nil { + var provider secrets.ManagerProvider + switch deployment.SecretsProviders.Type { - case "b64": - sm, err := b64.NewProvider().FromState(deployment.SecretsProviders.State) - if err != nil { - return nil, errors.Wrap(err, "creating secrets manager") - } - secretsManager = sm + case b64.Type: + provider = b64.NewProvider() + case passphrase.Type: + provider = passphrase.NewProvider() + case service.Type: + provider = service.NewProvider() default: return nil, errors.Errorf("unknown secrets provider type %s", deployment.SecretsProviders.Type) } + + sm, err := provider.FromState(deployment.SecretsProviders.State) + if err != nil { + return nil, errors.Wrap(err, "creating secrets manager from existing state") + } + secretsManager = sm } var dec config.Decrypter diff --git a/pkg/secrets/b64/manager.go b/pkg/secrets/b64/manager.go index eb63d1704..12fe8a3a6 100644 --- a/pkg/secrets/b64/manager.go +++ b/pkg/secrets/b64/manager.go @@ -22,6 +22,8 @@ import ( "github.com/pulumi/pulumi/pkg/secrets" ) +const Type = "b64" + type provider struct{} func (p *provider) FromState(state json.RawMessage) (secrets.Manager, error) { @@ -40,7 +42,7 @@ func NewBase64SecretsManager() secrets.Manager { type manager struct{} -func (m *manager) Type() string { return "b64" } +func (m *manager) Type() string { return Type } func (m *manager) State() interface{} { return map[string]string{} } func (m *manager) Encrypter() (config.Encrypter, error) { return &base64Crypter{}, nil } func (m *manager) Decrypter() (config.Decrypter, error) { return &base64Crypter{}, nil } diff --git a/pkg/secrets/passphrase/manager.go b/pkg/secrets/passphrase/manager.go new file mode 100644 index 000000000..040d8acf4 --- /dev/null +++ b/pkg/secrets/passphrase/manager.go @@ -0,0 +1,148 @@ +// Copyright 2016-2019, Pulumi Corporation. +// +// 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. +package passphrase + +import ( + "encoding/base64" + "encoding/json" + "os" + "strings" + + "github.com/pkg/errors" + + "github.com/pulumi/pulumi/pkg/resource/config" + "github.com/pulumi/pulumi/pkg/secrets" + "github.com/pulumi/pulumi/pkg/util/contract" +) + +const Type = "passphrase" + +var errIncorrectPassphrase = errors.New("incorrect passphrase") + +// given a passphrase and an encryption state, construct a Crypter 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.Crypter, 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 + } + + decrypter := config.NewSymmetricCrypterFromPassphrase(phrase, salt) + decrypted, err := decrypter.DecryptValue(state[indexN(state, ":", 2)+1:]) + if err != nil || decrypted != "pulumi" { + return nil, errors.New("incorrect passphrase") + } + + return decrypter, 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)) +} + +type localSecretsManagerState struct { + Salt string `json:"salt"` +} + +var _ secrets.Manager = &localSecretsManager{} + +type localSecretsManager struct { + state localSecretsManagerState + crypter config.Crypter +} + +func (sm *localSecretsManager) Type() string { + return Type +} + +func (sm *localSecretsManager) State() interface{} { + return sm.state +} + +func (sm *localSecretsManager) Decrypter() (config.Decrypter, error) { + contract.Assert(sm.crypter != nil) + return sm.crypter, nil +} + +func (sm *localSecretsManager) Encrypter() (config.Encrypter, error) { + contract.Assert(sm.crypter != nil) + return sm.crypter, nil +} + +func NewLocalSecretsManager(phrase string, state string) (secrets.Manager, error) { + crypter, err := symmetricCrypterFromPhraseAndState(phrase, state) + if err != nil { + return nil, err + } + + return &localSecretsManager{ + crypter: crypter, + state: localSecretsManagerState{ + Salt: state, + }, + }, nil +} + +type provider struct{} + +var _ secrets.ManagerProvider = &provider{} + +func NewProvider() secrets.ManagerProvider { + return &provider{} +} + +func (p *provider) FromState(state json.RawMessage) (secrets.Manager, error) { + var s localSecretsManagerState + if err := json.Unmarshal(state, &s); err != nil { + return nil, errors.Wrap(err, "unmarshalling state") + } + + // This is not ideal, but we don't have a great way to prompt the user in this case, since this may be + // called during an update when trying to read stack outputs as part servicing a StackReference request + // (since we need to decrypt the deployment) + phrase := os.Getenv("PULUMI_CONFIG_PASSPHRASE") + + sm, err := NewLocalSecretsManager(phrase, s.Salt) + switch { + case err == errIncorrectPassphrase: + return nil, errors.New("incorrect passphrase, please set PULUMI_CONFIG_PASSPHRASE to the correct passphrase") + case err != nil: + return nil, errors.Wrap(err, "constructing secrets manager") + default: + return sm, nil + } +} diff --git a/pkg/secrets/service/manager.go b/pkg/secrets/service/manager.go new file mode 100644 index 000000000..c6a0db48b --- /dev/null +++ b/pkg/secrets/service/manager.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "io/ioutil" + + "github.com/pulumi/pulumi/pkg/diag" + + "github.com/pulumi/pulumi/pkg/workspace" + + "github.com/pkg/errors" + "github.com/pulumi/pulumi/pkg/backend/httpstate/client" + "github.com/pulumi/pulumi/pkg/resource/config" + "github.com/pulumi/pulumi/pkg/secrets" + "github.com/pulumi/pulumi/pkg/util/contract" +) + +const Type = "pulumi" + +// cloudCrypter is an encrypter/decrypter that uses the Pulumi cloud to encrypt/decrypt a stack's secrets. +type cloudCrypter struct { + client *client.Client + stack client.StackIdentifier +} + +func newCloudCrypter(client *client.Client, stack client.StackIdentifier) config.Crypter { + return &cloudCrypter{client: client, stack: stack} +} + +func (c *cloudCrypter) EncryptValue(plaintext string) (string, error) { + ciphertext, err := c.client.EncryptValue(context.Background(), c.stack, []byte(plaintext)) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (c *cloudCrypter) DecryptValue(cipherstring string) (string, error) { + ciphertext, err := base64.StdEncoding.DecodeString(cipherstring) + if err != nil { + return "", err + } + plaintext, err := c.client.DecryptValue(context.Background(), c.stack, ciphertext) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +type cloudSecretsManagerState struct { + URL string `json:"url,omitempty"` + Owner string `json:"owner"` + Project string `json:"project"` + Stack string `json:"stack"` +} + +var _ secrets.Manager = &cloudSecretsManager{} + +type cloudSecretsManager struct { + state cloudSecretsManagerState + crypter config.Crypter +} + +func (sm *cloudSecretsManager) Type() string { + return Type +} + +func (sm *cloudSecretsManager) State() interface{} { + return sm.state +} + +func (sm *cloudSecretsManager) Decrypter() (config.Decrypter, error) { + contract.Assert(sm.crypter != nil) + return sm.crypter, nil +} + +func (sm *cloudSecretsManager) Encrypter() (config.Encrypter, error) { + contract.Assert(sm.crypter != nil) + return sm.crypter, nil +} + +func NewCloudSecretsManager(c *client.Client, id client.StackIdentifier) (secrets.Manager, error) { + return &cloudSecretsManager{ + state: cloudSecretsManagerState{ + URL: c.URL(), + Owner: id.Owner, + Project: id.Project, + Stack: id.Stack, + }, + crypter: newCloudCrypter(c, id), + }, nil +} + +type provider struct{} + +var _ secrets.ManagerProvider = &provider{} + +func NewProvider() secrets.ManagerProvider { + return &provider{} +} + +func (p *provider) FromState(state json.RawMessage) (secrets.Manager, error) { + var s cloudSecretsManagerState + if err := json.Unmarshal(state, &s); err != nil { + return nil, errors.Wrap(err, "unmarshalling state") + } + + token, err := workspace.GetAccessToken(s.URL) + if err != nil { + return nil, errors.Wrap(err, "getting access token") + } + + if token == "" { + return nil, errors.Errorf("could not find access token for %s, have you logged in?", s.URL) + } + + id := client.StackIdentifier{ + Owner: s.Owner, + Project: s.Project, + Stack: s.Stack, + } + c := client.NewClient(s.URL, token, diag.DefaultSink(ioutil.Discard, ioutil.Discard, diag.FormatOptions{})) + + return &cloudSecretsManager{ + state: s, + crypter: newCloudCrypter(c, id), + }, nil +}