bwx 0.5.0 → 0.6.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/README.md +55 -10
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +54 -8
- package/src/bw/encoding.ts +14 -3
- package/src/bw/fields.ts +117 -37
- package/src/bw/items.ts +8 -0
- package/src/bw/session.ts +42 -5
- package/src/bw/totp.ts +246 -0
- package/src/cli/commands/create.ts +9 -0
- package/src/cli/commands/edit.ts +35 -0
- package/src/cli/commands/get.ts +103 -4
- package/src/cli/input.ts +25 -1
- package/src/cli/items.ts +4 -2
- package/src/config/paths.ts +2 -0
- package/src/config/process-lock.ts +255 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Bitwarden CLI wrapper for AI agents (`bwx` = `bw extended`). Auto-unlock, sessio
|
|
|
17
17
|
The native `bw` CLI requires interactive prompts and manual session management. `bwx` wraps it with:
|
|
18
18
|
|
|
19
19
|
- **Auto-unlock** — first vault command authenticates automatically via macOS Keychain
|
|
20
|
-
- **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session
|
|
20
|
+
- **Session caching** — token written atomically as mode 600 to `~/.config/bwx/session`; concurrent callers share one bounded login/unlock attempt, and the token reaches `bw` through `BW_SESSION` rather than a process command line
|
|
21
21
|
- **Structured output** — `--json` and `--plain` flags for machine-parseable output
|
|
22
22
|
- **Leak-resistant** — listings never print secrets, `bwx run` hands them to a child process instead of stdout
|
|
23
23
|
- **No interaction** — credentials from config + Keychain, never prompts
|
|
@@ -70,6 +70,7 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
|
|
|
70
70
|
| Command | Description |
|
|
71
71
|
|---------|-------------|
|
|
72
72
|
| `bwx get <field> <item>` | Get field (`password`, `username`, `totp`, `notes`, `uri`, `item`) |
|
|
73
|
+
| `bwx get totp <item>` | TOTP code (`--fresh [n]`, `--seed`) |
|
|
73
74
|
| `bwx field <name> <item>` | Get custom field by name (same argument order as `get`) |
|
|
74
75
|
| `bwx search <query>` | Search items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
75
76
|
| `bwx list [type]` | List items (`--type`, `--folder`, `--limit`, `--full-items`) |
|
|
@@ -79,10 +80,17 @@ unlock. `valid` means reads work now, `stale` means the cached session was rejec
|
|
|
79
80
|
| `bwx attach list <item>` | List attachments for an item |
|
|
80
81
|
| `bwx attach get <item> <attachment>` | Download attachment by ID or filename |
|
|
81
82
|
|
|
82
|
-
`search` and `list` print the same reduced projection — id, type, name, username
|
|
83
|
-
human, `--plain`, and `--json` form, so asking for machine-readable output
|
|
84
|
-
what is exposed. `--full-items` is the explicit opt-in to complete item
|
|
85
|
-
carry passwords, notes, and custom fields.
|
|
83
|
+
`search` and `list` print the same reduced projection — id, type, name, username,
|
|
84
|
+
hasTotp — in human, `--plain`, and `--json` form, so asking for machine-readable output
|
|
85
|
+
never widens what is exposed. `--full-items` is the explicit opt-in to complete item
|
|
86
|
+
objects, which carry passwords, notes, and custom fields.
|
|
87
|
+
|
|
88
|
+
`hasTotp` reports that an item has a TOTP secret without carrying it, so finding your 2FA
|
|
89
|
+
items does not mean dumping the vault:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
bwx list --json --limit 0 | jq -r '.data[] | select(.hasTotp) | .name'
|
|
93
|
+
```
|
|
86
94
|
|
|
87
95
|
A lookup that finds nothing suggests what you probably meant, and one that matches
|
|
88
96
|
several items lists them, so neither ends in a guessing round:
|
|
@@ -95,6 +103,42 @@ error: No item matching "GitHub PAT vforsh old". Did you mean:
|
|
|
95
103
|
3683fb9a-… note GitHub PAT (yaforsh) [codex-monitor-pr]
|
|
96
104
|
```
|
|
97
105
|
|
|
106
|
+
### TOTP
|
|
107
|
+
|
|
108
|
+
Codes are computed locally from the stored secret. A TOTP therefore costs no extra `bw`
|
|
109
|
+
spawn and batches with the other fields of the same item, and bwx can report how long the
|
|
110
|
+
code has left.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
bwx get totp "AWS" # code on stdout, "Valid for 17s" on stderr
|
|
114
|
+
bwx get totp "AWS" --json # { "data": "123456", "meta": { "secondsRemaining": 17, "period": 30 } }
|
|
115
|
+
bwx get totp "AWS" --fresh # wait for the next window if under 5s remain
|
|
116
|
+
bwx get totp "AWS" --fresh 15 # ...or under 15s
|
|
117
|
+
bwx get totp "AWS" --seed # the stored secret, for moving it to another authenticator
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The remainder matters more than it looks: a vault read plus the time to actually use the
|
|
121
|
+
code often leaves single-digit seconds, and a code that expires in transit fails the login
|
|
122
|
+
without saying why. `--fresh` sleeps at most one period and hands back a full window.
|
|
123
|
+
|
|
124
|
+
Bare base32 and `otpauth://` URIs are both understood, including non-default `period`,
|
|
125
|
+
`digits`, and `algorithm` (SHA1/SHA256/SHA512). Secrets bwx will not compute exactly —
|
|
126
|
+
`steam://`, unknown algorithms, anything that fails to decode — fall back to `bw get totp`
|
|
127
|
+
rather than being guessed at, since a wrong code is indistinguishable from a right one
|
|
128
|
+
until the login fails.
|
|
129
|
+
|
|
130
|
+
Storing a secret:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
BWX_SEED="JBSWY3DPEHPK3PXP" bwx create --type login --name "AWS" --username me --totp-env BWX_SEED
|
|
134
|
+
bwx edit "AWS" --totp "otpauth://totp/AWS:me?secret=JBSWY3DPEHPK3PXP&issuer=AWS"
|
|
135
|
+
bwx edit "AWS" --rm-totp
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Spaces, hyphens, and `=` padding are stripped and case is normalized; an `otpauth://` URI
|
|
139
|
+
is stored verbatim so its parameters survive. Malformed secrets are rejected before the
|
|
140
|
+
write, because a bad seed is otherwise discovered at the login it was supposed to unlock.
|
|
141
|
+
|
|
98
142
|
### Listings are capped
|
|
99
143
|
|
|
100
144
|
Listings stop at 50 items by default — an uncapped `bwx list` returns the whole vault,
|
|
@@ -153,15 +197,15 @@ The check is cached in `~/.config/bwx/state.json`, so a fresh vault costs no ext
|
|
|
153
197
|
|
|
154
198
|
| Command | Description |
|
|
155
199
|
|---------|-------------|
|
|
156
|
-
| `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--field-file k=path`) |
|
|
157
|
-
| `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--add-field-file k=path`, `--rm-field name`) |
|
|
200
|
+
| `bwx create` | Create item (`--type`, `--name`, `--username`, `--password-stdin`, `--totp-env`, `--field-file k=path`) |
|
|
201
|
+
| `bwx edit <item>` | Edit item (`--name`, `--password-env`, `--totp`, `--rm-totp`, `--add-field-file k=path`, `--rm-field name`) |
|
|
158
202
|
| `bwx delete <item>` | Delete item (`--permanent`, `--force`) |
|
|
159
203
|
| `bwx restore <item>` | Restore an item from the trash |
|
|
160
204
|
| `bwx generate` | Generate a password or passphrase (local, no vault access) |
|
|
161
205
|
|
|
162
|
-
Login-only flags (`--username`, any `--password-*`, `--uri`) require a login
|
|
163
|
-
secure note has nowhere to put them, so passing them to one is an error rather
|
|
164
|
-
exit 0 over a credential that was silently dropped.
|
|
206
|
+
Login-only flags (`--username`, any `--password-*` or `--totp-*`, `--uri`) require a login
|
|
207
|
+
item. A secure note has nowhere to put them, so passing them to one is an error rather
|
|
208
|
+
than an exit 0 over a credential that was silently dropped.
|
|
165
209
|
|
|
166
210
|
`bwx delete` requires `--force` whenever it is not talking to a terminal. The confirmation
|
|
167
211
|
prompt cannot render for a script or an agent, so without it `--force` would be decorative
|
|
@@ -217,6 +261,7 @@ bwx create --name "Acme" --field-file '!API Key=./token.txt' --field-env account
|
|
|
217
261
|
Supported source flags:
|
|
218
262
|
|
|
219
263
|
- Passwords: `--password-stdin`, `--password-file <path>`, `--password-env <name>`
|
|
264
|
+
- TOTP secrets: `--totp-stdin`, `--totp-file <path>`, `--totp-env <name>`
|
|
220
265
|
- Notes: `--notes-file <path>`
|
|
221
266
|
- Create fields: `--field-file k=path`, `--field-env k=ENV`
|
|
222
267
|
- Edit fields: `--add-field-file k=path`, `--add-field-env k=ENV`
|
package/package.json
CHANGED
package/skill/bwx/SKILL.md
CHANGED
|
@@ -11,7 +11,8 @@ Extended Bitwarden CLI. Wraps `bw` with auto-unlock, session caching, and struct
|
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
bwx get password "GitHub" # retrieve a password (auto-unlocks)
|
|
14
|
-
bwx get totp "AWS (user@example.com)" # TOTP code
|
|
14
|
+
bwx get totp "AWS (user@example.com)" # TOTP code (+ seconds left, on stderr)
|
|
15
|
+
bwx get totp "AWS" --fresh # wait out a nearly-expired code first
|
|
15
16
|
bwx get username "GitHub" # username
|
|
16
17
|
bwx get notes "Deploy key" # secure note content
|
|
17
18
|
bwx get uri "GitHub" # first URI
|
|
@@ -49,6 +50,27 @@ Anything else is looked up as a custom field. Lists available fields on miss.
|
|
|
49
50
|
A miss suggests near matches; an ambiguous name lists candidates with IDs. Read the
|
|
50
51
|
suggestions instead of retrying variations — the answer is usually in them.
|
|
51
52
|
|
|
53
|
+
### `bwx get totp <item>`
|
|
54
|
+
|
|
55
|
+
Codes are computed locally from the stored secret, so a TOTP costs no extra `bw` spawn and
|
|
56
|
+
batches with other fields from the same item.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bwx get totp "AWS" # code on stdout, "Valid for 17s" on stderr
|
|
60
|
+
bwx get totp "AWS" --json # { "data": "123456", "meta": { "secondsRemaining", "period" } }
|
|
61
|
+
bwx get totp "AWS" --fresh # wait for the next window if under 5s remain
|
|
62
|
+
bwx get totp "AWS" --fresh 15 # ...or under 15s
|
|
63
|
+
bwx get totp "AWS" --seed # the stored secret, for migrating to another app
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Check `secondsRemaining` before using a code.** A `bw` read plus the time to hand the code
|
|
67
|
+
over routinely leaves under 5 seconds, and a code that expires in transit fails the login
|
|
68
|
+
with no useful error. `--fresh` sleeps at most one period and returns a full-window code.
|
|
69
|
+
|
|
70
|
+
Accepts bare base32 and `otpauth://` URIs, honoring their `period`, `digits`, and
|
|
71
|
+
`algorithm` (SHA1/SHA256/SHA512). `steam://` secrets and anything else bwx cannot compute
|
|
72
|
+
exactly are delegated to `bw get totp` instead of guessed at.
|
|
73
|
+
|
|
52
74
|
### `bwx field <name> <item>`
|
|
53
75
|
|
|
54
76
|
Custom fields only, so an item whose custom field is named `password` is still reachable.
|
|
@@ -59,8 +81,13 @@ Takes its arguments in the same order as `get` (this changed in 0.5.0).
|
|
|
59
81
|
Flags: `--type login|note|card|identity`, `--folder <name|id>`, `--limit <n>`, `--full-items`
|
|
60
82
|
|
|
61
83
|
Human, `--plain`, and `--json` output all carry the same reduced projection — `id`, `type`,
|
|
62
|
-
`name`, `username` — so listings never leak passwords, notes, or custom fields.
|
|
63
|
-
opts into complete item objects and **does** include secrets; avoid it in
|
|
84
|
+
`name`, `username`, `hasTotp` — so listings never leak passwords, notes, or custom fields.
|
|
85
|
+
`--full-items` opts into complete item objects and **does** include secrets; avoid it in
|
|
86
|
+
logged sessions.
|
|
87
|
+
|
|
88
|
+
`hasTotp` says whether an item has a TOTP secret, never what it is. Use it to find 2FA items
|
|
89
|
+
(`bwx list --json --limit 0 | jq '.data[]|select(.hasTotp)'`) instead of reaching for
|
|
90
|
+
`--full-items`, which would dump the whole vault in plaintext to answer a boolean.
|
|
64
91
|
|
|
65
92
|
**Listings stop at 50 items** unless you pass `--limit` (`--limit 0` for all). Truncation is
|
|
66
93
|
reported on stderr and in `meta` on the JSON envelope — check it before concluding a listing
|
|
@@ -99,10 +126,10 @@ Downloads an attachment by ID or filename. Defaults to saving as the attachment
|
|
|
99
126
|
|
|
100
127
|
### `bwx create [options]`
|
|
101
128
|
|
|
102
|
-
Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
|
|
129
|
+
Flags: `--type login|note`, `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--totp`, `--totp-stdin`, `--totp-file`, `--totp-env`, `--uri` (repeatable), `--field k=v` (repeatable), `--field-file k=path`, `--field-env k=ENV`, `--folder <name|id>`, `--favorite`, `--from-json`
|
|
103
130
|
|
|
104
|
-
`--username`, any `--password-*`, and `--uri` require `--type login`; a note
|
|
105
|
-
them and passing them to one is now an error rather than a silent drop.
|
|
131
|
+
`--username`, any `--password-*` or `--totp-*`, and `--uri` require `--type login`; a note
|
|
132
|
+
cannot hold them and passing them to one is now an error rather than a silent drop.
|
|
106
133
|
|
|
107
134
|
Field syntax: `k=v` (text), `!k=v` (hidden), `bool:k=v` (boolean). The same key syntax works for `--field-file` and `--field-env`, where the value is a path or environment variable name. Stdin is read as `--notes` when piped; prefer `--password-stdin` for login passwords.
|
|
108
135
|
|
|
@@ -114,7 +141,24 @@ bwx edit "API Key" --add-field region=us-east-1
|
|
|
114
141
|
printf '%s' "$TOKEN" | bwx edit "GitHub" --password-stdin --uri https://github.com
|
|
115
142
|
bwx edit "Note" --rm-field old-key
|
|
116
143
|
```
|
|
117
|
-
Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder <name|id>` (`none` unfiles), `--favorite/--no-favorite`, `--from-json`
|
|
144
|
+
Flags: `--name`, `--notes`, `--notes-file`, `--username`, `--password`, `--password-stdin`, `--password-file`, `--password-env`, `--password-generate`, `--generate-length <n>`, `--totp`, `--totp-stdin`, `--totp-file`, `--totp-env`, `--rm-totp`, `--uri` (replaces all), `--add-field k=v`, `--add-field-file k=path`, `--add-field-env k=ENV`, `--rm-field name`, `--folder <name|id>` (`none` unfiles), `--favorite/--no-favorite`, `--from-json`
|
|
145
|
+
|
|
146
|
+
### Storing a TOTP secret
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
BWX_SEED="JBSWY3DPEHPK3PXP" bwx create --type login --name "AWS" --username me --totp-env BWX_SEED
|
|
150
|
+
bwx edit "AWS" --totp "otpauth://totp/AWS:me?secret=JBSWY3DPEHPK3PXP&issuer=AWS"
|
|
151
|
+
bwx edit "AWS" --rm-totp
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Takes a bare base32 secret (spaces, hyphens, and `=` padding are stripped; case is
|
|
155
|
+
normalized) or a full `otpauth://` URI, which is stored verbatim so its `period`/`digits`/
|
|
156
|
+
`algorithm` survive. Malformed secrets are rejected **before** the write — a bad seed
|
|
157
|
+
otherwise sits in the vault until it fails the login it was meant to unlock, which is the
|
|
158
|
+
one moment it cannot be fixed.
|
|
159
|
+
|
|
160
|
+
Prefer `--totp-env` or `--totp-stdin` over `--totp`: a seed on the command line lands in
|
|
161
|
+
shell history and `ps` output.
|
|
118
162
|
|
|
119
163
|
### `bwx delete <item>`
|
|
120
164
|
|
|
@@ -186,7 +230,9 @@ even under `--json`; `-q` silences them.
|
|
|
186
230
|
- Prefer `--password-generate` over generating a secret yourself and passing it in.
|
|
187
231
|
- Request the narrowest field, resolving an exact item ID first.
|
|
188
232
|
- Use `-q` for command substitution so unlock logs cannot contaminate merged output.
|
|
189
|
-
- On writes, prefer `--password-stdin`, `--password-env`, `--field-file`, `--field-env`.
|
|
233
|
+
- On writes, prefer `--password-stdin`, `--password-env`, `--totp-stdin`, `--totp-env`, `--field-file`, `--field-env`.
|
|
234
|
+
- Check `secondsRemaining` on a TOTP, or pass `--fresh`, before handing the code anywhere.
|
|
235
|
+
- Use `hasTotp` on a listing to find 2FA items; never `--full-items` for that.
|
|
190
236
|
- Never echo, log, checksum, or commit retrieved values; `unset` temporary variables.
|
|
191
237
|
|
|
192
238
|
## Auth
|
package/src/bw/encoding.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface NewItemOptions {
|
|
|
40
40
|
notes?: string | null;
|
|
41
41
|
username?: string | null;
|
|
42
42
|
password?: string | null;
|
|
43
|
+
totp?: string | null;
|
|
43
44
|
uris?: string[];
|
|
44
45
|
fields?: ParsedField[];
|
|
45
46
|
folderId?: string | null;
|
|
@@ -65,7 +66,7 @@ export function buildNewItem(options: NewItemOptions): Record<string, unknown> {
|
|
|
65
66
|
item.login = {
|
|
66
67
|
username: options.username ?? null,
|
|
67
68
|
password: options.password ?? null,
|
|
68
|
-
totp: null,
|
|
69
|
+
totp: options.totp ?? null,
|
|
69
70
|
uris: options.uris?.length
|
|
70
71
|
? options.uris.map((uri) => ({ match: null, uri }))
|
|
71
72
|
: null,
|
|
@@ -95,6 +96,8 @@ export interface PatchOptions {
|
|
|
95
96
|
notes?: string;
|
|
96
97
|
username?: string;
|
|
97
98
|
password?: string;
|
|
99
|
+
/** `null` clears the stored secret; `undefined` leaves it alone. */
|
|
100
|
+
totp?: string | null;
|
|
98
101
|
uris?: string[];
|
|
99
102
|
addFields?: ParsedField[];
|
|
100
103
|
rmFields?: string[];
|
|
@@ -115,10 +118,18 @@ export function patchItem(
|
|
|
115
118
|
if (patch.favorite !== undefined) result.favorite = patch.favorite;
|
|
116
119
|
|
|
117
120
|
// Login fields
|
|
118
|
-
if (
|
|
119
|
-
|
|
121
|
+
if (
|
|
122
|
+
patch.username !== undefined ||
|
|
123
|
+
patch.password !== undefined ||
|
|
124
|
+
patch.totp !== undefined ||
|
|
125
|
+
patch.uris !== undefined
|
|
126
|
+
) {
|
|
127
|
+
// Copied, not aliased: `{ ...item }` above is shallow, so assigning into
|
|
128
|
+
// the original `login` would edit the caller's item as a side effect.
|
|
129
|
+
const login = { ...((result.login as Record<string, unknown>) ?? {}) };
|
|
120
130
|
if (patch.username !== undefined) login.username = patch.username;
|
|
121
131
|
if (patch.password !== undefined) login.password = patch.password;
|
|
132
|
+
if (patch.totp !== undefined) login.totp = patch.totp;
|
|
122
133
|
if (patch.uris !== undefined) {
|
|
123
134
|
login.uris = patch.uris.map((uri) => ({ match: null, uri }));
|
|
124
135
|
}
|
package/src/bw/fields.ts
CHANGED
|
@@ -4,6 +4,14 @@ import type { GlobalOptions } from "../cli/types.ts";
|
|
|
4
4
|
import { summarizeItem, type ItemSummary } from "./items.ts";
|
|
5
5
|
import { runBwOrThrow } from "./runner.ts";
|
|
6
6
|
import { withSession } from "./session.ts";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_PERIOD,
|
|
9
|
+
generateTotp,
|
|
10
|
+
parseTotpSecret,
|
|
11
|
+
secondsRemaining,
|
|
12
|
+
TotpUnsupportedError,
|
|
13
|
+
type TotpCode,
|
|
14
|
+
} from "./totp.ts";
|
|
7
15
|
import { BwItemSchema, type BwItem } from "./types.ts";
|
|
8
16
|
|
|
9
17
|
/** Fields `bw get` resolves natively; anything else is a custom field name. */
|
|
@@ -16,16 +24,15 @@ const BUILTIN_FIELDS = [
|
|
|
16
24
|
"item",
|
|
17
25
|
] as const;
|
|
18
26
|
|
|
19
|
-
/**
|
|
20
|
-
* `totp` is a computed code, not stored data — only `bw get totp` can derive it,
|
|
21
|
-
* so it is the one field that cannot be served from a fetched item.
|
|
22
|
-
*/
|
|
23
|
-
const DERIVED_FIELDS = new Set(["totp"]);
|
|
24
|
-
|
|
25
27
|
function isBuiltinField(field: string): boolean {
|
|
26
28
|
return (BUILTIN_FIELDS as readonly string[]).includes(field);
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/** True when `field` should be read as the item's TOTP rather than a custom field. */
|
|
32
|
+
function isTotpField(field: string, options?: ReadFieldOptions): boolean {
|
|
33
|
+
return field === "totp" && !options?.customOnly;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export interface ReadFieldOptions {
|
|
30
37
|
/**
|
|
31
38
|
* Always resolve `field` as a custom field name, so an item whose custom
|
|
@@ -58,17 +65,85 @@ export async function readItemField(
|
|
|
58
65
|
opts: GlobalOptions,
|
|
59
66
|
options?: ReadFieldOptions,
|
|
60
67
|
): Promise<string> {
|
|
61
|
-
return withLookupHelp(item, opts, () =>
|
|
62
|
-
|
|
68
|
+
return withLookupHelp(item, opts, async () => {
|
|
69
|
+
if (isTotpField(field, options)) {
|
|
70
|
+
return (await resolveTotp(await fetchItem(item, opts), item, opts)).code;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return isBuiltinField(field) && !options?.customOnly
|
|
63
74
|
? readBuiltinField(field, item, opts)
|
|
64
|
-
: readCustomField(field, item, opts)
|
|
75
|
+
: readCustomField(field, item, opts);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads a TOTP with the time it has left. A code handed over with three seconds
|
|
81
|
+
* on it is usually dead before it is pasted, so callers need the remainder to
|
|
82
|
+
* decide whether to wait for the next window.
|
|
83
|
+
*/
|
|
84
|
+
export async function readTotp(
|
|
85
|
+
item: string,
|
|
86
|
+
opts: GlobalOptions,
|
|
87
|
+
): Promise<TotpCode> {
|
|
88
|
+
return withLookupHelp(item, opts, async () =>
|
|
89
|
+
resolveTotp(await fetchItem(item, opts), item, opts),
|
|
65
90
|
);
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
/** The stored secret itself, for moving a seed back out to another authenticator. */
|
|
94
|
+
export async function readTotpSecret(
|
|
95
|
+
item: string,
|
|
96
|
+
opts: GlobalOptions,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
return withLookupHelp(item, opts, async () => {
|
|
99
|
+
const fetched = await fetchItem(item, opts);
|
|
100
|
+
return requireStoredTotp(fetched);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function requireStoredTotp(item: BwItem): string {
|
|
105
|
+
const stored = item.login?.totp;
|
|
106
|
+
if (!stored) {
|
|
107
|
+
throw new FieldMissingError(
|
|
108
|
+
`No totp found for item: ${item.name}`,
|
|
109
|
+
ExitCode.NotFound,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return stored;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Computes the code from the secret already in hand, falling back to `bw get
|
|
117
|
+
* totp` for the shapes this codebase will not compute itself (see `totp.ts`).
|
|
118
|
+
* The fallback costs a second spawn, which is the correct trade against
|
|
119
|
+
* emitting a confidently wrong six digits.
|
|
120
|
+
*/
|
|
121
|
+
async function resolveTotp(
|
|
122
|
+
fetched: BwItem,
|
|
123
|
+
item: string,
|
|
124
|
+
opts: GlobalOptions,
|
|
125
|
+
): Promise<TotpCode> {
|
|
126
|
+
const stored = requireStoredTotp(fetched);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return generateTotp(parseTotpSecret(stored));
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (!(err instanceof TotpUnsupportedError)) throw err;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
code: await readBuiltinField("totp", item, opts),
|
|
135
|
+
period: DEFAULT_PERIOD,
|
|
136
|
+
secondsRemaining: secondsRemaining(DEFAULT_PERIOD),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
68
141
|
/**
|
|
69
142
|
* Resolves many fields at once, paying a single `bw get item` per distinct item
|
|
70
143
|
* rather than one `bw` spawn per field. Each spawn costs ~2.5s, so a caller
|
|
71
|
-
* pulling a username and password from one item halves its own runtime.
|
|
144
|
+
* pulling a username and password from one item halves its own runtime. `totp`
|
|
145
|
+
* joins that batch too, since the code is computed from the secret the fetch
|
|
146
|
+
* already returned.
|
|
72
147
|
*
|
|
73
148
|
* Returns values positionally aligned with `requests`.
|
|
74
149
|
*/
|
|
@@ -88,34 +163,25 @@ export async function readFields(
|
|
|
88
163
|
for (const [item, indexes] of byItem) {
|
|
89
164
|
// A single `bw` process at a time: each read may trigger an unlock, and
|
|
90
165
|
// concurrent unlocks would race over the cached session.
|
|
91
|
-
const
|
|
92
|
-
const stored = indexes.filter((i) => !isDerived(requests[i]!));
|
|
166
|
+
const fetched = await withLookupHelp(item, opts, () => fetchItem(item, opts));
|
|
93
167
|
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
168
|
+
for (const index of indexes) {
|
|
169
|
+
const request = requests[index]!;
|
|
170
|
+
|
|
171
|
+
if (isTotpField(request.field, request.options)) {
|
|
172
|
+
const totp = await withLookupHelp(item, opts, () =>
|
|
173
|
+
resolveTotp(fetched, item, opts),
|
|
174
|
+
);
|
|
175
|
+
values[index] = totp.code;
|
|
176
|
+
} else {
|
|
100
177
|
values[index] = extractField(fetched, request.field, request.options);
|
|
101
178
|
}
|
|
102
179
|
}
|
|
103
|
-
|
|
104
|
-
for (const index of derived) {
|
|
105
|
-
const request = requests[index]!;
|
|
106
|
-
values[index] = await withLookupHelp(item, opts, () =>
|
|
107
|
-
readBuiltinField(request.field, item, opts),
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
180
|
}
|
|
111
181
|
|
|
112
182
|
return values;
|
|
113
183
|
}
|
|
114
184
|
|
|
115
|
-
function isDerived(request: FieldRequest): boolean {
|
|
116
|
-
return !request.options?.customOnly && DERIVED_FIELDS.has(request.field);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
185
|
/**
|
|
120
186
|
* Turns bw's bare "Not found." and "More than one result" into errors that name
|
|
121
187
|
* the candidates, so a caller can correct itself without a round of guessing.
|
|
@@ -165,14 +231,28 @@ function extractField(
|
|
|
165
231
|
|
|
166
232
|
if (field === "item") return JSON.stringify(item);
|
|
167
233
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
234
|
+
// Named exhaustively rather than defaulted: a chain ending in `password`
|
|
235
|
+
// would hand back the password for any field it failed to recognize.
|
|
236
|
+
let value: string | null;
|
|
237
|
+
switch (field) {
|
|
238
|
+
case "notes":
|
|
239
|
+
value = item.notes;
|
|
240
|
+
break;
|
|
241
|
+
case "uri":
|
|
242
|
+
value = item.login?.uris?.[0]?.uri ?? null;
|
|
243
|
+
break;
|
|
244
|
+
case "username":
|
|
245
|
+
value = item.login?.username ?? null;
|
|
246
|
+
break;
|
|
247
|
+
case "password":
|
|
248
|
+
value = item.login?.password ?? null;
|
|
249
|
+
break;
|
|
250
|
+
default:
|
|
251
|
+
throw new FieldMissingError(
|
|
252
|
+
`Field "${field}" cannot be read from a fetched item`,
|
|
253
|
+
ExitCode.NotFound,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
176
256
|
|
|
177
257
|
if (!value) {
|
|
178
258
|
throw new FieldMissingError(
|
package/src/bw/items.ts
CHANGED
|
@@ -10,16 +10,24 @@ export interface ItemSummary {
|
|
|
10
10
|
type: string;
|
|
11
11
|
name: string;
|
|
12
12
|
username: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the item carries a TOTP secret — the fact, never the secret.
|
|
15
|
+
* Without it, finding which items have 2FA means `--full-items`, which dumps
|
|
16
|
+
* every password and note in the vault to answer a boolean.
|
|
17
|
+
*/
|
|
18
|
+
hasTotp: boolean;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export function summarizeItem(item: Record<string, unknown>): ItemSummary {
|
|
16
22
|
const login = item.login as Record<string, unknown> | null | undefined;
|
|
17
23
|
const username = login?.username;
|
|
24
|
+
const totp = login?.totp;
|
|
18
25
|
|
|
19
26
|
return {
|
|
20
27
|
id: String(item.id ?? ""),
|
|
21
28
|
type: BW_TYPE_LABELS[item.type as number] ?? `type:${item.type}`,
|
|
22
29
|
name: String(item.name ?? ""),
|
|
23
30
|
username: typeof username === "string" && username ? username : null,
|
|
31
|
+
hasTotp: typeof totp === "string" && totp.length > 0,
|
|
24
32
|
};
|
|
25
33
|
}
|
package/src/bw/session.ts
CHANGED
|
@@ -2,9 +2,16 @@ import { unlinkSync } from "node:fs";
|
|
|
2
2
|
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
3
3
|
import type { GlobalOptions } from "../cli/types.ts";
|
|
4
4
|
import { emitLog, emitWarn } from "../cli/io.ts";
|
|
5
|
-
import { SESSION_FILE } from "../config/paths.ts";
|
|
5
|
+
import { SESSION_FILE, SESSION_LOCK_DIR } from "../config/paths.ts";
|
|
6
|
+
import { withProcessLock } from "../config/process-lock.ts";
|
|
6
7
|
import { readPrivateFile, writePrivateFile } from "../config/secure-file.ts";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_TIMEOUT_MS,
|
|
10
|
+
runBw,
|
|
11
|
+
runBwOrThrow,
|
|
12
|
+
setSession,
|
|
13
|
+
getSession,
|
|
14
|
+
} from "./runner.ts";
|
|
8
15
|
import { checkVaultFreshness } from "./freshness.ts";
|
|
9
16
|
import { getStatus } from "./status.ts";
|
|
10
17
|
import { BwStatusSchema } from "./types.ts";
|
|
@@ -96,13 +103,36 @@ export async function probeSessionState(
|
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
|
|
99
|
-
//
|
|
106
|
+
// Keep the ordinary valid-session path entirely outside the process lock.
|
|
100
107
|
const cached = getSession() || loadCachedSession(opts);
|
|
101
108
|
if (cached) {
|
|
102
109
|
setSession(cached);
|
|
103
110
|
if (await sessionWorks(cached)) return;
|
|
111
|
+
setSession(null);
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
await withProcessLock(
|
|
115
|
+
{
|
|
116
|
+
path: SESSION_LOCK_DIR,
|
|
117
|
+
label: "Bitwarden session establishment",
|
|
118
|
+
waitMs: sessionLockWaitMs(opts),
|
|
119
|
+
},
|
|
120
|
+
async () => {
|
|
121
|
+
// A process that waited must use the winner's disk cache, not the stale
|
|
122
|
+
// token it may still have held in memory before entering the lock.
|
|
123
|
+
const winner = loadCachedSession(opts);
|
|
124
|
+
if (winner) {
|
|
125
|
+
setSession(winner);
|
|
126
|
+
if (await sessionWorks(winner)) return;
|
|
127
|
+
setSession(null);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await establishSession(opts);
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function establishSession(opts: GlobalOptions): Promise<void> {
|
|
106
136
|
const status = await getStatus();
|
|
107
137
|
|
|
108
138
|
if (status.status === "unlocked") {
|
|
@@ -152,6 +182,13 @@ export async function ensureUnlocked(opts: GlobalOptions): Promise<void> {
|
|
|
152
182
|
saveCachedSession(token);
|
|
153
183
|
}
|
|
154
184
|
|
|
185
|
+
/** Four sequential `bw` calls cover stale validation plus status/login/unlock. */
|
|
186
|
+
function sessionLockWaitMs(opts: GlobalOptions): number {
|
|
187
|
+
const perCall =
|
|
188
|
+
opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
189
|
+
return Math.min(Math.max(perCall * 4 + 10_000, 10_000), 5 * 60_000);
|
|
190
|
+
}
|
|
191
|
+
|
|
155
192
|
export async function lockVault(): Promise<void> {
|
|
156
193
|
await runBw(["lock"], { session: null });
|
|
157
194
|
setSession(null);
|
|
@@ -197,9 +234,9 @@ async function withAuthRetry<T>(
|
|
|
197
234
|
return await fn();
|
|
198
235
|
} catch (err) {
|
|
199
236
|
if (err instanceof CliError && err.exitCode === ExitCode.AuthFailed) {
|
|
200
|
-
//
|
|
237
|
+
// Do not unlink the shared cache here: another process may already have
|
|
238
|
+
// atomically replaced our stale token with a freshly unlocked session.
|
|
201
239
|
setSession(null);
|
|
202
|
-
clearCachedSession();
|
|
203
240
|
await ensureUnlocked(opts);
|
|
204
241
|
return await fn();
|
|
205
242
|
}
|