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