tamper-signal 1.7.2 → 2.1.1
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 +257 -132
- package/README.md +58 -32
- package/badge/badge.js +147 -5
- package/badge/console.d.ts +6 -0
- package/badge/console.js +48 -331
- package/badge/room.d.ts +118 -0
- package/badge/room.js +1629 -0
- package/badge/table.d.ts +25 -0
- package/badge/table.js +72 -498
- package/node/annotations.js +95 -0
- package/node/cli.js +176 -5
- package/node/express.d.ts +67 -5
- package/node/express.js +111 -20
- package/node/test/annotations.test.js +96 -0
- package/node/test/append_period.test.js +1 -1
- package/node/test/badge.test.js +7 -2
- package/node/test/cli_color.test.js +9 -1
- package/node/test/domstub.js +214 -0
- package/node/test/express.test.js +45 -5
- package/node/test/pipeline.test.js +20 -1
- package/node/test/room_state.test.js +200 -0
- package/node/test/stale_table.test.js +69 -0
- package/node/test/timeline.test.js +69 -0
- package/node/test/vendored_skew.test.js +77 -0
- package/node/test/verify_memo.test.js +133 -0
- package/node/test/vocab.test.js +60 -0
- package/node/test/zip.test.js +4 -4
- package/node/timeline.js +100 -0
- package/node/wrapper.js +13 -1
- package/package.json +11 -3
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,7 +497,87 @@ 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
|
|
|
492
|
-
##
|
|
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
|
+
|
|
572
|
+
## 6. Add the light to the host UI (the room is already served)
|
|
573
|
+
|
|
574
|
+
The UI is two things, always shipped together: **the light** (the inline
|
|
575
|
+
status pill, the lightest touch) and **the room** (the one robust surface
|
|
576
|
+
behind it — the attested data table with the chain rail, break exhibit,
|
|
577
|
+
receipt inspector, event log, chain of custody, and evidence export). The
|
|
578
|
+
attach helpers below serve the room automatically and pre-wire the light's
|
|
579
|
+
"view receipts →" link to it; if you mount by hand, wiring `receiptsHref` to
|
|
580
|
+
a room is YOUR one extra line — the light must never dead-end in raw JSON.
|
|
493
581
|
|
|
494
582
|
With a bundler, import straight from the npm package
|
|
495
583
|
(`import { mountTamperSignal } from "tamper-signal/light"`). Without one, copy
|
|
@@ -497,33 +585,43 @@ the browser assets into the host app. The CLI does this for you (no hunting
|
|
|
497
585
|
through `site-packages` or `node_modules`):
|
|
498
586
|
|
|
499
587
|
```bash
|
|
500
|
-
|
|
588
|
+
tamper-signal assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
|
|
501
589
|
```
|
|
502
590
|
|
|
503
|
-
That writes `light.js`, `badge.js`, `element.js`, `table.js`,
|
|
504
|
-
into `badge/`. For the inline signal you need two of them side
|
|
505
|
-
imports `./badge.js` relatively)
|
|
591
|
+
That writes `light.js`, `badge.js`, `element.js`, `table.js`, `console.js`,
|
|
592
|
+
and `room.js` into `badge/`. For the inline signal you need two of them side
|
|
593
|
+
by side (light.js imports `./badge.js` relatively); the room needs `room.js`
|
|
594
|
+
and `badge.js`:
|
|
506
595
|
|
|
507
|
-
- `badge/badge.js` (verification core
|
|
596
|
+
- `badge/badge.js` (the shared verification core)
|
|
508
597
|
- `badge/light.js` (the signal: the inline status light)
|
|
598
|
+
- `badge/room.js` (the room: `mountSignalRoom`, `<tamper-signal-room>`)
|
|
509
599
|
|
|
510
600
|
Serve the `receipts/` directory statically, then mount the signal in the host
|
|
511
|
-
header
|
|
512
|
-
you vendored into `badge/` and serve it
|
|
601
|
+
header and a room for it to link to. Import the assets from wherever you
|
|
602
|
+
served them; the snippets here assume you vendored into `badge/` and serve it
|
|
603
|
+
at `/badge/`:
|
|
513
604
|
|
|
514
605
|
```html
|
|
515
606
|
<script type="module">
|
|
516
607
|
import { mountTamperSignal } from "/badge/light.js";
|
|
517
|
-
|
|
608
|
+
import { mountSignalRoom } from "/badge/room.js";
|
|
609
|
+
mountSignalRoom(document.querySelector("#data"), "/receipts/chain.json");
|
|
610
|
+
mountTamperSignal(document.querySelector("header"), "/receipts/chain.json",
|
|
611
|
+
undefined, { receiptsHref: "#tamper-room" });
|
|
518
612
|
</script>
|
|
519
613
|
```
|
|
520
614
|
|
|
615
|
+
(The first room in a document owns the id `tamper-room`, so `#tamper-room`
|
|
616
|
+
always reaches it. If the room lives on its own page, point `receiptsHref` at
|
|
617
|
+
that URL instead — the attach helpers do exactly this.)
|
|
618
|
+
|
|
521
619
|
**These surfaces verify over HTTP, not from `file://`.** The signal, badge, and
|
|
522
620
|
table all `fetch()` the chain (and table.json), which the browser blocks on a
|
|
523
621
|
`file://` page, so opening `index.html` directly leaves them silently
|
|
524
622
|
unverified. Serve the page over HTTP: any static server works, and
|
|
525
|
-
`
|
|
526
|
-
offline recipient verifies with the CLI on a bundle (`
|
|
623
|
+
`tamper-signal serve` is the one-liner for local dev. There is no `file://` mode; an
|
|
624
|
+
offline recipient verifies with the CLI on a bundle (`tamper-signal export --bundle`,
|
|
527
625
|
step 8) instead.
|
|
528
626
|
|
|
529
627
|
React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
|
|
@@ -535,22 +633,30 @@ in the `exports` map), so imports like `tamper-signal/react`, `/table`, and
|
|
|
535
633
|
the Node entries (`.`, `/express`) expect `@types/node` as a normal Node
|
|
536
634
|
project already has.
|
|
537
635
|
|
|
538
|
-
Any other framework, or plain HTML: the web
|
|
636
|
+
Any other framework, or plain HTML: the web components. Import
|
|
539
637
|
`tamper-signal/element` (or vendor `badge/element.js`, which needs light.js
|
|
540
|
-
and badge.js beside it)
|
|
638
|
+
and badge.js beside it) for the light, `tamper-signal/room` (or
|
|
639
|
+
`badge/room.js`) for the room, and write one tag each:
|
|
541
640
|
|
|
542
641
|
```html
|
|
543
|
-
<tamper-signal chain="/receipts/chain.json"></tamper-signal>
|
|
642
|
+
<tamper-signal chain="/receipts/chain.json" receipts-href="#tamper-room"></tamper-signal>
|
|
643
|
+
<tamper-signal-room chain="/receipts/chain.json"></tamper-signal-room>
|
|
544
644
|
```
|
|
545
645
|
|
|
546
|
-
|
|
646
|
+
Light attributes mirror the options: `pub-key`, `watch`, `warn-drift`,
|
|
547
647
|
`receipts-href`, `surface` (`"light"` default or `"dark"` for a dark host),
|
|
548
648
|
`invert` (present = shortcut for `surface="dark"`); `theme` is the deprecated
|
|
549
|
-
alias of `surface="dark"`.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
649
|
+
alias of `surface="dark"`. Room attributes: `chain` (required), `table`,
|
|
650
|
+
`timeline`, `pub-key`, `watch`, `warn-drift`, `strict`, `max-rows`, `focus`,
|
|
651
|
+
`preset` (`room` | `table` | `console`), `density` (`embedded` | `page`).
|
|
652
|
+
React hosts use `<tamper-signal-room>` straight from JSX (`badge/room.d.ts`
|
|
653
|
+
ships the JSX typing) — import `tamper-signal/room` for the side effect.
|
|
654
|
+
|
|
655
|
+
Prefer the one-call attach helpers; each serves the receipts directory, the
|
|
656
|
+
bundled browser assets, AND the room page at `<assets_prefix>/receipts`, and
|
|
657
|
+
returns a `snippet` to render once in the layout (it mounts the signal into
|
|
658
|
+
`header`, falling back to `body`, with `receiptsHref` pre-wired to the served
|
|
659
|
+
room):
|
|
554
660
|
|
|
555
661
|
```python
|
|
556
662
|
# Flask
|
|
@@ -579,16 +685,24 @@ These verify SERVER-SIDE with the Python verifier and the pill says so;
|
|
|
579
685
|
Streamlit cannot serve the receipts directory for the in-browser walk, and
|
|
580
686
|
faking the stronger claim would violate rule 1.
|
|
581
687
|
|
|
582
|
-
Every attach helper
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
688
|
+
Every attach helper serves the room at `<assets_prefix>/receipts` (e.g.
|
|
689
|
+
`/tamper-signal/receipts`) and adds `?focus=auto` to the light's link, so a
|
|
690
|
+
red chain opens scrolled to the break exhibit and a yellow one to the first
|
|
691
|
+
caveat. The helper return exposes `roomUrl` and `roomSnippet` (an inline
|
|
692
|
+
embedded room, for a host that renders its own Data tab); `console_url` /
|
|
693
|
+
`consoleUrl` stays reachable, serving the same room with its rail open.
|
|
694
|
+
Opting out (`room=False` / `{ room: false }`) is not recommended; the light
|
|
695
|
+
will link to raw JSON. Mention the room to the user when handing over; it is
|
|
696
|
+
the page to open when the light is anything but green.
|
|
697
|
+
|
|
698
|
+
Hosts that want their own tab chrome: mount `roomSnippet` inside the tab,
|
|
699
|
+
pass `strict`, listen for the bubbling `tamper-signal:state` event, and paint
|
|
700
|
+
your own dot on your own tab — the room never touches host chrome.
|
|
587
701
|
|
|
588
702
|
Manual fallback when no helper fits: serve the directory statically (Flask
|
|
589
703
|
`static_folder="receipts"`, FastAPI `StaticFiles`, Express
|
|
590
704
|
`express.static("receipts")`), or copy `receipts/` into the public dir of a
|
|
591
|
-
static site at build time. For local development, `
|
|
705
|
+
static site at build time. For local development, `tamper-signal serve` serves
|
|
592
706
|
the directory on localhost with CORS open and caching off.
|
|
593
707
|
|
|
594
708
|
Placement: the right end of the host header, after the host's own controls.
|
|
@@ -599,8 +713,8 @@ Options on the fourth argument: `watch` (re-verify every N ms), `warnDrift`,
|
|
|
599
713
|
boolean shortcut for `surface: "dark"`); the old `theme: "light"` still works
|
|
600
714
|
as the `surface: "dark"` alias.
|
|
601
715
|
|
|
602
|
-
The expandable badge (`renderReceiptBadge
|
|
603
|
-
|
|
716
|
+
The expandable badge (`renderReceiptBadge`) is deprecated (removed in 3.0):
|
|
717
|
+
mount the light with the room behind it instead.
|
|
604
718
|
|
|
605
719
|
## 7. Let the signal flag broken metrics
|
|
606
720
|
|
|
@@ -621,65 +735,64 @@ so their column never reaches `numeric_sums`, and a `data-receipt-column` on it
|
|
|
621
735
|
can never flag a change — silently. Grouping isn't auto-stripped on purpose: it
|
|
622
736
|
would diverge from the Python canonicalization and is locale-ambiguous (`"1,234"`
|
|
623
737
|
is 1234 or 1.234?). Fix it upstream with a signed normalize stage that strips
|
|
624
|
-
the separators before the receipt is written. `tamper-signal ingest`
|
|
625
|
-
warning naming any such columns
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
`designs/03-data-tab.html`.
|
|
738
|
+
the separators before the receipt is written. On Node, `tamper-signal ingest`
|
|
739
|
+
prints a warning naming any such columns, and `groupedNumericColumns(records)`
|
|
740
|
+
finds them in code; the Python CLI also warns on stderr during normal ingest
|
|
741
|
+
(both CLIs keep `--json` stderr silent), and `grouped_numeric_columns(records)`
|
|
742
|
+
is available from `tamper_signal`.
|
|
743
|
+
|
|
744
|
+
## 8. Publish table.json so the room's landing plane fills
|
|
745
|
+
|
|
746
|
+
The room's primary content is the attested data table. It fills from
|
|
747
|
+
`table.json`, the canonical table document, which the room re-hashes in the
|
|
748
|
+
viewer's browser against the final receipt — so its VERIFIED means the rows
|
|
749
|
+
on screen are byte-for-byte the attested data. Export it as the LAST step of
|
|
750
|
+
every pipeline run:
|
|
751
|
+
|
|
752
|
+
```bash
|
|
753
|
+
# Python
|
|
754
|
+
tamper-signal export receipts/chain.json --data path/to/dashboard_data.xlsx
|
|
755
|
+
# JavaScript
|
|
756
|
+
tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
The chain path is positional in both CLIs (the Python CLI also accepts
|
|
760
|
+
`--chain receipts/chain.json` for the same value). The command refuses if the
|
|
761
|
+
data does not match the final receipt — the room only ever shows attested
|
|
762
|
+
data. In a JS build you can write it programmatically instead with
|
|
763
|
+
`canonicalDocument(finalRecords)` (see step 1b).
|
|
764
|
+
|
|
765
|
+
Skipping or forgetting this step degrades honestly, never silently:
|
|
766
|
+
|
|
767
|
+
- **No table.json published**: the room's table plane shows a grey "NO
|
|
768
|
+
ATTESTED TABLE PUBLISHED" slab naming the export command. The chain
|
|
769
|
+
verdict, rail, and drawers still render; grey, because absence is not
|
|
770
|
+
tampering. The emitted state carries `attested: false`.
|
|
771
|
+
- **Stale table.json** (pipeline re-ran, export didn't): the room's verdict
|
|
772
|
+
reads `NOT THE ATTESTED DATA` with the exact re-run command — a
|
|
773
|
+
build-behind state, deliberately distinct from a broken chain.
|
|
774
|
+
|
|
775
|
+
So: wire the export into the pipeline run itself, not a manual step. Both
|
|
776
|
+
wrappers do it for you — pass `exportTable: true` to `rebuildChain(...)`
|
|
777
|
+
(Node), or `write_table=True` on your FINAL `@receipt_step` stage (Python) —
|
|
778
|
+
and `table.json` is written as the last pipeline step, always matching the
|
|
779
|
+
chain tail. If a published table does go stale anyway, `tamper-signal verify`
|
|
780
|
+
prints a one-line stderr reminder naming the re-run command (absence stays
|
|
781
|
+
silent: CLI-only projects never publish a table).
|
|
782
|
+
|
|
783
|
+
Embedding the room inline (instead of the helper-served page): mount
|
|
784
|
+
`mountSignalRoom` / `<tamper-signal-room>` where the old Data tab lived. The
|
|
785
|
+
`table.js` shim (`mountReceiptTable`, `<tamper-signal-table>`) keeps working
|
|
786
|
+
through 2.x and now renders the room's table preset. The state contract is
|
|
787
|
+
unchanged: after each verification the room fires a bubbling
|
|
788
|
+
`tamper-signal:state` event (and an `onState` callback) carrying
|
|
789
|
+
`{ state, attested, strict }`, where `state` is the chain verdict and
|
|
790
|
+
`attested` the byte-identity boolean. The room never blocks UI itself.
|
|
791
|
+
Recommended host gate: `strict && (state === "red" || !attested)`.
|
|
679
792
|
|
|
680
793
|
## 9. Verify your work before reporting done
|
|
681
794
|
|
|
682
|
-
On a Python project, run `
|
|
795
|
+
On a Python project, run `tamper-signal doctor` first: it checks the Python version,
|
|
683
796
|
that the private key exists and is not tracked by git, that .gitignore covers
|
|
684
797
|
it, and that the chain verifies; pass `--url http://localhost:PORT/chain.json`
|
|
685
798
|
to also confirm the receipts directory is reachable over HTTP. Every failure
|
|
@@ -688,17 +801,28 @@ Python-only; on a JS project, `tamper-signal verify receipts/chain.json` exits
|
|
|
688
801
|
0 when the chain is healthy, and you should confirm the private key is
|
|
689
802
|
gitignored yourself.) Then confirm the user-visible surfaces:
|
|
690
803
|
|
|
691
|
-
1. `
|
|
804
|
+
1. `tamper-signal verify receipts/chain.json --pub keys/signing.pub` exits 0.
|
|
692
805
|
2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
|
|
693
806
|
the per-stage popover).
|
|
694
|
-
3.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
4.
|
|
807
|
+
3. Click the pill's `view receipts →` link and confirm it lands in the room
|
|
808
|
+
(the served page or an inline mount), NOT on raw chain.json. If it opens
|
|
809
|
+
unstyled JSON, `receiptsHref` was never wired — fix it (the attach helpers
|
|
810
|
+
do it for you).
|
|
811
|
+
4. Confirm the room's table plane shows the attested rows with the signed
|
|
812
|
+
control-totals row (not the grey "no attested table published" slab). If
|
|
813
|
+
it shows the slab, the export step (§8) is missing from the pipeline run.
|
|
814
|
+
5. Negative test without touching the user's real chain: this repo commits
|
|
815
|
+
known-good fixtures under `examples/chains/`. Point the room at
|
|
816
|
+
`tampered/` and confirm the break exhibit leads with business numbers;
|
|
817
|
+
point it at `gap/` and confirm the located caveat card and the ghost node
|
|
818
|
+
in the expanded rail.
|
|
819
|
+
6. If the pill reads `UNVERIFIED · could not load chain`, the receipts
|
|
699
820
|
directory is not being served at the URL you passed (or it is blocked by
|
|
700
821
|
CORS). That state is a capability fallback, not a verdict.
|
|
701
822
|
|
|
823
|
+
When you report done, tell the user where the docs and issue tracker live:
|
|
824
|
+
https://github.com/welovejeff/tamper-evident-verification.
|
|
825
|
+
|
|
702
826
|
## Troubleshooting
|
|
703
827
|
|
|
704
828
|
- `ChainTailMismatch` when running a wrapped transform: the data fed to the
|
|
@@ -717,8 +841,9 @@ gitignored yourself.) Then confirm the user-visible surfaces:
|
|
|
717
841
|
|---|---|
|
|
718
842
|
| `tamper_signal/` | Python package: canonicalization, keys, receipts, verify, `receipt_step` |
|
|
719
843
|
| `node/` | JavaScript package: same API (`receiptStep`, `verifyChain`), interchangeable chains |
|
|
720
|
-
| `badge/badge.js` | Browser verification core
|
|
844
|
+
| `badge/badge.js` | Browser verification core (shared by every surface) |
|
|
721
845
|
| `badge/light.js` | The signal (inline status light), `mountTamperSignal` |
|
|
846
|
+
| `badge/room.js` | The room (the one robust surface), `mountSignalRoom`, `<tamper-signal-room>` |
|
|
722
847
|
| `badge/light-react.js` | `<TamperSignal />` React wrapper |
|
|
723
848
|
| `examples/chains/` | Committed known-good and known-broken demo chains |
|
|
724
849
|
| `designs/` | Working HTML mockups for the signal, console, and Data tab |
|