github.com/myhau/pulumi/pkg/v3@v3.70.2-0.20221116134521-f2775972e587/secrets/passphrase/manager.go (about)

     1  // Copyright 2016-2022, Pulumi Corporation.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package passphrase implements support for a local passphrase secret manager.
    16  package passphrase
    17  
    18  import (
    19  	"bufio"
    20  	"context"
    21  	cryptorand "crypto/rand"
    22  	"encoding/base64"
    23  	"encoding/json"
    24  	"errors"
    25  	"fmt"
    26  	"os"
    27  	"path/filepath"
    28  	"strings"
    29  	"sync"
    30  
    31  	"github.com/pulumi/pulumi/pkg/v3/secrets"
    32  	"github.com/pulumi/pulumi/sdk/v3/go/common/diag"
    33  	"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
    34  	"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
    35  	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
    36  )
    37  
    38  const Type = "passphrase"
    39  
    40  var ErrIncorrectPassphrase = errors.New("incorrect passphrase")
    41  
    42  // given a passphrase and an encryption state, construct a Crypter from it. Our encryption
    43  // state value is a version tag followed by version specific state information. Presently, we only have one version
    44  // we support (`v1`) which is AES-256-GCM using a key derived from a passphrase using 1,000,000 iterations of PDKDF2
    45  // using SHA256.
    46  func symmetricCrypterFromPhraseAndState(phrase string, state string) (config.Crypter, error) {
    47  	splits := strings.SplitN(state, ":", 3)
    48  	if len(splits) != 3 {
    49  		return nil, errors.New("malformed state value")
    50  	}
    51  
    52  	if splits[0] != "v1" {
    53  		return nil, errors.New("unknown state version")
    54  	}
    55  
    56  	salt, err := base64.StdEncoding.DecodeString(splits[1])
    57  	if err != nil {
    58  		return nil, err
    59  	}
    60  
    61  	decrypter := config.NewSymmetricCrypterFromPassphrase(phrase, salt)
    62  	// symmetricCrypter does not use ctx, safe to pass context.Background()
    63  	ignoredCtx := context.Background()
    64  	decrypted, err := decrypter.DecryptValue(ignoredCtx, state[indexN(state, ":", 2)+1:])
    65  	if err != nil || decrypted != "pulumi" {
    66  		return nil, ErrIncorrectPassphrase
    67  	}
    68  
    69  	return decrypter, nil
    70  }
    71  
    72  func indexN(s string, substr string, n int) int {
    73  	contract.Require(n > 0, "n")
    74  	scratch := s
    75  
    76  	for i := n; i > 0; i-- {
    77  		idx := strings.Index(scratch, substr)
    78  		if i == -1 {
    79  			return -1
    80  		}
    81  
    82  		scratch = scratch[idx+1:]
    83  	}
    84  
    85  	return len(s) - (len(scratch) + len(substr))
    86  }
    87  
    88  type localSecretsManagerState struct {
    89  	Salt string `json:"salt"`
    90  }
    91  
    92  var _ secrets.Manager = &localSecretsManager{}
    93  
    94  type localSecretsManager struct {
    95  	state   localSecretsManagerState
    96  	crypter config.Crypter
    97  }
    98  
    99  func (sm *localSecretsManager) Type() string {
   100  	return Type
   101  }
   102  
   103  func (sm *localSecretsManager) State() interface{} {
   104  	return sm.state
   105  }
   106  
   107  func (sm *localSecretsManager) Decrypter() (config.Decrypter, error) {
   108  	contract.Assert(sm.crypter != nil)
   109  	return sm.crypter, nil
   110  }
   111  
   112  func (sm *localSecretsManager) Encrypter() (config.Encrypter, error) {
   113  	contract.Assert(sm.crypter != nil)
   114  	return sm.crypter, nil
   115  }
   116  
   117  var lock sync.Mutex
   118  var cache map[string]secrets.Manager
   119  
   120  // clearCachedSecretsManagers is used to clear the cache, for tests.
   121  func clearCachedSecretsManagers() {
   122  	lock.Lock()
   123  	defer lock.Unlock()
   124  	cache = nil
   125  }
   126  
   127  // getCachedSecretsManager returns a cached secret manager and true, or nil and false if not in the cache.
   128  func getCachedSecretsManager(state string) (secrets.Manager, bool) {
   129  	lock.Lock()
   130  	defer lock.Unlock()
   131  	sm, ok := cache[state]
   132  	return sm, ok
   133  }
   134  
   135  // setCachedSecretsManager saves a secret manager in the cache.
   136  func setCachedSecretsManager(state string, sm secrets.Manager) {
   137  	lock.Lock()
   138  	defer lock.Unlock()
   139  	if cache == nil {
   140  		cache = make(map[string]secrets.Manager)
   141  	}
   142  	cache[state] = sm
   143  }
   144  
   145  func NewPassphaseSecretsManager(phrase string, state string) (secrets.Manager, error) {
   146  	// Check the cache first, if we have already seen this state before, return a cached value.
   147  	if cached, ok := getCachedSecretsManager(state); ok {
   148  		return cached, nil
   149  	}
   150  
   151  	// Wasn't in the cache so try to construct it and add it if there's no error.
   152  	crypter, err := symmetricCrypterFromPhraseAndState(phrase, state)
   153  	if err != nil {
   154  		return nil, err
   155  	}
   156  	sm := &localSecretsManager{
   157  		crypter: crypter,
   158  		state: localSecretsManagerState{
   159  			Salt: state,
   160  		},
   161  	}
   162  	setCachedSecretsManager(state, sm)
   163  	return sm, nil
   164  }
   165  
   166  // NewPromptingPassphraseSecretsManager returns a new passphrase-based secrets manager, from the
   167  // given state. Will use the passphrase found in PULUMI_CONFIG_PASSPHRASE, the file specified by
   168  // PULUMI_CONFIG_PASSPHRASE_FILE, or otherwise will prompt for the passphrase if interactive.
   169  func NewPromptingPassphraseSecretsManager(state string) (secrets.Manager, error) {
   170  	// Check the cache first, if we have already seen this state before, return a cached value.
   171  	if cached, ok := getCachedSecretsManager(state); ok {
   172  		return cached, nil
   173  	}
   174  
   175  	// Otherwise, prompt for the password.
   176  	const prompt = "Enter your passphrase to unlock config/secrets\n" +
   177  		"    (set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember)"
   178  	for {
   179  		phrase, interactive, phraseErr := readPassphrase(prompt, true /*useEnv*/)
   180  		if phraseErr != nil {
   181  			return nil, phraseErr
   182  		}
   183  
   184  		sm, smerr := NewPassphaseSecretsManager(phrase, state)
   185  		switch {
   186  		case interactive && smerr == ErrIncorrectPassphrase:
   187  			cmdutil.Diag().Errorf(diag.Message("", "incorrect passphrase"))
   188  			continue
   189  		case smerr != nil:
   190  			return nil, smerr
   191  		default:
   192  			return sm, nil
   193  		}
   194  	}
   195  }
   196  
   197  // NewPassphaseSecretsManagerFromState returns a new passphrase-based secrets manager, from the
   198  // given state. Will use the passphrase found in PULUMI_CONFIG_PASSPHRASE, the file specified by
   199  // PULUMI_CONFIG_PASSPHRASE_FILE, or otherwise will prompt for the passphrase if interactive.
   200  func NewPromptingPassphaseSecretsManagerFromState(state json.RawMessage) (secrets.Manager, error) {
   201  	var s localSecretsManagerState
   202  	if err := json.Unmarshal(state, &s); err != nil {
   203  		return nil, fmt.Errorf("unmarshalling state: %w", err)
   204  	}
   205  
   206  	sm, err := NewPromptingPassphraseSecretsManager(s.Salt)
   207  	switch {
   208  	case err == ErrIncorrectPassphrase:
   209  		return newLockedPasspharseSecretsManager(s), nil
   210  	case err != nil:
   211  		return nil, fmt.Errorf("constructing secrets manager: %w", err)
   212  	default:
   213  		return sm, nil
   214  	}
   215  }
   216  
   217  // PromptForNewPassphrase prompts for a new passphrase, and returns the state and the secrets manager.
   218  func PromptForNewPassphrase(rotate bool) (string, secrets.Manager, error) {
   219  	var phrase string
   220  
   221  	// Get a the passphrase from the user, ensuring that they match.
   222  	for {
   223  		firstMessage := "Enter your passphrase to protect config/secrets"
   224  		if rotate {
   225  			firstMessage = "Enter your new passphrase to protect config/secrets"
   226  
   227  			if !isInteractive() {
   228  				scanner := bufio.NewScanner(os.Stdin)
   229  				scanner.Scan()
   230  				phrase = strings.TrimSpace(scanner.Text())
   231  				break
   232  			}
   233  		}
   234  		// Here, the stack does not have an EncryptionSalt, so we will get a passphrase and create one
   235  		first, _, err := readPassphrase(firstMessage, !rotate)
   236  		if err != nil {
   237  			return "", nil, err
   238  		}
   239  		secondMessage := "Re-enter your passphrase to confirm"
   240  		if rotate {
   241  			secondMessage = "Re-enter your new passphrase to confirm"
   242  		}
   243  		second, _, err := readPassphrase(secondMessage, !rotate)
   244  		if err != nil {
   245  			return "", nil, err
   246  		}
   247  
   248  		if first == second {
   249  			phrase = first
   250  			break
   251  		}
   252  		// If they didn't match, print an error and try again
   253  		cmdutil.Diag().Errorf(diag.Message("", "passphrases do not match"))
   254  	}
   255  
   256  	// Produce a new salt.
   257  	salt := make([]byte, 8)
   258  	_, err := cryptorand.Read(salt)
   259  	contract.AssertNoErrorf(err, "could not read from system random")
   260  
   261  	// Encrypt a message and store it with the salt so we can test if the password is correct later.
   262  	crypter := config.NewSymmetricCrypterFromPassphrase(phrase, salt)
   263  
   264  	// symmetricCrypter does not use ctx, safe to use context.Background()
   265  	ignoredCtx := context.Background()
   266  	msg, err := crypter.EncryptValue(ignoredCtx, "pulumi")
   267  	contract.AssertNoError(err)
   268  
   269  	// Encode the salt as the passphrase secrets manager state.
   270  	state := fmt.Sprintf("v1:%s:%s", base64.StdEncoding.EncodeToString(salt), msg)
   271  
   272  	// Create the secrets manager using the state.
   273  	sm, err := NewPassphaseSecretsManager(phrase, state)
   274  	if err != nil {
   275  		return "", nil, err
   276  	}
   277  
   278  	// Return both the state and the secrets manager.
   279  	return state, sm, nil
   280  }
   281  
   282  func readPassphrase(prompt string, useEnv bool) (phrase string, interactive bool, err error) {
   283  	if useEnv {
   284  		if phrase, ok := os.LookupEnv("PULUMI_CONFIG_PASSPHRASE"); ok {
   285  			return phrase, false, nil
   286  		}
   287  		if phraseFile, ok := os.LookupEnv("PULUMI_CONFIG_PASSPHRASE_FILE"); ok && phraseFile != "" {
   288  			phraseFilePath, err := filepath.Abs(phraseFile)
   289  			if err != nil {
   290  				return "", false, fmt.Errorf("unable to construct a path the PULUMI_CONFIG_PASSPHRASE_FILE: %w", err)
   291  			}
   292  			phraseDetails, err := os.ReadFile(phraseFilePath)
   293  			if err != nil {
   294  				return "", false, fmt.Errorf("unable to read PULUMI_CONFIG_PASSPHRASE_FILE: %w", err)
   295  			}
   296  			return strings.TrimSpace(string(phraseDetails)), false, nil
   297  		}
   298  		if !isInteractive() {
   299  			return "", false, errors.New("passphrase must be set with PULUMI_CONFIG_PASSPHRASE or " +
   300  				"PULUMI_CONFIG_PASSPHRASE_FILE environment variables")
   301  		}
   302  	}
   303  	phrase, err = cmdutil.ReadConsoleNoEcho(prompt)
   304  	return phrase, true, err
   305  }
   306  
   307  func isInteractive() bool {
   308  	test, ok := os.LookupEnv("PULUMI_TEST_PASSPHRASE")
   309  	return cmdutil.Interactive() || ok && cmdutil.IsTruthy(test)
   310  }
   311  
   312  // newLockedPasspharseSecretsManager returns a Passphrase secrets manager that has the correct state, but can not
   313  // encrypt or decrypt anything. This is helpful today for some cases, because we have operations that roundtrip
   314  // checkpoints and we'd like to continue to support these operations even if we don't have the correct passphrase. But
   315  // if we never end up having to call encrypt or decrypt, this provider will be sufficient.  Since it has the correct
   316  // state, we ensure that when we roundtrip, we don't lose the state stored in the deployment.
   317  func newLockedPasspharseSecretsManager(state localSecretsManagerState) secrets.Manager {
   318  	return &localSecretsManager{
   319  		state:   state,
   320  		crypter: &errorCrypter{},
   321  	}
   322  }
   323  
   324  type errorCrypter struct{}
   325  
   326  func (ec *errorCrypter) EncryptValue(ctx context.Context, _ string) (string, error) {
   327  	return "", errors.New("failed to encrypt: incorrect passphrase, please set PULUMI_CONFIG_PASSPHRASE to the " +
   328  		"correct passphrase or set PULUMI_CONFIG_PASSPHRASE_FILE to a file containing the passphrase")
   329  }
   330  
   331  func (ec *errorCrypter) DecryptValue(ctx context.Context, _ string) (string, error) {
   332  	return "", errors.New("failed to decrypt: incorrect passphrase, please set PULUMI_CONFIG_PASSPHRASE to the " +
   333  		"correct passphrase or set PULUMI_CONFIG_PASSPHRASE_FILE to a file containing the passphrase")
   334  }
   335  
   336  func (ec *errorCrypter) BulkDecrypt(ctx context.Context, _ []string) (map[string]string, error) {
   337  	return nil, errors.New("failed to decrypt: incorrect passphrase, please set PULUMI_CONFIG_PASSPHRASE to the " +
   338  		"correct passphrase or set PULUMI_CONFIG_PASSPHRASE_FILE to a file containing the passphrase")
   339  }