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/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 {
@@ -8,5 +8,7 @@ export const CONFIG_DIR = `${CONFIG_HOME}/bwx`;
8
8
  export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
9
9
  /** Cached `bw` session token (owner-only). */
10
10
  export const SESSION_FILE = `${CONFIG_DIR}/session`;
11
+ /** Cross-process gate used only while establishing a session. */
12
+ export const SESSION_LOCK_DIR = `${CONFIG_DIR}/session.lock`;
11
13
  /** Cached vault-freshness snapshot — see bw/freshness.ts. */
12
14
  export const STATE_FILE = `${CONFIG_DIR}/state.json`;