@dotenvx/dotenvx 2.25.0 → 2.26.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 +6 -2
- package/package.json +1 -1
- package/src/cli/actions/encrypt.js +5 -2
- package/src/cli/actions/keypair.js +2 -0
- package/src/cli/actions/set.js +4 -1
- package/src/cli/dotenvx.js +6 -0
- package/src/lib/helpers/bitwardenCustody.js +162 -0
- package/src/lib/helpers/onePasswordCustody.js +159 -0
- package/src/lib/helpers/selectKeyStorage.js +16 -5
- package/src/lib/main.d.ts +6 -0
- package/src/lib/main.js +2 -0
- package/src/lib/providers/index.js +16 -0
- package/src/lib/transforms/encrypt.js +7 -1
- package/src/lib/transforms/set.js +10 -2
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.26.0...main)
|
|
6
|
+
|
|
7
|
+
## [2.25.0](https://github.com/dotenvx/dotenvx/compare/v2.25.0...v2.26.0) (2026-09-15)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* Add support for storing private key to bitwarden or 1password ([#968](https://github.com/dotenvx/dotenvx/pull/968))
|
|
6
12
|
|
|
7
13
|
## [2.25.0](https://github.com/dotenvx/dotenvx/compare/v2.24.1...v2.25.0) (2026-09-14)
|
|
8
14
|
|
package/README.md
CHANGED
|
@@ -872,11 +872,15 @@ $ 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.
|
|
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. The interactive picker offers Local Custody through your OS secret store, Local Custody through 1Password when available, and Managed Custody through Armor when logged in. When only the OS store is available, it is selected automatically. Existing `.env.keys` files are not automatically migrated.
|
|
876
876
|
|
|
877
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
878
|
|
|
879
|
-
CI uses file storage. To explicitly use file storage elsewhere, run `dotenvx encrypt --no-native --no-armor` (or pass
|
|
879
|
+
CI uses file storage. To explicitly use file storage elsewhere, run `dotenvx encrypt --no-native --no-armor --no-1password` (or pass all three flags to `set`). For deployment, continue supplying the private key through your platform's secret injection.
|
|
880
|
+
|
|
881
|
+
The 1Password option appears when CLI version 2 is installed and an account is configured (or `OP_SERVICE_ACCOUNT_TOKEN` is set). Detection does not sign in or unlock your vault. Selecting it authenticates as needed, uses the default vault selected by the 1Password CLI, and verifies the saved key before writing the encrypted env file. An unavailable, denied, or cancelled 1Password operation fails without falling back to local key storage.
|
|
882
|
+
|
|
883
|
+
1Password custody stores the key in your default vault and saves only its account/item reference in dotenvx's per-user settings. `run`, `get`, and `keypair` then retrieve it automatically, subject to 1Password authorization. `--no-1password` (where supported) or `DOTENVX_NO_1PASSWORD=true` disables that integration. The local reference is needed for automatic lookup on this machine; on another machine, the key is still accessible in the vault but its locator must also be configured for automatic lookup. No plaintext private key is written to `.env.keys` or passed in command arguments. The “Local Custody” label refers to using your own password manager; 1Password vaults may sync off-machine.
|
|
880
884
|
|
|
881
885
|
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
886
|
|
package/package.json
CHANGED
|
@@ -23,11 +23,14 @@ async function encryptAction () {
|
|
|
23
23
|
const noArmor = options.armor === false || (!options.token && (await sesh.noArmor()))
|
|
24
24
|
const noKeychain = options.native === false || options.noNative === true
|
|
25
25
|
|
|
26
|
+
const noBitwarden = options.bitwarden === false || options.noBitwarden === true
|
|
27
|
+
const no1Password = options['1password'] === false || options.no1Password === true
|
|
28
|
+
|
|
26
29
|
let errorCount = 0
|
|
27
30
|
|
|
28
31
|
// stdout - should not have a try so that exit codes can surface to stdout
|
|
29
32
|
if (options.stdout) {
|
|
30
|
-
const { processedEnvs } = await encryptTransform({ envs, ik, ek, fk, noArmor, noCreate, noKeychain })
|
|
33
|
+
const { processedEnvs } = await encryptTransform({ envs, ik, ek, fk, noArmor, noCreate, noKeychain, no1Password, noBitwarden })
|
|
31
34
|
|
|
32
35
|
if (spinner) spinner.stop()
|
|
33
36
|
for (const processedEnv of processedEnvs) {
|
|
@@ -48,7 +51,7 @@ async function encryptAction () {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
try {
|
|
51
|
-
const { keysSrc, processedEnvs, changedFilepaths, unchangedFilepaths } = await encryptTransform({ envs, ik, ek, fk, noArmor, noCreate, noKeychain })
|
|
54
|
+
const { keysSrc, processedEnvs, changedFilepaths, unchangedFilepaths } = await encryptTransform({ envs, ik, ek, fk, noArmor, noCreate, noKeychain, no1Password, noBitwarden })
|
|
52
55
|
|
|
53
56
|
if (keysSrc) {
|
|
54
57
|
await fsx.writeFileX(fk, keysSrc)
|
|
@@ -22,6 +22,8 @@ async function keypair (key) {
|
|
|
22
22
|
envKeysFile: options.envKeysFile,
|
|
23
23
|
armor: options.armor,
|
|
24
24
|
noKeychain: options.native === false || options.noNative === true,
|
|
25
|
+
noBitwarden: options.bitwarden === false || options.noBitwarden === true,
|
|
26
|
+
no1Password: options['1password'] === false || options.no1Password === true,
|
|
25
27
|
token: options.token,
|
|
26
28
|
command: process.argv.slice(2),
|
|
27
29
|
onStatus: (text) => {
|
package/src/cli/actions/set.js
CHANGED
|
@@ -61,10 +61,13 @@ async function set (key, value) {
|
|
|
61
61
|
const noArmor = options.armor === false || (!options.token && (await sesh.noArmor()))
|
|
62
62
|
const noKeychain = options.native === false || options.noNative === true
|
|
63
63
|
|
|
64
|
+
const noBitwarden = options.bitwarden === false || options.noBitwarden === true
|
|
65
|
+
const no1Password = options['1password'] === false || options.no1Password === true
|
|
66
|
+
|
|
64
67
|
let errorCount = 0
|
|
65
68
|
|
|
66
69
|
try {
|
|
67
|
-
const { keysSrc, processedEnvs, changedFilepaths, unchangedFilepaths } = await setTransform({ envs, key, value, fk, noArmor, noCreate, encrypt, noKeychain })
|
|
70
|
+
const { keysSrc, processedEnvs, changedFilepaths, unchangedFilepaths } = await setTransform({ envs, key, value, fk, noArmor, noCreate, encrypt, noKeychain, no1Password, noBitwarden })
|
|
68
71
|
|
|
69
72
|
if (keysSrc) {
|
|
70
73
|
await fsx.writeFileX(fk, keysSrc)
|
package/src/cli/dotenvx.js
CHANGED
|
@@ -131,6 +131,8 @@ program.command('set')
|
|
|
131
131
|
.option('--no-create', 'do not create .env file(s) when missing')
|
|
132
132
|
.option('--no-armor', 'disable Dotenvx Armor features')
|
|
133
133
|
.option('--no-native', 'disable OS secret store features')
|
|
134
|
+
.option('--no-1password', 'disable 1Password features')
|
|
135
|
+
.option('--no-bitwarden', 'disable Bitwarden features')
|
|
134
136
|
.action(function (...args) {
|
|
135
137
|
this.envs = envs
|
|
136
138
|
return require('./actions/set').apply(this, args)
|
|
@@ -161,6 +163,8 @@ function configureEncryptCommand (command) {
|
|
|
161
163
|
.option('--no-create', 'do not create .env file(s) when missing')
|
|
162
164
|
.option('--no-armor', 'disable Dotenvx Armor features')
|
|
163
165
|
.option('--no-native', 'disable OS secret store features')
|
|
166
|
+
.option('--no-1password', 'disable 1Password features')
|
|
167
|
+
.option('--no-bitwarden', 'disable Bitwarden features')
|
|
164
168
|
.action(function (...args) {
|
|
165
169
|
this.envs = envs
|
|
166
170
|
return require('./actions/encrypt').apply(this, args)
|
|
@@ -198,6 +202,8 @@ program.command('keypair')
|
|
|
198
202
|
.option('-fk, --env-keys-file <path>', 'path(s) to your .env.keys file(s) (default: same path as your env file)', collectEnvKeys)
|
|
199
203
|
.option('--no-armor', 'disable Dotenvx Armor features')
|
|
200
204
|
.option('--no-native', 'disable OS secret store features')
|
|
205
|
+
.option('--no-1password', 'disable 1Password features')
|
|
206
|
+
.option('--no-bitwarden', 'disable Bitwarden features')
|
|
201
207
|
.option('-pp, --pretty-print', 'pretty print output')
|
|
202
208
|
.option('--pp', 'pretty print output (alias)')
|
|
203
209
|
.option('--format <type>', 'format of the output (json, shell, colon)', 'json')
|
|
@@ -0,0 +1,162 @@
|
|
|
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')
|
|
6
|
+
let unlockedSession
|
|
7
|
+
|
|
8
|
+
const armoredKeyDisplay = require('./armoredKeyDisplay')
|
|
9
|
+
|
|
10
|
+
const PREFIX = 'DOTENVX_BITWARDEN_'
|
|
11
|
+
const ID = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i
|
|
12
|
+
const COMMAND_OPTIONS = { encoding: 'utf8', windowsHide: true, timeout: 120000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }
|
|
13
|
+
|
|
14
|
+
function failure (message) {
|
|
15
|
+
const error = new Error(message)
|
|
16
|
+
error.code = 'BITWARDEN_FAILED'
|
|
17
|
+
return error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function commandFailure () {
|
|
21
|
+
return failure('Bitwarden CLI operation failed; check sign-in and unlock your vault with BW_SESSION')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run (args, input, timeout = COMMAND_OPTIONS.timeout, env = commandEnv()) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const child = execFile('bw', [...args, '--nointeraction'], { ...COMMAND_OPTIONS, timeout, env }, (error, stdout) => {
|
|
27
|
+
if (error) reject(commandFailure())
|
|
28
|
+
else resolve(stdout)
|
|
29
|
+
})
|
|
30
|
+
if (child.stdin) {
|
|
31
|
+
child.stdin.on('error', () => {})
|
|
32
|
+
child.stdin.end(input)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parse (value) {
|
|
38
|
+
try { return JSON.parse(value) } catch {
|
|
39
|
+
throw failure('Bitwarden CLI returned an invalid response')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function available () {
|
|
44
|
+
try { return /^\d+\.\d+\.\d+/.test((await run(['--version'], undefined, 2000)).trim()) } catch { return false }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function configured () {
|
|
48
|
+
const store = new Session().openStore()
|
|
49
|
+
return !!store && Object.keys(store.store).some(key => key.startsWith(PREFIX))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function location (publicKey) {
|
|
53
|
+
const store = new Session().openStore()
|
|
54
|
+
const value = store && store.get(`${PREFIX}${publicKey}`)
|
|
55
|
+
if (!value) return null
|
|
56
|
+
// Base64 keeps the nonsecret JSON locator safe in the dotenv settings file.
|
|
57
|
+
const loc = parse(Buffer.from(String(value), 'base64').toString('utf8'))
|
|
58
|
+
if (!loc || !ID.test(loc.item || '') || !ID.test(loc.userId || '') || typeof loc.serverUrl !== 'string') {
|
|
59
|
+
throw failure('invalid Bitwarden private-key reference in dotenvx settings')
|
|
60
|
+
}
|
|
61
|
+
return loc
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function commandEnv () {
|
|
65
|
+
return { ...process.env, ...(unlockedSession ? { BW_SESSION: unlockedSession } : {}) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function authenticate (loc) {
|
|
69
|
+
let status = parse(await run(['status']))
|
|
70
|
+
if (loc && status && status.userId && (status.userId !== loc.userId || (status.serverUrl || '') !== loc.serverUrl)) {
|
|
71
|
+
throw failure('Bitwarden account or server does not match the stored private-key reference')
|
|
72
|
+
}
|
|
73
|
+
if (status && status.status === 'unlocked') return checkIdentity(status, loc)
|
|
74
|
+
unlockedSession = undefined
|
|
75
|
+
if (status && status.status === 'unauthenticated') throw failure('Sign in to Bitwarden once with bw login, then retry; dotenvx will prompt to unlock your vault')
|
|
76
|
+
if (!status || status.status !== 'locked') throw commandFailure()
|
|
77
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY || process.env.CI) {
|
|
78
|
+
throw failure('Bitwarden is locked; set an unlocked BW_SESSION for noninteractive use')
|
|
79
|
+
}
|
|
80
|
+
let password
|
|
81
|
+
createSpinner.pause()
|
|
82
|
+
try {
|
|
83
|
+
password = await prompts.password({ message: 'Bitwarden master password', prefix: '◇', separator: '=' }, { input: process.stdin, output: process.stderr })
|
|
84
|
+
} finally {
|
|
85
|
+
createSpinner.resume()
|
|
86
|
+
}
|
|
87
|
+
const passwordEnv = 'DOTENVX_BITWARDEN_PASSWORD'
|
|
88
|
+
const session = (await run(['unlock', '--passwordenv', passwordEnv, '--raw'], undefined, COMMAND_OPTIONS.timeout, { ...process.env, [passwordEnv]: password })).trim()
|
|
89
|
+
if (!session) throw commandFailure()
|
|
90
|
+
unlockedSession = session
|
|
91
|
+
try {
|
|
92
|
+
status = parse(await run(['status']))
|
|
93
|
+
return checkIdentity(status, loc)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
unlockedSession = undefined
|
|
96
|
+
throw error
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function requireSession () {
|
|
101
|
+
if (!unlockedSession && !process.env.BW_SESSION) throw failure('Bitwarden requires an unlocked BW_SESSION; run bw login, then bw unlock --raw and set BW_SESSION')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function checkIdentity (status, loc) {
|
|
105
|
+
if (!status || status.status !== 'unlocked' || !ID.test(status.userId || '')) throw commandFailure()
|
|
106
|
+
if (loc && (status.userId !== loc.userId || (status.serverUrl || '') !== loc.serverUrl)) {
|
|
107
|
+
throw failure('Bitwarden account or server does not match the stored private-key reference')
|
|
108
|
+
}
|
|
109
|
+
return status
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function verified (publicKey, privateKey) {
|
|
113
|
+
try { if (derive(privateKey) === publicKey) return { [publicKey]: privateKey } } catch {}
|
|
114
|
+
throw failure('Bitwarden private key does not match the .env public key')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function get (publicKey) {
|
|
118
|
+
const loc = location(publicKey)
|
|
119
|
+
if (!loc) return {}
|
|
120
|
+
await authenticate(loc)
|
|
121
|
+
return verified(publicKey, (await run(['get', 'password', loc.item])).trim())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getSync (publicKey) {
|
|
125
|
+
const loc = location(publicKey)
|
|
126
|
+
if (!loc) return {}
|
|
127
|
+
requireSession()
|
|
128
|
+
function read (args) {
|
|
129
|
+
try { return execFileSync('bw', [...args, '--nointeraction'], { ...COMMAND_OPTIONS, env: commandEnv(), stdio: ['ignore', 'pipe', 'pipe'] }) } catch { throw commandFailure() }
|
|
130
|
+
}
|
|
131
|
+
checkIdentity(parse(read(['status'])), loc)
|
|
132
|
+
return verified(publicKey, read(['get', 'password', loc.item]).trim())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function set (publicKey, privateKey) {
|
|
136
|
+
verified(publicKey, privateKey)
|
|
137
|
+
if (location(publicKey)) {
|
|
138
|
+
await get(publicKey)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const status = await authenticate()
|
|
142
|
+
const template = {
|
|
143
|
+
organizationId: null,
|
|
144
|
+
collectionIds: [],
|
|
145
|
+
folderId: null,
|
|
146
|
+
type: 1,
|
|
147
|
+
name: `dotenvx (${armoredKeyDisplay(publicKey)})`,
|
|
148
|
+
notes: null,
|
|
149
|
+
favorite: false,
|
|
150
|
+
fields: [{ name: 'public_key', value: publicKey, type: 0 }],
|
|
151
|
+
login: { username: 'private_key', password: privateKey, totp: null, uris: [] }
|
|
152
|
+
}
|
|
153
|
+
// Pass encoded JSON on stdin, never secrets in command arguments or files.
|
|
154
|
+
const item = parse(await run(['create', 'item'], Buffer.from(JSON.stringify(template)).toString('base64')))
|
|
155
|
+
if (!item || !ID.test(item.id || '') || item.organizationId) throw failure('Bitwarden did not return a personal vault item')
|
|
156
|
+
const saved = (await run(['get', 'password', item.id])).trim()
|
|
157
|
+
verified(publicKey, saved)
|
|
158
|
+
const loc = { item: item.id, userId: status.userId, serverUrl: status.serverUrl || '' }
|
|
159
|
+
new Session().createStore().set(`${PREFIX}${publicKey}`, Buffer.from(JSON.stringify(loc)).toString('base64'))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { available, configured, get, getSync, set }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const { execFile, execFileSync } = require('child_process')
|
|
2
|
+
const { derive } = require('@dotenvx/primitives')
|
|
3
|
+
const Session = require('../../db/session')
|
|
4
|
+
const armoredKeyDisplay = require('./armoredKeyDisplay')
|
|
5
|
+
|
|
6
|
+
const PREFIX = 'DOTENVX_ONEPASSWORD_'
|
|
7
|
+
const ID = /^[a-z0-9]{26}$/i
|
|
8
|
+
const COMMAND_OPTIONS = { encoding: 'utf8', windowsHide: true, timeout: 120000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }
|
|
9
|
+
|
|
10
|
+
function failure (message) {
|
|
11
|
+
const error = new Error(message)
|
|
12
|
+
error.code = '1PASSWORD_FAILED'
|
|
13
|
+
return error
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandError (args, stderr) {
|
|
17
|
+
const step = args[0] === 'item'
|
|
18
|
+
? 'create the key item'
|
|
19
|
+
: {
|
|
20
|
+
whoami: 'check the signed-in account',
|
|
21
|
+
signin: 'sign in',
|
|
22
|
+
read: 'read the private key'
|
|
23
|
+
}[args[0]] || 'check availability'
|
|
24
|
+
if (/account is not signed in/i.test(String(stderr || ''))) {
|
|
25
|
+
const error = failure(`1Password could not ${step}: account is not signed in. Sign in with op signin or enable the desktop app CLI integration`)
|
|
26
|
+
error.reason = 'NOT_SIGNED_IN'
|
|
27
|
+
return error
|
|
28
|
+
}
|
|
29
|
+
return failure(`1Password could not ${step}; check sign-in and vault permissions`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run (args, input, timeout = COMMAND_OPTIONS.timeout) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
// On Unix, Node supplies a socket for stdin. op only detects piped JSON
|
|
35
|
+
// through a real pipe, so cat bridges the socket without writing a file.
|
|
36
|
+
const pipeInput = input !== undefined && process.platform !== 'win32'
|
|
37
|
+
const command = pipeInput ? '/bin/sh' : 'op'
|
|
38
|
+
const commandArgs = pipeInput ? ['-c', 'cat | op "$@"', 'dotenvx-op', ...args] : args
|
|
39
|
+
let timer
|
|
40
|
+
const child = execFile(command, commandArgs, {
|
|
41
|
+
...COMMAND_OPTIONS,
|
|
42
|
+
timeout: pipeInput ? 0 : timeout,
|
|
43
|
+
...(pipeInput ? { detached: true } : {})
|
|
44
|
+
}, (error, stdout, stderr) => {
|
|
45
|
+
clearTimeout(timer)
|
|
46
|
+
// Subprocess errors may include secrets from stdin or stdout.
|
|
47
|
+
if (error) reject(commandError(args, stderr))
|
|
48
|
+
else resolve(stdout)
|
|
49
|
+
})
|
|
50
|
+
if (pipeInput) {
|
|
51
|
+
timer = setTimeout(() => {
|
|
52
|
+
// Kill the whole pipeline, including op, if authentication times out.
|
|
53
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch {}
|
|
54
|
+
}, timeout)
|
|
55
|
+
timer.unref()
|
|
56
|
+
}
|
|
57
|
+
if (child.stdin) {
|
|
58
|
+
child.stdin.on('error', () => {})
|
|
59
|
+
child.stdin.end(input)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseResponse (value) {
|
|
65
|
+
try { return JSON.parse(value) } catch {
|
|
66
|
+
throw failure('1Password CLI returned an invalid response')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function available () {
|
|
71
|
+
try {
|
|
72
|
+
const version = await run(['--version'], undefined, 2000)
|
|
73
|
+
if (!/^2\./.test(version.trim())) return false
|
|
74
|
+
if (process.env.OP_SERVICE_ACCOUNT_TOKEN) return true
|
|
75
|
+
// Account discovery is local; authenticate only after the user selects 1Password.
|
|
76
|
+
const accounts = parseResponse(await run(['account', 'list', '--format=json'], undefined, 2000))
|
|
77
|
+
return Array.isArray(accounts) && accounts.length > 0
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function configured () {
|
|
84
|
+
const store = new Session().openStore()
|
|
85
|
+
return !!store && Object.keys(store.store).some(key => key.startsWith(PREFIX))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function location (publicKey) {
|
|
89
|
+
const store = new Session().openStore()
|
|
90
|
+
const value = store && store.get(`${PREFIX}${publicKey}`)
|
|
91
|
+
if (!value) return null
|
|
92
|
+
const [account, reference] = String(value).split('|')
|
|
93
|
+
if (!ID.test(account) || !/^op:\/\/[a-z0-9]{26}\/[a-z0-9]{26}\/(?:private_key|password)$/i.test(reference || '')) {
|
|
94
|
+
throw failure('invalid 1Password private-key reference in dotenvx settings')
|
|
95
|
+
}
|
|
96
|
+
return { account, reference }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function verified (publicKey, privateKey) {
|
|
100
|
+
try {
|
|
101
|
+
if (derive(privateKey) === publicKey) return { [publicKey]: privateKey }
|
|
102
|
+
} catch {}
|
|
103
|
+
throw failure('1Password private key does not match the .env public key')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function get (publicKey) {
|
|
107
|
+
const loc = location(publicKey)
|
|
108
|
+
if (!loc) return {}
|
|
109
|
+
const privateKey = await run(['read', loc.reference, '--no-newline', `--account=${loc.account}`])
|
|
110
|
+
return verified(publicKey, privateKey.trim())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getSync (publicKey) {
|
|
114
|
+
const loc = location(publicKey)
|
|
115
|
+
if (!loc) return {}
|
|
116
|
+
let privateKey
|
|
117
|
+
try {
|
|
118
|
+
privateKey = execFileSync('op', ['read', loc.reference, '--no-newline', `--account=${loc.account}`], {
|
|
119
|
+
...COMMAND_OPTIONS, stdio: ['ignore', 'pipe', 'pipe']
|
|
120
|
+
}).trim()
|
|
121
|
+
} catch {
|
|
122
|
+
throw failure('1Password CLI could not read the private key; check sign-in and vault permissions')
|
|
123
|
+
}
|
|
124
|
+
return verified(publicKey, privateKey)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function set (publicKey, privateKey) {
|
|
128
|
+
let identity
|
|
129
|
+
try {
|
|
130
|
+
identity = parseResponse(await run(['whoami', '--format=json']))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.reason !== 'NOT_SIGNED_IN' || process.env.OP_SERVICE_ACCOUNT_TOKEN) throw error
|
|
133
|
+
// whoami checks authentication but does not initiate the desktop app flow.
|
|
134
|
+
// Discard signin output; never print or evaluate a returned session token.
|
|
135
|
+
await run(['signin'])
|
|
136
|
+
identity = parseResponse(await run(['whoami', '--format=json']))
|
|
137
|
+
}
|
|
138
|
+
const account = identity.account_uuid
|
|
139
|
+
if (!ID.test(account || '')) throw failure('could not identify the signed-in 1Password account')
|
|
140
|
+
const item = parseResponse(await run(['item', 'create', '-', '--format=json', `--account=${account}`], JSON.stringify({
|
|
141
|
+
title: `dotenvx (${armoredKeyDisplay(publicKey)})`,
|
|
142
|
+
category: 'PASSWORD',
|
|
143
|
+
tags: ['dotenvx'],
|
|
144
|
+
fields: [
|
|
145
|
+
{ id: 'password', label: 'private_key', type: 'CONCEALED', purpose: 'PASSWORD', value: privateKey },
|
|
146
|
+
{ id: 'public_key', label: 'public_key', type: 'STRING', value: publicKey }
|
|
147
|
+
]
|
|
148
|
+
})))
|
|
149
|
+
if (!ID.test(item.id || '')) throw failure('1Password did not return a saved item ID')
|
|
150
|
+
const vault = item.vault && item.vault.id
|
|
151
|
+
if (!ID.test(vault || '')) throw failure('1Password did not return the saved item vault ID')
|
|
152
|
+
const reference = `op://${vault}/${item.id}/password`
|
|
153
|
+
const saved = await run(['read', reference, '--no-newline', `--account=${account}`])
|
|
154
|
+
if (saved.trim() !== privateKey) throw failure('could not verify private key in 1Password')
|
|
155
|
+
// Only a nonsecret locator is persisted locally, never the private key.
|
|
156
|
+
new Session().createStore().set(`${PREFIX}${publicKey}`, `${account}|${reference}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { available, configured, get, getSync, set }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const prompts = require('./prompts')
|
|
2
|
+
const onePasswordCustody = require('./onePasswordCustody')
|
|
3
|
+
const bitwardenCustody = require('./bitwardenCustody')
|
|
2
4
|
|
|
3
5
|
const secretStoreNames = {
|
|
4
6
|
darwin: 'macOS Keychain',
|
|
@@ -9,14 +11,23 @@ const secretStoreNames = {
|
|
|
9
11
|
async function selectKeyStorage (options = {}) {
|
|
10
12
|
const useNative = !options.noKeychain && !process.env.CI && ['darwin', 'linux', 'win32'].includes(process.platform)
|
|
11
13
|
const defaultStorage = useNative ? 'native' : 'file'
|
|
12
|
-
if (process.env.CI || options.noCreate ||
|
|
14
|
+
if (process.env.CI || options.noCreate || !process.stdin.isTTY || !process.stderr.isTTY) return defaultStorage
|
|
15
|
+
|
|
16
|
+
const choices = [
|
|
17
|
+
...(useNative ? [{ name: `□ Local Custody (${secretStoreNames[process.platform]})`, value: 'native' }] : [])
|
|
18
|
+
]
|
|
19
|
+
if (!options.no1Password && process.env.DOTENVX_NO_1PASSWORD !== 'true' && await onePasswordCustody.available()) {
|
|
20
|
+
choices.push({ name: '□ Local Custody (1Password)', value: 'onepassword' })
|
|
21
|
+
}
|
|
22
|
+
if (!options.noBitwarden && process.env.DOTENVX_NO_BITWARDEN !== 'true' && await bitwardenCustody.available()) {
|
|
23
|
+
choices.push({ name: '□ Local Custody (Bitwarden)', value: 'bitwarden' })
|
|
24
|
+
}
|
|
25
|
+
if (!options.noArmor) choices.push({ name: '⛨ Managed Custody (Armor)', value: 'armored' })
|
|
26
|
+
if (choices.length < 2) return choices.length ? choices[0].value : defaultStorage
|
|
13
27
|
|
|
14
28
|
return prompts.select({
|
|
15
29
|
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
|
-
]
|
|
30
|
+
choices
|
|
20
31
|
}, { input: process.stdin, output: process.stderr })
|
|
21
32
|
}
|
|
22
33
|
|
package/src/lib/main.d.ts
CHANGED
|
@@ -293,6 +293,12 @@ export interface SetOptions {
|
|
|
293
293
|
*/
|
|
294
294
|
noNative?: boolean;
|
|
295
295
|
|
|
296
|
+
/** Disable 1Password custody and secret-reference resolution. */
|
|
297
|
+
no1Password?: boolean;
|
|
298
|
+
|
|
299
|
+
/** Disable Bitwarden custody and secret-reference resolution. */
|
|
300
|
+
noBitwarden?: boolean;
|
|
301
|
+
|
|
296
302
|
}
|
|
297
303
|
|
|
298
304
|
export type SetProcessedEnv = {
|
package/src/lib/main.js
CHANGED
|
@@ -2,6 +2,8 @@ const Session = require('./../../db/session')
|
|
|
2
2
|
|
|
3
3
|
const armorProvider = require('./armor/index')
|
|
4
4
|
const nativeProvider = require('./native/index')
|
|
5
|
+
const onePasswordCustody = require('../helpers/onePasswordCustody')
|
|
6
|
+
const bitwardenCustody = require('../helpers/bitwardenCustody')
|
|
5
7
|
|
|
6
8
|
function syncArmorProvider (publicKeyHex) {
|
|
7
9
|
const { createSyncFn } = require('@dotenvx/tooling')
|
|
@@ -49,6 +51,14 @@ function useNative (options) {
|
|
|
49
51
|
return options.noNative !== true && options.native !== false && options.noKeychain !== true
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function useOnePassword (options) {
|
|
55
|
+
return options.no1Password !== true && process.env.DOTENVX_NO_1PASSWORD !== 'true' && onePasswordCustody.configured()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function useBitwarden (options) {
|
|
59
|
+
return options.noBitwarden !== true && process.env.DOTENVX_NO_BITWARDEN !== 'true' && bitwardenCustody.configured()
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
function useArmor (options, noArmor) {
|
|
53
63
|
return options.noArmor !== true && options.armor !== false && !noArmor
|
|
54
64
|
}
|
|
@@ -71,6 +81,9 @@ async function providers (options = {}) {
|
|
|
71
81
|
providerFns.push(nativeProvider)
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
if (useOnePassword(options)) providerFns.push(onePasswordCustody.get)
|
|
85
|
+
if (useBitwarden(options)) providerFns.push(bitwardenCustody.get)
|
|
86
|
+
|
|
74
87
|
if (options.noArmor !== true && options.armor !== false) {
|
|
75
88
|
const sesh = new Session()
|
|
76
89
|
const noArmor = !options.token && await sesh.noArmor()
|
|
@@ -93,6 +106,9 @@ providers.sync = function providersSync (options = {}) {
|
|
|
93
106
|
providerFns.push(nativeProvider)
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
if (useOnePassword(options)) providerFns.push(onePasswordCustody.getSync)
|
|
110
|
+
if (useBitwarden(options)) providerFns.push(bitwardenCustody.getSync)
|
|
111
|
+
|
|
96
112
|
if (options.noArmor !== true && options.armor !== false) {
|
|
97
113
|
const sesh = new Session()
|
|
98
114
|
const noArmor = !options.token && sesh.noArmorSync()
|
|
@@ -18,6 +18,8 @@ const Session = require('../../db/session')
|
|
|
18
18
|
|
|
19
19
|
const selectKeyStorage = require('../helpers/selectKeyStorage')
|
|
20
20
|
const storeNativePrivateKey = require('../helpers/storeNativePrivateKey')
|
|
21
|
+
const onePasswordCustody = require('../helpers/onePasswordCustody')
|
|
22
|
+
const bitwardenCustody = require('../helpers/bitwardenCustody')
|
|
21
23
|
|
|
22
24
|
async function encryptTransform (options = {}) {
|
|
23
25
|
const envs = options.envs || []
|
|
@@ -85,7 +87,11 @@ async function encryptTransform (options = {}) {
|
|
|
85
87
|
|
|
86
88
|
const comment = path.basename(envFilepath)
|
|
87
89
|
|
|
88
|
-
if (storage === '
|
|
90
|
+
if (storage === 'bitwarden') {
|
|
91
|
+
await bitwardenCustody.set(publicKey, privateKey)
|
|
92
|
+
} else if (storage === 'onepassword') {
|
|
93
|
+
await onePasswordCustody.set(publicKey, privateKey)
|
|
94
|
+
} else if (storage === 'native' && storeNativePrivateKey(publicKey, privateKey, fk)) {
|
|
89
95
|
row.nativePrivateKeyAdded = true
|
|
90
96
|
} else if (storage !== 'armored') {
|
|
91
97
|
const mutated = mutateKeysSrc({ keysSrc, privateKeyName, privateKeyValue: privateKey, comment })
|
|
@@ -18,6 +18,8 @@ const Session = require('../../db/session')
|
|
|
18
18
|
|
|
19
19
|
const selectKeyStorage = require('../helpers/selectKeyStorage')
|
|
20
20
|
const storeNativePrivateKey = require('../helpers/storeNativePrivateKey')
|
|
21
|
+
const onePasswordCustody = require('../helpers/onePasswordCustody')
|
|
22
|
+
const bitwardenCustody = require('../helpers/bitwardenCustody')
|
|
21
23
|
|
|
22
24
|
async function setTransform (options = {}) {
|
|
23
25
|
const envs = options.envs || []
|
|
@@ -89,7 +91,11 @@ async function setTransform (options = {}) {
|
|
|
89
91
|
|
|
90
92
|
const comment = path.basename(envFilepath)
|
|
91
93
|
|
|
92
|
-
if (storage === '
|
|
94
|
+
if (storage === 'bitwarden') {
|
|
95
|
+
await bitwardenCustody.set(publicKey, privateKey)
|
|
96
|
+
} else if (storage === 'onepassword') {
|
|
97
|
+
await onePasswordCustody.set(publicKey, privateKey)
|
|
98
|
+
} else if (storage === 'native' && storeNativePrivateKey(publicKey, privateKey, fk)) {
|
|
93
99
|
row.nativePrivateKeyAdded = true
|
|
94
100
|
} else if (storage !== 'armored') {
|
|
95
101
|
const mutated = mutateKeysSrc({ keysSrc, privateKeyName, privateKeyValue: privateKey, comment })
|
|
@@ -141,7 +147,9 @@ async function setTransform (options = {}) {
|
|
|
141
147
|
all: true,
|
|
142
148
|
envKeysFile: fk,
|
|
143
149
|
noArmor,
|
|
144
|
-
noKeychain
|
|
150
|
+
noKeychain,
|
|
151
|
+
no1Password: options.no1Password,
|
|
152
|
+
noBitwarden: options.noBitwarden
|
|
145
153
|
})
|
|
146
154
|
|
|
147
155
|
const before = parsed[key]
|