bwx 0.5.0 → 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/README.md CHANGED
@@ -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 — in
83
- human, `--plain`, and `--json` form, so asking for machine-readable output never widens
84
- what is exposed. `--full-items` is the explicit opt-in to complete item objects, which
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 item. A
163
- secure note has nowhere to put them, so passing them to one is an error rather than an
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bwx",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "bitwarden",
@@ -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. `--full-items`
63
- opts into complete item objects and **does** include secrets; avoid it in logged sessions.
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 cannot hold
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
@@ -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 (patch.username !== undefined || patch.password !== undefined || patch.uris !== undefined) {
119
- const login = (result.login as Record<string, unknown>) ?? {};
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
- isBuiltinField(field) && !options?.customOnly
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 derived = indexes.filter((i) => isDerived(requests[i]!));
92
- const stored = indexes.filter((i) => !isDerived(requests[i]!));
166
+ const fetched = await withLookupHelp(item, opts, () => fetchItem(item, opts));
93
167
 
94
- if (stored.length > 0) {
95
- const fetched = await withLookupHelp(item, opts, () =>
96
- fetchItem(item, opts),
97
- );
98
- for (const index of stored) {
99
- const request = requests[index]!;
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
- const value =
169
- field === "notes"
170
- ? item.notes
171
- : field === "uri"
172
- ? (item.login?.uris?.[0]?.uri ?? null)
173
- : field === "username"
174
- ? (item.login?.username ?? null)
175
- : (item.login?.password ?? null);
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/totp.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { CliError, ExitCode } from "../cli/errors.ts";
3
+
4
+ /**
5
+ * TOTP handling, kept local so a code costs no extra `bw` spawn and can be
6
+ * reported with the time it has left.
7
+ *
8
+ * Only shapes this module can compute *exactly* are computed here. Anything
9
+ * else — a `steam://` secret, an unknown algorithm, a seed that will not decode
10
+ * — is reported as unsupported so the caller can fall back to `bw get totp`.
11
+ * A wrong six-digit code is indistinguishable from a right one until the login
12
+ * fails, so guessing is never the safer option.
13
+ */
14
+
15
+ /** RFC 4648 base32, minus the padding and separators authenticators sprinkle in. */
16
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
17
+
18
+ const STEAM_PREFIX = "steam://";
19
+
20
+ export const DEFAULT_PERIOD = 30;
21
+ export const DEFAULT_DIGITS = 6;
22
+ export const DEFAULT_ALGORITHM = "SHA1";
23
+
24
+ const SUPPORTED_ALGORITHMS: Record<string, string> = {
25
+ SHA1: "sha1",
26
+ SHA256: "sha256",
27
+ SHA512: "sha512",
28
+ };
29
+
30
+ export interface TotpConfig {
31
+ /** Normalized base32 secret, without padding or separators. */
32
+ secret: string;
33
+ period: number;
34
+ digits: number;
35
+ /** Uppercase name as it appears in an `otpauth://` URI. */
36
+ algorithm: string;
37
+ }
38
+
39
+ export interface TotpCode {
40
+ code: string;
41
+ period: number;
42
+ /** Whole seconds this code stays valid, counting the current one. */
43
+ secondsRemaining: number;
44
+ }
45
+
46
+ /** Why a stored secret cannot be computed locally, for the fallback path. */
47
+ export class TotpUnsupportedError extends Error {}
48
+
49
+ /**
50
+ * Reads a stored `login.totp` value. Bitwarden accepts a bare base32 secret, a
51
+ * full `otpauth://` URI, or a `steam://` secret, and all three turn up in real
52
+ * vaults.
53
+ */
54
+ export function parseTotpSecret(raw: string): TotpConfig {
55
+ const value = raw.trim();
56
+
57
+ if (value.length === 0) {
58
+ throw new TotpUnsupportedError("secret is empty");
59
+ }
60
+
61
+ if (value.toLowerCase().startsWith(STEAM_PREFIX)) {
62
+ // Steam's alphabet and digit count differ from RFC 6238, and bwx has no
63
+ // way to verify its output against a real Steam login.
64
+ throw new TotpUnsupportedError("steam:// secrets are computed by bw");
65
+ }
66
+
67
+ const config = value.toLowerCase().startsWith("otpauth://")
68
+ ? parseOtpauthUri(value)
69
+ : {
70
+ secret: normalizeBase32(value),
71
+ period: DEFAULT_PERIOD,
72
+ digits: DEFAULT_DIGITS,
73
+ algorithm: DEFAULT_ALGORITHM,
74
+ };
75
+
76
+ if (!(config.algorithm in SUPPORTED_ALGORITHMS)) {
77
+ throw new TotpUnsupportedError(`unsupported algorithm ${config.algorithm}`);
78
+ }
79
+
80
+ if (!isBase32(config.secret)) {
81
+ throw new TotpUnsupportedError("secret is not base32");
82
+ }
83
+
84
+ return config;
85
+ }
86
+
87
+ function parseOtpauthUri(value: string): TotpConfig {
88
+ let url: URL;
89
+ try {
90
+ url = new URL(value);
91
+ } catch {
92
+ throw new TotpUnsupportedError("otpauth URI does not parse");
93
+ }
94
+
95
+ if (url.host.toLowerCase() !== "totp") {
96
+ throw new TotpUnsupportedError(`otpauth type "${url.host}" is not totp`);
97
+ }
98
+
99
+ const secret = url.searchParams.get("secret");
100
+ if (!secret) throw new TotpUnsupportedError("otpauth URI has no secret");
101
+
102
+ return {
103
+ secret: normalizeBase32(secret),
104
+ period: readPositiveInt(url, "period", DEFAULT_PERIOD),
105
+ digits: readPositiveInt(url, "digits", DEFAULT_DIGITS),
106
+ algorithm: (url.searchParams.get("algorithm") ?? DEFAULT_ALGORITHM).toUpperCase(),
107
+ };
108
+ }
109
+
110
+ function readPositiveInt(url: URL, name: string, fallback: number): number {
111
+ const raw = url.searchParams.get(name);
112
+ if (raw === null) return fallback;
113
+
114
+ const value = Number(raw);
115
+ if (!Number.isInteger(value) || value <= 0) {
116
+ throw new TotpUnsupportedError(`otpauth ${name}="${raw}" is not a positive integer`);
117
+ }
118
+ return value;
119
+ }
120
+
121
+ /** Uppercases and drops the spaces, hyphens, and padding authenticators show. */
122
+ export function normalizeBase32(value: string): string {
123
+ return value.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
124
+ }
125
+
126
+ function isBase32(value: string): boolean {
127
+ return value.length > 0 && /^[A-Z2-7]+$/.test(value);
128
+ }
129
+
130
+ /**
131
+ * Decodes base32 into bytes, discarding the trailing bits when the secret is not
132
+ * a whole number of bytes. Real secrets do this — a 29-character seed carries
133
+ * 145 bits and yields 18 bytes — and bw resolves them the same way, so rejecting
134
+ * them or padding them out would produce codes that quietly disagree with bw's.
135
+ */
136
+ export function base32Decode(value: string): Buffer {
137
+ const bytes: number[] = [];
138
+ let accumulator = 0;
139
+ let bits = 0;
140
+
141
+ for (const char of value) {
142
+ const index = BASE32_ALPHABET.indexOf(char);
143
+ if (index === -1) {
144
+ throw new TotpUnsupportedError(`invalid base32 character "${char}"`);
145
+ }
146
+
147
+ accumulator = (accumulator << 5) | index;
148
+ bits += 5;
149
+
150
+ if (bits >= 8) {
151
+ bits -= 8;
152
+ bytes.push((accumulator >> bits) & 0xff);
153
+ }
154
+ }
155
+
156
+ if (bytes.length === 0) {
157
+ throw new TotpUnsupportedError("secret decodes to no bytes");
158
+ }
159
+
160
+ return Buffer.from(bytes);
161
+ }
162
+
163
+ /** Seconds left in the window `at` falls in, counting the current second. */
164
+ export function secondsRemaining(period: number, at: number = Date.now()): number {
165
+ return period - (Math.floor(at / 1000) % period);
166
+ }
167
+
168
+ export function generateTotp(config: TotpConfig, at: number = Date.now()): TotpCode {
169
+ const key = base32Decode(config.secret);
170
+ const counter = Math.floor(at / 1000 / config.period);
171
+
172
+ const message = Buffer.alloc(8);
173
+ message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
174
+ message.writeUInt32BE(counter >>> 0, 4);
175
+
176
+ const digest = createHmac(SUPPORTED_ALGORITHMS[config.algorithm]!, key)
177
+ .update(message)
178
+ .digest();
179
+
180
+ // RFC 6238 dynamic truncation: the low nibble of the last byte picks the
181
+ // four-byte window, and the high bit is masked off to stay sign-agnostic.
182
+ const offset = digest[digest.length - 1]! & 0x0f;
183
+ const binary = digest.readUInt32BE(offset) & 0x7fffffff;
184
+
185
+ return {
186
+ code: String(binary % 10 ** config.digits).padStart(config.digits, "0"),
187
+ period: config.period,
188
+ secondsRemaining: secondsRemaining(config.period, at),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Validates a secret on its way *into* the vault. A seed is only ever exercised
194
+ * at login, so a typo accepted here surfaces when the caller is already locked
195
+ * out — the one moment they cannot fix it. Returns the value to store.
196
+ */
197
+ export function normalizeTotpInput(raw: string): string {
198
+ const value = raw.trim();
199
+
200
+ if (value.length === 0) {
201
+ throw new CliError("TOTP secret cannot be empty", ExitCode.BadArgs);
202
+ }
203
+
204
+ // Stored verbatim: bw owns these, and bwx would only mangle them.
205
+ if (value.toLowerCase().startsWith(STEAM_PREFIX)) return value;
206
+
207
+ if (value.toLowerCase().startsWith("otpauth://")) {
208
+ try {
209
+ parseTotpSecret(value);
210
+ } catch (err) {
211
+ throw new CliError(
212
+ `Invalid otpauth URI: ${describe(err)}`,
213
+ ExitCode.BadArgs,
214
+ );
215
+ }
216
+ return value;
217
+ }
218
+
219
+ const secret = normalizeBase32(value);
220
+ if (!isBase32(secret)) {
221
+ throw new CliError(
222
+ `Invalid TOTP secret: expected base32 (A-Z, 2-7) or an otpauth:// URI, got "${redact(value)}"`,
223
+ ExitCode.BadArgs,
224
+ );
225
+ }
226
+
227
+ // A secret shorter than a byte cannot key an HMAC.
228
+ try {
229
+ base32Decode(secret);
230
+ } catch (err) {
231
+ throw new CliError(`Invalid TOTP secret: ${describe(err)}`, ExitCode.BadArgs);
232
+ }
233
+
234
+ return secret;
235
+ }
236
+
237
+ /** Keeps a rejected secret out of logs while still showing what was wrong with it. */
238
+ function redact(value: string): string {
239
+ const bad = value.replace(/[A-Za-z0-9\s=-]/g, "");
240
+ if (bad.length > 0) return `…contains "${[...new Set(bad)].join("")}"`;
241
+ return `…${value.length} characters`;
242
+ }
243
+
244
+ function describe(err: unknown): string {
245
+ return err instanceof Error ? err.message : String(err);
246
+ }
@@ -41,6 +41,10 @@ const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
41
41
  ["passwordFile", "--password-file"],
42
42
  ["passwordEnv", "--password-env"],
43
43
  ["passwordGenerate", "--password-generate"],
44
+ ["totp", "--totp"],
45
+ ["totpStdin", "--totp-stdin"],
46
+ ["totpFile", "--totp-file"],
47
+ ["totpEnv", "--totp-env"],
44
48
  ];
45
49
 
46
50
  export function registerCreate(program: Command): void {
@@ -58,6 +62,10 @@ export function registerCreate(program: Command): void {
58
62
  .option("--password-env <name>", "Read password from environment variable")
59
63
  .option("--password-generate", "Generate the password; it is never printed")
60
64
  .option("--generate-length <n>", "Length for --password-generate", parseInt)
65
+ .option("--totp <secret>", "TOTP secret: base32 or otpauth:// URI (login type)")
66
+ .option("--totp-stdin", "Read the TOTP secret from stdin")
67
+ .option("--totp-file <path>", "Read the TOTP secret from file (use - for stdin)")
68
+ .option("--totp-env <name>", "Read the TOTP secret from environment variable")
61
69
  .option("--uri <url>", "URI (repeatable)", collect, [])
62
70
  .option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
63
71
  .option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
@@ -139,6 +147,7 @@ export function registerCreate(program: Command): void {
139
147
  notes: notes ?? null,
140
148
  username: localOpts.username ?? null,
141
149
  password: textInputs.password ?? null,
150
+ totp: textInputs.totp ?? null,
142
151
  uris: localOpts.uri,
143
152
  fields,
144
153
  folderId: localOpts.folder
@@ -27,6 +27,7 @@ interface EditOptions extends ItemTextInputs {
27
27
  addFieldFile: string[];
28
28
  addFieldEnv: string[];
29
29
  rmField: string[];
30
+ rmTotp?: boolean;
30
31
  folder?: string;
31
32
  favorite?: boolean;
32
33
  fromJson?: boolean;
@@ -40,6 +41,11 @@ const LOGIN_ONLY_FLAGS: Array<[keyof EditOptions, string]> = [
40
41
  ["passwordFile", "--password-file"],
41
42
  ["passwordEnv", "--password-env"],
42
43
  ["passwordGenerate", "--password-generate"],
44
+ ["totp", "--totp"],
45
+ ["totpStdin", "--totp-stdin"],
46
+ ["totpFile", "--totp-file"],
47
+ ["totpEnv", "--totp-env"],
48
+ ["rmTotp", "--rm-totp"],
43
49
  ];
44
50
 
45
51
  export function registerEdit(program: Command): void {
@@ -57,6 +63,11 @@ export function registerEdit(program: Command): void {
57
63
  .option("--password-env <name>", "Read password from environment variable")
58
64
  .option("--password-generate", "Generate the password; it is never printed")
59
65
  .option("--generate-length <n>", "Length for --password-generate", parseInt)
66
+ .option("--totp <secret>", "Set TOTP secret: base32 or otpauth:// URI")
67
+ .option("--totp-stdin", "Read the TOTP secret from stdin")
68
+ .option("--totp-file <path>", "Read the TOTP secret from file (use - for stdin)")
69
+ .option("--totp-env <name>", "Read the TOTP secret from environment variable")
70
+ .option("--rm-totp", "Remove the stored TOTP secret")
60
71
  .option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
61
72
  .option("--add-field <kv>", "Add/update field k=v", collect, [])
62
73
  .option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
@@ -91,6 +102,7 @@ export function registerEdit(program: Command): void {
91
102
  encoded = bwEncode(stdinText);
92
103
  } else {
93
104
  assertLoginFlags(localOpts, current);
105
+ assertTotpIntent(localOpts);
94
106
 
95
107
  assertSingleStdinSource(
96
108
  listExplicitStdinInputs({
@@ -119,6 +131,7 @@ export function registerEdit(program: Command): void {
119
131
  notes: textInputs.notes,
120
132
  username: localOpts.username,
121
133
  password: textInputs.password,
134
+ totp: localOpts.rmTotp ? null : textInputs.totp,
122
135
  uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
123
136
  addFields,
124
137
  rmFields: localOpts.rmField,
@@ -145,6 +158,28 @@ export function registerEdit(program: Command): void {
145
158
  });
146
159
  }
147
160
 
161
+ /**
162
+ * Setting and clearing the same secret in one call has no sensible winner, and
163
+ * picking one silently would either drop a seed or fail to remove one.
164
+ */
165
+ function assertTotpIntent(localOpts: EditOptions): void {
166
+ if (!localOpts.rmTotp) return;
167
+
168
+ const setters = [
169
+ localOpts.totp !== undefined ? "--totp" : null,
170
+ localOpts.totpStdin ? "--totp-stdin" : null,
171
+ localOpts.totpFile !== undefined ? "--totp-file" : null,
172
+ localOpts.totpEnv !== undefined ? "--totp-env" : null,
173
+ ].filter((flag): flag is string => flag !== null);
174
+
175
+ if (setters.length > 0) {
176
+ throw new CliError(
177
+ `--rm-totp cannot be combined with ${setters.join(", ")}.`,
178
+ ExitCode.BadArgs,
179
+ );
180
+ }
181
+ }
182
+
148
183
  /**
149
184
  * Same trap as `create`: only a login item has somewhere to put a password, so
150
185
  * setting one on a note would be accepted and then quietly lost.
@@ -1,8 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { getGlobalOpts } from "../program.ts";
3
- import { emitData } from "../io.ts";
4
- import { readItemField } from "../../bw/fields.ts";
5
- import { CliError } from "../errors.ts";
3
+ import { emitData, emitLog } from "../io.ts";
4
+ import { readItemField, readTotp, readTotpSecret } from "../../bw/fields.ts";
5
+ import { CliError, ExitCode } from "../errors.ts";
6
+ import type { GlobalOptions } from "../types.ts";
7
+
8
+ /** Seconds left below which `--fresh` waits, when the flag is given bare. */
9
+ const DEFAULT_FRESH_SECONDS = 5;
6
10
 
7
11
  export function registerGet(program: Command): void {
8
12
  program
@@ -10,8 +14,26 @@ export function registerGet(program: Command): void {
10
14
  .description("Get a field from a vault item (built-in or custom)")
11
15
  .argument("<field>", "Field: password | username | totp | notes | uri | item | <custom>")
12
16
  .argument("<item>", "Item name or ID")
13
- .action(async function (this: Command, field: string, item: string) {
17
+ .option(
18
+ "--fresh [seconds]",
19
+ `Wait for the next TOTP window if fewer than N seconds remain (default ${DEFAULT_FRESH_SECONDS})`,
20
+ )
21
+ .option("--seed", "Emit the stored TOTP secret instead of a code")
22
+ .action(async function (
23
+ this: Command,
24
+ field: string,
25
+ item: string,
26
+ localOpts: { fresh?: string | boolean; seed?: boolean },
27
+ ) {
14
28
  const opts = getGlobalOpts(this);
29
+
30
+ if (field === "totp") {
31
+ await emitTotp(item, localOpts, opts);
32
+ return;
33
+ }
34
+
35
+ assertTotpOnlyFlags(field, localOpts);
36
+
15
37
  const value = await readItemField(field, item, opts);
16
38
  // `item` is the one field that is itself JSON — emit it as structure.
17
39
  emitData(field === "item" ? JSON.parse(value) : value, opts);
@@ -37,6 +59,83 @@ export function registerGet(program: Command): void {
37
59
  });
38
60
  }
39
61
 
62
+ /**
63
+ * Emits a TOTP alongside how long it stays valid. The remainder rides in the
64
+ * `--json` envelope's `meta` rather than replacing `data`, so `data` is still
65
+ * the plain code every other field returns and existing parsers keep working.
66
+ */
67
+ async function emitTotp(
68
+ item: string,
69
+ localOpts: { fresh?: string | boolean; seed?: boolean },
70
+ opts: GlobalOptions,
71
+ ): Promise<void> {
72
+ if (localOpts.seed) {
73
+ if (localOpts.fresh !== undefined) {
74
+ throw new CliError(
75
+ "--seed and --fresh cannot be combined: a stored secret has no window.",
76
+ ExitCode.BadArgs,
77
+ );
78
+ }
79
+ emitData(await readTotpSecret(item, opts), opts);
80
+ return;
81
+ }
82
+
83
+ const threshold = parseFreshSeconds(localOpts.fresh);
84
+ let totp = await readTotp(item, opts);
85
+
86
+ if (threshold !== null && totp.secondsRemaining < threshold) {
87
+ const wait = totp.secondsRemaining;
88
+ emitLog(`Waiting ${wait}s for a fresh code…`, opts);
89
+ await Bun.sleep(wait * 1000 + 250);
90
+ totp = await readTotp(item, opts);
91
+ }
92
+
93
+ emitLog(`Valid for ${totp.secondsRemaining}s`, opts);
94
+ emitData(totp.code, opts, {
95
+ secondsRemaining: totp.secondsRemaining,
96
+ period: totp.period,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * `--fresh` is a bare flag by default and takes an explicit threshold when the
102
+ * caller needs longer than {@link DEFAULT_FRESH_SECONDS} to use the code.
103
+ */
104
+ function parseFreshSeconds(raw: string | boolean | undefined): number | null {
105
+ if (raw === undefined || raw === false) return null;
106
+ if (raw === true) return DEFAULT_FRESH_SECONDS;
107
+
108
+ const value = Number(raw.trim());
109
+ if (!Number.isInteger(value) || value < 0) {
110
+ throw new CliError(
111
+ `Invalid --fresh "${raw}". Use a non-negative integer of seconds.`,
112
+ ExitCode.BadArgs,
113
+ );
114
+ }
115
+ return value;
116
+ }
117
+
118
+ /**
119
+ * Accepting `--fresh` on a password would read as "give me a fresh password",
120
+ * which is not what it does. Rejecting it beats quietly ignoring it.
121
+ */
122
+ function assertTotpOnlyFlags(
123
+ field: string,
124
+ localOpts: { fresh?: string | boolean; seed?: boolean },
125
+ ): void {
126
+ const used = [
127
+ localOpts.fresh !== undefined ? "--fresh" : null,
128
+ localOpts.seed ? "--seed" : null,
129
+ ].filter((flag): flag is string => flag !== null);
130
+
131
+ if (used.length > 0) {
132
+ throw new CliError(
133
+ `${used.join(", ")} ${used.length > 1 ? "apply" : "applies"} to 'get totp', not '${field}'.`,
134
+ ExitCode.BadArgs,
135
+ );
136
+ }
137
+ }
138
+
40
139
  /**
41
140
  * `bwx field` took `<item> <name>` until 0.5.0. A caller using the old order
42
141
  * fails somewhere unhelpful — a missing item, or a missing field on the wrong
package/src/cli/input.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseFieldFlag, type ParsedField } from "../bw/encoding.ts";
2
+ import { normalizeTotpInput } from "../bw/totp.ts";
2
3
  import { CliError, ExitCode } from "./errors.ts";
3
4
 
4
5
  export function collect(val: string, prev: string[]): string[] {
@@ -59,6 +60,10 @@ export interface ItemTextInputs {
59
60
  passwordFile?: string;
60
61
  passwordEnv?: string;
61
62
  passwordGenerate?: boolean;
63
+ totp?: string;
64
+ totpStdin?: boolean;
65
+ totpFile?: string;
66
+ totpEnv?: string;
62
67
  }
63
68
 
64
69
  export interface ResolvedItemTextInputs {
@@ -66,6 +71,8 @@ export interface ResolvedItemTextInputs {
66
71
  password?: string;
67
72
  /** True when the password was generated, so callers can keep it unprinted. */
68
73
  passwordGenerated?: boolean;
74
+ /** Normalized and validated by {@link normalizeTotpInput} before it is stored. */
75
+ totp?: string;
69
76
  }
70
77
 
71
78
  export interface ExplicitStdinInputs extends ItemTextInputs {
@@ -81,6 +88,7 @@ const PASSWORD_SOURCE_FLAGS = [
81
88
  "--password-env",
82
89
  "--password-generate",
83
90
  ];
91
+ const TOTP_SOURCE_FLAGS = ["--totp", "--totp-stdin", "--totp-file", "--totp-env"];
84
92
 
85
93
  export async function resolveItemTextInputs(
86
94
  inputs: ItemTextInputs,
@@ -89,7 +97,7 @@ export async function resolveItemTextInputs(
89
97
  ): Promise<ResolvedItemTextInputs> {
90
98
  assertSingleStdinSource(listExplicitStdinInputs(inputs));
91
99
 
92
- const [notes, password] = await Promise.all([
100
+ const [notes, password, totp] = await Promise.all([
93
101
  resolveOptionalTextSource(
94
102
  {
95
103
  label: "notes",
@@ -115,12 +123,26 @@ export async function resolveItemTextInputs(
115
123
  },
116
124
  stdin,
117
125
  ),
126
+ resolveOptionalTextSource(
127
+ {
128
+ label: "TOTP secret",
129
+ inline: inputs.totp,
130
+ stdin: inputs.totpStdin,
131
+ file: inputs.totpFile,
132
+ env: inputs.totpEnv,
133
+ flagNames: TOTP_SOURCE_FLAGS,
134
+ },
135
+ stdin,
136
+ ),
118
137
  ]);
119
138
 
120
139
  return {
121
140
  notes,
122
141
  password,
123
142
  passwordGenerated: inputs.passwordGenerate ?? false,
143
+ // Validated here rather than at the call sites so every write path gets
144
+ // the check: a malformed seed is only ever noticed at the login it breaks.
145
+ totp: totp === undefined ? undefined : normalizeTotpInput(totp),
124
146
  };
125
147
  }
126
148
 
@@ -129,6 +151,8 @@ export function listExplicitStdinInputs(inputs: ExplicitStdinInputs): string[] {
129
151
  inputs.notesFile === "-" ? "--notes-file -" : null,
130
152
  inputs.passwordStdin ? "--password-stdin" : null,
131
153
  inputs.passwordFile === "-" ? "--password-file -" : null,
154
+ inputs.totpStdin ? "--totp-stdin" : null,
155
+ inputs.totpFile === "-" ? "--totp-file -" : null,
132
156
  ...(inputs.fieldFileFlag && inputs.fieldFiles
133
157
  ? stdinSourcesForFieldFiles(inputs.fieldFileFlag, inputs.fieldFiles)
134
158
  : []),
package/src/cli/items.ts CHANGED
@@ -107,12 +107,14 @@ export function writeItems(
107
107
  }
108
108
 
109
109
  function plainLine(summary: ItemSummary): string {
110
- return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}`;
110
+ const totp = summary.hasTotp ? "totp" : "-";
111
+ return `${summary.id}\t${summary.type}\t${summary.name}\t${summary.username ?? "-"}\t${totp}`;
111
112
  }
112
113
 
113
114
  function humanLine(summary: ItemSummary): string {
114
115
  const userPart = summary.username ? pc.dim(` (${summary.username})`) : "";
115
- return `${pc.dim(summary.id)} ${pc.cyan(summary.type)} ${summary.name}${userPart}`;
116
+ const totpPart = summary.hasTotp ? " " + pc.yellow("2fa") : "";
117
+ return `${pc.dim(summary.id)} ${pc.cyan(summary.type)} ${summary.name}${userPart}${totpPart}`;
116
118
  }
117
119
 
118
120
  function writeLines<T>(values: T[], format: (value: T) => string): void {