tamper-signal 1.2.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/badge/light.js ADDED
@@ -0,0 +1,430 @@
1
+ // The inline status light: a small dark instrument that mounts in a host
2
+ // dashboard's header and attests the receipt chain behind the page. Built on
3
+ // the same verification core as the badge (verifyReceipts in badge.js): Web
4
+ // Crypto Ed25519 signatures, hash-link walk, yellow caveats. Only the
5
+ // rendering layer differs. No build step, no framework.
6
+ //
7
+ // mountTamperSignal(hostEl, chainUrl, pubKeyHex?, opts?)
8
+ // Same argument contract as renderReceiptBadge, with the light appended to
9
+ // hostEl rather than replacing its contents. Returns a handle:
10
+ // { el, ready, refresh(), destroy(), getState(), setOpen(open) }.
11
+ // `ready` resolves with the first verification result; setOpen() opens or
12
+ // closes the popover programmatically (demo pages use it to present the
13
+ // verdict without waiting for a click).
14
+ //
15
+ // opts:
16
+ // watch re-verify every N ms and pulse on state transitions
17
+ // warnDrift flag any control-totals movement across links as a caveat
18
+ // receiptsHref href for the popover's "view receipts" link (default: chainUrl)
19
+ // theme "dark" (default) or "light"; the pill should be the one
20
+ // foreign object on the page, so invert it on dark hosts
21
+ //
22
+ // States: checking (boot), green, yellow, red, and unverifiable (fetch failed
23
+ // or no Web Crypto Ed25519). Unverifiable is a capability fallback, not a
24
+ // verdict: it says nothing about the chain and never wears the yellow color.
25
+ //
26
+ // In the red state the light also reaches into the host page: any element
27
+ // carrying data-receipt-column="<column>" whose column appears in the broken
28
+ // link's totals delta is outlined and tagged "tamper signal: unverified value".
29
+ // Mapping DOM nodes to chain columns is the host author's one manual step.
30
+
31
+ import { verifyReceipts, SHORT, totalsOf, stageNameOf, outputHashOf } from "./badge.js";
32
+
33
+ let uid = 0;
34
+
35
+ const WORDS = {
36
+ checking: "CHECKING",
37
+ green: "VERIFIED",
38
+ yellow: "CAVEAT",
39
+ red: "BROKEN",
40
+ unverifiable: "UNVERIFIED",
41
+ };
42
+
43
+ const VERDICTS = {
44
+ checking: "VERIFYING",
45
+ green: "CHAIN VERIFIED",
46
+ yellow: "VERIFIABLE WITH CAVEATS",
47
+ red: "CHAIN BROKEN",
48
+ unverifiable: "COULD NOT VERIFY",
49
+ };
50
+
51
+ function el(tag, props = {}, children = []) {
52
+ const node = document.createElement(tag);
53
+ Object.assign(node, props);
54
+ for (const child of [].concat(children)) {
55
+ if (child == null) continue;
56
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
57
+ }
58
+ return node;
59
+ }
60
+
61
+ function injectLightStyles() {
62
+ if (document.getElementById("tamper-signal-styles")) return;
63
+ const css = `
64
+ .lr-light{position:relative;display:inline-block;margin-left:14px;
65
+ font-family:ui-monospace,'SF Mono',Menlo,Monaco,'Cascadia Code',monospace;
66
+ --lr-bg:#0b0f14;--lr-panel:#11161d;--lr-border:#1f2937;--lr-chrome:#161d26;
67
+ --lr-text:#e5e7eb;--lr-dim:#8b98a5;--lr-faint:#3d4854;--lr-row:#18202b;
68
+ --lr-green:#34d399;--lr-red:#f87171;--lr-amber:#fbbf24;--lr-cyan:#67e8f9}
69
+ .lr-light[data-theme="light"]{--lr-bg:#ffffff;--lr-panel:#ffffff;--lr-border:#d0d5dd;
70
+ --lr-chrome:#f2f4f7;--lr-text:#1d2939;--lr-dim:#5c6470;--lr-faint:#98a2b3;--lr-row:#eef0f4;
71
+ --lr-green:#067647;--lr-red:#b42318;--lr-amber:#b54708;--lr-cyan:#0e7090}
72
+ .lr-light .lr-pill{display:inline-flex;align-items:center;gap:8px;
73
+ border:1px solid var(--lr-border);background:var(--lr-bg);color:var(--lr-text);
74
+ border-radius:999px;padding:6px 12px 6px 9px;font:11px/1 inherit;font-family:inherit;
75
+ letter-spacing:0.3px;cursor:pointer;white-space:nowrap;
76
+ transition:border-color 0.15s,transform 0.1s}
77
+ .lr-light .lr-pill:hover{border-color:var(--lr-faint)}
78
+ .lr-light .lr-pill:active{transform:translateY(1px)}
79
+ .lr-light .lr-pill:focus-visible{outline:2px solid var(--lr-cyan);outline-offset:2px}
80
+ .lr-light .lr-caret{color:var(--lr-faint);font-size:9px;transition:transform 0.15s}
81
+ .lr-light .lr-pill[aria-expanded="true"] .lr-caret{transform:rotate(90deg)}
82
+ .lr-light .lr-dot{width:10px;height:10px;border-radius:50%;flex:none;background:var(--lr-faint)}
83
+ .lr-light .lr-word{font-weight:700}
84
+ .lr-light .lr-sub{color:var(--lr-dim)}
85
+ /* the seal never wraps; on narrow hosts the sub-label drops out instead */
86
+ @media (max-width: 920px){ .lr-light .lr-sub{display:none} }
87
+ .lr-light[data-state="green"] .lr-dot{background:var(--lr-green);box-shadow:0 0 6px 0 rgba(52,211,153,0.7)}
88
+ .lr-light[data-state="yellow"] .lr-dot{background:var(--lr-amber);box-shadow:0 0 6px 0 rgba(251,191,36,0.7)}
89
+ .lr-light[data-state="red"] .lr-dot{background:var(--lr-red);box-shadow:0 0 6px 0 rgba(248,113,113,0.7)}
90
+ .lr-light[data-state="green"] .lr-word{color:var(--lr-green)}
91
+ .lr-light[data-state="yellow"] .lr-word{color:var(--lr-amber)}
92
+ .lr-light[data-state="red"] .lr-word{color:var(--lr-red)}
93
+ .lr-light[data-state="checking"] .lr-word,.lr-light[data-state="unverifiable"] .lr-word{color:var(--lr-dim)}
94
+ @keyframes lr-pulse{0%{box-shadow:0 0 0 0 var(--lr-pulse)}70%{box-shadow:0 0 0 9px transparent}
95
+ 100%{box-shadow:0 0 0 0 transparent}}
96
+ .lr-light .lr-dot.lr-pulsing{animation:lr-pulse 0.9s ease-out 2}
97
+ .lr-light[data-state="green"] .lr-dot.lr-pulsing{--lr-pulse:rgba(52,211,153,0.55)}
98
+ .lr-light[data-state="yellow"] .lr-dot.lr-pulsing{--lr-pulse:rgba(251,191,36,0.55)}
99
+ .lr-light[data-state="red"] .lr-dot.lr-pulsing{--lr-pulse:rgba(248,113,113,0.55)}
100
+ @media (prefers-reduced-motion: reduce){.lr-light .lr-dot.lr-pulsing{animation:none}}
101
+ .lr-light .lr-pop{position:absolute;top:calc(100% + 10px);right:0;width:372px;max-width:90vw;
102
+ background:var(--lr-panel);border:1px solid var(--lr-border);border-radius:10px;
103
+ color:var(--lr-text);font:12px/1.55 inherit;font-family:inherit;text-align:left;
104
+ box-shadow:0 16px 48px rgba(4,8,14,0.45);z-index:2147483000;display:none}
105
+ .lr-light .lr-pop.lr-open{display:block}
106
+ .lr-light .lr-pop-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;background:var(--lr-chrome);
107
+ border-bottom:1px solid var(--lr-border);border-radius:10px 10px 0 0;padding:9px 14px;font-size:11px}
108
+ .lr-light .lr-wordmark{color:var(--lr-dim);letter-spacing:1px}
109
+ .lr-light .lr-verdict{font-weight:700}
110
+ .lr-light[data-state="green"] .lr-verdict{color:var(--lr-green)}
111
+ .lr-light[data-state="yellow"] .lr-verdict{color:var(--lr-amber)}
112
+ .lr-light[data-state="red"] .lr-verdict{color:var(--lr-red)}
113
+ .lr-light .lr-pop-head time{margin-left:auto;color:var(--lr-faint);font-size:10px}
114
+ .lr-light .lr-pop-body{padding:12px 14px 14px}
115
+ .lr-light .lr-pop-body p{margin:0 0 10px;color:var(--lr-dim);font-family:inherit}
116
+ .lr-light .lr-pop-body p strong{color:var(--lr-text);font-weight:700}
117
+ .lr-light .lr-chain{width:100%;border-collapse:collapse;margin:2px 0 4px}
118
+ .lr-light .lr-chain th{text-align:left;font-weight:400;font-size:10px;color:var(--lr-faint);
119
+ padding:0 8px 5px 0;border-bottom:1px solid var(--lr-border)}
120
+ .lr-light .lr-chain td{padding:5px 8px 5px 0;border-bottom:1px solid var(--lr-row);font-size:11px}
121
+ .lr-light .lr-chain tr:last-child td{border-bottom:0}
122
+ .lr-light .lr-hash{color:var(--lr-cyan)}
123
+ .lr-light .lr-ok{color:var(--lr-green)}
124
+ .lr-light .lr-warn{color:var(--lr-amber)}
125
+ .lr-light .lr-bad{color:var(--lr-red)}
126
+ .lr-light .lr-mut{color:var(--lr-dim)}
127
+ .lr-light .lr-kv{margin:8px 0 2px}
128
+ .lr-light .lr-kv div{display:flex;gap:10px;padding:2px 0}
129
+ .lr-light .lr-kv dt{color:var(--lr-faint);width:76px;flex:none}
130
+ .lr-light .lr-kv dd{margin:0;overflow-wrap:anywhere}
131
+ .lr-light .lr-delta{margin-top:9px;border:1px solid rgba(248,113,113,0.35);
132
+ background:rgba(180,35,24,0.12);border-radius:7px;padding:8px 10px;color:var(--lr-red);font-size:11px}
133
+ .lr-light .lr-gap{margin-top:9px;border:1px dashed rgba(251,191,36,0.45);
134
+ background:rgba(251,191,36,0.07);border-radius:7px;padding:8px 10px;color:var(--lr-amber);font-size:11px}
135
+ .lr-light .lr-pop-foot{display:flex;align-items:center;gap:10px;border-top:1px solid var(--lr-border);
136
+ padding:8px 14px;font-size:10px;color:var(--lr-faint)}
137
+ .lr-light .lr-pop-foot a{margin-left:auto;color:var(--lr-cyan);text-decoration:none}
138
+ .lr-light .lr-pop-foot a:hover{text-decoration:underline}
139
+ .lr-light .lr-pop-foot a:focus-visible{outline:2px solid var(--lr-cyan);outline-offset:2px;border-radius:3px}
140
+ .lr-light .lr-tagline{color:var(--lr-green)}
141
+ .lr-suspect{box-shadow:0 0 0 2px #f87171,0 0 0 5px rgba(248,113,113,0.18) !important}
142
+ .lr-suspect-tag{font:10px/1 ui-monospace,'SF Mono',Menlo,monospace;color:#f87171;margin-top:6px}`;
143
+ document.head.appendChild(el("style", { id: "tamper-signal-styles", textContent: css }));
144
+ }
145
+
146
+ // Host-page columns whose totals moved across the broken link. Used to flag
147
+ // [data-receipt-column] elements; row/column count changes have no single
148
+ // column to point at, so only numeric_sums and null_counts participate.
149
+ function changedColumns(up, down) {
150
+ const cols = new Set();
151
+ for (const key of ["numeric_sums", "null_counts"]) {
152
+ const a = (up || {})[key] || {};
153
+ const b = (down || {})[key] || {};
154
+ for (const c of new Set([...Object.keys(a), ...Object.keys(b)])) {
155
+ if (a[c] !== b[c]) cols.add(c);
156
+ }
157
+ }
158
+ return cols;
159
+ }
160
+
161
+ // Per-stage table for the green/yellow popovers; the sig column is honest
162
+ // because these states only render after every signature has verified.
163
+ function chainTable(receipts) {
164
+ const rows = receipts.map((r) =>
165
+ el("tr", {}, [
166
+ el("td", {}, stageNameOf(r)),
167
+ el("td", {}, [el("span", { className: "lr-hash" }, SHORT(outputHashOf(r)))]),
168
+ el("td", {}, String(totalsOf(r).row_count ?? "")),
169
+ el("td", { className: "lr-ok" }, "✓"),
170
+ ])
171
+ );
172
+ return el("table", { className: "lr-chain" }, [
173
+ el("thead", {}, el("tr", {}, [
174
+ el("th", {}, "stage"),
175
+ el("th", {}, "output hash"),
176
+ el("th", {}, "rows"),
177
+ el("th", {}, "sig"),
178
+ ])),
179
+ el("tbody", {}, rows),
180
+ ]);
181
+ }
182
+
183
+ function shortCaveatTag(caveats) {
184
+ const first = caveats[0] || "";
185
+ if (first.startsWith("coverage gap")) return "coverage gap";
186
+ if (first.startsWith("unrecognized signing key")) return "unknown key";
187
+ if (first.startsWith("totals drift")) return "totals drift";
188
+ return "see details";
189
+ }
190
+
191
+ export function mountTamperSignal(hostEl, chainUrl, pubKeyHex, opts) {
192
+ // Allow mountTamperSignal(el, url, {watch: ...}) without a key argument.
193
+ if (pubKeyHex && typeof pubKeyHex === "object") {
194
+ opts = pubKeyHex;
195
+ pubKeyHex = undefined;
196
+ }
197
+ opts = opts || {};
198
+ injectLightStyles();
199
+
200
+ const id = `lr-light-${++uid}`;
201
+ const dot = el("span", { className: "lr-dot" });
202
+ dot.setAttribute("aria-hidden", "true");
203
+ const word = el("span", { className: "lr-word" });
204
+ const sub = el("span", { className: "lr-sub" });
205
+ const text = el("span", {}, [word, sub]);
206
+ text.setAttribute("aria-live", "polite");
207
+ const caret = el("span", { className: "lr-caret", textContent: "▸" });
208
+ caret.setAttribute("aria-hidden", "true");
209
+ const pill = el("button", { className: "lr-pill", type: "button" }, [dot, text, caret]);
210
+ pill.setAttribute("aria-expanded", "false");
211
+ pill.setAttribute("aria-controls", `${id}-pop`);
212
+ pill.setAttribute("aria-label", "Tamper Signal verification status");
213
+
214
+ const verdict = el("span", { className: "lr-verdict" });
215
+ const time = el("time", {});
216
+ const popHead = el("div", { className: "lr-pop-head" }, [
217
+ el("span", { className: "lr-wordmark" }, "TAMPER SIGNAL"),
218
+ verdict,
219
+ time,
220
+ ]);
221
+ const popBody = el("div", { className: "lr-pop-body" });
222
+ const footNote = el("span", {});
223
+ const footLink = el("a", {
224
+ href: opts.receiptsHref || chainUrl,
225
+ textContent: "view receipts →",
226
+ });
227
+ const popFoot = el("div", { className: "lr-pop-foot" }, [footNote, footLink]);
228
+ const pop = el("div", { className: "lr-pop", id: `${id}-pop` }, [popHead, popBody, popFoot]);
229
+ pop.setAttribute("role", "dialog");
230
+ pop.setAttribute("aria-label", "Tamper Signal verification detail");
231
+
232
+ const root = el("span", { className: "lr-light", id }, [pill, pop]);
233
+ root.dataset.state = "checking";
234
+ if (opts.theme === "light") root.dataset.theme = "light";
235
+ hostEl.appendChild(root);
236
+
237
+ // --- popover open/close ---
238
+ function setOpen(open) {
239
+ pop.classList.toggle("lr-open", open);
240
+ pill.setAttribute("aria-expanded", String(open));
241
+ }
242
+ pill.addEventListener("click", () => setOpen(!pop.classList.contains("lr-open")));
243
+ function onKeydown(e) {
244
+ if (e.key === "Escape" && pop.classList.contains("lr-open")) {
245
+ setOpen(false);
246
+ pill.focus();
247
+ }
248
+ }
249
+ function onClickAway(e) {
250
+ if (pop.classList.contains("lr-open") && !root.contains(e.target)) setOpen(false);
251
+ }
252
+ document.addEventListener("keydown", onKeydown);
253
+ document.addEventListener("click", onClickAway);
254
+
255
+ function pulse() {
256
+ dot.classList.remove("lr-pulsing");
257
+ void dot.offsetWidth; // restart the animation
258
+ dot.classList.add("lr-pulsing");
259
+ }
260
+ dot.addEventListener("animationend", () => dot.classList.remove("lr-pulsing"));
261
+
262
+ // --- host metric flagging (red state) ---
263
+ let flagged = [];
264
+ function clearFlags() {
265
+ for (const { node, tag } of flagged) {
266
+ node.classList.remove("lr-suspect");
267
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag);
268
+ }
269
+ flagged = [];
270
+ }
271
+ function flagSuspects(result) {
272
+ clearFlags();
273
+ const { linkResult, receipts } = result;
274
+ if (!linkResult || linkResult.ok !== false || linkResult.brokenAt == null) return;
275
+ const cols = changedColumns(
276
+ totalsOf(receipts[linkResult.brokenAt - 1]),
277
+ totalsOf(receipts[linkResult.brokenAt])
278
+ );
279
+ if (!cols.size) return;
280
+ for (const node of document.querySelectorAll("[data-receipt-column]")) {
281
+ if (!cols.has(node.getAttribute("data-receipt-column"))) continue;
282
+ node.classList.add("lr-suspect");
283
+ const tag = el("div", { className: "lr-suspect-tag" }, "⚠ tamper signal: unverified value");
284
+ node.appendChild(tag);
285
+ flagged.push({ node, tag });
286
+ }
287
+ }
288
+
289
+ // --- state rendering ---
290
+ let state = "checking";
291
+ function setChrome(name, subText, footSpec) {
292
+ root.dataset.state = name;
293
+ word.textContent = WORDS[name];
294
+ sub.textContent = subText ? ` · ${subText}` : "";
295
+ verdict.textContent = VERDICTS[name];
296
+ footNote.textContent = footSpec.text;
297
+ footNote.className = footSpec.tagline ? "lr-tagline" : "";
298
+ }
299
+
300
+ function render(result) {
301
+ const prev = state;
302
+ state = result.state;
303
+ popBody.textContent = "";
304
+ if (result.verifiedAt) {
305
+ time.dateTime = result.verifiedAt;
306
+ time.textContent = `verified ${result.verifiedAt.slice(11, 19)}Z`;
307
+ }
308
+
309
+ if (state === "unverifiable") {
310
+ setChrome("unverifiable", result.reason, { text: "capability fallback, not a verdict" });
311
+ popBody.appendChild(
312
+ el("p", {}, [
313
+ el("strong", {}, "The light could not check this chain: "),
314
+ `${result.reason}. This says nothing about the data either way.`,
315
+ ])
316
+ );
317
+ clearFlags();
318
+ return;
319
+ }
320
+
321
+ const { receipts, origin, finalRows, transforms, linkResult, caveats } = result;
322
+ const stats =
323
+ `${receipts.length} receipt${receipts.length === 1 ? "" : "s"} · ` +
324
+ `${transforms} transform${transforms === 1 ? "" : "s"}`;
325
+
326
+ if (state === "green") {
327
+ setChrome("green", "chain intact", {
328
+ text: "The light is green, the data is clean.",
329
+ tagline: true,
330
+ });
331
+ popBody.appendChild(
332
+ el("p", {}, [
333
+ el("strong", {}, `${stats} · signatures valid.`),
334
+ ` Every hash link from ${origin} to this page re-verified in your browser.`,
335
+ ])
336
+ );
337
+ popBody.appendChild(chainTable(receipts));
338
+ } else if (state === "yellow") {
339
+ setChrome("yellow", shortCaveatTag(caveats), { text: "a human should look" });
340
+ popBody.appendChild(
341
+ el("p", {}, [
342
+ el("strong", {}, "Chain verifies, with caveats."),
343
+ " Here's what we couldn't check.",
344
+ ])
345
+ );
346
+ popBody.appendChild(chainTable(receipts));
347
+ for (const caveat of caveats) {
348
+ popBody.appendChild(el("div", { className: "lr-gap" }, `⚠ ${caveat}`));
349
+ }
350
+ } else {
351
+ // red
352
+ const broken = linkResult && linkResult.ok === false;
353
+ setChrome("red", broken ? `hash mismatch at ${linkResult.brokenStage}` : result.reason, {
354
+ text: "do not trust totals fed by this stage",
355
+ });
356
+ if (broken) {
357
+ popBody.appendChild(
358
+ el("p", {}, [
359
+ el("strong", {}, `Hash mismatch at link ${linkResult.brokenAt - 1} -> ${linkResult.brokenAt}.`),
360
+ ` The declared input of ${linkResult.brokenStage} is not the verified output of ` +
361
+ `${stageNameOf(receipts[linkResult.brokenAt - 1])}. Data changed between these stages.`,
362
+ ])
363
+ );
364
+ const kv = el("dl", { className: "lr-kv" }, [
365
+ el("div", {}, [el("dt", {}, "broken at"), el("dd", { className: "lr-bad" }, linkResult.brokenStage)]),
366
+ el("div", {}, [
367
+ el("dt", {}, "expected"),
368
+ el("dd", {}, [
369
+ el("span", { className: "lr-hash" }, SHORT(linkResult.expected)),
370
+ el("span", { className: "lr-mut" }, ` (output of ${stageNameOf(receipts[linkResult.brokenAt - 1])}, signed)`),
371
+ ]),
372
+ ]),
373
+ el("div", {}, [
374
+ el("dt", {}, "found"),
375
+ el("dd", {}, [el("span", { className: "lr-bad" }, SHORT(linkResult.found))]),
376
+ ]),
377
+ ]);
378
+ popBody.appendChild(kv);
379
+ if (linkResult.delta && linkResult.delta.length) {
380
+ popBody.appendChild(
381
+ el("div", { className: "lr-delta" }, `totals delta vs upstream: ${linkResult.delta.join(" · ")}`)
382
+ );
383
+ }
384
+ } else {
385
+ popBody.appendChild(
386
+ el("p", {}, [
387
+ el("strong", {}, `${result.reason.charAt(0).toUpperCase()}${result.reason.slice(1)}.`),
388
+ " The chain cannot be trusted as signed.",
389
+ ])
390
+ );
391
+ }
392
+ }
393
+
394
+ flagSuspects(result);
395
+ if (prev !== state) pulse();
396
+ }
397
+
398
+ // --- verification loop ---
399
+ let timer = null;
400
+ let destroyed = false;
401
+ async function refresh() {
402
+ const result = await verifyReceipts(chainUrl, pubKeyHex, { warnDrift: opts.warnDrift });
403
+ if (!destroyed) render(result);
404
+ return result;
405
+ }
406
+
407
+ setChrome("checking", "verifying", { text: "re-verifying in your browser" });
408
+ popBody.appendChild(el("p", {}, "Fetching the receipt chain and re-verifying every signature and hash link."));
409
+ const ready = refresh();
410
+
411
+ if (opts.watch) {
412
+ timer = setInterval(refresh, Math.max(1000, Number(opts.watch) || 0));
413
+ }
414
+
415
+ return {
416
+ el: root,
417
+ ready,
418
+ refresh,
419
+ getState: () => state,
420
+ setOpen,
421
+ destroy() {
422
+ destroyed = true;
423
+ if (timer) clearInterval(timer);
424
+ document.removeEventListener("keydown", onKeydown);
425
+ document.removeEventListener("click", onClickAway);
426
+ clearFlags();
427
+ if (root.parentNode) root.parentNode.removeChild(root);
428
+ },
429
+ };
430
+ }