@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.
- package/README.md +561 -2
- package/package.json +44 -4
- package/redaktyn-fingerprint.mjs +814 -0
- package/redaktyn-import.mjs +1652 -0
- package/redaktyn-scan.mjs +565 -0
- package/redaktyn-verify.mjs +557 -0
|
@@ -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
|
+
}
|