tamper-signal 1.7.2 → 2.0.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 +136 -51
- package/README.md +38 -21
- package/badge/console.d.ts +6 -0
- package/badge/console.js +133 -4
- package/badge/table.d.ts +25 -0
- package/badge/table.js +30 -1
- package/node/annotations.js +95 -0
- package/node/cli.js +138 -1
- package/node/express.js +15 -0
- package/node/test/annotations.test.js +96 -0
- package/node/test/express.test.js +1 -0
- package/node/test/timeline.test.js +69 -0
- package/node/timeline.js +100 -0
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ every step is verifiable.
|
|
|
7
7
|
|
|
8
8
|
What you are installing: signed receipts for a data pipeline. Every pipeline
|
|
9
9
|
stage signs a receipt (a hash of its input, its code, and its output, plus
|
|
10
|
-
human-legible control totals). Receipts link into a chain; `
|
|
10
|
+
human-legible control totals). Receipts link into a chain; `tamper-signal verify`
|
|
11
11
|
and an in-browser signal re-verify the whole chain and report a traffic light.
|
|
12
12
|
Green: intact. Yellow: verifies, with caveats a human should look at. Red:
|
|
13
13
|
broken, at an exact link, with the totals delta.
|
|
@@ -46,21 +46,22 @@ pip install tamper-signal
|
|
|
46
46
|
(Installing from source also works:
|
|
47
47
|
`pip install git+https://github.com/welovejeff/tamper-evident-verification.git`)
|
|
48
48
|
|
|
49
|
-
This provides the `
|
|
50
|
-
Verify: `
|
|
51
|
-
the
|
|
49
|
+
This provides the `tamper-signal` CLI and the `tamper_signal` Python package.
|
|
50
|
+
Verify: `tamper-signal --help` exits 0. (`receipts` is a deprecated alias kept
|
|
51
|
+
working through the 2.x line — prefer `tamper-signal`.) JavaScript-only project?
|
|
52
|
+
Use step 1b; both stacks share the `tamper-signal` command and interchangeable chains.
|
|
52
53
|
|
|
53
|
-
If `
|
|
54
|
-
directory that is not on PATH. This is common on the python.org framework
|
|
54
|
+
If `tamper-signal` comes up "command not found", pip installed the script into a
|
|
55
|
+
bin directory that is not on PATH. This is common on the python.org framework
|
|
55
56
|
Python (the default macOS download), where pip prints a warning like *"The
|
|
56
|
-
script
|
|
57
|
-
PATH."* Two fixes, either works:
|
|
57
|
+
script tamper-signal is installed in '/Library/Frameworks/.../bin' which is not
|
|
58
|
+
on PATH."* Two fixes, either works:
|
|
58
59
|
|
|
59
60
|
- Run it through the interpreter that installed it (no PATH change needed):
|
|
60
|
-
`python3 -m tamper_signal --help`. Every `
|
|
61
|
+
`python3 -m tamper_signal --help`. Every `tamper-signal <args>` works as
|
|
61
62
|
`python3 -m tamper_signal <args>`.
|
|
62
63
|
- Or link the script onto PATH once:
|
|
63
|
-
`sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/
|
|
64
|
+
`sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/tamper-signal" /usr/local/bin/tamper-signal`.
|
|
64
65
|
|
|
65
66
|
## 1b. Install (JavaScript pipelines)
|
|
66
67
|
|
|
@@ -162,22 +163,29 @@ tamper-signal`); the resulting chain verifies interchangeably on the JS side.
|
|
|
162
163
|
|
|
163
164
|
### Command parity (and what is Python-only)
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
As of 2.0 the command is `tamper-signal` on both stacks (on Python, `receipts`
|
|
167
|
+
is a deprecated alias that still works). Every subcommand runs on both installs
|
|
168
|
+
**except** these, which are Python-only for now; on a JS-only project use the
|
|
169
|
+
noted equivalent and skip them:
|
|
168
170
|
|
|
169
|
-
|
|
|
171
|
+
| Python-only subcommand | On Node |
|
|
170
172
|
| --- | --- |
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
173
|
+
| `tamper-signal doctor` | use `tamper-signal verify` (exit 0 = healthy); confirm the key is gitignored yourself |
|
|
174
|
+
| `tamper-signal anchor` | Python-only today (transparency-log anchoring; Node support planned for 2.1) |
|
|
175
|
+
| `tamper-signal custody` | Python-only today (the CLI-local custody view over history/archive) |
|
|
176
|
+
| `tamper-signal watch` | Python-only today (the live-source watcher; see §5c) — its signed manifests and snapshots stay fully readable/verifiable by the JS stack |
|
|
177
|
+
| `tamper-signal review` | Python-only today (human sign-off for withheld watch changes) |
|
|
178
|
+
|
|
179
|
+
Shared subcommands (`ingest`, `verify`, `diff`, `log`, `export`, `assets`,
|
|
180
|
+
`annotate`, `timeline`, `keygen`, `serve`) behave identically on both, with the
|
|
181
|
+
Node programmatic API alongside (`ingestFile()`, `verifyChain()`,
|
|
182
|
+
`canonicalDocument()`). `serve` on Node is your bundler's static server or
|
|
183
|
+
`tamper-signal/express`.
|
|
184
|
+
|
|
185
|
+
If you run a Node host and want a live source kept under custody, the watcher
|
|
186
|
+
itself runs as a Python sidecar process (`pip install "tamper-signal[watch]"`)
|
|
187
|
+
writing into the same `receipts/` directory your Node app serves — the chains
|
|
188
|
+
stay interchangeable, only the `watch`/`review` *commands* are Python-only.
|
|
181
189
|
|
|
182
190
|
CI signing works here too: `TAMPER_SIGNAL_KEY` (PEM contents of the private
|
|
183
191
|
key) wins over any key path, same semantics as the Python side (step 5).
|
|
@@ -186,18 +194,18 @@ key) wins over any key path, same semantics as the Python side (step 5).
|
|
|
186
194
|
## 2. Scaffold the project (once)
|
|
187
195
|
|
|
188
196
|
```bash
|
|
189
|
-
|
|
197
|
+
tamper-signal init
|
|
190
198
|
```
|
|
191
199
|
|
|
192
200
|
Idempotent. Generates `keys/signing.key` (private, PEM; never commit) and
|
|
193
201
|
`keys/signing.pub` (raw hex; safe to commit), adds `keys/` and `*.key` to
|
|
194
202
|
.gitignore, creates `receipts/`, and prints exactly what it did. The pieces
|
|
195
|
-
are also available separately (`
|
|
203
|
+
are also available separately (`tamper-signal keygen --out keys/`).
|
|
196
204
|
|
|
197
205
|
## 3. Start the chain at the source export
|
|
198
206
|
|
|
199
207
|
```bash
|
|
200
|
-
|
|
208
|
+
tamper-signal ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
|
|
201
209
|
--key keys/signing.key --out receipts/
|
|
202
210
|
```
|
|
203
211
|
|
|
@@ -236,7 +244,7 @@ the user has a source export and a hand-built artifact (say a generated
|
|
|
236
244
|
wrap. That is fine. Ingest the source and stop:
|
|
237
245
|
|
|
238
246
|
```bash
|
|
239
|
-
|
|
247
|
+
tamper-signal ingest path/to/export.csv --origin "TikTok export, May 2026" \
|
|
240
248
|
--key keys/signing.key --out receipts/
|
|
241
249
|
```
|
|
242
250
|
|
|
@@ -259,7 +267,7 @@ verified table. Until then, a source-only chain is the honest amount of proof.
|
|
|
259
267
|
## 5. Verify from the command line
|
|
260
268
|
|
|
261
269
|
```bash
|
|
262
|
-
|
|
270
|
+
tamper-signal verify receipts/chain.json --pub keys/signing.pub --data path/to/dashboard_data.xlsx
|
|
263
271
|
```
|
|
264
272
|
|
|
265
273
|
Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
|
|
@@ -268,7 +276,7 @@ receipt. `--warn-drift` additionally flags any control-totals movement across
|
|
|
268
276
|
links (only for pipelines expected to preserve totals).
|
|
269
277
|
|
|
270
278
|
Key rotation: `--pub` repeats. Old chains stay green while new receipts sign
|
|
271
|
-
under a new key: `
|
|
279
|
+
under a new key: `tamper-signal verify chain.json --pub new.pub --pub old.pub`. A
|
|
272
280
|
signature valid under any trusted key is trusted; the browser surfaces accept
|
|
273
281
|
a list the same way (the `<tamper-signal>` element takes a space-separated
|
|
274
282
|
`pub-key` list).
|
|
@@ -279,7 +287,7 @@ on disk. The env var wins over any `--key` path while set. The Node CLI
|
|
|
279
287
|
(`tamper-signal ingest`) honors the same env var with the same precedence.
|
|
280
288
|
|
|
281
289
|
Add `--json` to get a structured verdict instead of the text report (both
|
|
282
|
-
CLIs: `
|
|
290
|
+
CLIs: `tamper-signal verify --json` and `tamper-signal verify --json` emit the
|
|
283
291
|
same payload). Parse this rather than scraping text:
|
|
284
292
|
|
|
285
293
|
```json
|
|
@@ -381,7 +389,7 @@ jobs:
|
|
|
381
389
|
- name: Verify the receipt chain
|
|
382
390
|
run: |
|
|
383
391
|
set +e
|
|
384
|
-
|
|
392
|
+
tamper-signal verify receipts/chain.json --json | tee verdict.json
|
|
385
393
|
code=$?
|
|
386
394
|
if [ "$code" = "2" ]; then
|
|
387
395
|
echo "::warning::The light is yellow, a human should look: $(python -c 'import json;print("; ".join(json.load(open("verdict.json"))["caveats"]))')"
|
|
@@ -401,7 +409,7 @@ run. This is opt-in and starts at ingest, where the producer declares how much
|
|
|
401
409
|
movement is normal:
|
|
402
410
|
|
|
403
411
|
```bash
|
|
404
|
-
|
|
412
|
+
tamper-signal ingest export.csv --origin "nightly" --band 5% --settle 72h \
|
|
405
413
|
--bucket-column day --key keys/signing.key --out receipts/
|
|
406
414
|
```
|
|
407
415
|
|
|
@@ -424,7 +432,7 @@ Run history is automatic. Every non-red CLI `verify` archives a compact run
|
|
|
424
432
|
snapshot under `receipts/history/` (signed when a private key is available).
|
|
425
433
|
Snapshots are what give the chain a memory; the cross-run judgment reads them
|
|
426
434
|
on the next verify and folds its findings in as yellow caveats (never red).
|
|
427
|
-
History is CLI-local: `
|
|
435
|
+
History is CLI-local: `tamper-signal serve` 404s anything under `history/`, because
|
|
428
436
|
snapshots carry per-day totals and run cadence that the published receipts do
|
|
429
437
|
not. History is weaker evidence than the chain itself: snapshots sit outside
|
|
430
438
|
`receipt_hashes` and outside anchoring.
|
|
@@ -432,8 +440,8 @@ not. History is weaker evidence than the chain itself: snapshots sit outside
|
|
|
432
440
|
Two read-only commands work the archived history, both exit 0:
|
|
433
441
|
|
|
434
442
|
```bash
|
|
435
|
-
|
|
436
|
-
|
|
443
|
+
tamper-signal diff # current chain vs the latest differing snapshot
|
|
444
|
+
tamper-signal log --granularity week # per-metric trend across runs, oldest first
|
|
437
445
|
```
|
|
438
446
|
|
|
439
447
|
`diff` reports per-stage code-hash changes and a structured totals delta
|
|
@@ -452,11 +460,11 @@ transparency log:
|
|
|
452
460
|
|
|
453
461
|
```bash
|
|
454
462
|
pip install "tamper-signal[anchor]"
|
|
455
|
-
|
|
456
|
-
|
|
463
|
+
tamper-signal anchor # browser login locally; automatic in GitHub Actions
|
|
464
|
+
tamper-signal verify receipts/chain.json --anchor
|
|
457
465
|
```
|
|
458
466
|
|
|
459
|
-
Agent note: run `
|
|
467
|
+
Agent note: run `tamper-signal anchor` in CI (GitHub Actions and similar), where
|
|
460
468
|
an ambient OIDC credential makes it non-interactive. Outside CI it opens a
|
|
461
469
|
browser login and blocks until a human completes it; do not invoke it from
|
|
462
470
|
an unattended session.
|
|
@@ -471,7 +479,7 @@ pipeline re-runs.
|
|
|
471
479
|
`anchor.json` (next to chain.json) records the Sigstore bundle plus the
|
|
472
480
|
identity and issuer used; `verify --anchor` enforces that identity, reports
|
|
473
481
|
the logged time on success, exits 2 when no anchor exists, and exits 1 when
|
|
474
|
-
the chain changed after anchoring. `
|
|
482
|
+
the chain changed after anchoring. `tamper-signal anchor --json` emits the anchor
|
|
475
483
|
record (identity, issuer, integrated time) as JSON for CI logs. An anchor
|
|
476
484
|
made with `--staging` is rejected at verify time unless you pass
|
|
477
485
|
`--anchor-staging`, so the anchor file cannot pick a weaker trust root. To
|
|
@@ -480,7 +488,7 @@ pin whose anchor is acceptable instead of trusting the recorded one, pass
|
|
|
480
488
|
like:
|
|
481
489
|
|
|
482
490
|
```bash
|
|
483
|
-
|
|
491
|
+
tamper-signal verify receipts/chain.json --anchor \
|
|
484
492
|
--anchor-identity "https://github.com/OWNER/REPO/.github/workflows/anchor.yml@refs/heads/main" \
|
|
485
493
|
--anchor-issuer "https://token.actions.githubusercontent.com"
|
|
486
494
|
```
|
|
@@ -489,6 +497,78 @@ Re-anchor after every pipeline run that changes the chain. Honest scope: an
|
|
|
489
497
|
anchor proves this exact chain existed at the logged time under the recorded
|
|
490
498
|
identity, nothing more.
|
|
491
499
|
|
|
500
|
+
## 5c. Live-source watcher (optional, for feeds you do not re-export by hand)
|
|
501
|
+
|
|
502
|
+
When the source is a live HTTP/JSON-API or RSS feed rather than a file you
|
|
503
|
+
re-export, the watcher keeps it on the same signed chain: it polls, judges the
|
|
504
|
+
new data against the declared band/settle (§5a), and **auto-appends only a
|
|
505
|
+
clean change**. A retroactive change to an already-settled period — or a slow
|
|
506
|
+
drift that cumulatively breaches the band — is **not** signed unattended; it is
|
|
507
|
+
withheld as a signed *pending event* and paused for a human reason.
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
pip install "tamper-signal[watch]"
|
|
511
|
+
# Seed the chain once from any first sample, declaring the tolerance (§5a):
|
|
512
|
+
tamper-signal ingest first.csv --origin "https://feed.example/rates" \
|
|
513
|
+
--band 5% --settle 72h --bucket-column day --key keys/watch.key --out receipts/
|
|
514
|
+
|
|
515
|
+
# One tick (poll once, judge, append-if-clean, else withhold). Config is a
|
|
516
|
+
# small JSON file: {url, format: json|rss, source_id, optional field_map,
|
|
517
|
+
# band/settle/bucket_column, per_tick_cap}.
|
|
518
|
+
tamper-signal watch --config feed.json --key keys/watch.key --out receipts/
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
- **`source_id`** is a STABLE identity for the feed (a feed has no filename).
|
|
522
|
+
Keep it constant across ticks, or cross-run judgment cannot match history and
|
|
523
|
+
the watcher refuses rather than appending unjudged.
|
|
524
|
+
- Change detection is a **full-content fingerprint**, never the server's
|
|
525
|
+
`ETag`/`304` — a compromised origin cannot replay an old validator to hide a
|
|
526
|
+
mutation.
|
|
527
|
+
- The fetch is **SSRF-hardened**: only public hosts (an affirmative `is_global`
|
|
528
|
+
check), redirects off, TLS verified, bounded by bytes and wall-clock. RSS is
|
|
529
|
+
parsed through `defusedxml` (billion-laughs / XXE rejected).
|
|
530
|
+
- The watcher key must be the chain's trusted signer (else it fails closed).
|
|
531
|
+
Use a **dedicated** key, distinct from any interactive human key, for
|
|
532
|
+
isolation and revocability.
|
|
533
|
+
|
|
534
|
+
Withheld changes are reviewed explicitly — each acceptance signs its own reason:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
tamper-signal review # list pending changes awaiting sign-off
|
|
538
|
+
tamper-signal review accept <hash> --reason "confirmed by finance" --author dana
|
|
539
|
+
tamper-signal review reject <hash> # discard; the chain is untouched
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Accepting commits the exact reviewed candidate and signs a reason linked to it;
|
|
543
|
+
if later ticks advanced the chain in the meantime, acceptance re-surfaces for
|
|
544
|
+
review instead of overwriting newer data. The console shows pending changes in
|
|
545
|
+
a distinct "AWAITING REVIEW" section that never affects the verdict.
|
|
546
|
+
|
|
547
|
+
**Deployment — a local file-writer, not a server.** The recommended shape is
|
|
548
|
+
the **stateless tick under a systemd timer / cron**, so the signing key is not
|
|
549
|
+
resident between runs. A `--daemon --interval <seconds>` loop exists for hosts
|
|
550
|
+
without a scheduler; it only polls and writes files. Harden the unit:
|
|
551
|
+
|
|
552
|
+
```ini
|
|
553
|
+
# /etc/systemd/system/tamper-watch.service (paired with a .timer)
|
|
554
|
+
[Service]
|
|
555
|
+
Type=oneshot
|
|
556
|
+
User=tamper-watch # dedicated, unprivileged user
|
|
557
|
+
ExecStart=/usr/bin/tamper-signal watch --config /etc/tamper/feed.json \
|
|
558
|
+
--key %d/watch.key --out /var/lib/tamper/receipts
|
|
559
|
+
LoadCredential=watch.key:/etc/tamper/watch.key # key material via $CREDENTIALS_DIRECTORY, not the env
|
|
560
|
+
NoNewPrivileges=true
|
|
561
|
+
ProtectSystem=strict
|
|
562
|
+
ProtectHome=true
|
|
563
|
+
ReadWritePaths=/var/lib/tamper/receipts
|
|
564
|
+
PrivateTmp=true
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Deliver the key with `LoadCredential=` (it lands under `%d`/`$CREDENTIALS_DIRECTORY`,
|
|
568
|
+
mode 0400, never in the process environment) — do **not** put the key material
|
|
569
|
+
in `EnvironmentFile`, which would expose it via `/proc/<pid>/environ`. Keep the
|
|
570
|
+
key file `0600`; the watcher fails closed if it is group/world-readable.
|
|
571
|
+
|
|
492
572
|
## 6. Add the signal to the host UI
|
|
493
573
|
|
|
494
574
|
With a bundler, import straight from the npm package
|
|
@@ -497,7 +577,7 @@ the browser assets into the host app. The CLI does this for you (no hunting
|
|
|
497
577
|
through `site-packages` or `node_modules`):
|
|
498
578
|
|
|
499
579
|
```bash
|
|
500
|
-
|
|
580
|
+
tamper-signal assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
|
|
501
581
|
```
|
|
502
582
|
|
|
503
583
|
That writes `light.js`, `badge.js`, `element.js`, `table.js`, and `console.js`
|
|
@@ -522,8 +602,8 @@ you vendored into `badge/` and serve it at `/badge/`:
|
|
|
522
602
|
table all `fetch()` the chain (and table.json), which the browser blocks on a
|
|
523
603
|
`file://` page, so opening `index.html` directly leaves them silently
|
|
524
604
|
unverified. Serve the page over HTTP: any static server works, and
|
|
525
|
-
`
|
|
526
|
-
offline recipient verifies with the CLI on a bundle (`
|
|
605
|
+
`tamper-signal serve` is the one-liner for local dev. There is no `file://` mode; an
|
|
606
|
+
offline recipient verifies with the CLI on a bundle (`tamper-signal export --bundle`,
|
|
527
607
|
step 8) instead.
|
|
528
608
|
|
|
529
609
|
React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
|
|
@@ -588,7 +668,7 @@ over; it is the page to open when the light is anything but green.
|
|
|
588
668
|
Manual fallback when no helper fits: serve the directory statically (Flask
|
|
589
669
|
`static_folder="receipts"`, FastAPI `StaticFiles`, Express
|
|
590
670
|
`express.static("receipts")`), or copy `receipts/` into the public dir of a
|
|
591
|
-
static site at build time. For local development, `
|
|
671
|
+
static site at build time. For local development, `tamper-signal serve` serves
|
|
592
672
|
the directory on localhost with CORS open and caching off.
|
|
593
673
|
|
|
594
674
|
Placement: the right end of the host header, after the host's own controls.
|
|
@@ -633,7 +713,7 @@ verified table, not just charts. Two steps:
|
|
|
633
713
|
|
|
634
714
|
```bash
|
|
635
715
|
# Python
|
|
636
|
-
|
|
716
|
+
tamper-signal export receipts/chain.json --data path/to/dashboard_data.xlsx
|
|
637
717
|
# JavaScript
|
|
638
718
|
tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
|
|
639
719
|
```
|
|
@@ -648,7 +728,7 @@ verified table, not just charts. Two steps:
|
|
|
648
728
|
`canonicalDocument(finalRecords)` (see step 1b).
|
|
649
729
|
|
|
650
730
|
2. Mount the table (vendor `badge/table.js` beside badge.js with
|
|
651
|
-
`
|
|
731
|
+
`tamper-signal assets`, or import `tamper-signal/table`):
|
|
652
732
|
|
|
653
733
|
```html
|
|
654
734
|
<script type="module">
|
|
@@ -667,8 +747,13 @@ verified table, not just charts. Two steps:
|
|
|
667
747
|
```
|
|
668
748
|
|
|
669
749
|
Attributes: `chain` (required), `table` (table.json URL; defaults to
|
|
670
|
-
table.json beside the chain),
|
|
671
|
-
footer, default 500)
|
|
750
|
+
table.json beside the chain), `max-rows` (rows before the "show all"
|
|
751
|
+
footer, default 500), and `strict` (present = the table emits its verdict
|
|
752
|
+
with `strict: true` so the host can gate other views on a broken chain). The
|
|
753
|
+
table never blocks UI itself; after each verification it fires a bubbling
|
|
754
|
+
`tamper-signal:state` event (and an `onState` callback) carrying
|
|
755
|
+
`{ state, attested, strict }`. Default (no `strict`) stays always-on and
|
|
756
|
+
always-honest. Recommended host gate: `strict && (state === "red" || !attested)`.
|
|
672
757
|
|
|
673
758
|
The component re-hashes the served document in the viewer's browser and
|
|
674
759
|
compares it against the final receipt, so VERIFIED means the rows on screen
|
|
@@ -679,7 +764,7 @@ attested data" when table.json is stale or edited. Design reference:
|
|
|
679
764
|
|
|
680
765
|
## 9. Verify your work before reporting done
|
|
681
766
|
|
|
682
|
-
On a Python project, run `
|
|
767
|
+
On a Python project, run `tamper-signal doctor` first: it checks the Python version,
|
|
683
768
|
that the private key exists and is not tracked by git, that .gitignore covers
|
|
684
769
|
it, and that the chain verifies; pass `--url http://localhost:PORT/chain.json`
|
|
685
770
|
to also confirm the receipts directory is reachable over HTTP. Every failure
|
|
@@ -688,7 +773,7 @@ Python-only; on a JS project, `tamper-signal verify receipts/chain.json` exits
|
|
|
688
773
|
0 when the chain is healthy, and you should confirm the private key is
|
|
689
774
|
gitignored yourself.) Then confirm the user-visible surfaces:
|
|
690
775
|
|
|
691
|
-
1. `
|
|
776
|
+
1. `tamper-signal verify receipts/chain.json --pub keys/signing.pub` exits 0.
|
|
692
777
|
2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
|
|
693
778
|
the per-stage popover).
|
|
694
779
|
3. Negative test without touching the user's real chain: this repo commits
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# The light is green, the data is clean.
|
|
4
4
|
|
|
5
|
-
[](https://pypi.org/project/tamper-signal/) [](https://www.npmjs.com/package/tamper-signal) [](https://pypi.org/project/tamper-signal/) [](https://www.npmjs.com/package/tamper-signal) [](https://socket.dev/npm/package/tamper-signal/overview/2.0.0) [](https://socket.dev/pypi/package/tamper-signal/overview/2.0.0) [](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
|
|
|
@@ -28,7 +28,7 @@ The badge and the verifier reduce the whole chain to one state:
|
|
|
28
28
|
|
|
29
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.*
|
|
30
30
|
|
|
31
|
-
Honest status: all three verdicts are implemented in `
|
|
31
|
+
Honest status: all three verdicts are implemented in `tamper-signal 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.
|
|
32
32
|
|
|
33
33
|
## 60-second quickstart
|
|
34
34
|
|
|
@@ -37,29 +37,31 @@ Python 3.11+. Open source (MIT).
|
|
|
37
37
|
```bash
|
|
38
38
|
pip install tamper-signal
|
|
39
39
|
git clone https://github.com/welovejeff/tamper-evident-verification && cd tamper-evident-verification
|
|
40
|
-
|
|
40
|
+
tamper-signal demo
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`
|
|
43
|
+
`tamper-signal demo` runs the whole story end to end: generates a deliberately messy sample export, ingests it, runs two AI-written-style transforms, verifies the chain (PASS), then tampers with one spend value and verifies again (FAIL, pinpointing the broken link and the totals delta). It finishes by serving the badge at `http://localhost:8000/badge/badge.html` so you can see green, yellow, and red side by side.
|
|
44
44
|
|
|
45
|
-
> **`
|
|
45
|
+
> **`tamper-signal: command not found`?** pip installed the script into a bin directory that is not on PATH (common on the python.org framework Python, the default macOS download). Either run it through the same interpreter, `python3 -m tamper_signal verify ...` (works as a drop-in for every `tamper-signal ...` command), or link it onto PATH once: `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/tamper-signal" /usr/local/bin/tamper-signal`.
|
|
46
46
|
|
|
47
47
|
## CLI
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
tamper-signal init # scaffold: keys, .gitignore safety, receipts dir (idempotent)
|
|
51
|
+
tamper-signal ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
|
|
52
|
+
tamper-signal verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
|
|
53
|
+
tamper-signal diff # compare two runs: code-hash changes and totals deltas (read-only)
|
|
54
|
+
tamper-signal log # archived run history as a per-metric trend across runs (read-only)
|
|
55
|
+
tamper-signal doctor # integration self-check with actionable fixes
|
|
56
|
+
tamper-signal serve # serve receipts/ on localhost with CORS (dev only)
|
|
57
|
+
tamper-signal assets --out badge/ # vendor the browser surfaces (light/badge/element/table/console.js) into a project
|
|
58
|
+
tamper-signal annotate --reason "backfill approved" --author dana # sign a reason onto a receipt (chain of custody)
|
|
59
|
+
tamper-signal watch --config feed.json --out receipts/ # poll a live feed onto the chain (needs [watch]; see below)
|
|
58
60
|
```
|
|
59
61
|
|
|
60
62
|
`--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.
|
|
61
63
|
|
|
62
|
-
For a recurring refresh of the same report, declare a tolerance at ingest with `--band` (default 5%) and `--settle` (default 72h), optionally keyed off a date column with `--bucket-column`. The declaration is signed into the source manifest. Every non-red `verify` then archives a run snapshot under `receipts/history/`, and the next verify judges this run against that memory: recent buckets may drift within the band, settled buckets (older than the window) may not, and any breach is a yellow caveat. `
|
|
64
|
+
For a recurring refresh of the same report, declare a tolerance at ingest with `--band` (default 5%) and `--settle` (default 72h), optionally keyed off a date column with `--bucket-column`. The declaration is signed into the source manifest. Every non-red `verify` then archives a run snapshot under `receipts/history/`, and the next verify judges this run against that memory: recent buckets may drift within the band, settled buckets (older than the window) may not, and any breach is a yellow caveat. `tamper-signal diff` and `tamper-signal log` read that history (both read-only, exit 0) to show what moved between runs and the per-metric trend across them. History is CLI-local and weaker evidence than the chain: it stays out of `receipt_hashes` and anchoring, and `serve` never exposes it.
|
|
63
65
|
|
|
64
66
|
Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
|
|
65
67
|
|
|
@@ -104,7 +106,7 @@ TikTok/Sprinklr export.xlsx
|
|
|
104
106
|
[transform_agg] ──> 002_transform_aggregate.json
|
|
105
107
|
|
|
|
106
108
|
v
|
|
107
|
-
dashboard data <───
|
|
109
|
+
dashboard data <─── tamper-signal verify: walk every link, check every signature
|
|
108
110
|
```
|
|
109
111
|
|
|
110
112
|
Each receipt contains the SHA-256 of its input, the SHA-256 of the transform's source code, the SHA-256 of its output, and human-legible control totals (row counts, numeric sums, date ranges, null counts). Receipts link because each stage's input hash must equal the prior stage's output hash. Everything is signed with Ed25519; `chain.json` is just an ordered list of receipt files plus the public key.
|
|
@@ -145,7 +147,7 @@ React, with a bundler: `import { TamperSignal } from "tamper-signal/react"` and
|
|
|
145
147
|
|
|
146
148
|
The pill expands to a popover: the per-stage table when green, the caveat list when yellow, the broken link with its totals delta when red. In the red state the light also reaches into the page: give any metric element a `data-receipt-column="spend_usd"` attribute, and if that column moved at the broken link the element gets outlined and tagged `tamper signal: unverified value`. Mark up your metrics once and the light flags the exact number that no longer descends from the source.
|
|
147
149
|
|
|
148
|
-
Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `surface: "dark"` so the pill inverts to stay the one foreign object on a dark host (`surface` describes your page; `invert: true` is a shortcut for it, and the deprecated `theme: "light"` is the same thing). `
|
|
150
|
+
Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `surface: "dark"` so the pill inverts to stay the one foreign object on a dark host (`surface` describes your page; `invert: true` is a shortcut for it, and the deprecated `theme: "light"` is the same thing). `tamper-signal demo` serves a live three-state example at `http://localhost:8000/badge/light.html`.
|
|
149
151
|
|
|
150
152
|
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).
|
|
151
153
|
|
|
@@ -153,7 +155,7 @@ One-call framework helpers serve the receipts directory and the browser files to
|
|
|
153
155
|
|
|
154
156
|
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.
|
|
155
157
|
|
|
156
|
-
It ships: `
|
|
158
|
+
It ships: `tamper-signal 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`.
|
|
157
159
|
|
|
158
160
|

|
|
159
161
|
|
|
@@ -161,21 +163,36 @@ It ships: `receipts export` writes the canonical table document next to the chai
|
|
|
161
163
|
|
|
162
164
|
## Take your data with you
|
|
163
165
|
|
|
164
|
-
Verified data should be portable, proof and all. `
|
|
166
|
+
Verified data should be portable, proof and all. `tamper-signal export --bundle` (or `tamper-signal export --bundle`) writes a verified bundle: a zip of the data file plus `chain.json` and its receipts, kept byte for byte, so whoever you send it to runs `tamper-signal verify chain.json` and gets the same light, offline. In the browser, the Data tab's "Take your data" control exports the attested data client-side as that bundle or as a bare rows-only file (csv/tsv/json/ndjson; xlsx routes through the Python CLI). Because the semantic hash is format-agnostic, a CSV you export here re-verifies as JSON and the light stays green; numeric-looking text canonicalizes to its number, so leading zeros and trailing decimals do not survive the round trip.
|
|
165
167
|
|
|
166
|
-
To bring an updated file back, `
|
|
168
|
+
To bring an updated file back, `tamper-signal ingest --as replace|period`. `replace` (the default) re-signs a fresh chain and archives the prior one under `receipts/archive/`. `period` continues the chain's run history as the next period, judged against prior runs through the prior run's signed tolerance band; it continues only under a trusted signer (`--pub` to trust a key other than the chain's) and refuses an untrusted one rather than appending silently. Re-attestation is never silent: the importer's identity is recorded, and an unrecognized signer stays yellow.
|
|
167
169
|
|
|
168
170
|
## The console
|
|
169
171
|
|
|
170
|
-
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 `
|
|
172
|
+
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 `tamper-signal verify` line for line. Every attach helper also serves it ready-made at `/tamper-signal/console`. Live demo: `badge/console.html`.
|
|
171
173
|
|
|
172
174
|

|
|
173
175
|
|
|
174
176
|
*The verification console: calm when green, surgical when red.*
|
|
175
177
|
|
|
178
|
+
Below the pipeline the console renders the **chain of custody**: the imports and changes, each signed reason attached to the receipt it explains (`tamper-signal annotate`), and any changes **awaiting human review**. It is an additive layer over the published `timeline.json` — it never feeds the verdict above.
|
|
179
|
+
|
|
180
|
+
## Live-source watcher (optional)
|
|
181
|
+
|
|
182
|
+
When the source is a live feed rather than a file you re-export by hand, the watcher keeps it on the same signed chain. `tamper-signal watch` (behind `pip install "tamper-signal[watch]"`) polls an HTTP/JSON-API or RSS/Atom endpoint, judges the new data against the declared band/settle, and **auto-appends only a clean change**. A retroactive edit to an already-settled period — or a slow drift that cumulatively breaches the band — is never signed unattended: it is withheld as a signed *pending event* and paused for a human.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pip install "tamper-signal[watch]"
|
|
186
|
+
tamper-signal watch --config feed.json --key keys/watch.key --out receipts/ # one tick
|
|
187
|
+
tamper-signal review # list withheld changes
|
|
188
|
+
tamper-signal review accept <hash> --reason "confirmed by finance" # sign off + commit
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The fetch is SSRF-hardened (public hosts only, redirects off, TLS verified, byte + wall-clock caps; RSS parsed through `defusedxml`), change detection uses a full-content fingerprint rather than a trust-me `ETag`, and the unattended commit is crash-safe. The recommended deployment is the stateless tick under a systemd timer or cron, so the signing key is not resident between runs — see `AGENTS.md` §5c for a hardened unit. `watch`/`review` are Python-only today; the chains they write are read and verified by the JavaScript stack unchanged.
|
|
192
|
+
|
|
176
193
|
## Anchoring (optional)
|
|
177
194
|
|
|
178
|
-
`pip install "tamper-signal[anchor]"`, then `
|
|
195
|
+
`pip install "tamper-signal[anchor]"`, then `tamper-signal 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. `tamper-signal 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.
|
|
179
196
|
|
|
180
197
|
## What this proves, and what it doesn't
|
|
181
198
|
|
package/badge/console.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface ReceiptConsoleOptions {
|
|
|
11
11
|
warnDrift?: boolean;
|
|
12
12
|
/** Trusted public key hex, single or rotation list. */
|
|
13
13
|
pubKey?: string | string[];
|
|
14
|
+
/**
|
|
15
|
+
* URL of the published `timeline.json` for the chain-of-custody layer.
|
|
16
|
+
* Defaults to `timeline.json` beside the chain. The custody layer is
|
|
17
|
+
* additive and never affects the verdict (which comes from `chain.json`).
|
|
18
|
+
*/
|
|
19
|
+
timeline?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export interface ReceiptConsoleHandle {
|