@dotenvx/dotenvx 2.28.0 → 2.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +40 -8
  3. package/package.json +1 -1
  4. package/src/cli/actions/define.js +17 -0
  5. package/src/cli/actions/ext/precommit.js +3 -0
  6. package/src/cli/actions/ext/precommitClean.js +21 -0
  7. package/src/cli/actions/get.js +2 -1
  8. package/src/cli/actions/init.js +3 -0
  9. package/src/cli/actions/lock/down.js +13 -32
  10. package/src/cli/actions/lock/up.js +13 -30
  11. package/src/cli/actions/run.js +2 -1
  12. package/src/cli/commands/custody.js +2 -2
  13. package/src/cli/commands/ext.js +3 -1
  14. package/src/cli/commands/fileOptions.js +22 -0
  15. package/src/cli/dotenvx.js +38 -21
  16. package/src/lib/custodians/index.js +73 -0
  17. package/src/lib/{helpers/bitwardenCustody.js → custodians/local/bitwarden.js} +12 -7
  18. package/src/lib/custodians/local/file.js +18 -0
  19. package/src/lib/custodians/local/native/backend.js +62 -0
  20. package/src/lib/custodians/local/native/index.js +28 -0
  21. package/src/lib/{helpers/storeNativePrivateKey.js → custodians/local/native/store.js} +3 -3
  22. package/src/lib/{helpers/onePasswordCustody.js → custodians/local/onepassword.js} +9 -5
  23. package/src/lib/custodians/lock.js +80 -0
  24. package/src/lib/{providers/armor/index.js → custodians/managed/armor/get.js} +6 -6
  25. package/src/lib/custodians/managed/armor/index.js +24 -0
  26. package/src/lib/custodians/managed/armor/store.js +37 -0
  27. package/src/lib/helpers/installPrecommitFilter.js +50 -0
  28. package/src/lib/helpers/installPrecommitHook.js +2 -2
  29. package/src/lib/helpers/lockedValue.js +24 -0
  30. package/src/lib/helpers/macosKeychain.js +18 -2
  31. package/src/lib/helpers/matchesStoredKey.js +16 -0
  32. package/src/lib/helpers/normalizeDotenvConfigPath.js +1 -1
  33. package/src/lib/helpers/parseWithDecryptor.js +3 -0
  34. package/src/lib/helpers/prompts.js +18 -0
  35. package/src/lib/helpers/resolveLockPassword.js +5 -0
  36. package/src/lib/helpers/selectKeyStorage.js +9 -21
  37. package/src/lib/helpers/unlockedValue.js +26 -0
  38. package/src/lib/helpers/withLockedKeys.js +41 -0
  39. package/src/lib/main.d.ts +6 -0
  40. package/src/lib/main.js +4 -1
  41. package/src/lib/providers/index.js +9 -66
  42. package/src/lib/providers/native/index.js +2 -62
  43. package/src/lib/providers/provider-worker.js +1 -1
  44. package/src/lib/resolvers/envs.js +14 -5
  45. package/src/lib/resolvers/get.js +1 -0
  46. package/src/lib/services/custodyTransfer.js +2 -2
  47. package/src/lib/services/init.js +49 -0
  48. package/src/lib/services/precommit.js +12 -1
  49. package/src/lib/services/validate.js +1 -0
  50. package/src/lib/transforms/encrypt.js +6 -49
  51. package/src/lib/transforms/set.js +6 -49
@@ -0,0 +1,18 @@
1
+ const { mutateKeysSrc } = require('../../helpers/cryptography')
2
+
3
+ // File keys are loaded by envResolution before provider lookup. Creation is
4
+ // staged here so the caller can commit .env.keys with the encrypted env files.
5
+ module.exports = {
6
+ id: 'file',
7
+ name: 'File (.env.keys)',
8
+ enabled: () => true,
9
+ available: () => true,
10
+ store (publicKey, privateKey, context) {
11
+ return mutateKeysSrc({
12
+ keysSrc: context.keysSrc,
13
+ privateKeyName: context.privateKeyName,
14
+ privateKeyValue: privateKey,
15
+ comment: context.comment
16
+ })
17
+ }
18
+ }
@@ -0,0 +1,62 @@
1
+ const macosKeychain = require('../../../helpers/macosKeychain')
2
+ const windowsCredentialManager = require('../../../helpers/windowsCredentialManager')
3
+ const linuxSecretService = require('../../../helpers/linuxSecretService')
4
+
5
+ function get (key) {
6
+ if (process.platform === 'win32') {
7
+ return windowsCredentialManager.get(key)
8
+ }
9
+
10
+ if (process.platform === 'linux') {
11
+ return linuxSecretService.get(key)
12
+ }
13
+
14
+ return macosKeychain.get(key)
15
+ }
16
+
17
+ function set (key, value, label = key) {
18
+ if (process.platform === 'win32') {
19
+ windowsCredentialManager.set(key, value, label)
20
+ return
21
+ }
22
+
23
+ if (process.platform === 'linux') {
24
+ linuxSecretService.set(key, value, label)
25
+ return
26
+ }
27
+
28
+ macosKeychain.set(key, value, label)
29
+ }
30
+
31
+ index.delete = function (key) {
32
+ if (process.platform === 'win32') {
33
+ windowsCredentialManager.delete(key)
34
+ return
35
+ }
36
+
37
+ if (process.platform === 'linux') {
38
+ linuxSecretService.delete(key)
39
+ return
40
+ }
41
+
42
+ macosKeychain.delete(key)
43
+ }
44
+
45
+ function index (publicKeyHex) {
46
+ if (!['darwin', 'linux', 'win32'].includes(process.platform)) return {}
47
+
48
+ try {
49
+ const privateKeyHex = get(publicKeyHex)
50
+
51
+ if (!privateKeyHex) return {}
52
+
53
+ return { [publicKeyHex]: privateKeyHex }
54
+ } catch {
55
+ return {}
56
+ }
57
+ }
58
+
59
+ index.set = set
60
+ index.get = get
61
+
62
+ module.exports = index
@@ -0,0 +1,28 @@
1
+ const backend = require('./backend')
2
+ const storePrivateKey = require('./store')
3
+
4
+ const names = {
5
+ darwin: 'macOS Keychain',
6
+ win32: 'Windows Credential Manager',
7
+ linux: 'Linux Secret Service'
8
+ }
9
+
10
+ function enabled (options = {}) {
11
+ return !!names[process.platform] && !process.env.CI && options.noNative !== true && options.native !== false && process.env.DOTENVX_NO_NATIVE !== 'true'
12
+ }
13
+
14
+ module.exports = {
15
+ id: 'native',
16
+ get name () { return `OS${names[process.platform] ? ` (${names[process.platform]})` : ''}` },
17
+ enabled,
18
+ available: () => true,
19
+ configured: () => true,
20
+ get: backend,
21
+ getSync: backend,
22
+ set: backend.set,
23
+ delete: backend.delete,
24
+ store (publicKey, privateKey, context) {
25
+ if (!storePrivateKey(publicKey, privateKey, context.keysFilepath)) return { fallback: 'file' }
26
+ return { nativePrivateKeyAdded: true }
27
+ }
28
+ }
@@ -1,6 +1,6 @@
1
- const nativeProvider = require('../providers/native')
2
- const armoredKeyDisplay = require('./armoredKeyDisplay')
3
- const { logger } = require('../../shared/logger')
1
+ const nativeProvider = require('./backend')
2
+ const armoredKeyDisplay = require('../../../helpers/armoredKeyDisplay')
3
+ const { logger } = require('../../../../shared/logger')
4
4
 
5
5
  function storeNativePrivateKey (publicKey, privateKey, keysFilepath) {
6
6
  try {
@@ -1,7 +1,7 @@
1
1
  const { execFile, execFileSync } = require('child_process')
2
- const { derive } = require('@dotenvx/primitives')
3
- const Session = require('../../db/session')
4
- const armoredKeyDisplay = require('./armoredKeyDisplay')
2
+ const matchesStoredKey = require('../../helpers/matchesStoredKey')
3
+ const Session = require('../../../db/session')
4
+ const armoredKeyDisplay = require('../../helpers/armoredKeyDisplay')
5
5
 
6
6
  const PREFIX = 'DOTENVX_ONEPASSWORD_'
7
7
  const ID = /^[a-z0-9]{26}$/i
@@ -98,7 +98,7 @@ function location (publicKey) {
98
98
 
99
99
  function verified (publicKey, privateKey) {
100
100
  try {
101
- if (derive(privateKey) === publicKey) return { [publicKey]: privateKey }
101
+ if (matchesStoredKey(publicKey, privateKey)) return { [publicKey]: privateKey }
102
102
  } catch {}
103
103
  throw failure('1Password private key does not match the .env public key')
104
104
  }
@@ -164,4 +164,8 @@ async function remove (publicKey) {
164
164
  new Session().openStore().delete(`${PREFIX}${publicKey}`)
165
165
  }
166
166
 
167
- module.exports = { available, configured, get, getSync, set, delete: remove }
167
+ function enabled (options = {}) {
168
+ return options.no1Password !== true && process.env.DOTENVX_NO_1PASSWORD !== 'true'
169
+ }
170
+
171
+ module.exports = { id: 'onepassword', name: '1Password', enabled, store: set, available, configured, get, getSync, set, delete: remove }
@@ -0,0 +1,80 @@
1
+ const { derive } = require('@dotenvx/primitives')
2
+ const lockedValue = require('../helpers/lockedValue')
3
+ const unlockedValue = require('../helpers/unlockedValue')
4
+ const prompts = require('../helpers/prompts')
5
+ const createSpinner = require('../helpers/createSpinner')
6
+ const matchesKey = require('../helpers/matchesStoredKey')
7
+ const Errors = require('../helpers/errors')
8
+ const resolveLockPassword = require('../helpers/resolveLockPassword')
9
+
10
+ // Context is shared only within a transform; never cache passphrases globally
11
+ // by filename or return them alongside the staged keys.
12
+ const passphrases = new WeakMap()
13
+
14
+ async function promptPassphrase () {
15
+ createSpinner.pause()
16
+ try {
17
+ const passphrase = await prompts.password({ message: 'passphrase', prefix: '⊡', separator: '=' }, {
18
+ input: process.stdin,
19
+ output: process.stderr
20
+ })
21
+ if (!passphrase) throw new Error('passphrase must not be empty')
22
+ return passphrase
23
+ } finally {
24
+ createSpinner.resume()
25
+ }
26
+ }
27
+
28
+ async function lock (publicKey, privateKey, context) {
29
+ if (derive(privateKey) !== publicKey) throw new Error('private key does not match the .env public key')
30
+ if (!passphrases.has(context)) {
31
+ const password = resolveLockPassword(context)
32
+ if (password === '') throw new Error('passphrase must not be empty')
33
+ passphrases.set(context, password !== undefined ? password : promptPassphrase())
34
+ }
35
+ return lockedValue(privateKey, await passphrases.get(context), publicKey)
36
+ }
37
+
38
+ function isLocked (value) {
39
+ return typeof value === 'string' && value.startsWith('locked:')
40
+ }
41
+
42
+ function unlockValue (publicKey, ring, passphrase) {
43
+ try {
44
+ const privateKey = unlockedValue(ring[publicKey], passphrase)
45
+ if (derive(privateKey) !== publicKey) throw new Error('key mismatch')
46
+ return { ...ring, [publicKey]: privateKey }
47
+ } catch {
48
+ throw new Errors().invalidPassphrase()
49
+ }
50
+ }
51
+
52
+ function validateLocked (publicKey, ring) {
53
+ const value = ring && ring[publicKey]
54
+ if (!isLocked(value)) return false
55
+ if (!matchesKey(publicKey, value)) throw new Error('invalid locked private key')
56
+ return true
57
+ }
58
+
59
+ function passwordRequired () {
60
+ const error = new Error('[LOCKED_PRIVATE_KEY] supply --lock-password, lockPassword, or DOTENVX_LOCK_PASSWORD to unlock the private key')
61
+ error.code = 'LOCKED_PRIVATE_KEY'
62
+ return error
63
+ }
64
+
65
+ async function unlock (publicKey, ring, options = {}) {
66
+ if (!validateLocked(publicKey, ring)) return ring
67
+ const password = resolveLockPassword(options)
68
+ if (password !== undefined) return unlockValue(publicKey, ring, password)
69
+ if (!process.stdin.isTTY || !process.stderr.isTTY || process.env.CI) throw passwordRequired()
70
+ return unlockValue(publicKey, ring, await promptPassphrase())
71
+ }
72
+
73
+ function unlockSync (publicKey, ring, options = {}) {
74
+ if (!validateLocked(publicKey, ring)) return ring
75
+ const password = resolveLockPassword(options)
76
+ if (password === undefined) throw passwordRequired()
77
+ return unlockValue(publicKey, ring, password)
78
+ }
79
+
80
+ module.exports = { lock, unlock, unlockSync }
@@ -1,9 +1,9 @@
1
- const Session = require('../../../db/session')
2
- const ArmorKeyring = require('../../services/armorKeyring')
3
- const armoredKeyDisplay = require('../../helpers/armoredKeyDisplay')
4
- const isNetworkError = require('../../helpers/isNetworkError')
5
- const listenForOpenKey = require('../../helpers/listenForOpenKey')
6
- const openUrl = require('../../helpers/openUrl')
1
+ const Session = require('../../../../db/session')
2
+ const ArmorKeyring = require('../../../services/armorKeyring')
3
+ const armoredKeyDisplay = require('../../../helpers/armoredKeyDisplay')
4
+ const isNetworkError = require('../../../helpers/isNetworkError')
5
+ const listenForOpenKey = require('../../../helpers/listenForOpenKey')
6
+ const openUrl = require('../../../helpers/openUrl')
7
7
 
8
8
  async function index (publicKeyHex, options = {}) {
9
9
  const sesh = new Session()
@@ -0,0 +1,24 @@
1
+ const Session = require('../../../../db/session')
2
+ const get = require('./get')
3
+ const store = require('./store')
4
+
5
+ module.exports = {
6
+ id: 'armored',
7
+ name: '⛨ Armor',
8
+ custody: 'managed',
9
+ enabled: (options = {}) => options.noArmor !== true && options.armor !== false,
10
+ available: () => true,
11
+ async configured (options = {}) {
12
+ return !!options.token || !await new Session().noArmor()
13
+ },
14
+ configuredSync (options = {}) {
15
+ return !!options.token || !new Session().noArmorSync()
16
+ },
17
+ get,
18
+ getSync (publicKey) {
19
+ const { createSyncFn } = require('@dotenvx/tooling')
20
+ const runProviderSync = createSyncFn(require.resolve('../../../providers/provider-worker.js'))
21
+ return runProviderSync(publicKey)
22
+ },
23
+ store
24
+ }
@@ -0,0 +1,37 @@
1
+ const Session = require('../../../../db/session')
2
+ const PostArmorUp = require('../../../api/postArmorUp')
3
+ const prompts = require('../../../helpers/prompts')
4
+ const teamChoicesFromMeta = require('../../../helpers/teamChoicesFromMeta')
5
+ const isTeamRequiredError = require('../../../helpers/isTeamRequiredError')
6
+
7
+ async function store (publicKey, privateKey) {
8
+ const sesh = new Session()
9
+ const hostname = sesh.hostname()
10
+ const token = sesh.token()
11
+ const devicePublicKey = sesh.devicePublicKey()
12
+
13
+ try {
14
+ await new PostArmorUp(hostname, token, devicePublicKey, publicKey, privateKey, undefined).run()
15
+ } catch (error) {
16
+ if (!isTeamRequiredError(error)) {
17
+ throw error
18
+ }
19
+
20
+ const choices = teamChoicesFromMeta(error.meta)
21
+
22
+ let team = choices[0].value
23
+ if (choices.length > 1) {
24
+ team = await prompts.select({
25
+ message: 'Select team',
26
+ choices
27
+ }, {
28
+ input: process.stdin,
29
+ output: process.stderr
30
+ })
31
+ }
32
+
33
+ await new PostArmorUp(hostname, token, devicePublicKey, publicKey, privateKey, team).run()
34
+ }
35
+ }
36
+
37
+ module.exports = store
@@ -0,0 +1,50 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const os = require('os')
4
+ const { execFileSync } = require('child_process')
5
+
6
+ const ATTRIBUTE = '.env* filter=dotenvx'
7
+
8
+ function quote (value) {
9
+ return "'" + value.replace(/'/g, "'\\''") + "'"
10
+ }
11
+
12
+ function installPrecommitFilter (global = false) {
13
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
14
+ let attributesPath
15
+ if (global) {
16
+ try {
17
+ attributesPath = git('config', '--global', '--includes', '--path', '--get', 'core.attributesFile')
18
+ } catch (error) {
19
+ if (error.status !== 1) throw error
20
+ }
21
+ if (!attributesPath) {
22
+ attributesPath = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'git/attributes')
23
+ git('config', '--global', 'core.attributesFile', attributesPath)
24
+ }
25
+ } else {
26
+ attributesPath = git('rev-parse', '--git-path', 'info/attributes')
27
+ }
28
+ const hookPath = global ? null : git('rev-parse', '--git-path', 'hooks/pre-commit')
29
+ const current = fs.existsSync(attributesPath) ? fs.readFileSync(attributesPath, 'utf8') : ''
30
+ // Use the installed executable, including standalone builds. Never download a
31
+ // replacement via npx while Git is handling secret input.
32
+ const executable = process.pkg
33
+ ? quote(process.execPath)
34
+ : `${quote(process.execPath)} ${quote(path.resolve(__dirname, '../../cli/dotenvx.js'))}`
35
+
36
+ const scope = global ? '--global' : '--local'
37
+ // Git's filter_buffer_or_fd shell-quotes the filename with sq_quote_buf
38
+ // before substituting %f (convert.c). Keep the placeholder unquoted here.
39
+ git('config', scope, 'filter.dotenvx.clean', `${executable} precommit --clean %f`)
40
+ git('config', scope, 'filter.dotenvx.required', 'true')
41
+ fs.mkdirSync(path.dirname(attributesPath), { recursive: true })
42
+ if (!current.split(/\r?\n/).includes(ATTRIBUTE)) {
43
+ fs.appendFileSync(attributesPath, `${current && !current.endsWith('\n') ? '\n' : ''}${ATTRIBUTE}\n`)
44
+ }
45
+ if (global) return attributesPath
46
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true })
47
+ return hookPath
48
+ }
49
+
50
+ module.exports = installPrecommitFilter
@@ -25,8 +25,8 @@ fi
25
25
  `
26
26
 
27
27
  class InstallPrecommitHook {
28
- constructor () {
29
- this.hookPath = path.join('.git', 'hooks', 'pre-commit')
28
+ constructor (hookPath = path.join('.git', 'hooks', 'pre-commit')) {
29
+ this.hookPath = hookPath
30
30
  }
31
31
 
32
32
  run () {
@@ -0,0 +1,24 @@
1
+ const crypto = require('crypto')
2
+
3
+ function lockedValue (privateKey, passphrase, publicKey) {
4
+ const salt = crypto.randomBytes(16)
5
+ const iv = crypto.randomBytes(12)
6
+ const key = crypto.scryptSync(passphrase, salt, 32)
7
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
8
+ const ciphertext = Buffer.concat([
9
+ cipher.update(privateKey, 'utf8'),
10
+ cipher.final()
11
+ ])
12
+ const tag = cipher.getAuthTag()
13
+ const payload = Buffer.concat([
14
+ Buffer.from([1]),
15
+ salt,
16
+ iv,
17
+ tag,
18
+ ciphertext
19
+ ]).toString('base64url')
20
+
21
+ return `locked:${publicKey}:${payload}`
22
+ }
23
+
24
+ module.exports = lockedValue
@@ -1,9 +1,14 @@
1
- const { execFileSync } = require('child_process')
1
+ const { execFileSync, spawnSync } = require('child_process')
2
2
  const nativeStoreError = require('./nativeStoreError')
3
3
 
4
4
  const SECURITY_BIN = '/usr/bin/security'
5
5
  const SERVICE = 'dotenvx'
6
6
 
7
+ function quoteArgument (value) {
8
+ if (typeof value !== 'string' || /[\r\n\0]/.test(value)) throw new Error('invalid Keychain argument')
9
+ return '"' + value.replace(/[\\"]/g, '\\$&') + '"'
10
+ }
11
+
7
12
  module.exports = {
8
13
  get (key) {
9
14
  try {
@@ -20,7 +25,18 @@ module.exports = {
20
25
 
21
26
  set (key, value, label) {
22
27
  try {
23
- execFileSync(SECURITY_BIN, ['add-generic-password', '-U', '-s', SERVICE, '-a', key, '-l', label, '-w', value], { timeout: 10000, killSignal: 'SIGKILL', stdio: 'ignore' })
28
+ const input = ['add-generic-password', '-U', '-s', SERVICE, '-a', key, '-l', label, '-w', value].map(quoteArgument).join(' ') + '\n'
29
+ // security's 4096-byte line buffer must also consume the newline and NUL.
30
+ if (Buffer.byteLength(input, 'utf8') >= 4096) throw new Error('Keychain command too long')
31
+ // Interactive security can exit zero on failure, so inspect stderr too.
32
+ const result = spawnSync(SECURITY_BIN, ['-i'], {
33
+ input,
34
+ timeout: 10000,
35
+ killSignal: 'SIGKILL',
36
+ stdio: ['pipe', 'ignore', 'pipe']
37
+ })
38
+ if (result.error) throw result.error
39
+ if (result.status !== 0 || result.signal || result.stderr.length > 0) throw new Error('Keychain write failed')
24
40
  } catch (error) {
25
41
  throw nativeStoreError('failed to save private key to macOS Keychain', error)
26
42
  }
@@ -0,0 +1,16 @@
1
+ const { derive } = require('@dotenvx/primitives')
2
+
3
+ // Locked values can be transported without unlocking. Validate the envelope
4
+ // here; authenticate and derive the actual key when unlocking for use.
5
+ function matchesStoredKey (publicKey, value) {
6
+ if (typeof value !== 'string') return false
7
+ if (value.startsWith('locked:')) {
8
+ const parts = value.split(':')
9
+ if (parts.length !== 3 || parts[1] !== publicKey || !/^(02|03)[a-f0-9]{64}$/i.test(publicKey)) return false
10
+ const payload = Buffer.from(parts[2], 'base64url')
11
+ return payload.length === 109 && payload[0] === 1 && payload.toString('base64url') === parts[2]
12
+ }
13
+ try { return derive(value) === publicKey } catch { return false }
14
+ }
15
+
16
+ module.exports = matchesStoredKey
@@ -3,7 +3,7 @@ const TYPE_ENV_FILE = 'envFile'
3
3
  function normalizeDotenvConfigPath (envs = [], processEnv = process.env) {
4
4
  if (envs.some(env => env.type === TYPE_ENV_FILE)) return envs
5
5
 
6
- const configuredEnvFiles = processEnv.DOTENV_PATH || processEnv.DOTENV_F
6
+ const configuredEnvFiles = processEnv.DOTENV_FILE || processEnv.DOTENV_PATH || processEnv.DOTENV_F
7
7
  if (!configuredEnvFiles) return envs
8
8
 
9
9
  const envFiles = configuredEnvFiles.split(',').map(value => value.trim()).filter(Boolean)
@@ -1,5 +1,6 @@
1
1
  const { parse, parseSync, parsearrays, scan, encrypted } = require('@dotenvx/primitives')
2
2
  const prepareProxy = require('../proxy/prepareProxy')
3
+ const withLockedKeys = require('./withLockedKeys')
3
4
  const SERVER_SIDE_DECRYPTION_REQUIRED = 'SERVER_SIDE_DECRYPTION_REQUIRED'
4
5
 
5
6
  function decryptOptions (error) {
@@ -43,6 +44,7 @@ parseWithDecryptor.arrays = async function parsearraysWithDecryptor (src, option
43
44
  }
44
45
 
45
46
  async function parseWith (src, options, parser) {
47
+ options = withLockedKeys(options)
46
48
  try {
47
49
  return await parser(src, options)
48
50
  } catch (error) {
@@ -64,6 +66,7 @@ async function parseWith (src, options, parser) {
64
66
  }
65
67
 
66
68
  parseWithDecryptor.sync = function parseWithDecryptorSync (src, options = {}) {
69
+ options = withLockedKeys(options, true)
67
70
  try {
68
71
  return parseSync(src, options)
69
72
  } catch (error) {
@@ -51,6 +51,23 @@ async function select ({ message, choices }, context) {
51
51
  return answer.value
52
52
  }
53
53
 
54
+ async function confirm ({ message, initial = false }, context) {
55
+ try {
56
+ const answer = await enquirer.prompt({
57
+ type: 'confirm',
58
+ name: 'value',
59
+ message,
60
+ initial,
61
+ ...enquirerOptions(context)
62
+ })
63
+ return answer.value === true
64
+ } catch {
65
+ const error = new Error('prompt cancelled')
66
+ error.code = 'PROMPT_CANCELLED'
67
+ throw error
68
+ }
69
+ }
70
+
54
71
  async function password ({ message, prefix, separator }, context) {
55
72
  const output = (context && context.output) || process.stderr
56
73
 
@@ -85,6 +102,7 @@ async function password ({ message, prefix, separator }, context) {
85
102
  }
86
103
 
87
104
  module.exports = {
105
+ confirm,
88
106
  password,
89
107
  select
90
108
  }
@@ -0,0 +1,5 @@
1
+ function resolveLockPassword (options = {}) {
2
+ return options.lockPassword !== undefined ? options.lockPassword : process.env.DOTENVX_LOCK_PASSWORD
3
+ }
4
+
5
+ module.exports = resolveLockPassword
@@ -1,29 +1,15 @@
1
1
  const prompts = require('./prompts')
2
- const onePasswordCustody = require('./onePasswordCustody')
3
- const bitwardenCustody = require('./bitwardenCustody')
4
-
5
- const secretStoreNames = {
6
- darwin: 'macOS Keychain',
7
- win32: 'Windows Credential Manager',
8
- linux: 'Linux Secret Service'
9
- }
2
+ const custodians = require('../custodians')
10
3
 
11
4
  async function selectKeyStorage (options = {}) {
12
- const useNative = !options.noNative && process.env.DOTENVX_NO_NATIVE !== 'true' && !process.env.CI && ['darwin', 'linux', 'win32'].includes(process.platform)
13
- const defaultStorage = useNative ? 'native' : 'file'
5
+ const defaultStorage = custodians.get('native').enabled(options) ? 'native' : 'file'
14
6
  if (process.env.CI || options.noCreate || !process.stdin.isTTY || !process.stderr.isTTY) return defaultStorage
15
7
 
16
- const use1Password = !options.no1Password && process.env.DOTENVX_NO_1PASSWORD !== 'true' && await onePasswordCustody.available()
17
- const useBitwarden = !options.noBitwarden && process.env.DOTENVX_NO_BITWARDEN !== 'true' && await bitwardenCustody.available()
18
- const localChoices = [
19
- { name: `OS${secretStoreNames[process.platform] ? ` (${secretStoreNames[process.platform]})` : ''}`, value: 'native', disabled: !useNative },
20
- { name: '1Password', value: 'onepassword', disabled: !use1Password },
21
- { name: 'Bitwarden', value: 'bitwarden', disabled: !useBitwarden },
22
- { name: 'File (.env.keys)', value: 'file', disabled: false }
23
- ]
8
+ const localChoices = await custodians.choices(options)
24
9
 
25
10
  const choices = [{ name: '⛉ Local Custody', value: 'local', disabled: false }]
26
- if (!options.noArmor) choices.push({ name: '⛊ Managed Custody', value: 'managed' })
11
+ const managedChoices = (await custodians.choices(options, 'managed')).filter(choice => !choice.disabled).map(({ name, value }) => ({ name, value }))
12
+ if (managedChoices.length) choices.push({ name: '⛊ Managed Custody', value: 'managed' })
27
13
 
28
14
  const context = { input: process.stdin, output: process.stderr }
29
15
  const custody = await prompts.select({
@@ -31,10 +17,12 @@ async function selectKeyStorage (options = {}) {
31
17
  choices
32
18
  }, context)
33
19
 
34
- return prompts.select({
20
+ const storage = await prompts.select({
35
21
  message: custody === 'local' ? 'Choose local custody' : 'Choose managed custody',
36
- choices: custody === 'local' ? localChoices : [{ name: '⛨ Armor', value: 'armored' }]
22
+ choices: custody === 'local' ? localChoices : managedChoices
37
23
  }, context)
24
+
25
+ return storage
38
26
  }
39
27
 
40
28
  module.exports = selectKeyStorage
@@ -0,0 +1,26 @@
1
+ const crypto = require('crypto')
2
+
3
+ function unlockedValue (lockedPrivateKey, passphrase) {
4
+ const parts = lockedPrivateKey.split(':')
5
+ const payload = Buffer.from(parts.slice(2).join(':'), 'base64url')
6
+ const version = payload.subarray(0, 1)[0]
7
+ const salt = payload.subarray(1, 17)
8
+ const iv = payload.subarray(17, 29)
9
+ const tag = payload.subarray(29, 45)
10
+ const ciphertext = payload.subarray(45)
11
+ const key = crypto.scryptSync(passphrase, salt, 32)
12
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
13
+
14
+ if (version !== 1) {
15
+ throw new Error('unsupported locked private key version')
16
+ }
17
+
18
+ decipher.setAuthTag(tag)
19
+
20
+ return Buffer.concat([
21
+ decipher.update(ciphertext),
22
+ decipher.final()
23
+ ]).toString('utf8')
24
+ }
25
+
26
+ module.exports = unlockedValue
@@ -0,0 +1,41 @@
1
+ const fs = require('fs')
2
+ const { scan } = require('@dotenvx/primitives')
3
+ const protection = require('../custodians/lock')
4
+
5
+ // Primitives already resolve raw file/environment keys first. This provider
6
+ // supplies locked keys only when the requested private key is still missing.
7
+ function withLockedKeys (options, sync = false) {
8
+ const candidates = []
9
+ const paths = Array.isArray(options.fk) ? options.fk : [options.fk || '.env.keys']
10
+ for (const filepath of paths) {
11
+ let src
12
+ try { src = fs.readFileSync(filepath, 'utf8') } catch { continue }
13
+ for (const [name, values] of Object.entries(scan(src).parsed)) {
14
+ if (name.startsWith('DOTENV_PRIVATE_KEY')) candidates.push(...values.flatMap(value => value.split(',')))
15
+ }
16
+ }
17
+ // Environment assignments take precedence over key files.
18
+ for (const [name, value] of Object.entries(options.processEnv || process.env)) {
19
+ if (name.startsWith('DOTENV_PRIVATE_KEY') && typeof value === 'string') candidates.push(...value.split(','))
20
+ }
21
+ if (!candidates.some(value => value.startsWith('locked:'))) return options
22
+ candidates.reverse()
23
+ function find (publicKey) {
24
+ return candidates.find(value => value.startsWith(`locked:${publicKey}:`))
25
+ }
26
+ const provider = options.provider
27
+ return {
28
+ ...options,
29
+ provider: sync
30
+ ? publicKey => {
31
+ const value = find(publicKey)
32
+ return value ? protection.unlockSync(publicKey, { [publicKey]: value }, options) : (provider ? provider(publicKey) : {})
33
+ }
34
+ : async publicKey => {
35
+ const value = find(publicKey)
36
+ return value ? protection.unlock(publicKey, { [publicKey]: value }, options) : (provider ? provider(publicKey) : {})
37
+ }
38
+ }
39
+ }
40
+
41
+ module.exports = withLockedKeys