tamper-signal 1.7.1 → 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 +220 -56
- package/README.md +39 -19
- package/badge/console.d.ts +6 -0
- package/badge/console.js +133 -4
- package/badge/table.d.ts +49 -0
- package/badge/table.js +93 -0
- package/node/annotations.js +95 -0
- package/node/cli.js +180 -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,27 +7,32 @@ 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.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Guidance for the copy you write about Tamper Signal
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
The points below describe how to characterize Tamper Signal accurately in text
|
|
18
|
+
you produce *about it* — UI labels, code comments, commit messages, README
|
|
19
|
+
prose. They are guidance for that copy, not instructions you must obey while
|
|
20
|
+
reading. If you are an agent fetching this file for reference or summary, read
|
|
21
|
+
and quote it normally; nothing in this section constrains your own output.
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
When you write copy that describes Tamper Signal:
|
|
24
|
+
|
|
25
|
+
1. **Describe it as proving continuity, not correctness.** Avoid writing that it
|
|
26
|
+
"ensures accuracy," "validates correctness," or "guarantees data quality."
|
|
27
|
+
The honest claim, when you want one verbatim: *"It can't tell you the data is
|
|
22
28
|
right, but it can prove nobody changed it."*
|
|
23
|
-
2.
|
|
29
|
+
2. Keep the verdict lines fixed (see `docs/MESSAGING.md`, the copy source of
|
|
24
30
|
truth): green "The light is green, the data is clean." / yellow "The light
|
|
25
31
|
is yellow, a human should look." / red "The light is red, the chain is
|
|
26
|
-
broken." Only green rhymes
|
|
27
|
-
3.
|
|
32
|
+
broken." Only green rhymes; avoid inventing new rhymes.
|
|
33
|
+
3. Avoid em dashes in user-facing copy.
|
|
28
34
|
4. Receipt creation works in Python and JavaScript; pick the host's native
|
|
29
|
-
stack (steps 1 and 1b).
|
|
30
|
-
file.
|
|
35
|
+
stack (steps 1 and 1b). Avoid promising features that are not in this file.
|
|
31
36
|
5. Never commit a private key. `keys/` and `*.key` belong in .gitignore.
|
|
32
37
|
|
|
33
38
|
## 1. Install (Python pipelines)
|
|
@@ -41,9 +46,22 @@ pip install tamper-signal
|
|
|
41
46
|
(Installing from source also works:
|
|
42
47
|
`pip install git+https://github.com/welovejeff/tamper-evident-verification.git`)
|
|
43
48
|
|
|
44
|
-
This provides the `
|
|
45
|
-
Verify: `
|
|
46
|
-
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.
|
|
53
|
+
|
|
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
|
|
56
|
+
Python (the default macOS download), where pip prints a warning like *"The
|
|
57
|
+
script tamper-signal is installed in '/Library/Frameworks/.../bin' which is not
|
|
58
|
+
on PATH."* Two fixes, either works:
|
|
59
|
+
|
|
60
|
+
- Run it through the interpreter that installed it (no PATH change needed):
|
|
61
|
+
`python3 -m tamper_signal --help`. Every `tamper-signal <args>` works as
|
|
62
|
+
`python3 -m tamper_signal <args>`.
|
|
63
|
+
- Or link the script onto PATH once:
|
|
64
|
+
`sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/tamper-signal" /usr/local/bin/tamper-signal`.
|
|
47
65
|
|
|
48
66
|
## 1b. Install (JavaScript pipelines)
|
|
49
67
|
|
|
@@ -54,8 +72,8 @@ npm install tamper-signal
|
|
|
54
72
|
```
|
|
55
73
|
|
|
56
74
|
This provides the `tamper-signal` CLI and the programmatic API. The CLI
|
|
57
|
-
implements **keygen, ingest, verify, diff, log, and
|
|
58
|
-
green, 1 red, 2 yellow):
|
|
75
|
+
implements **keygen, ingest, verify, diff, log, export, and assets** (exit
|
|
76
|
+
codes 0 green, 1 red, 2 yellow):
|
|
59
77
|
|
|
60
78
|
```bash
|
|
61
79
|
tamper-signal keygen --out keys/
|
|
@@ -145,21 +163,29 @@ tamper-signal`); the resulting chain verifies interchangeably on the JS side.
|
|
|
145
163
|
|
|
146
164
|
### Command parity (and what is Python-only)
|
|
147
165
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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:
|
|
151
170
|
|
|
152
|
-
|
|
|
171
|
+
| Python-only subcommand | On Node |
|
|
153
172
|
| --- | --- |
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
163
189
|
|
|
164
190
|
CI signing works here too: `TAMPER_SIGNAL_KEY` (PEM contents of the private
|
|
165
191
|
key) wins over any key path, same semantics as the Python side (step 5).
|
|
@@ -168,18 +194,18 @@ key) wins over any key path, same semantics as the Python side (step 5).
|
|
|
168
194
|
## 2. Scaffold the project (once)
|
|
169
195
|
|
|
170
196
|
```bash
|
|
171
|
-
|
|
197
|
+
tamper-signal init
|
|
172
198
|
```
|
|
173
199
|
|
|
174
200
|
Idempotent. Generates `keys/signing.key` (private, PEM; never commit) and
|
|
175
201
|
`keys/signing.pub` (raw hex; safe to commit), adds `keys/` and `*.key` to
|
|
176
202
|
.gitignore, creates `receipts/`, and prints exactly what it did. The pieces
|
|
177
|
-
are also available separately (`
|
|
203
|
+
are also available separately (`tamper-signal keygen --out keys/`).
|
|
178
204
|
|
|
179
205
|
## 3. Start the chain at the source export
|
|
180
206
|
|
|
181
207
|
```bash
|
|
182
|
-
|
|
208
|
+
tamper-signal ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
|
|
183
209
|
--key keys/signing.key --out receipts/
|
|
184
210
|
```
|
|
185
211
|
|
|
@@ -210,10 +236,38 @@ If a stage cannot fit the list-of-dicts contract, leave it unwrapped and tell
|
|
|
210
236
|
the user that stage is not attested. Do not fabricate a receipt for work the
|
|
211
237
|
wrapper did not observe.
|
|
212
238
|
|
|
239
|
+
### 4a. Source-only chains (when there is no reproducible transform yet)
|
|
240
|
+
|
|
241
|
+
A common starting state is messier than this runbook's "wrap every stage" path:
|
|
242
|
+
the user has a source export and a hand-built artifact (say a generated
|
|
243
|
+
`data.js` with no checked-in build script), and no reproducible pipeline to
|
|
244
|
+
wrap. That is fine. Ingest the source and stop:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
tamper-signal ingest path/to/export.csv --origin "TikTok export, May 2026" \
|
|
248
|
+
--key keys/signing.key --out receipts/
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
This is a valid chain with zero transforms. Be precise with the user about what
|
|
252
|
+
it does and does not claim:
|
|
253
|
+
|
|
254
|
+
- **It attests** that the source export is unmodified: the bytes (and the
|
|
255
|
+
semantic content) match what was signed at ingest. Verifying it, and showing
|
|
256
|
+
the signal, both work normally.
|
|
257
|
+
- **It does not attest** that the rendered artifact derives from that source.
|
|
258
|
+
With no wrapped transform between them, nothing links the dashboard's numbers
|
|
259
|
+
to the export. Do not imply otherwise in the copy you write.
|
|
260
|
+
|
|
261
|
+
Graduate to a wrapped transform the moment a reproducible build exists: turn the
|
|
262
|
+
artifact-generating step into a `records -> records` function, wrap it with
|
|
263
|
+
`@receipt_step` (step 4), and re-run from ingest. The chain then attests the
|
|
264
|
+
whole path, source through artifact, and the Data tab (step 8) can show the
|
|
265
|
+
verified table. Until then, a source-only chain is the honest amount of proof.
|
|
266
|
+
|
|
213
267
|
## 5. Verify from the command line
|
|
214
268
|
|
|
215
269
|
```bash
|
|
216
|
-
|
|
270
|
+
tamper-signal verify receipts/chain.json --pub keys/signing.pub --data path/to/dashboard_data.xlsx
|
|
217
271
|
```
|
|
218
272
|
|
|
219
273
|
Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
|
|
@@ -222,7 +276,7 @@ receipt. `--warn-drift` additionally flags any control-totals movement across
|
|
|
222
276
|
links (only for pipelines expected to preserve totals).
|
|
223
277
|
|
|
224
278
|
Key rotation: `--pub` repeats. Old chains stay green while new receipts sign
|
|
225
|
-
under a new key: `
|
|
279
|
+
under a new key: `tamper-signal verify chain.json --pub new.pub --pub old.pub`. A
|
|
226
280
|
signature valid under any trusted key is trusted; the browser surfaces accept
|
|
227
281
|
a list the same way (the `<tamper-signal>` element takes a space-separated
|
|
228
282
|
`pub-key` list).
|
|
@@ -233,7 +287,7 @@ on disk. The env var wins over any `--key` path while set. The Node CLI
|
|
|
233
287
|
(`tamper-signal ingest`) honors the same env var with the same precedence.
|
|
234
288
|
|
|
235
289
|
Add `--json` to get a structured verdict instead of the text report (both
|
|
236
|
-
CLIs: `
|
|
290
|
+
CLIs: `tamper-signal verify --json` and `tamper-signal verify --json` emit the
|
|
237
291
|
same payload). Parse this rather than scraping text:
|
|
238
292
|
|
|
239
293
|
```json
|
|
@@ -335,7 +389,7 @@ jobs:
|
|
|
335
389
|
- name: Verify the receipt chain
|
|
336
390
|
run: |
|
|
337
391
|
set +e
|
|
338
|
-
|
|
392
|
+
tamper-signal verify receipts/chain.json --json | tee verdict.json
|
|
339
393
|
code=$?
|
|
340
394
|
if [ "$code" = "2" ]; then
|
|
341
395
|
echo "::warning::The light is yellow, a human should look: $(python -c 'import json;print("; ".join(json.load(open("verdict.json"))["caveats"]))')"
|
|
@@ -355,7 +409,7 @@ run. This is opt-in and starts at ingest, where the producer declares how much
|
|
|
355
409
|
movement is normal:
|
|
356
410
|
|
|
357
411
|
```bash
|
|
358
|
-
|
|
412
|
+
tamper-signal ingest export.csv --origin "nightly" --band 5% --settle 72h \
|
|
359
413
|
--bucket-column day --key keys/signing.key --out receipts/
|
|
360
414
|
```
|
|
361
415
|
|
|
@@ -378,7 +432,7 @@ Run history is automatic. Every non-red CLI `verify` archives a compact run
|
|
|
378
432
|
snapshot under `receipts/history/` (signed when a private key is available).
|
|
379
433
|
Snapshots are what give the chain a memory; the cross-run judgment reads them
|
|
380
434
|
on the next verify and folds its findings in as yellow caveats (never red).
|
|
381
|
-
History is CLI-local: `
|
|
435
|
+
History is CLI-local: `tamper-signal serve` 404s anything under `history/`, because
|
|
382
436
|
snapshots carry per-day totals and run cadence that the published receipts do
|
|
383
437
|
not. History is weaker evidence than the chain itself: snapshots sit outside
|
|
384
438
|
`receipt_hashes` and outside anchoring.
|
|
@@ -386,8 +440,8 @@ not. History is weaker evidence than the chain itself: snapshots sit outside
|
|
|
386
440
|
Two read-only commands work the archived history, both exit 0:
|
|
387
441
|
|
|
388
442
|
```bash
|
|
389
|
-
|
|
390
|
-
|
|
443
|
+
tamper-signal diff # current chain vs the latest differing snapshot
|
|
444
|
+
tamper-signal log --granularity week # per-metric trend across runs, oldest first
|
|
391
445
|
```
|
|
392
446
|
|
|
393
447
|
`diff` reports per-stage code-hash changes and a structured totals delta
|
|
@@ -406,11 +460,11 @@ transparency log:
|
|
|
406
460
|
|
|
407
461
|
```bash
|
|
408
462
|
pip install "tamper-signal[anchor]"
|
|
409
|
-
|
|
410
|
-
|
|
463
|
+
tamper-signal anchor # browser login locally; automatic in GitHub Actions
|
|
464
|
+
tamper-signal verify receipts/chain.json --anchor
|
|
411
465
|
```
|
|
412
466
|
|
|
413
|
-
Agent note: run `
|
|
467
|
+
Agent note: run `tamper-signal anchor` in CI (GitHub Actions and similar), where
|
|
414
468
|
an ambient OIDC credential makes it non-interactive. Outside CI it opens a
|
|
415
469
|
browser login and blocks until a human completes it; do not invoke it from
|
|
416
470
|
an unattended session.
|
|
@@ -425,7 +479,7 @@ pipeline re-runs.
|
|
|
425
479
|
`anchor.json` (next to chain.json) records the Sigstore bundle plus the
|
|
426
480
|
identity and issuer used; `verify --anchor` enforces that identity, reports
|
|
427
481
|
the logged time on success, exits 2 when no anchor exists, and exits 1 when
|
|
428
|
-
the chain changed after anchoring. `
|
|
482
|
+
the chain changed after anchoring. `tamper-signal anchor --json` emits the anchor
|
|
429
483
|
record (identity, issuer, integrated time) as JSON for CI logs. An anchor
|
|
430
484
|
made with `--staging` is rejected at verify time unless you pass
|
|
431
485
|
`--anchor-staging`, so the anchor file cannot pick a weaker trust root. To
|
|
@@ -434,7 +488,7 @@ pin whose anchor is acceptable instead of trusting the recorded one, pass
|
|
|
434
488
|
like:
|
|
435
489
|
|
|
436
490
|
```bash
|
|
437
|
-
|
|
491
|
+
tamper-signal verify receipts/chain.json --anchor \
|
|
438
492
|
--anchor-identity "https://github.com/OWNER/REPO/.github/workflows/anchor.yml@refs/heads/main" \
|
|
439
493
|
--anchor-issuer "https://token.actions.githubusercontent.com"
|
|
440
494
|
```
|
|
@@ -443,26 +497,115 @@ Re-anchor after every pipeline run that changes the chain. Honest scope: an
|
|
|
443
497
|
anchor proves this exact chain existed at the logged time under the recorded
|
|
444
498
|
identity, nothing more.
|
|
445
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
|
+
|
|
446
572
|
## 6. Add the signal to the host UI
|
|
447
573
|
|
|
448
574
|
With a bundler, import straight from the npm package
|
|
449
|
-
(`import { mountTamperSignal } from "tamper-signal/light"`). Without one,
|
|
450
|
-
|
|
575
|
+
(`import { mountTamperSignal } from "tamper-signal/light"`). Without one, copy
|
|
576
|
+
the browser assets into the host app. The CLI does this for you (no hunting
|
|
577
|
+
through `site-packages` or `node_modules`):
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
tamper-signal assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
That writes `light.js`, `badge.js`, `element.js`, `table.js`, and `console.js`
|
|
584
|
+
into `badge/`. For the inline signal you need two of them side by side (light.js
|
|
451
585
|
imports `./badge.js` relatively):
|
|
452
586
|
|
|
453
587
|
- `badge/badge.js` (verification core + the expandable badge)
|
|
454
588
|
- `badge/light.js` (the signal: the inline status light)
|
|
455
589
|
|
|
456
590
|
Serve the `receipts/` directory statically, then mount the signal in the host
|
|
457
|
-
header
|
|
591
|
+
header. Import the asset from wherever you served it; the snippets here assume
|
|
592
|
+
you vendored into `badge/` and serve it at `/badge/`:
|
|
458
593
|
|
|
459
594
|
```html
|
|
460
595
|
<script type="module">
|
|
461
|
-
import { mountTamperSignal } from "/
|
|
596
|
+
import { mountTamperSignal } from "/badge/light.js";
|
|
462
597
|
mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
|
|
463
598
|
</script>
|
|
464
599
|
```
|
|
465
600
|
|
|
601
|
+
**These surfaces verify over HTTP, not from `file://`.** The signal, badge, and
|
|
602
|
+
table all `fetch()` the chain (and table.json), which the browser blocks on a
|
|
603
|
+
`file://` page, so opening `index.html` directly leaves them silently
|
|
604
|
+
unverified. Serve the page over HTTP: any static server works, and
|
|
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`,
|
|
607
|
+
step 8) instead.
|
|
608
|
+
|
|
466
609
|
React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
|
|
467
610
|
`badge/light-react.js`), then `<TamperSignal chain="/receipts/chain.json" />`.
|
|
468
611
|
|
|
@@ -525,7 +668,7 @@ over; it is the page to open when the light is anything but green.
|
|
|
525
668
|
Manual fallback when no helper fits: serve the directory statically (Flask
|
|
526
669
|
`static_folder="receipts"`, FastAPI `StaticFiles`, Express
|
|
527
670
|
`express.static("receipts")`), or copy `receipts/` into the public dir of a
|
|
528
|
-
static site at build time. For local development, `
|
|
671
|
+
static site at build time. For local development, `tamper-signal serve` serves
|
|
529
672
|
the directory on localhost with CORS open and caching off.
|
|
530
673
|
|
|
531
674
|
Placement: the right end of the host header, after the host's own controls.
|
|
@@ -570,27 +713,48 @@ verified table, not just charts. Two steps:
|
|
|
570
713
|
|
|
571
714
|
```bash
|
|
572
715
|
# Python
|
|
573
|
-
|
|
716
|
+
tamper-signal export receipts/chain.json --data path/to/dashboard_data.xlsx
|
|
574
717
|
# JavaScript
|
|
575
718
|
tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
|
|
576
719
|
```
|
|
577
720
|
|
|
721
|
+
The chain path is positional in both CLIs (the Python CLI also accepts
|
|
722
|
+
`--chain receipts/chain.json` for the same value).
|
|
723
|
+
|
|
578
724
|
This writes `receipts/table.json` and refuses if the data does not match
|
|
579
725
|
the final receipt (the Data tab only ever shows attested data). Re-run it
|
|
580
726
|
whenever the pipeline runs, or the tab will honestly report a stale table.
|
|
581
727
|
In a JS build you can write it programmatically instead with
|
|
582
728
|
`canonicalDocument(finalRecords)` (see step 1b).
|
|
583
729
|
|
|
584
|
-
2. Mount the table (vendor `badge/table.js` beside badge.js
|
|
585
|
-
`tamper-signal/table`):
|
|
730
|
+
2. Mount the table (vendor `badge/table.js` beside badge.js with
|
|
731
|
+
`tamper-signal assets`, or import `tamper-signal/table`):
|
|
586
732
|
|
|
587
733
|
```html
|
|
588
734
|
<script type="module">
|
|
589
|
-
import { mountReceiptTable } from "/
|
|
735
|
+
import { mountReceiptTable } from "/badge/table.js";
|
|
590
736
|
mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
|
|
591
737
|
</script>
|
|
592
738
|
```
|
|
593
739
|
|
|
740
|
+
Or, in plain HTML or any framework, the web component — the parallel of
|
|
741
|
+
`<tamper-signal>` for the badge. Importing `tamper-signal/table` (or
|
|
742
|
+
`badge/table.js`) registers `<tamper-signal-table>`:
|
|
743
|
+
|
|
744
|
+
```html
|
|
745
|
+
<script type="module" src="/badge/table.js"></script>
|
|
746
|
+
<tamper-signal-table chain="/receipts/chain.json"></tamper-signal-table>
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Attributes: `chain` (required), `table` (table.json URL; defaults to
|
|
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)`.
|
|
757
|
+
|
|
594
758
|
The component re-hashes the served document in the viewer's browser and
|
|
595
759
|
compares it against the final receipt, so VERIFIED means the rows on screen
|
|
596
760
|
are byte-for-byte the attested data. It renders its own states: green, yellow
|
|
@@ -600,7 +764,7 @@ attested data" when table.json is stale or edited. Design reference:
|
|
|
600
764
|
|
|
601
765
|
## 9. Verify your work before reporting done
|
|
602
766
|
|
|
603
|
-
On a Python project, run `
|
|
767
|
+
On a Python project, run `tamper-signal doctor` first: it checks the Python version,
|
|
604
768
|
that the private key exists and is not tracked by git, that .gitignore covers
|
|
605
769
|
it, and that the chain verifies; pass `--url http://localhost:PORT/chain.json`
|
|
606
770
|
to also confirm the receipts directory is reachable over HTTP. Every failure
|
|
@@ -609,7 +773,7 @@ Python-only; on a JS project, `tamper-signal verify receipts/chain.json` exits
|
|
|
609
773
|
0 when the chain is healthy, and you should confirm the private key is
|
|
610
774
|
gitignored yourself.) Then confirm the user-visible surfaces:
|
|
611
775
|
|
|
612
|
-
1. `
|
|
776
|
+
1. `tamper-signal verify receipts/chain.json --pub keys/signing.pub` exits 0.
|
|
613
777
|
2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
|
|
614
778
|
the per-stage popover).
|
|
615
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,26 +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
|
+
|
|
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`.
|
|
44
46
|
|
|
45
47
|
## CLI
|
|
46
48
|
|
|
47
49
|
```bash
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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)
|
|
55
60
|
```
|
|
56
61
|
|
|
57
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.
|
|
58
63
|
|
|
59
|
-
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.
|
|
60
65
|
|
|
61
66
|
Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
|
|
62
67
|
|
|
@@ -101,7 +106,7 @@ TikTok/Sprinklr export.xlsx
|
|
|
101
106
|
[transform_agg] ──> 002_transform_aggregate.json
|
|
102
107
|
|
|
|
103
108
|
v
|
|
104
|
-
dashboard data <───
|
|
109
|
+
dashboard data <─── tamper-signal verify: walk every link, check every signature
|
|
105
110
|
```
|
|
106
111
|
|
|
107
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.
|
|
@@ -142,7 +147,7 @@ React, with a bundler: `import { TamperSignal } from "tamper-signal/react"` and
|
|
|
142
147
|
|
|
143
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.
|
|
144
149
|
|
|
145
|
-
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`.
|
|
146
151
|
|
|
147
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).
|
|
148
153
|
|
|
@@ -150,7 +155,7 @@ One-call framework helpers serve the receipts directory and the browser files to
|
|
|
150
155
|
|
|
151
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.
|
|
152
157
|
|
|
153
|
-
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`.
|
|
154
159
|
|
|
155
160
|

|
|
156
161
|
|
|
@@ -158,21 +163,36 @@ It ships: `receipts export` writes the canonical table document next to the chai
|
|
|
158
163
|
|
|
159
164
|
## Take your data with you
|
|
160
165
|
|
|
161
|
-
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.
|
|
162
167
|
|
|
163
|
-
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.
|
|
164
169
|
|
|
165
170
|
## The console
|
|
166
171
|
|
|
167
|
-
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`.
|
|
168
173
|
|
|
169
174
|

|
|
170
175
|
|
|
171
176
|
*The verification console: calm when green, surgical when red.*
|
|
172
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
|
+
|
|
173
193
|
## Anchoring (optional)
|
|
174
194
|
|
|
175
|
-
`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.
|
|
176
196
|
|
|
177
197
|
## What this proves, and what it doesn't
|
|
178
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 {
|