@redaktyn/cli 0.0.0-stage → 1.1.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.
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ┌─────────────────────────────────────────────────────────────────┐
4
+ * │ Redaktyn Fingerprint CLI · Zero-Knowledge Secret Digest │
5
+ * │ Generates HMAC-SHA256 fingerprints locally — nothing leaves │
6
+ * │ your machine. Built on Node.js built-in crypto only. │
7
+ * └─────────────────────────────────────────────────────────────────┘
8
+ *
9
+ * Usage (interactive wizard):
10
+ * node redaktyn-fingerprint.mjs
11
+ *
12
+ * Usage (non-interactive / scripting):
13
+ * node redaktyn-fingerprint.mjs \
14
+ * --org-id <uuid> \
15
+ * --label "AWS_SECRET_KEY" \
16
+ * --secret "AKIAIOSFODNN7EXAMPLE..." \
17
+ * [--passphrase "your-org-passphrase"] # prefer env var
18
+ *
19
+ * REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs --org-id ... --label ... --secret ...
20
+ *
21
+ * Batch mode (one label:secret per line in a file):
22
+ * node redaktyn-fingerprint.mjs --batch secrets.txt --org-id <uuid>
23
+ *
24
+ * Flags:
25
+ * --json Output pure JSON (default: human-readable table)
26
+ * --no-color Disable ANSI colors
27
+ * --help Show this help
28
+ *
29
+ * Required Node.js ≥ 18 (uses built-in crypto — NO npm install needed).
30
+ */
31
+
32
+ import { createHmac, pbkdf2 as _pbkdf2, createHash, hkdfSync } from "node:crypto";
33
+ import { promisify } from "node:util";
34
+ import * as readline from "node:readline";
35
+ import { readFileSync, existsSync } from "node:fs";
36
+ import { pathToFileURL } from "node:url";
37
+
38
+ const pbkdf2 = promisify(_pbkdf2);
39
+
40
+ /* ═══════════════════════════════════════════════════════════════
41
+ ANSI colors — disabled with --no-color
42
+ ═══════════════════════════════════════════════════════════════ */
43
+ const NO_COLOR = process.argv.includes("--no-color") || process.env["NO_COLOR"];
44
+ const c = {
45
+ reset: NO_COLOR ? "" : "\x1b[0m",
46
+ bold: NO_COLOR ? "" : "\x1b[1m",
47
+ dim: NO_COLOR ? "" : "\x1b[2m",
48
+ red: NO_COLOR ? "" : "\x1b[31m",
49
+ green: NO_COLOR ? "" : "\x1b[32m",
50
+ yellow: NO_COLOR ? "" : "\x1b[33m",
51
+ blue: NO_COLOR ? "" : "\x1b[34m",
52
+ magenta: NO_COLOR ? "" : "\x1b[35m",
53
+ cyan: NO_COLOR ? "" : "\x1b[36m",
54
+ white: NO_COLOR ? "" : "\x1b[37m",
55
+ gray: NO_COLOR ? "" : "\x1b[90m",
56
+ bgBlue: NO_COLOR ? "" : "\x1b[44m",
57
+ underline: NO_COLOR ? "" : "\x1b[4m",
58
+ };
59
+
60
+ /* ═══════════════════════════════════════════════════════════════
61
+ Crypto — identical algorithm to dashboard & extension
62
+ ═══════════════════════════════════════════════════════════════ */
63
+
64
+ export const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 floor — must match shared/secret-digest.ts
65
+ const PBKDF2_KEYLEN = 32; // 256 bits
66
+
67
+ /**
68
+ * Canonical normalization matching shared/secret-digest.ts:
69
+ * NFC unicode · CRLF→LF · strip invisible Unicode · trim · UUID hex case-fold
70
+ *
71
+ * v2 adds stripping of zero-width and invisible characters that survive NFC
72
+ * and can be used to bypass HMAC detection by inserting invisible chars.
73
+ */
74
+ export function canonicalize(raw) {
75
+ let s = raw.normalize("NFC");
76
+ // Kept byte-identical to INVISIBLE_CHARS_CLASS in shared/secret-digest.ts —
77
+ // this file is standalone and cannot import it, so a guard test compares the
78
+ // two. They had already drifted once.
79
+ s = s.replace(/\r\n/g, "\n").replace(/[\r\u2028\u2029]/g, "\n");
80
+ // v2: strip invisible Unicode (zero-width, bidi marks, invisible operators)
81
+ s = s.replace(/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g, "");
82
+ s = s.trim();
83
+ return foldUuid(s);
84
+ }
85
+
86
+ /** UUID hex case fold: 8-4-4-4-12. Matches foldUuidHexCase() in the shared module. */
87
+ function foldUuid(s) {
88
+ if (
89
+ s.length === 36 && s[8] === "-" && s[13] === "-" &&
90
+ s[18] === "-" && s[23] === "-" &&
91
+ /^[0-9a-fA-F]{32}$/.test(s.replace(/-/g, ""))
92
+ ) {
93
+ return s.toLowerCase();
94
+ }
95
+ return s;
96
+ }
97
+
98
+ /**
99
+ * Derive HMAC key from passphrase + orgId via PBKDF2-SHA256.
100
+ * Matches deriveHmacKeyFromPassphrase() in shared/secret-digest.ts.
101
+ *
102
+ * There was a `--device-id` flag that mixed an extension id into the salt.
103
+ * Anything enrolled through it was unmatchable everywhere — the extension
104
+ * derives from the org id alone, as it must for one digest to be checkable on
105
+ * every device — so the flag could only ever produce a secret that looked
106
+ * enrolled and was never enforced.
107
+ */
108
+ export async function deriveKey(passphrase, orgId) {
109
+ const salt = createHash("sha256").update(orgId, "utf8").digest();
110
+ return pbkdf2(passphrase, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, "sha256");
111
+ }
112
+
113
+ /**
114
+ * A project's HMAC key: HKDF-SHA256(orgKey, salt = projectId, info = the label
115
+ * below). Matches deriveProjectKey() in shared/secret-digest.ts byte for byte.
116
+ * Changing the label changes every project key, so it is the same string, not a
117
+ * look-alike. A project with `usesOrgKey: true` (the default one) hashes under
118
+ * the org key itself and must NOT be passed through this.
119
+ */
120
+ export const PROJECT_KDF_LABEL = "redaktyn-project-key-v1";
121
+ export function deriveProjectKey(orgKey, projectId) {
122
+ if (!projectId) throw new Error("deriveProjectKey: projectId is required");
123
+ return Buffer.from(hkdfSync("sha256", orgKey, Buffer.from(projectId, "utf8"),
124
+ Buffer.from(PROJECT_KDF_LABEL, "utf8"), PBKDF2_KEYLEN));
125
+ }
126
+
127
+ /**
128
+ * Whitespace-collapsed form, matching normalizeSecretPlaintextCollapsed().
129
+ * Note the order: collapse first, then UUID case-fold, exactly as the shared
130
+ * module composes them. Reversing the two changes nothing for most inputs and
131
+ * silently changes the digest for a UUID with stray whitespace.
132
+ */
133
+ function canonicalizeCollapsed(raw) {
134
+ const base = raw
135
+ .normalize("NFC")
136
+ .replace(/\r\n/g, "\n")
137
+ .replace(/[\r\u2028\u2029]/g, "\n")
138
+ .replace(/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g, "")
139
+ .trim()
140
+ .replace(/\s/g, "");
141
+ return foldUuid(base);
142
+ }
143
+
144
+ /** Lowercase-folded form, matching normalizeSecretPlaintextLower(). */
145
+ function canonicalizeLower(raw) {
146
+ return canonicalizeCollapsed(raw).toLowerCase();
147
+ }
148
+
149
+ function hmacHex(text, keyBuf) {
150
+ return createHmac("sha256", keyBuf).update(text, "utf8").digest("hex");
151
+ }
152
+
153
+ /**
154
+ * Keyed scan fingerprints, matching encodeScanPrefilter() in
155
+ * shared/scan-prefilter.ts — mirrored because this file cannot import it, and
156
+ * held to it by a parity test. Lets devices sign only windows that could match
157
+ * this secret; the seed comes from the key, which the server never holds.
158
+ */
159
+ const SCAN_PREFILTER_LABEL = "redaktyn-scan-prefilter-v1";
160
+
161
+ function scanPrefilterFingerprint(base, s) {
162
+ let h = 0;
163
+ for (let i = 0; i < s.length; i++) {
164
+ const c = s.charCodeAt(i);
165
+ h = (Math.imul(h, base) + (c >= 65 && c <= 90 ? c + 32 : c) + 1) | 0;
166
+ }
167
+ return h >>> 16;
168
+ }
169
+
170
+ export function scanPrefilter(signed, keyBuf) {
171
+ const seed = createHmac("sha256", keyBuf).update(SCAN_PREFILTER_LABEL, "utf8").digest();
172
+ const base = seed.readInt32BE(0) | 1;
173
+ const hex4 = (s) => scanPrefilterFingerprint(base, s).toString(16).padStart(4, "0");
174
+ return `v1.${hex4(signed.exact)}${hex4(signed.collapsed)}${hex4(signed.lower)}`;
175
+ }
176
+
177
+ /** Shapes a computeHmac() result into the record every output path consumes. */
178
+ export function fingerprint(label, secret, keyBuf, orgId) {
179
+ const r = computeHmac(secret, keyBuf);
180
+ return {
181
+ label,
182
+ orgId,
183
+ hmacHex: r.digest,
184
+ length: r.length,
185
+ hmacHexStripped: r.hmacHexStripped,
186
+ lengthStripped: r.lengthStripped,
187
+ hmacHexLower: r.hmacHexLower,
188
+ lengthLower: r.lengthLower,
189
+ scanPrefilter: r.scanPrefilter,
190
+ canonical: r.canonical,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Compute all three digest variants the dashboard enrolls.
196
+ *
197
+ * Emitting only `hmacHex` was not merely incomplete — a secret enrolled through
198
+ * this CLI would be caught when pasted verbatim and missed the moment someone
199
+ * added a space or changed a letter's case, because the two variants that cover
200
+ * those cases were never stored. The output shape mirrors POST /api/secrets.
201
+ */
202
+ export function computeHmac(secret, keyBuf) {
203
+ const canonical = canonicalize(secret);
204
+ const collapsed = canonicalizeCollapsed(secret);
205
+ const lowered = canonicalizeLower(secret);
206
+ return {
207
+ digest: hmacHex(canonical, keyBuf),
208
+ canonical,
209
+ length: canonical.length,
210
+ hmacHexStripped: hmacHex(collapsed, keyBuf),
211
+ lengthStripped: collapsed.length,
212
+ hmacHexLower: hmacHex(lowered, keyBuf),
213
+ lengthLower: lowered.length,
214
+ scanPrefilter: scanPrefilter({ exact: canonical, collapsed, lower: lowered }, keyBuf),
215
+ };
216
+ }
217
+
218
+ /* ═══════════════════════════════════════════════════════════════
219
+ Argument parsing
220
+ ═══════════════════════════════════════════════════════════════ */
221
+ function parseArgs(argv) {
222
+ const out = {
223
+ help: false, json: false, noColor: false,
224
+ orgId: null, passphrase: null, secret: null, label: null,
225
+ batch: null,
226
+ allowSecretArgv: false, allowPassphraseArgv: false,
227
+ secretFromArgv: false, passphraseFromArgv: false,
228
+ };
229
+ for (let i = 2; i < argv.length; i++) {
230
+ const a = argv[i];
231
+ if (a === "--help" || a === "-h") { out.help = true; }
232
+ else if (a === "--json") { out.json = true; }
233
+ else if (a === "--no-color") { out.noColor = true; }
234
+ else if (a === "--org-id") { out.orgId = argv[++i] ?? null; }
235
+ else if (a === "--passphrase") { out.passphrase = argv[++i] ?? null; out.passphraseFromArgv = true; }
236
+ else if (a === "--secret") { out.secret = argv[++i] ?? null; out.secretFromArgv = true; }
237
+ else if (a === "--label") { out.label = argv[++i] ?? null; }
238
+ else if (a === "--batch") { out.batch = argv[++i] ?? null; }
239
+ else if (a === "--i-know-this-leaks-to-history") { out.allowSecretArgv = true; out.allowPassphraseArgv = true; }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Refuse to read secrets/passphrases off argv unless the user has explicitly
246
+ * acknowledged the risk. argv is visible to:
247
+ * • shell history (.zsh_history, .bash_history)
248
+ * • `ps`, `/proc/<pid>/cmdline`, container audit logs
249
+ * • CI build logs (most providers redact env vars but not argv)
250
+ *
251
+ * Recommended alternatives:
252
+ * • `REDAKTYN_PASSPHRASE=… node redaktyn-fingerprint.mjs …`
253
+ * • `printf '%s' "$SECRET" | node redaktyn-fingerprint.mjs --label … --org-id …`
254
+ * • Interactive wizard (no flags) — secret is read from a hidden TTY prompt.
255
+ */
256
+ function enforceArgvSecretsPolicy(args) {
257
+ const offenders = [];
258
+ if (args.secretFromArgv && !args.allowSecretArgv) offenders.push("--secret");
259
+ if (args.passphraseFromArgv && !args.allowPassphraseArgv) offenders.push("--passphrase");
260
+ if (offenders.length === 0) return;
261
+ console.error(`${c.red}${c.bold}Refusing to read sensitive material from argv: ${offenders.join(", ")}${c.reset}`);
262
+ console.error("");
263
+ console.error(" argv is recorded in shell history and is visible to other processes.");
264
+ console.error(" Use one of the safer paths instead:");
265
+ console.error("");
266
+ console.error(` ${c.green}# 1) env var (passphrase)${c.reset}`);
267
+ console.error(` REDAKTYN_PASSPHRASE='…' node redaktyn-fingerprint.mjs \\`);
268
+ console.error(` --org-id <uuid> --label MY_KEY`);
269
+ console.error("");
270
+ console.error(` ${c.green}# 2) stdin pipe (secret)${c.reset}`);
271
+ console.error(` printf '%s' "$SECRET" | node redaktyn-fingerprint.mjs \\`);
272
+ console.error(` --org-id <uuid> --label MY_KEY`);
273
+ console.error("");
274
+ console.error(` ${c.green}# 3) interactive wizard (no flags) — hidden TTY prompts${c.reset}`);
275
+ console.error(` node redaktyn-fingerprint.mjs`);
276
+ console.error("");
277
+ console.error(` ${c.dim}If you absolutely must keep using argv (e.g. one-off in an ephemeral`);
278
+ console.error(` shell where history is disabled), pass --i-know-this-leaks-to-history.${c.reset}`);
279
+ process.exit(2);
280
+ }
281
+
282
+ /* ═══════════════════════════════════════════════════════════════
283
+ Terminal helpers
284
+ ═══════════════════════════════════════════════════════════════ */
285
+ const WIDTH = Math.min(process.stdout.columns || 80, 80);
286
+
287
+ function hr(ch = "─") { return c.gray + ch.repeat(WIDTH) + c.reset; }
288
+ function pad(str, w) { return str + " ".repeat(Math.max(0, w - stripAnsi(str).length)); }
289
+ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ""); }
290
+
291
+ function banner() {
292
+ const line1 = `${c.bold}${c.cyan} Redaktyn${c.reset}${c.bold} Fingerprint CLI${c.reset}`;
293
+ const line2 = `${c.gray} Zero-knowledge local secret digest — nothing leaves your machine${c.reset}`;
294
+ console.log();
295
+ console.log(hr("═"));
296
+ console.log(` ${line1}`);
297
+ console.log(` ${line2}`);
298
+ console.log(hr("═"));
299
+ console.log();
300
+ }
301
+
302
+ function step(num, label, detail) {
303
+ console.log(` ${c.blue}${c.bold}[${num}]${c.reset} ${c.bold}${label}${c.reset}`);
304
+ if (detail) console.log(` ${c.gray}${detail}${c.reset}`);
305
+ }
306
+
307
+ function ok(label, value) {
308
+ console.log(` ${c.green}✓${c.reset} ${c.bold}${label}${c.reset} ${c.gray}${value}${c.reset}`);
309
+ }
310
+
311
+ function warn(msg) {
312
+ // stderr, not stdout. `--batch --json` writes the digest array to stdout for
313
+ // a caller to parse, and a skipped line printed to the same stream lands in
314
+ // the middle of it — one malformed line in the batch file and
315
+ // `JSON.parse(stdout)` throws. A warning is a diagnostic either way, so it
316
+ // belongs on the stream that is not the payload.
317
+ console.error(` ${c.yellow}⚠${c.reset} ${c.yellow}${msg}${c.reset}`);
318
+ }
319
+
320
+ function info(msg) {
321
+ console.log(` ${c.cyan}ℹ${c.reset} ${c.gray}${msg}${c.reset}`);
322
+ }
323
+
324
+ /** Simple terminal spinner */
325
+ function spinner(msg) {
326
+ if (!process.stdout.isTTY || NO_COLOR) return { stop: (m) => m && console.log(` ${c.green}✓${c.reset} ${m}`) };
327
+ const frames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
328
+ let i = 0;
329
+ const t = setInterval(() => {
330
+ process.stdout.write(`\r ${c.cyan}${frames[i++ % frames.length]}${c.reset} ${msg} `);
331
+ }, 80);
332
+ return {
333
+ stop: (doneMsg) => {
334
+ clearInterval(t);
335
+ process.stdout.write(`\r ${c.green}✓${c.reset} ${doneMsg ?? msg}` + " ".repeat(20) + "\n");
336
+ }
337
+ };
338
+ }
339
+
340
+ /* ═══════════════════════════════════════════════════════════════
341
+ Masked password prompt (no readline default masking)
342
+ ═══════════════════════════════════════════════════════════════ */
343
+ function promptMasked(promptText) {
344
+ return new Promise((resolve) => {
345
+ process.stdout.write(` ${c.bold}${promptText}${c.reset} `);
346
+ if (!process.stdin.isTTY) {
347
+ let buf = "";
348
+ process.stdin.setEncoding("utf8");
349
+ process.stdin.once("data", (d) => {
350
+ process.stdin.pause();
351
+ resolve(d.trim());
352
+ });
353
+ return;
354
+ }
355
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
356
+ let value = "";
357
+ const raw = process.stdin.setRawMode;
358
+ if (typeof raw === "function") process.stdin.setRawMode(true);
359
+ process.stdin.resume();
360
+ process.stdin.setEncoding("utf8");
361
+ const onData = (ch) => {
362
+ if (ch === "\u0003") { process.exit(); }
363
+ if (ch === "\r" || ch === "\n") {
364
+ process.stdin.removeListener("data", onData);
365
+ if (typeof raw === "function") process.stdin.setRawMode(false);
366
+ rl.close();
367
+ process.stdout.write("\n");
368
+ resolve(value);
369
+ } else if (ch === "\u007f" || ch === "\b") {
370
+ if (value.length > 0) { value = value.slice(0, -1); process.stdout.write("\b \b"); }
371
+ } else {
372
+ value += ch;
373
+ process.stdout.write("•");
374
+ }
375
+ };
376
+ process.stdin.on("data", onData);
377
+ });
378
+ }
379
+
380
+ /** Plain visible prompt */
381
+ function prompt(promptText, defaultVal) {
382
+ return new Promise((resolve) => {
383
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
384
+ const q = defaultVal
385
+ ? ` ${c.bold}${promptText}${c.reset} ${c.gray}[${defaultVal}]${c.reset} `
386
+ : ` ${c.bold}${promptText}${c.reset} `;
387
+ rl.question(q, (ans) => {
388
+ rl.close();
389
+ resolve(ans.trim() || defaultVal || "");
390
+ });
391
+ });
392
+ }
393
+
394
+ /** yes/no confirmation */
395
+ async function confirm(msg) {
396
+ const ans = await prompt(`${msg} ${c.gray}(y/N)${c.reset}:`);
397
+ return ans.toLowerCase() === "y" || ans.toLowerCase() === "yes";
398
+ }
399
+
400
+ /* ═══════════════════════════════════════════════════════════════
401
+ Output formatters
402
+ ═══════════════════════════════════════════════════════════════ */
403
+ function printResult(result, jsonMode) {
404
+ if (jsonMode) {
405
+ // enrollPayload, not the whole record: the record carries `canonical`, the
406
+ // normalized plaintext. Printing it would put the secret on stdout of a
407
+ // tool whose entire promise is that the secret stays in memory.
408
+ console.log(JSON.stringify({ ...enrollPayload(result), orgId: result.orgId }, null, 2));
409
+ return;
410
+ }
411
+ console.log();
412
+ console.log(hr());
413
+ console.log(` ${c.green}${c.bold}✅ Fingerprint generated successfully${c.reset}`);
414
+ console.log(hr());
415
+ console.log();
416
+ console.log(` ${c.bold}Label ${c.reset}${c.cyan}${result.label}${c.reset}`);
417
+ console.log(` ${c.bold}HMAC Hex ${c.reset}${c.yellow}${result.hmacHex}${c.reset}`);
418
+ console.log(` ${c.bold} stripped ${c.reset}${c.gray}${result.hmacHexStripped}${c.reset}`);
419
+ console.log(` ${c.bold} lower ${c.reset}${c.gray}${result.hmacHexLower}${c.reset}`);
420
+ console.log(` ${c.bold}Length ${c.reset}${result.length} chars ${c.gray}(stripped ${result.lengthStripped}, lower ${result.lengthLower})${c.reset}`);
421
+ console.log(` ${c.bold}Org ID ${c.reset}${c.gray}${result.orgId}${c.reset}`);
422
+ console.log(` ${c.bold}Algorithm ${c.reset}PBKDF2-SHA256 · ${PBKDF2_ITERATIONS.toLocaleString("en-US")} iters · HMAC-SHA256`);
423
+ console.log();
424
+ console.log(` ${c.dim}── Ready to paste into dashboard ───────────────────${c.reset}`);
425
+ console.log();
426
+ console.log(` ${c.bold}${c.blue}Copy this JSON and paste it using the "Import Digest" option:${c.reset}`);
427
+ console.log();
428
+ JSON.stringify(enrollPayload(result), null, 4)
429
+ .split("\n")
430
+ .forEach((l) => console.log(` ${c.green}${l}${c.reset}`));
431
+ console.log();
432
+ console.log(hr());
433
+ }
434
+
435
+ /**
436
+ * The exact field set POST /api/secrets accepts. Kept in one place so the
437
+ * single-secret and batch outputs cannot disagree about which variants they
438
+ * include, which is how the stripped and lower digests went missing before.
439
+ */
440
+ export function enrollPayload(r) {
441
+ return {
442
+ label: r.label,
443
+ hmacHex: r.hmacHex,
444
+ length: r.length,
445
+ hmacHexStripped: r.hmacHexStripped,
446
+ lengthStripped: r.lengthStripped,
447
+ hmacHexLower: r.hmacHexLower,
448
+ lengthLower: r.lengthLower,
449
+ ...(r.scanPrefilter ? { scanPrefilter: r.scanPrefilter } : {}),
450
+ };
451
+ }
452
+
453
+ function printMultiResults(results, jsonMode) {
454
+ if (jsonMode) {
455
+ console.log(JSON.stringify(results.map(enrollPayload), null, 2));
456
+ return;
457
+ }
458
+ console.log();
459
+ console.log(hr());
460
+ console.log(` ${c.green}${c.bold}✅ ${results.length} fingerprint(s) generated${c.reset}`);
461
+ console.log(hr());
462
+ console.log();
463
+ results.forEach((r, i) => {
464
+ console.log(` ${c.bold}${c.cyan}[${i + 1}] ${r.label}${c.reset}`);
465
+ console.log(` hmacHex ${c.yellow}${r.hmacHex.slice(0, 16)}…${r.hmacHex.slice(-8)}${c.reset}`);
466
+ console.log(` length ${r.length}`);
467
+ console.log();
468
+ });
469
+ console.log(` ${c.bold}${c.blue}Full JSON (paste into API /api/secrets or Import Digest):${c.reset}`);
470
+ console.log();
471
+ JSON.stringify(results.map(enrollPayload), null, 4)
472
+ .split("\n")
473
+ .forEach((l) => console.log(` ${c.green}${l}${c.reset}`));
474
+ console.log();
475
+ console.log(hr());
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════════════════════
479
+ Help text
480
+ ═══════════════════════════════════════════════════════════════ */
481
+ function printHelp() {
482
+ console.log(`
483
+ ${c.bold}${c.cyan}Redaktyn Fingerprint CLI${c.reset} — Zero-knowledge local secret digest generator
484
+
485
+ ${c.bold}USAGE${c.reset}
486
+
487
+ ${c.green}Interactive wizard (recommended):${c.reset}
488
+ node redaktyn-fingerprint.mjs
489
+
490
+ ${c.green}Non-interactive (scripting / CI):${c.reset}
491
+ REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs \\
492
+ --org-id "your-org-uuid" \\
493
+ --label "MY_SECRET_KEY" \\
494
+ --secret "raw-secret-value"
495
+
496
+ ${c.green}Batch mode (file with label:secret lines):${c.reset}
497
+ REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs \\
498
+ --batch secrets.txt --org-id "your-org-uuid"
499
+
500
+ ${c.bold}OPTIONS${c.reset}
501
+
502
+ --org-id ${c.gray}<uuid> Your organization UUID from the dashboard Settings page${c.reset}
503
+ --passphrase ${c.gray}<str> Organization passphrase (prefer REDAKTYN_PASSPHRASE env var)${c.reset}
504
+ --label ${c.gray}<str> Human-readable name for the secret (e.g. AWS_SECRET_KEY)${c.reset}
505
+ --secret ${c.gray}<str> The raw secret value to fingerprint${c.reset}
506
+ --batch ${c.gray}<file> Path to a file; each line: label:secret_value${c.reset}
507
+ --json Output pure JSON (no colors, no table)
508
+ --no-color Disable ANSI colors
509
+ --help Show this help
510
+
511
+ ${c.bold}HOW IT WORKS${c.reset}
512
+
513
+ 1. Your passphrase + org ID → PBKDF2-SHA256 (${PBKDF2_ITERATIONS.toLocaleString("en-US")} iters) → 256-bit HMAC key
514
+ 2. Secret → NFC normalize + strip invisible Unicode + trim + UUID case-fold
515
+ 3. HMAC-SHA256 over that, plus two bypass variants:
516
+ ${c.bold}hmacHex${c.reset} exact canonical form
517
+ ${c.bold}hmacHexStripped${c.reset} all whitespace removed ("A mit@1" → "Amit@1")
518
+ ${c.bold}hmacHexLower${c.reset} whitespace removed, then lowercased
519
+
520
+ All three are registered. Without them a secret is caught when pasted verbatim
521
+ and missed the moment someone adds a space or changes a letter's case.
522
+
523
+ ${c.yellow}Nothing is sent to any server.${c.reset} The secret and passphrase never leave this machine.
524
+ The digests are byte-identical to what the Redaktyn dashboard computes in your
525
+ browser — the algorithm is published as ${c.bold}@redaktyn/shared${c.reset} if you want to diff it.
526
+
527
+ ${c.bold}BATCH FILE FORMAT${c.reset}
528
+
529
+ One entry per line: ${c.cyan}LABEL:secret_value${c.reset}
530
+ Lines starting with # are ignored (comments).
531
+
532
+ Example:
533
+ # My company secrets
534
+ AWS_ACCESS_KEY:AKIAIOSFODNN7EXAMPLE
535
+ DATABASE_PASSWORD:s3cr3tPassw0rd!
536
+ STRIPE_SECRET:sk_live_abc123
537
+
538
+ ${c.bold}FINDING YOUR ORG ID${c.reset}
539
+
540
+ Dashboard → Settings → scroll to "Organization Details" → copy the UUID.
541
+
542
+ ${c.bold}SECURITY NOTE${c.reset}
543
+
544
+ ${c.dim}─ The CLI REFUSES to read --secret or --passphrase from argv by default
545
+ (argv is recorded in shell history and visible to other processes).
546
+ ─ Preferred: REDAKTYN_PASSPHRASE env var + stdin pipe for the secret:
547
+ printf '%s' "$SECRET" | REDAKTYN_PASSPHRASE='…' \\
548
+ node redaktyn-fingerprint.mjs --org-id <uuid> --label MY_KEY
549
+ ─ Override with --i-know-this-leaks-to-history only in disposable shells.
550
+ ─ This tool is intentionally dependency-free. Audit it: it's ~300 lines of Node.js.${c.reset}
551
+
552
+ `);
553
+ }
554
+
555
+ /* ═══════════════════════════════════════════════════════════════
556
+ Batch file parsing
557
+ ═══════════════════════════════════════════════════════════════ */
558
+ function parseBatchFile(filePath) {
559
+ if (!existsSync(filePath)) {
560
+ console.error(`${c.red}Error: batch file not found: ${filePath}${c.reset}`);
561
+ process.exit(1);
562
+ }
563
+ const lines = readFileSync(filePath, "utf8").split("\n");
564
+ const entries = [];
565
+ for (const [index, raw] of lines.entries()) {
566
+ const line = raw.trim();
567
+ if (!line || line.startsWith("#")) continue;
568
+ const colonIdx = line.indexOf(":");
569
+ if (colonIdx < 1) {
570
+ // The line number, never the line. A line is malformed precisely when it
571
+ // has no `LABEL:` prefix, and the most likely reason for that is a bare
572
+ // secret on its own — so echoing it back to help the user identify it
573
+ // would print the secret to stderr, into scrollback and CI logs, which
574
+ // is the one thing this tool exists to avoid.
575
+ warn(`Skipping malformed line ${index + 1} (expected LABEL:value).`);
576
+ continue;
577
+ }
578
+ entries.push({ label: line.slice(0, colonIdx).trim(), secret: line.slice(colonIdx + 1) });
579
+ }
580
+ return entries;
581
+ }
582
+
583
+ /* ═══════════════════════════════════════════════════════════════
584
+ UUID validation
585
+ ═══════════════════════════════════════════════════════════════ */
586
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
587
+
588
+ /* ═══════════════════════════════════════════════════════════════
589
+ Interactive wizard
590
+ ═══════════════════════════════════════════════════════════════ */
591
+ async function runWizard(args) {
592
+ banner();
593
+
594
+ console.log(` ${c.bold}Welcome!${c.reset} This wizard generates a zero-knowledge fingerprint of`);
595
+ console.log(` your secret locally. Nothing is sent anywhere.\n`);
596
+
597
+ console.log(hr("·"));
598
+ console.log(` ${c.bold}${c.magenta}STEP 1 — Organization ID${c.reset}`);
599
+ console.log(` ${c.gray}Find this in: Dashboard → Settings → Organization Details${c.reset}`);
600
+ console.log();
601
+
602
+ let orgId = args.orgId?.trim() ?? "";
603
+ if (!orgId || !UUID_RE.test(orgId)) {
604
+ orgId = await prompt("Org ID (UUID format, e.g. a1b2c3d4-…):");
605
+ while (!UUID_RE.test(orgId.trim())) {
606
+ warn("That doesn't look like a valid UUID. Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx");
607
+ orgId = await prompt("Org ID:");
608
+ }
609
+ } else {
610
+ ok("Org ID", orgId);
611
+ }
612
+ orgId = orgId.trim();
613
+
614
+ console.log();
615
+ console.log(hr("·"));
616
+ console.log(` ${c.bold}${c.magenta}STEP 2 — Organization Passphrase${c.reset}`);
617
+ console.log(` ${c.gray}The same passphrase your team uses when connecting the extension.${c.reset}`);
618
+ console.log(` ${c.gray}Tip: set REDAKTYN_PASSPHRASE in your shell to avoid retyping.${c.reset}`);
619
+ console.log();
620
+
621
+ let passphrase = (process.env["REDAKTYN_PASSPHRASE"] && String(process.env["REDAKTYN_PASSPHRASE"])) ?? "";
622
+ if (!passphrase) {
623
+ passphrase = await promptMasked("Passphrase (hidden):");
624
+ while (!passphrase || passphrase.length < 8) {
625
+ warn("Passphrase must be at least 8 characters.");
626
+ passphrase = await promptMasked("Passphrase:");
627
+ }
628
+ } else {
629
+ ok("Passphrase", "•••••••• (loaded from REDAKTYN_PASSPHRASE)");
630
+ }
631
+
632
+ const results = [];
633
+ let addMore = true;
634
+ let secretNum = 0;
635
+
636
+ while (addMore) {
637
+ secretNum++;
638
+ console.log();
639
+ console.log(hr("·"));
640
+ console.log(` ${c.bold}${c.magenta}STEP ${secretNum === 1 ? "3" : secretNum + 2} — Secret #${secretNum}${c.reset}`);
641
+ console.log();
642
+
643
+ const label = await prompt(`Label (human-readable name, e.g. AWS_SECRET_KEY${secretNum > 1 ? "" : ""}):`);
644
+ if (!label) { warn("Label cannot be empty — skipping."); continue; }
645
+
646
+ const secret = await promptMasked("Secret value (hidden):");
647
+ if (!secret) { warn("Secret cannot be empty — skipping."); continue; }
648
+
649
+ // Show crypto transparency
650
+ console.log();
651
+ console.log(` ${c.dim}── Computing fingerprint ──────────────────────────${c.reset}`);
652
+ const sp = spinner("Deriving HMAC key via PBKDF2-SHA256 (600,000 iterations)…");
653
+
654
+ let key;
655
+ try {
656
+ key = await deriveKey(passphrase, orgId);
657
+ sp.stop("HMAC key derived " + c.gray + "(256-bit, browser-identical)" + c.reset);
658
+ } catch (e) {
659
+ sp.stop();
660
+ console.error(`${c.red}Key derivation failed: ${e.message}${c.reset}`);
661
+ process.exit(1);
662
+ }
663
+
664
+ const fp = fingerprint(label, secret, key, orgId);
665
+
666
+ // Show steps
667
+ console.log();
668
+ console.log(` ${c.dim}Step 1${c.reset} ${c.gray}NFC normalize + trim + UUID case-fold${c.reset}`);
669
+ // `fp.canonical` is the normalized secret — for the overwhelming majority
670
+ // of real secrets (no leading/trailing whitespace, no exotic Unicode)
671
+ // that's byte-identical to what was just typed. enrollPayload() already
672
+ // excludes it from every other output path for exactly this reason; this
673
+ // line used to print it anyway, putting the secret on stdout (and so in
674
+ // terminal scrollback, session logging, and screen shares) on the
675
+ // wizard's own recommended, flag-free usage path.
676
+ console.log(` ${c.gray}canonical form computed (${fp.canonical.length} chars) — never printed${c.reset}`);
677
+ console.log(` ${c.dim}Step 2${c.reset} ${c.gray}HMAC-SHA256(canonical, derivedKey)${c.reset}`);
678
+ console.log(` ${c.yellow}${fp.hmacHex.slice(0, 16)}…${fp.hmacHex.slice(-8)}${c.reset} ${c.gray}(64-char hex, not reversible)${c.reset}`);
679
+ console.log(` ${c.dim}Step 3${c.reset} ${c.gray}Two bypass variants: whitespace-collapsed and lowercased${c.reset}`);
680
+ console.log(` ${c.gray}${fp.hmacHexStripped.slice(0, 16)}… · ${fp.hmacHexLower.slice(0, 16)}…${c.reset}`);
681
+ console.log(` ${c.dim}Step 4${c.reset} ${c.gray}Length recorded: ${fp.length} chars${c.reset}`);
682
+
683
+ results.push(fp);
684
+
685
+ console.log();
686
+ addMore = await confirm("Add another secret with the same passphrase?");
687
+ }
688
+
689
+ const jsonMode = args.json;
690
+ if (results.length === 1) {
691
+ printResult(results[0], jsonMode);
692
+ } else {
693
+ printMultiResults(results, jsonMode);
694
+ }
695
+
696
+ // Dashboard import tip
697
+ if (!jsonMode) {
698
+ console.log(` ${c.bold}${c.cyan}Next steps:${c.reset}`);
699
+ console.log(` ${c.gray}1. Copy the JSON above${c.reset}`);
700
+ console.log(` ${c.gray}2. Dashboard → Secrets → "Import Digest" button${c.reset}`);
701
+ console.log(` ${c.gray}3. Paste the JSON and click Add${c.reset}`);
702
+ console.log(` ${c.gray}4. Extensions pick it up on next sync (≤5 min)${c.reset}`);
703
+ console.log();
704
+ console.log(` ${c.dim}Your secret value was used only in-memory and is now gone.${c.reset}`);
705
+ console.log();
706
+ }
707
+ }
708
+
709
+ /* ═══════════════════════════════════════════════════════════════
710
+ Non-interactive / batch mode
711
+ ═══════════════════════════════════════════════════════════════ */
712
+ async function runNonInteractive(args) {
713
+ const orgId = args.orgId?.trim();
714
+ if (!orgId || !UUID_RE.test(orgId)) {
715
+ console.error(`${c.red}Error: --org-id must be a valid UUID.${c.reset}`);
716
+ console.error(` Find it in: Dashboard → Settings → Organization Details`);
717
+ process.exit(1);
718
+ }
719
+
720
+ const passphrase =
721
+ (process.env["REDAKTYN_PASSPHRASE"] && String(process.env["REDAKTYN_PASSPHRASE"])) ||
722
+ args.passphrase?.trim() || "";
723
+ if (!passphrase) {
724
+ console.error(`${c.red}Error: set REDAKTYN_PASSPHRASE env var or use --passphrase.${c.reset}`);
725
+ process.exit(1);
726
+ }
727
+
728
+ const key = await deriveKey(passphrase, orgId);
729
+
730
+ if (args.batch) {
731
+ const entries = parseBatchFile(args.batch);
732
+ if (entries.length === 0) {
733
+ console.error(`${c.red}Error: batch file is empty or has no valid entries.${c.reset}`);
734
+ process.exit(1);
735
+ }
736
+ const results = entries.map(({ label, secret }) => fingerprint(label, secret, key, orgId));
737
+ printMultiResults(results, args.json);
738
+ return;
739
+ }
740
+
741
+ // Single secret
742
+ const label = args.label?.trim();
743
+ const secret = args.secret;
744
+
745
+ if (!label) {
746
+ console.error(`${c.red}Error: --label is required in non-interactive mode.${c.reset}`);
747
+ process.exit(1);
748
+ }
749
+
750
+ let rawSecret = secret;
751
+ if (rawSecret == null) {
752
+ // Try stdin
753
+ if (!process.stdin.isTTY) {
754
+ rawSecret = await new Promise((resolve, reject) => {
755
+ let data = "";
756
+ process.stdin.setEncoding("utf8");
757
+ process.stdin.on("data", (d) => (data += d));
758
+ process.stdin.on("end", () => resolve(data.trim()));
759
+ process.stdin.on("error", reject);
760
+ });
761
+ }
762
+ }
763
+
764
+ if (!rawSecret) {
765
+ console.error(`${c.red}Error: --secret is required (or pipe on stdin).${c.reset}`);
766
+ process.exit(1);
767
+ }
768
+
769
+ printResult(fingerprint(label, rawSecret, key, orgId), args.json);
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════════════════════
773
+ Entry point
774
+ ═══════════════════════════════════════════════════════════════ */
775
+ async function main() {
776
+ const args = parseArgs(process.argv);
777
+
778
+ if (args.help) {
779
+ printHelp();
780
+ process.exit(0);
781
+ }
782
+
783
+ enforceArgvSecretsPolicy(args);
784
+
785
+ const isInteractive = process.stdin.isTTY &&
786
+ !args.orgId && !args.secret && !args.batch && !args.label &&
787
+ !process.env["REDAKTYN_PASSPHRASE"];
788
+
789
+ if (isInteractive) {
790
+ await runWizard(args);
791
+ } else {
792
+ await runNonInteractive(args);
793
+ }
794
+ }
795
+
796
+ /*
797
+ Run only when invoked directly.
798
+
799
+ `redaktyn-import.mjs` imports the digest helpers above rather than carrying a
800
+ second copy of them. Two copies of this pipeline is the one failure this file
801
+ already has a guard test against: a secret enrolled under one canonical form
802
+ and searched for under another is enrolled and unprotected at the same time.
803
+ Without this check, importing anything from here would run the whole CLI —
804
+ argv policy, wizard and all — as a side effect of asking for a function.
805
+ */
806
+ const invokedDirectly =
807
+ !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
808
+
809
+ if (invokedDirectly) {
810
+ main().catch((err) => {
811
+ console.error(`${c.red}Fatal: ${err instanceof Error ? err.message : err}${c.reset}`);
812
+ process.exit(1);
813
+ });
814
+ }