@dotenvx/dotenvx 2.24.0 → 2.25.0

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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
- [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.24.0...main)
5
+ [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.25.0...main)
6
+
7
+ ## [2.25.0](https://github.com/dotenvx/dotenvx/compare/v2.24.1...v2.25.0) (2026-09-14)
8
+
9
+ ### Changed
10
+
11
+ * Store private key to os secret store rather than `.env.keys` by default ([#967](https://github.com/dotenvx/dotenvx/pull/967))
12
+
13
+ ## [2.24.1](https://github.com/dotenvx/dotenvx/compare/v2.24.0...v2.24.1) (2026-09-11)
14
+
15
+ ### Changed
16
+
17
+ * Upgraded to latest [@dotenvx/primitives](https://www.npmjs.com/package/@dotenvx/primitives)
6
18
 
7
19
  ## [2.24.0](https://github.com/dotenvx/dotenvx/compare/v2.23.0...v2.24.0) (2026-09-10)
8
20
 
package/README.md CHANGED
@@ -872,6 +872,14 @@ $ dotenvx encrypt
872
872
 
873
873
  > A `DOTENV_PUBLIC_KEY` (encryption key) and a `DOTENV_PRIVATE_KEY` (decryption key) are generated using the same public-key cryptography as [Bitcoin](https://en.bitcoin.it/wiki/Secp256k1).
874
874
 
875
+ New private keys created by `encrypt` and encrypted `set` default to your OS secret store: macOS Keychain, Windows Credential Manager, or Linux Secret Service. When logged into Armor, the interactive picker offers OS secret store or Armor. When Armor is off, OS storage is selected automatically. Existing `.env.keys` files are not automatically migrated.
876
+
877
+ Linux requires `secret-tool` (typically the `libsecret-tools` package), a running Secret Service such as GNOME Keyring, and a user D-Bus session. When native tooling or the service is missing, dotenvx reports the fallback to `.env.keys`. Locked, denied, timed-out, or unverified storage operations fail without falling back to a plaintext key file.
878
+
879
+ CI uses file storage. To explicitly use file storage elsewhere, run `dotenvx encrypt --no-native --no-armor` (or pass both flags to `set`). For deployment, continue supplying the private key through your platform's secret injection.
880
+
881
+ To move an existing key into the OS store, run `dotenvx native up`. It verifies the stored key before removing it from `.env.keys`. Use `dotenvx native push` to keep a file copy, `dotenvx native pull` to export a copy, or `dotenvx native down` to move it back to `.env.keys`. Add `-f .env.production` for a particular env file. Keep a recoverable copy in your team's secret manager or backup before replacing or losing the machine.
882
+
875
883
  More examples
876
884
 
877
885
  <details><summary>`.env`</summary><br>
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.24.0",
2
+ "version": "2.25.0",
3
3
  "name": "@dotenvx/dotenvx",
4
4
  "description": "a secure dotenv–from the creator of `dotenv`",
5
5
  "author": "@motdotla",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "funding": "https://dotenvx.com",
55
55
  "dependencies": {
56
- "@dotenvx/primitives": "^2.2.0",
56
+ "@dotenvx/primitives": "^3.0.2",
57
57
  "@dotenvx/tooling": "^1.0.3",
58
58
  "yocto-spinner": "^1.2.1"
59
59
  },
@@ -35,7 +35,7 @@ async function encryptAction () {
35
35
  errorCount += 1
36
36
  logger.error(processedEnv.error.messageWithHelp || processedEnv.error.message)
37
37
  }
38
- if (processedEnv.envSrc) {
38
+ if (!processedEnv.error && processedEnv.envSrc) {
39
39
  console.log(processedEnv.envSrc)
40
40
  }
41
41
  }
@@ -1,4 +1,5 @@
1
1
  const { execFileSync } = require('child_process')
2
+ const nativeStoreError = require('./nativeStoreError')
2
3
 
3
4
  const SECRET_TOOL_BIN = 'secret-tool'
4
5
  const SERVICE = 'dotenvx'
@@ -10,11 +11,13 @@ function attributes (publicKey) {
10
11
  function get (publicKey) {
11
12
  try {
12
13
  return execFileSync(SECRET_TOOL_BIN, ['lookup', ...attributes(publicKey)], {
14
+ timeout: 10000,
15
+ killSignal: 'SIGKILL',
13
16
  encoding: 'utf8',
14
17
  stdio: ['ignore', 'pipe', 'pipe']
15
18
  }).trim() || null
16
- } catch {
17
- throw new Error('failed to read private key from Linux Secret Service')
19
+ } catch (error) {
20
+ throw nativeStoreError('failed to read private key from Linux Secret Service', error, true)
18
21
  }
19
22
  }
20
23
 
@@ -22,11 +25,13 @@ function set (publicKey, privateKey, label) {
22
25
  try {
23
26
  execFileSync(SECRET_TOOL_BIN, ['store', `--label=${label}`, ...attributes(publicKey)], {
24
27
  input: privateKey,
28
+ timeout: 10000,
29
+ killSignal: 'SIGKILL',
25
30
  encoding: 'utf8',
26
31
  stdio: ['pipe', 'ignore', 'pipe']
27
32
  })
28
- } catch {
29
- throw new Error('failed to save private key to Linux Secret Service')
33
+ } catch (error) {
34
+ throw nativeStoreError('failed to save private key to Linux Secret Service', error, true)
30
35
  }
31
36
  }
32
37
 
@@ -36,10 +41,12 @@ module.exports = {
36
41
  delete (publicKey) {
37
42
  try {
38
43
  execFileSync(SECRET_TOOL_BIN, ['clear', ...attributes(publicKey)], {
44
+ timeout: 10000,
45
+ killSignal: 'SIGKILL',
39
46
  stdio: ['ignore', 'ignore', 'pipe']
40
47
  })
41
- } catch {
42
- throw new Error('failed to delete private key from Linux Secret Service')
48
+ } catch (error) {
49
+ throw nativeStoreError('failed to delete private key from Linux Secret Service', error, true)
43
50
  }
44
51
  }
45
52
  }
@@ -1,26 +1,36 @@
1
1
  const { execFileSync } = require('child_process')
2
+ const nativeStoreError = require('./nativeStoreError')
2
3
 
3
4
  const SECURITY_BIN = '/usr/bin/security'
4
5
  const SERVICE = 'dotenvx'
5
6
 
6
7
  module.exports = {
7
8
  get (key) {
8
- return execFileSync(SECURITY_BIN, ['find-generic-password', '-s', SERVICE, '-a', key, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
9
+ try {
10
+ return execFileSync(SECURITY_BIN, ['find-generic-password', '-s', SERVICE, '-a', key, '-w'], {
11
+ timeout: 10000,
12
+ killSignal: 'SIGKILL',
13
+ encoding: 'utf8',
14
+ stdio: ['ignore', 'pipe', 'ignore']
15
+ }).trim()
16
+ } catch (error) {
17
+ throw nativeStoreError('failed to read private key from macOS Keychain', error)
18
+ }
9
19
  },
10
20
 
11
21
  set (key, value, label) {
12
22
  try {
13
- execFileSync(SECURITY_BIN, ['add-generic-password', '-U', '-s', SERVICE, '-a', key, '-l', label, '-w', value], { stdio: 'ignore' })
14
- } catch {
15
- throw new Error('failed to save private key to macOS Keychain')
23
+ execFileSync(SECURITY_BIN, ['add-generic-password', '-U', '-s', SERVICE, '-a', key, '-l', label, '-w', value], { timeout: 10000, killSignal: 'SIGKILL', stdio: 'ignore' })
24
+ } catch (error) {
25
+ throw nativeStoreError('failed to save private key to macOS Keychain', error)
16
26
  }
17
27
  },
18
28
 
19
29
  delete (key) {
20
30
  try {
21
- execFileSync(SECURITY_BIN, ['delete-generic-password', '-s', SERVICE, '-a', key], { stdio: 'ignore' })
22
- } catch {
23
- throw new Error('failed to delete private key from macOS Keychain')
31
+ execFileSync(SECURITY_BIN, ['delete-generic-password', '-s', SERVICE, '-a', key], { timeout: 10000, killSignal: 'SIGKILL', stdio: 'ignore' })
32
+ } catch (error) {
33
+ throw nativeStoreError('failed to delete private key from macOS Keychain', error)
24
34
  }
25
35
  }
26
36
  }
@@ -0,0 +1,10 @@
1
+ // Never attach subprocess errors: their command/output may contain private keys.
2
+ function nativeStoreError (message, error, linux = false) {
3
+ const unavailable = error.code === 'ENOENT' || (linux &&
4
+ /org\.freedesktop\.DBus\.Error\.(ServiceUnknown|NoServer)|Cannot autolaunch D-Bus without X11|Failed to connect to socket .*: No such file or directory/.test(String(error.stderr || '')))
5
+ const result = new Error(message)
6
+ result.code = unavailable ? 'NATIVE_UNAVAILABLE' : 'NATIVE_ACCESS_FAILED'
7
+ return result
8
+ }
9
+
10
+ module.exports = nativeStoreError
@@ -1,4 +1,4 @@
1
- const { parse, parseSync } = require('@dotenvx/primitives')
1
+ const { parse, parseSync, parsearrays } = require('@dotenvx/primitives')
2
2
  const SERVER_SIDE_DECRYPTION_REQUIRED = 'SERVER_SIDE_DECRYPTION_REQUIRED'
3
3
 
4
4
  function decryptOptions (error) {
@@ -27,21 +27,29 @@ function failedKeyAccessFallback (result, error) {
27
27
  }
28
28
 
29
29
  async function parseWithDecryptor (src, options = {}) {
30
+ return parseWith(src, options, parse)
31
+ }
32
+
33
+ parseWithDecryptor.arrays = async function parsearraysWithDecryptor (src, options = {}) {
34
+ return parseWith(src, options, parsearrays)
35
+ }
36
+
37
+ async function parseWith (src, options, parser) {
30
38
  try {
31
- return await parse(src, options)
39
+ return await parser(src, options)
32
40
  } catch (error) {
33
41
  if (error.code !== SERVER_SIDE_DECRYPTION_REQUIRED || typeof options.decryptor !== 'function') {
34
42
  if (typeof options.provider !== 'function') throw error
35
43
 
36
- const result = await parse(src, parseOptionsWithoutProvider(options))
44
+ const result = await parser(src, parseOptionsWithoutProvider(options))
37
45
  return failedKeyAccessFallback(result, error)
38
46
  }
39
47
 
40
48
  try {
41
49
  const result = await options.decryptor(src, decryptOptions(error))
42
- return await parse(result.src, parseOptionsWithoutProvider(options))
50
+ return await parser(result.src, parseOptionsWithoutProvider(options))
43
51
  } catch (decryptorError) {
44
- const result = await parse(src, parseOptionsWithoutProvider(options))
52
+ const result = await parser(src, parseOptionsWithoutProvider(options))
45
53
  return failedKeyAccessFallback(result, decryptorError)
46
54
  }
47
55
  }
@@ -0,0 +1,23 @@
1
+ const prompts = require('./prompts')
2
+
3
+ const secretStoreNames = {
4
+ darwin: 'macOS Keychain',
5
+ win32: 'Windows Credential Manager',
6
+ linux: 'Linux Secret Service'
7
+ }
8
+
9
+ async function selectKeyStorage (options = {}) {
10
+ const useNative = !options.noKeychain && !process.env.CI && ['darwin', 'linux', 'win32'].includes(process.platform)
11
+ const defaultStorage = useNative ? 'native' : 'file'
12
+ if (process.env.CI || options.noCreate || options.noArmor || !process.stdin.isTTY || !process.stderr.isTTY) return defaultStorage
13
+
14
+ return prompts.select({
15
+ message: 'Choose private key storage',
16
+ choices: [
17
+ ...(useNative ? [{ name: `□ Local Custody (${secretStoreNames[process.platform]})`, value: 'native' }] : []),
18
+ { name: '⛨ Managed Custody (Armor)', value: 'armored' }
19
+ ]
20
+ }, { input: process.stdin, output: process.stderr })
21
+ }
22
+
23
+ module.exports = selectKeyStorage
@@ -0,0 +1,24 @@
1
+ const nativeProvider = require('../providers/native')
2
+ const armoredKeyDisplay = require('./armoredKeyDisplay')
3
+ const { logger } = require('../../shared/logger')
4
+
5
+ function storeNativePrivateKey (publicKey, privateKey, keysFilepath) {
6
+ try {
7
+ nativeProvider.set(publicKey, privateKey, `dotenvx (${armoredKeyDisplay(publicKey)})`)
8
+ } catch (error) {
9
+ if (error.code !== 'NATIVE_UNAVAILABLE') throw error
10
+ logger.warn(`OS secret store unavailable; saving private key to ${keysFilepath}`)
11
+ return false
12
+ }
13
+
14
+ // A successful write must be readable before committing the encrypted file.
15
+ // Read failures must never fall back to plaintext storage.
16
+ if (nativeProvider.get(publicKey) !== privateKey) {
17
+ const error = new Error('could not verify private key in OS secret store')
18
+ error.code = 'NATIVE_VERIFY_FAILED'
19
+ throw error
20
+ }
21
+ return true
22
+ }
23
+
24
+ module.exports = storeNativePrivateKey
@@ -1,4 +1,5 @@
1
1
  const { execFileSync } = require('child_process')
2
+ const nativeStoreError = require('./nativeStoreError')
2
3
 
3
4
  const POWERSHELL_BIN = 'powershell.exe'
4
5
  const SERVICE = 'dotenvx'
@@ -128,6 +129,8 @@ function target (publicKey) {
128
129
  function run (payload) {
129
130
  return execFileSync(POWERSHELL_BIN, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodedScript], {
130
131
  input: JSON.stringify(payload),
132
+ timeout: 10000,
133
+ killSignal: 'SIGKILL',
131
134
  encoding: 'utf8',
132
135
  stdio: ['pipe', 'pipe', 'pipe'],
133
136
  windowsHide: true
@@ -137,16 +140,16 @@ function run (payload) {
137
140
  function get (publicKey) {
138
141
  try {
139
142
  return run({ action: 'read', target: target(publicKey) }).trim() || null
140
- } catch {
141
- throw new Error('failed to read private key from Windows Credential Manager')
143
+ } catch (error) {
144
+ throw nativeStoreError('failed to read private key from Windows Credential Manager', error)
142
145
  }
143
146
  }
144
147
 
145
148
  function set (publicKey, privateKey) {
146
149
  try {
147
150
  run({ action: 'write', target: target(publicKey), username: publicKey, secret: privateKey })
148
- } catch {
149
- throw new Error('failed to save private key to Windows Credential Manager')
151
+ } catch (error) {
152
+ throw nativeStoreError('failed to save private key to Windows Credential Manager', error)
150
153
  }
151
154
  }
152
155
 
@@ -156,8 +159,8 @@ module.exports = {
156
159
  delete (publicKey) {
157
160
  try {
158
161
  run({ action: 'delete', target: target(publicKey) })
159
- } catch {
160
- throw new Error('failed to delete private key from Windows Credential Manager')
162
+ } catch (error) {
163
+ throw nativeStoreError('failed to delete private key from Windows Credential Manager', error)
161
164
  }
162
165
  }
163
166
  }
package/src/lib/main.d.ts CHANGED
@@ -306,6 +306,7 @@ export type SetProcessedEnv = {
306
306
  publicKey?: string;
307
307
  privateKey?: string;
308
308
  localPrivateKeyAdded?: boolean;
309
+ nativePrivateKeyAdded?: boolean;
309
310
  remotePrivateKeyAdded?: boolean;
310
311
  privateKeyName?: string;
311
312
  error?: Error;
@@ -54,6 +54,9 @@ class KeychainUp {
54
54
  }
55
55
 
56
56
  nativeProvider.set(publicKey, privateKey, label)
57
+ if (nativeProvider.get(publicKey) !== privateKey) {
58
+ throw new Error('could not verify private key in OS secret store; .env.keys unchanged')
59
+ }
57
60
  removeEnvKey(privateKeyName, envKeysFile)
58
61
 
59
62
  return {
@@ -44,7 +44,7 @@ async function decrypt (options = {}) {
44
44
  const encoding = await detectEncoding(filepath)
45
45
  row.envSrc = await fsx.readFileX(filepath, { encoding })
46
46
 
47
- const { parsed, errors } = await parseWithDecryptor(row.envSrc, { fk, ik, ek, array: true, provider, decryptor })
47
+ const { parsed, errors } = await parseWithDecryptor.arrays(row.envSrc, { fk, ik, ek, provider, decryptor })
48
48
 
49
49
  if (errors.length > 0) {
50
50
  row.error = parseError(errors[0])
@@ -16,27 +16,15 @@ const teamChoicesFromMeta = require('../helpers/teamChoicesFromMeta')
16
16
  const isTeamRequiredError = require('../helpers/isTeamRequiredError')
17
17
  const Session = require('../../db/session')
18
18
 
19
- async function selectKeyStorage () {
20
- const selected = await prompts.select({
21
- message: 'Choose private key storage',
22
- choices: [
23
- { name: '◫ File (.env.keys)', value: 'file' },
24
- { name: '⛨ Armor (armor.dotenvx.com)', value: 'armored' }
25
- ]
26
- }, {
27
- input: process.stdin,
28
- output: process.stderr
29
- })
30
-
31
- return selected
32
- }
19
+ const selectKeyStorage = require('../helpers/selectKeyStorage')
20
+ const storeNativePrivateKey = require('../helpers/storeNativePrivateKey')
33
21
 
34
22
  async function encryptTransform (options = {}) {
35
23
  const envs = options.envs || []
36
24
  const ik = options.ik
37
25
  const ek = options.ek
38
26
  const fk = options.fk || '.env.keys'
39
- let noArmor = options.noArmor // key storage selector below
27
+ let storage
40
28
  const noCreate = options.noCreate
41
29
 
42
30
  const processedEnvs = []
@@ -85,9 +73,7 @@ async function encryptTransform (options = {}) {
85
73
  let publicKey = publickeys(row.envSrc)[0]
86
74
 
87
75
  if (!publicKey) {
88
- if (!noCreate && !noArmor && selectKeyStorage) {
89
- noArmor = await selectKeyStorage() !== 'armored'
90
- }
76
+ storage = storage || await selectKeyStorage(options)
91
77
 
92
78
  // upsert public key to .env file
93
79
  const kp = keypair() // local
@@ -99,7 +85,9 @@ async function encryptTransform (options = {}) {
99
85
 
100
86
  const comment = path.basename(envFilepath)
101
87
 
102
- if (noArmor) {
88
+ if (storage === 'native' && storeNativePrivateKey(publicKey, privateKey, fk)) {
89
+ row.nativePrivateKeyAdded = true
90
+ } else if (storage !== 'armored') {
103
91
  const mutated = mutateKeysSrc({ keysSrc, privateKeyName, privateKeyValue: privateKey, comment })
104
92
  keysSrc = mutated.keysSrc
105
93
  } else {
@@ -16,27 +16,16 @@ const teamChoicesFromMeta = require('../helpers/teamChoicesFromMeta')
16
16
  const isTeamRequiredError = require('../helpers/isTeamRequiredError')
17
17
  const Session = require('../../db/session')
18
18
 
19
- async function selectKeyStorage () {
20
- const selected = await prompts.select({
21
- message: 'Choose private key storage',
22
- choices: [
23
- { name: '◫ File (.env.keys)', value: 'file' },
24
- { name: '⛨ Armor (armor.dotenvx.com)', value: 'armored' }
25
- ]
26
- }, {
27
- input: process.stdin,
28
- output: process.stderr
29
- })
30
-
31
- return selected
32
- }
19
+ const selectKeyStorage = require('../helpers/selectKeyStorage')
20
+ const storeNativePrivateKey = require('../helpers/storeNativePrivateKey')
33
21
 
34
22
  async function setTransform (options = {}) {
35
23
  const envs = options.envs || []
36
24
  const key = options.key
37
25
  const value = options.value
38
26
  const fk = options.fk || '.env.keys'
39
- let noArmor = options.noArmor // key storage selector below
27
+ const noArmor = options.noArmor
28
+ let storage
40
29
  const noKeychain = options.noKeychain
41
30
  const noCreate = options.noCreate
42
31
  const noEncrypt = !options.encrypt || isPlainKey(key)
@@ -88,9 +77,7 @@ async function setTransform (options = {}) {
88
77
 
89
78
  // only create if missing public key and encryption needed
90
79
  if (!publicKey && !noEncrypt) {
91
- if (!noCreate && !noArmor && selectKeyStorage) {
92
- noArmor = await selectKeyStorage() !== 'armored'
93
- }
80
+ storage = storage || await selectKeyStorage(options)
94
81
 
95
82
  // upsert public key to .env file
96
83
  const kp = keypair() // local
@@ -102,7 +89,9 @@ async function setTransform (options = {}) {
102
89
 
103
90
  const comment = path.basename(envFilepath)
104
91
 
105
- if (noArmor) {
92
+ if (storage === 'native' && storeNativePrivateKey(publicKey, privateKey, fk)) {
93
+ row.nativePrivateKeyAdded = true
94
+ } else if (storage !== 'armored') {
106
95
  const mutated = mutateKeysSrc({ keysSrc, privateKeyName, privateKeyValue: privateKey, comment })
107
96
  keysSrc = mutated.keysSrc
108
97
  } else {