@dotenvx/dotenvx 2.24.1 → 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 +7 -1
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/actions/encrypt.js +1 -1
- package/src/lib/helpers/linuxSecretService.js +13 -6
- package/src/lib/helpers/macosKeychain.js +17 -7
- package/src/lib/helpers/nativeStoreError.js +10 -0
- package/src/lib/helpers/selectKeyStorage.js +23 -0
- package/src/lib/helpers/storeNativePrivateKey.js +24 -0
- package/src/lib/helpers/windowsCredentialManager.js +9 -6
- package/src/lib/main.d.ts +1 -0
- package/src/lib/services/keychainUp.js +3 -0
- package/src/lib/transforms/encrypt.js +7 -19
- package/src/lib/transforms/set.js +8 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
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.
|
|
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))
|
|
6
12
|
|
|
7
13
|
## [2.24.1](https://github.com/dotenvx/dotenvx/compare/v2.24.0...v2.24.1) (2026-09-11)
|
|
8
14
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
@@ -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 {
|
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 {
|