bwx 0.4.1 → 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 +136 -10
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +107 -14
- package/src/bw/encoding.ts +16 -4
- package/src/bw/fields.ts +326 -27
- package/src/bw/folders.ts +96 -0
- package/src/bw/generate.ts +91 -0
- package/src/bw/items.ts +8 -0
- package/src/bw/session.ts +36 -10
- package/src/bw/totp.ts +246 -0
- package/src/cli/commands/config.ts +1 -10
- package/src/cli/commands/create.ts +60 -5
- package/src/cli/commands/delete.ts +48 -21
- package/src/cli/commands/edit.ts +88 -5
- package/src/cli/commands/folders.ts +34 -0
- package/src/cli/commands/generate.ts +57 -0
- package/src/cli/commands/get.ts +130 -7
- package/src/cli/commands/list.ts +25 -8
- package/src/cli/commands/run.ts +14 -9
- package/src/cli/commands/search.ts +16 -12
- package/src/cli/commands/status.ts +32 -5
- package/src/cli/commands/trash.ts +92 -0
- package/src/cli/input.ts +55 -2
- package/src/cli/io.ts +12 -2
- package/src/cli/items.ts +56 -10
- package/src/cli/program.ts +6 -0
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
loadConfig,
|
|
8
8
|
saveConfig,
|
|
9
9
|
} from "../../config/store.ts";
|
|
10
|
+
import { readLine } from "../input.ts";
|
|
10
11
|
|
|
11
12
|
export function registerConfig(program: Command): void {
|
|
12
13
|
const configCmd = program
|
|
@@ -161,13 +162,3 @@ function redactSensitive(value: unknown): unknown {
|
|
|
161
162
|
function isSensitiveKey(key: string): boolean {
|
|
162
163
|
return SENSITIVE_KEY_RE.some((pattern) => pattern.test(key));
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
-
async function readLine(): Promise<string> {
|
|
166
|
-
const chunks: Uint8Array[] = [];
|
|
167
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
168
|
-
chunks.push(chunk);
|
|
169
|
-
const text = Buffer.concat(chunks).toString();
|
|
170
|
-
if (text.includes("\n")) return text.split("\n")[0]!.trim();
|
|
171
|
-
}
|
|
172
|
-
return Buffer.concat(chunks).toString().trim();
|
|
173
|
-
}
|
|
@@ -5,6 +5,8 @@ import { CliError, ExitCode } from "../errors.ts";
|
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
7
|
import { buildNewItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import { generateSecret } from "../../bw/generate.ts";
|
|
9
|
+
import { resolveFolderRef } from "../../bw/folders.ts";
|
|
8
10
|
import {
|
|
9
11
|
assertSingleStdinSource,
|
|
10
12
|
collect,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
StdinReader,
|
|
16
18
|
type ItemTextInputs,
|
|
17
19
|
} from "../input.ts";
|
|
20
|
+
import { redactGeneratedPassword } from "./generate.ts";
|
|
18
21
|
|
|
19
22
|
interface CreateOptions extends ItemTextInputs {
|
|
20
23
|
type: string;
|
|
@@ -27,8 +30,23 @@ interface CreateOptions extends ItemTextInputs {
|
|
|
27
30
|
folder?: string;
|
|
28
31
|
favorite?: boolean;
|
|
29
32
|
fromJson?: boolean;
|
|
33
|
+
generateLength?: number;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/** Flags a secure note has nowhere to store — see {@link assertLoginFlags}. */
|
|
37
|
+
const LOGIN_ONLY_FLAGS: Array<[keyof CreateOptions, string]> = [
|
|
38
|
+
["username", "--username"],
|
|
39
|
+
["password", "--password"],
|
|
40
|
+
["passwordStdin", "--password-stdin"],
|
|
41
|
+
["passwordFile", "--password-file"],
|
|
42
|
+
["passwordEnv", "--password-env"],
|
|
43
|
+
["passwordGenerate", "--password-generate"],
|
|
44
|
+
["totp", "--totp"],
|
|
45
|
+
["totpStdin", "--totp-stdin"],
|
|
46
|
+
["totpFile", "--totp-file"],
|
|
47
|
+
["totpEnv", "--totp-env"],
|
|
48
|
+
];
|
|
49
|
+
|
|
32
50
|
export function registerCreate(program: Command): void {
|
|
33
51
|
program
|
|
34
52
|
.command("create").alias("add")
|
|
@@ -42,11 +60,17 @@ export function registerCreate(program: Command): void {
|
|
|
42
60
|
.option("--password-stdin", "Read password from stdin")
|
|
43
61
|
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
44
62
|
.option("--password-env <name>", "Read password from environment variable")
|
|
63
|
+
.option("--password-generate", "Generate the password; it is never printed")
|
|
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")
|
|
45
69
|
.option("--uri <url>", "URI (repeatable)", collect, [])
|
|
46
70
|
.option("--field <kv>", "Custom field k=v (repeatable)", collect, [])
|
|
47
71
|
.option("--field-file <kv>", "Custom field value from file k=path (repeatable)", collect, [])
|
|
48
72
|
.option("--field-env <kv>", "Custom field value from env var k=ENV (repeatable)", collect, [])
|
|
49
|
-
.option("--folder <id>", "Folder ID")
|
|
73
|
+
.option("--folder <name|id>", "Folder name or ID")
|
|
50
74
|
.option("--favorite", "Mark as favorite")
|
|
51
75
|
.option("--from-json", "Read full item JSON from stdin")
|
|
52
76
|
.action(async function (
|
|
@@ -57,6 +81,7 @@ export function registerCreate(program: Command): void {
|
|
|
57
81
|
const stdin = new StdinReader();
|
|
58
82
|
|
|
59
83
|
let encoded: string;
|
|
84
|
+
let generated = false;
|
|
60
85
|
|
|
61
86
|
if (localOpts.fromJson) {
|
|
62
87
|
const stdinText = await stdin.read();
|
|
@@ -82,6 +107,8 @@ export function registerCreate(program: Command): void {
|
|
|
82
107
|
);
|
|
83
108
|
}
|
|
84
109
|
|
|
110
|
+
assertLoginFlags(localOpts);
|
|
111
|
+
|
|
85
112
|
const explicitStdinSources = listExplicitStdinInputs({
|
|
86
113
|
...localOpts,
|
|
87
114
|
fieldFileFlag: "--field-file",
|
|
@@ -89,7 +116,10 @@ export function registerCreate(program: Command): void {
|
|
|
89
116
|
});
|
|
90
117
|
assertSingleStdinSource(explicitStdinSources);
|
|
91
118
|
|
|
92
|
-
const textInputs = await resolveItemTextInputs(localOpts, stdin)
|
|
119
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin, () =>
|
|
120
|
+
generateSecret({ length: localOpts.generateLength }),
|
|
121
|
+
);
|
|
122
|
+
generated = textInputs.passwordGenerated ?? false;
|
|
93
123
|
let notes = textInputs.notes;
|
|
94
124
|
|
|
95
125
|
// Backward compatibility: piped create input becomes note content.
|
|
@@ -117,9 +147,12 @@ export function registerCreate(program: Command): void {
|
|
|
117
147
|
notes: notes ?? null,
|
|
118
148
|
username: localOpts.username ?? null,
|
|
119
149
|
password: textInputs.password ?? null,
|
|
150
|
+
totp: textInputs.totp ?? null,
|
|
120
151
|
uris: localOpts.uri,
|
|
121
152
|
fields,
|
|
122
|
-
folderId: localOpts.folder
|
|
153
|
+
folderId: localOpts.folder
|
|
154
|
+
? await resolveFolderRef(localOpts.folder, opts)
|
|
155
|
+
: null,
|
|
123
156
|
favorite: localOpts.favorite ?? false,
|
|
124
157
|
});
|
|
125
158
|
|
|
@@ -132,11 +165,33 @@ export function registerCreate(program: Command): void {
|
|
|
132
165
|
|
|
133
166
|
const created = JSON.parse(result);
|
|
134
167
|
if (opts.json) {
|
|
135
|
-
emitData(created, opts);
|
|
168
|
+
emitData(redactGeneratedPassword(created, generated), opts);
|
|
136
169
|
} else if (opts.plain) {
|
|
137
170
|
emitData(created.id, opts);
|
|
138
171
|
} else {
|
|
139
|
-
|
|
172
|
+
const note = generated ? " — password generated (not printed)" : "";
|
|
173
|
+
emitSuccess(`Created "${created.name}" (${created.id})${note}`, opts);
|
|
140
174
|
}
|
|
141
175
|
});
|
|
142
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A secure note has no login object, so `buildNewItem` drops usernames,
|
|
180
|
+
* passwords, and URIs on the floor. Accepting them silently means a caller can
|
|
181
|
+
* "store" a credential, get exit 0, and end up with an item that never held it.
|
|
182
|
+
*/
|
|
183
|
+
function assertLoginFlags(localOpts: CreateOptions): void {
|
|
184
|
+
if (localOpts.type === "login") return;
|
|
185
|
+
|
|
186
|
+
const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
|
|
187
|
+
([, flag]) => flag,
|
|
188
|
+
);
|
|
189
|
+
if (localOpts.uri.length > 0) used.push("--uri");
|
|
190
|
+
|
|
191
|
+
if (used.length > 0) {
|
|
192
|
+
throw new CliError(
|
|
193
|
+
`${used.join(", ")} ${used.length > 1 ? "need" : "needs"} --type login (a note cannot store them).`,
|
|
194
|
+
ExitCode.BadArgs,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -4,6 +4,8 @@ import { emitData, emitSuccess } from "../io.ts";
|
|
|
4
4
|
import { CliError, ExitCode } from "../errors.ts";
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
|
+
import { readLine } from "../input.ts";
|
|
8
|
+
import type { GlobalOptions } from "../types.ts";
|
|
7
9
|
import pc from "picocolors";
|
|
8
10
|
|
|
9
11
|
export function registerDelete(program: Command): void {
|
|
@@ -12,7 +14,7 @@ export function registerDelete(program: Command): void {
|
|
|
12
14
|
.description("Delete a vault item")
|
|
13
15
|
.argument("<item>", "Item name or ID")
|
|
14
16
|
.option("--permanent", "Permanently delete (no trash)")
|
|
15
|
-
.option("--force", "Skip confirmation prompt")
|
|
17
|
+
.option("--force", "Skip confirmation prompt (required when not interactive)")
|
|
16
18
|
.action(async function (
|
|
17
19
|
this: Command,
|
|
18
20
|
item: string,
|
|
@@ -28,17 +30,7 @@ export function registerDelete(program: Command): void {
|
|
|
28
30
|
const itemId: string = current.id;
|
|
29
31
|
const itemName: string = current.name;
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
if (!localOpts.force && !opts.json && !opts.plain && process.stdin.isTTY) {
|
|
33
|
-
const label = localOpts.permanent ? "permanently delete" : "delete";
|
|
34
|
-
process.stderr.write(
|
|
35
|
-
`${pc.yellow("?")} ${label} "${itemName}" (${itemId})? [y/N] `,
|
|
36
|
-
);
|
|
37
|
-
const answer = await readLine();
|
|
38
|
-
if (answer.toLowerCase() !== "y") {
|
|
39
|
-
throw new CliError("Cancelled", ExitCode.UserCancelled);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
33
|
+
await confirmDelete(itemName, itemId, localOpts, opts);
|
|
42
34
|
|
|
43
35
|
const args = ["delete", "item", itemId];
|
|
44
36
|
if (localOpts.permanent) args.push("--permanent");
|
|
@@ -46,22 +38,57 @@ export function registerDelete(program: Command): void {
|
|
|
46
38
|
await withSession(opts, () => runBwOrThrow(args));
|
|
47
39
|
|
|
48
40
|
if (opts.json) {
|
|
49
|
-
emitData(
|
|
41
|
+
emitData(
|
|
42
|
+
{
|
|
43
|
+
id: itemId,
|
|
44
|
+
name: itemName,
|
|
45
|
+
deleted: true,
|
|
46
|
+
permanent: localOpts.permanent ?? false,
|
|
47
|
+
},
|
|
48
|
+
opts,
|
|
49
|
+
);
|
|
50
50
|
} else if (opts.plain) {
|
|
51
51
|
emitData(itemId, opts);
|
|
52
52
|
} else {
|
|
53
53
|
const label = localOpts.permanent ? "Permanently deleted" : "Deleted";
|
|
54
|
-
|
|
54
|
+
const undo = localOpts.permanent
|
|
55
|
+
? ""
|
|
56
|
+
: ` — undo with ${pc.dim(`bwx restore ${itemId}`)}`;
|
|
57
|
+
emitSuccess(`${label} "${itemName}" (${itemId})${undo}`, opts);
|
|
55
58
|
}
|
|
56
59
|
});
|
|
60
|
+
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* A prompt only guards a human at a terminal. The common caller here is a script
|
|
65
|
+
* or an agent, where the prompt never renders — so anything non-interactive must
|
|
66
|
+
* say `--force` out loud instead of being deleted on the strength of a flag that
|
|
67
|
+
* silently did nothing.
|
|
68
|
+
*/
|
|
69
|
+
async function confirmDelete(
|
|
70
|
+
itemName: string,
|
|
71
|
+
itemId: string,
|
|
72
|
+
localOpts: { permanent?: boolean; force?: boolean },
|
|
73
|
+
opts: GlobalOptions,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
if (localOpts.force) return;
|
|
76
|
+
|
|
77
|
+
const interactive = process.stdin.isTTY && !opts.json && !opts.plain;
|
|
78
|
+
const label = localOpts.permanent ? "permanently delete" : "delete";
|
|
79
|
+
|
|
80
|
+
if (!interactive) {
|
|
81
|
+
throw new CliError(
|
|
82
|
+
`Refusing to ${label} "${itemName}" (${itemId}) without confirmation. Pass --force.`,
|
|
83
|
+
ExitCode.BadArgs,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
`${pc.yellow("?")} ${label} "${itemName}" (${itemId})? [y/N] `,
|
|
89
|
+
);
|
|
90
|
+
const answer = await readLine();
|
|
91
|
+
if (answer.toLowerCase() !== "y") {
|
|
92
|
+
throw new CliError("Cancelled", ExitCode.UserCancelled);
|
|
65
93
|
}
|
|
66
|
-
return Buffer.concat(chunks).toString().trim();
|
|
67
94
|
}
|
package/src/cli/commands/edit.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { CliError, ExitCode } from "../errors.ts";
|
|
|
5
5
|
import { runBwOrThrow } from "../../bw/runner.ts";
|
|
6
6
|
import { withSession } from "../../bw/session.ts";
|
|
7
7
|
import { patchItem, bwEncode } from "../../bw/encoding.ts";
|
|
8
|
+
import { generateSecret } from "../../bw/generate.ts";
|
|
9
|
+
import { resolveFolderRef } from "../../bw/folders.ts";
|
|
10
|
+
import { BwItemType } from "../../bw/types.ts";
|
|
8
11
|
import {
|
|
9
12
|
assertSingleStdinSource,
|
|
10
13
|
collect,
|
|
@@ -14,6 +17,7 @@ import {
|
|
|
14
17
|
StdinReader,
|
|
15
18
|
type ItemTextInputs,
|
|
16
19
|
} from "../input.ts";
|
|
20
|
+
import { redactGeneratedPassword } from "./generate.ts";
|
|
17
21
|
|
|
18
22
|
interface EditOptions extends ItemTextInputs {
|
|
19
23
|
name?: string;
|
|
@@ -23,11 +27,27 @@ interface EditOptions extends ItemTextInputs {
|
|
|
23
27
|
addFieldFile: string[];
|
|
24
28
|
addFieldEnv: string[];
|
|
25
29
|
rmField: string[];
|
|
30
|
+
rmTotp?: boolean;
|
|
26
31
|
folder?: string;
|
|
27
32
|
favorite?: boolean;
|
|
28
33
|
fromJson?: boolean;
|
|
34
|
+
generateLength?: number;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
const LOGIN_ONLY_FLAGS: Array<[keyof EditOptions, string]> = [
|
|
38
|
+
["username", "--username"],
|
|
39
|
+
["password", "--password"],
|
|
40
|
+
["passwordStdin", "--password-stdin"],
|
|
41
|
+
["passwordFile", "--password-file"],
|
|
42
|
+
["passwordEnv", "--password-env"],
|
|
43
|
+
["passwordGenerate", "--password-generate"],
|
|
44
|
+
["totp", "--totp"],
|
|
45
|
+
["totpStdin", "--totp-stdin"],
|
|
46
|
+
["totpFile", "--totp-file"],
|
|
47
|
+
["totpEnv", "--totp-env"],
|
|
48
|
+
["rmTotp", "--rm-totp"],
|
|
49
|
+
];
|
|
50
|
+
|
|
31
51
|
export function registerEdit(program: Command): void {
|
|
32
52
|
program
|
|
33
53
|
.command("edit")
|
|
@@ -41,12 +61,19 @@ export function registerEdit(program: Command): void {
|
|
|
41
61
|
.option("--password-stdin", "Read password from stdin")
|
|
42
62
|
.option("--password-file <path>", "Read password from file (use - for stdin)")
|
|
43
63
|
.option("--password-env <name>", "Read password from environment variable")
|
|
64
|
+
.option("--password-generate", "Generate the password; it is never printed")
|
|
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")
|
|
44
71
|
.option("--uri <url>", "Set URIs (repeatable, replaces all)", collect, [])
|
|
45
72
|
.option("--add-field <kv>", "Add/update field k=v", collect, [])
|
|
46
73
|
.option("--add-field-file <kv>", "Add/update field value from file k=path", collect, [])
|
|
47
74
|
.option("--add-field-env <kv>", "Add/update field value from env var k=ENV", collect, [])
|
|
48
75
|
.option("--rm-field <name>", "Remove field by name", collect, [])
|
|
49
|
-
.option("--folder <id>", "Move to folder")
|
|
76
|
+
.option("--folder <name|id>", "Move to folder ('none' to unfile)")
|
|
50
77
|
.option("--favorite", "Set favorite")
|
|
51
78
|
.option("--no-favorite", "Unset favorite")
|
|
52
79
|
.option("--from-json", "Read full item JSON from stdin (replaces item)")
|
|
@@ -57,6 +84,7 @@ export function registerEdit(program: Command): void {
|
|
|
57
84
|
) {
|
|
58
85
|
const opts = getGlobalOpts(this);
|
|
59
86
|
const stdin = new StdinReader();
|
|
87
|
+
let generated = false;
|
|
60
88
|
|
|
61
89
|
const result = await withSession(opts, async () => {
|
|
62
90
|
// Fetch current item
|
|
@@ -73,6 +101,9 @@ export function registerEdit(program: Command): void {
|
|
|
73
101
|
}
|
|
74
102
|
encoded = bwEncode(stdinText);
|
|
75
103
|
} else {
|
|
104
|
+
assertLoginFlags(localOpts, current);
|
|
105
|
+
assertTotpIntent(localOpts);
|
|
106
|
+
|
|
76
107
|
assertSingleStdinSource(
|
|
77
108
|
listExplicitStdinInputs({
|
|
78
109
|
...localOpts,
|
|
@@ -81,7 +112,10 @@ export function registerEdit(program: Command): void {
|
|
|
81
112
|
}),
|
|
82
113
|
);
|
|
83
114
|
|
|
84
|
-
const textInputs = await resolveItemTextInputs(localOpts, stdin)
|
|
115
|
+
const textInputs = await resolveItemTextInputs(localOpts, stdin, () =>
|
|
116
|
+
generateSecret({ length: localOpts.generateLength }),
|
|
117
|
+
);
|
|
118
|
+
generated = textInputs.passwordGenerated ?? false;
|
|
85
119
|
|
|
86
120
|
const addFields = await resolveFieldSources({
|
|
87
121
|
inline: localOpts.addField,
|
|
@@ -97,10 +131,13 @@ export function registerEdit(program: Command): void {
|
|
|
97
131
|
notes: textInputs.notes,
|
|
98
132
|
username: localOpts.username,
|
|
99
133
|
password: textInputs.password,
|
|
134
|
+
totp: localOpts.rmTotp ? null : textInputs.totp,
|
|
100
135
|
uris: localOpts.uri.length > 0 ? localOpts.uri : undefined,
|
|
101
136
|
addFields,
|
|
102
137
|
rmFields: localOpts.rmField,
|
|
103
|
-
folderId: localOpts.folder
|
|
138
|
+
folderId: localOpts.folder
|
|
139
|
+
? await resolveFolderRef(localOpts.folder, opts)
|
|
140
|
+
: undefined,
|
|
104
141
|
favorite: localOpts.favorite,
|
|
105
142
|
});
|
|
106
143
|
encoded = bwEncode(JSON.stringify(patched));
|
|
@@ -111,11 +148,57 @@ export function registerEdit(program: Command): void {
|
|
|
111
148
|
|
|
112
149
|
const updated = JSON.parse(result);
|
|
113
150
|
if (opts.json) {
|
|
114
|
-
emitData(updated, opts);
|
|
151
|
+
emitData(redactGeneratedPassword(updated, generated), opts);
|
|
115
152
|
} else if (opts.plain) {
|
|
116
153
|
emitData(updated.id, opts);
|
|
117
154
|
} else {
|
|
118
|
-
|
|
155
|
+
const note = generated ? " — password generated (not printed)" : "";
|
|
156
|
+
emitSuccess(`Updated "${updated.name}" (${updated.id})${note}`, opts);
|
|
119
157
|
}
|
|
120
158
|
});
|
|
121
159
|
}
|
|
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
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Same trap as `create`: only a login item has somewhere to put a password, so
|
|
185
|
+
* setting one on a note would be accepted and then quietly lost.
|
|
186
|
+
*/
|
|
187
|
+
function assertLoginFlags(
|
|
188
|
+
localOpts: EditOptions,
|
|
189
|
+
current: Record<string, unknown>,
|
|
190
|
+
): void {
|
|
191
|
+
if (current.type === BwItemType.Login) return;
|
|
192
|
+
|
|
193
|
+
const used = LOGIN_ONLY_FLAGS.filter(([key]) => Boolean(localOpts[key])).map(
|
|
194
|
+
([, flag]) => flag,
|
|
195
|
+
);
|
|
196
|
+
if (localOpts.uri.length > 0) used.push("--uri");
|
|
197
|
+
|
|
198
|
+
if (used.length === 0) return;
|
|
199
|
+
|
|
200
|
+
throw new CliError(
|
|
201
|
+
`${used.join(", ")} ${used.length > 1 ? "need" : "needs"} a login item, but "${String(current.name)}" is not one.`,
|
|
202
|
+
ExitCode.BadArgs,
|
|
203
|
+
);
|
|
204
|
+
}
|