tamper-signal 1.2.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
@@ -35,9 +35,12 @@ These govern copy, code comments, commit messages, and UI text you produce:
35
35
  Requires Python 3.11+.
36
36
 
37
37
  ```bash
38
- pip install git+https://github.com/welovejeff/tamper-evident-verification.git
38
+ pip install tamper-signal
39
39
  ```
40
40
 
41
+ (Installing from source also works:
42
+ `pip install git+https://github.com/welovejeff/tamper-evident-verification.git`)
43
+
41
44
  This provides the `receipts` CLI and the `tamper_signal` Python package.
42
45
  Verify: `receipts --help` exits 0. JavaScript-only project? Use step 1b and
43
46
  the JS equivalents; the two stacks produce interchangeable chains.
@@ -70,14 +73,21 @@ same package: `tamper-signal/light`, `tamper-signal/badge`,
70
73
  `tamper-signal/element`, `tamper-signal/react`. JS reads .csv/.tsv/.json/
71
74
  .ndjson; only the Python side reads .xlsx.
72
75
 
73
- ## 2. Generate a signing keypair (once per project)
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
+
81
+ ## 2. Scaffold the project (once)
74
82
 
75
83
  ```bash
76
- receipts keygen --out keys/
84
+ receipts init
77
85
  ```
78
86
 
79
- Writes `keys/signing.key` (private, PEM; never commit) and `keys/signing.pub`
80
- (raw 32-byte hex; safe to commit). Add `keys/` and `*.key` to .gitignore now.
87
+ Idempotent. Generates `keys/signing.key` (private, PEM; never commit) and
88
+ `keys/signing.pub` (raw hex; safe to commit), adds `keys/` and `*.key` to
89
+ .gitignore, creates `receipts/`, and prints exactly what it did. The pieces
90
+ are also available separately (`receipts keygen --out keys/`).
81
91
 
82
92
  ## 3. Start the chain at the source export
83
93
 
@@ -121,9 +131,132 @@ receipts verify receipts/chain.json --pub keys/signing.pub --data path/to/dashbo
121
131
 
122
132
  Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
123
133
  optional and checks the file the dashboard actually reads against the final
124
- receipt. In CI: fail the build on exit 1; surface exit 2 to a human rather
125
- than failing silently. `--warn-drift` additionally flags any control-totals
126
- movement across links (only for pipelines expected to preserve totals).
134
+ receipt. `--warn-drift` additionally flags any control-totals movement across
135
+ links (only for pipelines expected to preserve totals).
136
+
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:
151
+
152
+ ```json
153
+ {
154
+ "verdict": "green | yellow | red",
155
+ "exit_code": 0,
156
+ "spec_version": "1.1",
157
+ "receipts": 3,
158
+ "transforms": 2,
159
+ "stages": ["source", "clean", "aggregate"],
160
+ "final_row_count": 304,
161
+ "caveats": ["..."],
162
+ "broken_link": {
163
+ "link": [1, 2],
164
+ "stage": "aggregate",
165
+ "expected_input_hash": "...",
166
+ "found_input_hash": "...",
167
+ "totals_delta": ["row_count 4987 -> 304 (-4683)"]
168
+ },
169
+ "data_mismatch": null,
170
+ "receipt_mismatch": null,
171
+ "report": ["human-legible lines"],
172
+ "anchor": ["anchor report lines; present only when --anchor is passed"]
173
+ }
174
+ ```
175
+
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.
185
+
186
+ ### CI: verify the chain on every push
187
+
188
+ ```yaml
189
+ # .github/workflows/tamper-signal.yml
190
+ name: tamper-signal
191
+ on: [push]
192
+ jobs:
193
+ verify:
194
+ runs-on: ubuntu-latest
195
+ steps:
196
+ - uses: actions/checkout@v4
197
+ - uses: actions/setup-python@v5
198
+ with: { python-version: "3.12" }
199
+ - run: pip install tamper-signal
200
+ - name: Verify the receipt chain
201
+ run: |
202
+ set +e
203
+ receipts verify receipts/chain.json --json | tee verdict.json
204
+ code=$?
205
+ if [ "$code" = "2" ]; then
206
+ echo "::warning::The light is yellow, a human should look: $(python -c 'import json;print("; ".join(json.load(open("verdict.json"))["caveats"]))')"
207
+ exit 0
208
+ fi
209
+ exit $code
210
+ ```
211
+
212
+ Exit 1 (red) fails the build; exit 2 (yellow) surfaces a warning annotation
213
+ without failing.
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.
127
260
 
128
261
  ## 6. Add the signal to the host UI
129
262
 
@@ -159,11 +292,48 @@ and badge.js beside it) and write one tag:
159
292
  Attributes mirror the options: `pub-key`, `watch`, `warn-drift`,
160
293
  `receipts-href`, `theme`.
161
294
 
162
- Static serving examples: Flask
163
- `app = Flask(__name__, static_folder="receipts", static_url_path="/receipts")`;
164
- FastAPI `app.mount("/receipts", StaticFiles(directory="receipts"))`; Express
165
- `app.use("/receipts", express.static("receipts"))`. A purely static site can
166
- copy `receipts/` into its public directory at build time.
295
+ Prefer the one-call attach helpers; each serves the receipts directory AND
296
+ the bundled browser assets, and returns a `snippet` to render once in the
297
+ layout (it mounts the signal into `header`, falling back to `body`):
298
+
299
+ ```python
300
+ # Flask
301
+ from tamper_signal.flask_ext import attach
302
+ signal = attach(app, receipts_dir="receipts/") # then: {{ signal.snippet | safe }}
303
+
304
+ # FastAPI
305
+ from tamper_signal.fastapi_ext import attach
306
+ signal = attach(app, receipts_dir="receipts/")
307
+ ```
308
+
309
+ ```js
310
+ // Express (or any Connect-style router)
311
+ import { tamperSignal } from "tamper-signal/express";
312
+ const signal = tamperSignal(app, { receiptsDir: "receipts/" });
313
+ // serve signal.snippet once in your layout
314
+ ```
315
+
316
+ Next.js: copy `receipts/` into `public/receipts/` as part of the pipeline
317
+ run (the simplest correct path; receipts are plain files), then mount with
318
+ `<TamperSignal chain="/receipts/chain.json" />` from `tamper-signal/react`
319
+ in a client component, or the `<tamper-signal>` element in any layout.
320
+
321
+ Streamlit: `from tamper_signal.streamlit_ext import signal, verified_dataframe`.
322
+ These verify SERVER-SIDE with the Python verifier and the pill says so;
323
+ Streamlit cannot serve the receipts directory for the in-browser walk, and
324
+ faking the stronger claim would violate rule 1.
325
+
326
+ Every attach helper also serves the verification console at
327
+ `<assets_prefix>/console` (e.g. `/tamper-signal/console`): the chain as an
328
+ inspectable pipeline with the break pinned at the severed link, for the
329
+ dashboard's builder and for auditors. Mention it to the user when handing
330
+ over; it is the page to open when the light is anything but green.
331
+
332
+ Manual fallback when no helper fits: serve the directory statically (Flask
333
+ `static_folder="receipts"`, FastAPI `StaticFiles`, Express
334
+ `express.static("receipts")`), or copy `receipts/` into the public dir of a
335
+ static site at build time. For local development, `receipts serve` serves
336
+ the directory on localhost with CORS open and caching off.
167
337
 
168
338
  Placement: the right end of the host header, after the host's own controls.
169
339
  The pill is intentionally dark and mono; do not restyle it to match the host
@@ -189,15 +359,44 @@ under `control_totals.numeric_sums` / `null_counts`.
189
359
  ## 8. The Data tab (when asked for table UI or views)
190
360
 
191
361
  The project's stance: a dashboard built on verified data should show the
192
- verified table, not just charts. If the user asks for the table UI, add a
193
- "Data" tab next to the charts that renders the final stage's output (the same
194
- file `--data` verifies), labeled with the current verdict. The design
195
- reference is `designs/03-data-tab.html` with notes in `designs/03-NOTES.md`;
196
- there is no packaged component yet, so build it in the host's own stack and
197
- say so honestly.
362
+ verified table, not just charts. Two steps:
363
+
364
+ 1. After the pipeline runs, export the canonical table document:
365
+
366
+ ```bash
367
+ receipts export --chain receipts/chain.json --data path/to/dashboard_data.xlsx
368
+ ```
369
+
370
+ This writes `receipts/table.json` and refuses if the data does not match
371
+ the final receipt (the Data tab only ever shows attested data). Re-run it
372
+ whenever the pipeline runs, or the tab will honestly report a stale table.
373
+
374
+ 2. Mount the table (vendor `badge/table.js` beside badge.js, or import
375
+ `tamper-signal/table`):
376
+
377
+ ```html
378
+ <script type="module">
379
+ import { mountReceiptTable } from "/static/table.js";
380
+ mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
381
+ </script>
382
+ ```
383
+
384
+ The component re-hashes the served document in the viewer's browser and
385
+ compares it against the final receipt, so VERIFIED means the rows on screen
386
+ are byte-for-byte the attested data. It renders its own states: green, yellow
387
+ with caveats, chain broken (with the moved columns flagged), and "not the
388
+ attested data" when table.json is stale or edited. Design reference:
389
+ `designs/03-data-tab.html`.
198
390
 
199
391
  ## 9. Verify your work before reporting done
200
392
 
393
+ Run `receipts doctor` first: it checks the Python version, that the private
394
+ key exists and is not tracked by git, that .gitignore covers it, and that the
395
+ chain verifies; pass `--url http://localhost:PORT/chain.json` to also confirm
396
+ the receipts directory is reachable over HTTP. Every failure prints its fix.
397
+ Exit 0 means the integration is healthy. Then confirm the user-visible
398
+ surfaces:
399
+
201
400
  1. `receipts verify receipts/chain.json --pub keys/signing.pub` exits 0.
202
401
  2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
203
402
  the per-stage popover).
@@ -233,3 +432,5 @@ say so honestly.
233
432
  | `examples/chains/` | Committed known-good and known-broken demo chains |
234
433
  | `designs/` | Working HTML mockups for the signal, console, and Data tab |
235
434
  | `docs/MESSAGING.md` | Copy rules; the source of truth for any words you write |
435
+ | `docs/solutions/` | Documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`); relevant when working in documented areas |
436
+ | `CONCEPTS.md` | Shared domain vocabulary (entities, named processes, status concepts); relevant when orienting to the codebase |
package/README.md CHANGED
@@ -2,9 +2,11 @@
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.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
+
5
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.
6
8
 
7
- **Live demo:** [welovejeff.github.io/tamper-evident-verification](https://welovejeff.github.io/tamper-evident-verification/) re-verifies a real committed receipt chain in your browser: swap in a tampered chain or an untrusted key and watch the light catch it.
9
+ **Live demo:** [tampersignal.com](https://tampersignal.com/) re-verifies a real committed receipt chain in your browser: swap in a tampered chain or an untrusted key and watch the light catch it.
8
10
 
9
11
  **Pointing a coding agent at this repo?** `AGENTS.md` is the full integration runbook: install, keygen, ingest, wrap transforms, mount the signal, verify. Tell your agent "add tamper signal" and it will find it.
10
12
 
@@ -26,15 +28,15 @@ The badge and the verifier reduce the whole chain to one state:
26
28
 
27
29
  *The inline status light: a small dark instrument in your dashboard's header. When the chain breaks, it reaches into the page and flags the exact metric that no longer descends from the source.*
28
30
 
29
- Honest status: all three verdicts are implemented in `receipts verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The Data tab and console animations in this README are design previews of later interface tiers, built from the working mockups in `designs/`. The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
31
+ Honest status: all three verdicts are implemented in `receipts verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The animations in this README are renders of the design mockups in `designs/`; the interfaces they depict have since shipped (`badge/light.js`, `badge/table.js`, `badge/console.js`). The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
30
32
 
31
33
  ## 60-second quickstart
32
34
 
33
- Python 3.11+. Open source (MIT), `pip`-installable.
35
+ Python 3.11+. Open source (MIT).
34
36
 
35
37
  ```bash
36
- git clone <this repo> && cd tamper-evident-verification
37
- pip install -e .
38
+ pip install tamper-signal
39
+ git clone https://github.com/welovejeff/tamper-evident-verification && cd tamper-evident-verification
38
40
  receipts demo
39
41
  ```
40
42
 
@@ -43,12 +45,14 @@ receipts demo
43
45
  ## CLI
44
46
 
45
47
  ```bash
46
- receipts keygen --out keys/
48
+ receipts init # scaffold: keys, .gitignore safety, receipts dir (idempotent)
47
49
  receipts ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
48
50
  receipts verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
51
+ receipts doctor # integration self-check with actionable fixes
52
+ receipts serve # serve receipts/ on localhost with CORS (dev only)
49
53
  ```
50
54
 
51
- `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.
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.
52
56
 
53
57
  Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
54
58
 
@@ -136,29 +140,40 @@ The pill expands to a popover: the per-stage table when green, the caveat list w
136
140
 
137
141
  Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `theme: "light"` so the pill stays the one foreign object on a dark host. `receipts demo` serves a live three-state example at `http://localhost:8000/badge/light.html`.
138
142
 
143
+ One-call framework helpers serve the receipts directory and the browser files together and hand back the mounting snippet: `tamper_signal.flask_ext.attach(app)`, `tamper_signal.fastapi_ext.attach(app)`, and `tamperSignal(app)` from `tamper-signal/express`. Streamlit apps get a server-side-verified pill and table caption via `tamper_signal.streamlit_ext` (labeled as the weaker check it is).
144
+
139
145
  ## Dashboards should show their work
140
146
 
141
147
  We think any dashboard built on verified data should let you see the data. Not a tooltip, not an export-on-request: a Data tab, right next to the charts, showing the raw verified table the pretty numbers came from. If the chain is intact and the light is green, there is no reason to hide the rows, and if you find yourself wanting to hide them, that's worth sitting with. A chart asks you to believe; a table lets you check. Green light, open table: that's the whole standard.
142
148
 
149
+ It ships: `receipts export` writes the canonical table document next to the chain (refusing data that does not match the final receipt), and `mountReceiptTable(el, "/receipts/chain.json")` from `badge/table.js` (npm: `tamper-signal/table`) renders it after re-hashing it in the viewer's browser against the final receipt. VERIFIED means the rows on screen are byte-for-byte the attested data; a stale or edited table.json renders dimmed under a "not the attested data" strip, and a broken chain flags the columns that moved at the break. Live demo: `badge/table.html`.
150
+
143
151
  ![The Data tab: the dashboard flips to a dark raw-table view where a broken chain is localized to the views column](docs/media/data-tab.gif)
144
152
 
145
153
  *Design preview: install the verification layer and your dashboard grows a Data tab. When the chain breaks, the break is localized to the column and total that no longer verify, right in the table.*
146
154
 
155
+ ## The console
156
+
157
+ The light answers "is it fine?"; the console answers "where, exactly, and by how much?" `mountReceiptConsole(el, "/receipts/chain.json")` from `badge/console.js` (npm: `tamper-signal/console`) renders the chain as an inspectable pipeline: links carry the hash they proved, a break severs the link with the break card pinned at it, coverage gaps appear as ghost nodes at their position, and the event log mirrors `receipts verify` line for line. Every attach helper also serves it ready-made at `/tamper-signal/console`. Live demo: `badge/console.html`.
158
+
159
+ ![The verification console: a pipeline of signed receipts where a tampered stage severs the chain at the exact link](docs/media/console.gif)
160
+
161
+ *The verification console: calm when green, surgical when red.*
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
+
147
167
  ## What this proves, and what it doesn't
148
168
 
149
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.
150
170
 
151
- 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.
152
172
 
153
173
  ## Roadmap
154
174
 
155
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`).
156
- - **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`.
157
- - **Verification console.** A devtools-for-data window: the receipt chain as an inspectable pipeline, an event log of verify runs, and the break pinned at the severed link.
158
-
159
- ![The verification console: a pipeline of signed receipts where a tampered stage severs the chain at the exact link](docs/media/console.gif)
160
176
 
161
- *Design preview of the verification console: calm when green, surgical when red.*
162
177
 
163
178
  ## Relation to OpenLineage, dbt, and Great Expectations
164
179
 
package/badge/badge.js CHANGED
@@ -24,7 +24,7 @@ export const SHORT = (h) => (h && h.length > 10 ? `${h.slice(0, 4)}...${h.slice(
24
24
  // --- Canonical JSON, byte-identical to tamper_signal/canonical.py's JCS output. ---
25
25
  // Leaves are strings, integers, booleans, or null (no floats). Object keys are
26
26
  // sorted; strings use JSON.stringify, whose escaping matches the Python side.
27
- function canonicalize(value) {
27
+ export function canonicalize(value) {
28
28
  if (value === null) return "null";
29
29
  const t = typeof value;
30
30
  if (t === "boolean") return value ? "true" : "false";
@@ -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;