pulumi/pkg/secrets/cloud/manager.go

239 lines
8.2 KiB
Go

// Copyright 2016-2018, 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 cloud implements support for a generic cloud secret manager.
package cloud
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
netUrl "net/url"
"os"
"strings"
gosecrets "gocloud.dev/secrets"
_ "gocloud.dev/secrets/awskms" // support for awskms://
_ "gocloud.dev/secrets/azurekeyvault" // support for azurekeyvault://
"gocloud.dev/secrets/gcpkms" // support for gcpkms://
_ "gocloud.dev/secrets/hashivault" // support for hashivault://
"google.golang.org/api/cloudkms/v1"
"github.com/pulumi/pulumi/pkg/v3/authhelpers"
"github.com/pulumi/pulumi/pkg/v3/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
)
// Type is the type of secrets managed by this secrets provider
const Type = "cloud"
type cloudSecretsManagerState struct {
URL string `json:"url"`
EncryptedKey []byte `json:"encryptedkey"`
}
// openKeeper opens the keeper, handling pulumi-specifc cases in the URL.
func openKeeper(ctx context.Context, url string) (*gosecrets.Keeper, error) {
u, err := netUrl.Parse(url)
if err != nil {
return nil, fmt.Errorf("unable to parse the secrets provider URL: %w", err)
}
switch u.Scheme {
case gcpkms.Scheme:
credentials, err := authhelpers.ResolveGoogleCredentials(ctx, cloudkms.CloudkmsScope)
if err != nil {
return nil, fmt.Errorf("missing google credentials: %w", err)
}
kmsClient, _, err := gcpkms.Dial(ctx, credentials.TokenSource)
if err != nil {
return nil, fmt.Errorf("failed to connect to gcpkms: %w", err)
}
opener := gcpkms.URLOpener{
Client: kmsClient,
}
return opener.OpenKeeperURL(ctx, u)
default:
return gosecrets.OpenKeeper(ctx, url)
}
}
// generateNewDataKey generates a new DataKey seeded by a fresh random 32-byte key and encrypted
// using the target cloud key management service.
func generateNewDataKey(url string) ([]byte, error) {
plaintextDataKey := make([]byte, 32)
_, err := rand.Read(plaintextDataKey)
if err != nil {
return nil, err
}
keeper, err := openKeeper(context.Background(), url)
if err != nil {
return nil, err
}
return keeper.Encrypt(context.Background(), plaintextDataKey)
}
// newCloudSecretsManager returns a secrets manager that uses the target cloud key management
// service to encrypt/decrypt a data key used for envelope encryption of secrets values.
func newCloudSecretsManager(url string, encryptedDataKey []byte) (*Manager, error) {
keeper, err := openKeeper(context.Background(), url)
if err != nil {
return nil, err
}
// We're emulating gocloud.dev's old behaviour here. Pre v0.28.0 it used to have an inner wrapping, which
// we keep for compatibility (see above). However the newer version expects this to be unwrapped, before
// it's used, so let's do that here.
ciphertext := encryptedDataKey
if strings.HasPrefix(url, "azurekeyvault://") {
ciphertext, err = base64.RawURLEncoding.DecodeString(string(encryptedDataKey))
if err != nil {
return nil, err
}
}
plaintextDataKey, err := keeper.Decrypt(context.Background(), ciphertext)
if err != nil {
return nil, err
}
state, err := json.Marshal(cloudSecretsManagerState{
URL: url,
EncryptedKey: encryptedDataKey,
})
if err != nil {
return nil, fmt.Errorf("marshalling state: %w", err)
}
crypter := config.NewSymmetricCrypter(plaintextDataKey)
return &Manager{
crypter: crypter,
state: state,
}, nil
}
// Manager is the secrets.Manager implementation for cloud key management services
type Manager struct {
state json.RawMessage
crypter config.Crypter
}
func (m *Manager) Type() string { return Type }
func (m *Manager) State() json.RawMessage { return m.state }
func (m *Manager) Encrypter() (config.Encrypter, error) { return m.crypter, nil }
func (m *Manager) Decrypter() (config.Decrypter, error) { return m.crypter, nil }
func EditProjectStack(info *workspace.ProjectStack, state json.RawMessage) error {
info.EncryptionSalt = ""
var s cloudSecretsManagerState
err := json.Unmarshal(state, &s)
if err != nil {
return fmt.Errorf("unmarshalling cloud state: %w", err)
}
info.SecretsProvider = s.URL
info.EncryptedKey = base64.StdEncoding.EncodeToString(s.EncryptedKey)
return nil
}
// NewCloudSecretsManagerFromState deserialize configuration from state and returns a secrets
// manager that uses the target cloud key management service to encrypt/decrypt a data key used for
// envelope encryption of secrets values.
func NewCloudSecretsManagerFromState(state json.RawMessage) (secrets.Manager, error) {
var s cloudSecretsManagerState
err := json.Unmarshal(state, &s)
if err != nil {
return nil, fmt.Errorf("unmarshalling state: %w", err)
}
// We're emulating gocloud.dev's old behaviour here. Pre v0.28.0 it used to have an inner wrapping, which
// we keep for compatibility (see above). However the newer version expects this to be unwrapped, before
// it's used. newCloudSecretsManager will manage that but we need to check here as well to handle the
// #15329 regression.
dataKey := s.EncryptedKey
if strings.HasPrefix(s.URL, "azurekeyvault://") {
wrappedKey, err := base64.RawURLEncoding.DecodeString(string(dataKey))
if err != nil {
// https://github.com/pulumi/pulumi/issues/15329 resulted in some non-encoded keys being written
// to state. This checks that case to see if there valid base64 data.
firstErr := err
_, err := base64.StdEncoding.DecodeString(string(wrappedKey))
if err != nil {
// Wasn't valid base64 so probably just gibberish, return the first error we saw.
return nil, firstErr
}
// This is a valid base64 string so it's probably just a bad encoding, wrap it as expected and
// pass it on to newCloudSecretsManager
dataKey = []byte(base64.RawURLEncoding.EncodeToString(dataKey))
}
}
return newCloudSecretsManager(s.URL, dataKey)
}
func NewCloudSecretsManager(info *workspace.ProjectStack,
secretsProvider string, rotateSecretsProvider bool,
) (secrets.Manager, error) {
// Only a passphrase provider has an encryption salt. So changing a secrets provider
// from passphrase to a cloud secrets provider should ensure that we remove the enryptionsalt
// as it's a legacy artifact and needs to be removed
info.EncryptionSalt = ""
var secretsManager *Manager
// Allow per-execution override of the secrets provider via an environment
// variable. This allows a temporary replacement without updating the stack
// config, such a during CI.
if override := os.Getenv("PULUMI_CLOUD_SECRET_OVERRIDE"); override != "" {
secretsProvider = override
}
// If we're rotating then just clear the key so we create a fresh one below
if rotateSecretsProvider {
info.EncryptedKey = ""
}
// if there is no key OR the secrets provider is changing
// then we need to generate the new key based on the new secrets provider
if info.EncryptedKey == "" || info.SecretsProvider != secretsProvider {
dataKey, err := generateNewDataKey(secretsProvider)
if err != nil {
return nil, err
}
// gocloud.dev versions before v0.28.0 wrapped the
// data key in a base64.RawURLEncoding before wrapping
// it again in base64.StdEncoding. We keep emulating
// this here for compatibility.
if strings.HasPrefix(secretsProvider, "azurekeyvault://") {
dataKey = []byte(base64.RawURLEncoding.EncodeToString(dataKey))
}
info.EncryptedKey = base64.StdEncoding.EncodeToString(dataKey)
}
info.SecretsProvider = secretsProvider
dataKey, err := base64.StdEncoding.DecodeString(info.EncryptedKey)
if err != nil {
return nil, err
}
secretsManager, err = newCloudSecretsManager(secretsProvider, dataKey)
if err != nil {
return nil, err
}
return secretsManager, nil
}