@redaktyn/cli 1.5.9 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -12
- package/package.json +6 -3
- package/redaktyn-connect.mjs +12 -5
- package/redaktyn-fingerprint.mjs +11 -11
- package/redaktyn-import.mjs +18 -17
- package/redaktyn-invite.mjs +14 -5
- package/redaktyn-lock-net.mjs +1 -0
- package/redaktyn-login.mjs +7 -7
- package/redaktyn-passphrase.mjs +2 -2
- package/redaktyn-scan.mjs +10 -7
- package/redaktyn-session.mjs +8 -7
- package/redaktyn-share.mjs +36 -0
- package/redaktyn-shared.cjs +28 -5
- package/redaktyn-verify.mjs +15 -15
package/redaktyn-verify.mjs
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{createHash as T,createHmac as L,pbkdf2 as F,verify as J,createPublicKey as M}from"node:crypto";import{promisify as V}from"node:util";import{readFileSync as E,writeFileSync as W,existsSync as A}from"node:fs";import{join as
|
|
2
|
+
import{createHash as T,createHmac as L,pbkdf2 as F,verify as J,createPublicKey as M}from"node:crypto";import{promisify as V}from"node:util";import{readFileSync as E,writeFileSync as W,existsSync as A}from"node:fs";import{join as z}from"node:path";import{homedir as G}from"node:os";import{createInterface as U}from"node:readline";const X=V(F),P=6e5,Q=/[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g;function I(e){return e.normalize("NFC").replace(/\r\n/g,`
|
|
3
3
|
`).replace(/[\r\u2028\u2029]/g,`
|
|
4
|
-
`).replace(Q,"").trim()}function q(e){return e.length===36&&e[8]==="-"&&e[13]==="-"&&e[18]==="-"&&e[23]==="-"&&/^[0-9a-fA-F]{32}$/.test(e.replace(/-/g,""))?e.toLowerCase():e}async function Z(e,s){const t=T("sha256").update(s,"utf8").digest();return X(e,t,P,32,"sha256")}function N(e,s){return L("sha256",s).update(e,"utf8").digest("hex")}function ee(e,s){const t=q(I(e).replace(/\s/g,""));return{hmacHex:N(q(I(e)),s),hmacHexStripped:N(t,s),hmacHexLower:N(t.toLowerCase(),s)}}const te=Buffer.from("302a300506032b6570032100","hex");function se(e,s,t){const
|
|
5
|
-
${n.red}${e}${n.r}`),process.exit(1)}function
|
|
6
|
-
|
|
7
|
-
`),s(t)
|
|
8
|
-
${n.b}Deriving key${n.r} PBKDF2-SHA256, ${P.toLocaleString("en-US")} iterations`);const
|
|
9
|
-
${n.b}Computed locally${n.r}`);for(const[
|
|
4
|
+
`).replace(Q,"").trim()}function q(e){return e.length===36&&e[8]==="-"&&e[13]==="-"&&e[18]==="-"&&e[23]==="-"&&/^[0-9a-fA-F]{32}$/.test(e.replace(/-/g,""))?e.toLowerCase():e}async function Z(e,s){const t=T("sha256").update(s,"utf8").digest();return X(e,t,P,32,"sha256")}function N(e,s){return L("sha256",s).update(e,"utf8").digest("hex")}function ee(e,s){const t=q(I(e).replace(/\s/g,""));return{hmacHex:N(q(I(e)),s),hmacHexStripped:N(t,s),hmacHexLower:N(t.toLowerCase(),s)}}const te=Buffer.from("302a300506032b6570032100","hex");function se(e,s,t){const r=Buffer.from(t,"base64");if(r.length!==32)return!1;const c=M({key:Buffer.concat([te,r]),format:"der",type:"spki"});return J(null,e,c,Buffer.from(s,"base64"))}function x(e){return e===null||typeof e!="object"?JSON.stringify(e):Array.isArray(e)?`[${e.map(x).join(",")}]`:`{${Object.keys(e).sort().map(s=>`${JSON.stringify(s)}:${x(e[s])}`).join(",")}}`}const ne="0".repeat(64);function H(e){let s=ne;return e.forEach((t,r)=>{t.seq!==r+1&&d(`Release log entry ${r+1} is out of order (seq ${t.seq}).`),t.prevHead!==s&&d(`Release log entry ${t.seq} does not follow ${t.seq-1}.`);const{head:c,...l}=t;T("sha256").update(x(l),"utf8").digest("hex")!==c&&d(`Release log entry ${t.seq} was modified after it was written.`),s=c}),s}const b=process.env.NO_COLOR!=null||process.argv.includes("--no-color"),n={b:b?"":"\x1B[1m",d:b?"":"\x1B[2m",r:b?"":"\x1B[0m",g:b?"":"\x1B[32m",y:b?"":"\x1B[33m",red:b?"":"\x1B[31m"},p=e=>console.log(` ${n.g}\u2713${n.r} ${e}`),g=e=>console.log(` ${n.red}\u2717${n.r} ${e}`),v=e=>console.log(` ${n.y}!${n.r} ${e}`),o=e=>console.log(` ${n.d}${e}${n.r}`);function d(e){console.error(`
|
|
5
|
+
${n.red}${e}${n.r}`),process.exit(1)}function S(e,s=null){const t=process.argv.indexOf(e);return t>=0&&process.argv[t+1]!=null?process.argv[t+1]:s}function D(e){const s=S(e);return s==null&&d(`Missing required argument ${e}. Run with --help.`),s}function w(){return(S("--server")??process.env.REDAKTYN_SERVER??"").replace(/\/+$/,"")||d("Set --server https://your-redaktyn-host (or REDAKTYN_SERVER).")}async function C(e,s={}){const t=await fetch(e,{headers:s});return t.ok||d(`GET ${e} \u2192 HTTP ${t.status}`),t.json()}async function B(e){const s=await fetch(e);return s.ok||d(`GET ${e} \u2192 HTTP ${s.status}`),Buffer.from(await s.arrayBuffer())}function _(e){return new Promise(s=>{if(process.stdout.write(` ${e} `),!process.stdin.isTTY){const c=U({input:process.stdin});c.once("line",l=>{c.close(),s(l)});return}let t="";process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.setEncoding("utf8");const r=c=>{for(const l of c){if(l===""&&process.exit(130),l==="\r"||l===`
|
|
6
|
+
`){process.stdin.removeListener("data",r),process.stdin.setRawMode(!1),process.stdin.pause(),process.stdout.write(`
|
|
7
|
+
`),s(t);return}l==="\x7F"||l==="\b"?t&&(t=t.slice(0,-1),process.stdout.write("\b \b")):(t+=l,process.stdout.write("\u2022"))}};process.stdin.on("data",r)})}const k=process.env.REDAKTYN_VERIFY_PIN??z(G(),".redaktyn-verify-pin.json");async function re(){const e=D("--org-id"),s=process.env.REDAKTYN_PASSPHRASE??await _("Organization passphrase:");s||d("A passphrase is required \u2014 it is what the key is derived from.");const t=process.env.REDAKTYN_SECRET??await _("Secret value:");t||d("A secret value is required."),console.log(`
|
|
8
|
+
${n.b}Deriving key${n.r} PBKDF2-SHA256, ${P.toLocaleString("en-US")} iterations`);const r=await Z(s,e),c=ee(t,r);console.log(`
|
|
9
|
+
${n.b}Computed locally${n.r}`);for(const[a,f]of Object.entries(c))console.log(` ${a.padEnd(16)} ${f}`);const l=S("--token")??process.env.REDAKTYN_TOKEN;if(!l){console.log(`
|
|
10
10
|
${n.d}No API token given, so nothing was compared against the server.
|
|
11
11
|
Pass --token <admin JWT> to fetch GET /api/secrets and check whether the
|
|
12
|
-
stored digests equal the values above.${n.r}`);return}const
|
|
13
|
-
${n.b}Compared against ${
|
|
14
|
-
${n.b}Manifest${n.r} commit ${
|
|
15
|
-
${n.b}Served bytes vs manifest${n.r}`);let
|
|
16
|
-
`).map(
|
|
12
|
+
stored digests equal the values above.${n.r}`);return}const h=await fetch(`${w()}/api/org/key-lock`,{headers:{Authorization:`Bearer ${l}`,"X-Redaktyn-Lock-Versions":"1,2,3"}});if(h.ok&&(await h.json().catch(()=>null))?.lock){g("This workspace's key is locked with the server, so it cannot be recomputed from the passphrase alone."),o("That is by design: nothing the server stores lets anyone test a passphrase offline."),o("redaktyn-verify uses Node built-ins only and does not open locks. Compare digests with"),o("redaktyn-import without --apply, which opens the lock and previews what it would file."),process.exitCode=1;return}const u=await C(`${w()}/api/secrets`,{Authorization:`Bearer ${l}`});console.log(`
|
|
13
|
+
${n.b}Compared against ${u.length} registered fingerprint(s)${n.r}`);const i=u.find(a=>["hmacHex","hmacHexStripped","hmacHexLower"].some(f=>a[f]!=null&&a[f]===c[f]));if(!i){g("No stored digest equals what was just computed."),o("Either this secret has no registered fingerprint, or the passphrase differs"),o("from the one used to register it. A different passphrase yields an unrelated digest."),process.exitCode=1;return}p(`Matched "${i.label}".`);for(const a of["hmacHex","hmacHexStripped","hmacHexLower"])i[a]==null?v(`${a} is not stored for this secret \u2014 that variant is unprotected.`):i[a]===c[a]?p(`${a} identical.`):(g(`${a} differs from the local computation.`),process.exitCode=1);o("The server holds a one-way digest of this secret, not the secret."),o("It does not follow that the server never received the plaintext by some"),o("other route \u2014 run `egress` against a capture to check what the client sends.")}async function oe(){const e=w(),s=await C(`${e}/api/verify/release`);if(s.released===!1){v("This deployment publishes no signed release manifest."),o(s.detail??""),o("Nothing can be verified about the served bundle. Build from source and"),o("compare, or ask the operator which commit is deployed."),process.exitCode=1;return}const t=Buffer.from(s.manifest,"utf8"),r=JSON.parse(s.manifest);if(console.log(`
|
|
14
|
+
${n.b}Manifest${n.r} commit ${r.gitCommit} \xB7 ${r.artifactCount} artifacts`),!s.signed)v("The manifest is NOT signed \u2014 it lists hashes but attests to nothing.");else{const i=S("--pubkey")??process.env.REDAKTYN_PUBKEY,a=s.signature.publicKeyBase64;if(i==null)v("Signature checked against the key this same server supplied."),o("That only rules out accidental corruption. A server able to alter the"),o("manifest can alter the key beside it. Pass --pubkey <base64> with a key"),o("you obtained elsewhere to make this meaningful.");else if(i!==a){g("The server's signing key is not the key you pinned."),o(`pinned ${i}`),o(`served ${a}`),process.exitCode=1;return}else p("Signing key matches the one you pinned.");if(!se(t,s.signature.signature,a)){g("Ed25519 signature does not verify \u2014 the manifest has been altered."),process.exitCode=1;return}p("Signature verifies over the manifest bytes.")}const c=new Map(r.artifacts.map(i=>[i.path,i.sha256])),l=await B(`${e}/`),h=[["dashboard/index.html",l]];for(const i of l.toString("utf8").matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g))h.push([`dashboard${i[1]}`,await B(`${e}${i[1]}`)]);console.log(`
|
|
15
|
+
${n.b}Served bytes vs manifest${n.r}`);let u=0;for(const[i,a]of h){const f=T("sha256").update(a).digest("hex"),m=c.get(i);m==null?v(`${i} \u2014 not in the manifest`):m===f?p(`${i}`):(g(`${i} \u2014 served bytes differ from the signed hash`),u++)}if(u>0){process.exitCode=1;return}o("The bundle served to you matches the manifest. To close the loop, check"),o("that the manifest matches the published source: clone the repo at"),o(`${r.gitCommit.slice(0,12)} and run scripts/release/verify-reproducible.mjs.`)}async function K(e){const s=await(await fetch(`${e}/api/verify/log`)).text();s.trimStart().startsWith("{")&&s.includes('"released":false')&&d("This deployment publishes no release log.");const t=s.split(`
|
|
16
|
+
`).map(r=>r.trim()).filter(Boolean).map(r=>JSON.parse(r));return t.length===0&&d("The release log is empty."),t}async function ie(){const e=w(),s=await K(e),t=H(s);p(`Chain of ${s.length} release(s) is internally consistent.`);const r=A(k)?JSON.parse(E(k,"utf8")):{};r[e]={head:t,seq:s.length,pinnedAt:new Date().toISOString()},W(k,JSON.stringify(r,null,2)+`
|
|
17
17
|
`),console.log(`
|
|
18
|
-
head ${t}`),console.log(` saved ${
|
|
19
|
-
${n.b}Inspected ${
|
|
18
|
+
head ${t}`),console.log(` saved ${k}`),o("Run `redaktyn-verify check` later. If releases up to this point are ever"),o("rewritten or dropped, the chain will no longer reproduce this head.")}async function ae(){const e=w();A(k)||d(`Nothing pinned yet \u2014 run \`redaktyn-verify pin --server ${e}\` first.`);const t=JSON.parse(E(k,"utf8"))[e];t==null&&d(`No pin recorded for ${e}.`);const r=await K(e);if(H(r),p(`Chain of ${r.length} release(s) is internally consistent.`),r.length<t.seq){g(`The log has shrunk: ${t.seq} entries were pinned, ${r.length} are served now.`),process.exitCode=1;return}const c=r[t.seq-1];if(c.head!==t.head){g(`Release ${t.seq} has changed since you pinned it.`),o(`pinned ${t.head}`),o(`served ${c.head}`),o("History was rewritten. Treat every signature from this server as suspect."),process.exitCode=1;return}p(`Release ${t.seq} still hashes to the head you pinned on ${t.pinnedAt.slice(0,10)}.`),r.length>t.seq&&p(`${r.length-t.seq} release(s) appended since, with your pin intact beneath them.`),o("This detects a rewrite of the log served to you. It cannot detect a server"),o("that serves a different, self-consistent log to somebody else.")}async function ce(){const e=D("--har");A(e)||d(`No such file: ${e}`);const s=JSON.parse(E(e,"utf8")),{endpoints:t}=await C(`${w()}/api/verify/fields`),r=u=>t.find(i=>{const a="^"+i.path.replace(/:[^/]+/g,"[^/]+")+"$";return new RegExp(a).test(u)}),c=s?.log?.entries??[];c.length===0&&d("That HAR contains no entries.");let l=0;const h=[];for(const u of c){const i=u.request;if(i?.postData?.text==null)continue;let a;try{a=new URL(i.url).pathname}catch{continue}const f=r(a);if(f==null||f.method!==i.method)continue;let m;try{m=JSON.parse(i.postData.text)}catch{continue}l++;const Y=Object.keys(m).filter(y=>!f.fields.includes(y));for(const y of Y)h.push(`${i.method} ${a} \u2192 "${y}"`);const R=f.itemFields;if(R&&Array.isArray(m[R.field])){for(const y of m[R.field])if(!(y==null||typeof y!="object"))for(const O of Object.keys(y))R.fields.includes(O)||h.push(`${i.method} ${a} \u2192 ${R.field}[].${O}`)}}if(console.log(`
|
|
19
|
+
${n.b}Inspected ${l} Redaktyn API request(s) in ${e}${n.r}`),l===0){v("None of the captured requests hit a Redaktyn ingest endpoint."),o("Record the capture while registering a fingerprint or triggering a block.");return}if(h.length>0){g(`${h.length} field(s) outside the published allowlist:`);for(const u of[...new Set(h)])console.log(` ${u}`);process.exitCode=1;return}p("Every field sent is one the API publicly declares it accepts."),o("This covers the requests in your capture, to endpoints on the allowlist."),o("It says nothing about requests you did not record, or about what the"),o("server does with the fields it does accept.")}function le(){console.log(`
|
|
20
20
|
${n.b}redaktyn-verify${n.r} \u2014 check Redaktyn's claims for yourself
|
|
21
21
|
|
|
22
22
|
${n.b}COMMANDS${n.r}
|
|
@@ -57,4 +57,4 @@ ${n.b}WHAT THIS CANNOT SHOW${n.r}
|
|
|
57
57
|
A one-way digest proves the stored value cannot be reversed; it does not
|
|
58
58
|
prove the plaintext was never written elsewhere. That needs source access,
|
|
59
59
|
an independent audit, or a deployment you host.${n.r}
|
|
60
|
-
`)}const
|
|
60
|
+
`)}const $=process.argv[2],j={recompute:re,build:oe,pin:ie,check:ae,egress:ce};($==null||$==="--help"||$==="-h"||j[$]==null)&&(le(),process.exit($==null||$.startsWith("-")?0:1)),j[$]().catch(e=>{d(e instanceof Error?e.message:String(e))});
|