@dotenvx/dotenvx 2.25.0 → 2.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.25.0...main)
5
+ [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.26.1...main)
6
+
7
+ ## [2.26.1](https://github.com/dotenvx/dotenvx/compare/v2.26.0...v2.26.1) (2026-09-15)
8
+
9
+ ### Changed
10
+
11
+ * Add `dotenvx 1password up|down|push|pull` and `dotenvx bitwarden up|down|push|pull` ([#969](https://github.com/dotenvx/dotenvx/pull/969))
12
+
13
+ ## [2.26.0](https://github.com/dotenvx/dotenvx/compare/v2.25.0...v2.26.0) (2026-09-15)
14
+
15
+ ### Added
16
+
17
+ * Add support for storing private key to bitwarden or 1password ([#968](https://github.com/dotenvx/dotenvx/pull/968))
6
18
 
7
19
  ## [2.25.0](https://github.com/dotenvx/dotenvx/compare/v2.24.1...v2.25.0) (2026-09-14)
8
20
 
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. 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.
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 both flags to `set`). For deployment, continue supplying the private key through your platform's secret injection.
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.25.0",
2
+ "version": "2.26.1",
3
3
  "name": "@dotenvx/dotenvx",
4
4
  "description": "a secure dotenv–from the creator of `dotenv`",
5
5
  "author": "@motdotla",
@@ -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) => {
@@ -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)
@@ -2,7 +2,7 @@ const executeDynamic = require('./../../lib/helpers/executeDynamic')
2
2
 
3
3
  function configureArmorCommand (armor) {
4
4
  armor
5
- .description('move private keys into Dotenvx Armor [www.dotenvx.com/armor]')
5
+ .description('move private keys in/out of Dotenvx Armor [www.dotenvx.com/armor]')
6
6
  .allowUnknownOption()
7
7
  .argument('[command]', 'dotenvx-armor command')
8
8
  .argument('[args...]', 'dotenvx-armor command arguments')
@@ -0,0 +1,43 @@
1
+ function configureCustodyCommand (command, name, providerPath) {
2
+ command.hook('preAction', async () => {
3
+ const Session = require('../../db/session')
4
+ await new Session().notifyUpdate()
5
+ })
6
+ command.description(`move private keys in/out of ${name}`).action(function () { this.help() })
7
+ const descriptions = {
8
+ up: `move key from .env.keys into ${name}`,
9
+ down: `move key from ${name} to .env.keys`,
10
+ push: `copy key from .env.keys into ${name}`,
11
+ pull: `copy key from ${name} into .env.keys`
12
+ }
13
+ for (const [operation, description] of Object.entries(descriptions)) {
14
+ command.command(operation)
15
+ .description(description)
16
+ .option('-f, --env-file <path>', 'path to your env file')
17
+ .option('-fk, --env-keys-file <path>', 'path to your .env.keys file', '.env.keys')
18
+ .action(async function () {
19
+ const { logger } = require('../../shared/logger')
20
+ const createSpinner = require('../../lib/helpers/createSpinner')
21
+ const armoredKeyDisplay = require('../../lib/helpers/armoredKeyDisplay')
22
+ const transfer = require('../../lib/services/custodyTransfer')
23
+ const options = this.opts()
24
+ const spinner = await createSpinner({ ...this.optsWithGlobals(), text: `${operation === 'up' || operation === 'push' ? 'storing in' : 'reading from'} ${name}` })
25
+ try {
26
+ const result = await transfer(require(providerPath), name, operation, options.envFile, options.envKeysFile)
27
+ if (spinner) spinner.stop()
28
+ const display = armoredKeyDisplay(result.publicKeyValue) || result.privateKeyName
29
+ const messages = { up: `stored in ${name}`, down: `moved to ${options.envKeysFile}`, push: `pushed to ${name}`, pull: `pulled to ${options.envKeysFile}` }
30
+ if (result.changed) logger.success(`□ ${messages[operation]} (${display})`)
31
+ else logger.info(`○ no change (${display})`)
32
+ } catch (error) {
33
+ if (spinner) spinner.stop()
34
+ if (error.code === 'PROMPT_CANCELLED') return process.exit(130)
35
+ logger.error(error.message)
36
+ process.exit(1)
37
+ }
38
+ })
39
+ }
40
+ return command
41
+ }
42
+
43
+ module.exports = configureCustodyCommand
@@ -6,7 +6,7 @@ function configureNativeCommand (native) {
6
6
  })
7
7
 
8
8
  native
9
- .description('move private keys into your OS secret store')
9
+ .description('move private keys in/out of your OS secret store')
10
10
  .action(function () {
11
11
  this.help()
12
12
  })
@@ -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')
@@ -340,14 +346,19 @@ program.command('help [command]')
340
346
 
341
347
  // security sections (hidden commands advertised here)
342
348
  program.addHelpText('after', ' ')
343
- program.addHelpText('after', 'Better Security:')
349
+ program.addHelpText('after', 'Local Custody:')
344
350
  program.addHelpText('after', ' lock ⊡ lock private keys with a local passphrase')
345
- program.addHelpText('after', ' native ⌥ move private keys into your OS secret store')
351
+ program.addHelpText('after', ' native ⌥ move private keys in/out of your OS secret store')
352
+ program.addHelpText('after', ' 1password □ move private keys in/out of 1Password')
353
+ program.addHelpText('after', ' bitwarden □ move private keys in/out of Bitwarden')
346
354
  program.addHelpText('after', ' ')
347
- program.addHelpText('after', 'For Security Teams:')
348
- program.addHelpText('after', ' armor ⛨ move private keys into Dotenvx Armor [www.dotenvx.com/armor]')
355
+ program.addHelpText('after', 'Managed Custody:')
356
+ program.addHelpText('after', ' armor ⛨ move private keys in/out of Dotenvx Armor [www.dotenvx.com/armor]')
349
357
  program.addHelpText('after', ' curl ⛨ call authenticated api Dotenvx Armor [www.dotenvx.com/armor]')
350
358
 
359
+ require('./commands/custody')(program.command('1password', { hidden: true }), '1Password', '../../lib/helpers/onePasswordCustody')
360
+ require('./commands/custody')(program.command('bitwarden', { hidden: true }), 'Bitwarden', '../../lib/helpers/bitwardenCustody')
361
+
351
362
  // dotenvx native
352
363
  require('./commands/native')(program.command('native', { hidden: true }))
353
364
 
@@ -0,0 +1,170 @@
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
+ async function remove (publicKey) {
163
+ const loc = location(publicKey)
164
+ if (!loc) return
165
+ await authenticate(loc)
166
+ await run(['delete', 'item', loc.item])
167
+ new Session().openStore().delete(`${PREFIX}${publicKey}`)
168
+ }
169
+
170
+ module.exports = { available, configured, get, getSync, set, delete: remove }
@@ -0,0 +1,167 @@
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
+ ? (args[1] === 'delete' ? 'delete the key item' : '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
+ async function remove (publicKey) {
160
+ const loc = location(publicKey)
161
+ if (!loc) return
162
+ const [vault, item] = loc.reference.slice('op://'.length).split('/')
163
+ await run(['item', 'delete', item, `--vault=${vault}`, `--account=${loc.account}`])
164
+ new Session().openStore().delete(`${PREFIX}${publicKey}`)
165
+ }
166
+
167
+ module.exports = { available, configured, get, getSync, set, delete: remove }
@@ -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 || options.noArmor || !process.stdin.isTTY || !process.stderr.isTTY) return defaultStorage
14
+ if (process.env.CI || options.noCreate || !process.stdin.isTTY || !process.stderr.isTTY) return defaultStorage
15
+
16
+ const choices = [
17
+ ...(useNative ? [{ name: `□ Local Custody (Native ${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
@@ -257,6 +257,8 @@ const set = async function (key, value, options = {}) {
257
257
  fk: envKeysFilepath,
258
258
  noArmor,
259
259
  noKeychain,
260
+ no1Password: options.no1Password,
261
+ noBitwarden: options.noBitwarden,
260
262
  noCreate,
261
263
  encrypt
262
264
  })
@@ -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()
@@ -0,0 +1,50 @@
1
+ const { derive } = require('@dotenvx/primitives')
2
+ const keynames = require('../conventions/keynames')
3
+ const readEnvKey = require('../helpers/readEnvKey')
4
+ const removeEnvKey = require('../helpers/removeEnvKey')
5
+ const upsertEnvKey = require('../helpers/upsertEnvKey')
6
+
7
+ function verify (publicKey, privateKey) {
8
+ try {
9
+ if (derive(privateKey) === publicKey) return
10
+ } catch {}
11
+ throw new Error('private key does not match the .env public key')
12
+ }
13
+
14
+ async function custodyTransfer (provider, name, operation, envFile = '.env', envKeysFile = '.env.keys') {
15
+ if (!['up', 'down', 'push', 'pull'].includes(operation)) throw new Error('unknown custody operation')
16
+ const { publicKeyName, privateKeyName } = keynames(envFile)
17
+ const publicKey = readEnvKey(publicKeyName, envFile, { strict: true })
18
+ const result = changed => ({ changed, privateKeyName, publicKeyValue: publicKey })
19
+ const local = readEnvKey(privateKeyName, envKeysFile)
20
+ if (operation === 'up' || operation === 'push') {
21
+ if (local) verify(publicKey, local)
22
+ if (operation === 'push' && !local) throw new Error(`missing ${privateKeyName} in ${envKeysFile}`)
23
+ const existing = (await provider.get(publicKey))[publicKey]
24
+ if (existing) verify(publicKey, existing)
25
+ if (!local && !existing) throw new Error(`missing ${privateKeyName} in ${envKeysFile}`)
26
+ if (!existing) await provider.set(publicKey, local)
27
+ const saved = (await provider.get(publicKey))[publicKey]
28
+ if (!saved || saved !== (local || existing)) throw new Error(`could not verify private key in ${name}; ${envKeysFile} unchanged`)
29
+ if (operation === 'up') return result(removeEnvKey(privateKeyName, envKeysFile).changed || !existing)
30
+ return result(!existing)
31
+ }
32
+
33
+ const privateKey = (await provider.get(publicKey))[publicKey]
34
+ if (!privateKey) {
35
+ if (operation === 'down' && local) {
36
+ verify(publicKey, local)
37
+ return result(false)
38
+ }
39
+ throw new Error(`[NOT_FOUND] private key not found in ${name}`)
40
+ }
41
+ verify(publicKey, privateKey)
42
+ const written = upsertEnvKey(privateKeyName, privateKey, envKeysFile)
43
+ if (readEnvKey(privateKeyName, envKeysFile, { strict: true }) !== privateKey) {
44
+ throw new Error(`could not verify private key in ${envKeysFile}; ${name} unchanged`)
45
+ }
46
+ if (operation === 'down') await provider.delete(publicKey)
47
+ return result(written.changed || operation === 'down')
48
+ }
49
+
50
+ module.exports = custodyTransfer
@@ -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 === 'native' && storeNativePrivateKey(publicKey, privateKey, fk)) {
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 === 'native' && storeNativePrivateKey(publicKey, privateKey, fk)) {
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]