@redaktyn/cli 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +561 -2
- package/package.json +44 -4
- package/redaktyn-fingerprint.mjs +814 -0
- package/redaktyn-import.mjs +1652 -0
- package/redaktyn-scan.mjs +565 -0
- package/redaktyn-verify.mjs +557 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* redaktyn-verify — check Redaktyn's claims without taking Redaktyn's word for any of them.
|
|
4
|
+
*
|
|
5
|
+
* Zero dependencies, Node built-ins only, one file. That is a requirement, not
|
|
6
|
+
* a preference: a verifier you have to trust is not a verifier. This is meant
|
|
7
|
+
* to be read end to end before it is run, and it is short enough to do that.
|
|
8
|
+
*
|
|
9
|
+
* redaktyn-verify recompute derive a digest from your secret; compare to the server's
|
|
10
|
+
* redaktyn-verify build hash what the server served you; compare to the signed manifest
|
|
11
|
+
* redaktyn-verify pin record the release log head as you see it today
|
|
12
|
+
* redaktyn-verify check confirm the log still reproduces the head you pinned
|
|
13
|
+
* redaktyn-verify egress assert a captured HAR contains no field the API does not accept
|
|
14
|
+
*
|
|
15
|
+
* WHAT THESE DO NOT PROVE, stated up front so nothing here is oversold:
|
|
16
|
+
*
|
|
17
|
+
* - Nothing establishes what the server does internally. `recompute` shows the
|
|
18
|
+
* stored value is a one-way function of your secret; it cannot show the
|
|
19
|
+
* server did not also write the plaintext somewhere it never sends back.
|
|
20
|
+
* Only source access, an audit, or self-hosting settles that.
|
|
21
|
+
* - `build` establishes that the bytes you were served match a signed manifest.
|
|
22
|
+
* Whether that manifest describes the published source is what
|
|
23
|
+
* `scripts/release/verify-reproducible.mjs` is for — build it yourself.
|
|
24
|
+
* - `check` catches a rewrite of the log served to you. A server can serve a
|
|
25
|
+
* different, internally consistent log to someone else, and nothing here
|
|
26
|
+
* would notice. Catching that needs a witness Redaktyn does not yet have.
|
|
27
|
+
*
|
|
28
|
+
* Requires Node 18+.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createHash, createHmac, pbkdf2 as _pbkdf2, verify as edVerify, createPublicKey } from "node:crypto";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { createInterface } from "node:readline";
|
|
37
|
+
|
|
38
|
+
const pbkdf2 = promisify(_pbkdf2);
|
|
39
|
+
|
|
40
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
The digest pipeline. Must match @redaktyn/shared/secret-digest.ts exactly.
|
|
42
|
+
Reimplemented rather than imported: a verifier that pulls in the code it is
|
|
43
|
+
checking verifies nothing.
|
|
44
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
45
|
+
|
|
46
|
+
const PBKDF2_ITERATIONS = 600_000;
|
|
47
|
+
const INVISIBLE = /[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/g;
|
|
48
|
+
|
|
49
|
+
function normalize(raw) {
|
|
50
|
+
return raw
|
|
51
|
+
.normalize("NFC")
|
|
52
|
+
.replace(/\r\n/g, "\n")
|
|
53
|
+
.replace(/[\r\u2028\u2029]/g, "\n")
|
|
54
|
+
.replace(INVISIBLE, "")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function foldUuid(s) {
|
|
59
|
+
if (
|
|
60
|
+
s.length === 36 && s[8] === "-" && s[13] === "-" && s[18] === "-" && s[23] === "-" &&
|
|
61
|
+
/^[0-9a-fA-F]{32}$/.test(s.replace(/-/g, ""))
|
|
62
|
+
) return s.toLowerCase();
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function deriveKey(passphrase, orgId) {
|
|
67
|
+
const salt = createHash("sha256").update(orgId, "utf8").digest();
|
|
68
|
+
return pbkdf2(passphrase, salt, PBKDF2_ITERATIONS, 32, "sha256");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hmac(text, key) {
|
|
72
|
+
return createHmac("sha256", key).update(text, "utf8").digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function digests(secret, key) {
|
|
76
|
+
const collapsed = foldUuid(normalize(secret).replace(/\s/g, ""));
|
|
77
|
+
return {
|
|
78
|
+
hmacHex: hmac(foldUuid(normalize(secret)), key),
|
|
79
|
+
hmacHexStripped: hmac(collapsed, key),
|
|
80
|
+
hmacHexLower: hmac(collapsed.toLowerCase(), key),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
Signature and log verification
|
|
86
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
87
|
+
|
|
88
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
89
|
+
|
|
90
|
+
function verifySignature(bytes, signatureB64, publicKeyB64) {
|
|
91
|
+
const raw = Buffer.from(publicKeyB64, "base64");
|
|
92
|
+
if (raw.length !== 32) return false;
|
|
93
|
+
const key = createPublicKey({
|
|
94
|
+
key: Buffer.concat([SPKI_PREFIX, raw]),
|
|
95
|
+
format: "der",
|
|
96
|
+
type: "spki",
|
|
97
|
+
});
|
|
98
|
+
return edVerify(null, bytes, key, Buffer.from(signatureB64, "base64"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Canonical JSON — key order pinned, so hashes are reproducible. */
|
|
102
|
+
function canonicalJson(v) {
|
|
103
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
104
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
105
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const GENESIS = "0".repeat(64);
|
|
109
|
+
|
|
110
|
+
function verifyChain(entries) {
|
|
111
|
+
let prev = GENESIS;
|
|
112
|
+
entries.forEach((entry, i) => {
|
|
113
|
+
if (entry.seq !== i + 1) fail(`Release log entry ${i + 1} is out of order (seq ${entry.seq}).`);
|
|
114
|
+
if (entry.prevHead !== prev) fail(`Release log entry ${entry.seq} does not follow ${entry.seq - 1}.`);
|
|
115
|
+
const { head, ...body } = entry;
|
|
116
|
+
const recomputed = createHash("sha256").update(canonicalJson(body), "utf8").digest("hex");
|
|
117
|
+
if (recomputed !== head) fail(`Release log entry ${entry.seq} was modified after it was written.`);
|
|
118
|
+
prev = head;
|
|
119
|
+
});
|
|
120
|
+
return prev;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
124
|
+
Plumbing
|
|
125
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
126
|
+
|
|
127
|
+
const NO_COLOR = process.env["NO_COLOR"] != null || process.argv.includes("--no-color");
|
|
128
|
+
const c = {
|
|
129
|
+
b: NO_COLOR ? "" : "\x1b[1m", d: NO_COLOR ? "" : "\x1b[2m", r: NO_COLOR ? "" : "\x1b[0m",
|
|
130
|
+
g: NO_COLOR ? "" : "\x1b[32m", y: NO_COLOR ? "" : "\x1b[33m", red: NO_COLOR ? "" : "\x1b[31m",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const ok = (m) => console.log(` ${c.g}✓${c.r} ${m}`);
|
|
134
|
+
const bad = (m) => console.log(` ${c.red}✗${c.r} ${m}`);
|
|
135
|
+
const warn = (m) => console.log(` ${c.y}!${c.r} ${m}`);
|
|
136
|
+
const note = (m) => console.log(` ${c.d}${m}${c.r}`);
|
|
137
|
+
|
|
138
|
+
function fail(message) {
|
|
139
|
+
console.error(`\n${c.red}${message}${c.r}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function arg(name, fallback = null) {
|
|
144
|
+
const i = process.argv.indexOf(name);
|
|
145
|
+
return i >= 0 && process.argv[i + 1] != null ? process.argv[i + 1] : fallback;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function requireArg(name) {
|
|
149
|
+
const v = arg(name);
|
|
150
|
+
if (v == null) fail(`Missing required argument ${name}. Run with --help.`);
|
|
151
|
+
return v;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Base URL of the Redaktyn deployment being checked. */
|
|
155
|
+
function serverBase() {
|
|
156
|
+
return (arg("--server") ?? process.env["REDAKTYN_SERVER"] ?? "").replace(/\/+$/, "") ||
|
|
157
|
+
fail("Set --server https://your-redaktyn-host (or REDAKTYN_SERVER).");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function getJson(url, headers = {}) {
|
|
161
|
+
const res = await fetch(url, { headers });
|
|
162
|
+
if (!res.ok) fail(`GET ${url} → HTTP ${res.status}`);
|
|
163
|
+
return res.json();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function getBytes(url) {
|
|
167
|
+
const res = await fetch(url);
|
|
168
|
+
if (!res.ok) fail(`GET ${url} → HTTP ${res.status}`);
|
|
169
|
+
return Buffer.from(await res.arrayBuffer());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function promptHidden(label) {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
process.stdout.write(` ${label} `);
|
|
175
|
+
if (!process.stdin.isTTY) {
|
|
176
|
+
const rl = createInterface({ input: process.stdin });
|
|
177
|
+
rl.once("line", (l) => { rl.close(); resolve(l); });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let value = "";
|
|
181
|
+
process.stdin.setRawMode(true);
|
|
182
|
+
process.stdin.resume();
|
|
183
|
+
process.stdin.setEncoding("utf8");
|
|
184
|
+
const onData = (ch) => {
|
|
185
|
+
if (ch === "\u0003") process.exit(130);
|
|
186
|
+
if (ch === "\r" || ch === "\n") {
|
|
187
|
+
process.stdin.removeListener("data", onData);
|
|
188
|
+
process.stdin.setRawMode(false);
|
|
189
|
+
process.stdin.pause();
|
|
190
|
+
process.stdout.write("\n");
|
|
191
|
+
resolve(value);
|
|
192
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
193
|
+
if (value) { value = value.slice(0, -1); process.stdout.write("\b \b"); }
|
|
194
|
+
} else {
|
|
195
|
+
value += ch;
|
|
196
|
+
process.stdout.write("•");
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
process.stdin.on("data", onData);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Where pinned log heads are recorded. Overridable so the pin can live beside
|
|
205
|
+
* a CI job or a per-customer profile rather than only in one home directory —
|
|
206
|
+
* a pin that cannot be checked into the place doing the checking never gets
|
|
207
|
+
* checked.
|
|
208
|
+
*/
|
|
209
|
+
const PIN_PATH =
|
|
210
|
+
process.env["REDAKTYN_VERIFY_PIN"] ?? join(homedir(), ".redaktyn-verify-pin.json");
|
|
211
|
+
|
|
212
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
213
|
+
recompute
|
|
214
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
215
|
+
|
|
216
|
+
async function cmdRecompute() {
|
|
217
|
+
const orgId = requireArg("--org-id");
|
|
218
|
+
const passphrase = process.env["REDAKTYN_PASSPHRASE"] ?? (await promptHidden("Organization passphrase:"));
|
|
219
|
+
if (!passphrase) fail("A passphrase is required — it is what the key is derived from.");
|
|
220
|
+
const secret = process.env["REDAKTYN_SECRET"] ?? (await promptHidden("Secret value:"));
|
|
221
|
+
if (!secret) fail("A secret value is required.");
|
|
222
|
+
|
|
223
|
+
console.log(`\n${c.b}Deriving key${c.r} PBKDF2-SHA256, ${PBKDF2_ITERATIONS.toLocaleString("en-US")} iterations`);
|
|
224
|
+
const key = await deriveKey(passphrase, orgId);
|
|
225
|
+
const local = digests(secret, key);
|
|
226
|
+
|
|
227
|
+
console.log(`\n${c.b}Computed locally${c.r}`);
|
|
228
|
+
for (const [k, v] of Object.entries(local)) console.log(` ${k.padEnd(16)} ${v}`);
|
|
229
|
+
|
|
230
|
+
const token = arg("--token") ?? process.env["REDAKTYN_TOKEN"];
|
|
231
|
+
if (!token) {
|
|
232
|
+
console.log(
|
|
233
|
+
`\n${c.d}No API token given, so nothing was compared against the server.\n` +
|
|
234
|
+
`Pass --token <admin JWT> to fetch GET /api/secrets and check whether the\n` +
|
|
235
|
+
`stored digests equal the values above.${c.r}`
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const stored = await getJson(`${serverBase()}/api/secrets`, { Authorization: `Bearer ${token}` });
|
|
241
|
+
console.log(`\n${c.b}Compared against ${stored.length} registered fingerprint(s)${c.r}`);
|
|
242
|
+
|
|
243
|
+
const match = stored.find((s) =>
|
|
244
|
+
["hmacHex", "hmacHexStripped", "hmacHexLower"].some((k) => s[k] != null && s[k] === local[k])
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (!match) {
|
|
248
|
+
bad("No stored digest equals what was just computed.");
|
|
249
|
+
note("Either this secret has no registered fingerprint, or the passphrase differs");
|
|
250
|
+
note("from the one used to register it. A different passphrase yields an unrelated digest.");
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
ok(`Matched "${match.label}".`);
|
|
256
|
+
for (const k of ["hmacHex", "hmacHexStripped", "hmacHexLower"]) {
|
|
257
|
+
if (match[k] == null) warn(`${k} is not stored for this secret — that variant is unprotected.`);
|
|
258
|
+
else if (match[k] === local[k]) ok(`${k} identical.`);
|
|
259
|
+
else {
|
|
260
|
+
bad(`${k} differs from the local computation.`);
|
|
261
|
+
// A variant that disagrees is a real verification failure: that pass
|
|
262
|
+
// will not match this secret on a device. `bad()` only prints, so
|
|
263
|
+
// without this the command said "✗" and still exited 0 — and anything
|
|
264
|
+
// gating on the exit code, which is how this is meant to be used, read
|
|
265
|
+
// a mismatch as a pass.
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
note("The server holds a one-way digest of this secret, not the secret.");
|
|
270
|
+
note("It does not follow that the server never received the plaintext by some");
|
|
271
|
+
note("other route — run `egress` against a capture to check what the client sends.");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
275
|
+
build
|
|
276
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
277
|
+
|
|
278
|
+
async function cmdBuild() {
|
|
279
|
+
const base = serverBase();
|
|
280
|
+
const release = await getJson(`${base}/api/verify/release`);
|
|
281
|
+
|
|
282
|
+
if (release.released === false) {
|
|
283
|
+
warn("This deployment publishes no signed release manifest.");
|
|
284
|
+
note(release.detail ?? "");
|
|
285
|
+
note("Nothing can be verified about the served bundle. Build from source and");
|
|
286
|
+
note("compare, or ask the operator which commit is deployed.");
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const manifestBytes = Buffer.from(release.manifest, "utf8");
|
|
292
|
+
const manifest = JSON.parse(release.manifest);
|
|
293
|
+
console.log(`\n${c.b}Manifest${c.r} commit ${manifest.gitCommit} · ${manifest.artifactCount} artifacts`);
|
|
294
|
+
|
|
295
|
+
if (!release.signed) {
|
|
296
|
+
warn("The manifest is NOT signed — it lists hashes but attests to nothing.");
|
|
297
|
+
} else {
|
|
298
|
+
const expected = arg("--pubkey") ?? process.env["REDAKTYN_PUBKEY"];
|
|
299
|
+
const served = release.signature.publicKeyBase64;
|
|
300
|
+
if (expected == null) {
|
|
301
|
+
warn("Signature checked against the key this same server supplied.");
|
|
302
|
+
note("That only rules out accidental corruption. A server able to alter the");
|
|
303
|
+
note("manifest can alter the key beside it. Pass --pubkey <base64> with a key");
|
|
304
|
+
note("you obtained elsewhere to make this meaningful.");
|
|
305
|
+
} else if (expected !== served) {
|
|
306
|
+
bad("The server's signing key is not the key you pinned.");
|
|
307
|
+
note(`pinned ${expected}`);
|
|
308
|
+
note(`served ${served}`);
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
return;
|
|
311
|
+
} else {
|
|
312
|
+
ok("Signing key matches the one you pinned.");
|
|
313
|
+
}
|
|
314
|
+
if (!verifySignature(manifestBytes, release.signature.signature, served)) {
|
|
315
|
+
bad("Ed25519 signature does not verify — the manifest has been altered.");
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
ok("Signature verifies over the manifest bytes.");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Hash what this server actually serves and compare with the manifest.
|
|
323
|
+
const byPath = new Map(manifest.artifacts.map((a) => [a.path, a.sha256]));
|
|
324
|
+
const html = await getBytes(`${base}/`);
|
|
325
|
+
const checks = [["dashboard/index.html", html]];
|
|
326
|
+
|
|
327
|
+
for (const m of html.toString("utf8").matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)) {
|
|
328
|
+
checks.push([`dashboard${m[1]}`, await getBytes(`${base}${m[1]}`)]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log(`\n${c.b}Served bytes vs manifest${c.r}`);
|
|
332
|
+
let mismatched = 0;
|
|
333
|
+
for (const [path, bytes] of checks) {
|
|
334
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
335
|
+
const expected = byPath.get(path);
|
|
336
|
+
if (expected == null) {
|
|
337
|
+
warn(`${path} — not in the manifest`);
|
|
338
|
+
} else if (expected === actual) {
|
|
339
|
+
ok(`${path}`);
|
|
340
|
+
} else {
|
|
341
|
+
bad(`${path} — served bytes differ from the signed hash`);
|
|
342
|
+
mismatched++;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (mismatched > 0) {
|
|
347
|
+
process.exitCode = 1;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
note("The bundle served to you matches the manifest. To close the loop, check");
|
|
352
|
+
note("that the manifest matches the published source: clone the repo at");
|
|
353
|
+
note(`${manifest.gitCommit.slice(0, 12)} and run scripts/release/verify-reproducible.mjs.`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
357
|
+
pin / check
|
|
358
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
359
|
+
|
|
360
|
+
async function fetchLog(base) {
|
|
361
|
+
const text = await (await fetch(`${base}/api/verify/log`)).text();
|
|
362
|
+
if (text.trimStart().startsWith("{") && text.includes('"released":false')) {
|
|
363
|
+
fail("This deployment publishes no release log.");
|
|
364
|
+
}
|
|
365
|
+
const entries = text.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l));
|
|
366
|
+
if (entries.length === 0) fail("The release log is empty.");
|
|
367
|
+
return entries;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function cmdPin() {
|
|
371
|
+
const base = serverBase();
|
|
372
|
+
const entries = await fetchLog(base);
|
|
373
|
+
const head = verifyChain(entries);
|
|
374
|
+
ok(`Chain of ${entries.length} release(s) is internally consistent.`);
|
|
375
|
+
|
|
376
|
+
const pins = existsSync(PIN_PATH) ? JSON.parse(readFileSync(PIN_PATH, "utf8")) : {};
|
|
377
|
+
pins[base] = { head, seq: entries.length, pinnedAt: new Date().toISOString() };
|
|
378
|
+
writeFileSync(PIN_PATH, JSON.stringify(pins, null, 2) + "\n");
|
|
379
|
+
|
|
380
|
+
console.log(`\n head ${head}`);
|
|
381
|
+
console.log(` saved ${PIN_PATH}`);
|
|
382
|
+
note("Run `redaktyn-verify check` later. If releases up to this point are ever");
|
|
383
|
+
note("rewritten or dropped, the chain will no longer reproduce this head.");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function cmdCheck() {
|
|
387
|
+
const base = serverBase();
|
|
388
|
+
if (!existsSync(PIN_PATH)) fail(`Nothing pinned yet — run \`redaktyn-verify pin --server ${base}\` first.`);
|
|
389
|
+
const pins = JSON.parse(readFileSync(PIN_PATH, "utf8"));
|
|
390
|
+
const pin = pins[base];
|
|
391
|
+
if (pin == null) fail(`No pin recorded for ${base}.`);
|
|
392
|
+
|
|
393
|
+
const entries = await fetchLog(base);
|
|
394
|
+
verifyChain(entries);
|
|
395
|
+
ok(`Chain of ${entries.length} release(s) is internally consistent.`);
|
|
396
|
+
|
|
397
|
+
if (entries.length < pin.seq) {
|
|
398
|
+
bad(`The log has shrunk: ${pin.seq} entries were pinned, ${entries.length} are served now.`);
|
|
399
|
+
process.exitCode = 1;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const atPin = entries[pin.seq - 1];
|
|
404
|
+
if (atPin.head !== pin.head) {
|
|
405
|
+
bad(`Release ${pin.seq} has changed since you pinned it.`);
|
|
406
|
+
note(`pinned ${pin.head}`);
|
|
407
|
+
note(`served ${atPin.head}`);
|
|
408
|
+
note("History was rewritten. Treat every signature from this server as suspect.");
|
|
409
|
+
process.exitCode = 1;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
ok(`Release ${pin.seq} still hashes to the head you pinned on ${pin.pinnedAt.slice(0, 10)}.`);
|
|
414
|
+
if (entries.length > pin.seq) {
|
|
415
|
+
ok(`${entries.length - pin.seq} release(s) appended since, with your pin intact beneath them.`);
|
|
416
|
+
}
|
|
417
|
+
note("This detects a rewrite of the log served to you. It cannot detect a server");
|
|
418
|
+
note("that serves a different, self-consistent log to somebody else.");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
422
|
+
egress
|
|
423
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
424
|
+
|
|
425
|
+
async function cmdEgress() {
|
|
426
|
+
const harPath = requireArg("--har");
|
|
427
|
+
if (!existsSync(harPath)) fail(`No such file: ${harPath}`);
|
|
428
|
+
const har = JSON.parse(readFileSync(harPath, "utf8"));
|
|
429
|
+
const { endpoints } = await getJson(`${serverBase()}/api/verify/fields`);
|
|
430
|
+
|
|
431
|
+
/** Matches a concrete request path against an allowlist path with :params. */
|
|
432
|
+
const routeFor = (pathname) =>
|
|
433
|
+
endpoints.find((e) => {
|
|
434
|
+
const pattern = "^" + e.path.replace(/:[^/]+/g, "[^/]+") + "$";
|
|
435
|
+
return new RegExp(pattern).test(pathname);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const entries = har?.log?.entries ?? [];
|
|
439
|
+
if (entries.length === 0) fail("That HAR contains no entries.");
|
|
440
|
+
|
|
441
|
+
let inspected = 0;
|
|
442
|
+
const violations = [];
|
|
443
|
+
|
|
444
|
+
for (const entry of entries) {
|
|
445
|
+
const req = entry.request;
|
|
446
|
+
if (req?.postData?.text == null) continue;
|
|
447
|
+
let pathname;
|
|
448
|
+
try { pathname = new URL(req.url).pathname; } catch { continue; }
|
|
449
|
+
const route = routeFor(pathname);
|
|
450
|
+
if (route == null || route.method !== req.method) continue;
|
|
451
|
+
|
|
452
|
+
let body;
|
|
453
|
+
try { body = JSON.parse(req.postData.text); } catch { continue; }
|
|
454
|
+
inspected++;
|
|
455
|
+
|
|
456
|
+
const unknownTop = Object.keys(body).filter((k) => !route.fields.includes(k));
|
|
457
|
+
for (const k of unknownTop) violations.push(`${req.method} ${pathname} → "${k}"`);
|
|
458
|
+
|
|
459
|
+
const nested = route.itemFields;
|
|
460
|
+
if (nested && Array.isArray(body[nested.field])) {
|
|
461
|
+
for (const item of body[nested.field]) {
|
|
462
|
+
if (item == null || typeof item !== "object") continue;
|
|
463
|
+
for (const k of Object.keys(item)) {
|
|
464
|
+
if (!nested.fields.includes(k)) {
|
|
465
|
+
violations.push(`${req.method} ${pathname} → ${nested.field}[].${k}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
console.log(`\n${c.b}Inspected ${inspected} Redaktyn API request(s) in ${harPath}${c.r}`);
|
|
473
|
+
if (inspected === 0) {
|
|
474
|
+
warn("None of the captured requests hit a Redaktyn ingest endpoint.");
|
|
475
|
+
note("Record the capture while registering a fingerprint or triggering a block.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (violations.length > 0) {
|
|
480
|
+
bad(`${violations.length} field(s) outside the published allowlist:`);
|
|
481
|
+
for (const v of [...new Set(violations)]) console.log(` ${v}`);
|
|
482
|
+
process.exitCode = 1;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
ok("Every field sent is one the API publicly declares it accepts.");
|
|
487
|
+
note("This covers the requests in your capture, to endpoints on the allowlist.");
|
|
488
|
+
note("It says nothing about requests you did not record, or about what the");
|
|
489
|
+
note("server does with the fields it does accept.");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
+
Entry point
|
|
494
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
495
|
+
|
|
496
|
+
function help() {
|
|
497
|
+
console.log(`
|
|
498
|
+
${c.b}redaktyn-verify${c.r} — check Redaktyn's claims for yourself
|
|
499
|
+
|
|
500
|
+
${c.b}COMMANDS${c.r}
|
|
501
|
+
|
|
502
|
+
${c.b}recompute${c.r} --org-id <uuid> [--token <jwt>]
|
|
503
|
+
Derives the HMAC key from your passphrase and recomputes all three
|
|
504
|
+
digests. With a token, compares them against GET /api/secrets.
|
|
505
|
+
Passphrase and secret are read from a hidden prompt, or from
|
|
506
|
+
REDAKTYN_PASSPHRASE / REDAKTYN_SECRET.
|
|
507
|
+
|
|
508
|
+
${c.b}build${c.r} [--pubkey <base64>]
|
|
509
|
+
Fetches the signed release manifest, verifies the Ed25519 signature,
|
|
510
|
+
then hashes the bundle this server actually serves and compares.
|
|
511
|
+
Supply --pubkey with a key obtained independently — verifying against
|
|
512
|
+
the key the same server just handed you proves very little.
|
|
513
|
+
|
|
514
|
+
${c.b}pin${c.r}
|
|
515
|
+
Records the release log head as it stands today, in
|
|
516
|
+
~/.redaktyn-verify-pin.json (override with REDAKTYN_VERIFY_PIN).
|
|
517
|
+
|
|
518
|
+
${c.b}check${c.r}
|
|
519
|
+
Re-fetches the log and confirms it still reproduces your pinned head.
|
|
520
|
+
|
|
521
|
+
${c.b}egress${c.r} --har <file.har>
|
|
522
|
+
Asserts that every field sent to a Redaktyn ingest endpoint in the capture
|
|
523
|
+
is one the API publicly declares. Record with DevTools → Network →
|
|
524
|
+
"Export HAR" while registering a fingerprint.
|
|
525
|
+
|
|
526
|
+
${c.b}COMMON${c.r}
|
|
527
|
+
|
|
528
|
+
--server <url> Redaktyn base URL (or REDAKTYN_SERVER)
|
|
529
|
+
--no-color Disable ANSI colour
|
|
530
|
+
--help
|
|
531
|
+
|
|
532
|
+
${c.b}WHAT THIS CANNOT SHOW${c.r}
|
|
533
|
+
|
|
534
|
+
${c.d}Nothing here establishes what the server does with data after receiving it.
|
|
535
|
+
A one-way digest proves the stored value cannot be reversed; it does not
|
|
536
|
+
prove the plaintext was never written elsewhere. That needs source access,
|
|
537
|
+
an independent audit, or a deployment you host.${c.r}
|
|
538
|
+
`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const command = process.argv[2];
|
|
542
|
+
const commands = {
|
|
543
|
+
recompute: cmdRecompute,
|
|
544
|
+
build: cmdBuild,
|
|
545
|
+
pin: cmdPin,
|
|
546
|
+
check: cmdCheck,
|
|
547
|
+
egress: cmdEgress,
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
if (command == null || command === "--help" || command === "-h" || commands[command] == null) {
|
|
551
|
+
help();
|
|
552
|
+
process.exit(command == null || command.startsWith("-") ? 0 : 1);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
commands[command]().catch((err) => {
|
|
556
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
557
|
+
});
|