@redaktyn/cli 0.0.0-stage → 1.5.7
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/LICENSE +26 -0
- package/README.md +629 -2
- package/package.json +51 -4
- package/redaktyn-clipboard.mjs +1 -0
- package/redaktyn-connect.mjs +13 -0
- package/redaktyn-demo.mjs +4 -0
- package/redaktyn-fingerprint.mjs +83 -0
- package/redaktyn-import.mjs +69 -0
- package/redaktyn-invite.mjs +16 -0
- package/redaktyn-login.mjs +18 -0
- package/redaktyn-passphrase.mjs +2 -0
- package/redaktyn-scan.mjs +26 -0
- package/redaktyn-session.mjs +7 -0
- package/redaktyn-shared.cjs +11 -0
- package/redaktyn-ui.mjs +5 -0
- package/redaktyn-verify.mjs +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redaktyn/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.5.7",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"redaktyn-fingerprint": "./redaktyn-fingerprint.mjs",
|
|
8
|
+
"redaktyn-import": "./redaktyn-import.mjs",
|
|
9
|
+
"redaktyn-scan": "./redaktyn-scan.mjs",
|
|
10
|
+
"redaktyn-verify": "./redaktyn-verify.mjs",
|
|
11
|
+
"redaktyn-connect": "./redaktyn-connect.mjs",
|
|
12
|
+
"redaktyn-invite": "./redaktyn-invite.mjs",
|
|
13
|
+
"redaktyn-login": "./redaktyn-login.mjs",
|
|
14
|
+
"redaktyn-demo": "./redaktyn-demo.mjs"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.0.0"
|
|
19
|
+
},
|
|
20
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"redaktyn",
|
|
23
|
+
"secret",
|
|
24
|
+
"secrets",
|
|
25
|
+
"dlp",
|
|
26
|
+
"scan",
|
|
27
|
+
"git",
|
|
28
|
+
"pre-commit",
|
|
29
|
+
"dotenv",
|
|
30
|
+
"vault",
|
|
31
|
+
"import"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"README.md",
|
|
38
|
+
"redaktyn-connect.mjs",
|
|
39
|
+
"redaktyn-clipboard.mjs",
|
|
40
|
+
"redaktyn-demo.mjs",
|
|
41
|
+
"redaktyn-fingerprint.mjs",
|
|
42
|
+
"redaktyn-import.mjs",
|
|
43
|
+
"redaktyn-invite.mjs",
|
|
44
|
+
"redaktyn-login.mjs",
|
|
45
|
+
"redaktyn-passphrase.mjs",
|
|
46
|
+
"redaktyn-scan.mjs",
|
|
47
|
+
"redaktyn-session.mjs",
|
|
48
|
+
"redaktyn-ui.mjs",
|
|
49
|
+
"redaktyn-verify.mjs",
|
|
50
|
+
"redaktyn-shared.cjs",
|
|
51
|
+
"LICENSE"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{spawnSync as n}from"node:child_process";function p(){return process.platform==="darwin"?[["pbcopy",[]]]:process.platform==="win32"?[["clip",[]]]:[["wl-copy",[]],["xclip",["-selection","clipboard"]],["xsel",["--clipboard","--input"]]]}function c(t){for(const[o,i]of p()){const r=n(o,i,{input:t,stdio:["pipe","ignore","ignore"],timeout:4e3});if(!r.error&&r.status===0)return{ok:!0,tool:o}}return{ok:!1}}export{c as copyToClipboard};
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
|
3
|
+
|
|
4
|
+
redaktyn-connect prompt for the link (not saved in shell history)
|
|
5
|
+
redaktyn-connect --stdin read the link from standard input
|
|
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
|
|
9
|
+
|
|
10
|
+
--server <url> Override the server (default: the link's own address)
|
|
11
|
+
|
|
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};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{pathToFileURL as b}from"node:url";import{realpathSync as v}from"node:fs";import{createRequire as S}from"node:module";import{canonicalize as f,computeHmac as h,deriveKey as k,enrollPayload as x}from"./redaktyn-fingerprint.mjs";import{banner as H,box as T,g as u,humanBytes as E,k as e,reveal as P,say as t,step as R,rows as A}from"./redaktyn-ui.mjs";const L=S(import.meta.url)("./package.json").version,c=r=>new Promise(i=>setTimeout(i,r)),s="sk_live_demo_4f9a1c7e2b8d5036",N="demo passphrase, not yours",B="demo-workspace-0000",O=(r,i)=>[...r].filter((d,o)=>d!==i[o]).length,y=r=>r.replace(/(.{4})/g,"$1 ").trim();async function K(r){if(r.includes("--help")||r.includes("-h"))return console.log("redaktyn-demo: watch a fingerprint being made, offline, with a made-up secret. No account needed."),0;H("how a fingerprint is made \xB7 offline demo",L),t(` ${e.dim("Everything below runs on this machine with a made-up key. Nothing is sent anywhere.")}`),t(""),t(` ${e.bold("1 The secret")}`),t(` ${e.amber(s)} ${e.dim("(fake, for this demo)")}`),await c(250),t(""),t(` ${e.bold("2 Normalise")} ${e.dim("so harmless formatting cannot change the answer")}`);const i=` ${s}\u200B \r
|
|
3
|
+
`;t(` ${e.dim("input ")} ${JSON.stringify(i)} ${e.dim(`${i.length} chars`)}`),t(` ${e.dim("normalised")} ${f(i)} ${e.dim(`${f(i).length} chars`)}`),await c(250),t(""),t(` ${e.bold("3 Derive the key")} ${e.dim("from a passphrase, on this machine, deliberately slow")}`);const d=R("PBKDF2-SHA256, 600,000 rounds"),o=await k(N,B);d.succeed(`Key derived ${e.dim("(32 bytes, held in memory, never shown, never sent)")}`),t(""),t(` ${e.bold("4 Fingerprint")} ${e.dim("HMAC-SHA256 of the normalised secret under that key")}`);const n=h(s,o);await P(" ",n.digest,{show:32}),t(` ${e.dim("Only this leaves the machine. It cannot be turned back into the secret without the key.")}`),t(""),t(` ${e.bold("5 Change one character")}`);const w=s.slice(0,-1)+"7",l=h(w,o);t(` ${e.dim("original ")} ${e.amber(y(n.digest.slice(0,32)))} ${e.dim("\u2026")}`),t(` ${e.dim("altered ")} ${e.amber(y(l.digest.slice(0,32)))} ${e.dim("\u2026")}`),t(` ${e.dim(`${O(n.digest,l.digest)} of 64 characters differ. Near-matches reveal nothing.`)}`),await c(250),t(""),t(` ${e.bold("6 A disguised paste is still caught")}`);const m=s.toUpperCase().replace(/(.{4})/g,"$1 ").trim(),g=h(m,o),p=g.hmacHexLower===n.hmacHexLower||g.hmacHexStripped===n.hmacHexStripped;t(` ${e.dim("pasted ")} ${m}`),t(` ${p?e.green(u.ok):e.red(u.fail)} ${p?"matches a stored variant (case or spacing changed)":"no match"}`),await c(250),t(""),t(` ${e.bold("7 What a server would receive")}`);const $={label:"STRIPE_KEY",...x({label:"STRIPE_KEY",hmacHex:n.digest,length:n.length,hmacHexStripped:n.hmacHexStripped,lengthStripped:n.lengthStripped,hmacHexLower:n.hmacHexLower,lengthLower:n.lengthLower,scanPrefilter:n.scanPrefilter})};return JSON.stringify($,null,2).replace(/[0-9a-f]{40,}/g,a=>`${a.slice(0,12)}\u2026(${a.length} hex)`).split(`
|
|
4
|
+
`).forEach(a=>t(` ${e.gray(a)}`)),t(""),T("The whole story",["",...A([["Request size",E(Buffer.byteLength(JSON.stringify($)))],["The secret",e.green("0 B")],["The passphrase",e.green("0 B")],["The key",e.green("0 B")]],{indent:0,labelWidth:16}),"",e.dim("Try it on your own .env. A dry run sends nothing:"),` ${e.bold("redaktyn-login")}`,` ${e.bold("redaktyn-import --env .env --project <name>")}`,""],{tone:"green",width:70}),t(""),0}function _(){try{return v(process.argv[1])}catch{return process.argv[1]}}process.argv[1]&&import.meta.url===b(_()).href&&K(process.argv.slice(2)).then(r=>process.exit(r),r=>{console.error(r.message),process.exit(1)});export{K as main};
|
|
@@ -0,0 +1,83 @@
|
|
|
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,`
|
|
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,`
|
|
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(`
|
|
12
|
+
${e.bold}${e.cyan}Redaktyn Fingerprint CLI${e.reset} \u2014 Zero-knowledge local secret digest generator
|
|
13
|
+
|
|
14
|
+
${e.bold}USAGE${e.reset}
|
|
15
|
+
|
|
16
|
+
${e.green}Interactive wizard (recommended):${e.reset}
|
|
17
|
+
node redaktyn-fingerprint.mjs
|
|
18
|
+
|
|
19
|
+
${e.green}Non-interactive (scripting / CI):${e.reset}
|
|
20
|
+
REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs \\
|
|
21
|
+
--org-id "your-org-uuid" \\
|
|
22
|
+
--label "MY_SECRET_KEY" \\
|
|
23
|
+
--secret "raw-secret-value"
|
|
24
|
+
|
|
25
|
+
${e.green}Batch mode (file with label:secret lines):${e.reset}
|
|
26
|
+
REDAKTYN_PASSPHRASE="..." node redaktyn-fingerprint.mjs \\
|
|
27
|
+
--batch secrets.txt --org-id "your-org-uuid"
|
|
28
|
+
|
|
29
|
+
${e.bold}OPTIONS${e.reset}
|
|
30
|
+
|
|
31
|
+
--org-id ${e.gray}<uuid> Your organization UUID from the dashboard Settings page${e.reset}
|
|
32
|
+
--passphrase ${e.gray}<str> Organization passphrase (prefer REDAKTYN_PASSPHRASE env var)${e.reset}
|
|
33
|
+
--label ${e.gray}<str> Human-readable name for the secret (e.g. AWS_SECRET_KEY)${e.reset}
|
|
34
|
+
--secret ${e.gray}<str> The raw secret value to fingerprint${e.reset}
|
|
35
|
+
--batch ${e.gray}<file> Path to a file; each line: label:secret_value${e.reset}
|
|
36
|
+
--json Output pure JSON (no colors, no table)
|
|
37
|
+
--no-color Disable ANSI colors
|
|
38
|
+
--help Show this help
|
|
39
|
+
|
|
40
|
+
${e.bold}HOW IT WORKS${e.reset}
|
|
41
|
+
|
|
42
|
+
1. Your passphrase + org ID \u2192 PBKDF2-SHA256 (${b.toLocaleString("en-US")} iters) \u2192 256-bit HMAC key
|
|
43
|
+
2. Secret \u2192 NFC normalize + strip invisible Unicode + trim + UUID case-fold
|
|
44
|
+
3. HMAC-SHA256 over that, plus two bypass variants:
|
|
45
|
+
${e.bold}hmacHex${e.reset} exact canonical form
|
|
46
|
+
${e.bold}hmacHexStripped${e.reset} all whitespace removed ("A mit@1" \u2192 "Amit@1")
|
|
47
|
+
${e.bold}hmacHexLower${e.reset} whitespace removed, then lowercased
|
|
48
|
+
|
|
49
|
+
All three are registered. Without them a secret is caught when pasted verbatim
|
|
50
|
+
and missed the moment someone adds a space or changes a letter's case.
|
|
51
|
+
|
|
52
|
+
${e.yellow}Nothing is sent to any server.${e.reset} The secret and passphrase never leave this machine.
|
|
53
|
+
The digests are byte-identical to what the Redaktyn dashboard computes in your
|
|
54
|
+
browser \u2014 ${e.bold}redaktyn-verify recompute${e.reset} recomputes them independently of this tool.
|
|
55
|
+
|
|
56
|
+
${e.bold}BATCH FILE FORMAT${e.reset}
|
|
57
|
+
|
|
58
|
+
One entry per line: ${e.cyan}LABEL:secret_value${e.reset}
|
|
59
|
+
Lines starting with # are ignored (comments).
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
# My company secrets
|
|
63
|
+
AWS_ACCESS_KEY:AKIAIOSFODNN7EXAMPLE
|
|
64
|
+
DATABASE_PASSWORD:s3cr3tPassw0rd!
|
|
65
|
+
STRIPE_SECRET:sk_live_abc123
|
|
66
|
+
|
|
67
|
+
${e.bold}FINDING YOUR ORG ID${e.reset}
|
|
68
|
+
|
|
69
|
+
Dashboard \u2192 Settings \u2192 scroll to "Organization Details" \u2192 copy the UUID.
|
|
70
|
+
|
|
71
|
+
${e.bold}SECURITY NOTE${e.reset}
|
|
72
|
+
|
|
73
|
+
${e.dim}\u2500 The CLI REFUSES to read --secret or --passphrase from argv by default
|
|
74
|
+
(argv is recorded in shell history and visible to other processes).
|
|
75
|
+
\u2500 Preferred: REDAKTYN_PASSPHRASE env var + stdin pipe for the secret:
|
|
76
|
+
printf '%s' "$SECRET" | REDAKTYN_PASSPHRASE='\u2026' \\
|
|
77
|
+
node redaktyn-fingerprint.mjs --org-id <uuid> --label MY_KEY
|
|
78
|
+
\u2500 Override with --i-know-this-leaks-to-history only in disposable shells.
|
|
79
|
+
\u2500 This tool is intentionally dependency-free. Audit it: it's ~300 lines of Node.js.${e.reset}
|
|
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};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{readFileSync as me,realpathSync as ye}from"node:fs";import{basename as W}from"node:path";import*as $e from"node:readline";import{pathToFileURL as we}from"node:url";import{canonicalize as ke,computeHmac as ve,deriveKey as be,deriveProjectKey as Se,enrollPayload as Re}from"./redaktyn-fingerprint.mjs";import{freshAdminSession as Ne,freshSession as je,loadAdminSession as U,loadSession as Ae,resolveServerUrl as Ee}from"./redaktyn-session.mjs";import{askWorkspacePassphrase as xe}from"./redaktyn-passphrase.mjs";import{banner as Te,bar as Oe,box as B,g as C,humanBytes as z,k as g,rich as R,reveal as Le,rows as K,say as w,step as G}from"./redaktyn-ui.mjs";import{createRequire as Ie}from"node:module";const _e=Ie(import.meta.url)("./package.json").version,x=process.argv.includes("--no-color")||!!process.env.NO_COLOR,s={reset:x?"":"\x1B[0m",bold:x?"":"\x1B[1m",dim:x?"":"\x1B[2m",red:x?"":"\x1B[31m",green:x?"":"\x1B[32m",yellow:x?"":"\x1B[33m",blue:x?"":"\x1B[34m",cyan:x?"":"\x1B[36m",gray:x?"":"\x1B[90m"};class m extends Error{}const V=15e5,Z=5e4,Pe=1e4,D=5*1024*1024,Ce=256,De=65536,He=/^v1\.[0-9a-f]{12}$/,X=e=>typeof e.scanPrefilter=="string"&&He.test(e.scanPrefilter);function Q(e,t=!1){const o=[new Map,new Map,new Map],r=(n,i,h)=>{if(!(i>0))return;const d=o[n].get(i);o[n].set(i,d?{windowLen:i,prefiltered:d.prefiltered&&h,entries:d.entries+1}:{windowLen:i,prefiltered:h,entries:1})};for(const n of e){const i=t||X(n);n.hmacHex&&r(0,n.length,i),typeof n.hmacHexStripped=="string"&&r(1,n.lengthStripped,i),typeof n.hmacHexLower=="string"&&r(2,n.lengthLower,i)}return o.flatMap(n=>[...n.values()])}function ee(e,t){let o=0;for(const r of t){const n=Math.max(0,e-r.windowLen+1);o+=r.prefiltered?Math.ceil(n/Ce)+Math.ceil(n*r.entries/De):n}return o}function te(e){if(e.length===0)return 1/0;if(ee(D,e)<=V)return D;let t=0,o=D;for(;t<o;){const r=Math.ceil((t+o)/2);ee(r,e)<=V?t=r:o=r-1}return t}function Me(e){const t=Q(e),o=te(t);return{windowLengths:t.length,ceiling:o,legacySecrets:e.filter(r=>r.hmacHex&&!X(r)).length,ceilingIfReenrolled:te(Q(e,!0)),atCharacterCap:o===D}}function We(e){return e.length===0||e.some(t=>t&&Object.prototype.hasOwnProperty.call(t,"scanPrefilter"))}function Ke(e){const t=e.data&&Array.isArray(e.data.unknownFields)?e.data.unknownFields:null;return e.status===400&&e.data?.code==="UNKNOWN_FIELD"&&t!==null&&t.length===1&&t[0]==="scanPrefilter"}function Ye(e){const t={help:!1,env:null,jsonFile:null,yaml:null,stdin:!1,source:null,apply:!1,yes:!1,minLength:null,allowWeak:!1,only:[],exclude:[],server:null,orgId:null,project:null,reportJson:!1,showRequest:!1,noColor:!1},o=(r,n)=>{const i=e[r];if(i===void 0||i.startsWith("--"))throw new m(`${n} needs a value`);return i};for(let r=2;r<e.length;r++){const n=e[r];if(n==="--help"||n==="-h")t.help=!0;else if(n==="--env")t.env=o(++r,"--env");else if(n==="--json")t.jsonFile=o(++r,"--json");else if(n==="--yaml")t.yaml=o(++r,"--yaml");else if(n==="--stdin")t.stdin=!0;else if(n==="--source")t.source=o(++r,"--source");else if(n==="--apply")t.apply=!0;else if(n==="--yes"||n==="-y")t.yes=!0;else if(n==="--min-length")t.minLength=Number(o(++r,"--min-length"));else if(n==="--allow-weak")t.allowWeak=!0;else if(n==="--only")t.only.push(o(++r,"--only"));else if(n==="--exclude")t.exclude.push(o(++r,"--exclude"));else if(n==="--server")t.server=o(++r,"--server");else if(n==="--org-id")t.orgId=o(++r,"--org-id");else if(n==="--project")t.project=o(++r,"--project");else if(n==="--report-json")t.reportJson=!0;else if(n==="--show-request")t.showRequest=!0;else if(n==="--no-color")t.noColor=!0;else throw new m(`Unknown flag: ${n}`)}if(t.minLength!==null&&(!Number.isInteger(t.minLength)||t.minLength<1))throw new m("--min-length must be a positive integer");return t}const Je=/^[A-Za-z_][A-Za-z0-9_.-]*$/;function Fe(e){const t=e.replace(/\r\n/g,`
|
|
3
|
+
`).replace(/\r/g,`
|
|
4
|
+
`),o=[],r=[];let n=0,i=1;const h=()=>{const c=t.indexOf(`
|
|
5
|
+
`,n);n=c===-1?t.length:c+1},d=(c,p)=>{let a=0;for(let l=c;l<p;l++)t[l]===`
|
|
6
|
+
`&&a++;return a};for(;n<t.length;){const c=t[n];if(c===`
|
|
7
|
+
`){i++,n++;continue}if(c===" "||c===" "){n++;continue}if(c==="#"){h(),i++;continue}const p=i;let a=n;for(;a<t.length&&t[a]!=="="&&t[a]!==`
|
|
8
|
+
`;)a++;if(a>=t.length||t[a]===`
|
|
9
|
+
`){r.push({line:p,message:"no '=' on this line \u2014 skipped"}),n=a+1,i++;continue}let l=t.slice(n,a).trim();for(/^export\s/.test(l)&&(l=l.replace(/^export\s+/,"").trim()),n=a+1;n<t.length&&(t[n]===" "||t[n]===" ");)n++;let y="",u=!0;const $=t[n];if($==='"'||$==="'"){const S=++n,k=t.indexOf(`
|
|
10
|
+
`,n);let v=!1;for(;n<t.length;){const b=t[n];if(b==="\\"&&$==='"'&&n+1<t.length){const A=t[n+1];y+=A==="n"?`
|
|
11
|
+
`:A==="t"?" ":A==="r"?"\r":A,n+=2;continue}if(b===$){n++,v=!0;break}y+=b,n++}v?(i+=d(S,n),h(),i++):(r.push({line:p,message:"unterminated quote \u2014 skipped this entry"}),u=!1,n=k===-1?t.length:k+1,i++)}else{const S=t.indexOf(`
|
|
12
|
+
`,n),k=S===-1?t.length:S;let v=t.slice(n,k);const b=v.search(/\s#/);b!==-1&&(v=v.slice(0,b)),y=v.trim(),n=k===t.length?k:k+1,i++}if(u){if(!Je.test(l)){r.push({line:p,message:`unusable key name ${JSON.stringify(l)} \u2014 skipped`});continue}o.push({key:l,value:y,line:p})}}return{entries:o,problems:r}}const qe=new Set(["data","metadata","lease_id","lease_duration","lease_renewable","renewable","request_id","warnings","auth","wrap_info","mount_type"]),I=e=>typeof e=="object"&&e!==null&&!Array.isArray(e);function ne(e){if(!I(e))throw new m("Expected a JSON object of secrets, got "+re(e));if(typeof e.SecretString=="string"){let r;try{r=JSON.parse(e.SecretString)}catch{throw new m("SecretString is not JSON. For a single-value secret pass it to redaktyn-fingerprint instead.")}return ne(r)}if(Array.isArray(e.fields)){const r={};for(const n of e.fields){if(!I(n))continue;const i=n.label??n.id;typeof i!="string"||typeof n.value!="string"||(r[i]=n.value)}return{shape:"1password-item",doc:r}}const t=Object.keys(e);if(t.length>0&&t.every(r=>qe.has(r))&&I(e.data)){const r=e.data;return I(r.data)&&"metadata"in r?{shape:"vault-kv2",doc:r.data}:{shape:"vault-kv1",doc:r}}return{shape:"flat",doc:e}}function re(e){return e===null?"null":Array.isArray(e)?"an array":typeof e}function Ue(e){const t=[],o=[];for(const[r,n]of Object.entries(e)){if(typeof n=="string"){t.push({key:r,value:n});continue}if(typeof n=="number"||typeof n=="boolean"){t.push({key:r,value:String(n)});continue}if(I(n)){const i=["value","raw","computed","computedValue"].find(h=>typeof n[h]=="string");if(i){t.push({key:r,value:n[i]});continue}o.push({key:r,message:"nested object with no value/raw/computed field \u2014 skipped"});continue}o.push({key:r,message:`${re(n)} is not a secret value \u2014 skipped`})}return{entries:t,problems:o}}function oe(e){let t;try{t=JSON.parse(e)}catch(h){throw new m(`Input is not valid JSON: ${h.message}`)}const{shape:o,doc:r}=ne(t),{entries:n,problems:i}=Ue(r);return{entries:n,problems:i,shape:o}}function Be(e){const t=[],o=[];return e.replace(/\r\n/g,`
|
|
13
|
+
`).replace(/\r/g,`
|
|
14
|
+
`).split(`
|
|
15
|
+
`).forEach((n,i)=>{const h=i+1;if(n.trim()===""||n.trim().startsWith("#")||n.trim()==="---"||n.trim()==="...")return;if(/^\s/.test(n))throw new m(`${h}: this YAML is indented, so it is not flat key/value. Refusing rather than guessing \u2014 convert it to JSON and use --json.`);if(/^-\s/.test(n.trim())||n.trim()==="-")throw new m(`${h}: YAML lists are not supported. Use --json.`);if(/^[|>]/.test(n.trim()))throw new m(`${h}: YAML block scalars are not supported. Use --json.`);const d=/^([A-Za-z_][A-Za-z0-9_.-]*)\s*:\s?(.*)$/.exec(n);if(!d){o.push({line:h,message:"not a `key: value` line \u2014 skipped"});return}const c=d[1];let p=d[2]??"";if(p.trim()===""||p.trim()==="|"||p.trim()===">")throw new m(`${h}: "${c}" has no inline value, which in YAML means a nested block or null. Refusing rather than registering an empty fingerprint.`);const a=p.trim();if(a.startsWith('"')&&a.endsWith('"')&&a.length>1||a.startsWith("'")&&a.endsWith("'")&&a.length>1)p=a.slice(1,-1),a[0]==='"'&&(p=p.replace(/\\n/g,`
|
|
16
|
+
`).replace(/\\t/g," ").replace(/\\(.)/g,"$1"));else{const l=a.search(/\s#/);p=(l===-1?a:a.slice(0,l)).trim()}t.push({key:c,value:p,line:h})}),{entries:t,problems:o}}const Y=12,ze=new Set(["changeme","change-me","change_me","replaceme","replace-me","your-secret-here","your_secret_here","yoursecrethere","development","production","staging","localhost","password","passw0rd","secret","topsecret","undefined","none","null","n/a","todo","tbd","example","test","dummy","placeholder"]);function se(e){if(e.length===0)return 0;const t=new Map;for(const r of e)t.set(r,(t.get(r)??0)+1);let o=0;for(const r of t.values()){const n=r/e.length;o-=n*Math.log2(n)}return o}function Ge(e){if(!/:\/\//.test(e))return null;let t;try{t=new URL(e)}catch{return null}return t.password?{ok:!0}:[...t.pathname.split("/"),...t.searchParams.values(),t.hash.replace(/^#/,"")].filter(Boolean).some(r=>r.length>=16&&se(r)>=2.5)?{ok:!0}:{ok:!1,reason:"URL with no embedded credential or token"}}function Ve(e,{minLength:t=Y}={}){if(e.length===0)return{ok:!1,reason:"empty value"};if(e.length<t)return{ok:!1,reason:`only ${e.length} chars (minimum ${t})`};if(/^\d+$/.test(e))return{ok:!1,reason:"all digits \u2014 port, id or timestamp"};if(/^(true|false|yes|no|on|off|null|nil|none|undefined|enabled|disabled)$/i.test(e))return{ok:!1,reason:"boolean or empty-ish flag"};if(/^v?\d+(\.\d+){1,3}([-+][0-9A-Za-z.-]+)?$/.test(e))return{ok:!1,reason:"version number"};if(ze.has(e.toLowerCase()))return{ok:!1,reason:"placeholder or environment name"};if(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(e))return{ok:!1,reason:"email address"};if(/^(\/[^\s]*|\.{1,2}\/[^\s]*|~\/[^\s]*|[A-Za-z]:[\\/][^\s]*)$/.test(e))return{ok:!1,reason:"filesystem path"};const o=Ge(e);if(o&&!o.ok)return{ok:!1,reason:o.reason};if(!o){const r=new Set(e).size;if(r<5)return{ok:!1,reason:`only ${r} distinct characters`};if(se(e)<2)return{ok:!1,reason:"very low character diversity"}}return{ok:!0}}async function Ze(){if(process.stdin.isTTY)throw new m("--stdin was passed but nothing is piped in.");const e=[];for await(const t of process.stdin)e.push(t);return Buffer.concat(e).toString("utf8")}function J(e,t){try{return me(e,"utf8")}catch(o){throw new m(`${t} ${e}: ${o.code==="ENOENT"?"no such file":o.message}`)}}function O(e){return`import:${e}`}function Xe(e){return String(e).replace(/^[./]+/,"").replace(/[^A-Za-z0-9._-]+/g,"-").replace(/^-+|-+$/g,"").slice(0,60)||"import"}function ie(e,t){return t.some(o=>o.includes("*")?new RegExp("^"+o.split("*").map(Qe).join(".*")+"$","i").test(e):e.toLowerCase()===o.toLowerCase())}const Qe=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");function et({candidates:e,existing:t,source:o}){const r=O(o),n=new Map;for(const l of t)l.hmacHex&&n.set(l.hmacHex,l);const i=t.filter(l=>Array.isArray(l.tags)&&l.tags.includes(r)),h=new Map(i.map(l=>[l.label,l])),d=new Map;for(const l of t)i.includes(l)||d.has(l.label)||d.set(l.label,l);const c=[],p=new Set;for(const l of e){const{key:y,digests:u}=l;p.add(y);const $=h.get(y);if($){if($.hmacHex===u.digest&&tt($,u))c.push({key:y,action:"skip",reason:"already registered and unchanged",id:$.id,length:u.length});else{const v=n.get(u.digest);if(v&&v.id!==$.id)c.push({key:y,action:"conflict",reason:`new value is already registered as "${v.label}"`,id:$.id,length:u.length});else{const b=$.hmacHex===u.digest?"value unchanged; adding missing variants":"value changed";c.push({key:y,action:"rotate",reason:b,id:$.id,length:u.length})}}continue}const S=n.get(u.digest);if(S){c.push({key:y,action:"skip",reason:`already registered as "${S.label}"`,id:S.id,length:u.length});continue}const k=d.get(y);if(k){c.push({key:y,action:"conflict",reason:"a secret with this label exists outside this import source",id:k.id,length:u.length});continue}c.push({key:y,action:"create",reason:"new",length:u.length})}const a=i.filter(l=>!p.has(l.label)).map(l=>({label:l.label,id:l.id,status:l.status}));return{plan:c,missing:a}}function tt(e,t){return e.hmacHexStripped===t.hmacHexStripped&&e.hmacHexLower===t.hmacHexLower&&e.scanPrefilter===t.scanPrefilter}const ae=e=>e.replace(/\/+$/,"");async function F(e,t,o,r,n){let i;try{i=await fetch(`${ae(e)}${r}`,{method:o,headers:{Authorization:`Bearer ${t}`,...n?{"Content-Type":"application/json"}:{}},...n?{body:JSON.stringify(n)}:{}})}catch(c){throw new m(`Cannot reach ${ae(e)}: ${c.message}`)}const h=await i.text();let d=null;if(h)try{d=JSON.parse(h)}catch{d=null}return{status:i.status,ok:i.ok,data:d,text:h}}async function nt(e,t,o){const r=await F(e,t,"GET","/api/projects");if(!r.ok||!Array.isArray(r.data))throw new m(`GET /api/projects failed: ${r.status} ${String(r.text).slice(0,200)}`);const n=o.trim().toLowerCase(),i=r.data.filter(d=>d.id===o.trim()),h=i.length?i:r.data.filter(d=>String(d.name).toLowerCase()===n);if(h.length===0)throw new m(`No project named or numbered "${o}". Known: ${r.data.map(d=>d.name).join(", ")||"none"}.`);if(h.length>1)throw new m(`"${o}" matches ${h.length} projects. Pass the project id instead.`);if(h[0].status==="CLOSED")throw new m(`Project "${h[0].name}" is closed. Reopen it before enrolling into it.`);return h[0]}async function rt(e,t,o=!1){const r=await F(e,t,"GET","/api/secrets");if(o&&(r.status===401||r.status===403))throw new m("This machine's Redaktyn session is no longer valid: the device was removed or its session ended. Ask your admin for a new invite link and run `redaktyn-connect` again.");if(r.status===401)throw new m("Server rejected the token (401). REDAKTYN_TOKEN must be a current OWNER or ADMIN login token.");if(r.status===403)throw new m("This token is not allowed to manage secrets (403). Importing needs an OWNER or ADMIN token.");if(!r.ok)throw new m(`GET /api/secrets failed: ${r.status} ${r.text.slice(0,200)}`);if(!Array.isArray(r.data))throw new m("GET /api/secrets did not return a list.");return r.data}function ot(e){try{const t=e.split(".")[1];if(!t)return null;const o=t+"=".repeat((4-t.length%4)%4),r=Buffer.from(o.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8");return JSON.parse(r)}catch{return null}}function st(e){if(!e)return null;if(e.type==="extension")return`REDAKTYN_TOKEN is an extension device token. Those can sync fingerprints but not register them.
|
|
17
|
+
Get an OWNER or ADMIN login token instead:
|
|
18
|
+
curl -s -X POST "$REDAKTYN_SERVER_URL/api/auth/login" \\
|
|
19
|
+
-H 'Content-Type: application/json' \\
|
|
20
|
+
-d '{"email":"you@example.com","password":"\u2026"}' | jq -r .token`;const t=e.role;return typeof t=="string"&&!["OWNER","ADMIN"].includes(t.toUpperCase())?`This token has role ${t}. Importing needs OWNER or ADMIN.`:null}const it={create:{label:"CREATE",color:s.green},rotate:{label:"ROTATE",color:s.yellow},skip:{label:"skip",color:s.gray},conflict:{label:"CONFLICT",color:s.red},rejected:{label:"rejected",color:s.gray}};function le(e,t){return e.length<=t?e.padEnd(t):e.slice(0,t-1)+"\u2026"}function at({rows:e,missing:t,source:o,dryRun:r,budget:n,projectName:i=null}){const h=Math.min(process.stdout.columns||80,100),d=34;console.log(""),console.log(`${s.bold}Redaktyn import${s.reset} ${s.dim}\xB7 source tag ${O(o)}${i?` \xB7 project ${i}`:""}${s.reset}`),console.log(s.dim+"\u2500".repeat(h)+s.reset),console.log(`${s.dim}${"KEY".padEnd(d)} ${"LEN".padStart(5)} ${"ACTION".padEnd(9)} WHY${s.reset}`);for(const a of e){const l=it[a.action]??{label:a.action,color:""};console.log(`${le(a.key,d)} ${String(a.length??"-").padStart(5)} ${l.color}${l.label.padEnd(9)}${s.reset} ${s.dim}${a.reason??""}${s.reset}`)}console.log(s.dim+"\u2500".repeat(h)+s.reset);const c=H(e),p=[c.create?`${s.green}${c.create} to create${s.reset}`:null,c.rotate?`${s.yellow}${c.rotate} to rotate${s.reset}`:null,c.skip?`${c.skip} unchanged`:null,c.rejected?`${c.rejected} filtered out`:null,c.conflict?`${s.red}${c.conflict} conflicting${s.reset}`:null].filter(Boolean);if(console.log(" "+(p.length?p.join(s.dim+" \xB7 "+s.reset):"nothing to do")),t.length>0){console.log(""),console.log(`${s.yellow}${t.length} secret(s) tagged ${O(o)} are not in this input.${s.reset}`),console.log(`${s.dim} Reported only \u2014 import never removes anything. Retire them in the dashboard if they are gone.${s.reset}`);for(const a of t.slice(0,20))console.log(`${s.dim} \xB7 ${a.label}${s.reset}`);t.length>20&&console.log(`${s.dim} \xB7 \u2026 ${t.length-20} more${s.reset}`)}q(n),r&&(console.log(""),console.log(`${s.bold}Dry run \u2014 nothing was sent.${s.reset} ${s.dim}Re-run with --apply to register.${s.reset}`)),console.log("")}const lt={create:{label:"CREATE",paint:g.green},rotate:{label:"ROTATE",paint:g.amber},skip:{label:"skip",paint:g.gray},conflict:{label:"CONFLICT",paint:g.red},rejected:{label:"rejected",paint:g.gray}};function ct({rows:e,missing:t,source:o,dryRun:r,budget:n,projectName:i=null,byKey:h}){const d=Math.min(process.stderr.columns||90,100),c=30;w(""),w(` ${g.bold("Plan")} ${g.dim(`source tag ${O(o)}${i?` \xB7 project ${i}`:""}`)}`),w(` ${g.gray("\u2500".repeat(d-4))}`),w(` ${g.gray(`${"KEY".padEnd(c)} ${"LEN".padStart(4)} ${"ACTION".padEnd(8)} ${"FINGERPRINT".padEnd(11)} WHY`)}`);for(const l of e){const y=lt[l.action]??{label:l.action,paint:S=>S},u=h.get(l.key),$=u&&(l.action==="create"||l.action==="rotate")?de(u.digests.digest):"";w(` ${le(l.key,c)} ${String(l.length??"-").padStart(4)} ${y.paint(y.label.padEnd(8))} ${g.amber($.padEnd(11))} ${g.dim(l.reason??"")}`)}w(` ${g.gray("\u2500".repeat(d-4))}`);const p=H(e),a=[p.create?g.green(`${p.create} to create`):null,p.rotate?g.amber(`${p.rotate} to rotate`):null,p.skip?`${p.skip} unchanged`:null,p.rejected?`${p.rejected} filtered out`:null,p.conflict?g.red(`${p.conflict} conflicting`):null].filter(Boolean);if(w(` ${a.length?a.join(g.dim(" \xB7 ")):"nothing to do"}`),t.length>0){w(""),w(` ${g.amber("\u25B2")} ${t.length} secret(s) tagged ${O(o)} are not in this input. ${g.dim("Reported only: import never removes anything.")}`);for(const l of t.slice(0,10))w(` ${g.dim(`\xB7 ${l.label}`)}`);t.length>10&&w(` ${g.dim(`\xB7 \u2026 ${t.length-10} more`)}`)}q(n)}function q(e){if(!e||e.ceiling===1/0)return;const t=e.ceiling.toLocaleString("en-US"),o=r=>{if(!(e.legacySecrets>0)||!(e.ceilingIfReenrolled>e.ceiling))return;const n=e.legacySecrets;console.log(`${r} ${n} enrolled secret${n===1?" has":"s have"} no scan prefilter (enrolled before 2026-10-07 or by an older client),${s.reset}`),console.log(`${r} so every paste is checked against ${n===1?"it":"them"} the slow way. They are outside this import; re-enrolling ${n===1?"it":"them"}${s.reset}`),console.log(`${r} from the dashboard, or importing ${n===1?"it":"them"}, raises this to ~${e.ceilingIfReenrolled.toLocaleString("en-US")} chars.${s.reset}`)};if(e.ceiling<Pe){console.log(""),console.log(`${s.red}${s.bold}Scan budget: pastes over ~${t} characters will be refused${s.reset}`),console.log(` ${e.windowLengths} distinct window lengths after this import. Detection slides one pass per`),console.log(` length, so past that size the extension ${s.bold}refuses to scan${s.reset} rather than hang \u2014`),console.log(" which is a hard block in sensitive contexts. A paste at the ceiling takes up to ~8s."),o(""),console.log(`${s.dim} Register the high-value secrets only, or split them across workspaces.${s.reset}`);return}if(e.ceiling<Z){console.log(""),console.log(`${s.yellow}Scan budget:${s.reset} ${e.windowLengths} window lengths after this import; pastes above ~${t} chars are refused.`),o("");return}console.log(e.atCharacterCap?`${s.dim} Scan budget: every paste up to the extension's ${t}-char hard limit is scanned.${s.reset}`:`${s.dim} Scan budget: ${e.windowLengths} window lengths, pastes up to ~${t} chars.${s.reset}`),o(s.dim)}function H(e){const t={create:0,rotate:0,skip:0,conflict:0,rejected:0};for(const o of e)t[o.action]=(t[o.action]??0)+1;return t}async function dt(e){if(!process.stdin.isTTY)throw new m("--apply needs a terminal to confirm on, or --yes to skip the prompt.");const t=$e.createInterface({input:process.stdin,output:process.stdout});try{const o=await new Promise(r=>t.question(`${e} `,r));return/^y(es)?$/i.test(o.trim())}finally{t.close()}}function pt(e,t){return Re({label:e,hmacHex:t.digest,length:t.length,hmacHexStripped:t.hmacHexStripped,lengthStripped:t.lengthStripped,hmacHexLower:t.hmacHexLower,lengthLower:t.lengthLower,scanPrefilter:t.scanPrefilter})}function ce(e,t,{withPrefilter:o,projectId:r,tag:n}){const{scanPrefilter:i,...h}=t.digests,d=pt(e.key,o?{...h,scanPrefilter:i}:h),{label:c,...p}=d,a=r?{projectId:r}:{};return e.action==="create"?{method:"POST",path:"/api/secrets",body:{...d,...a,tags:[n]}}:{method:"PUT",path:`/api/secrets/${e.id}/digest`,body:{...p,...a}}}const de=e=>`${e.slice(0,8)}\u2026`;function ht(e,t){const o=[];let r="";for(const n of e.split(" "))r&&(r+" "+n).length>t?(o.push(r),r=n):r=r?`${r} ${n}`:n;return r&&o.push(r),o}async function ut({rows:e,byKey:t,serverUrl:o,token:r,source:n,projectId:i=null}){const h=[],d=O(n);let c=!0,p=0,a=0;const l=e.filter(u=>u.action==="create"||u.action==="rotate").length;let y=0;for(const u of e){if(u.action!=="create"&&u.action!=="rotate")continue;const $=t.get(u.key),S=A=>{const E=ce(u,$,{withPrefilter:A,projectId:i,tag:d});return p+=Buffer.byteLength(JSON.stringify(E.body)),a+=1,F(o,r,E.method,E.path,E.body)};let k=await S(c);c&&$.digests.scanPrefilter&&Ke(k)&&(c=!1,pe(),k=await S(!1)),y+=1;const v=R?` ${g.dim(de($.digests.digest))}`:"";if(k.ok){h.push({key:u.key,action:u.action,ok:!0}),console.log(` ${s.green}\u2713${s.reset} ${u.action==="create"?"created":"rotated"} ${u.key}${v}`),R&&w(` ${Oe(y,l)}`);continue}const b=yt(k);h.push({key:u.key,action:u.action,ok:!1,status:k.status,reason:b}),console.log(` ${s.red}\u2717${s.reset} ${u.key}: ${b}`)}return h.sentBytes=p,h.requests=a,h}const ft=e=>e.replace(/[0-9a-f]{40,}/g,t=>`${t.slice(0,12)}\u2026(${t.length} hex)`);function gt({writes:e,byKey:t,serverUrl:o,projectId:r,source:n,dryRun:i,showRequest:h,viaSession:d=!1}){if(e.length===0)return;const c=O(n),p=e.map(u=>{const $=t.get(u.key);return ce(u,$,{withPrefilter:!!$.digests.scanPrefilter,projectId:r,tag:c})}),a=p.reduce((u,$)=>u+Buffer.byteLength(JSON.stringify($.body)),0),l=[...new Set(p.flatMap(u=>Object.keys(u.body)))];let y=o;try{y=new URL(o).host}catch{}if(R&&(w(""),B(i?"What WOULD leave this machine":"What leaves this machine",["",...K([["Sent to",`${y}: ${p.length} request${p.length===1?"":"s"}, ${z(a)} in total`],["Each request","label, 3 fingerprints, 3 lengths"],...ht(l.join(", "),44).map(u=>["",g.dim(u)]),["Secret values",g.green("0 B")]],{indent:0,labelWidth:18}),"",g.dim("Never leaves this machine:"),...K([["The values",`${e.length} read into memory, fingerprinted, then dropped`],...d?[["Your passphrase","not used here. This machine holds the project's key from its invite"],["The project key","in the session file only you can read; never sent to the server"]]:[["Your passphrase","asked for, used to derive the key, then forgotten"],["The workspace key","derived here, held in memory, never written or sent"]]],{indent:0,labelWidth:18}),""],{tone:"green",width:100})),h){const u=p[0];w(""),w(` ${g.dim(`The first request, exactly as it is built (${u.method} ${u.path}), digests shortened:`)}`),w(ft(JSON.stringify(u.body,null,2)).split(`
|
|
21
|
+
`).map($=>` ${$}`).join(`
|
|
22
|
+
`))}else R&&w(` ${g.dim("See the exact request body: --show-request")}`)}async function mt({label:e,cand:t,viaSession:o=!1}){const r=t.digests.length;w(""),w(` ${g.bold(e)} ${g.gray("\u2022".repeat(Math.min(r,22)))} ${g.dim(`${r} characters, read here and never sent`)}`),w(` ${g.gray(C.bar)} ${g.dim("normalise ")} trim, unicode NFC, strip invisible characters`),w(o?` ${g.gray(C.bar)} ${g.dim("project key ")} the key from your invite, already on this machine (no passphrase needed)`:` ${g.gray(C.bar)} ${g.dim("derive key ")} PBKDF2-SHA256, 600,000 rounds, from your passphrase, on this machine`),await Le(` ${g.gray(C.down)} ${g.dim("fingerprint ")} `,t.digests.digest,{show:16}),w(` ${g.dim("HMAC-SHA256, plus whitespace-stripped and lower-case variants, so a disguised paste still matches")}`),w("")}function pe(){console.log(`${s.yellow}!${s.reset} This server does not store scan prefilters yet, so secrets are registered without one.`),console.log(`${s.dim} They are protected exactly the same, but every paste checks them the slow way. Re-run this${s.reset}`),console.log(`${s.dim} import once the server is updated; it adds the prefilter to each of them.${s.reset}`)}function yt(e){if(e.status===409)return"another secret in this workspace already has this fingerprint";if(e.status===403)return"token is not OWNER or ADMIN";if(e.status===401)return"token expired or invalid";if(e.status===404)return"secret no longer exists \u2014 re-run to re-plan";const t=e.data&&typeof e.data.error=="string"?e.data.error:e.text.slice(0,160);return`${e.status} ${t}`}function $t(){console.log(`
|
|
23
|
+
${s.bold}redaktyn-import${s.reset} \u2014 register fingerprints in bulk from files or any vault CLI
|
|
24
|
+
|
|
25
|
+
${s.bold}Digests are computed on this machine.${s.reset} Values are never sent, never
|
|
26
|
+
printed, and never written to the report.
|
|
27
|
+
|
|
28
|
+
${s.bold}Input (choose one)${s.reset}
|
|
29
|
+
--env <file> dotenv file (export prefix, quotes, comments, multi-line)
|
|
30
|
+
--json <file> JSON object of secrets
|
|
31
|
+
--yaml <file> flat "key: value" YAML only; refuses anything nested
|
|
32
|
+
--stdin JSON piped in \u2014 this is how vault CLIs are consumed
|
|
33
|
+
|
|
34
|
+
${s.bold}Vault recipes${s.reset}
|
|
35
|
+
vault kv get -format=json secret/prod | redaktyn-import --stdin
|
|
36
|
+
doppler secrets download --no-file --format json | redaktyn-import --stdin
|
|
37
|
+
op item get prod --format json | redaktyn-import --stdin
|
|
38
|
+
aws secretsmanager get-secret-value --secret-id prod \\
|
|
39
|
+
--query SecretString --output text | redaktyn-import --stdin
|
|
40
|
+
|
|
41
|
+
${s.bold}Options${s.reset}
|
|
42
|
+
--source <name> Provenance tag, stored as import:<name>
|
|
43
|
+
(default: input file name, or "stdin")
|
|
44
|
+
--apply Actually register. Without it this is a dry run.
|
|
45
|
+
--yes Skip the confirmation prompt (for CI)
|
|
46
|
+
--min-length <n> Minimum value length to accept (default ${Y})
|
|
47
|
+
--allow-weak Register values the junk filter rejected
|
|
48
|
+
--only <key|glob> Only these keys (repeatable)
|
|
49
|
+
--exclude <key|glob> Skip these keys (repeatable)
|
|
50
|
+
--server <url> Override REDAKTYN_SERVER_URL
|
|
51
|
+
--org-id <uuid> Override org id (must match the token)
|
|
52
|
+
--project <name|id> Enrol into this project (hashed under the project's key and
|
|
53
|
+
compared only with that project's secrets). Default: the
|
|
54
|
+
organisation's default project.
|
|
55
|
+
--report-json Machine-readable plan on stdout, no table
|
|
56
|
+
--show-request Print the exact request body that would be sent for the first secret (digests shortened)
|
|
57
|
+
--no-color Disable ANSI colors
|
|
58
|
+
|
|
59
|
+
${s.bold}Environment${s.reset}
|
|
60
|
+
REDAKTYN_SERVER_URL API base URL
|
|
61
|
+
REDAKTYN_PASSPHRASE org passphrase (local HMAC only, never sent)
|
|
62
|
+
REDAKTYN_TOKEN OWNER/ADMIN JWT \u2014 an extension token cannot register
|
|
63
|
+
REDAKTYN_ORG_ID optional; must agree with the token
|
|
64
|
+
|
|
65
|
+
${s.bold}Exit codes${s.reset}
|
|
66
|
+
0 plan printed, or every write succeeded
|
|
67
|
+
1 one or more writes failed
|
|
68
|
+
2 usage, auth or parse error
|
|
69
|
+
`)}async function wt(e){const t=[e.env&&"env",e.jsonFile&&"json",e.yaml&&"yaml",e.stdin&&"stdin"].filter(Boolean);if(t.length===0)throw new m("Nothing to import. Pass one of --env, --json, --yaml or --stdin (see --help).");if(t.length>1)throw new m(`Pick one input mode, got: ${t.map(i=>"--"+i).join(", ")}`);const o=t[0];if(o==="env")return{...Fe(J(e.env,"--env")),mode:o,defaultSource:W(e.env)};if(o==="json")return{...oe(J(e.jsonFile,"--json")),mode:o,defaultSource:W(e.jsonFile)};if(o==="yaml")return{...Be(J(e.yaml,"--yaml")),mode:o,defaultSource:W(e.yaml)};const r=await Ze();if(r.trim()==="")throw new m("Nothing arrived on stdin.");return{...oe(r),mode:o,defaultSource:"stdin"}}async function kt(e){const t=process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT;let o=null;if(!t){const a=U();if(a)try{o=await Ne(a)}catch(l){throw new m(l.message)}}const r=e.server||!t&&o?.serverUrl||Ee(),n=t||o?.token;if(!n)throw new m("Run `redaktyn-login` to sign in as an OWNER or ADMIN (or set REDAKTYN_TOKEN for a script).");const i=ot(n),h=st(i);if(h)throw new m(h);const d=e.orgId||i?.orgId||i?.org_id||process.env.REDAKTYN_ORG_ID;if(!d)throw new m("Could not determine the org id from the token. Pass --org-id.");const c=e.orgId||process.env.REDAKTYN_ORG_ID;if(c&&i?.orgId&&c!==i.orgId)throw new m("The org id given does not match the token's org id.");let p;try{p=await xe({server:r,token:n,orgId:d,who:o?`Signed in as ${o.email}${o.orgName?` (${o.orgName})`:""}.`:null,label:"Org passphrase",record:!!e.apply})}catch(a){throw new m(a.message)}return{serverUrl:r,token:n,orgId:d,passphrase:p}}async function vt(e){const o=process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT||U()?null:Ae();if(o){let a;try{a=await je(o)}catch(l){throw new m(l.message)}if(e.project){const l=e.project.trim().toLowerCase();if(l!==String(a.projectId).toLowerCase()&&l!==String(a.projectName||"").toLowerCase())throw new m(`This machine is connected to project "${a.projectName??a.projectId}" only, not "${e.project}". Drop --project, or ask for an invite to the other project.`)}if(!a.projectId)throw new m("This session is not tied to a project. Ask for a new invite link.");if(e.apply&&!a.canEnrol)throw new m("This invite lets you scan but not enrol secrets. Ask your admin for a link made with: redaktyn-invite --can-enrol");return{serverUrl:e.server||a.serverUrl,token:a.token,key:Buffer.from(a.key,"hex"),project:{id:a.projectId,name:a.projectName??a.projectId,usesOrgKey:a.projectUsesOrgKey===!0},viaSession:!0}}const{serverUrl:r,token:n,orgId:i,passphrase:h}=await kt(e),d=await be(h,i),c=e.project?await nt(r,n,e.project):null,p=c&&!c.usesOrgKey?Se(d,c.id):d;return{serverUrl:r,token:n,key:p,project:c,viaSession:!1}}async function bt(){const e=Ye(process.argv);if(e.help)return $t(),0;e.reportJson||Te("import secrets",_e);const t=await wt(e),o=Xe(e.source??t.defaultSource);R&&!e.reportJson&&w(` ${g.green("\u2714")} Read ${t.entries.length} key${t.entries.length===1?"":"s"} ${g.dim(`(${t.mode}); the values stay in memory`)}`);const r=new Map;for(const f of t.entries)r.set(f.key,f);const n=t.entries.length-r.size;let i=[...r.values()];if(e.only.length>0&&(i=i.filter(f=>ie(f.key,e.only))),e.exclude.length>0&&(i=i.filter(f=>!ie(f.key,e.exclude))),i.length===0)throw new m(`No usable keys found in the input (${t.mode}). `+(t.problems?.length?`${t.problems.length} line(s) were skipped \u2014 see above.`:""));const{serverUrl:h,token:d,key:c,project:p,viaSession:a}=await vt(e),l=e.minLength??Y,y=[],u=[],$=R&&!e.reportJson?G("Fingerprinting on this machine"):null;for(const f of i){if(f.key.length>200){u.push({key:f.key,action:"rejected",reason:"key longer than 200 characters"});continue}const j=ke(f.value),P=Ve(j,{minLength:l});if(!P.ok&&!e.allowWeak){u.push({key:f.key,action:"rejected",reason:P.reason,length:j.length});continue}const ge=ve(f.value,c);y.push({key:f.key,digests:ge,weak:!P.ok,weakReason:P.reason})}$?.succeed(`${y.length} fingerprint${y.length===1?"":"s"} computed here ${g.dim("(three variants each)")}`),R&&!e.reportJson&&y.length>0&&await mt({label:y[0].key,cand:y[0],viaSession:a});const S=R&&!e.reportJson?G("Comparing with what your workspace already holds"):null;let k=await rt(h,d,a);p&&(k=k.filter(f=>f&&f.projectId===p.id)),S?.succeed(`${k.length} existing secret${k.length===1?"":"s"} compared`);const v=We(k);if(!v)for(const f of y)delete f.digests.scanPrefilter;const{plan:b,missing:A}=et({candidates:y,existing:k,source:o}),E=new Map(y.map(f=>[f.key,f]));for(const f of b){const j=E.get(f.key);j?.weak&&(f.action==="create"||f.action==="rotate")&&(f.reason=`${f.reason} (weak: ${j.weakReason}, allowed by --allow-weak)`)}const _=[...b,...u].sort((f,j)=>ue(f.action)-ue(j.action)||f.key.localeCompare(j.key)),fe=St({existing:k,plan:b,byKey:E}),N=Me(fe),M=b.filter(f=>f.action==="create"||f.action==="rotate");if(e.reportJson){if(console.log(JSON.stringify({source:o,sourceTag:O(o),mode:t.mode,shape:t.shape??null,dryRun:!e.apply,minLength:l,allowWeak:e.allowWeak,duplicateKeys:n,inputProblems:t.problems??[],counts:H(_),plan:_.map(f=>({key:f.key,action:f.action,reason:f.reason??null,length:f.length??null})),missing:A,scanBudget:{windowLengths:N.windowLengths,maxPasteChars:N.ceiling===1/0?null:N.ceiling,warn:N.ceiling<Z,atCharacterCap:N.atCharacterCap,legacySecrets:N.legacySecrets,maxPasteCharsIfReenrolled:N.ceilingIfReenrolled===1/0?null:N.ceilingIfReenrolled,serverStoresScanPrefilter:v}},null,2)),!e.apply)return 0}else{for(const f of t.problems??[]){const j=f.line!==void 0?`line ${f.line}`:f.key;console.log(`${s.yellow}!${s.reset} ${j}: ${f.message}`)}n>0&&console.log(`${s.dim}${n} duplicate key(s) in the input; the last value of each was used.${s.reset}`),v||pe(),R?ct({rows:_,missing:A,source:o,dryRun:!e.apply,budget:N,projectName:p?p.name:null,byKey:E}):at({rows:_,missing:A,source:o,dryRun:!e.apply,budget:N,projectName:p?p.name:null}),gt({writes:M,byKey:E,serverUrl:h,projectId:p?p.id:null,source:o,dryRun:!e.apply,showRequest:e.showRequest,viaSession:a}),R&&!e.apply&&(w(""),w(` ${g.bold("Dry run. Nothing was sent.")} ${g.dim("Re-run with --apply to register.")}`)),R&&w("")}if(!e.apply)return 0;if(M.length===0)return console.log(`${s.dim}Nothing to write.${s.reset}`),0;if(!e.yes){const f=H(b);if(!await dt(`Register ${f.create} new and rotate ${f.rotate} existing fingerprint(s) for this org? [y/N]`))return console.log("Aborted."),0}console.log("");const T=await ut({rows:M,byKey:E,serverUrl:h,token:d,source:o,projectId:p?p.id:null}),L=T.filter(f=>!f.ok);return console.log(""),console.log(L.length===0?`${s.green}${T.length} fingerprint(s) registered.${s.reset}`:`${s.red}${L.length} of ${T.length} failed.${s.reset}`),q(N),R&&(w(""),B(L.length===0?"Done":"Finished with errors",["",...K([["Registered",`${T.length-L.length} of ${T.length}`],["Sent",`${z(T.sentBytes??0)} in ${T.requests??0} request${T.requests===1?"":"s"}`],["Secret values sent",g.green("0 B")],["Takes effect","in every connected browser within about a minute"]],{indent:0}),""],{tone:L.length===0?"green":"red",width:78})),console.log(""),L.length===0?0:1}const he=["create","rotate","conflict","skip","rejected"],ue=e=>{const t=he.indexOf(e);return t===-1?he.length:t};function St({existing:e,plan:t,byKey:o}){const r=e.map(i=>({...i})),n=new Map(r.map(i=>[i.id,i]));for(const i of t){const h=o.get(i.key);if(!h)continue;const d=h.digests;if(i.action==="create"){r.push({id:`pending:${i.key}`,label:i.key,hmacHex:d.digest,length:d.length,hmacHexStripped:d.hmacHexStripped,lengthStripped:d.lengthStripped,hmacHexLower:d.hmacHexLower,lengthLower:d.lengthLower,scanPrefilter:d.scanPrefilter,tags:[]});continue}if(i.action==="rotate"){const c=n.get(i.id);if(!c)continue;c.hmacHex=d.digest,c.length=d.length,c.hmacHexStripped=d.hmacHexStripped,c.lengthStripped=d.lengthStripped,c.hmacHexLower=d.hmacHexLower,c.lengthLower=d.lengthLower,c.scanPrefilter=d.scanPrefilter}}return r}function Rt(){try{return ye(process.argv[1])}catch{return process.argv[1]}}const Nt=!!process.argv[1]&&import.meta.url===we(Rt()).href;Nt&&bt().then(e=>process.exit(e)).catch(e=>{e instanceof m&&(console.error(`${s.red}${e.message}${s.reset}`),process.exit(2)),console.error(`${s.red}Fatal: ${e instanceof Error?e.message:e}${s.reset}`),process.exit(1)});export{Y as DEFAULT_MIN_LENGTH,ot as decodeJwtPayload,et as diffAgainstServer,Ue as entriesFromJsonDocument,Ve as junkVerdict,Xe as normalizeSource,Fe as parseDotenv,Be as parseFlatYaml,oe as parseJsonSecrets,Me as scanBudgetForSecrets,O as sourceTag,st as tokenRoleProblem,ne as unwrapSecretsDocument};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{pathToFileURL as K}from"node:url";import{realpathSync as P}from"node:fs";import{createInterface as R}from"node:readline";import{deriveKey as C,deriveProjectKey as D}from"./redaktyn-fingerprint.mjs";import{SessionError as i,apiJson as j,freshAdminSession as L,loadAdminSession as O,resolveServerUrl as U,sealInviteKey as Y}from"./redaktyn-session.mjs";import{askWorkspacePassphrase as W}from"./redaktyn-passphrase.mjs";import{copyToClipboard as q}from"./redaktyn-clipboard.mjs";import{banner as B,box as F,k as s,rich as v,rows as G,say as u,step as k}from"./redaktyn-ui.mjs";import{createRequire as _}from"node:module";const H=_(import.meta.url)("./package.json").version;function J(){console.log(`redaktyn-invite: mint a single-use invite link for one project
|
|
3
|
+
|
|
4
|
+
--project <name|id> The project the link is for (required)
|
|
5
|
+
--can-enrol Let the redeeming CLI register secrets on that project
|
|
6
|
+
--expires-days <n> Link lifetime (default 7)
|
|
7
|
+
--name <text> Name to show for the person
|
|
8
|
+
--email <address> Restrict the link to this email
|
|
9
|
+
--copy Copy the link to your clipboard (asked, by default, when run in a terminal)
|
|
10
|
+
--no-copy Do not copy it, and do not ask
|
|
11
|
+
--app-url <url> Address to put in the link (default https://redaktyn.com)
|
|
12
|
+
--server <url> API address (default https://redaktyn.com; self-hosted only)
|
|
13
|
+
|
|
14
|
+
Needs: redaktyn-login first (or REDAKTYN_TOKEN), and the workspace passphrase (REDAKTYN_PASSPHRASE or a prompt)`)}function M(r){const e={project:null,canEnrol:!1,days:7,name:null,email:null,appUrl:"https://redaktyn.com",server:null,help:!1,copy:null};for(let o=2;o<r.length;o++){const t=r[o],n=()=>{const l=r[++o];if(l===void 0||l.startsWith("--"))throw new i(`${t} needs a value`);return l};if(t==="--help"||t==="-h")e.help=!0;else if(t==="--project")e.project=n();else if(t==="--can-enrol"||t==="--can-enroll")e.canEnrol=!0;else if(t==="--expires-days")e.days=Number(n());else if(t==="--name")e.name=n();else if(t==="--email")e.email=n();else if(t==="--copy")e.copy=!0;else if(t==="--no-copy")e.copy=!1;else if(t==="--app-url")e.appUrl=n();else if(t==="--server")e.server=n();else throw new i(`Unknown flag: ${t}`)}if(!Number.isInteger(e.days)||e.days<1||e.days>365)throw new i("--expires-days must be 1 to 365");return e}function V(r){return new Promise(e=>{const o=R({input:process.stdin,output:process.stderr});o.question(r,t=>{o.close(),e(!/^\s*n/i.test(t))})})}async function E(r,e){let o=e;if(o===null&&(o=process.stdin.isTTY&&v?await V(` Copy the link to your clipboard? ${s.dim("[Y/n]")} `):!1),!o)return;const t=q(r),n=t.ok?"Copied. Paste it where you are sending it; it works once.":"Could not copy (no clipboard tool found). The link is printed above.";v?u(` ${t.ok?s.green("\u2714"):s.amber("\u25B2")} ${n}`):console.log(n)}function z(r){try{return JSON.parse(Buffer.from(r.split(".")[1],"base64url").toString()).orgId}catch{return null}}async function Q(r){const e=M(r);if(e.help)return J(),0;if(!e.project)throw new i("Say which project the link is for: --project <name|id>");const o=process.env.REDAKTYN_TOKEN,t=o?null:await L(O()),n=e.server||(t&&!o?t.serverUrl:null)||U(),l=o||t.token,g=z(l);if(!g)throw new i("That token carries no org id.");const T=t?`Signed in as ${t.email}${t.orgName?` (${t.orgName})`:""}.`:null;B("invite link",H);const m=k("Looking up the project"),c=await j(n,"GET","/api/projects",{token:l});if(!c.ok||!Array.isArray(c.data))throw m.fail("Could not read your projects"),new i(`GET /api/projects failed (${c.status}). Is the token an OWNER/ADMIN token?`);const p=e.project.trim(),f=c.data.filter(h=>h.id===p).length?c.data.filter(h=>h.id===p):c.data.filter(h=>String(h.name).toLowerCase()===p.toLowerCase());if(f.length!==1)throw m.fail(f.length?`"${p}" matches several projects`:`No project "${p}"`),new i(f.length?`"${p}" matches several projects; pass the id.`:`No project "${p}". Known: ${c.data.map(h=>h.name).join(", ")}.`);const a=f[0];if(a.status==="CLOSED")throw m.fail("That project is closed"),new i(`Project ${a.name} is closed.`);m.succeed(`Project ${a.name}`);const b=await W({server:n,token:l,orgId:g,who:T}),S=k("Deriving the project key on this machine"),w=await C(b,g),N=a.usesOrgKey?w:D(w,a.id);S.succeed(`Project key derived here ${s.dim("(PBKDF2-SHA256, 600,000 rounds)")}`);const A=k("Sealing the key into the link"),{w:I,blob:x}=await Y(N,a.usesOrgKey?1:2);A.succeed(`Key sealed ${s.dim("(AES-GCM; the opener lives only in the link's #fragment)")}`);const $=k("Registering the link with the server"),d=await j(n,"POST","/api/invites",{token:l,body:{projectId:a.id,keyBlob:x,maxUses:1,expiresInDays:e.days,...e.canEnrol?{canEnrol:!0}:{},...e.name?{assignedName:e.name}:{},...e.email?{assignedEmail:e.email}:{}}});if(!d.ok)throw $.fail("The server refused the invite"),new i(`The server refused the invite (${d.status}): ${d.data?.error||d.text.slice(0,200)}`);$.succeed(`Registered ${s.dim("(the server stored ciphertext it cannot open)")}`);const y=`${e.appUrl.replace(/\/+$/,"")}/connect?code=${d.data.code}#w=${I}`;return v?(u(""),F("Invite ready",["",...G([["Project",a.name],["Access",e.canEnrol?s.amber("may enrol secrets on this project only"):"scan only"],["Lifetime",`single use ${s.dim("\xB7")} expires in ${e.days} day${e.days===1?"":"s"}`],["Opens with","the part after # in the link. It never reaches the server"],["Revoke",`DELETE /api/invites/${d.data.id}`]],{indent:0}),""],{tone:"green",width:78}),u(""),u(` ${s.bold("Send this link over a channel you trust.")} ${s.dim("Whoever opens it first gets this project's key.")}`),u(` ${s.dim("They run")} ${s.bold("redaktyn-connect")} ${s.dim("and paste it.")}`),u(""),console.log(y),u(""),await E(y,e.copy),0):(console.log(`Invite for project ${a.name}${e.canEnrol?" (may enrol secrets)":" (scan only)"}, single use, expires in ${e.days} day(s):
|
|
15
|
+
`),console.log(y),console.log(`
|
|
16
|
+
Send it over a channel you trust: whoever opens it first gets this project's key.`),console.log(`They run: redaktyn-connect and paste it. Revoke before use: DELETE /api/invites/${d.data.id}`),await E(y,e.copy),0)}function X(){try{return P(process.argv[1])}catch{return process.argv[1]}}process.argv[1]&&import.meta.url===K(X()).href&&Q(process.argv).then(r=>process.exit(r),r=>{console.error(r.message),process.exit(r instanceof i?1:2)});export{Q as main};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{pathToFileURL as x}from"node:url";import{realpathSync as A}from"node:fs";import{spawn as R}from"node:child_process";import{createRequire as I}from"node:module";import{banner as P,box as y,k as o,rich as c,rows as T,say as d,step as O}from"./redaktyn-ui.mjs";const W=I(import.meta.url)("./package.json").version;import*as E from"node:readline";import{SessionError as v,deleteAdminSession as j,loadAdminSession as k,loginWithBrowser as Y,loginWithPassword as C,readHidden as $,resolveServerUrl as D,sessionDir as b}from"./redaktyn-session.mjs";function U(e){if(process.env.REDAKTYN_NO_BROWSER)return!1;try{const[n,t]=process.platform==="darwin"?["open",[e]]:process.platform==="win32"?["cmd",["/c","start","",e]]:["xdg-open",[e]],a=R(n,t,{stdio:"ignore",detached:!0});return a.on("error",()=>{}),a.unref(),!0}catch{return!1}}function B(){console.log(`redaktyn-login: sign in as a workspace admin
|
|
3
|
+
|
|
4
|
+
redaktyn-login open the browser and approve the code there (nothing typed here)
|
|
5
|
+
redaktyn-login --no-browser print the link instead of opening it (SSH, containers)
|
|
6
|
+
redaktyn-login --password email and password in the terminal (machines with no browser)
|
|
7
|
+
redaktyn-login --email <address> with --password: skip the email prompt
|
|
8
|
+
redaktyn-login --password-stdin read the password from standard input (scripts)
|
|
9
|
+
redaktyn-login --status show who this machine is logged in as
|
|
10
|
+
redaktyn-login --logout forget the login on this machine
|
|
11
|
+
|
|
12
|
+
--server <url> API address (default ${D()}; only a self-hosted install changes it)
|
|
13
|
+
|
|
14
|
+
The login is stored in ${b()} (readable only by you). Your password is never saved.`)}function M(e){return new Promise(n=>{const t=E.createInterface({input:process.stdin,output:process.stderr});t.question(e,a=>{t.close(),n(a.trim())})})}function m(e){if(c){d(""),y("Signed in",[...T([["Account",`${e.email} ${o.dim(`(${e.role})`)}`],["Workspace",e.orgName??e.orgId??"your workspace"],["Server",e.serverUrl],["Session",`${o.dim("kept in")} ${b()}${o.dim("/admin.json, readable only by you")}`],["Password",o.green("never typed here, never stored")]],{indent:0})],{tone:"green"});return}console.log(`Logged in as ${e.email} (${e.role}) of ${e.orgName??e.orgId??"your workspace"}.`),console.log(` server ${e.serverUrl}`)}async function q(e){const n=e.slice(2);if(n.includes("--help")||n.includes("-h"))return B(),0;if(n.includes("--logout")){const r=!!k();return j(),console.log(r?"Forgot this machine's Redaktyn admin login.":"There was no admin login to forget."),0}if(n.includes("--status")){const r=k();return r?(m(r),0):(console.log("Not logged in. Run: redaktyn-login"),1)}const t=r=>{const i=n.indexOf(r);return i>=0?n[i+1]:void 0},a=t("--server"),p=n.includes("--password-stdin");if(!(p||n.includes("--password")||t("--email")!==void 0)){let r=null,i=null;try{P("sign in",W);const s=await Y({serverUrl:a,onCode:({userCode:h,verificationUrl:g,expiresIn:S})=>{const f=!n.includes("--no-browser")&&U(g);if(c){y("Approve in your browser",["",` ${o.gray("Your code")} ${o.bold(o.clay(h))}`,"",` ${f?"A browser tab is opening on this link.":"Open this link in a browser where you are signed in:"}`,` ${o.blue(g)}`,"",` ${o.dim("Check the page shows the same code, then press Approve.")}`,` ${o.dim("Nothing is typed in this terminal, and no password is sent from it.")}`,""],{tone:"clay",width:70}),d("");const N=Date.now()+(S||600)*1e3;r=O("Waiting for you to approve in the browser"),i=setInterval(()=>{const w=Math.max(0,Math.round((N-Date.now())/1e3));r.update(`Waiting for you to approve in the browser ${o.dim(`${Math.floor(w/60)}:${String(w%60).padStart(2,"0")} left`)}`)},1e3);return}console.error(f?"Opening your browser to sign in.":"Open this page in a browser where you are signed in:"),console.error(`
|
|
15
|
+
${g}
|
|
16
|
+
`),console.error(`Check that it shows the code ${h} and approve it. Waiting\u2026 (Ctrl+C to cancel)`)}});return i&&clearInterval(i),r&&r.succeed(`Approved by ${s.email}`),c||console.log(""),m(s),c?(d(""),d(` ${o.dim("Next")} ${o.bold("redaktyn-import --env .env --project <name>")} ${o.dim("(add --apply to register)")}`),d("")):console.log(`
|
|
17
|
+
Next: redaktyn-import --env .env --project <name> (add --apply to register)`),0}catch(s){if(i&&clearInterval(i),r&&r.fail(s instanceof Error?s.message:String(s)),s instanceof v)return r||console.error(s.message),1;throw s}}let l=t("--email");if(!l){if(!process.stdin.isTTY)return console.error("Pass --email, or run this in a terminal."),2;l=await M("Email: ")}if(!l)return console.error("No email given."),2;if(!p&&!process.stdin.isTTY)return console.error("Run this in a terminal, or use --password-stdin."),2;const u=p?(await $("")).trim():await $("Password: ");if(!u)return console.error("No password given."),2;try{const r=await C(l,u,{serverUrl:a});return m(r),console.log(`
|
|
18
|
+
Next: redaktyn-import --env .env --project <name> (add --apply to register)`),0}catch(r){if(r instanceof v)return console.error(r.message),1;throw r}}function F(){try{return A(process.argv[1])}catch{return process.argv[1]}}process.argv[1]&&import.meta.url===x(F()).href&&q(process.argv).then(e=>process.exit(e),e=>{console.error(e.message),process.exit(1)});export{q as main};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createHmac as E}from"node:crypto";import{createRequire as N}from"node:module";import{createInterface as b}from"node:readline";import{deriveKey as $}from"./redaktyn-fingerprint.mjs";import{SessionError as d,apiJson as S,readHidden as D}from"./redaktyn-session.mjs";import{box as m,k as c,rich as i,say as f,step as k}from"./redaktyn-ui.mjs";const H="vaultx-passphrase-verify-v1",P=3,r=e=>process.stderr.write(e+`
|
|
2
|
+
`),Y=N(import.meta.url);function _(e){return new Promise(a=>{const s=b({input:process.stdin,output:process.stderr});s.question(e,o=>{s.close(),a(o)})})}function A(){let e=null;try{e=Y("./redaktyn-shared.cjs")}catch{}if(!e||typeof e.generateOrgPassphrase!="function"||typeof e.validateGeneratedPassphrase!="function")throw new d("This workspace has no passphrase yet, and this copy of the CLI cannot generate one. Reinstall it (npm install -g @redaktyn/cli@latest), or from a checkout rebuild shared, and run this again.");return e}async function K({server:e,token:a}){const s=await S(e,"GET","/api/org/passphrase-verifier",{token:a});if(!s.ok)return{state:"unknown"};const o=s.data?.verifier;return typeof o=="string"&&o.length>0?{state:"anchored",verifier:o}:{state:"none"}}async function T(e,a){const s=await $(e,a);return E("sha256",s).update(H,"utf8").digest("hex")}async function R(e,a,s){return a.state!=="anchored"?"unchecked":await T(e,s)===a.verifier?"ok":"wrong"}async function I(e,{server:a,token:s,orgId:o}){const w=await T(e,o),y=await S(a,"PUT","/api/org/passphrase-verifier",{token:s,body:{verifier:w}});if(y.status===409)throw new d("This workspace already has a different passphrase, set a moment ago by someone else. Run this again and use that one.");return y.ok}async function O({server:e,token:a,orgId:s,who:o=null,label:w="Workspace passphrase",record:y=!0}){o&&(i?f(` ${c.green("\u2714")} ${o.replace(/^Signed in as /,"Signed in as ").replace(/\.$/,"")}`):r(o));const l=await K({server:e,token:a}),v=l.state==="none",u=process.env.REDAKTYN_PASSPHRASE,g=async n=>{if(!y){r(i?` ${c.dim("Dry run: this passphrase is accepted for the preview but not recorded. Run with --apply to set it.")}`:"Dry run: this passphrase is accepted for the preview but not recorded. Run with --apply to set it.");return}const t=k("Setting this as the workspace passphrase");await I(n,{server:e,token:a,orgId:s})?t.succeed("Workspace passphrase set. Every device and the dashboard are now checked against it"):t.succeed("Passphrase accepted, but the server did not record it yet. It will be checked once it is"),r(i?` ${c.dim("Nobody can reset it for you. Set recovery codes in the dashboard if you have not.")}`:"Nobody can reset it for you. Set recovery codes in the dashboard if you have not.")};if(u){if(v){if(!A().validateGeneratedPassphrase(u).ok)throw new d("This workspace has no passphrase yet, so REDAKTYN_PASSPHRASE would become it, and a new workspace's passphrase has to be generated. Run this once in a terminal to get one, then set REDAKTYN_PASSPHRASE to it.");return await g(u),u}const n=k("Checking the passphrase from REDAKTYN_PASSPHRASE"),t=await R(u,l,s);if(t==="wrong")throw n.fail("REDAKTYN_PASSPHRASE is not this workspace's passphrase"),new d("REDAKTYN_PASSPHRASE is not this workspace's passphrase.");return n.succeed(t==="ok"?"Passphrase from REDAKTYN_PASSPHRASE verified":"Passphrase from REDAKTYN_PASSPHRASE accepted. The server has nothing to check it against"),u}if(!process.stdin.isTTY)throw new d("Set REDAKTYN_PASSPHRASE. It is used locally to derive the key and is never sent.");if(v){const t=A().generateOrgPassphrase();if(!y)return r(i?` ${c.dim("Dry run: this workspace has no passphrase yet. The preview uses a throwaway one. Run with --apply to generate the real one.")}`:"Dry run: this workspace has no passphrase yet. The preview uses a throwaway one. Run with --apply to generate the real one."),t;const h=["This workspace has no passphrase yet, so one was generated for you.","It derives your keys on this machine and is never sent.","Nobody can reset it for you.","",t,"","Save it in your password manager now.","It stays in this terminal's scrollback until you clear it."];i?(f(""),m("Your workspace passphrase",h,{tone:"clay"}),f("")):(r(""),h.forEach(p=>r(p)),r(""));for(let p=P;p>0;p--){if((await _(i?` ${c.clay("\u203A")} Type ${c.dim("saved")} once you have kept it `:"Type 'saved' once you have kept it > ")).trim().toLowerCase()==="saved")return await g(t),t;r("Not set yet. Save the passphrase above, then type: saved")}throw new d("No passphrase was set after 3 tries. Nothing was changed.")}i?(f(""),m(w,["This derives your keys on this machine and is never sent,","never stored, and not shown while you type."],{tone:"clay"}),f("")):(r(""),r(`${w}: this derives the key on your machine and is never sent. Nothing shows while you type.`));for(let n=P;n>0;n--){const t=await D(i?` ${c.clay("\u203A")} Passphrase ${c.dim("(hidden)")} `:"Type it, then press Enter > ");if(!t){r("Nothing was typed.");continue}const h=k(i?"Checking it against your workspace":"Checking"),p=await R(t,l,s);if(p!=="wrong")return h.succeed(p==="ok"?"Passphrase verified. It matches your workspace":"Passphrase accepted. This workspace has nothing to check it against"),t;h.fail("That is not this workspace's passphrase"),r(i?n>1?` ${c.dim(`Try again (${n-1} left).`)}`:"":n>1?`That is not this workspace's passphrase. Try again (${n-1} left).`:"That is not this workspace's passphrase.")}throw new d("No correct passphrase after 3 tries. Nothing was changed.")}export{O as askWorkspacePassphrase};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{resolveServerUrl as O}from"./redaktyn-session.mjs";import{box as _,humanBytes as Y,k as m,rich as v,rows as V,say as R}from"./redaktyn-ui.mjs";import{execSync as k}from"node:child_process";import{createRequire as F}from"node:module";import{readFileSync as C,existsSync as z,readdirSync as W,statSync as P,writeFileSync as I,chmodSync as G}from"node:fs";import E from"node:path";import{fileURLToPath as M}from"node:url";const q=F(import.meta.url);function X(){try{return q("./redaktyn-shared.cjs")}catch{console.error("Cannot load @redaktyn/shared. Run `npm install` from the repo root, then `npm run build -w @redaktyn/shared`."),process.exit(2)}}const{deriveHmacKeyFromPassphrase:Q,hmacDigestHex:Z,normalizeSecretPlaintextForMatch:ee,normalizeSecretPlaintextCollapsed:te,normalizeSecretPlaintextLower:oe,extractAddedLinesFromUnifiedDiff:re}=X(),ne=5*1024*1024,y=512*1024,se=2e4,K=3e6,ie=[{name:"exact",transform:ee,lengthKey:"length"},{name:"whitespace-collapse",transform:te,lengthKey:"lengthStripped"},{name:"case-fold",transform:oe,lengthKey:"lengthLower"}],ce=new Set([".png",".jpg",".jpeg",".gif",".webp",".ico",".pdf",".zip",".gz",".exe",".dll",".so",".dylib",".woff",".woff2",".ttf",".eot",".mp4",".mp3",".wasm",".bin"]);function ae(e){const o={exclude:[],_:[]};for(let t=2;t<e.length;t++){const r=e[t];r==="--staged"?o.staged=!0:r==="--json"?o.json=!0:r==="--no-color"?o.noColor=!0:r==="--verbose"?o.verbose=!0:r==="--file"?o.file=e[++t]:r==="--dir"?o.dir=e[++t]:r==="--org-id"?o.orgId=e[++t]:r==="--server"?o.server=e[++t]:r==="--exclude"?o.exclude.push(e[++t]):r.startsWith("-")?(console.error(`Unknown flag: ${r}`),process.exit(1)):o._.push(r)}const n=o._[0];return{...o,cmd:n}}function le(e){try{const o=e.split(".")[1];if(!o)return null;const n=o+"=".repeat((4-o.length%4)%4),t=Buffer.from(n.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8");return JSON.parse(t)}catch{return null}}function de(e,o){const n=e.replace(/\\/g,"/");if(n.includes("node_modules/")||n.startsWith(".git/"))return!0;for(const t of o)if(t.includes("*")){if(new RegExp("^"+t.replace(/\*/g,".*").replace(/\//g,"\\/")+"$").test(n))return!0}else if(n.includes(t))return!0;return!1}function j(e,o){const n=P(e);if(!n.isFile())return null;if(n.size>ne)return o&&console.warn(`Skipping large file (${n.size} bytes): ${e}`),null;const t=E.extname(e).toLowerCase();if(ce.has(t))return null;try{const r=C(e);return r.includes(0)?null:r.toString("utf8")}catch{return null}}function H(e,o,n=[]){for(const t of W(e)){const r=E.join(e,t),s=E.relative(process.cwd(),r);if(de(s,o))continue;P(r).isDirectory()?H(r,o,n):n.push(r)}return n}function*fe(e,o,n,t){const r=e.length>y?e.slice(0,y):e;e.length>y&&(console.warn(`Content truncated to ${y} characters for scan.`),t.truncatedChars=!0);let s=[];for(const i of ie){const l=new Set(o.map(c=>Number(c[i.lengthKey])).filter(c=>Number.isFinite(c)&&c>0));if(l.size===0)continue;const d=i.transform(r);t.passesRun.push(i.name);for(const c of l)if(!(c>d.length))for(let p=0;p+c<=d.length;p++){if(s.push(Z(d.slice(p,p+c),n)),t.total++,t.total>=K){t.exhausted=!0,s.length>0&&(yield s);return}s.length>=se&&(yield s,s=[])}}s.length>0&&(yield s)}async function he(e,o,n){const t=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify(n)}),r=await t.text();let s;try{s=r?JSON.parse(r):{}}catch{s={raw:r}}if(!t.ok){const i=new Error(s.error||t.statusText||"HTTP error");throw i.status=t.status,i.body=s,i}return s}async function J(e,o){const n=`${e.replace(/\/+$/,"")}/api/secrets`,t=await fetch(n,{headers:{Authorization:`Bearer ${o}`}});if(!t.ok){const s=await t.text().catch(()=>"");throw new Error(`GET /api/secrets failed: ${t.status} ${s.slice(0,200)}`)}const r=await t.text();try{return JSON.parse(r)}catch{throw new Error("GET /api/secrets: invalid JSON response")}}async function ue(){(process.argv.includes("--help")||process.argv.includes("-h"))&&(D(),process.exit(0));const e=ae(process.argv),o=e.cmd;if(o==="install-hooks"){ge();return}if(o==="uninstall-hooks"){me();return}if(o==="status"){await pe(e);return}!e.staged&&!e.file&&!e.dir&&(D(),process.exit(2));const n=process.env.REDAKTYN_DEVICE_TOKEN||process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT||process.env.REDAKTYN_PASSPHRASE;let t=null;if(!n){const{loadSession:a,freshSession:u}=await import("./redaktyn-session.mjs");if(a())try{t=await u()}catch(S){console.error(S.message),process.exit(2)}}const r=e.server||t?.serverUrl||O(),s=t?.token||process.env.REDAKTYN_DEVICE_TOKEN||process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT,i=t?"session":process.env.REDAKTYN_PASSPHRASE,l=e.orgId||process.env.REDAKTYN_ORG_ID;(!r||!s||!i)&&(console.error("Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."),process.exit(2));const d=le(s),c=d?.orgId??d?.org_id??l;c||(console.error("Could not determine orgId (JWT payload orgId or REDAKTYN_ORG_ID)."),process.exit(2)),l&&d?.orgId&&l!==d.orgId&&(console.error("REDAKTYN_ORG_ID does not match JWT orgId."),process.exit(2));let p="",N=!1;if(e.staged)try{const a=k("git diff --cached --unified=0",{encoding:"utf8",maxBuffer:20971520});p=re(a)}catch{console.error("Not a git repository or git diff failed."),process.exit(2)}else if(e.file){const a=j(E.resolve(e.file),e.verbose);a===null&&(N=!0,console.error(`Could not read ${e.file} as text \u2014 nothing was scanned.`)),p=a??""}else if(e.dir){const a=H(E.resolve(e.dir),e.exclude||[]),u=[];for(const S of a){const h=j(S,e.verbose);h!==null&&u.push(h)}u.length===0&&(N=!0,console.error(`No readable text files under ${e.dir} \u2014 nothing was scanned.`)),p=u.join(`
|
|
3
|
+
`)}else D(),process.exit(e.cmd?2:0);const x=await J(r,s);(!Array.isArray(x)||x.length===0)&&(console.error("No enforced secrets returned from server."),process.exit(2));const L=t?new Uint8Array(Buffer.from(t.key,"hex")):await Q(i,c),f={total:0,passesRun:[],exhausted:!1,truncatedChars:!1},U=`${r.replace(/\/+$/,"")}/api/cli/scan`,$=new Set,b=new Map;let A=0,T=0;for(const a of fe(p,x,L,f)){const u=[];for(const h of a)$.has(h)||($.add(h),u.push(h));if(u.length===0)continue;A+=Buffer.byteLength(JSON.stringify({digestHex:u})),T+=1;const S=await he(U,s,{digestHex:u});for(const h of Array.isArray(S.matches)?S.matches:[])h&&h.secretId&&b.set(h.secretId,h)}const g=[...b.values()],w=f.exhausted||f.truncatedChars||N;e.json&&(console.log(JSON.stringify({ok:g.length===0&&!w,matches:g,scan:{passes:f.passesRun,digests:f.total,complete:!w}},null,2)),process.exit(g.length>0||w?1:0));const B=`${Y(A)} of fingerprints in ${T} request${T===1?"":"s"}. 0 B of your code.`;if(v&&g.length>0&&(R(""),_("Blocked: a registered secret is in this content",["",...g.map(a=>`${m.red("\u2716")} ${m.bold(a.label)} ${m.dim(String(a.secretId).slice(0,8))}`),"","Remove the secret material, or rotate it and register the new","fingerprint, before committing.","",m.dim(`Sent to the server: ${Y(A)} of fingerprints in ${T} request${T===1?"":"s"}.`),m.dim("Sent of your code: 0 B."),""],{tone:"red",width:72}),R(""),process.exit(1)),v&&w&&(R(""),_("Not a clean result: the scan could not finish",["",...f.truncatedChars?[`${m.amber("\u25B2")} Content was truncated at ${y} characters.`]:[],...f.exhausted?[`${m.amber("\u25B2")} Hit the ${K} digest ceiling before the end.`]:[],...N?[`${m.amber("\u25B2")} Nothing was read from the path given.`]:[],"","Scan a narrower path with --file or --dir, or split the commit.",""],{tone:"amber",width:72}),R(""),process.exit(1)),g.length>0){console.error(""),console.error(`Redaktyn: ${g.length} registered fingerprint${g.length===1?"":"s"} matched in scanned content:`);for(const a of g)console.error(` \u2022 ${a.label} (${a.secretId})`);console.error(""),console.error("Remove the secret material, or rotate it and register the new fingerprint, before committing."),process.exit(1)}w&&(console.error(""),console.error("Redaktyn could not finish scanning this input, so this is not a clean result."),f.truncatedChars&&console.error(` \u2022 Content was truncated at ${y} characters.`),f.exhausted&&console.error(` \u2022 Hit the ${K} digest ceiling before the end of the input.`),N&&console.error(" \u2022 Nothing was read from the path given, so no content was checked."),console.error("Scan a narrower path with --file or --dir, or split the commit."),process.exit(1)),e.verbose&&(v?(R(""),_("Clean: no registered fingerprint found",["",...V([["Checked",`${f.total.toLocaleString("en-US")} windows, ${f.passesRun.join(", ")}`],["Sent to the server",B]],{indent:0,labelWidth:20}),""],{tone:"green",width:78}),R("")):console.log(`Redaktyn: no registered fingerprint matches (${f.passesRun.join(", ")}; ${f.total} windows).`)),process.exit(0)}async function pe(e){const o=e.json===!0,n=process.env.REDAKTYN_DEVICE_TOKEN||process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT;let t=null;if(!n&&!process.env.REDAKTYN_PASSPHRASE){const{loadSession:i,freshSession:l}=await import("./redaktyn-session.mjs");if(i())try{t=await l()}catch(d){const c={connected:!1,reason:String(d?.message||d)};console.log(o?JSON.stringify(c):c.reason),process.exit(1)}}const r=e.server||t?.serverUrl||O(),s=t?.token||n;if(!r||!s){const i={connected:!1,reason:"Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL and REDAKTYN_TOKEN."};console.log(o?JSON.stringify(i):i.reason),process.exit(2)}try{const i=await J(r,s),l={connected:!0,source:t?"saved session":"environment",serverUrl:r,...t?.orgName?{workspace:t.orgName}:{},...t?.projectName?{project:t.projectName}:{},enrolledSecrets:Array.isArray(i)?i.length:0,hasPassphrase:t?!0:!!process.env.REDAKTYN_PASSPHRASE};console.log(o?JSON.stringify(l,null,2):Object.entries(l).map(([d,c])=>`${d}: ${c}`).join(`
|
|
4
|
+
`)),process.exit(0)}catch(i){const l={connected:!1,error:String(i?.message||i)};console.log(o?JSON.stringify(l):l.error),process.exit(1)}}function ge(){const e=k("git rev-parse --show-toplevel",{encoding:"utf8"}).trim(),o=E.join(e,".git","hooks","pre-commit"),t=["#!/bin/sh","# Redaktyn pre-commit \u2014 blocks commits when registered fingerprints appear in staged text.",'export REDAKTYN_SERVER_URL="${REDAKTYN_SERVER_URL:-}"','export REDAKTYN_PASSPHRASE="${REDAKTYN_PASSPHRASE:-}"','export REDAKTYN_DEVICE_TOKEN="${REDAKTYN_DEVICE_TOKEN:-${REDAKTYN_TOKEN:-}}"','echo "Redaktyn: scanning staged changes..."',"set +e",`node "${M(import.meta.url)}" --staged`,"CODE=$?","set -e",'if [ "$CODE" -ne 0 ]; then',' echo "Redaktyn blocked this commit."'," exit 1","fi",'echo "Redaktyn: OK"',"exit 0",""].join(`
|
|
5
|
+
`);I(o,t,{mode:493}),G(o,493),console.log("Installed pre-commit hook at",o)}function me(){const e=k("git rev-parse --show-toplevel",{encoding:"utf8"}).trim(),o=E.join(e,".git","hooks","pre-commit");if(!z(o)){console.log("No pre-commit hook found.");return}if(!C(o,"utf8").includes("Redaktyn")){console.log("pre-commit exists but is not a Redaktyn hook \u2014 not removing.");return}I(o,""),console.log("Removed Redaktyn pre-commit hook body at",o)}function D(){console.log(`
|
|
6
|
+
redaktyn-scan \u2014 Redaktyn digest scanner (ZK: HMAC hex batches only)
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
(default) --staged | --file <path> | --dir <path>
|
|
10
|
+
status Show connection + registered fingerprint count
|
|
11
|
+
install-hooks Write .git/hooks/pre-commit
|
|
12
|
+
uninstall-hooks Clear Redaktyn pre-commit hook
|
|
13
|
+
|
|
14
|
+
Flags:
|
|
15
|
+
--staged Scan git staged additions (git diff --cached)
|
|
16
|
+
--file <path> Scan a single file
|
|
17
|
+
--dir <path> Scan all text files under directory
|
|
18
|
+
--server <url> Override REDAKTYN_SERVER_URL
|
|
19
|
+
--org-id <uuid> Override org id (must match JWT)
|
|
20
|
+
--json JSON output
|
|
21
|
+
--verbose Extra logs
|
|
22
|
+
--exclude <pat> Skip paths containing pattern (repeatable)
|
|
23
|
+
|
|
24
|
+
Environment:
|
|
25
|
+
REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE, REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)
|
|
26
|
+
`)}ue().catch(e=>{console.error(e?.message||e),process.exit(2)});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import{createHash as U,randomBytes as k,webcrypto as g}from"node:crypto";import{existsSync as w,mkdirSync as C,readFileSync as v,rmSync as O,writeFileSync as K,chmodSync as D,renameSync as P}from"node:fs";import{homedir as F,hostname as j}from"node:os";import{join as p}from"node:path";const $=20*60*60*1e3,S=32,L="https://redaktyn.com";function _(e){return(e||process.env.REDAKTYN_SERVER_URL||process.env.REDAKTYN_SERVER||L).replace(/\/+$/,"")}class i extends Error{}function h(){return process.env.REDAKTYN_CONFIG_DIR||(process.env.XDG_CONFIG_HOME?p(process.env.XDG_CONFIG_HOME,"redaktyn"):p(F(),".config","redaktyn"))}const E=()=>p(h(),"session.json"),W=()=>p(h(),"device-id");function G(){C(h(),{recursive:!0,mode:448});try{D(h(),448)}catch{}}function x(e,t){G();const n=`${e}.${process.pid}.tmp`;K(n,t,{mode:384});try{D(n,384)}catch{}P(n,e)}function Y(){const e=E();if(!w(e))return null;try{const t=JSON.parse(v(e,"utf8"));if(t&&t.token&&t.key&&t.serverUrl&&t.orgId)return t}catch{}return null}function b(e){x(E(),JSON.stringify(e,null,2)+`
|
|
2
|
+
`)}function te(){const e=E();return w(e)&&O(e),!0}const T=()=>p(h(),"admin.json");function J(){const e=T();if(!w(e))return null;try{const t=JSON.parse(v(e,"utf8"));if(t&&t.token&&t.serverUrl)return t}catch{}return null}function I(e){x(T(),JSON.stringify(e,null,2)+`
|
|
3
|
+
`)}function ne(){const e=T();return w(e)&&O(e),!0}function V(){const e=W();if(w(e)){const n=v(e,"utf8").trim();if(/^cli-[a-f0-9]{32}$/.test(n))return n}const t=`cli-${k(16).toString("hex")}`;return x(e,t+`
|
|
4
|
+
`),t}const B=e=>Buffer.from(e.replace(/-/g,"+").replace(/_/g,"/"),"base64"),H=e=>Buffer.from(e).toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");function q(e){let t;try{t=new URL(String(e).trim())}catch{throw new i("That is not an invite link. It looks like https://\u2026/connect?code=\u2026#w=\u2026")}const n=t.searchParams.get("code"),r=new URLSearchParams(t.hash.replace(/^#/,"")).get("w");if(!n)throw new i("The link has no invite code.");if(!r)throw new i("The link has no key part (the bit after #w=). Copy the whole link, including everything after the #, and quote it so the shell does not cut it.");if(B(r).length!==S)throw new i("The key part of the link is the wrong length.");return{origin:t.origin,code:n,w:r}}async function X(e,t){if(!e||typeof e!="object"||e.v!==1&&e.v!==2||typeof e.iv!="string"||typeof e.ct!="string")throw new i("The server sent no sealed key for this invite.");const n=await g.subtle.importKey("raw",B(t),{name:"AES-GCM"},!1,["decrypt"]);try{const r=await g.subtle.decrypt({name:"AES-GCM",iv:Buffer.from(e.iv,"base64")},n,Buffer.from(e.ct,"base64")),o=Buffer.from(r);if(o.length!==S)throw new Error("length");return o}catch{throw new i("Could not open the sealed key with this link. The link may be incomplete or altered.")}}async function oe(e,t){const n=k(S),r=k(12),o=await g.subtle.importKey("raw",n,{name:"AES-GCM"},!1,["encrypt"]),c=Buffer.from(await g.subtle.encrypt({name:"AES-GCM",iv:r},o,e));return{w:H(n),blob:{v:t,iv:r.toString("base64"),ct:c.toString("base64")}}}const R=e=>e.replace(/\/+$/,"");async function l(e,t,n,{token:r,body:o}={}){let c;try{c=await fetch(`${R(e)}${n}`,{method:t,headers:{...r?{Authorization:`Bearer ${r}`}:{},...o?{"Content-Type":"application/json"}:{}},...o?{body:JSON.stringify(o)}:{}})}catch(d){throw new i(`Cannot reach ${R(e)}: ${d.message}`)}const s=await c.text();let a=null;try{a=s?JSON.parse(s):null}catch{a=null}return{status:c.status,ok:c.ok,data:a,text:s}}async function re(e,{serverUrl:t}={}){const{origin:n,code:r,w:o}=q(e),c=t||process.env.REDAKTYN_SERVER_URL||n,s=await l(c,"POST","/api/auth/connect",{body:{inviteCode:r,extensionId:V(),client:"cli"}});if(!s.ok){const y=s.data?.error||s.text.slice(0,200)||`HTTP ${s.status}`;throw new i(`The server refused the invite (${s.status}): ${y}`)}const a=s.data||{};if(!a.token||!a.keyBlob)throw new i("The server accepted the invite but sent no sealed key, so nothing could be set up. Ask for a new link.");const d=await X(a.keyBlob,o),f={serverUrl:R(c),orgId:a.orgId,orgName:a.orgName??null,projectId:a.projectId??null,projectName:a.projectName??null,projectUsesOrgKey:a.projectUsesOrgKey??null,canEnrol:a.canEnrol===!0,token:a.token,tokenIssuedAt:Date.now(),key:d.toString("hex"),keyFingerprint:U("sha256").update(d).digest("hex").slice(0,8),connectedAt:new Date().toISOString()};return b(f),f}async function ae(e=Y()){if(!e)throw new i("Not connected. Run: redaktyn-connect '<invite link>'");if(Date.now()-(e.tokenIssuedAt||0)<$)return e;const t=await l(e.serverUrl,"POST","/api/auth/refresh",{token:e.token});if(!t.ok||!t.data?.token){const r=t.data?.code,o=r==="ENROLLMENT_REVOKED"||r==="SESSION_REVOKED"?"This device was removed by an admin.":"The session could not be renewed (it may have passed its maximum age).";throw new i(`${o} Ask for a new invite link and run redaktyn-connect again.`)}const n={...e,token:t.data.token,tokenIssuedAt:Date.now()};return b(n),n}async function ie(e,t,{serverUrl:n}={}){const r=_(n),o=await l(r,"POST","/api/auth/login",{body:{email:e,password:t}});if(o.status===429)throw new i("Too many attempts for this account. Wait a few minutes and try again.");if(!o.ok||!o.data?.token)throw new i(o.status===401?"Wrong email or password.":`Login failed (${o.status}): ${o.data?.error||o.text.slice(0,160)}`);const c=o.data.user?.role;if(c!=="OWNER"&&c!=="ADMIN")throw new i(`This account is a ${c??"non-admin"}. Bulk import and invites need an OWNER or ADMIN login.`);const s={serverUrl:r,token:o.data.token,tokenIssuedAt:Date.now(),loggedInAt:new Date().toISOString(),email:o.data.user.email,role:c,orgId:o.data.org?.id??null,orgName:o.data.org?.name??null};return I(s),s}async function se(e=J()){if(!e)throw new i("Not logged in. Run: redaktyn-login");if(Date.now()-(e.tokenIssuedAt||0)<$)return e;const t=await l(e.serverUrl,"POST","/api/auth/refresh",{token:e.token});if(!t.ok||!t.data?.token)throw new i("Your login has expired. Run: redaktyn-login");const n={...e,token:t.data.token,tokenIssuedAt:Date.now()};return I(n),n}function ce(e){return new Promise(t=>{const n=process.stdin;if(!n.isTTY||typeof n.setRawMode!="function"){let s="";n.setEncoding("utf8"),n.on("data",a=>s+=a),n.on("end",()=>t(s.split(/\r?\n/)[0]??""));return}process.stderr.write(e);let r="";n.setRawMode(!0),n.resume(),n.setEncoding("utf8");const o=()=>{n.setRawMode(!1),n.pause(),n.off("data",c),process.stderr.write(`
|
|
5
|
+
`),t(r)},c=s=>{for(const a of s){if(a==="\r"||a===`
|
|
6
|
+
`||a==="")return o();a===""&&(n.setRawMode(!1),process.stderr.write(`
|
|
7
|
+
`),process.exit(130)),a==="\x7F"||a==="\b"?r=r.slice(0,-1):r+=a}};n.on("data",c)})}async function ue({serverUrl:e,onCode:t,sleep:n=r=>new Promise(o=>setTimeout(o,r))}={}){const r=_(e),o=await l(r,"POST","/api/cli-login/start",{body:{clientName:j()}});if(o.status===429)throw new i("Too many sign-in requests. Wait a few minutes and try again.");if(!o.ok||!o.data?.deviceCode)throw new i(`Could not start a browser sign-in (${o.status}). This server may be older; use: redaktyn-login --password`);const{deviceCode:c,userCode:s,verificationUrl:a,expiresIn:d}=o.data;let f=(o.data.interval||2)*1e3;const y=Date.now()+(d||600)*1e3;t&&t({userCode:s,verificationUrl:a,expiresIn:d});let N=0;for(;Date.now()<y;){await n(f);let u;try{u=await l(r,"POST","/api/cli-login/poll",{body:{deviceCode:c}}),N=0}catch(M){if(++N>=5)throw M;continue}if(u.status===429){f=((u.data?.interval||5)+1)*1e3;continue}if(u.status===403)throw new i("The sign-in was denied in the browser.");if(u.status===410)throw new i("The sign-in request expired. Run the command again.");if(!u.ok)throw new i(`Sign-in failed (${u.status}): ${u.data?.error||u.text.slice(0,160)}`);if(u.data?.status!=="approved")continue;const m=u.data.user?.role;if(m!=="OWNER"&&m!=="ADMIN")throw new i(`That account is a ${m??"non-admin"}. Bulk import and invites need an OWNER or ADMIN.`);const A={serverUrl:r,token:u.data.token,tokenIssuedAt:Date.now(),loggedInAt:new Date().toISOString(),email:u.data.user.email,role:m,orgId:u.data.org?.id??null,orgName:u.data.org?.name??null};return I(A),A}throw new i("Timed out waiting for approval. Run the command again.")}export{L as DEFAULT_SERVER_URL,S as INVITE_KEY_BYTES,i as SessionError,$ as TOKEN_REFRESH_AFTER_MS,l as apiJson,H as bytesToB64u,re as connectWithInvite,ne as deleteAdminSession,te as deleteSession,V as deviceId,se as freshAdminSession,ae as freshSession,J as loadAdminSession,Y as loadSession,ue as loginWithBrowser,ie as loginWithPassword,q as parseInviteLink,ce as readHidden,_ as resolveServerUrl,I as saveAdminSession,b as saveSession,oe as sealInviteKey,h as sessionDir,X as unsealInviteKey};
|