@redaktyn/cli 1.5.8 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,11 +35,10 @@ This CLI is for you.
35
35
 
36
36
  ### Interactive wizard (recommended for first-timers)
37
37
 
38
- From the **`cli`** directory (or invoke by path):
38
+ Run it (after `npm install -g @redaktyn/cli`, or as `npx -p @redaktyn/cli redaktyn-fingerprint`):
39
39
 
40
40
  ```bash
41
- cd cli
42
- node redaktyn-fingerprint.mjs
41
+ redaktyn-fingerprint
43
42
  ```
44
43
 
45
44
  The wizard will guide you through:
@@ -56,7 +55,7 @@ The wizard will guide you through:
56
55
  # Preferred: passphrase via env var (won't appear in shell history)
57
56
  export REDAKTYN_PASSPHRASE="your-org-passphrase"
58
57
 
59
- cd cli && node redaktyn-fingerprint.mjs \
58
+ redaktyn-fingerprint \
60
59
  --org-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
61
60
  --label "AWS_SECRET_ACCESS_KEY" \
62
61
  --secret "AKIAIOSFODNN7EXAMPLE..."
@@ -79,7 +78,7 @@ Run:
79
78
  ```bash
80
79
  export REDAKTYN_PASSPHRASE="your-org-passphrase"
81
80
 
82
- cd cli && node redaktyn-fingerprint.mjs \
81
+ redaktyn-fingerprint \
83
82
  --org-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
84
83
  --batch secrets.txt
85
84
  ```
@@ -89,7 +88,7 @@ Outputs a JSON array — register each digest on Dashboard → **Secrets** (expa
89
88
  ### JSON output (for piping / automation)
90
89
 
91
90
  ```bash
92
- node cli/redaktyn-fingerprint.mjs --org-id ... --label ... --secret ... --json
91
+ redaktyn-fingerprint --org-id ... --label ... --secret ... --json
93
92
  ```
94
93
 
95
94
  Output:
@@ -214,7 +213,22 @@ redaktyn-invite --project acme-engagement --can-enrol --expires-days 3
214
213
  The project's scanning key is derived locally and sealed under a one-time key that exists only in the link's
215
214
  `#fragment`. The server stores ciphertext it cannot open, and the fragment is never sent to it. `--can-enrol`
216
215
  is off unless you ask: without it the link is scan-only. A link carries a key for **one** device, so it works
217
- once.
216
+ once. The link is on the server you are signed in to (`redaktyn-connect` redeems it there); `--app-url` puts another
217
+ address in it.
218
+
219
+ **A whole team at once.** Give it a CSV (`Name,Email`, or just email addresses, one per line):
220
+
221
+ ```bash
222
+ redaktyn-invite --project acme-engagement --csv people.csv # dry run: the plan, nothing created
223
+ redaktyn-invite --project acme-engagement --csv people.csv --apply --out links.csv
224
+ ```
225
+
226
+ The dry run reads the file, says how many will be invited and why any row is set aside (an address the server would
227
+ refuse, a repeat, one that already has an unused invite on the project), and checks your plan's limit, all before you are
228
+ asked for the passphrase. With `--apply` it makes one single-use link per person, assigned to them, and writes
229
+ `name,email,link` to the `--out` file with permissions only you can read. **The links are never printed**: each is a
230
+ credential that works once. It will not overwrite an existing `--out` file without `--force`. Exit code 1 means some rows
231
+ were not created, and the file has an empty link on those rows.
218
232
 
219
233
  **Developer: connect once, then work.**
220
234
 
@@ -238,13 +252,43 @@ What the developer has, and does not have:
238
252
  The session lives in `~/.config/redaktyn/session.json` (directory `0700`, file `0600`), the same protection
239
253
  `~/.aws/credentials` relies on. It is a file and not the OS keychain on purpose: macOS `security
240
254
  add-generic-password` takes the secret as a command-line argument, which any process on the machine can read
241
- with `ps` while it runs. The token renews itself; if the session cannot be renewed, or an admin removed the
242
- device, the command says to ask for a new link. An admin's own `REDAKTYN_TOKEN` in the environment always takes
243
- precedence over a saved session.
255
+ with `ps` while it runs. The token renews itself. If an admin removed the device, or the server refuses the renewal,
256
+ the command says to ask for a new link; if the server only failed to answer (an error, a deploy, a rate limit), the
257
+ current token is used while it lasts and nobody is sent for a new link. An admin's own `REDAKTYN_TOKEN` in the
258
+ environment takes precedence over a saved session for `redaktyn-import`. For `redaktyn-scan` the environment wins only
259
+ as a complete set (a token and `REDAKTYN_PASSPHRASE`); half of one, such as a passphrase left exported, does not hide
260
+ the saved session (`--verbose` names it).
261
+
262
+ `redaktyn-connect --status` shows what was saved and then checks the server still accepts this machine (exit 1 if it was
263
+ removed; the saved details stand if the server cannot be reached). `--logout` forgets the session here only: the admin
264
+ still sees the machine, and it keeps its seat, until they remove it. One machine covers one project for now: a link for
265
+ a second project is refused without being used, and works once an admin removes the machine from the first. A link
266
+ for a different workspace is refused too, before it is used, because connecting would replace this machine's session
267
+ and the first workspace's repositories would no longer be checked here. To switch on purpose, run
268
+ `redaktyn-connect --switch '<link>'`, or `--logout` first. A link pasted with quotes or `<angle brackets>` around it is
269
+ accepted.
270
+
271
+ ## `redaktyn-share` — a key server your organisation runs (Enterprise)
272
+
273
+ For the workspace owner. With a key share on, your workspace's key opens only when a small server **you** run answers as
274
+ well as Redaktyn's, so Redaktyn cannot open it alone or test passphrase guesses, whatever passphrase you chose. It is optional,
275
+ and the product works as before without it.
276
+
277
+ ```
278
+ redaktyn-share new-key # the SECRET key (for your server only) and the public key to give to "enable"
279
+ redaktyn-share enable --url https://keys.example.com --public-key <64 hex>
280
+ redaktyn-share check # reachable, and proves it holds the key the lock names
281
+ redaktyn-share status
282
+ redaktyn-share disable # back to Redaktyn's server alone; never needs the plan
283
+ ```
284
+
285
+ `enable` refuses unless the server at that address shows the public key you typed, and proves the new lock opens before saving
286
+ it. The service itself is a single file for Cloudflare Workers or Node (`infra/oprf-share`). If the share asks for a token, set
287
+ `REDAKTYN_SHARE_TOKEN`.
244
288
 
245
- **Server note:** `--can-enrol` needs the `InviteEnrolGrant` table. Schema changes here are applied by hand
246
- (`npx prisma db push`), and an invite minted before that is refused with `ENROL_GRANTS_UNAVAILABLE` rather
247
- than quietly becoming a plain link.
289
+ **Keep two copies of the secret key.** If it is lost while the share is on, nothing can open the workspace, and recovery codes
290
+ do not help: they return the passphrase, not that key. The way back is the owner's reset in the dashboard, which makes a new
291
+ workspace key and means every secret is registered again.
248
292
 
249
293
  ## `redaktyn-import` — bulk enrollment from files and vaults
250
294
 
@@ -262,10 +306,10 @@ Like `redaktyn-fingerprint`, it needs **no npm install** — Node 18+ and nothin
262
306
  redaktyn-login # once: your OWNER/ADMIN email and password
263
307
 
264
308
  # 1. See what it would do. It asks for the workspace passphrase (or read REDAKTYN_PASSPHRASE). This is the default; nothing is written.
265
- node cli/redaktyn-import.mjs --env .env.production --source prod
309
+ redaktyn-import --env .env.production --source prod
266
310
 
267
311
  # 2. Happy with the plan? Enroll it.
268
- node cli/redaktyn-import.mjs --env .env.production --source prod --apply
312
+ redaktyn-import --env .env.production --source prod --apply
269
313
  ```
270
314
 
271
315
  The dry run prints one line per key — the key name, the value's length, and the decision:
@@ -295,22 +339,22 @@ JSON, so `--stdin` covers all of them today:
295
339
 
296
340
  ```bash
297
341
  # HashiCorp Vault (KV v1 and v2 envelopes are both unwrapped)
298
- vault kv get -format=json secret/prod | node cli/redaktyn-import.mjs --stdin --source vault
342
+ vault kv get -format=json secret/prod | redaktyn-import --stdin --source vault
299
343
 
300
344
  # AWS Secrets Manager
301
345
  aws secretsmanager get-secret-value --secret-id prod \
302
- --query SecretString --output text | node cli/redaktyn-import.mjs --stdin --source aws
346
+ --query SecretString --output text | redaktyn-import --stdin --source aws
303
347
 
304
348
  # …or pipe the whole envelope; SecretString is unwrapped for you
305
349
  aws secretsmanager get-secret-value --secret-id prod \
306
- | node cli/redaktyn-import.mjs --stdin --source aws
350
+ | redaktyn-import --stdin --source aws
307
351
 
308
352
  # Doppler
309
353
  doppler secrets download --no-file --format json \
310
- | node cli/redaktyn-import.mjs --stdin --source doppler
354
+ | redaktyn-import --stdin --source doppler
311
355
 
312
356
  # 1Password
313
- op item get prod --format json | node cli/redaktyn-import.mjs --stdin --source 1password
357
+ op item get prod --format json | redaktyn-import --stdin --source 1password
314
358
 
315
359
  # GitHub Actions secrets cannot be read back by design — export from wherever they
316
360
  # were set, or enroll those with redaktyn-fingerprint.
@@ -450,7 +494,8 @@ prefilter to each secret. `--report-json` includes `scanBudget.legacySecrets`,
450
494
  **Signing in.** Run `redaktyn-login` once. It opens your browser on a code, like `gh auth login`: you are already signed in
451
495
  to the dashboard, so you check that the code matches the one in the terminal and press Approve. Nothing is typed into the
452
496
  terminal, and the token is never in a URL. Over SSH or in a container, `--no-browser` prints the link to open anywhere you are
453
- signed in. A machine with no browser at all can use `--password` (email and password in the terminal) or `--password-stdin`.
497
+ signed in. A machine with no browser at all can use `--password` (email and password in the terminal) or `--password-stdin`,
498
+ which takes the password exactly as sent, spaces included, minus the line ending (send it with `printf '%s'`).
454
499
  The login token is kept in a file only you can read, so `redaktyn-import` and `redaktyn-invite` need no `export` for it; the
455
500
  password is never stored. Only an OWNER or ADMIN can approve. The server address defaults to `https://redaktyn.com`; only a
456
501
  self-hosted install sets `REDAKTYN_SERVER_URL` or `--server`. `redaktyn-login --status` shows who you are, `--logout` forgets
@@ -498,7 +543,7 @@ export REDAKTYN_TOKEN=$(curl -s -X POST "https://redaktyn.com/api/auth/login" \
498
543
  REDAKTYN_TOKEN: ${{ secrets.REDAKTYN_ADMIN_TOKEN }}
499
544
  run: |
500
545
  doppler secrets download --no-file --format json \
501
- | node cli/redaktyn-import.mjs --stdin --source doppler --apply --yes
546
+ | redaktyn-import --stdin --source doppler --apply --yes
502
547
  ```
503
548
 
504
549
  ---
@@ -510,36 +555,33 @@ zero-knowledge HMAC detection as the browser extension, but inside your git work
510
555
 
511
556
  ### Quick start
512
557
 
513
- Reinstall the package: `npm install -g @redaktyn/cli`, or run a command with `npx -p @redaktyn/cli`.
558
+ On your own machine, run `redaktyn-connect` once with your project's invite link and these commands need no environment variables. In CI, or anywhere there is no saved connection, set the variables below.
514
559
 
515
560
  ```bash
516
- # API base URL — for an EC2 deployment with no custom domain, use the public IP:
517
- # export REDAKTYN_SERVER_URL="http://<EC2-PUBLIC-IP>"
518
- export REDAKTYN_SERVER_URL="https://your-api.example.com"
561
+ # Only when there is no saved connection (CI, scripts). A self-hosted install sets its own address.
562
+ export REDAKTYN_SERVER_URL="https://redaktyn.com"
519
563
  export REDAKTYN_PASSPHRASE="your-org-passphrase"
520
- export REDAKTYN_DEVICE_TOKEN="eyJ…" # extension JWT from popup → Connect, or admin JWT for CI
564
+ export REDAKTYN_DEVICE_TOKEN="eyJ…" # an OWNER/ADMIN token, for CI
521
565
 
522
566
  # Scan staged changes before committing
523
- node cli/redaktyn-scan.mjs --staged
567
+ redaktyn-scan --staged
524
568
 
525
569
  # Install permanent git hook (runs automatically on every commit)
526
- node cli/redaktyn-scan.mjs install-hooks
570
+ redaktyn-scan install-hooks
527
571
 
528
572
  # Remove the hook
529
- node cli/redaktyn-scan.mjs uninstall-hooks
573
+ redaktyn-scan uninstall-hooks
530
574
 
531
575
  # Scan a single file
532
- node cli/redaktyn-scan.mjs --file ./src/config.ts
576
+ redaktyn-scan --file ./src/config.ts
533
577
 
534
578
  # Scan all text files under a directory
535
- node cli/redaktyn-scan.mjs --dir ./src --exclude "*.min.js"
579
+ redaktyn-scan --dir ./src --exclude "*.min.js"
536
580
 
537
581
  # Check connection and enrolled secret count
538
- node cli/redaktyn-scan.mjs status --json
582
+ redaktyn-scan status --json
539
583
  ```
540
584
 
541
- Or from repo root via workspace bin: `npm run redaktyn-scan -- --staged` (passes flags after `--`).
542
-
543
585
  ### CI/CD integration (GitHub Actions example)
544
586
 
545
587
  ```yaml
@@ -549,18 +591,42 @@ Or from repo root via workspace bin: `npm run redaktyn-scan -- --staged` (passes
549
591
  REDAKTYN_PASSPHRASE: ${{ secrets.REDAKTYN_PASSPHRASE }}
550
592
  REDAKTYN_TOKEN: ${{ secrets.REDAKTYN_TOKEN }}
551
593
  run: |
552
- node cli/redaktyn-scan.mjs --staged --json
594
+ redaktyn-scan --staged --json
553
595
  ```
554
596
 
555
597
  Exit code `1` when enrolled digests are found in the staged diff — blocks the commit or fails the CI job.
556
598
 
599
+ **Exit codes:** `0` clean, nothing is registered yet, or a commit too large to check in full (each says so) · `1` a
600
+ registered secret was found, or a `--file`/`--dir` scan could not finish (truncated, a folder it could not read) —
601
+ never reported as clean · `2` it could not run: not connected, not a git repository, or the server could not be
602
+ reached. A commit blocked because the server was unreachable says so; `git commit --no-verify` skips the check, at
603
+ your own risk.
604
+
605
+ Staged text is checked up to 4 MB, about 5 seconds against 30 registered secrets. A larger commit (a vendored library,
606
+ a first commit) is **not refused**: the part that fits is checked, a secret found there still blocks, and the commit
607
+ otherwise goes ahead with a warning that says what was not checked. With `--json` it reports `"ok": true` and
608
+ `"complete": false`. A staged diff over 20 MB is read up to that size in the same way. Package managers' lockfiles
609
+ (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `go.sum` and the like, at any depth) do not count
610
+ towards the 4 MB and are scanned in full, because a registry credential can end up in a `resolved` URL.
611
+
557
612
  ### How it works (zero-knowledge)
558
613
 
559
614
  1. Derives an HMAC key from your passphrase (PBKDF2-SHA256, 600K iterations, org ID as salt) with the same digest code the browser extension uses, so its digests always match extension-enrolled secrets.
560
- 2. Slides windows of each enrolled secret's length over added lines from the git diff.
561
- 3. Sends **only 64-char hex HMAC digests** to `POST /api/cli/scan` — raw content never leaves your machine.
615
+ A secret in a project with its own key is checked under that project's key. With an admin token the CLI reads
616
+ which projects have one; with a device token it tries both keys, which can never cause a false match.
617
+ 2. Slides windows of each enrolled secret's length over the added lines of the staged patch, in the same three
618
+ passes as the extension (exact, whitespace collapsed, case folded). The patch is read as plain text whatever your
619
+ git config says (colour, external diff tools, textconv), and a text file that `.gitattributes` marks as binary is
620
+ still read.
621
+ 3. Skips every window the keyed **scan prefilter** rules out, as the extension does, and signs the rest. Only
622
+ **64-char hex HMAC digests** go to `POST /api/cli/scan`; raw content never leaves your machine.
562
623
  4. Server checks digests against enrolled rows and returns label matches.
563
624
 
625
+ **The git hook.** `install-hooks` writes the pre-commit hook where git looks for it, including from a worktree. It
626
+ never overwrites a pre-commit hook that is not Redaktyn's: it prints the one line to add to it instead. If
627
+ `core.hooksPath` is set (husky, lefthook, a shared hooks folder), it writes nothing and prints that line for your
628
+ hook manager. `uninstall-hooks` removes only the hook it wrote.
629
+
564
630
  **Scan flags:**
565
631
 
566
632
  | Flag | Description |
@@ -572,13 +638,13 @@ Exit code `1` when enrolled digests are found in the staged diff — blocks the
572
638
  | `--org-id <uuid>` | Override org id (must match JWT) |
573
639
  | `--json` | JSON output (machine-readable, CI-friendly) |
574
640
  | `--verbose` | Extra diagnostic output |
575
- | `--exclude <pat>` | Skip paths containing pattern (repeatable) |
576
- | `install-hooks` | Write `.git/hooks/pre-commit` |
577
- | `uninstall-hooks` | Remove Redaktyn pre-commit hook |
641
+ | `--exclude <pat>` | Skip paths containing pattern, or matching it when it has `*` (repeatable) |
642
+ | `install-hooks` | Write this repository's pre-commit hook (never over one that is not Redaktyn's) |
643
+ | `uninstall-hooks` | Remove the Redaktyn pre-commit hook |
578
644
  | `status` | Show connection + enrolled secret count |
579
645
 
580
- **Note:** scans use the primary normalization path. Secrets enrolled with **only** a whitespace-collapsed
581
- digest may require the browser extension for full bypass-variant parity.
646
+ **Note:** a secret enrolled before the scan prefilter existed has no prefilter, so every window of its lengths is
647
+ signed and sent. Re-enrolling it (`redaktyn-import` re-sends it) makes large scans fast again.
582
648
 
583
649
  ---
584
650
 
@@ -590,12 +656,10 @@ reimplements the digest pipeline on its own, on purpose, so that agreeing with t
590
656
  one file, and short.
591
657
 
592
658
  ```bash
593
- curl -o redaktyn-verify.mjs https://your-api.example.com/api/verify/cli
659
+ curl -o redaktyn-verify.mjs https://redaktyn.com/api/verify/cli
594
660
  node redaktyn-verify.mjs --help
595
661
  ```
596
662
 
597
- From the repo: `npm run verify -- <command>`.
598
-
599
663
  | Command | Checks |
600
664
  |---------|--------|
601
665
  | `recompute` | Derives the three digests from your own secret and compares them against what `GET /api/secrets` holds |
@@ -605,7 +669,7 @@ From the repo: `npm run verify -- <command>`.
605
669
  | `egress` | Asserts every field in a captured HAR against the published allowlist |
606
670
 
607
671
  ```bash
608
- export REDAKTYN_SERVER="https://your-api.example.com"
672
+ export REDAKTYN_SERVER="https://redaktyn.com"
609
673
 
610
674
  node redaktyn-verify.mjs recompute --org-id <uuid> # prompts for passphrase + secret
611
675
  node redaktyn-verify.mjs recompute --org-id <uuid> --token <jwt> # also compare vs server
@@ -628,3 +692,9 @@ the Trust Center in your Redaktyn dashboard.
628
692
  ## License
629
693
 
630
694
  MIT — free to use, inspect, fork, and redistribute.
695
+
696
+ ## Security code and Secret Key
697
+
698
+ An invite made with this version comes with a **security code** as well as its link. `redaktyn-connect` asks for it after the link; a script sets `REDAKTYN_SECURITY_CODE`. It is not the `code=` in the link, and it is never asked for on a web page.
699
+
700
+ A workspace set up with a passphrase someone chose also has a **Secret Key**, starting `RSK-`. The commands that need the workspace key ask for it after the passphrase; a script sets `REDAKTYN_SECRET_KEY`. A generated passphrase has none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redaktyn/cli",
3
- "version": "1.5.8",
3
+ "version": "1.6.1",
4
4
  "description": "Redaktyn CLI: bulk-enrol secrets from a .env or any vault (--project aware), local fingerprints, git/directory digest scans. Digests are computed on your machine; secret values never leave it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "redaktyn-connect": "./redaktyn-connect.mjs",
12
12
  "redaktyn-invite": "./redaktyn-invite.mjs",
13
13
  "redaktyn-login": "./redaktyn-login.mjs",
14
- "redaktyn-demo": "./redaktyn-demo.mjs"
14
+ "redaktyn-demo": "./redaktyn-demo.mjs",
15
+ "redaktyn-share": "./redaktyn-share.mjs"
15
16
  },
16
17
  "dependencies": {},
17
18
  "engines": {
@@ -35,16 +36,18 @@
35
36
  },
36
37
  "files": [
37
38
  "README.md",
38
- "redaktyn-connect.mjs",
39
39
  "redaktyn-clipboard.mjs",
40
+ "redaktyn-connect.mjs",
40
41
  "redaktyn-demo.mjs",
41
42
  "redaktyn-fingerprint.mjs",
42
43
  "redaktyn-import.mjs",
43
44
  "redaktyn-invite.mjs",
45
+ "redaktyn-lock-net.mjs",
44
46
  "redaktyn-login.mjs",
45
47
  "redaktyn-passphrase.mjs",
46
48
  "redaktyn-scan.mjs",
47
49
  "redaktyn-session.mjs",
50
+ "redaktyn-share.mjs",
48
51
  "redaktyn-ui.mjs",
49
52
  "redaktyn-verify.mjs",
50
53
  "redaktyn-shared.cjs",
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import{pathToFileURL as m}from"node:url";import{realpathSync as f}from"node:fs";import*as y from"node:readline";import{SessionError as g,connectWithInvite as k,deleteSession as v,loadSession as d,sessionDir as h}from"./redaktyn-session.mjs";import{banner as w,box as $,k as t,rich as c,rows as b,say as i,step as j}from"./redaktyn-ui.mjs";import{createRequire as N}from"node:module";const S=N(import.meta.url)("./package.json").version;function x(){console.log(`redaktyn-connect: set this machine up from an invite link
2
+ import{pathToFileURL as g}from"node:url";import{realpathSync as w}from"node:fs";import*as $ from"node:readline";import{SessionError as p,apiJson as T,cleanUserName as b,connectWithInvite as N,deleteSession as S,freshSession as I,inviteInfo as j,linkNeedsInviteCode as x,loadSession as v,sessionDir as y}from"./redaktyn-session.mjs";import{banner as E,box as U,k as i,rich as l,rows as W,say as d,step as R}from"./redaktyn-ui.mjs";import{createRequire as A}from"node:module";const C=A(import.meta.url)("./package.json").version;function O(){console.log(`redaktyn-connect: set this machine up from an invite link
3
3
 
4
4
  redaktyn-connect prompt for the link (not saved in shell history)
5
5
  redaktyn-connect --stdin read the link from standard input
6
6
  redaktyn-connect '<link>' pass the link; quote it, the key is after the #
7
- redaktyn-connect --status show what this machine is connected to
8
- redaktyn-connect --logout forget the session on this machine
7
+ redaktyn-connect --status show what this machine is connected to, and check the server still accepts it
8
+ redaktyn-connect --logout forget the session on this machine (your admin removes it to free the seat)
9
9
 
10
10
  --server <url> Override the server (default: the link's own address)
11
+ --name <name> The name your admin sees for this machine. Asked for when the link has none and this is a terminal.
12
+ --switch The link is for a different workspace than the one this machine is connected to: replace it.
13
+ Without this such a link is refused, because the first workspace's repositories would stop
14
+ being checked here.
11
15
 
12
- The session is stored in ${h()} (readable only by you).`)}function I(e){return new Promise(n=>{const o=y.createInterface({input:process.stdin,output:process.stderr});o.question(e,a=>{o.close(),n(a.trim())})})}async function R(){let e="";for await(const n of process.stdin)e+=n;return e.trim()}function p(e){if(c){i(""),$("Connected",["",...b([["Workspace",e.orgName??e.orgId],["Project",e.projectName??"default"],["Access",e.canEnrol?t.amber("scan, and enrol secrets on this project only"):"scan only. Ask for an invite that allows enrolling to add secrets"],["Key",`${e.keyFingerprint} ${t.dim("(a fingerprint of the key, never the key)")}`],["Server",e.serverUrl],["Stored in",`${h()} ${t.dim("(readable only by you)")}`]],{indent:0}),""],{tone:"green",width:78});return}console.log(`Connected to ${e.orgName??e.orgId}${e.projectName?`, project ${e.projectName}`:""}.`),console.log(` server ${e.serverUrl}`),console.log(` can enrol ${e.canEnrol?"yes, on this project only":"no (scan only). Ask for an invite that allows enrolling."}`),console.log(` key ${e.keyFingerprint} (a fingerprint, never the key)`)}async function T(e){const n=e.slice(2);if(n.includes("--help")||n.includes("-h"))return x(),0;if(n.includes("--logout")){const r=!!d();return v(),console.log(r?"Forgot this machine's Redaktyn session.":"There was no session to forget."),0}if(n.includes("--status")){const r=d();return r?(p(r),0):(console.log("Not connected. Run: redaktyn-connect"),1)}const o=n.indexOf("--server"),a=o>=0?n[o+1]:void 0;let s=n.find((r,u)=>!r.startsWith("--")&&n[u-1]!=="--server");if(n.includes("--stdin")&&(s=await R()),!s){if(!process.stdin.isTTY)return console.error("Pass the link, use --stdin, or run this in a terminal."),2;s=await I("Invite link: ")}w("connect this machine",S);const l=j("Redeeming the invite and unsealing the project key on this machine");try{const r=await k(s,{serverUrl:a});return l.succeed(`Invite redeemed ${t.dim("(the key was unsealed here; the server never saw it)")}`),p(r),c?(i(""),i(` ${t.dim("Next")} ${t.bold("redaktyn-import --env .env")} ${t.dim("(add --apply to register)")}`),i("")):console.log(`
13
- Next: redaktyn-import --env .env (add --apply to register)`),0}catch(r){if(r instanceof g)return l.fail(r.message),c||console.error(r.message),1;throw r}}function A(){try{return f(process.argv[1])}catch{return process.argv[1]}}process.argv[1]&&import.meta.url===m(A()).href&&T(process.argv).then(e=>process.exit(e),e=>{console.error(e.message),process.exit(1)});export{T as main};
16
+ The session is stored in ${y()} (readable only by you).`)}function u(e){return new Promise(n=>{const r=$.createInterface({input:process.stdin,output:process.stderr});r.question(e,s=>{r.close(),n(s.trim())})})}async function Y(){let e="";for await(const n of process.stdin)e+=n;return e.trim()}function k(e){if(l){d(""),U("Connected",["",...W([["Workspace",e.orgName??e.orgId],["Project",e.projectName??"default"],["Access",e.canEnrol?i.amber("scan, and enrol secrets on this project only"):"scan only. Ask for an invite that allows enrolling to add secrets"],["Key",`${e.keyFingerprint} ${i.dim("(a fingerprint of the key, never the key)")}`],["Server",e.serverUrl],["Stored in",`${y()} ${i.dim("(readable only by you)")}`]],{indent:0}),""],{tone:"green",width:78});return}console.log(`Connected to ${e.orgName??e.orgId}${e.projectName?`, project ${e.projectName}`:""}.`),console.log(` server ${e.serverUrl}`),console.log(` can enrol ${e.canEnrol?"yes, on this project only":"no (scan only). Ask for an invite that allows enrolling."}`),console.log(` key ${e.keyFingerprint} (a fingerprint, never the key)`)}async function F(e){const n=r=>(console.log(`
17
+ Could not confirm this with the server (${r}). The details above are what this machine saved.`),0);try{const r=await I(e),s=await T(r.serverUrl,"GET","/api/secrets",{token:r.token});return s.status===401||s.status===403?(console.log(`
18
+ The server no longer accepts this machine: it was removed or its session ended. Ask your admin for a new invite link and run redaktyn-connect again.`),1):s.ok?(console.log(" confirmed the server accepts this machine"),0):n(`HTTP ${s.status}`)}catch(r){if(r?.unreachable)return n(`could not reach ${e.serverUrl}`);if(r instanceof p)return console.log(`
19
+ ${r.message}`),1;throw r}}async function K(e){const n=e.slice(2);if(n.includes("--help")||n.includes("-h"))return O(),0;if(n.includes("--logout")){const t=!!v();return S(),console.log(t?"Forgot this machine's Redaktyn session. Your admin still sees this machine, and it keeps its seat, until they remove it.":"There was no session to forget."),0}if(n.includes("--status")){const t=v();return t?(k(t),F(t)):(console.log("Not connected. Run: redaktyn-connect"),1)}const r=n.indexOf("--server"),s=r>=0?n[r+1]:void 0,f=n.indexOf("--name");let a=f>=0?n[f+1]:void 0,o=n.find((t,m)=>!t.startsWith("--")&&n[m-1]!=="--server"&&n[m-1]!=="--name");if(n.includes("--stdin")&&(o=await Y()),!o){if(!process.stdin.isTTY)return console.error("Pass the link, use --stdin, or run this in a terminal."),2;o=await u("Invite link: ")}if(a===void 0&&process.stdin.isTTY&&n.indexOf("--stdin")<0){const t=await j(o,{serverUrl:s});t&&!t.assignedName&&(a=await u("Your name (your admin sees it in the dashboard; press Enter to skip): "))}let c=process.env.REDAKTYN_SECURITY_CODE;try{!c&&x(o)&&process.stdin.isTTY&&(c=await u("Security code (your admin sent it with the link): "))}catch{}E("connect this machine",C);const h=R("Redeeming the invite and unsealing the project key on this machine");try{const t=await N(o,{serverUrl:s,replaceOtherWorkspace:n.includes("--switch"),...c?{inviteCode:c}:{},...b(a)?{userName:a}:{},onWaiting:()=>{h.update("Waiting for your admin to approve this device. Keep this open: it finishes by itself"),l||console.log("Waiting for your admin to approve this device. Keep this open: it finishes by itself.")}});return h.succeed(`Invite redeemed ${i.dim("(the key was unsealed here; the server never saw it)")}`),k(t),l?(d(""),d(` ${i.dim("Next")} ${i.bold("redaktyn-import --env .env")} ${i.dim("(add --apply to register)")}`),d("")):console.log(`
20
+ Next: redaktyn-import --env .env (add --apply to register)`),0}catch(t){if(t instanceof p)return h.fail(t.message),l||console.error(t.message),1;throw t}}function P(){try{return w(process.argv[1])}catch{return process.argv[1]}}process.argv[1]&&import.meta.url===g(P()).href&&K(process.argv).then(e=>process.exit(e),e=>{console.error(e.message),process.exit(1)});export{K as main};
@@ -1,29 +1,29 @@
1
1
  #!/usr/bin/env node
2
- import{createHmac as A,pbkdf2 as L,createHash as O,hkdfSync as _}from"node:crypto";import{promisify as k}from"node:util";import*as E from"node:readline";import{readFileSync as C,existsSync as Y,realpathSync as F}from"node:fs";import{pathToFileURL as K}from"node:url";const U=k(L),a=process.argv.includes("--no-color")||process.env.NO_COLOR,e={reset:a?"":"\x1B[0m",bold:a?"":"\x1B[1m",dim:a?"":"\x1B[2m",red:a?"":"\x1B[31m",green:a?"":"\x1B[32m",yellow:a?"":"\x1B[33m",blue:a?"":"\x1B[34m",magenta:a?"":"\x1B[35m",cyan:a?"":"\x1B[36m",white:a?"":"\x1B[37m",gray:a?"":"\x1B[90m",bgBlue:a?"":"\x1B[44m",underline:a?"":"\x1B[4m"},b=6e5,v=32;function M(o){let r=o.normalize("NFC");return r=r.replace(/\r\n/g,`
2
+ import{createHmac as A,pbkdf2 as k,createHash as N,hkdfSync as L}from"node:crypto";import{promisify as O}from"node:util";import*as _ from"node:readline";import{readFileSync as C,existsSync as F,realpathSync as Y}from"node:fs";import{pathToFileURL as K}from"node:url";const U=O(k),i=process.argv.includes("--no-color")||process.env.NO_COLOR,e={reset:i?"":"\x1B[0m",bold:i?"":"\x1B[1m",dim:i?"":"\x1B[2m",red:i?"":"\x1B[31m",green:i?"":"\x1B[32m",yellow:i?"":"\x1B[33m",blue:i?"":"\x1B[34m",magenta:i?"":"\x1B[35m",cyan:i?"":"\x1B[36m",white:i?"":"\x1B[37m",gray:i?"":"\x1B[90m",bgBlue:i?"":"\x1B[44m",underline:i?"":"\x1B[4m"},y=6e5,v=32;function M(o){let r=o.normalize("NFC");return r=r.replace(/\r\n/g,`
3
3
  `).replace(/[\r\u2028\u2029]/g,`
4
- `),r=r.replace(/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g,""),r=r.trim(),P(r)}function P(o){return o.length===36&&o[8]==="-"&&o[13]==="-"&&o[18]==="-"&&o[23]==="-"&&/^[0-9a-fA-F]{32}$/.test(o.replace(/-/g,""))?o.toLowerCase():o}async function H(o,r){const t=O("sha256").update(r,"utf8").digest();return U(o,t,b,v,"sha256")}const j="redaktyn-project-key-v1";function $e(o,r){if(!r)throw new Error("deriveProjectKey: projectId is required");return Buffer.from(_("sha256",o,Buffer.from(r,"utf8"),Buffer.from(j,"utf8"),v))}function R(o){const r=o.normalize("NFC").replace(/\r\n/g,`
4
+ `),r=r.replace(/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g,""),r=r.trim(),E(r)}function E(o){return o.length===36&&o[8]==="-"&&o[13]==="-"&&o[18]==="-"&&o[23]==="-"&&/^[0-9a-fA-F]{32}$/.test(o.replace(/-/g,""))?o.toLowerCase():o}async function P(o,r){const s=N("sha256").update(r,"utf8").digest();return U(o,s,y,v,"sha256")}const z="redaktyn-project-key-v1";function $e(o,r){if(!r)throw new Error("deriveProjectKey: projectId is required");return Buffer.from(L("sha256",o,Buffer.from(r,"utf8"),Buffer.from(z,"utf8"),v))}function H(o){const r=o.normalize("NFC").replace(/\r\n/g,`
5
5
  `).replace(/[\r\u2028\u2029]/g,`
6
- `).replace(/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g,"").trim().replace(/\s/g,"");return P(r)}function z(o){return R(o).toLowerCase()}function y(o,r){return A("sha256",r).update(o,"utf8").digest("hex")}const B="redaktyn-scan-prefilter-v1";function W(o,r){let t=0;for(let s=0;s<r.length;s++){const n=r.charCodeAt(s);t=Math.imul(t,o)+(n>=65&&n<=90?n+32:n)+1|0}return t>>>16}function J(o,r){const s=A("sha256",r).update(B,"utf8").digest().readInt32BE(0)|1,n=l=>W(s,l).toString(16).padStart(4,"0");return`v1.${n(o.exact)}${n(o.collapsed)}${n(o.lower)}`}function S(o,r,t,s){const n=q(r,t);return{label:o,orgId:s,hmacHex:n.digest,length:n.length,hmacHexStripped:n.hmacHexStripped,lengthStripped:n.lengthStripped,hmacHexLower:n.hmacHexLower,lengthLower:n.lengthLower,scanPrefilter:n.scanPrefilter,canonical:n.canonical}}function q(o,r){const t=M(o),s=R(o),n=z(o);return{digest:y(t,r),canonical:t,length:t.length,hmacHexStripped:y(s,r),lengthStripped:s.length,hmacHexLower:y(n,r),lengthLower:n.length,scanPrefilter:J({exact:t,collapsed:s,lower:n},r)}}function G(o){const r={help:!1,json:!1,noColor:!1,orgId:null,passphrase:null,secret:null,label:null,batch:null,allowSecretArgv:!1,allowPassphraseArgv:!1,secretFromArgv:!1,passphraseFromArgv:!1};for(let t=2;t<o.length;t++){const s=o[t];s==="--help"||s==="-h"?r.help=!0:s==="--json"?r.json=!0:s==="--no-color"?r.noColor=!0:s==="--org-id"?r.orgId=o[++t]??null:s==="--passphrase"?(r.passphrase=o[++t]??null,r.passphraseFromArgv=!0):s==="--secret"?(r.secret=o[++t]??null,r.secretFromArgv=!0):s==="--label"?r.label=o[++t]??null:s==="--batch"?r.batch=o[++t]??null:s==="--i-know-this-leaks-to-history"&&(r.allowSecretArgv=!0,r.allowPassphraseArgv=!0)}return r}function Z(o){const r=[];o.secretFromArgv&&!o.allowSecretArgv&&r.push("--secret"),o.passphraseFromArgv&&!o.allowPassphraseArgv&&r.push("--passphrase"),r.length!==0&&(console.error(`${e.red}${e.bold}Refusing to read sensitive material from argv: ${r.join(", ")}${e.reset}`),console.error(""),console.error(" argv is recorded in shell history and is visible to other processes."),console.error(" Use one of the safer paths instead:"),console.error(""),console.error(` ${e.green}# 1) env var (passphrase)${e.reset}`),console.error(" REDAKTYN_PASSPHRASE='\u2026' node redaktyn-fingerprint.mjs \\"),console.error(" --org-id <uuid> --label MY_KEY"),console.error(""),console.error(` ${e.green}# 2) stdin pipe (secret)${e.reset}`),console.error(` printf '%s' "$SECRET" | node redaktyn-fingerprint.mjs \\`),console.error(" --org-id <uuid> --label MY_KEY"),console.error(""),console.error(` ${e.green}# 3) interactive wizard (no flags) \u2014 hidden TTY prompts${e.reset}`),console.error(" node redaktyn-fingerprint.mjs"),console.error(""),console.error(` ${e.dim}If you absolutely must keep using argv (e.g. one-off in an ephemeral`),console.error(` shell where history is disabled), pass --i-know-this-leaks-to-history.${e.reset}`),process.exit(2))}const X=Math.min(process.stdout.columns||80,80);function c(o="\u2500"){return e.gray+o.repeat(X)+e.reset}function he(o,r){return o+" ".repeat(Math.max(0,r-Q(o).length))}function Q(o){return o.replace(/\x1b\[[0-9;]*m/g,"")}function V(){const o=`${e.bold}${e.cyan} Redaktyn${e.reset}${e.bold} Fingerprint CLI${e.reset}`,r=`${e.gray} Zero-knowledge local secret digest \u2014 nothing leaves your machine${e.reset}`;console.log(),console.log(c("\u2550")),console.log(` ${o}`),console.log(` ${r}`),console.log(c("\u2550")),console.log()}function ue(o,r,t){console.log(` ${e.blue}${e.bold}[${o}]${e.reset} ${e.bold}${r}${e.reset}`),t&&console.log(` ${e.gray}${t}${e.reset}`)}function I(o,r){console.log(` ${e.green}\u2713${e.reset} ${e.bold}${o}${e.reset} ${e.gray}${r}${e.reset}`)}function u(o){console.error(` ${e.yellow}\u26A0${e.reset} ${e.yellow}${o}${e.reset}`)}function fe(o){console.log(` ${e.cyan}\u2139${e.reset} ${e.gray}${o}${e.reset}`)}function ee(o){if(!process.stdout.isTTY||a)return{stop:n=>n&&console.log(` ${e.green}\u2713${e.reset} ${n}`)};const r=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"];let t=0;const s=setInterval(()=>{process.stdout.write(`\r ${e.cyan}${r[t++%r.length]}${e.reset} ${o} `)},80);return{stop:n=>{clearInterval(s),process.stdout.write(`\r ${e.green}\u2713${e.reset} ${n??o}`+" ".repeat(20)+`
7
- `)}}}function x(o){return new Promise(r=>{if(process.stdout.write(` ${e.bold}${o}${e.reset} `),!process.stdin.isTTY){let i="";process.stdin.setEncoding("utf8"),process.stdin.once("data",d=>{process.stdin.pause(),r(d.trim())});return}const t=E.createInterface({input:process.stdin,output:process.stdout,terminal:!0});let s="";const n=process.stdin.setRawMode;typeof n=="function"&&process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.setEncoding("utf8");const l=i=>{i===""&&process.exit(),i==="\r"||i===`
8
- `?(process.stdin.removeListener("data",l),typeof n=="function"&&process.stdin.setRawMode(!1),t.close(),process.stdout.write(`
9
- `),r(s)):i==="\x7F"||i==="\b"?s.length>0&&(s=s.slice(0,-1),process.stdout.write("\b \b")):(s+=i,process.stdout.write("\u2022"))};process.stdin.on("data",l)})}function f(o,r){return new Promise(t=>{const s=E.createInterface({input:process.stdin,output:process.stdout}),n=r?` ${e.bold}${o}${e.reset} ${e.gray}[${r}]${e.reset} `:` ${e.bold}${o}${e.reset} `;s.question(n,l=>{s.close(),t(l.trim()||r||"")})})}async function oe(o){const r=await f(`${o} ${e.gray}(y/N)${e.reset}:`);return r.toLowerCase()==="y"||r.toLowerCase()==="yes"}function T(o,r){if(r){console.log(JSON.stringify({...m(o),orgId:o.orgId},null,2));return}console.log(),console.log(c()),console.log(` ${e.green}${e.bold}\u2705 Fingerprint generated successfully${e.reset}`),console.log(c()),console.log(),console.log(` ${e.bold}Label ${e.reset}${e.cyan}${o.label}${e.reset}`),console.log(` ${e.bold}HMAC Hex ${e.reset}${e.yellow}${o.hmacHex}${e.reset}`),console.log(` ${e.bold} stripped ${e.reset}${e.gray}${o.hmacHexStripped}${e.reset}`),console.log(` ${e.bold} lower ${e.reset}${e.gray}${o.hmacHexLower}${e.reset}`),console.log(` ${e.bold}Length ${e.reset}${o.length} chars ${e.gray}(stripped ${o.lengthStripped}, lower ${o.lengthLower})${e.reset}`),console.log(` ${e.bold}Org ID ${e.reset}${e.gray}${o.orgId}${e.reset}`),console.log(` ${e.bold}Algorithm ${e.reset}PBKDF2-SHA256 \xB7 ${b.toLocaleString("en-US")} iters \xB7 HMAC-SHA256`),console.log(),console.log(` ${e.dim}\u2500\u2500 Ready to paste into dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${e.reset}`),console.log(),console.log(` ${e.bold}${e.blue}Copy this JSON and paste it using the "Import Digest" option:${e.reset}`),console.log(),JSON.stringify(m(o),null,4).split(`
10
- `).forEach(t=>console.log(` ${e.green}${t}${e.reset}`)),console.log(),console.log(c())}function m(o){return{label:o.label,hmacHex:o.hmacHex,length:o.length,hmacHexStripped:o.hmacHexStripped,lengthStripped:o.lengthStripped,hmacHexLower:o.hmacHexLower,lengthLower:o.lengthLower,...o.scanPrefilter?{scanPrefilter:o.scanPrefilter}:{}}}function D(o,r){if(r){console.log(JSON.stringify(o.map(m),null,2));return}console.log(),console.log(c()),console.log(` ${e.green}${e.bold}\u2705 ${o.length} fingerprint(s) generated${e.reset}`),console.log(c()),console.log(),o.forEach((t,s)=>{console.log(` ${e.bold}${e.cyan}[${s+1}] ${t.label}${e.reset}`),console.log(` hmacHex ${e.yellow}${t.hmacHex.slice(0,16)}\u2026${t.hmacHex.slice(-8)}${e.reset}`),console.log(` length ${t.length}`),console.log()}),console.log(` ${e.bold}${e.blue}Full JSON (paste into API /api/secrets or Import Digest):${e.reset}`),console.log(),JSON.stringify(o.map(m),null,4).split(`
11
- `).forEach(t=>console.log(` ${e.green}${t}${e.reset}`)),console.log(),console.log(c())}function re(){console.log(`
6
+ `).replace(/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g,"").trim().replace(/\s/g,"");return E(r)}function B(o){return H(o).toLowerCase()}function b(o,r){return A("sha256",r).update(o,"utf8").digest("hex")}const j="redaktyn-scan-prefilter-v1";function W(o,r){let s=0;for(let t=0;t<r.length;t++){const n=r.charCodeAt(t);s=Math.imul(s,o)+(n>=65&&n<=90?n+32:n)+1|0}return s>>>16}function J(o,r){const t=A("sha256",r).update(j,"utf8").digest().readInt32BE(0)|1,n=a=>W(t,a).toString(16).padStart(4,"0");return`v1.${n(o.exact)}${n(o.collapsed)}${n(o.lower)}`}function S(o,r,s,t){const n=q(r,s);return{label:o,orgId:t,hmacHex:n.digest,length:n.length,hmacHexStripped:n.hmacHexStripped,lengthStripped:n.lengthStripped,hmacHexLower:n.hmacHexLower,lengthLower:n.lengthLower,scanPrefilter:n.scanPrefilter,canonical:n.canonical}}function q(o,r){const s=M(o),t=H(o),n=B(o);return{digest:b(s,r),canonical:s,length:s.length,hmacHexStripped:b(t,r),lengthStripped:t.length,hmacHexLower:b(n,r),lengthLower:n.length,scanPrefilter:J({exact:s,collapsed:t,lower:n},r)}}function G(o){const r={help:!1,json:!1,noColor:!1,orgId:null,passphrase:null,secret:null,label:null,batch:null,allowSecretArgv:!1,allowPassphraseArgv:!1,secretFromArgv:!1,passphraseFromArgv:!1};for(let s=2;s<o.length;s++){const t=o[s];t==="--help"||t==="-h"?r.help=!0:t==="--json"?r.json=!0:t==="--no-color"?r.noColor=!0:t==="--org-id"?r.orgId=o[++s]??null:t==="--passphrase"?(r.passphrase=o[++s]??null,r.passphraseFromArgv=!0):t==="--secret"?(r.secret=o[++s]??null,r.secretFromArgv=!0):t==="--label"?r.label=o[++s]??null:t==="--batch"?r.batch=o[++s]??null:t==="--i-know-this-leaks-to-history"&&(r.allowSecretArgv=!0,r.allowPassphraseArgv=!0)}return r}function Z(o){const r=[];o.secretFromArgv&&!o.allowSecretArgv&&r.push("--secret"),o.passphraseFromArgv&&!o.allowPassphraseArgv&&r.push("--passphrase"),r.length!==0&&(console.error(`${e.red}${e.bold}Refusing to read sensitive material from argv: ${r.join(", ")}${e.reset}`),console.error(""),console.error(" argv is recorded in shell history and is visible to other processes."),console.error(" Use one of the safer paths instead:"),console.error(""),console.error(` ${e.green}# 1) env var (passphrase)${e.reset}`),console.error(" REDAKTYN_PASSPHRASE='\u2026' redaktyn-fingerprint \\"),console.error(" --org-id <uuid> --label MY_KEY"),console.error(""),console.error(` ${e.green}# 2) stdin pipe (secret)${e.reset}`),console.error(` printf '%s' "$SECRET" | redaktyn-fingerprint \\`),console.error(" --org-id <uuid> --label MY_KEY"),console.error(""),console.error(` ${e.green}# 3) interactive wizard (no flags) \u2014 hidden TTY prompts${e.reset}`),console.error(" redaktyn-fingerprint"),console.error(""),console.error(` ${e.dim}If you absolutely must keep using argv (e.g. one-off in an ephemeral`),console.error(` shell where history is disabled), pass --i-know-this-leaks-to-history.${e.reset}`),process.exit(2))}const X=Math.min(process.stdout.columns||80,80);function c(o="\u2500"){return e.gray+o.repeat(X)+e.reset}function he(o,r){return o+" ".repeat(Math.max(0,r-Q(o).length))}function Q(o){return o.replace(/\x1b\[[0-9;]*m/g,"")}function V(){const o=`${e.bold}${e.cyan} Redaktyn${e.reset}${e.bold} Fingerprint CLI${e.reset}`,r=`${e.gray} Zero-knowledge local secret digest \u2014 nothing leaves your machine${e.reset}`;console.log(),console.log(c("\u2550")),console.log(` ${o}`),console.log(` ${r}`),console.log(c("\u2550")),console.log()}function ue(o,r,s){console.log(` ${e.blue}${e.bold}[${o}]${e.reset} ${e.bold}${r}${e.reset}`),s&&console.log(` ${e.gray}${s}${e.reset}`)}function R(o,r){console.log(` ${e.green}\u2713${e.reset} ${e.bold}${o}${e.reset} ${e.gray}${r}${e.reset}`)}function u(o){console.error(` ${e.yellow}\u26A0${e.reset} ${e.yellow}${o}${e.reset}`)}function fe(o){console.log(` ${e.cyan}\u2139${e.reset} ${e.gray}${o}${e.reset}`)}function ee(o){if(!process.stdout.isTTY||i)return{stop:n=>n&&console.log(` ${e.green}\u2713${e.reset} ${n}`)};const r=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"];let s=0;const t=setInterval(()=>{process.stdout.write(`\r ${e.cyan}${r[s++%r.length]}${e.reset} ${o} `)},80);return{stop:n=>{clearInterval(t),process.stdout.write(`\r ${e.green}\u2713${e.reset} ${n??o}`+" ".repeat(20)+`
7
+ `)}}}function w(o){return new Promise(r=>{if(process.stdout.write(` ${e.bold}${o}${e.reset} `),!process.stdin.isTTY){let a="";process.stdin.setEncoding("utf8"),process.stdin.once("data",l=>{process.stdin.pause(),r(l.trim())});return}let s="";const t=process.stdin.setRawMode;typeof t=="function"&&process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.setEncoding("utf8");const n=a=>{for(const l of a){if(l===""&&process.exit(),l==="\r"||l===`
8
+ `){process.stdin.removeListener("data",n),typeof t=="function"&&process.stdin.setRawMode(!1),process.stdin.pause(),process.stdout.write(`
9
+ `),r(s);return}l==="\x7F"||l==="\b"?s.length>0&&(s=s.slice(0,-1),process.stdout.write("\b \b")):(s+=l,process.stdout.write("\u2022"))}};process.stdin.on("data",n)})}function f(o,r){return new Promise(s=>{const t=_.createInterface({input:process.stdin,output:process.stdout}),n=r?` ${e.bold}${o}${e.reset} ${e.gray}[${r}]${e.reset} `:` ${e.bold}${o}${e.reset} `;t.question(n,a=>{t.close(),s(a.trim()||r||"")})})}async function oe(o){const r=await f(`${o} ${e.gray}(y/N)${e.reset}:`);return r.toLowerCase()==="y"||r.toLowerCase()==="yes"}function I(o,r){if(r){console.log(JSON.stringify({...m(o),orgId:o.orgId},null,2));return}console.log(),console.log(c()),console.log(` ${e.green}${e.bold}\u2705 Fingerprint generated successfully${e.reset}`),console.log(c()),console.log(),console.log(` ${e.bold}Label ${e.reset}${e.cyan}${o.label}${e.reset}`),console.log(` ${e.bold}HMAC Hex ${e.reset}${e.yellow}${o.hmacHex}${e.reset}`),console.log(` ${e.bold} stripped ${e.reset}${e.gray}${o.hmacHexStripped}${e.reset}`),console.log(` ${e.bold} lower ${e.reset}${e.gray}${o.hmacHexLower}${e.reset}`),console.log(` ${e.bold}Length ${e.reset}${o.length} chars ${e.gray}(stripped ${o.lengthStripped}, lower ${o.lengthLower})${e.reset}`),console.log(` ${e.bold}Org ID ${e.reset}${e.gray}${o.orgId}${e.reset}`),console.log(` ${e.bold}Algorithm ${e.reset}PBKDF2-SHA256 \xB7 ${y.toLocaleString("en-US")} iters \xB7 HMAC-SHA256`),console.log(),console.log(` ${e.dim}\u2500\u2500 Ready to paste into dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${e.reset}`),console.log(),console.log(` ${e.bold}${e.blue}Copy this JSON and paste it using the "Import Digest" option:${e.reset}`),console.log(),JSON.stringify(m(o),null,4).split(`
10
+ `).forEach(s=>console.log(` ${e.green}${s}${e.reset}`)),console.log(),console.log(c())}function m(o){return{label:o.label,hmacHex:o.hmacHex,length:o.length,hmacHexStripped:o.hmacHexStripped,lengthStripped:o.lengthStripped,hmacHexLower:o.hmacHexLower,lengthLower:o.lengthLower,...o.scanPrefilter?{scanPrefilter:o.scanPrefilter}:{}}}function D(o,r){if(r){console.log(JSON.stringify(o.map(m),null,2));return}console.log(),console.log(c()),console.log(` ${e.green}${e.bold}\u2705 ${o.length} fingerprint(s) generated${e.reset}`),console.log(c()),console.log(),o.forEach((s,t)=>{console.log(` ${e.bold}${e.cyan}[${t+1}] ${s.label}${e.reset}`),console.log(` hmacHex ${e.yellow}${s.hmacHex.slice(0,16)}\u2026${s.hmacHex.slice(-8)}${e.reset}`),console.log(` length ${s.length}`),console.log()}),console.log(` ${e.bold}${e.blue}Full JSON (paste into API /api/secrets or Import Digest):${e.reset}`),console.log(),JSON.stringify(o.map(m),null,4).split(`
11
+ `).forEach(s=>console.log(` ${e.green}${s}${e.reset}`)),console.log(),console.log(c())}function re(){console.log(`
12
12
  ${e.bold}${e.cyan}Redaktyn Fingerprint CLI${e.reset} \u2014 Zero-knowledge local secret digest generator
13
13
 
14
14
  ${e.bold}USAGE${e.reset}
15
15
 
16
16
  ${e.green}Interactive wizard (recommended):${e.reset}
17
- node redaktyn-fingerprint.mjs
17
+ redaktyn-fingerprint
18
18
 
19
19
  ${e.green}Non-interactive (scripting / CI):${e.reset}
20
- REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs \\
20
+ REDAKTYN_PASSPHRASE="..." redaktyn-fingerprint \\
21
21
  --org-id "your-org-uuid" \\
22
22
  --label "MY_SECRET_KEY" \\
23
23
  --secret "raw-secret-value"
24
24
 
25
25
  ${e.green}Batch mode (file with label:secret lines):${e.reset}
26
- REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs \\
26
+ REDAKTYN_PASSPHRASE="..." redaktyn-fingerprint \\
27
27
  --batch secrets.txt --org-id "your-org-uuid"
28
28
 
29
29
  ${e.bold}OPTIONS${e.reset}
@@ -39,7 +39,7 @@ ${e.bold}OPTIONS${e.reset}
39
39
 
40
40
  ${e.bold}HOW IT WORKS${e.reset}
41
41
 
42
- 1. Your passphrase + org ID \u2192 PBKDF2-SHA256 (${b.toLocaleString("en-US")} iters) \u2192 256-bit HMAC key
42
+ 1. Your passphrase + org ID \u2192 PBKDF2-SHA256 (${y.toLocaleString("en-US")} iters) \u2192 256-bit HMAC key
43
43
  2. Secret \u2192 NFC normalize + strip invisible Unicode + trim + UUID case-fold
44
44
  3. HMAC-SHA256 over that, plus two bypass variants:
45
45
  ${e.bold}hmacHex${e.reset} exact canonical form
@@ -74,10 +74,10 @@ ${e.bold}SECURITY NOTE${e.reset}
74
74
  (argv is recorded in shell history and visible to other processes).
75
75
  \u2500 Preferred: REDAKTYN_PASSPHRASE env var + stdin pipe for the secret:
76
76
  printf '%s' "$SECRET" | REDAKTYN_PASSPHRASE='\u2026' \\
77
- node redaktyn-fingerprint.mjs --org-id <uuid> --label MY_KEY
77
+ redaktyn-fingerprint --org-id <uuid> --label MY_KEY
78
78
  \u2500 Override with --i-know-this-leaks-to-history only in disposable shells.
79
79
  \u2500 This tool is intentionally dependency-free. Audit it: it's ~300 lines of Node.js.${e.reset}
80
80
 
81
- `)}function te(o){Y(o)||(console.error(`${e.red}Error: batch file not found: ${o}${e.reset}`),process.exit(1));const r=C(o,"utf8").split(`
82
- `),t=[];for(const[s,n]of r.entries()){const l=n.trim();if(!l||l.startsWith("#"))continue;const i=l.indexOf(":");if(i<1){u(`Skipping malformed line ${s+1} (expected LABEL:value).`);continue}t.push({label:l.slice(0,i).trim(),secret:l.slice(i+1)})}return t}const w=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;async function se(o){V(),console.log(` ${e.bold}Welcome!${e.reset} This wizard generates a zero-knowledge fingerprint of`),console.log(` your secret locally. Nothing is sent anywhere.
83
- `),console.log(c("\xB7")),console.log(` ${e.bold}${e.magenta}STEP 1 \u2014 Organization ID${e.reset}`),console.log(` ${e.gray}Find this in: Dashboard \u2192 Settings \u2192 Organization Details${e.reset}`),console.log();let r=o.orgId?.trim()??"";if(!r||!w.test(r))for(r=await f("Org ID (UUID format, e.g. a1b2c3d4-\u2026):");!w.test(r.trim());)u("That doesn't look like a valid UUID. Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"),r=await f("Org ID:");else I("Org ID",r);r=r.trim(),console.log(),console.log(c("\xB7")),console.log(` ${e.bold}${e.magenta}STEP 2 \u2014 Organization Passphrase${e.reset}`),console.log(` ${e.gray}The same passphrase your team uses when connecting the extension.${e.reset}`),console.log(` ${e.gray}Tip: set REDAKTYN_PASSPHRASE in your shell to avoid retyping.${e.reset}`),console.log();let t=(process.env.REDAKTYN_PASSPHRASE&&String(process.env.REDAKTYN_PASSPHRASE))??"";if(t)I("Passphrase","\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (loaded from REDAKTYN_PASSPHRASE)");else for(t=await x("Passphrase (hidden):");!t||t.length<8;)u("Passphrase must be at least 8 characters."),t=await x("Passphrase:");const s=[];let n=!0,l=0;for(;n;){l++,console.log(),console.log(c("\xB7")),console.log(` ${e.bold}${e.magenta}STEP ${l===1?"3":l+2} \u2014 Secret #${l}${e.reset}`),console.log();const d=await f(`Label (human-readable name, e.g. AWS_SECRET_KEY${l>1,""}):`);if(!d){u("Label cannot be empty \u2014 skipping.");continue}const $=await x("Secret value (hidden):");if(!$){u("Secret cannot be empty \u2014 skipping.");continue}console.log(),console.log(` ${e.dim}\u2500\u2500 Computing fingerprint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${e.reset}`);const p=ee("Deriving HMAC key via PBKDF2-SHA256 (600,000 iterations)\u2026");let h;try{h=await H(t,r),p.stop("HMAC key derived "+e.gray+"(256-bit, browser-identical)"+e.reset)}catch(N){p.stop(),console.error(`${e.red}Key derivation failed: ${N.message}${e.reset}`),process.exit(1)}const g=S(d,$,h,r);console.log(),console.log(` ${e.dim}Step 1${e.reset} ${e.gray}NFC normalize + trim + UUID case-fold${e.reset}`),console.log(` ${e.gray}canonical form computed (${g.canonical.length} chars) \u2014 never printed${e.reset}`),console.log(` ${e.dim}Step 2${e.reset} ${e.gray}HMAC-SHA256(canonical, derivedKey)${e.reset}`),console.log(` ${e.yellow}${g.hmacHex.slice(0,16)}\u2026${g.hmacHex.slice(-8)}${e.reset} ${e.gray}(64-char hex, not reversible)${e.reset}`),console.log(` ${e.dim}Step 3${e.reset} ${e.gray}Two bypass variants: whitespace-collapsed and lowercased${e.reset}`),console.log(` ${e.gray}${g.hmacHexStripped.slice(0,16)}\u2026 \xB7 ${g.hmacHexLower.slice(0,16)}\u2026${e.reset}`),console.log(` ${e.dim}Step 4${e.reset} ${e.gray}Length recorded: ${g.length} chars${e.reset}`),s.push(g),console.log(),n=await oe("Add another secret with the same passphrase?")}const i=o.json;s.length===1?T(s[0],i):D(s,i),i||(console.log(` ${e.bold}${e.cyan}Next steps:${e.reset}`),console.log(` ${e.gray}1. Copy the JSON above${e.reset}`),console.log(` ${e.gray}2. Dashboard \u2192 Secrets \u2192 "Import Digest" button${e.reset}`),console.log(` ${e.gray}3. Paste the JSON and click Add${e.reset}`),console.log(` ${e.gray}4. Extensions pick it up on next sync (\u22645 min)${e.reset}`),console.log(),console.log(` ${e.dim}Your secret value was used only in-memory and is now gone.${e.reset}`),console.log())}async function ne(o){const r=o.orgId?.trim();(!r||!w.test(r))&&(console.error(`${e.red}Error: --org-id must be a valid UUID.${e.reset}`),console.error(" Find it in: Dashboard \u2192 Settings \u2192 Organization Details"),process.exit(1));const t=process.env.REDAKTYN_PASSPHRASE&&String(process.env.REDAKTYN_PASSPHRASE)||o.passphrase?.trim()||"";t||(console.error(`${e.red}Error: set REDAKTYN_PASSPHRASE env var or use --passphrase.${e.reset}`),process.exit(1));const s=await H(t,r);if(o.batch){const d=te(o.batch);d.length===0&&(console.error(`${e.red}Error: batch file is empty or has no valid entries.${e.reset}`),process.exit(1));const $=d.map(({label:p,secret:h})=>S(p,h,s,r));D($,o.json);return}const n=o.label?.trim(),l=o.secret;n||(console.error(`${e.red}Error: --label is required in non-interactive mode.${e.reset}`),process.exit(1));let i=l;i==null&&(process.stdin.isTTY||(i=await new Promise((d,$)=>{let p="";process.stdin.setEncoding("utf8"),process.stdin.on("data",h=>p+=h),process.stdin.on("end",()=>d(p.trim())),process.stdin.on("error",$)}))),i||(console.error(`${e.red}Error: --secret is required (or pipe on stdin).${e.reset}`),process.exit(1)),T(S(n,i,s,r),o.json)}async function le(){const o=G(process.argv);o.help&&(re(),process.exit(0)),Z(o),process.stdin.isTTY&&!o.orgId&&!o.secret&&!o.batch&&!o.label&&!process.env.REDAKTYN_PASSPHRASE?await se(o):await ne(o)}function ie(){try{return F(process.argv[1])}catch{return process.argv[1]}}const ae=!!process.argv[1]&&import.meta.url===K(ie()).href;ae&&le().catch(o=>{console.error(`${e.red}Fatal: ${o instanceof Error?o.message:o}${e.reset}`),process.exit(1)});export{b as PBKDF2_ITERATIONS,j as PROJECT_KDF_LABEL,M as canonicalize,q as computeHmac,H as deriveKey,$e as deriveProjectKey,m as enrollPayload,S as fingerprint,J as scanPrefilter};
81
+ `)}function se(o){F(o)||(console.error(`${e.red}Error: batch file not found: ${o}${e.reset}`),process.exit(1));const r=C(o,"utf8").split(`
82
+ `),s=[];for(const[t,n]of r.entries()){const a=n.trim();if(!a||a.startsWith("#"))continue;const l=a.indexOf(":");if(l<1){u(`Skipping malformed line ${t+1} (expected LABEL:value).`);continue}s.push({label:a.slice(0,l).trim(),secret:a.slice(l+1)})}return s}const x=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;async function te(o){V(),console.log(` ${e.bold}Welcome!${e.reset} This wizard generates a zero-knowledge fingerprint of`),console.log(` your secret locally. Nothing is sent anywhere.
83
+ `),console.log(c("\xB7")),console.log(` ${e.bold}${e.magenta}STEP 1 \u2014 Organization ID${e.reset}`),console.log(` ${e.gray}Find this in: Dashboard \u2192 Settings \u2192 Organization Details${e.reset}`),console.log();let r=o.orgId?.trim()??"";if(!r||!x.test(r))for(r=await f("Org ID (UUID format, e.g. a1b2c3d4-\u2026):");!x.test(r.trim());)u("That doesn't look like a valid UUID. Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"),r=await f("Org ID:");else R("Org ID",r);r=r.trim(),console.log(),console.log(c("\xB7")),console.log(` ${e.bold}${e.magenta}STEP 2 \u2014 Organization Passphrase${e.reset}`),console.log(` ${e.gray}The same passphrase your team uses when connecting the extension.${e.reset}`),console.log(` ${e.gray}Tip: set REDAKTYN_PASSPHRASE in your shell to avoid retyping.${e.reset}`),console.log(` ${e.yellow}Derives the key from the passphrase alone. For a workspace whose key is locked with the server${e.reset}`),console.log(` ${e.yellow}(Redaktyn 1.6.0+), use redaktyn-import instead.${e.reset}`),console.log();let s=(process.env.REDAKTYN_PASSPHRASE&&String(process.env.REDAKTYN_PASSPHRASE))??"";if(s)R("Passphrase","\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (loaded from REDAKTYN_PASSPHRASE)");else for(s=await w("Passphrase (hidden):");!s||s.length<8;)u("Passphrase must be at least 8 characters."),s=await w("Passphrase:");const t=[];let n=!0,a=0;for(;n;){a++,console.log(),console.log(c("\xB7")),console.log(` ${e.bold}${e.magenta}STEP ${a===1?"3":a+2} \u2014 Secret #${a}${e.reset}`),console.log();const d=await f(`Label (human-readable name, e.g. AWS_SECRET_KEY${a>1,""}):`);if(!d){u("Label cannot be empty \u2014 skipping.");continue}const $=await w("Secret value (hidden):");if(!$){u("Secret cannot be empty \u2014 skipping.");continue}console.log(),console.log(` ${e.dim}\u2500\u2500 Computing fingerprint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${e.reset}`);const p=ee("Deriving HMAC key via PBKDF2-SHA256 (600,000 iterations)\u2026");let h;try{h=await P(s,r),p.stop("HMAC key derived "+e.gray+"(256-bit, browser-identical)"+e.reset)}catch(T){p.stop(),console.error(`${e.red}Key derivation failed: ${T.message}${e.reset}`),process.exit(1)}const g=S(d,$,h,r);console.log(),console.log(` ${e.dim}Step 1${e.reset} ${e.gray}NFC normalize + trim + UUID case-fold${e.reset}`),console.log(` ${e.gray}canonical form computed (${g.canonical.length} chars) \u2014 never printed${e.reset}`),console.log(` ${e.dim}Step 2${e.reset} ${e.gray}HMAC-SHA256(canonical, derivedKey)${e.reset}`),console.log(` ${e.yellow}${g.hmacHex.slice(0,16)}\u2026${g.hmacHex.slice(-8)}${e.reset} ${e.gray}(64-char hex, not reversible)${e.reset}`),console.log(` ${e.dim}Step 3${e.reset} ${e.gray}Two bypass variants: whitespace-collapsed and lowercased${e.reset}`),console.log(` ${e.gray}${g.hmacHexStripped.slice(0,16)}\u2026 \xB7 ${g.hmacHexLower.slice(0,16)}\u2026${e.reset}`),console.log(` ${e.dim}Step 4${e.reset} ${e.gray}Length recorded: ${g.length} chars${e.reset}`),t.push(g),console.log(),n=await oe("Add another secret with the same passphrase?")}const l=o.json;t.length===1?I(t[0],l):D(t,l),l||(console.log(` ${e.bold}${e.cyan}Next steps:${e.reset}`),console.log(` ${e.gray}1. Copy the JSON above${e.reset}`),console.log(` ${e.gray}2. Dashboard \u2192 Secrets \u2192 "Import Digest" button${e.reset}`),console.log(` ${e.gray}3. Paste the JSON and click Add${e.reset}`),console.log(` ${e.gray}4. Extensions pick it up on next sync (\u22645 min)${e.reset}`),console.log(),console.log(` ${e.dim}Your secret value was used only in-memory and is now gone.${e.reset}`),console.log())}async function ne(o){const r=o.orgId?.trim();(!r||!x.test(r))&&(console.error(`${e.red}Error: --org-id must be a valid UUID.${e.reset}`),console.error(" Find it in: Dashboard \u2192 Settings \u2192 Organization Details"),process.exit(1));const s=process.env.REDAKTYN_PASSPHRASE&&String(process.env.REDAKTYN_PASSPHRASE)||o.passphrase?.trim()||"";s||(console.error(`${e.red}Error: set REDAKTYN_PASSPHRASE env var or use --passphrase.${e.reset}`),process.exit(1)),console.error(`${e.dim}Derives the key from the passphrase alone. For a workspace whose key is locked with the server (Redaktyn 1.6.0+), use redaktyn-import instead.${e.reset}`);const t=await P(s,r);if(o.batch){const d=se(o.batch);d.length===0&&(console.error(`${e.red}Error: batch file is empty or has no valid entries.${e.reset}`),process.exit(1));const $=d.map(({label:p,secret:h})=>S(p,h,t,r));D($,o.json);return}const n=o.label?.trim(),a=o.secret;n||(console.error(`${e.red}Error: --label is required in non-interactive mode.${e.reset}`),process.exit(1));let l=a;l==null&&(process.stdin.isTTY||(l=await new Promise((d,$)=>{let p="";process.stdin.setEncoding("utf8"),process.stdin.on("data",h=>p+=h),process.stdin.on("end",()=>d(p.trim())),process.stdin.on("error",$)}))),l||(console.error(`${e.red}Error: --secret is required (or pipe on stdin).${e.reset}`),process.exit(1)),I(S(n,l,t,r),o.json)}async function le(){const o=G(process.argv);o.help&&(re(),process.exit(0)),Z(o),process.stdin.isTTY&&!o.orgId&&!o.secret&&!o.batch&&!o.label&&!process.env.REDAKTYN_PASSPHRASE?await te(o):await ne(o)}function ae(){try{return Y(process.argv[1])}catch{return process.argv[1]}}const ie=!!process.argv[1]&&import.meta.url===K(ae()).href;ie&&le().catch(o=>{console.error(`${e.red}Fatal: ${o instanceof Error?o.message:o}${e.reset}`),process.exit(1)});export{y as PBKDF2_ITERATIONS,z as PROJECT_KDF_LABEL,M as canonicalize,q as computeHmac,P as deriveKey,$e as deriveProjectKey,m as enrollPayload,S as fingerprint,J as scanPrefilter};