@dotenvx/dotenvx 2.28.0 → 2.28.2

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 (56) hide show
  1. package/CHANGELOG.md +14 -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/encrypt.js +1 -1
  6. package/src/cli/actions/ext/precommit.js +3 -0
  7. package/src/cli/actions/ext/precommitClean.js +21 -0
  8. package/src/cli/actions/get.js +2 -1
  9. package/src/cli/actions/init.js +3 -0
  10. package/src/cli/actions/lock/down.js +13 -32
  11. package/src/cli/actions/lock/up.js +13 -30
  12. package/src/cli/actions/run.js +2 -1
  13. package/src/cli/actions/set.js +1 -1
  14. package/src/cli/commands/custody.js +2 -2
  15. package/src/cli/commands/ext.js +3 -1
  16. package/src/cli/commands/fileOptions.js +22 -0
  17. package/src/cli/dotenvx.js +38 -21
  18. package/src/lib/custodians/index.js +73 -0
  19. package/src/lib/{helpers/bitwardenCustody.js → custodians/local/bitwarden.js} +12 -7
  20. package/src/lib/custodians/local/file.js +18 -0
  21. package/src/lib/custodians/local/native/backend.js +62 -0
  22. package/src/lib/custodians/local/native/index.js +28 -0
  23. package/src/lib/{helpers/storeNativePrivateKey.js → custodians/local/native/store.js} +3 -3
  24. package/src/lib/{helpers/onePasswordCustody.js → custodians/local/onepassword.js} +9 -5
  25. package/src/lib/custodians/lock.js +80 -0
  26. package/src/lib/{providers/armor/index.js → custodians/managed/armor/get.js} +6 -6
  27. package/src/lib/custodians/managed/armor/index.js +24 -0
  28. package/src/lib/custodians/managed/armor/store.js +37 -0
  29. package/src/lib/helpers/fsx.js +16 -5
  30. package/src/lib/helpers/installPrecommitFilter.js +50 -0
  31. package/src/lib/helpers/installPrecommitHook.js +2 -2
  32. package/src/lib/helpers/lockedValue.js +24 -0
  33. package/src/lib/helpers/macosKeychain.js +18 -2
  34. package/src/lib/helpers/matchesStoredKey.js +16 -0
  35. package/src/lib/helpers/normalizeDotenvConfigPath.js +1 -1
  36. package/src/lib/helpers/parseWithDecryptor.js +3 -0
  37. package/src/lib/helpers/prompts.js +18 -0
  38. package/src/lib/helpers/removeEnvKey.js +2 -1
  39. package/src/lib/helpers/resolveLockPassword.js +5 -0
  40. package/src/lib/helpers/selectKeyStorage.js +9 -21
  41. package/src/lib/helpers/unlockedValue.js +26 -0
  42. package/src/lib/helpers/upsertEnvKey.js +2 -1
  43. package/src/lib/helpers/withLockedKeys.js +41 -0
  44. package/src/lib/main.d.ts +6 -0
  45. package/src/lib/main.js +5 -2
  46. package/src/lib/providers/index.js +9 -66
  47. package/src/lib/providers/native/index.js +2 -62
  48. package/src/lib/providers/provider-worker.js +1 -1
  49. package/src/lib/resolvers/envs.js +14 -5
  50. package/src/lib/resolvers/get.js +1 -0
  51. package/src/lib/services/custodyTransfer.js +2 -2
  52. package/src/lib/services/init.js +49 -0
  53. package/src/lib/services/precommit.js +12 -1
  54. package/src/lib/services/validate.js +1 -0
  55. package/src/lib/transforms/encrypt.js +6 -49
  56. package/src/lib/transforms/set.js +6 -49
@@ -1,11 +1,11 @@
1
1
  const { execFile, execFileSync } = require('child_process')
2
- const { derive } = require('@dotenvx/primitives')
3
- const Session = require('../../db/session')
4
- const prompts = require('./prompts')
5
- const createSpinner = require('./createSpinner')
2
+ const matchesStoredKey = require('../../helpers/matchesStoredKey')
3
+ const Session = require('../../../db/session')
4
+ const prompts = require('../../helpers/prompts')
5
+ const createSpinner = require('../../helpers/createSpinner')
6
6
  let unlockedSession
7
7
 
8
- const armoredKeyDisplay = require('./armoredKeyDisplay')
8
+ const armoredKeyDisplay = require('../../helpers/armoredKeyDisplay')
9
9
 
10
10
  const PREFIX = 'DOTENVX_BITWARDEN_'
11
11
  const ID = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i
@@ -110,7 +110,7 @@ function checkIdentity (status, loc) {
110
110
  }
111
111
 
112
112
  function verified (publicKey, privateKey) {
113
- try { if (derive(privateKey) === publicKey) return { [publicKey]: privateKey } } catch {}
113
+ try { if (matchesStoredKey(publicKey, privateKey)) return { [publicKey]: privateKey } } catch {}
114
114
  throw failure('Bitwarden private key does not match the .env public key')
115
115
  }
116
116
 
@@ -155,6 +155,7 @@ async function set (publicKey, privateKey) {
155
155
  if (!item || !ID.test(item.id || '') || item.organizationId) throw failure('Bitwarden did not return a personal vault item')
156
156
  const saved = (await run(['get', 'password', item.id])).trim()
157
157
  verified(publicKey, saved)
158
+ if (saved !== privateKey) throw failure('could not verify private key in Bitwarden')
158
159
  const loc = { item: item.id, userId: status.userId, serverUrl: status.serverUrl || '' }
159
160
  new Session().createStore().set(`${PREFIX}${publicKey}`, Buffer.from(JSON.stringify(loc)).toString('base64'))
160
161
  }
@@ -167,4 +168,8 @@ async function remove (publicKey) {
167
168
  new Session().openStore().delete(`${PREFIX}${publicKey}`)
168
169
  }
169
170
 
170
- module.exports = { available, configured, get, getSync, set, delete: remove }
171
+ function enabled (options = {}) {
172
+ return options.noBitwarden !== true && process.env.DOTENVX_NO_BITWARDEN !== 'true'
173
+ }
174
+
175
+ module.exports = { id: 'bitwarden', name: 'Bitwarden', enabled, store: set, available, configured, get, getSync, set, delete: remove }
@@ -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
@@ -2,6 +2,7 @@ const fs = require('fs')
2
2
  const Errors = require('./errors')
3
3
 
4
4
  const ENCODING = 'utf8'
5
+ const KEY_FILE_OPTIONS = { encoding: ENCODING, mode: 0o600 }
5
6
 
6
7
  async function readFileX (filepath, encoding = null) {
7
8
  if (!encoding) {
@@ -19,9 +20,9 @@ function readFileXSync (filepath, encoding = null) {
19
20
  return fs.readFileSync(filepath, encoding) // utf8 default so it returns a string
20
21
  }
21
22
 
22
- function writeFileXSync (filepath, str) {
23
+ function writeFileXSync (filepath, str, options = ENCODING) {
23
24
  try {
24
- return fs.writeFileSync(filepath, str, ENCODING) // utf8 always
25
+ return fs.writeFileSync(filepath, str, options)
25
26
  } catch (error) {
26
27
  if (error.code === 'EACCES' || error.code === 'EPERM') {
27
28
  throw new Errors({ filepath }).fileNotWritable()
@@ -31,9 +32,9 @@ function writeFileXSync (filepath, str) {
31
32
  }
32
33
  }
33
34
 
34
- async function writeFileX (filepath, str) {
35
+ async function writeFileX (filepath, str, options = ENCODING) {
35
36
  try {
36
- return await fs.promises.writeFile(filepath, str, ENCODING)
37
+ return await fs.promises.writeFile(filepath, str, options)
37
38
  } catch (error) {
38
39
  if (error.code === 'EACCES' || error.code === 'EPERM') {
39
40
  throw new Errors({ filepath }).fileNotWritable()
@@ -43,6 +44,14 @@ async function writeFileX (filepath, str) {
43
44
  }
44
45
  }
45
46
 
47
+ function writeKeyFile (filepath, str) {
48
+ return writeFileX(filepath, str, KEY_FILE_OPTIONS)
49
+ }
50
+
51
+ function writeKeyFileSync (filepath, str) {
52
+ return writeFileXSync(filepath, str, KEY_FILE_OPTIONS)
53
+ }
54
+
46
55
  async function exists (filepath) {
47
56
  try {
48
57
  await fs.promises.access(filepath)
@@ -65,7 +74,9 @@ const fsx = {
65
74
  readFileX,
66
75
  readFileXSync,
67
76
  writeFileX,
68
- writeFileXSync
77
+ writeFileXSync,
78
+ writeKeyFile,
79
+ writeKeyFileSync
69
80
  }
70
81
 
71
82
  module.exports = fsx
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
+ const fsx = require('./fsx')
3
4
 
4
5
  function escapeForRegex (value) {
5
6
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -34,7 +35,7 @@ function removeEnvKey (key, keysFilepath = '.env.keys') {
34
35
 
35
36
  if (hasRemainingKeys) {
36
37
  const nextSrc = `${nextLines.join(eol)}${eol}`
37
- fs.writeFileSync(resolvedKeysFilepath, nextSrc, 'utf8')
38
+ fsx.writeKeyFileSync(resolvedKeysFilepath, nextSrc)
38
39
  } else {
39
40
  fs.rmSync(resolvedKeysFilepath, { force: true })
40
41
  }
@@ -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