@cygnuxlabs/tracekit 1.2.3 → 1.4.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 +13 -1
- package/bin/tracekit.mjs +14 -12
- package/dist/v2/client.d.ts +3 -0
- package/dist/v2/client.js +9 -3
- package/dist/v2/launcher.js +1 -1
- package/dist/v2/rpc_schema.d.ts +1 -1
- package/dist/v2/rpc_schema.js +6 -4
- package/dist/verify/cli.js +4 -4
- package/dist/verify/explain.d.ts +46 -0
- package/dist/verify/explain.js +212 -0
- package/dist/verify/index.d.ts +20 -4
- package/dist/verify/index.js +434 -67
- package/dist/verify/jcs.d.ts +2 -0
- package/dist/verify/jcs.js +10 -2
- package/dist/verify/primitives.d.ts +2 -1
- package/dist/verify/primitives.js +3 -2
- package/dist/verify/schema.d.ts +3 -3
- package/dist/verify/schema.js +5 -3
- package/dist/verify/tracekit.event.v2.json +267 -3
- package/dist/verify/web.d.ts +1 -0
- package/dist/verify/web.js +215 -0
- package/dist/verify/zip.d.ts +0 -5
- package/dist/verify/zip.js +10 -5
- package/dist/verify.html +2799 -0
- package/package.json +11 -7
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser verifier: verify.html, which scripts/verify-page.mjs builds from this module, the verifier it imports and
|
|
3
|
+
* page.html. The bundle and trust config a person chooses are read and verified in the page, whose Content Security
|
|
4
|
+
* Policy lets it load and send nothing, so neither file leaves the browser. It reports what the TypeScript CLI does: a
|
|
5
|
+
* bundle that relies on a check this verifier doesn't implement is UNVERIFIABLE, never VERIFIED. Without a trust config
|
|
6
|
+
* it checks what needs no pinned key (verify's `trust` null). explain.ts says what the report means.
|
|
7
|
+
*
|
|
8
|
+
* The verification runs in a worker started from this same script (a blob: URL, which inherits the page's policy), so a
|
|
9
|
+
* large bundle doesn't freeze the page; in the page itself where the browser refuses the worker.
|
|
10
|
+
*/
|
|
11
|
+
import { MEANING, asJson, asText, cliExit, nextSteps, sh, summarize, verdict } from "./explain.js";
|
|
12
|
+
import { VERSION, clean, readZip, verify } from "./index.js";
|
|
13
|
+
/** The report, and a summary of the runs when nothing failed (what a failed bundle says can't be relied on). */
|
|
14
|
+
async function work(job) {
|
|
15
|
+
const { report, code } = await verify(job.bundle, job.trust, job.names);
|
|
16
|
+
const summary = !report.failures.length && report.checks.some((c) => c.check === "signatures")
|
|
17
|
+
? summarize(await readZip(job.bundle), report) : [];
|
|
18
|
+
return { report, code, summary };
|
|
19
|
+
}
|
|
20
|
+
if (typeof document === "undefined") // this script, as the page's worker: a report, or the error that stopped it
|
|
21
|
+
addEventListener("message", async (e) => {
|
|
22
|
+
try {
|
|
23
|
+
postMessage(await work(e.data));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
postMessage({ error: String(err?.message ?? err) });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
else
|
|
30
|
+
page();
|
|
31
|
+
function page() {
|
|
32
|
+
const $ = (id) => document.getElementById(id);
|
|
33
|
+
const el = (tag, text = "", cls = "") => {
|
|
34
|
+
const e = document.createElement(tag);
|
|
35
|
+
if (text)
|
|
36
|
+
e.textContent = text;
|
|
37
|
+
if (cls)
|
|
38
|
+
e.className = cls;
|
|
39
|
+
return e;
|
|
40
|
+
};
|
|
41
|
+
let bundle = null, trust = null, turn = 0, worker = null, workers = true, url = "";
|
|
42
|
+
let last = { text: "", json: "", name: "" };
|
|
43
|
+
$("version").textContent = VERSION;
|
|
44
|
+
/** `job` verified in a fresh worker (a newer job ends the one before it), else in the page. */
|
|
45
|
+
function run(job) {
|
|
46
|
+
worker?.terminate();
|
|
47
|
+
if (workers)
|
|
48
|
+
try {
|
|
49
|
+
// this page's own script, by id: a browser extension may add a script element of its own first
|
|
50
|
+
url ||= URL.createObjectURL(new Blob([$("verifier").textContent], { type: "text/javascript" }));
|
|
51
|
+
const w = worker = new Worker(url); // classic: Chrome refuses module workers to file:// pages
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
w.onmessage = (e) => {
|
|
54
|
+
w.terminate();
|
|
55
|
+
if (e.data.error)
|
|
56
|
+
reject(new Error(e.data.error));
|
|
57
|
+
else
|
|
58
|
+
resolve(e.data);
|
|
59
|
+
};
|
|
60
|
+
w.onerror = (e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
workers = false;
|
|
63
|
+
resolve(work(job));
|
|
64
|
+
};
|
|
65
|
+
w.postMessage(job);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
workers = false;
|
|
70
|
+
}
|
|
71
|
+
// lean: in the page, a bundle of 100k records holds the main thread for seconds; chunk the verifier if browsers
|
|
72
|
+
// without blob: workers matter
|
|
73
|
+
return work(job);
|
|
74
|
+
}
|
|
75
|
+
const read = async (f) => ({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
|
|
76
|
+
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
77
|
+
const sampleTrust = { name: "sample.trust.json", bytes: unb64(SAMPLES["sample.trust.json"]) };
|
|
78
|
+
async function choose(files) {
|
|
79
|
+
if (trust === sampleTrust)
|
|
80
|
+
trust = null; // the samples' signer is not the one behind the person's own bundle
|
|
81
|
+
for (const f of [...files ?? []]) {
|
|
82
|
+
if (/\.json$/i.test(f.name))
|
|
83
|
+
trust = await read(f);
|
|
84
|
+
else
|
|
85
|
+
bundle = await read(f);
|
|
86
|
+
}
|
|
87
|
+
await show();
|
|
88
|
+
}
|
|
89
|
+
function command(text) {
|
|
90
|
+
const row = el("div", "", "cmd"), copy = el("button", "copy");
|
|
91
|
+
copy.setAttribute("type", "button");
|
|
92
|
+
copy.setAttribute("aria-label", `Copy: ${text}`);
|
|
93
|
+
copy.addEventListener("click", () => navigator.clipboard?.writeText(text));
|
|
94
|
+
row.append(el("code", text), copy);
|
|
95
|
+
return row;
|
|
96
|
+
}
|
|
97
|
+
async function show() {
|
|
98
|
+
$("bundle-name").textContent = bundle?.name ?? "none chosen";
|
|
99
|
+
$("trust-name").textContent = trust?.name ?? "none";
|
|
100
|
+
$("clear-trust").hidden = !trust;
|
|
101
|
+
if (!bundle)
|
|
102
|
+
return;
|
|
103
|
+
const mine = ++turn, names = { bundle: bundle.name, trust: trust?.name ?? null };
|
|
104
|
+
$("result").hidden = true; // the last bundle's verdict never stands under this one's name
|
|
105
|
+
document.body.dataset.state = "verifying";
|
|
106
|
+
$("busy").textContent = "Verifying...";
|
|
107
|
+
$("busy").hidden = false;
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await run({ bundle: bundle.bytes, trust: trust?.bytes ?? null,
|
|
111
|
+
names: { bundle: names.bundle, trust: names.trust ?? "TRUST.json" } });
|
|
112
|
+
}
|
|
113
|
+
catch (err) { // never left at "Verifying...": say what stopped it and where else to check
|
|
114
|
+
if (mine !== turn)
|
|
115
|
+
return;
|
|
116
|
+
$("result").hidden = true;
|
|
117
|
+
$("busy").textContent = `Couldn't check this file: ${clean(String(err?.message ?? err))}. The `
|
|
118
|
+
+ `command-line verifier may say more: npx -y @cygnuxlabs/tracekit@latest verify ${sh(names.bundle)}`;
|
|
119
|
+
document.body.dataset.state = "error";
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (mine !== turn)
|
|
123
|
+
return; // a later choice of files: its report wins
|
|
124
|
+
const { report, code, summary } = result;
|
|
125
|
+
const v = verdict(report, code);
|
|
126
|
+
$("banner").className = `banner ${{ VERIFIED: "ok", FAILED: "bad", UNVERIFIABLE: "warn" }[v.word]}`;
|
|
127
|
+
$("verdict-word").textContent = v.word;
|
|
128
|
+
$("verdict-means").textContent = v.means;
|
|
129
|
+
$("verdict-not").textContent = v.notMeans;
|
|
130
|
+
const steps = nextSteps(report, code, names);
|
|
131
|
+
$("next").hidden = !steps.length;
|
|
132
|
+
$("steps").replaceChildren(...steps.map((s) => {
|
|
133
|
+
const li = el("li", s.text);
|
|
134
|
+
if (s.command)
|
|
135
|
+
li.append(command(s.command));
|
|
136
|
+
return li;
|
|
137
|
+
}));
|
|
138
|
+
$("summary-box").hidden = !summary.length;
|
|
139
|
+
$("summary-title").textContent = code === 0 ? "what the bundle holds"
|
|
140
|
+
: "what the bundle says it holds (not verified: see above)";
|
|
141
|
+
$("summary").replaceChildren(...summary.flatMap(([k, val]) => [el("dt", k), el("dd", val)]));
|
|
142
|
+
$("checks").replaceChildren(...report.checks.map((c) => {
|
|
143
|
+
const d = el("details"), s = el("summary"), line = el("span", "", "line");
|
|
144
|
+
const tone = { pass: "ok", warn: "warn", fail: "bad" }[c.status];
|
|
145
|
+
line.append(el("span", `[${c.status.toUpperCase()}]`, `status ${tone}`), ` ${c.check}` + (c.detail ? ` — ${clean(c.detail)}` : ""));
|
|
146
|
+
s.append(line);
|
|
147
|
+
d.append(s);
|
|
148
|
+
for (const p of c.problems)
|
|
149
|
+
d.append(el("div", clean(p), "problem")); // one by one: a spread overflows the stack
|
|
150
|
+
d.append(el("p", `What this means: ${MEANING[c.check] ?? "see docs/verdicts.md."}`, "means"));
|
|
151
|
+
d.open = c.status !== "pass" && c.problems.length > 0;
|
|
152
|
+
return d;
|
|
153
|
+
}));
|
|
154
|
+
$("integrity").textContent = `Integrity: ${clean(report.integrity)}.`;
|
|
155
|
+
$("assurance").textContent = `Assurance: ${clean(report.assurance)}.`;
|
|
156
|
+
last = { text: asText(report, code), json: JSON.stringify(asJson(report, code), null, 2) + "\n", name: bundle.name };
|
|
157
|
+
$("exit").textContent = last.text.trim().split("\n").at(-1) + cliExit(report);
|
|
158
|
+
$("report").textContent = last.text;
|
|
159
|
+
$("copied").textContent = "";
|
|
160
|
+
$("result").hidden = false;
|
|
161
|
+
$("busy").hidden = true;
|
|
162
|
+
$("banner").scrollIntoView?.({ block: "nearest" });
|
|
163
|
+
document.body.dataset.state = "done";
|
|
164
|
+
}
|
|
165
|
+
$("bundle").addEventListener("change", () => choose($("bundle").files));
|
|
166
|
+
$("trust").addEventListener("change", async () => {
|
|
167
|
+
const f = $("trust").files?.[0];
|
|
168
|
+
if (f)
|
|
169
|
+
trust = await read(f);
|
|
170
|
+
await show();
|
|
171
|
+
});
|
|
172
|
+
$("clear-trust").addEventListener("click", () => {
|
|
173
|
+
trust = null;
|
|
174
|
+
$("trust").value = "";
|
|
175
|
+
return show();
|
|
176
|
+
});
|
|
177
|
+
for (const [id, file] of [["sample-clean", "sample.tkb"], ["sample-tampered", "sample-tampered.tkb"]])
|
|
178
|
+
$(id).addEventListener("click", () => {
|
|
179
|
+
bundle = { name: file, bytes: unb64(SAMPLES[file]) };
|
|
180
|
+
trust = sampleTrust;
|
|
181
|
+
return show();
|
|
182
|
+
});
|
|
183
|
+
$("copy").addEventListener("click", async () => {
|
|
184
|
+
try {
|
|
185
|
+
await navigator.clipboard.writeText(last.text);
|
|
186
|
+
$("copied").textContent = "Copied.";
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
$("report").parentElement.open = true;
|
|
190
|
+
getSelection()?.selectAllChildren($("report"));
|
|
191
|
+
$("copied").textContent = "Selected: press Ctrl+C (Cmd+C on a Mac) to copy.";
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
$("download").addEventListener("click", () => {
|
|
195
|
+
const a = el("a");
|
|
196
|
+
a.href = URL.createObjectURL(new Blob([last.json], { type: "application/json" }));
|
|
197
|
+
a.download = `${last.name.replace(/\.[^.]*$/, "")}.verify.json`;
|
|
198
|
+
a.click();
|
|
199
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
200
|
+
});
|
|
201
|
+
const drop = $("drop");
|
|
202
|
+
for (const t of ["dragenter", "dragover"])
|
|
203
|
+
drop.addEventListener(t, (e) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
drop.classList.add("over");
|
|
206
|
+
});
|
|
207
|
+
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
|
|
208
|
+
drop.addEventListener("drop", (e) => {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
drop.classList.remove("over");
|
|
211
|
+
return choose(e.dataTransfer?.files);
|
|
212
|
+
});
|
|
213
|
+
for (const t of ["dragover", "drop"]) // a file dropped beside the zone isn't opened in place of the page
|
|
214
|
+
document.addEventListener(t, (e) => e.preventDefault());
|
|
215
|
+
}
|
package/dist/verify/zip.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The zip reader of docs/format-v2.md §9: one canonical layout, stored and deflated entries (Web Crypto's sibling
|
|
3
|
-
* DecompressionStream), no zip64, under the bundle limits. Names that could escape a directory, symlinks and duplicate
|
|
4
|
-
* entries are refused.
|
|
5
|
-
*/
|
|
6
1
|
export declare const MAX_ENTRIES = 10000;
|
|
7
2
|
export declare const MAX_ENTRY: number;
|
|
8
3
|
export declare const MAX_TOTAL: number;
|
package/dist/verify/zip.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* DecompressionStream), no zip64, under the bundle limits. Names that could escape a directory, symlinks and duplicate
|
|
4
4
|
* entries are refused.
|
|
5
5
|
*/
|
|
6
|
+
import { repr } from "./jcs.js";
|
|
6
7
|
export const MAX_ENTRIES = 10_000;
|
|
7
8
|
export const MAX_ENTRY = 64 << 20;
|
|
8
9
|
export const MAX_TOTAL = 512 << 20;
|
|
9
10
|
const NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*(\/[A-Za-z0-9._-]+)*$/;
|
|
10
11
|
export class Unusable extends Error {
|
|
11
12
|
}
|
|
13
|
+
/** An entry whose bytes don't match its CRC-32: Python's zipfile names it so, and the reports print the name. */
|
|
14
|
+
class BadZipFile extends Unusable {
|
|
15
|
+
}
|
|
12
16
|
let CRC;
|
|
13
17
|
function crc32(data) {
|
|
14
18
|
if (!CRC) {
|
|
@@ -83,7 +87,7 @@ export async function readZip(data) {
|
|
|
83
87
|
throw new Unusable("not the canonical zip layout: entry count");
|
|
84
88
|
let pos = 0;
|
|
85
89
|
for (const e of entries) {
|
|
86
|
-
const n =
|
|
90
|
+
const n = repr(e.name);
|
|
87
91
|
if (e.csize === 0xffffffff || e.usize === 0xffffffff || e.local === 0xffffffff)
|
|
88
92
|
throw new Unusable("zip64 is not supported");
|
|
89
93
|
if (e.method !== 0 && e.method !== 8 || e.method === 0 && e.csize !== e.usize)
|
|
@@ -101,7 +105,7 @@ export async function readZip(data) {
|
|
|
101
105
|
|| v.getUint32(e.local + 18, true) !== e.csize || v.getUint32(e.local + 22, true) !== e.usize
|
|
102
106
|
|| v.getUint16(e.local + 26, true) !== e.raw.length || v.getUint16(e.local + 28, true)
|
|
103
107
|
|| local.some((b, i) => b !== e.raw[i]))
|
|
104
|
-
throw new Unusable(`bad local header for ${n}
|
|
108
|
+
throw new Unusable(`bad local header for ${n}`);
|
|
105
109
|
pos = e.local + 30 + e.raw.length + e.csize;
|
|
106
110
|
}
|
|
107
111
|
if (pos !== cdOffset)
|
|
@@ -111,7 +115,7 @@ export async function readZip(data) {
|
|
|
111
115
|
for (const e of entries) {
|
|
112
116
|
const n = e.name;
|
|
113
117
|
if (!NAME.test(n) || n.split("/").some((p) => /^\.+$/.test(p)))
|
|
114
|
-
throw new Unusable(`bad entry name ${
|
|
118
|
+
throw new Unusable(`bad entry name ${repr(n.slice(0, 80))}`);
|
|
115
119
|
if (files.has(n))
|
|
116
120
|
throw new Unusable(`duplicate entry ${n}`);
|
|
117
121
|
if (((e.attr >>> 16) & 0o170000) === 0o120000)
|
|
@@ -129,15 +133,16 @@ export async function readZip(data) {
|
|
|
129
133
|
out = await inflate(body, limit);
|
|
130
134
|
}
|
|
131
135
|
catch (x) {
|
|
136
|
+
// lean: the decompressor's own message, which differs from zlib's in the reference verifier's report
|
|
132
137
|
throw new Unusable(`${n}: ${x.message}`);
|
|
133
138
|
}
|
|
134
139
|
}
|
|
135
140
|
if (out.length > cap)
|
|
136
141
|
throw new Unusable(`${n} is larger than the bundle limits`);
|
|
142
|
+
if (crc32(out) !== e.crc)
|
|
143
|
+
throw new BadZipFile(`Bad CRC-32 for file ${repr(n)}`); // first, as zipfile does
|
|
137
144
|
if (out.length !== e.usize)
|
|
138
145
|
throw new Unusable(`${n}: ${out.length} bytes, not the ${e.usize} its entry says`);
|
|
139
|
-
if (crc32(out) !== e.crc)
|
|
140
|
-
throw new Unusable(`${n}: bad CRC-32`);
|
|
141
146
|
files.set(n, out);
|
|
142
147
|
total += out.length;
|
|
143
148
|
}
|