tamper-signal 1.4.0 → 1.5.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/AGENTS.md CHANGED
@@ -73,6 +73,11 @@ same package: `tamper-signal/light`, `tamper-signal/badge`,
73
73
  `tamper-signal/element`, `tamper-signal/react`. JS reads .csv/.tsv/.json/
74
74
  .ndjson; only the Python side reads .xlsx.
75
75
 
76
+ CI signing works here too: `TAMPER_SIGNAL_KEY` (PEM contents of the private
77
+ key) wins over any key path, same semantics as the Python side (step 5).
78
+ `tamper-signal verify --json` emits the same structured verdict as the
79
+ Python CLI.
80
+
76
81
  ## 2. Scaffold the project (once)
77
82
 
78
83
  ```bash
@@ -129,8 +134,20 @@ optional and checks the file the dashboard actually reads against the final
129
134
  receipt. `--warn-drift` additionally flags any control-totals movement across
130
135
  links (only for pipelines expected to preserve totals).
131
136
 
132
- Add `--json` to get a structured verdict instead of the text report. Parse
133
- this rather than scraping text:
137
+ Key rotation: `--pub` repeats. Old chains stay green while new receipts sign
138
+ under a new key: `receipts verify chain.json --pub new.pub --pub old.pub`. A
139
+ signature valid under any trusted key is trusted; the browser surfaces accept
140
+ a list the same way (the `<tamper-signal>` element takes a space-separated
141
+ `pub-key` list).
142
+
143
+ CI signing: set `TAMPER_SIGNAL_KEY` to the PEM contents of the private key
144
+ (a repo secret) and the wrapper, ingest, and export sign without a key file
145
+ on disk. The env var wins over any `--key` path while set. The Node CLI
146
+ (`tamper-signal ingest`) honors the same env var with the same precedence.
147
+
148
+ Add `--json` to get a structured verdict instead of the text report (both
149
+ CLIs: `receipts verify --json` and `tamper-signal verify --json` emit the
150
+ same payload). Parse this rather than scraping text:
134
151
 
135
152
  ```json
136
153
  {
@@ -150,11 +167,21 @@ this rather than scraping text:
150
167
  "totals_delta": ["row_count 4987 -> 304 (-4683)"]
151
168
  },
152
169
  "data_mismatch": null,
153
- "report": ["human-legible lines"]
170
+ "receipt_mismatch": null,
171
+ "report": ["human-legible lines"],
172
+ "anchor": ["anchor report lines; present only when --anchor is passed"]
154
173
  }
155
174
  ```
156
175
 
157
- `broken_link` and `data_mismatch` are null unless the verdict is red.
176
+ `broken_link`, `data_mismatch`, and `receipt_mismatch` are null unless the
177
+ verdict is red, and all three stay null when red comes from an anchor
178
+ mismatch (the chain itself is intact; the reason is in `anchor` and
179
+ `report`). `receipt_mismatch` lists receipt files that no longer match the
180
+ sha256 chain.json records for them: a receipt was rewritten after the chain
181
+ was. With `--anchor`, the `anchor` array is added and the anchor outcome is
182
+ folded into `verdict`, `exit_code`, `caveats`, and `report` (a missing
183
+ anchor turns a green run yellow; a mismatch turns it red), so the payload
184
+ never contradicts itself.
158
185
 
159
186
  ### CI: verify the chain on every push
160
187
 
@@ -185,6 +212,52 @@ jobs:
185
212
  Exit 1 (red) fails the build; exit 2 (yellow) surfaces a warning annotation
186
213
  without failing.
187
214
 
215
+ ## 5b. Anchoring (optional, for disputes and audits)
216
+
217
+ The local keypair is the day-to-day root of trust; its known gap is that
218
+ whoever holds the key can re-sign a fresh chain. When existence-at-a-time
219
+ matters (client disputes, audits), anchor the chain in the public Sigstore
220
+ transparency log:
221
+
222
+ ```bash
223
+ pip install "tamper-signal[anchor]"
224
+ receipts anchor # browser login locally; automatic in GitHub Actions
225
+ receipts verify receipts/chain.json --anchor
226
+ ```
227
+
228
+ Agent note: run `receipts anchor` in CI (GitHub Actions and similar), where
229
+ an ambient OIDC credential makes it non-interactive. Outside CI it opens a
230
+ browser login and blocks until a human completes it; do not invoke it from
231
+ an unattended session.
232
+
233
+ The anchor covers receipt contents, not just names: chain.json records the
234
+ sha256 of every receipt file, and `verify` enforces those hashes, so a
235
+ receipt re-signed after anchoring is red even though chain.json itself did
236
+ not change. Chains written before 1.5.0 carry no receipt hashes; anchoring
237
+ them yields a yellow "anchor covers chain.json only" caveat until the
238
+ pipeline re-runs.
239
+
240
+ `anchor.json` (next to chain.json) records the Sigstore bundle plus the
241
+ identity and issuer used; `verify --anchor` enforces that identity, reports
242
+ the logged time on success, exits 2 when no anchor exists, and exits 1 when
243
+ the chain changed after anchoring. `receipts anchor --json` emits the anchor
244
+ record (identity, issuer, integrated time) as JSON for CI logs. An anchor
245
+ made with `--staging` is rejected at verify time unless you pass
246
+ `--anchor-staging`, so the anchor file cannot pick a weaker trust root. To
247
+ pin whose anchor is acceptable instead of trusting the recorded one, pass
248
+ `--anchor-identity` (and optionally `--anchor-issuer`); in CI that looks
249
+ like:
250
+
251
+ ```bash
252
+ receipts verify receipts/chain.json --anchor \
253
+ --anchor-identity "https://github.com/OWNER/REPO/.github/workflows/anchor.yml@refs/heads/main" \
254
+ --anchor-issuer "https://token.actions.githubusercontent.com"
255
+ ```
256
+
257
+ Re-anchor after every pipeline run that changes the chain. Honest scope: an
258
+ anchor proves this exact chain existed at the logged time under the recorded
259
+ identity, nothing more.
260
+
188
261
  ## 6. Add the signal to the host UI
189
262
 
190
263
  With a bundler, import straight from the npm package
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # The light is green, the data is clean.
4
4
 
5
- [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/1.2.0)](https://socket.dev/npm/package/tamper-signal/overview/1.2.0) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.2.0)](https://socket.dev/pypi/package/tamper-signal/overview/1.2.0) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+ [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/1.5.0)](https://socket.dev/npm/package/tamper-signal/overview/1.4.0) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.5.0)](https://socket.dev/pypi/package/tamper-signal/overview/1.4.0) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
6
 
7
7
  Your social team exports a month of TikTok performance data. Someone vibe-codes a dashboard on top of it with an AI assistant in an afternoon. It looks great. Then a transform silently drops 22 rows, or the model hallucinates an aggregation, and the numbers in front of your boss are wrong. Nothing in that workflow catches it. This is the missing verification layer: every stage of the pipeline signs a receipt for what went in and what came out, and one command (or a badge on the dashboard itself) tells you whether the chain is intact, or exactly where it broke and by how much.
8
8
 
@@ -52,7 +52,7 @@ receipts doctor # integration self-check with actionable fixes
52
52
  receipts serve # serve receipts/ on localhost with CORS (dev only)
53
53
  ```
54
54
 
55
- `ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals. `--json` emits a structured verdict (schema in `AGENTS.md`) for CI and coding agents.
55
+ `--pub` repeats for key rotation (any trusted key verifies), and `TAMPER_SIGNAL_KEY` can carry the PEM private key in CI so no key file touches disk. `ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals. `--json` emits a structured verdict (schema in `AGENTS.md`) for CI and coding agents.
56
56
 
57
57
  Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
58
58
 
@@ -160,16 +160,19 @@ The light answers "is it fine?"; the console answers "where, exactly, and by how
160
160
 
161
161
  *The verification console: calm when green, surgical when red.*
162
162
 
163
+ ## Anchoring (optional)
164
+
165
+ `pip install "tamper-signal[anchor]"`, then `receipts anchor` signs the exact bytes of chain.json into the public Sigstore transparency log under your OIDC identity (browser login locally, automatic in GitHub Actions). Because chain.json records the sha256 of every receipt file, the anchor covers the receipts themselves, not just their names. `receipts verify --anchor` then proves this exact chain, receipts included, existed at the logged time, independent of the signing key, closing the "whoever holds the key can quietly re-sign everything" gap for the moments that matter. A missing anchor is a yellow caveat; a chain that changed after anchoring is red.
166
+
163
167
  ## What this proves, and what it doesn't
164
168
 
165
169
  This proves **continuity, not correctness**. It can't tell you the data is right, but it can prove nobody changed it. The chain shows the dashboard numbers descend from the ingested export through a known sequence of code, and it locates the exact stage where a number changed unexpectedly. If the source export is itself wrong, the chain faithfully verifies wrong numbers. It is not a data-quality tool.
166
170
 
167
- Also worth knowing: the signing key lives on your machine, and today that local Ed25519 keypair is the whole root of trust. Anyone holding the key can sign a fresh, internally consistent chain. External anchoring (below) is what closes that gap.
171
+ Also worth knowing: the signing key lives on your machine, and day to day that local Ed25519 keypair is the root of trust. Anyone holding the key can sign a fresh, internally consistent chain; anchoring (above) is what closes that gap when it matters.
168
172
 
169
173
  ## Roadmap
170
174
 
171
175
  - **Richer yellow taxonomy.** Yellow currently detects coverage gaps, unrecognized signing keys, and opt-in totals drift. Distinct severities and smarter drift heuristics are open questions (see `designs/01-NOTES.md`).
172
- - **External anchoring.** Sigstore transparency logs or RFC 3161 timestamps, so a chain can't be silently re-signed after the fact. The attachment points are already marked `FUTURE:` in `tamper_signal/keys.py` and `tamper_signal/receipts.py`.
173
176
 
174
177
 
175
178
  ## Relation to OpenLineage, dbt, and Great Expectations
package/badge/badge.js CHANGED
@@ -134,18 +134,34 @@ export async function verifySignature(receipt, pubKeyHex) {
134
134
  // could make the viewer's browser fetch arbitrary / cross-origin resources.
135
135
  const SAFE_RECEIPT_NAME = /^[A-Za-z0-9._-]+$/;
136
136
 
137
+ async function sha256Hex(buf) {
138
+ const digest = await crypto.subtle.digest("SHA-256", buf);
139
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
140
+ }
141
+
137
142
  export async function loadChain(chainUrl) {
138
143
  const base = new URL(chainUrl, window.location.href);
139
144
  const chain = await (await fetch(base)).json();
145
+ // Newer chains record each receipt file's sha256; enforcing it here mirrors
146
+ // the CLI verifiers, so an anchored chain.json transitively witnesses the
147
+ // receipt contents. Older chains (no receipt_hashes) skip the check.
148
+ const recorded =
149
+ chain.receipt_hashes && typeof chain.receipt_hashes === "object" ? chain.receipt_hashes : null;
150
+ const canHash = Boolean(recorded && globalThis.crypto && crypto.subtle);
140
151
  const receipts = [];
152
+ const receiptMismatches = [];
141
153
  for (const name of chain.receipts || []) {
142
154
  if (typeof name !== "string" || !SAFE_RECEIPT_NAME.test(name)) {
143
155
  throw new Error("unsafe receipt name in chain: " + name);
144
156
  }
145
157
  const url = new URL(name, base);
146
- receipts.push(await (await fetch(url)).json());
158
+ // Fetch raw bytes so the receipt hashes exactly as it sits on disk, then
159
+ // parse the same bytes.
160
+ const buf = await (await fetch(url)).arrayBuffer();
161
+ if (canHash && (await sha256Hex(buf)) !== recorded[name]) receiptMismatches.push(name);
162
+ receipts.push(JSON.parse(new TextDecoder().decode(buf)));
147
163
  }
148
- return { chain, receipts };
164
+ return { chain, receipts, receiptMismatches };
149
165
  }
150
166
 
151
167
  // Gaps in the generated NNN_ receipt numbering, mirroring tamper_signal/receipts.py's
@@ -176,15 +192,19 @@ export function coverageGaps(receiptNames) {
176
192
  // embedded in chain.json means the chain is internally consistent but vouched
177
193
  // for by a key the caller does not trust (yellow), not broken (red).
178
194
  export async function checkSignatures(receipts, trustedKeyHex, chainKeyHex) {
195
+ // trustedKeyHex may be a single key or a list (key rotation). This inlines
196
+ // _as_trusted_keys (tamper_signal/receipts.py) / asTrustedKeys
197
+ // (node/receipts.js); update all three in lockstep.
198
+ const trusted = (Array.isArray(trustedKeyHex) ? trustedKeyHex : [trustedKeyHex]).filter(Boolean);
179
199
  let valid = true;
180
200
  let unrecognized = false;
181
- const useFallback = chainKeyHex && chainKeyHex !== trustedKeyHex;
201
+ const useFallback = chainKeyHex && !trusted.includes(chainKeyHex);
182
202
  for (const r of receipts) {
183
203
  let ok = false;
184
- try {
185
- ok = await verifySignature(r, trustedKeyHex);
186
- } catch (_e) {
187
- ok = false;
204
+ for (const key of trusted) {
205
+ try {
206
+ if (await verifySignature(r, key)) { ok = true; break; }
207
+ } catch (_e) { /* malformed key or signature: try the next */ }
188
208
  }
189
209
  if (ok) continue;
190
210
  if (useFallback) {
@@ -239,9 +259,9 @@ export function evaluate(receipts) {
239
259
  // legitimately move totals).
240
260
  export async function verifyReceipts(chainUrl, pubKeyHex, opts = {}) {
241
261
  const verifiedAt = new Date().toISOString();
242
- let chain, receipts;
262
+ let chain, receipts, receiptMismatches;
243
263
  try {
244
- ({ chain, receipts } = await loadChain(chainUrl));
264
+ ({ chain, receipts, receiptMismatches } = await loadChain(chainUrl));
245
265
  } catch (_e) {
246
266
  return { state: "unverifiable", reason: "could not load chain", caveats: [], verifiedAt };
247
267
  }
@@ -275,6 +295,14 @@ export async function verifyReceipts(chainUrl, pubKeyHex, opts = {}) {
275
295
  summary.finalRows = totalsOf(receipts[receipts.length - 1]).row_count;
276
296
  summary.transforms = receipts.filter((r) => r.kind === "transform_receipt").length;
277
297
 
298
+ if (receiptMismatches && receiptMismatches.length) {
299
+ return {
300
+ ...summary,
301
+ state: "red",
302
+ reason: `receipt file mismatch at ${receiptMismatches.join(", ")}`,
303
+ };
304
+ }
305
+
278
306
  const trustedKey = pubKeyHex || chain.public_key;
279
307
  const sigResult = await checkSignatures(receipts, trustedKey, chain.public_key);
280
308
  summary.signaturesValid = sigResult.valid;
package/badge/element.js CHANGED
@@ -7,7 +7,8 @@
7
7
  //
8
8
  // Attributes (all but `chain` optional):
9
9
  // chain URL of chain.json (required; nothing mounts without it)
10
- // pub-key trusted public key hex; defaults to the chain's embedded key
10
+ // pub-key trusted public key hex (space or comma separated list for
11
+ // key rotation); defaults to the chain's embedded key
11
12
  // watch re-verify every N milliseconds
12
13
  // warn-drift present = flag any control-totals movement across links
13
14
  // receipts-href href for the popover's "view receipts" link
@@ -61,7 +62,9 @@ class TamperSignalElement extends HTMLElement {
61
62
  const chain = this.getAttribute("chain");
62
63
  if (!chain) return; // nothing to verify yet; attribute may arrive later
63
64
  const watch = Number(this.getAttribute("watch"));
64
- this._handle = mountTamperSignal(this, chain, this.getAttribute("pub-key") || undefined, {
65
+ const pubAttr = (this.getAttribute("pub-key") || "").trim();
66
+ const pubKeys = pubAttr ? pubAttr.split(/[\s,]+/) : undefined;
67
+ this._handle = mountTamperSignal(this, chain, pubKeys, {
65
68
  watch: Number.isFinite(watch) && watch > 0 ? watch : undefined,
66
69
  warnDrift: this.hasAttribute("warn-drift"),
67
70
  receiptsHref: this.getAttribute("receipts-href") || undefined,
package/badge/light.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // rendering layer differs. No build step, no framework.
6
6
  //
7
7
  // mountTamperSignal(hostEl, chainUrl, pubKeyHex?, opts?)
8
+ // pubKeyHex: a trusted key hex, or an array of them (key rotation).
8
9
  // Same argument contract as renderReceiptBadge, with the light appended to
9
10
  // hostEl rather than replacing its contents. Returns a handle:
10
11
  // { el, ready, refresh(), destroy(), getState(), setOpen(open) }.
@@ -103,6 +104,13 @@ function injectLightStyles() {
103
104
  color:var(--lr-text);font:12px/1.55 inherit;font-family:inherit;text-align:left;
104
105
  box-shadow:0 16px 48px rgba(4,8,14,0.45);z-index:2147483000;display:none}
105
106
  .lr-light .lr-pop.lr-open{display:block}
107
+ /* On narrow screens the pill can sit anywhere in a wrapped header, so an
108
+ anchored popover clips at the viewport edge; pin it as a bottom sheet. */
109
+ @media (max-width: 600px){
110
+ .lr-light .lr-pop{position:fixed;left:10px;right:10px;top:auto;bottom:14px;
111
+ width:auto;max-width:none}
112
+ .lr-light .lr-pop::before{display:none}
113
+ }
106
114
  .lr-light .lr-pop-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;background:var(--lr-chrome);
107
115
  border-bottom:1px solid var(--lr-border);border-radius:10px 10px 0 0;padding:9px 14px;font-size:11px}
108
116
  .lr-light .lr-wordmark{color:var(--lr-dim);letter-spacing:1px}
@@ -190,7 +198,8 @@ function shortCaveatTag(caveats) {
190
198
 
191
199
  export function mountTamperSignal(hostEl, chainUrl, pubKeyHex, opts) {
192
200
  // Allow mountTamperSignal(el, url, {watch: ...}) without a key argument.
193
- if (pubKeyHex && typeof pubKeyHex === "object") {
201
+ // Arrays are trusted keysets (rotation), not options.
202
+ if (pubKeyHex && typeof pubKeyHex === "object" && !Array.isArray(pubKeyHex)) {
194
203
  opts = pubKeyHex;
195
204
  pubKeyHex = undefined;
196
205
  }
package/node/cli.js CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  buildSourceManifest,
22
22
  readChain,
23
23
  readReceipt,
24
+ receiptFileHashes,
25
+ stageNameOf,
26
+ totalsOf,
24
27
  verifyChain,
25
28
  writeChain,
26
29
  writeReceipt,
@@ -34,7 +37,7 @@ commands:
34
37
  ingest <file> --origin "..." [--key keys/signing.key] [--out receipts/]
35
38
  create a signed source manifest
36
39
  (.csv, .tsv, .json, .ndjson)
37
- verify <chain.json> [--pub key.pub] [--data <file>] [--warn-drift]
40
+ verify <chain.json> [--pub key.pub ...] [--data <file>] [--warn-drift] [--json]
38
41
  verify a chain (exit 0 green, 1 red, 2 yellow)
39
42
  `;
40
43
 
@@ -63,6 +66,10 @@ function cmdIngest(args) {
63
66
  }
64
67
  const raw = readFileSync(file);
65
68
  const records = loadRecords(file);
69
+ if (process.env.TAMPER_SIGNAL_KEY) {
70
+ // The env var silently outranks --key; say so where it matters.
71
+ console.error("Signing with TAMPER_SIGNAL_KEY from the environment (overrides --key)");
72
+ }
66
73
  const privateKey = loadPrivateKey(values.key);
67
74
  const manifest = buildSourceManifest({
68
75
  filename: basename(file),
@@ -89,9 +96,10 @@ function cmdVerify(args) {
89
96
  args,
90
97
  allowPositionals: true,
91
98
  options: {
92
- pub: { type: "string" },
99
+ pub: { type: "string", multiple: true },
93
100
  data: { type: "string" },
94
101
  "warn-drift": { type: "boolean", default: false },
102
+ json: { type: "boolean", default: false },
95
103
  },
96
104
  });
97
105
  const chainPath = positionals[0];
@@ -110,8 +118,17 @@ function cmdVerify(args) {
110
118
  }
111
119
 
112
120
  const chainKey = chain.public_key;
113
- const publicHex = values.pub ? loadPublicKeyHex(values.pub) : chainKey;
114
- if (!publicHex) {
121
+ const publicHex = values.pub?.length ? values.pub.map(loadPublicKeyHex) : chainKey;
122
+ if (Array.isArray(publicHex)) {
123
+ // An empty key file must not silently shrink the trusted set: the
124
+ // filtered-out key would fall back to the chain-embedded key instead.
125
+ const empty = values.pub.filter((path, i) => !publicHex[i]);
126
+ if (empty.length) {
127
+ console.error(`Empty public key file passed to --pub: ${empty.join(", ")}`);
128
+ return 1;
129
+ }
130
+ }
131
+ if (!publicHex || (Array.isArray(publicHex) && !publicHex.length)) {
115
132
  console.error("No public key: pass --pub or embed one in chain.json");
116
133
  return 1;
117
134
  }
@@ -124,13 +141,40 @@ function cmdVerify(args) {
124
141
  dataTotals = controlTotals(records);
125
142
  }
126
143
 
144
+ // Chains that record receipt hashes get them enforced; older chains skip.
145
+ const recordedHashes =
146
+ chain.receipt_hashes && typeof chain.receipt_hashes === "object" ? chain.receipt_hashes : null;
147
+ const actualHashes = recordedHashes ? receiptFileHashes(chainDir, chain.receipts ?? []) : null;
148
+
127
149
  const result = verifyChain(receipts, publicHex, dataHash, dataTotals, {
128
150
  chainPublicHex: chainKey,
129
151
  receiptNames: chain.receipts ?? [],
130
152
  warnDrift: values["warn-drift"],
153
+ recordedHashes,
154
+ actualHashes,
131
155
  });
132
- for (const line of result.lines) console.log(line);
133
- return { green: 0, red: 1, yellow: 2 }[result.verdict];
156
+ const code = { green: 0, red: 1, yellow: 2 }[result.verdict];
157
+ if (values.json) {
158
+ // Same payload shape as the Python CLI's verify --json (AGENTS.md step 5).
159
+ const payload = {
160
+ verdict: result.verdict,
161
+ exit_code: code,
162
+ spec_version: chain.spec_version ?? null,
163
+ receipts: receipts.length,
164
+ transforms: receipts.filter((r) => r.kind === "transform_receipt").length,
165
+ stages: receipts.map(stageNameOf),
166
+ final_row_count: receipts.length ? (totalsOf(receipts[receipts.length - 1]).row_count ?? null) : null,
167
+ caveats: result.caveats,
168
+ broken_link: result.brokenLinkDetail,
169
+ data_mismatch: result.dataMismatch,
170
+ receipt_mismatch: result.receiptMismatch,
171
+ report: result.lines,
172
+ };
173
+ console.log(JSON.stringify(payload, null, 2));
174
+ } else {
175
+ for (const line of result.lines) console.log(line);
176
+ }
177
+ return code;
134
178
  }
135
179
 
136
180
  const [, , command, ...rest] = process.argv;
package/node/keys.js CHANGED
@@ -46,8 +46,11 @@ export function generateKeys(outDir) {
46
46
  return { privatePath, publicPath };
47
47
  }
48
48
 
49
+ // When TAMPER_SIGNAL_KEY is set, its contents (the PEM text) are used instead
50
+ // of the file, so CI pipelines can sign without a key file on disk.
49
51
  export function loadPrivateKey(path) {
50
- return createPrivateKey(readFileSync(path));
52
+ const env = process.env.TAMPER_SIGNAL_KEY;
53
+ return createPrivateKey(env || readFileSync(path));
51
54
  }
52
55
 
53
56
  export function loadPublicKeyHex(path) {
package/node/receipts.js CHANGED
@@ -156,9 +156,27 @@ export function readChain(chainPath) {
156
156
  return JSON.parse(readFileSync(chainPath, "utf-8"));
157
157
  }
158
158
 
159
+ export function receiptFileHashes(chainDir, receiptFiles) {
160
+ // sha256 of each receipt file's raw bytes, keyed by filename.
161
+ const out = {};
162
+ for (const name of receiptFiles) {
163
+ out[name] = createHash("sha256").update(readFileSync(join(chainDir, name))).digest("hex");
164
+ }
165
+ return out;
166
+ }
167
+
159
168
  export function writeChain(chainDir, receiptFiles, publicHex) {
169
+ // chain.json records the sha256 of each receipt file so it commits to the
170
+ // receipt contents, not just their names: anchoring chain.json then
171
+ // transitively witnesses every receipt. The receipt files must already be
172
+ // on disk (every caller writes receipts before the chain).
160
173
  mkdirSync(chainDir, { recursive: true });
161
- const chain = { spec_version: SPEC_VERSION, public_key: publicHex, receipts: receiptFiles };
174
+ const chain = {
175
+ spec_version: SPEC_VERSION,
176
+ public_key: publicHex,
177
+ receipts: receiptFiles,
178
+ receipt_hashes: receiptFileHashes(chainDir, receiptFiles),
179
+ };
162
180
  const path = join(chainDir, CHAIN_FILENAME);
163
181
  writeFileSync(path, JSON.stringify(chain, null, 2) + "\n");
164
182
  return path;
@@ -220,11 +238,37 @@ const short = (value) =>
220
238
  ? value
221
239
  : `${value.slice(0, 4)}...${value.slice(-2)}`;
222
240
 
241
+ // Mirrors _as_trusted_keys in tamper_signal/receipts.py; badge/badge.js
242
+ // checkSignatures inlines the same rule -- update all three in lockstep when
243
+ // the normalization changes.
244
+ function asTrustedKeys(publicHex) {
245
+ if (publicHex === null || publicHex === undefined) return [];
246
+ return (Array.isArray(publicHex) ? publicHex : [publicHex]).filter(Boolean);
247
+ }
248
+
223
249
  // Mirrors tamper_signal/receipts.py verify_chain, including the yellow
224
- // verdict. Returns { ok, verdict, caveats, lines, brokenLink }.
250
+ // verdict. Returns { ok, verdict, caveats, lines, brokenLink, brokenLinkDetail,
251
+ // dataMismatch, receiptMismatch }.
225
252
  export function verifyChain(receipts, publicHex, dataSemanticHash = null, dataTotals = null, options = {}) {
226
- const { chainPublicHex = null, receiptNames = null, warnDrift = false } = options;
227
- const result = { ok: true, verdict: "green", caveats: [], lines: [], brokenLink: null };
253
+ const {
254
+ chainPublicHex = null,
255
+ receiptNames = null,
256
+ warnDrift = false,
257
+ recordedHashes = null,
258
+ actualHashes = null,
259
+ } = options;
260
+ // publicHex may be a single trusted key or a list (key rotation).
261
+ const trusted = asTrustedKeys(publicHex);
262
+ const result = {
263
+ ok: true,
264
+ verdict: "green",
265
+ caveats: [],
266
+ lines: [],
267
+ brokenLink: null,
268
+ brokenLinkDetail: null,
269
+ dataMismatch: null,
270
+ receiptMismatch: null,
271
+ };
228
272
  const fail = (...lines) => {
229
273
  result.ok = false;
230
274
  result.verdict = "red";
@@ -236,11 +280,29 @@ export function verifyChain(receipts, publicHex, dataSemanticHash = null, dataTo
236
280
  return result;
237
281
  }
238
282
 
239
- // 1) Signatures, trusted key first, chain key as fallback.
283
+ // 0) Receipt files against the hashes chain.json records. This is what
284
+ // lets an anchored chain.json transitively witness receipt contents.
285
+ if (recordedHashes !== null && actualHashes !== null) {
286
+ const mismatched = (receiptNames ?? []).filter(
287
+ (name) => actualHashes[name] !== recordedHashes[name]
288
+ );
289
+ if (mismatched.length) {
290
+ result.receiptMismatch = mismatched;
291
+ for (const name of mismatched) {
292
+ fail(
293
+ `✗ RECEIPT FILE MISMATCH: ${name} does not match the hash ` +
294
+ "recorded in chain.json; the receipt was rewritten after the chain was"
295
+ );
296
+ }
297
+ return result;
298
+ }
299
+ }
300
+
301
+ // 1) Signatures, trusted keys first, chain key as fallback.
240
302
  const unrecognized = [];
241
- const useFallback = Boolean(chainPublicHex) && chainPublicHex !== publicHex;
303
+ const useFallback = Boolean(chainPublicHex) && !trusted.includes(chainPublicHex);
242
304
  receipts.forEach((receipt, index) => {
243
- if (verifySignature(receipt, publicHex)) return;
305
+ if (trusted.some((key) => verifySignature(receipt, key))) return;
244
306
  if (useFallback && verifySignature(receipt, chainPublicHex)) {
245
307
  unrecognized.push(index);
246
308
  return;
@@ -257,9 +319,10 @@ export function verifyChain(receipts, publicHex, dataSemanticHash = null, dataTo
257
319
  return "<malformed key>";
258
320
  }
259
321
  };
322
+ const fingerprints = trusted.map(fp).join(", ") || "(none)";
260
323
  result.caveats.push(
261
324
  `unrecognized signing key: ${unrecognized.length} receipt(s) (${stages}) verify under ` +
262
- `the chain's embedded key ${fp(chainPublicHex)}, not the trusted key ${fp(publicHex)}`
325
+ `the chain's embedded key ${fp(chainPublicHex)}, not any of the ${trusted.length} trusted key(s) (${fingerprints})`
263
326
  );
264
327
  }
265
328
 
@@ -270,6 +333,13 @@ export function verifyChain(receipts, publicHex, dataSemanticHash = null, dataTo
270
333
  if (found !== expected) {
271
334
  result.brokenLink = index;
272
335
  const delta = totalsDelta(totalsOf(receipts[index - 1]), totalsOf(receipts[index]));
336
+ result.brokenLinkDetail = {
337
+ link: [index - 1, index],
338
+ stage: stageNameOf(receipts[index]),
339
+ expected_input_hash: expected,
340
+ found_input_hash: found,
341
+ totals_delta: delta,
342
+ };
273
343
  fail(
274
344
  `✗ CHAIN BROKEN at link ${index - 1} -> ${index} (${stageNameOf(receipts[index])})`,
275
345
  ` expected input hash ${short(expected)} (output of ${stageNameOf(receipts[index - 1])})`,
@@ -286,6 +356,12 @@ export function verifyChain(receipts, publicHex, dataSemanticHash = null, dataTo
286
356
  const expected = outputHashOf(final);
287
357
  if (dataSemanticHash !== expected) {
288
358
  const delta = dataTotals !== null ? totalsDelta(totalsOf(final), dataTotals) : null;
359
+ result.dataMismatch = {
360
+ stage: stageNameOf(final),
361
+ expected_output_hash: expected,
362
+ found_data_hash: dataSemanticHash,
363
+ totals_delta: delta,
364
+ };
289
365
  fail(
290
366
  `✗ DATA MISMATCH against final receipt (${stageNameOf(final)})`,
291
367
  ` expected output hash ${short(expected)}`,
@@ -0,0 +1,107 @@
1
+ // Executes the browser verification core (badge/badge.js) under Node's
2
+ // WebCrypto. The static drift test proves the shipped copies are byte-equal;
3
+ // this one actually runs the logic: multi-key rotation, the yellow fallback,
4
+ // and the receipt-hash enforcement that makes anchoring meaningful.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { test } from "node:test";
11
+
12
+ import { evidenceHash, semanticHash } from "../canonical.js";
13
+ import { generateKeys, loadPrivateKey, publicHexFromPrivate } from "../keys.js";
14
+ import { loadCsv } from "../load.js";
15
+ import {
16
+ SOURCE_RECEIPT_NAME,
17
+ buildSourceManifest,
18
+ readReceipt,
19
+ writeChain,
20
+ writeReceipt,
21
+ } from "../receipts.js";
22
+
23
+ // badge.js expects a browser; give it just enough of one before importing.
24
+ globalThis.window = { location: { href: "http://localhost/" }, crypto: globalThis.crypto };
25
+
26
+ const { verifyReceipts, ed25519Available } = await import("../../badge/badge.js");
27
+
28
+ const CSV = "date,campaign_name,spend_usd\n2026-05-01,a,10.50\n2026-05-02,b,20.00\n";
29
+
30
+ function buildChainDir() {
31
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-badge-"));
32
+ const keys = join(dir, "keys");
33
+ const chainDir = join(dir, "receipts");
34
+ generateKeys(keys);
35
+ const privateKey = loadPrivateKey(join(keys, "signing.key"));
36
+ const publicHex = publicHexFromPrivate(privateKey);
37
+ const csvPath = join(dir, "export.csv");
38
+ writeFileSync(csvPath, CSV);
39
+ const records = loadCsv(csvPath);
40
+ const manifest = buildSourceManifest({
41
+ filename: "export.csv",
42
+ evidenceHash: evidenceHash(Buffer.from(CSV)),
43
+ byteSize: CSV.length,
44
+ declaredOrigin: "badge test",
45
+ semanticHash: semanticHash(records),
46
+ records,
47
+ privateKey,
48
+ });
49
+ writeReceipt(chainDir, SOURCE_RECEIPT_NAME, manifest);
50
+ writeChain(chainDir, [SOURCE_RECEIPT_NAME], publicHex);
51
+ return { chainDir, publicHex };
52
+ }
53
+
54
+ function serve(chainDir) {
55
+ // Route badge.js's fetch() calls to the files on disk, by basename.
56
+ globalThis.fetch = async (url) => {
57
+ const name = decodeURIComponent(String(url).split("/").pop());
58
+ const buf = readFileSync(join(chainDir, name));
59
+ return {
60
+ json: async () => JSON.parse(buf.toString("utf-8")),
61
+ arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
62
+ };
63
+ };
64
+ }
65
+
66
+ // Node ships WebCrypto Ed25519 from 18 on, but gate anyway: where it is
67
+ // missing these tests prove nothing and should skip loudly, not fail.
68
+ const available = await ed25519Available();
69
+
70
+ test("browser core: green under the chain key and a rotated keyset", { skip: !available }, async () => {
71
+ const { chainDir, publicHex } = buildChainDir();
72
+ serve(chainDir);
73
+ assert.equal((await verifyReceipts("chain.json")).state, "green");
74
+ assert.equal((await verifyReceipts("chain.json", publicHex)).state, "green");
75
+ assert.equal((await verifyReceipts("chain.json", [publicHex])).state, "green");
76
+
77
+ const otherDir = mkdtempSync(join(tmpdir(), "tamper-signal-badge-rot-"));
78
+ generateKeys(otherDir);
79
+ const otherHex = publicHexFromPrivate(loadPrivateKey(join(otherDir, "signing.key")));
80
+ assert.equal((await verifyReceipts("chain.json", [otherHex, publicHex])).state, "green");
81
+
82
+ const yellow = await verifyReceipts("chain.json", [otherHex]);
83
+ assert.equal(yellow.state, "yellow");
84
+ assert.match(yellow.caveats[0], /unrecognized signing key/);
85
+ });
86
+
87
+ test("browser core: receipt rewritten after the chain is red", { skip: !available }, async () => {
88
+ const { chainDir } = buildChainDir();
89
+ serve(chainDir);
90
+ const receipt = readReceipt(chainDir, SOURCE_RECEIPT_NAME);
91
+ receipt.semantic_hash = "0".repeat(64);
92
+ writeReceipt(chainDir, SOURCE_RECEIPT_NAME, receipt);
93
+
94
+ const result = await verifyReceipts("chain.json");
95
+ assert.equal(result.state, "red");
96
+ assert.match(result.reason, /receipt file mismatch/);
97
+ });
98
+
99
+ test("browser core: chains without receipt hashes still verify", { skip: !available }, async () => {
100
+ const { chainDir } = buildChainDir();
101
+ serve(chainDir);
102
+ const chainPath = join(chainDir, "chain.json");
103
+ const chain = JSON.parse(readFileSync(chainPath, "utf-8"));
104
+ delete chain.receipt_hashes; // chains written before 1.5.0
105
+ writeFileSync(chainPath, JSON.stringify(chain, null, 2) + "\n");
106
+ assert.equal((await verifyReceipts("chain.json")).state, "green");
107
+ });
@@ -2,7 +2,7 @@
2
2
  // verify green; tamper -> red; foreign input -> ChainTailMismatch.
3
3
 
4
4
  import assert from "node:assert/strict";
5
- import { mkdtempSync, writeFileSync } from "node:fs";
5
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { test } from "node:test";
@@ -15,6 +15,7 @@ import {
15
15
  buildSourceManifest,
16
16
  loadReceipts,
17
17
  readReceipt,
18
+ receiptFileHashes,
18
19
  verifyChain,
19
20
  writeChain,
20
21
  writeReceipt,
@@ -95,3 +96,85 @@ test("foreign input is refused before the transform runs", async () => {
95
96
  await assert.rejects(step([{ not: "the chain tail" }]), ChainTailMismatch);
96
97
  assert.equal(ran, false);
97
98
  });
99
+
100
+ test("rotated trusted-key sets verify chains signed under the old key", async () => {
101
+ const { chainDir, publicHex } = setup();
102
+ const otherDir = mkdtempSync(join(tmpdir(), "tamper-signal-rot-"));
103
+ generateKeys(otherDir);
104
+ const newHex = publicHexFromPrivate(loadPrivateKey(join(otherDir, "signing.key")));
105
+
106
+ const receipts = loadReceipts(chainDir);
107
+ // New + old trusted: green. New only (no fallback): red.
108
+ assert.equal(verifyChain(receipts, [newHex, publicHex]).verdict, "green");
109
+ assert.equal(verifyChain(receipts, [newHex]).verdict, "red");
110
+ });
111
+
112
+ test("writeChain records a sha256 for every receipt file", () => {
113
+ const { chainDir } = setup();
114
+ const chain = JSON.parse(readFileSync(join(chainDir, "chain.json"), "utf-8"));
115
+ assert.deepEqual(Object.keys(chain.receipt_hashes), chain.receipts);
116
+ for (const hash of Object.values(chain.receipt_hashes)) assert.match(hash, /^[0-9a-f]{64}$/);
117
+ });
118
+
119
+ test("receipt rewritten after the chain is a red receipt-file mismatch", () => {
120
+ const { chainDir, publicHex } = setup();
121
+ const receipt = readReceipt(chainDir, SOURCE_RECEIPT_NAME);
122
+ receipt.semantic_hash = "0".repeat(64);
123
+ writeReceipt(chainDir, SOURCE_RECEIPT_NAME, receipt);
124
+
125
+ const chain = JSON.parse(readFileSync(join(chainDir, "chain.json"), "utf-8"));
126
+ const result = verifyChain(loadReceipts(chainDir), publicHex, null, null, {
127
+ chainPublicHex: chain.public_key,
128
+ receiptNames: chain.receipts,
129
+ recordedHashes: chain.receipt_hashes,
130
+ actualHashes: receiptFileHashes(chainDir, chain.receipts),
131
+ });
132
+ assert.equal(result.verdict, "red");
133
+ assert.deepEqual(result.receiptMismatch, [SOURCE_RECEIPT_NAME]);
134
+ assert.ok(result.lines[0].includes("RECEIPT FILE MISMATCH"));
135
+ });
136
+
137
+ test("unrecognized signing key with a trusted set is yellow and names the keyset", () => {
138
+ const { chainDir, publicHex } = setup();
139
+ const otherDir = mkdtempSync(join(tmpdir(), "tamper-signal-rot-"));
140
+ generateKeys(otherDir);
141
+ const newHex = publicHexFromPrivate(loadPrivateKey(join(otherDir, "signing.key")));
142
+
143
+ const receipts = loadReceipts(chainDir);
144
+ // Verifies under the chain-embedded key but not the trusted set: yellow.
145
+ const result = verifyChain(receipts, [newHex], null, null, { chainPublicHex: publicHex });
146
+ assert.equal(result.verdict, "yellow");
147
+ assert.match(result.caveats[0], /1 trusted key/);
148
+ });
149
+
150
+ test("CLI verify --json emits the machine-readable verdict", async () => {
151
+ const { execFileSync } = await import("node:child_process");
152
+ const { fileURLToPath } = await import("node:url");
153
+ const { chainDir } = setup();
154
+ const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
155
+ const out = execFileSync(process.execPath, [cli, "verify", join(chainDir, "chain.json"), "--json"], {
156
+ encoding: "utf-8",
157
+ });
158
+ const payload = JSON.parse(out);
159
+ assert.equal(payload.verdict, "green");
160
+ assert.equal(payload.exit_code, 0);
161
+ assert.deepEqual(payload.stages, ["source"]);
162
+ assert.equal(payload.receipt_mismatch, null);
163
+ assert.ok(Array.isArray(payload.report));
164
+ });
165
+
166
+ test("TAMPER_SIGNAL_KEY env supplies the signing key", () => {
167
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-env-"));
168
+ generateKeys(dir);
169
+ const pem = readFileSync(join(dir, "signing.key"), "utf-8");
170
+ // Expected value comes from the FILE before the env override exists, so the
171
+ // assertion really compares env-loading against file-loading.
172
+ const expected = publicHexFromPrivate(loadPrivateKey(join(dir, "signing.key")));
173
+ process.env.TAMPER_SIGNAL_KEY = pem;
174
+ try {
175
+ const key = loadPrivateKey(join(dir, "missing.key"));
176
+ assert.equal(publicHexFromPrivate(key), expected);
177
+ } finally {
178
+ delete process.env.TAMPER_SIGNAL_KEY;
179
+ }
180
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamper-signal",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Signed receipts for vibe-coded data pipelines. Proves nobody changed your data, and shows the exact link if they did.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "scripts": {
36
- "test": "node --test \"node/test/*.test.js\""
36
+ "test": "node --test node/test/badge.test.js node/test/canonical.test.js node/test/chain.test.js node/test/interop.test.js node/test/express.test.js"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=18.17"