keyshroud 0.5.2 → 0.6.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 +42 -0
- package/README.md +47 -7
- package/dist/cli.js +195 -28
- package/dist/core.d.ts +18 -4
- package/dist/core.js +268 -36
- package/dist/mcp.js +32 -17
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +26 -6
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +6 -2
- package/src/cli.ts +140 -22
- package/src/core.ts +228 -34
- package/src/mcp.ts +24 -15
- package/src/redact.ts +1 -1
- package/src/refs.ts +23 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ All notable changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `setup --email`: keyshroud gets its own `bw` login on a dedicated agent account
|
|
12
|
+
(`~/.config/keyshroud/bw`, keychain entry `keyshroud-master-password`), so agents
|
|
13
|
+
can no longer unlock your personal vault. `--confirm-unlock` (macOS) makes the
|
|
14
|
+
keychain ask before every unlock. Run from an agent's shell, it generates the
|
|
15
|
+
agent account's password, stores it over stdin, and hands it to you on the
|
|
16
|
+
clipboard for the signup page.
|
|
17
|
+
- `migrate`: copies the items your wired names reference from the personal vault
|
|
18
|
+
into the agent account (dry run unless `--yes`, names only, originals kept).
|
|
19
|
+
|
|
20
|
+
## [0.5.3] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
Fixes from an independent audit and a multi-model review of 0.5.2.
|
|
24
|
+
- The native dialog could be used by a prompt-injected agent to phish new
|
|
25
|
+
secrets, including the vault master password. It now says a program asked for
|
|
26
|
+
it and warns against the master password, refuses multi-line, control or bidi
|
|
27
|
+
characters and names over 120 characters, refuses a value equal to the master
|
|
28
|
+
password, opens one at a time with a 30 s pause after a cancel, and is audited.
|
|
29
|
+
zenity runs with `--no-markup`.
|
|
30
|
+
- Env files could set `PATH`, `NODE_OPTIONS`, `LD_PRELOAD` and other loader
|
|
31
|
+
variables, so a cloned repo's `.env.refs` could run code. They are rejected
|
|
32
|
+
in env files, refused by `add`/`wire`/`secrets_request`, and never injected.
|
|
33
|
+
- Streaming redaction leaked most of a secret longer than 4096 characters.
|
|
34
|
+
- `secrets_run` could return a partial secret when output hit the collection
|
|
35
|
+
limit; the cut tail is now dropped before masking, with per-stream budgets.
|
|
36
|
+
- `import` missed `*_PASS`, URL passwords with no username, hex keys, and known
|
|
37
|
+
key formats in names containing `PUBLIC`; `.env.refs` is now written `0600`.
|
|
38
|
+
- `inject -o` replaces a planted symlink instead of writing through it; temp
|
|
39
|
+
files use random names and exclusive creation.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- `add`/`rotate` read stdin only with `--stdin`; a multi-line paste into the
|
|
43
|
+
`*` prompt is drained and refused instead of reaching the shell.
|
|
44
|
+
- `secrets_request` checks names and existing items before opening the dialog,
|
|
45
|
+
reports auth.json conflicts instead of claiming success, and returns errors.
|
|
46
|
+
- Item names with leading or trailing spaces are refused (they shadowed
|
|
47
|
+
existing items). `add`, `rotate` and `secrets_request` refresh the redact
|
|
48
|
+
index and write to the audit log.
|
|
49
|
+
|
|
8
50
|
## [0.5.2] - 2026-10-15
|
|
9
51
|
|
|
10
52
|
### Added
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ webhooks, databases. The usual options are bad:
|
|
|
61
61
|
| 🙈 **Masked output** | `keyshroud run` replaces every secret value in stdout/stderr with `<redacted:NAME>` — streaming-safe across chunk boundaries. |
|
|
62
62
|
| 🪝 **Agent hooks** | The pi/omp `tool_result` hook redacts every tool result, failed ones included. Claude Code's `PostToolUse` hook redacts every *successful* tool result (Bash, Read, Grep, MCP…) — see [limits](#security-model). |
|
|
63
63
|
| 🔌 **MCP server** | `keyshroud mcp` gives any MCP client `secrets_run`, `secrets_list`, `secrets_request`, `vault_status`. No tool ever returns a value. |
|
|
64
|
-
| 🔗 **
|
|
64
|
+
| 🔗 **Committable references** | `bw://item`, `bw://item/username`, `bw://item/fields/name`, `bw://item/totp` in `.env.refs` files and templates: names, not values. |
|
|
65
65
|
| ⌨️ **Paste-to-create** | New secrets go through a masked prompt in the user's terminal or a masked popup in pi — never through the chat. |
|
|
66
66
|
| 📄 **`inject`** | Render `{{ bw://… }}` templates into `0600` config files for tools that can't read env vars (`.npmrc`, kubeconfig). |
|
|
67
67
|
| 🔎 **`scan`** | Find vault values that leaked into your working tree (pre-commit friendly). |
|
|
@@ -135,6 +135,32 @@ Optional: put `vaultwarden-api-client-id` / `vaultwarden-api-client-secret` (fro
|
|
|
135
135
|
also re-login automatically when the `bw` session expires. Linux keyring commands
|
|
136
136
|
and a headless-server option are in the [vault setup guide](examples/vaultwarden#4-let-keyshroud-unlock-without-prompting).
|
|
137
137
|
|
|
138
|
+
### Least privilege: a separate agent account
|
|
139
|
+
|
|
140
|
+
With the steps above, keyshroud holds your master password, so an agent that runs
|
|
141
|
+
`bw` as you can open your whole vault. `setup` gives keyshroud its own `bw` login
|
|
142
|
+
on a dedicated account instead, which holds only the secrets agents need:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
keyshroud setup --email you+agent@example.com # opens the signup page, then stores the new
|
|
146
|
+
# master password in the keychain and logs in
|
|
147
|
+
keyshroud migrate # dry run: which wired items would be copied
|
|
148
|
+
keyshroud migrate --yes # copy them; references keep working
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- The agent login lives in `~/.config/keyshroud/bw` (`BITWARDENCLI_APPDATA_DIR`), its master
|
|
152
|
+
password in the keychain entry `keyshroud-master-password`. Your own `bw` login is untouched.
|
|
153
|
+
- `migrate` copies the items that wired names (and any `--env-file` / `--item`) reference, with
|
|
154
|
+
the same names, then locks your personal vault again. Originals stay where they are. It needs
|
|
155
|
+
your personal master password in the keychain once; delete it afterwards (the command prints how).
|
|
156
|
+
- An agent can run `setup` too. Without a terminal, keyshroud generates the agent account's
|
|
157
|
+
master password, writes it to the keychain over stdin and puts it on the clipboard; you paste
|
|
158
|
+
it into the signup page, then the agent runs `setup --registered`, which clears the clipboard.
|
|
159
|
+
- `--confirm-unlock` (macOS, off by default) creates the keychain entry so macOS asks you before
|
|
160
|
+
every read, i.e. every vault unlock.
|
|
161
|
+
- Self-hosted Vaultwarden with `SIGNUPS_ALLOWED=false`: invite the address from the admin page
|
|
162
|
+
(`/admin`), or allow signups for a minute, then pass `--registered` once the account exists.
|
|
163
|
+
|
|
138
164
|
## How it works
|
|
139
165
|
|
|
140
166
|
```
|
|
@@ -274,6 +300,8 @@ keyshroud import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [
|
|
|
274
300
|
keyshroud rotate --item ITEM [--stdin|--dialog]
|
|
275
301
|
keyshroud wire --env NAME --item ITEM [--target pi|omp|both] [--force]
|
|
276
302
|
keyshroud unwire --env NAME [--target pi|omp|both]
|
|
303
|
+
keyshroud setup --email EMAIL [--server URL] [--confirm-unlock] [--registered]
|
|
304
|
+
keyshroud migrate [--item NAME]... [--env-file F]... [--yes]
|
|
277
305
|
keyshroud status | unlock | lock | doctor
|
|
278
306
|
keyshroud mcp
|
|
279
307
|
keyshroud hook claude <session-start|post-tool-use>
|
|
@@ -373,20 +401,32 @@ Guarantees we test for:
|
|
|
373
401
|
|
|
374
402
|
- secret values never appear in argv, logs, diagnostics, MCP results, hook
|
|
375
403
|
output, or `audit.jsonl`;
|
|
376
|
-
- new secrets are read from a masked prompt or
|
|
404
|
+
- new secrets are read from a masked prompt, `--stdin`, or a native dialog, never flags;
|
|
405
|
+
- the dialog an agent can open says it was requested by a program, warns against the
|
|
406
|
+
master password, refuses names that are not one printable line, refuses the master
|
|
407
|
+
password itself, and opens one at a time (30 s pause after a cancel);
|
|
377
408
|
- the session file, redact index, rendered templates and auth files are `0600`;
|
|
378
409
|
- auth.json writes are locked and atomic and never overwrite without `--force`;
|
|
379
410
|
- commands started by `run` and `secrets_run` never receive `BW_SESSION`,
|
|
380
411
|
`BW_PASSWORD`, `BW_CLIENTID` or `BW_CLIENTSECRET`, so `--only` really limits
|
|
381
412
|
what a command can read (inside pi/omp the agent process keeps the session for
|
|
382
413
|
legacy `!bw` auth entries; its value is redacted from tool output);
|
|
383
|
-
- env files may contain only references and literals
|
|
384
|
-
rejected, so
|
|
414
|
+
- env files may contain only references and literals: a `!command` value is
|
|
415
|
+
rejected, and so are variables that change what programs load or run (`PATH`,
|
|
416
|
+
`NODE_OPTIONS`, `LD_*`, `DYLD_*`, `BASH_ENV`, `GIT_SSH_COMMAND`, …), which are
|
|
417
|
+
also never wired or injected;
|
|
385
418
|
- an unparseable `auth.json` is never overwritten.
|
|
386
419
|
|
|
387
|
-
Known limits:
|
|
388
|
-
|
|
389
|
-
|
|
420
|
+
Known limits: a `.env.refs` can reference **any** item in your vault, not only the
|
|
421
|
+
ones you wired, so read a cloned repo's `.env.refs` before running `--env-file`
|
|
422
|
+
with it. `import` decides what is secret by heuristics and keeps everything else
|
|
423
|
+
as a literal; check its plan before committing `.env.refs`. The Claude Code
|
|
424
|
+
`PostToolUse` hook does not see failed commands (`PostToolUseFailure` cannot
|
|
425
|
+
rewrite output) or anything a client sends as telemetry, and it only knows wired
|
|
426
|
+
values, so `keyshroud get bw://…` or `run --no-mask` print in the clear; run
|
|
427
|
+
secret-using commands through `keyshroud run` or `secrets_run`. An agent with shell access as your user can read
|
|
428
|
+
`~/.config/keyshroud/session` and use `bw` directly — after `setup` that reaches
|
|
429
|
+
only the agent account; otherwise run agents in a sandbox or container if that matters. Redaction is exact-match, so a *partial* or transformed value
|
|
390
430
|
(`${TOKEN:0:10}`, base64, a line wrapped by a tool) is not masked; values
|
|
391
431
|
shorter than 6 characters are not redacted (to avoid noise), so don't wire short
|
|
392
432
|
PINs.
|
package/dist/cli.js
CHANGED
|
@@ -4,13 +4,14 @@ import { constants, homedir } from "node:os";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
7
8
|
import process from "node:process";
|
|
8
9
|
import { BRAND } from "./brand.js";
|
|
9
10
|
import * as core from "./core.js";
|
|
10
11
|
import { runClaudeHook } from "./hooks.js";
|
|
11
12
|
import { serveMcp } from "./mcp.js";
|
|
12
13
|
import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream } from "./redact.js";
|
|
13
|
-
import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef } from "./refs.js";
|
|
14
|
+
import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef } from "./refs.js";
|
|
14
15
|
const usage = `Usage: ${BRAND.bin} <command> [options]
|
|
15
16
|
|
|
16
17
|
Secrets
|
|
@@ -30,6 +31,11 @@ Secrets
|
|
|
30
31
|
unwire --env NAME [--target pi|omp|both]
|
|
31
32
|
|
|
32
33
|
Vault
|
|
34
|
+
setup --email EMAIL [--server URL] [--confirm-unlock] [--registered]
|
|
35
|
+
Give ${BRAND.name} its own bw login on a dedicated agent account
|
|
36
|
+
migrate [--item NAME]... [--env-file F]... [--yes]
|
|
37
|
+
Copy the wired items from your personal vault to that account
|
|
38
|
+
(dry run unless --yes; prints names only)
|
|
33
39
|
status | unlock | lock | doctor
|
|
34
40
|
|
|
35
41
|
Agents
|
|
@@ -63,20 +69,26 @@ function targetList(value) {
|
|
|
63
69
|
return [targetPaths.pi];
|
|
64
70
|
fail("--target must be omp, pi, or both", 2);
|
|
65
71
|
}
|
|
66
|
-
// Terminal: masked prompt.
|
|
67
|
-
// dialog, so the agent can start the command and the user only pastes.
|
|
68
|
-
async function readSecret(args, prompt,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
// --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
|
|
73
|
+
// a native dialog, so the agent can start the command and the user only pastes.
|
|
74
|
+
async function readSecret(args, prompt, itemName, envName) {
|
|
75
|
+
if (args.includes("--stdin"))
|
|
76
|
+
return core.readSecretFromInput({ prompt, stdin: true });
|
|
77
|
+
if (process.stdin.isTTY && !args.includes("--dialog"))
|
|
78
|
+
return core.readSecretFromInput({ prompt, stdin: false });
|
|
79
|
+
const value = await core.readSecretFromDialog({ itemName, envName });
|
|
80
|
+
if (value)
|
|
81
|
+
return value;
|
|
82
|
+
if (value === "")
|
|
83
|
+
throw new Error("Empty secret");
|
|
84
|
+
fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
|
|
85
|
+
}
|
|
86
|
+
function checkNames(envName, itemName) {
|
|
87
|
+
if (isLoaderEnvName(envName))
|
|
88
|
+
fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
89
|
+
const problem = itemNameProblem(itemName);
|
|
90
|
+
if (problem)
|
|
91
|
+
fail(`Vault item name ${problem}`, 2);
|
|
80
92
|
}
|
|
81
93
|
function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
82
94
|
function failureCode(error) {
|
|
@@ -172,14 +184,14 @@ async function inject(args) {
|
|
|
172
184
|
process.stdout.write(rendered);
|
|
173
185
|
return;
|
|
174
186
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await writeFile(
|
|
182
|
-
await
|
|
187
|
+
// lstat, and a private temp file renamed over the target, so a planted
|
|
188
|
+
// symlink (even a dangling one) is replaced instead of followed.
|
|
189
|
+
const exists = await lstat(output).then(() => true, () => false);
|
|
190
|
+
if (exists && !args.includes("--force"))
|
|
191
|
+
fail(`Refusing to overwrite ${output}; use --force`, 5);
|
|
192
|
+
const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
|
|
193
|
+
await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
|
|
194
|
+
await rename(tmp, output);
|
|
183
195
|
console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
|
|
184
196
|
}
|
|
185
197
|
const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".next", ".venv"]);
|
|
@@ -234,6 +246,143 @@ async function scan(paths) {
|
|
|
234
246
|
if (leaks)
|
|
235
247
|
process.exitCode = 1;
|
|
236
248
|
}
|
|
249
|
+
// setup: keyshroud gets its own bw login (CONFIG_DIR/bw) on a separate agent
|
|
250
|
+
// account, so agents never get a session for the user's personal vault.
|
|
251
|
+
async function setupCommand(args) {
|
|
252
|
+
requireKnownFlags(args, ["--email", "--server"], ["--confirm-unlock", "--registered"]);
|
|
253
|
+
const email = valueAfter(args, "--email");
|
|
254
|
+
if (!email || !/^[^\s@]+@[^\s@]+$/.test(email))
|
|
255
|
+
fail("setup needs --email for a separate agent account (not your personal one)", 2);
|
|
256
|
+
let server = valueAfter(args, "--server");
|
|
257
|
+
if (!server) {
|
|
258
|
+
try {
|
|
259
|
+
server = JSON.parse(await core.bw(["status"])).serverUrl ?? undefined;
|
|
260
|
+
}
|
|
261
|
+
catch { }
|
|
262
|
+
}
|
|
263
|
+
if (!server || !/^https:\/\//.test(server))
|
|
264
|
+
fail("setup needs --server https://… (no bw server is configured)", 2);
|
|
265
|
+
const confirm = args.includes("--confirm-unlock");
|
|
266
|
+
if (confirm && process.platform !== "darwin")
|
|
267
|
+
fail("--confirm-unlock needs the macOS keychain; Linux secret-tool cannot ask before each read", 2);
|
|
268
|
+
core.useOwnBwLogin();
|
|
269
|
+
await mkdir(core.OWN_BW_DIR, { recursive: true, mode: 0o700 });
|
|
270
|
+
const status = JSON.parse(await core.bw(["status"]).catch(() => "{}"));
|
|
271
|
+
if (status.userEmail && status.userEmail.toLowerCase() !== email.toLowerCase())
|
|
272
|
+
fail(`${BRAND.name}'s bw login already belongs to ${status.userEmail}; remove ${core.OWN_BW_DIR} to start over`, 5);
|
|
273
|
+
const loggedOut = !status.status || status.status === "unauthenticated";
|
|
274
|
+
if (loggedOut)
|
|
275
|
+
await core.bw(["config", "server", server]);
|
|
276
|
+
await unlink(core.SESSION_FILE).catch(() => { });
|
|
277
|
+
console.log(`${BRAND.name} now keeps its own bw login in ${core.OWN_BW_DIR}; your personal bw login is untouched.`);
|
|
278
|
+
const signupUrl = `${server.replace(/\/$/, "")}/#/signup?email=${encodeURIComponent(email)}`;
|
|
279
|
+
if (!process.stdin.isTTY) {
|
|
280
|
+
// An agent's shell: nothing can be typed, so keyshroud picks the password.
|
|
281
|
+
const hasEntry = Boolean(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE));
|
|
282
|
+
if (!loggedOut && (confirm || !hasEntry))
|
|
283
|
+
fail(`The agent account is already logged in; run \`${BRAND.bin} setup\` in a terminal to store its password`, 2);
|
|
284
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
285
|
+
if (!hasEntry && !(await core.generateKeychainSecret(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm)))
|
|
286
|
+
fail("Could not store a generated password (needs macOS security or Linux secret-tool)", 3);
|
|
287
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
288
|
+
if (!master || !(await core.copyToClipboard(master)))
|
|
289
|
+
fail(`No clipboard tool; run \`${BRAND.bin} setup\` in a terminal instead`, 3);
|
|
290
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [signupUrl], { stdio: "ignore" }).on("error", () => { });
|
|
291
|
+
console.log(`A random master password for ${email} is in the keychain (${core.KEYCHAIN_MASTER_SERVICE}) and on the clipboard.`);
|
|
292
|
+
console.log(`Create the account at ${signupUrl} and paste it into both password fields.`);
|
|
293
|
+
console.log(`Then: ${BRAND.bin} setup --email ${email} --registered`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (loggedOut && !args.includes("--registered")) {
|
|
298
|
+
const url = signupUrl;
|
|
299
|
+
console.log(`1. Create the agent account ${email}: ${url}`);
|
|
300
|
+
console.log(" Pick a new master password, not your personal one.");
|
|
301
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" }).on("error", () => { });
|
|
302
|
+
process.stdout.write(" Press Enter once the account exists. ");
|
|
303
|
+
await new Promise((resolve) => process.stdin.once("data", resolve));
|
|
304
|
+
process.stdin.pause();
|
|
305
|
+
}
|
|
306
|
+
if (confirm || !(await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE))) {
|
|
307
|
+
console.log(`2. Type the agent account's master password for the keychain entry ${core.KEYCHAIN_MASTER_SERVICE}${confirm ? " (macOS will ask before every unlock)" : ""}:`);
|
|
308
|
+
if (!(await core.storeKeychainInteractive(core.KEYCHAIN_MASTER_SERVICE, `${BRAND.name} agent account`, confirm)))
|
|
309
|
+
fail("Could not store it (needs macOS security or Linux secret-tool)", 3);
|
|
310
|
+
}
|
|
311
|
+
const master = await core.readKeychain(core.KEYCHAIN_MASTER_SERVICE);
|
|
312
|
+
if (!master)
|
|
313
|
+
fail(`The keychain entry ${core.KEYCHAIN_MASTER_SERVICE} is missing or access was denied`, 3);
|
|
314
|
+
if (loggedOut)
|
|
315
|
+
await core.loginOwnAccount(email, master);
|
|
316
|
+
else
|
|
317
|
+
await core.ensureSession();
|
|
318
|
+
await core.clearClipboardIf(master);
|
|
319
|
+
await core.audit("setup", [], "cli");
|
|
320
|
+
console.log(`ready: bw login=${email} (own). Next: \`${BRAND.bin} migrate\` copies the wired secrets from your personal vault.`);
|
|
321
|
+
}
|
|
322
|
+
// migrate: copy the items that wired names (and env files) reference from the
|
|
323
|
+
// personal vault into the agent account. Same names, so references keep working.
|
|
324
|
+
async function migrateCommand(args) {
|
|
325
|
+
requireKnownFlags(args, ["--item", "--env-file"], ["--yes"]);
|
|
326
|
+
if (!core.OWN_BW_LOGIN)
|
|
327
|
+
fail(`Run \`${BRAND.bin} setup\` first: migrate copies items into ${BRAND.name}'s own agent account`, 2);
|
|
328
|
+
const entries = [...await core.authEnvEntries()];
|
|
329
|
+
for (const file of valuesAfter(args, "--env-file"))
|
|
330
|
+
entries.push(...await core.envFileEntries(file));
|
|
331
|
+
const names = new Map();
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
try {
|
|
334
|
+
const ref = parseRef(entry.raw);
|
|
335
|
+
if (ref)
|
|
336
|
+
names.set(ref.item.toLowerCase(), ref.item);
|
|
337
|
+
}
|
|
338
|
+
catch { }
|
|
339
|
+
}
|
|
340
|
+
for (const name of valuesAfter(args, "--item"))
|
|
341
|
+
names.set(name.toLowerCase(), name);
|
|
342
|
+
await core.ensureSession();
|
|
343
|
+
const own = await core.findItems("");
|
|
344
|
+
const personal = await core.readPersonalItems();
|
|
345
|
+
const rows = [...names.values()].sort().map((name) => {
|
|
346
|
+
if (own.some((item) => item.name.toLowerCase() === name.toLowerCase() || item.id === name))
|
|
347
|
+
return { name, state: "present" };
|
|
348
|
+
const matches = personal.filter((item) => String(item.name ?? "").toLowerCase() === name.toLowerCase() || item.id === name);
|
|
349
|
+
return matches.length === 1 ? { name, state: "copy", item: matches[0] } : { name, state: matches.length ? "ambiguous" : "missing" };
|
|
350
|
+
});
|
|
351
|
+
const width = Math.max(4, ...rows.map((row) => row.name.length));
|
|
352
|
+
console.log("migrate plan (personal vault -> agent account):");
|
|
353
|
+
for (const row of rows)
|
|
354
|
+
console.log(` ${row.name.padEnd(width)} ${row.state === "present" ? "already in the agent account" : row.state}`);
|
|
355
|
+
const count = (state) => rows.filter((row) => row.state === state).length;
|
|
356
|
+
console.log(`${count("copy")} to copy, ${count("present")} already there, ${count("missing")} missing, ${count("ambiguous")} ambiguous`);
|
|
357
|
+
if (!args.includes("--yes")) {
|
|
358
|
+
console.log("Dry run: nothing copied. Re-run with --yes.");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const copied = [];
|
|
362
|
+
for (const row of rows.filter((entry) => entry.state === "copy")) {
|
|
363
|
+
try {
|
|
364
|
+
await core.copyItemToOwnVault(row.item);
|
|
365
|
+
copied.push(row.name);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
if (copied.length)
|
|
369
|
+
console.error(`Copied before the failure: ${copied.join(", ")}`);
|
|
370
|
+
fail(`Failed to copy ${row.name}`, 3);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await core.syncOwnVault();
|
|
374
|
+
const after = await core.findItems("");
|
|
375
|
+
const unverified = copied.filter((name) => !after.some((item) => item.name.toLowerCase() === name.toLowerCase()));
|
|
376
|
+
await core.audit("migrate", copied, "cli");
|
|
377
|
+
await core.refreshRedactIndex();
|
|
378
|
+
console.log(`copied ${copied.length} item(s) into the agent account; references are unchanged.`);
|
|
379
|
+
const cut = process.platform === "darwin" ? `security delete-generic-password -s ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE}` : `secret-tool clear service ${core.PERSONAL_KEYCHAIN_MASTER_SERVICE} account "$USER"`;
|
|
380
|
+
console.log(`Your personal master password is still in the keychain, where any program running as you can read it. Once you no longer need it there: ${cut}`);
|
|
381
|
+
if (unverified.length)
|
|
382
|
+
fail(`Not found after copying: ${unverified.join(", ")}`, 4);
|
|
383
|
+
if (count("missing") || count("ambiguous"))
|
|
384
|
+
process.exitCode = 4;
|
|
385
|
+
}
|
|
237
386
|
async function importCommand(args) {
|
|
238
387
|
requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
|
|
239
388
|
const from = valueAfter(args, "--from") ?? ".env";
|
|
@@ -347,9 +496,9 @@ async function importCommand(args) {
|
|
|
347
496
|
const lines = text.split(/\r?\n/);
|
|
348
497
|
for (const row of toStore)
|
|
349
498
|
lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
|
|
350
|
-
const mode = replace ? (await stat(out)).mode & 0o777 :
|
|
351
|
-
const tmp = `${out}.tmp.${
|
|
352
|
-
await writeFile(tmp, lines.join(eol), { mode });
|
|
499
|
+
const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
|
|
500
|
+
const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
|
|
501
|
+
await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
|
|
353
502
|
await rename(tmp, out);
|
|
354
503
|
await core.audit("import", toStore.map((row) => row.entry.name), "cli");
|
|
355
504
|
console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
|
|
@@ -466,11 +615,15 @@ async function main() {
|
|
|
466
615
|
const notes = valueAfter(args, "--notes");
|
|
467
616
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName))
|
|
468
617
|
fail(usage, 2);
|
|
618
|
+
checkNames(envName, itemName);
|
|
469
619
|
const reference = core.referenceFor(itemName);
|
|
470
620
|
const targets = targetList(valueAfter(args, "--target"));
|
|
471
621
|
await core.ensureSession();
|
|
472
|
-
|
|
622
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase()))
|
|
623
|
+
fail(`Vault item already exists: ${itemName}`, 5);
|
|
624
|
+
const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
|
|
473
625
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
626
|
+
await core.audit("add", [envName], "cli");
|
|
474
627
|
let conflict = false;
|
|
475
628
|
for (const path of targets) {
|
|
476
629
|
const result = await core.writeAuthEntry(path, envName, reference);
|
|
@@ -478,6 +631,7 @@ async function main() {
|
|
|
478
631
|
conflict = true;
|
|
479
632
|
}
|
|
480
633
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
634
|
+
await core.refreshRedactIndex();
|
|
481
635
|
console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
|
|
482
636
|
if (conflict) {
|
|
483
637
|
console.error("Item created, but one or more auth targets already contained the environment name.");
|
|
@@ -489,14 +643,24 @@ async function main() {
|
|
|
489
643
|
await importCommand(args);
|
|
490
644
|
return;
|
|
491
645
|
}
|
|
646
|
+
if (command === "setup") {
|
|
647
|
+
await setupCommand(args);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (command === "migrate") {
|
|
651
|
+
await migrateCommand(args);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
492
654
|
if (command === "rotate") {
|
|
493
655
|
requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
|
|
494
656
|
const itemName = valueAfter(args, "--item");
|
|
495
657
|
if (!itemName)
|
|
496
658
|
fail(usage, 2);
|
|
497
659
|
await core.ensureSession();
|
|
498
|
-
const secret = await readSecret(args, "Paste replacement secret: ",
|
|
660
|
+
const secret = await readSecret(args, "Paste replacement secret: ", itemName);
|
|
499
661
|
await core.rotateSecretItem({ itemName, secret });
|
|
662
|
+
await core.audit("rotate", [itemName], "cli");
|
|
663
|
+
await core.refreshRedactIndex();
|
|
500
664
|
console.log(`rotated item=${itemName}`);
|
|
501
665
|
return;
|
|
502
666
|
}
|
|
@@ -506,6 +670,8 @@ async function main() {
|
|
|
506
670
|
const itemName = valueAfter(args, "--item");
|
|
507
671
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName))
|
|
508
672
|
fail(usage, 2);
|
|
673
|
+
if (isLoaderEnvName(envName))
|
|
674
|
+
fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
509
675
|
const reference = core.referenceFor(itemName);
|
|
510
676
|
const targets = targetList(valueAfter(args, "--target"));
|
|
511
677
|
for (const path of targets) {
|
|
@@ -582,6 +748,7 @@ async function main() {
|
|
|
582
748
|
}
|
|
583
749
|
catch { }
|
|
584
750
|
const s = await core.getStatus();
|
|
751
|
+
console.log(`bw login=${core.OWN_BW_LOGIN ? `own agent account (${core.OWN_BW_DIR})` : `shared with your personal bw (run: ${BRAND.bin} setup)`}`);
|
|
585
752
|
console.log(`bw version=${s.version ?? "missing (install @bitwarden/cli)"}`);
|
|
586
753
|
console.log(`bw server=${s.serverUrl ?? "unconfigured (run: bw config server <url>)"}`);
|
|
587
754
|
console.log(`session=${s.status}`);
|
package/dist/core.d.ts
CHANGED
|
@@ -5,9 +5,13 @@ export declare const SESSION_FILE: string;
|
|
|
5
5
|
export declare const MASTER_PASSWORD_FILE: string;
|
|
6
6
|
export declare const REDACT_INDEX_FILE: string;
|
|
7
7
|
export declare const AUDIT_FILE: string;
|
|
8
|
-
export declare const
|
|
9
|
-
export declare const
|
|
10
|
-
export declare
|
|
8
|
+
export declare const OWN_BW_DIR: string;
|
|
9
|
+
export declare const PERSONAL_KEYCHAIN_MASTER_SERVICE: string;
|
|
10
|
+
export declare let OWN_BW_LOGIN: boolean;
|
|
11
|
+
export declare let KEYCHAIN_MASTER_SERVICE: string;
|
|
12
|
+
export declare let KEYCHAIN_CLIENT_ID_SERVICE: string;
|
|
13
|
+
export declare let KEYCHAIN_CLIENT_SECRET_SERVICE: string;
|
|
14
|
+
export declare function useOwnBwLogin(): void;
|
|
11
15
|
export declare const PROVIDER_KEYS: ReadonlySet<string>;
|
|
12
16
|
export interface VaultStatus {
|
|
13
17
|
status: string;
|
|
@@ -39,8 +43,17 @@ export interface VaultItem {
|
|
|
39
43
|
}
|
|
40
44
|
export declare function invalidateSecretCache(): void;
|
|
41
45
|
export declare function resolveRef(ref: SecretRef): Promise<string | null>;
|
|
46
|
+
export declare function readKeychain(service: string): Promise<string | null>;
|
|
42
47
|
export declare function keychainPresence(): Promise<Record<string, boolean>>;
|
|
43
48
|
export declare function ensureSession(): Promise<string>;
|
|
49
|
+
export declare function storeKeychainInteractive(service: string, label: string, confirm: boolean): Promise<boolean>;
|
|
50
|
+
export declare function generateKeychainSecret(service: string, label: string, confirm: boolean): Promise<boolean>;
|
|
51
|
+
export declare function copyToClipboard(text: string): Promise<boolean>;
|
|
52
|
+
export declare function clearClipboardIf(text: string): Promise<void>;
|
|
53
|
+
export declare function loginOwnAccount(email: string, master: string): Promise<string>;
|
|
54
|
+
export declare function readPersonalItems(): Promise<Array<Record<string, unknown>>>;
|
|
55
|
+
export declare function copyItemToOwnVault(item: Record<string, unknown>): Promise<void>;
|
|
56
|
+
export declare function syncOwnVault(): Promise<void>;
|
|
44
57
|
export declare function lock(): Promise<void>;
|
|
45
58
|
export declare function audit(action: string, names: string[], consumer: string): Promise<void>;
|
|
46
59
|
export declare function getStatus(): Promise<VaultStatus>;
|
|
@@ -78,6 +91,7 @@ export declare function buildEnvMap(): Promise<Record<string, string>>;
|
|
|
78
91
|
export declare function redactionValues(wired?: Record<string, string>): Promise<Record<string, string>>;
|
|
79
92
|
export declare function indexValues(wired?: Record<string, string>): Record<string, string>;
|
|
80
93
|
export declare function writeRedactIndex(values: Record<string, string>): Promise<RedactIndex>;
|
|
94
|
+
export declare function refreshRedactIndex(): Promise<void>;
|
|
81
95
|
export declare function readRedactIndex(): Promise<RedactIndex | null>;
|
|
82
96
|
export declare function findItems(search: string): Promise<Array<{
|
|
83
97
|
id: string;
|
|
@@ -104,6 +118,6 @@ export declare function readSecretFromInput(opts: {
|
|
|
104
118
|
stdin: boolean;
|
|
105
119
|
}): Promise<string>;
|
|
106
120
|
export declare function readSecretFromDialog(opts: {
|
|
107
|
-
label: string;
|
|
108
121
|
itemName: string;
|
|
122
|
+
envName?: string;
|
|
109
123
|
}): Promise<string | null>;
|