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 }