tamper-signal 1.6.0 → 1.7.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/README.md +6 -0
- package/badge/table.js +268 -2
- package/node/cli.js +165 -6
- package/node/index.d.ts +39 -0
- package/node/index.js +2 -0
- package/node/test/append_period.test.js +134 -0
- package/node/test/zip.test.js +84 -0
- package/node/wrapper.js +81 -0
- package/node/zip.js +108 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -156,6 +156,12 @@ It ships: `receipts export` writes the canonical table document next to the chai
|
|
|
156
156
|
|
|
157
157
|
*Design preview: install the verification layer and your dashboard grows a Data tab. When the chain breaks, the break is localized to the column and total that no longer verify, right in the table.*
|
|
158
158
|
|
|
159
|
+
## Take your data with you
|
|
160
|
+
|
|
161
|
+
Verified data should be portable, proof and all. `receipts 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 `receipts 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
|
+
|
|
163
|
+
To bring an updated file back, `receipts 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
|
+
|
|
159
165
|
## The console
|
|
160
166
|
|
|
161
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 `receipts verify` line for line. Every attach helper also serves it ready-made at `/tamper-signal/console`. Live demo: `badge/console.html`.
|
package/badge/table.js
CHANGED
|
@@ -82,7 +82,20 @@ function injectTableStyles() {
|
|
|
82
82
|
background:none;border:1px solid var(--tt-border);border-radius:6px;
|
|
83
83
|
padding:4px 10px;cursor:pointer}
|
|
84
84
|
.tt .tt-foot button:hover{border-color:var(--tt-faint)}
|
|
85
|
-
.tt .tt-tagline{margin-left:auto;color:var(--tt-green)}
|
|
85
|
+
.tt .tt-tagline{margin-left:auto;color:var(--tt-green)}
|
|
86
|
+
.tt .tt-export{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;
|
|
87
|
+
border-top:1px solid var(--tt-border);padding:8px 14px;font-size:11px;color:var(--tt-dim)}
|
|
88
|
+
.tt .tt-export .tt-xlabel{color:var(--tt-text);letter-spacing:0.3px}
|
|
89
|
+
.tt .tt-export label{display:inline-flex;align-items:center;gap:4px;cursor:pointer}
|
|
90
|
+
.tt .tt-export select{font:11px inherit;font-family:inherit;color:var(--tt-text);
|
|
91
|
+
background:var(--tt-panel);border:1px solid var(--tt-border);border-radius:6px;padding:3px 6px}
|
|
92
|
+
.tt .tt-export button{font:11px inherit;font-family:inherit;color:var(--tt-cyan);
|
|
93
|
+
background:none;border:1px solid var(--tt-border);border-radius:6px;padding:4px 12px;cursor:pointer}
|
|
94
|
+
.tt .tt-export button:hover:not(:disabled){border-color:var(--tt-faint)}
|
|
95
|
+
.tt .tt-export button:disabled{color:var(--tt-faint);cursor:not-allowed;opacity:0.6}
|
|
96
|
+
.tt .tt-export .tt-xnote{flex-basis:100%;color:var(--tt-dim)}
|
|
97
|
+
.tt .tt-export .tt-xnote.tt-warn{color:var(--tt-amber)}
|
|
98
|
+
.tt .tt-export .tt-xhint{margin-left:auto;color:var(--tt-faint)}`;
|
|
86
99
|
document.head.appendChild(el("style", { id: "tamper-table-styles", textContent: css }));
|
|
87
100
|
}
|
|
88
101
|
|
|
@@ -108,6 +121,163 @@ function brokenColumns(result) {
|
|
|
108
121
|
return cols;
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// "Take your data": client-side export of the attested data.
|
|
126
|
+
//
|
|
127
|
+
// The browser only holds the canonical table document, so it reconstructs a
|
|
128
|
+
// native-format file from that data (column- and row-sorted, normalized) — the
|
|
129
|
+
// attested data, not the user's original file byte-for-byte. A verified bundle
|
|
130
|
+
// also carries chain.json and the receipts (fetched as raw bytes so the
|
|
131
|
+
// recipient can re-verify), packaged with a store-only zip writer that mirrors
|
|
132
|
+
// node/zip.js. xlsx is not offered here; it goes through the Python CLI.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
const _CRC_TABLE = (() => {
|
|
136
|
+
const table = new Uint32Array(256);
|
|
137
|
+
for (let n = 0; n < 256; n++) {
|
|
138
|
+
let c = n;
|
|
139
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
140
|
+
table[n] = c >>> 0;
|
|
141
|
+
}
|
|
142
|
+
return table;
|
|
143
|
+
})();
|
|
144
|
+
|
|
145
|
+
function crc32(bytes) {
|
|
146
|
+
let c = 0xffffffff;
|
|
147
|
+
for (let i = 0; i < bytes.length; i++) c = _CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
148
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Store-only (no compression) zip. Entry bytes are kept verbatim so the
|
|
152
|
+
// bundled chain.json and receipts stay byte-identical to the server's copies —
|
|
153
|
+
// receipt_hashes commit to raw bytes, and any transform would verify as broken.
|
|
154
|
+
function makeStoredZip(entries) {
|
|
155
|
+
const enc = new TextEncoder();
|
|
156
|
+
const parts = [];
|
|
157
|
+
const central = [];
|
|
158
|
+
let offset = 0;
|
|
159
|
+
for (const { name, bytes } of entries) {
|
|
160
|
+
const nameBytes = enc.encode(name);
|
|
161
|
+
const crc = crc32(bytes);
|
|
162
|
+
const size = bytes.length;
|
|
163
|
+
const local = new DataView(new ArrayBuffer(30));
|
|
164
|
+
local.setUint32(0, 0x04034b50, true);
|
|
165
|
+
local.setUint16(4, 20, true);
|
|
166
|
+
local.setUint16(8, 0, true);
|
|
167
|
+
local.setUint16(12, 0x21, true);
|
|
168
|
+
local.setUint32(14, crc, true);
|
|
169
|
+
local.setUint32(18, size, true);
|
|
170
|
+
local.setUint32(22, size, true);
|
|
171
|
+
local.setUint16(26, nameBytes.length, true);
|
|
172
|
+
const localHeader = new Uint8Array(local.buffer);
|
|
173
|
+
parts.push(localHeader, nameBytes, bytes);
|
|
174
|
+
const cd = new DataView(new ArrayBuffer(46));
|
|
175
|
+
cd.setUint32(0, 0x02014b50, true);
|
|
176
|
+
cd.setUint16(4, 20, true);
|
|
177
|
+
cd.setUint16(6, 20, true);
|
|
178
|
+
cd.setUint16(14, 0x21, true); // DOS date at offset 14 (offset 12 is the time field, left 0)
|
|
179
|
+
cd.setUint32(16, crc, true);
|
|
180
|
+
cd.setUint32(20, size, true);
|
|
181
|
+
cd.setUint32(24, size, true);
|
|
182
|
+
cd.setUint16(28, nameBytes.length, true);
|
|
183
|
+
cd.setUint32(42, offset, true);
|
|
184
|
+
central.push(new Uint8Array(cd.buffer), nameBytes);
|
|
185
|
+
offset += localHeader.length + nameBytes.length + size;
|
|
186
|
+
}
|
|
187
|
+
let centralSize = 0;
|
|
188
|
+
for (const c of central) centralSize += c.length;
|
|
189
|
+
const eocd = new DataView(new ArrayBuffer(22));
|
|
190
|
+
eocd.setUint32(0, 0x06054b50, true);
|
|
191
|
+
eocd.setUint16(8, entries.length, true);
|
|
192
|
+
eocd.setUint16(10, entries.length, true);
|
|
193
|
+
eocd.setUint32(12, centralSize, true);
|
|
194
|
+
eocd.setUint32(16, offset, true);
|
|
195
|
+
const all = [...parts, ...central, new Uint8Array(eocd.buffer)];
|
|
196
|
+
let total = 0;
|
|
197
|
+
for (const c of all) total += c.length;
|
|
198
|
+
const out = new Uint8Array(total);
|
|
199
|
+
let p = 0;
|
|
200
|
+
for (const c of all) {
|
|
201
|
+
out.set(c, p);
|
|
202
|
+
p += c.length;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cellText(cell) {
|
|
208
|
+
if (cell === null || cell === undefined) return "";
|
|
209
|
+
if (cell === true) return "true";
|
|
210
|
+
if (cell === false) return "false";
|
|
211
|
+
return String(cell);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Reconstruct a native-format file from the canonical document. Values are the
|
|
215
|
+
// attested values; format is the recipient's choice. Re-ingesting any of these
|
|
216
|
+
// yields the same Semantic hash, since canonicalization is format-agnostic.
|
|
217
|
+
function serializeDoc(doc, format) {
|
|
218
|
+
const { headers, rows } = doc;
|
|
219
|
+
if (format === "json") {
|
|
220
|
+
return JSON.stringify(rows.map((r) => Object.fromEntries(headers.map((h, i) => [h, r[i]]))), null, 2) + "\n";
|
|
221
|
+
}
|
|
222
|
+
if (format === "ndjson") {
|
|
223
|
+
return rows.map((r) => JSON.stringify(Object.fromEntries(headers.map((h, i) => [h, r[i]])))).join("\n") + "\n";
|
|
224
|
+
}
|
|
225
|
+
const sep = format === "tsv" ? "\t" : ",";
|
|
226
|
+
const quote = (v) => {
|
|
227
|
+
const s = cellText(v);
|
|
228
|
+
if (format === "tsv") return s.replace(/[\t\r\n]/g, " ");
|
|
229
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
230
|
+
};
|
|
231
|
+
const lines = [headers.map(quote).join(sep)];
|
|
232
|
+
for (const row of rows) lines.push(row.map(quote).join(sep));
|
|
233
|
+
return lines.join("\n") + "\n";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const EXPORT_EXT = { csv: "csv", tsv: "tsv", json: "json", ndjson: "ndjson" };
|
|
237
|
+
|
|
238
|
+
function triggerDownload(filename, bytes, mime) {
|
|
239
|
+
const blob = new Blob([bytes], { type: mime });
|
|
240
|
+
const url = URL.createObjectURL(blob);
|
|
241
|
+
const a = el("a", { href: url, download: filename });
|
|
242
|
+
document.body.appendChild(a);
|
|
243
|
+
a.click();
|
|
244
|
+
document.body.removeChild(a);
|
|
245
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Shipped inside every verified bundle so a recipient can verify it without
|
|
249
|
+
// prior knowledge of Tamper Signal. Kept in sync with the CLIs (tamper_signal/
|
|
250
|
+
// cli.py BUNDLE_README, node/cli.js BUNDLE_README).
|
|
251
|
+
const BUNDLE_README = `# Verified data bundle (Tamper Signal)
|
|
252
|
+
|
|
253
|
+
This zip is a verified export from Tamper Signal. It holds the data file plus
|
|
254
|
+
chain.json and the receipt files that prove it.
|
|
255
|
+
|
|
256
|
+
## Verify it yourself, offline
|
|
257
|
+
|
|
258
|
+
Install either stack (chains are interchangeable across them):
|
|
259
|
+
|
|
260
|
+
pip install tamper-signal # Python 3.11+, command: receipts
|
|
261
|
+
npm install -g tamper-signal # Node 18.17+, command: tamper-signal
|
|
262
|
+
|
|
263
|
+
Then, from the folder you unzipped this into:
|
|
264
|
+
|
|
265
|
+
receipts verify chain.json
|
|
266
|
+
|
|
267
|
+
The exit code is the traffic light: 0 green (intact), 2 yellow (verifies, with
|
|
268
|
+
caveats), 1 red (broken, at the exact link, with the totals that moved).
|
|
269
|
+
|
|
270
|
+
## What a green light proves
|
|
271
|
+
|
|
272
|
+
Continuity, not correctness. It proves this data descends unchanged from the
|
|
273
|
+
signed source, not that the source was right to begin with. Green means nobody
|
|
274
|
+
changed the data between the export and you.
|
|
275
|
+
|
|
276
|
+
https://tampersignal.com
|
|
277
|
+
`;
|
|
278
|
+
|
|
279
|
+
let _ttSeq = 0;
|
|
280
|
+
|
|
111
281
|
export function mountReceiptTable(containerEl, chainUrl, tableUrl, opts) {
|
|
112
282
|
if (tableUrl && typeof tableUrl === "object") {
|
|
113
283
|
opts = tableUrl;
|
|
@@ -128,12 +298,102 @@ export function mountReceiptTable(containerEl, chainUrl, tableUrl, opts) {
|
|
|
128
298
|
const noteSlot = el("div");
|
|
129
299
|
const scroll = el("div", { className: "tt-scroll" });
|
|
130
300
|
const foot = el("div", { className: "tt-foot" });
|
|
131
|
-
|
|
301
|
+
const exportBar = el("div", { className: "tt-export" });
|
|
302
|
+
root.append(strip, noteSlot, scroll, foot, exportBar);
|
|
132
303
|
containerEl.appendChild(root);
|
|
133
304
|
root.dataset.state = "unverifiable";
|
|
134
305
|
|
|
306
|
+
const uid = String(_ttSeq++);
|
|
135
307
|
let destroyed = false;
|
|
136
308
|
let showAll = false;
|
|
309
|
+
let currentDoc = null;
|
|
310
|
+
let currentAttested = false;
|
|
311
|
+
|
|
312
|
+
// "Take your data": rebuilt each refresh from the current verdict. The
|
|
313
|
+
// verified-bundle option is offered only when the data is attested and the
|
|
314
|
+
// light is green or yellow; rows-only is always available but never claims
|
|
315
|
+
// proof.
|
|
316
|
+
function renderExport() {
|
|
317
|
+
exportBar.textContent = "";
|
|
318
|
+
const state = root.dataset.state;
|
|
319
|
+
const doc = currentDoc;
|
|
320
|
+
if (!doc) {
|
|
321
|
+
exportBar.appendChild(el("span", { className: "tt-xnote" },
|
|
322
|
+
"Export unavailable: verification did not complete."));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const bundleOk = currentAttested && (state === "green" || state === "yellow");
|
|
326
|
+
const typeName = `tt-xtype-${uid}`;
|
|
327
|
+
const bundleRadio = el("input",
|
|
328
|
+
{ type: "radio", name: typeName, value: "bundle", checked: bundleOk, disabled: !bundleOk });
|
|
329
|
+
const rowsRadio = el("input",
|
|
330
|
+
{ type: "radio", name: typeName, value: "rows", checked: !bundleOk });
|
|
331
|
+
const fmt = el("select", {}, Object.keys(EXPORT_EXT).map((f) => el("option", { value: f }, f)));
|
|
332
|
+
const btn = el("button", { type: "button" }, "Download");
|
|
333
|
+
const note = el("div", { className: "tt-xnote" });
|
|
334
|
+
|
|
335
|
+
exportBar.append(
|
|
336
|
+
el("span", { className: "tt-xlabel" }, "Take your data"),
|
|
337
|
+
el("label", {}, [bundleRadio, "Verified bundle"]),
|
|
338
|
+
el("label", {}, [rowsRadio, "Rows only"]),
|
|
339
|
+
fmt,
|
|
340
|
+
btn,
|
|
341
|
+
el("span", { className: "tt-xhint" }, "xlsx: use the receipts export command"),
|
|
342
|
+
note,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
function updateNote() {
|
|
346
|
+
note.className = "tt-xnote";
|
|
347
|
+
if (rowsRadio.checked) {
|
|
348
|
+
note.textContent = "Rows only: no chain or receipts. A recipient can't verify this file.";
|
|
349
|
+
} else if (state === "yellow") {
|
|
350
|
+
note.className = "tt-xnote tt-warn";
|
|
351
|
+
note.textContent = "The chain has caveats; a recipient will see a yellow light.";
|
|
352
|
+
} else if (!bundleOk) {
|
|
353
|
+
note.className = "tt-xnote tt-warn";
|
|
354
|
+
note.textContent = "Verified bundle unavailable: this table is not the attested data.";
|
|
355
|
+
} else {
|
|
356
|
+
note.textContent = "Verified bundle: data plus chain.json and receipts. The recipient verifies it offline.";
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
bundleRadio.addEventListener("change", updateNote);
|
|
360
|
+
rowsRadio.addEventListener("change", updateNote);
|
|
361
|
+
updateNote();
|
|
362
|
+
|
|
363
|
+
btn.addEventListener("click", async () => {
|
|
364
|
+
const rowsOnly = rowsRadio.checked;
|
|
365
|
+
const ext = EXPORT_EXT[fmt.value];
|
|
366
|
+
const dataBytes = new TextEncoder().encode(serializeDoc(doc, fmt.value));
|
|
367
|
+
const label = btn.textContent;
|
|
368
|
+
btn.disabled = true;
|
|
369
|
+
btn.textContent = "Preparing…";
|
|
370
|
+
try {
|
|
371
|
+
if (rowsOnly) {
|
|
372
|
+
triggerDownload(`data-rows-only.${ext}`, dataBytes, "application/octet-stream");
|
|
373
|
+
} else {
|
|
374
|
+
const base = new URL(chainUrl, window.location.href);
|
|
375
|
+
const chainBytes = new Uint8Array(await (await fetch(base)).arrayBuffer());
|
|
376
|
+
const chainDoc = JSON.parse(new TextDecoder().decode(chainBytes));
|
|
377
|
+
const entries = [
|
|
378
|
+
{ name: "README.md", bytes: new TextEncoder().encode(BUNDLE_README) },
|
|
379
|
+
{ name: `data.${ext}`, bytes: dataBytes },
|
|
380
|
+
{ name: "chain.json", bytes: chainBytes },
|
|
381
|
+
];
|
|
382
|
+
for (const name of chainDoc.receipts || []) {
|
|
383
|
+
const bytes = new Uint8Array(await (await fetch(new URL(name, base))).arrayBuffer());
|
|
384
|
+
entries.push({ name, bytes });
|
|
385
|
+
}
|
|
386
|
+
triggerDownload("data-verified.zip", makeStoredZip(entries), "application/zip");
|
|
387
|
+
}
|
|
388
|
+
} catch (_e) {
|
|
389
|
+
note.className = "tt-xnote tt-warn";
|
|
390
|
+
note.textContent = "Could not build the download. Try again.";
|
|
391
|
+
} finally {
|
|
392
|
+
btn.textContent = label;
|
|
393
|
+
btn.disabled = false;
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
137
397
|
|
|
138
398
|
function setStrip(state, verdictText, metaText, trusted) {
|
|
139
399
|
root.dataset.state = state;
|
|
@@ -197,6 +457,9 @@ export function mountReceiptTable(containerEl, chainUrl, tableUrl, opts) {
|
|
|
197
457
|
scroll.textContent = "";
|
|
198
458
|
foot.textContent = "";
|
|
199
459
|
if (doc) renderTable(doc, new Set());
|
|
460
|
+
currentDoc = doc;
|
|
461
|
+
currentAttested = false;
|
|
462
|
+
renderExport();
|
|
200
463
|
return result;
|
|
201
464
|
}
|
|
202
465
|
|
|
@@ -239,6 +502,9 @@ export function mountReceiptTable(containerEl, chainUrl, tableUrl, opts) {
|
|
|
239
502
|
setStrip("green", "VERIFIED", rowsText, true);
|
|
240
503
|
renderTable(doc, new Set());
|
|
241
504
|
}
|
|
505
|
+
currentDoc = doc;
|
|
506
|
+
currentAttested = attested;
|
|
507
|
+
renderExport();
|
|
242
508
|
return result;
|
|
243
509
|
}
|
|
244
510
|
|
package/node/cli.js
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
// tamper-signal keygen --out keys/
|
|
6
6
|
// tamper-signal ingest export.csv --origin "..." --key keys/signing.key --out receipts/
|
|
7
7
|
// tamper-signal verify receipts/chain.json [--pub keys/signing.pub] [--data current.csv] [--warn-drift]
|
|
8
|
-
// tamper-signal export receipts/chain.json --data current.csv [--out receipts/table.json]
|
|
8
|
+
// tamper-signal export receipts/chain.json --data current.csv [--out receipts/table.json] [--bundle]
|
|
9
9
|
//
|
|
10
10
|
// Exit codes are the traffic light: 0 green, 1 red, 2 yellow.
|
|
11
11
|
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { basename, dirname, join } from "node:path";
|
|
15
15
|
import { parseArgs } from "node:util";
|
|
16
16
|
import process from "node:process";
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { decimalToPlainString, parseDecimal } from "./canonical.js";
|
|
31
31
|
import { generateKeys, loadPrivateKey, loadPublicKeyHex, publicHexFromPrivate } from "./keys.js";
|
|
32
32
|
import { loadRecords } from "./load.js";
|
|
33
|
+
import { makeStoredZip } from "./zip.js";
|
|
33
34
|
import {
|
|
34
35
|
CHAIN_FILENAME,
|
|
35
36
|
SOURCE_RECEIPT_NAME,
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
verifyChain,
|
|
43
44
|
} from "./receipts.js";
|
|
44
45
|
import { controlTotals, groupedNumericColumns, structuredTotalsDelta } from "./totals.js";
|
|
45
|
-
import { ingestFile } from "./wrapper.js";
|
|
46
|
+
import { UntrustedSignerError, appendPeriod, ingestFile } from "./wrapper.js";
|
|
46
47
|
|
|
47
48
|
const USAGE = `usage: tamper-signal <command>
|
|
48
49
|
|
|
@@ -50,12 +51,18 @@ commands:
|
|
|
50
51
|
keygen --out keys/ generate an Ed25519 signing keypair
|
|
51
52
|
ingest <file> --origin "..." [--key keys/signing.key] [--out receipts/]
|
|
52
53
|
[--band 5%] [--settle 72h] [--bucket-column <name>]
|
|
54
|
+
[--as replace|period] [--pub key.pub ...]
|
|
53
55
|
create a signed source manifest
|
|
54
56
|
(.csv, .tsv, .json, .ndjson);
|
|
55
57
|
--band/--settle/--bucket-column
|
|
56
58
|
sign a tolerance declaration into
|
|
57
59
|
the manifest (band default 0.05,
|
|
58
|
-
settle default 72h)
|
|
60
|
+
settle default 72h). --as replace
|
|
61
|
+
(default) re-signs a fresh chain
|
|
62
|
+
(prior chain archived); --as period
|
|
63
|
+
continues run history under a
|
|
64
|
+
trusted signer (--pub to trust a
|
|
65
|
+
key other than the chain's)
|
|
59
66
|
verify <chain.json> [--pub key.pub ...] [--data <file>] [--warn-drift] [--json]
|
|
60
67
|
verify a chain (exit 0 green, 1 red, 2 yellow)
|
|
61
68
|
diff [A] [B] [--chain receipts/] [--json] compare two runs: per-stage
|
|
@@ -76,10 +83,43 @@ commands:
|
|
|
76
83
|
collapse last-wins); each metric
|
|
77
84
|
shows its value and the delta vs
|
|
78
85
|
the previous row. Read-only; exit 0
|
|
79
|
-
export <chain.json> --data <file> [--out receipts/table.json]
|
|
86
|
+
export <chain.json> --data <file> [--out receipts/table.json] [--bundle]
|
|
80
87
|
write the canonical table document
|
|
81
88
|
(refuses unless --data matches the
|
|
82
|
-
final receipt)
|
|
89
|
+
final receipt); --bundle writes a
|
|
90
|
+
verified zip (data + chain.json +
|
|
91
|
+
receipts) for offline re-verification
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
// Shipped inside every verified bundle so a recipient can verify it without
|
|
95
|
+
// prior knowledge of Tamper Signal. Kept in sync with the Python CLI and the
|
|
96
|
+
// browser export (tamper_signal/cli.py BUNDLE_README, badge/table.js).
|
|
97
|
+
const BUNDLE_README = `# Verified data bundle (Tamper Signal)
|
|
98
|
+
|
|
99
|
+
This zip is a verified export from Tamper Signal. It holds the data file plus
|
|
100
|
+
chain.json and the receipt files that prove it.
|
|
101
|
+
|
|
102
|
+
## Verify it yourself, offline
|
|
103
|
+
|
|
104
|
+
Install either stack (chains are interchangeable across them):
|
|
105
|
+
|
|
106
|
+
pip install tamper-signal # Python 3.11+, command: receipts
|
|
107
|
+
npm install -g tamper-signal # Node 18.17+, command: tamper-signal
|
|
108
|
+
|
|
109
|
+
Then, from the folder you unzipped this into:
|
|
110
|
+
|
|
111
|
+
receipts verify chain.json
|
|
112
|
+
|
|
113
|
+
The exit code is the traffic light: 0 green (intact), 2 yellow (verifies, with
|
|
114
|
+
caveats), 1 red (broken, at the exact link, with the totals that moved).
|
|
115
|
+
|
|
116
|
+
## What a green light proves
|
|
117
|
+
|
|
118
|
+
Continuity, not correctness. It proves this data descends unchanged from the
|
|
119
|
+
signed source, not that the source was right to begin with. Green means nobody
|
|
120
|
+
changed the data between the export and you.
|
|
121
|
+
|
|
122
|
+
https://tampersignal.com
|
|
83
123
|
`;
|
|
84
124
|
|
|
85
125
|
function cmdKeygen(args) {
|
|
@@ -112,6 +152,96 @@ function isUnsnapshottedReset(chainDir) {
|
|
|
112
152
|
}
|
|
113
153
|
}
|
|
114
154
|
|
|
155
|
+
// Before a replace reset, copy the prior chain.json and its receipts into
|
|
156
|
+
// <chainDir>/archive/<tail>/ so the prior chain is preserved, not silently
|
|
157
|
+
// overwritten (R9). Content-addressed by the prior chain tail; idempotent and
|
|
158
|
+
// collision-free. Best-effort: never throws, never blocks ingest.
|
|
159
|
+
function archivePriorChain(chainDir) {
|
|
160
|
+
const chainPath = join(chainDir, CHAIN_FILENAME);
|
|
161
|
+
if (!existsSync(chainPath)) return;
|
|
162
|
+
let chain;
|
|
163
|
+
let tail;
|
|
164
|
+
try {
|
|
165
|
+
chain = readChain(chainPath);
|
|
166
|
+
tail = chainTailHash(chainDir, chain);
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const dest = join(chainDir, "archive", tail);
|
|
171
|
+
if (existsSync(dest)) return;
|
|
172
|
+
try {
|
|
173
|
+
mkdirSync(dest, { recursive: true });
|
|
174
|
+
copyFileSync(chainPath, join(dest, CHAIN_FILENAME));
|
|
175
|
+
for (const name of chain.receipts ?? []) {
|
|
176
|
+
const src = join(chainDir, name);
|
|
177
|
+
if (existsSync(src)) copyFileSync(src, join(dest, name));
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// best-effort audit trail; ingest proceeds regardless
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// `ingest --as period`: continue the chain's run history under a trusted signer.
|
|
185
|
+
function cmdIngestPeriod(values, file) {
|
|
186
|
+
// Like replace, compute the unsnapshotted-reset condition and preserve the
|
|
187
|
+
// prior chain before appendPeriod's ingest overwrites chain.json. A refused
|
|
188
|
+
// untrusted import leaves the prior chain untouched, so the archive is an
|
|
189
|
+
// idempotent no-op in that case.
|
|
190
|
+
const unsnapshottedReset = isUnsnapshottedReset(values.out);
|
|
191
|
+
archivePriorChain(values.out);
|
|
192
|
+
|
|
193
|
+
const trusted = (values.pub ?? []).map((p) => loadPublicKeyHex(p)).filter(Boolean);
|
|
194
|
+
let result;
|
|
195
|
+
try {
|
|
196
|
+
result = appendPeriod({
|
|
197
|
+
file,
|
|
198
|
+
declaredOrigin: values.origin,
|
|
199
|
+
chainDir: values.out,
|
|
200
|
+
keyPath: values.key,
|
|
201
|
+
trustedPubHexes: trusted,
|
|
202
|
+
band: values.band ?? null,
|
|
203
|
+
settle: values.settle ?? null,
|
|
204
|
+
bucketColumn: values["bucket-column"] ?? null,
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (err instanceof UntrustedSignerError) {
|
|
208
|
+
console.error(`✗ Refusing to append a period: ${err.message}`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
console.error(err.message);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
if (unsnapshottedReset) {
|
|
215
|
+
console.error("warning: previous run was never verified; its totals will not enter history");
|
|
216
|
+
}
|
|
217
|
+
const totals = result.manifest.control_totals;
|
|
218
|
+
console.log(`Imported next period: ${result.manifest.source.filename}`);
|
|
219
|
+
console.log(` evidence_hash ${result.manifest.source.evidence_hash}`);
|
|
220
|
+
console.log(` semantic_hash ${result.manifest.semantic_hash}`);
|
|
221
|
+
console.log(` rows ${totals.row_count}, columns ${totals.column_count}`);
|
|
222
|
+
const grouped = groupedNumericColumns(result.records);
|
|
223
|
+
if (grouped.length) {
|
|
224
|
+
console.error("");
|
|
225
|
+
for (const { column, example } of grouped) {
|
|
226
|
+
console.error(` warning: column "${column}" looks numeric (e.g. "${example}") but is missing from numeric_sums.`);
|
|
227
|
+
}
|
|
228
|
+
console.error(" Grouped numbers don't parse as plain decimals, so these columns are left out of the control totals'");
|
|
229
|
+
console.error(" numeric_sums -- a data-receipt-column on them can never flag a change. Add a normalize step that");
|
|
230
|
+
console.error(" strips the separators before ingest. Only plain decimals (no thousands grouping) are summed.");
|
|
231
|
+
}
|
|
232
|
+
if (result.caveats.length) {
|
|
233
|
+
console.log(" the light is yellow, a human should look:");
|
|
234
|
+
for (const caveat of result.caveats) console.log(` - ${caveat}`);
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
237
|
+
console.log(
|
|
238
|
+
result.compared
|
|
239
|
+
? " in band against the prior run (the light stays green)"
|
|
240
|
+
: " recorded as the first period (no prior run to compare)",
|
|
241
|
+
);
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
115
245
|
function cmdIngest(args) {
|
|
116
246
|
const { values, positionals } = parseArgs({
|
|
117
247
|
args,
|
|
@@ -123,6 +253,8 @@ function cmdIngest(args) {
|
|
|
123
253
|
band: { type: "string" },
|
|
124
254
|
settle: { type: "string" },
|
|
125
255
|
"bucket-column": { type: "string" },
|
|
256
|
+
as: { type: "string", default: "replace" },
|
|
257
|
+
pub: { type: "string", multiple: true, default: [] },
|
|
126
258
|
},
|
|
127
259
|
});
|
|
128
260
|
const file = positionals[0];
|
|
@@ -134,10 +266,13 @@ function cmdIngest(args) {
|
|
|
134
266
|
// The env var silently outranks --key; say so where it matters.
|
|
135
267
|
console.error("Signing with TAMPER_SIGNAL_KEY from the environment (overrides --key)");
|
|
136
268
|
}
|
|
269
|
+
if ((values.as ?? "replace") === "period") return cmdIngestPeriod(values, file);
|
|
137
270
|
// Compute the unsnapshotted-reset condition from the OUTGOING chain before
|
|
138
271
|
// ingestFile overwrites chain.json; emit the warning only after ingest
|
|
139
272
|
// validation passes (below), so an invalid flag exits 1 with no warning.
|
|
140
273
|
const unsnapshottedReset = isUnsnapshottedReset(values.out);
|
|
274
|
+
// Preserve the prior chain before the reset overwrites it (R9).
|
|
275
|
+
archivePriorChain(values.out);
|
|
141
276
|
// ingestFile resets the chain to a fresh source manifest; the same call is
|
|
142
277
|
// the programmatic entry point and the foundation of rebuildChain. Invalid
|
|
143
278
|
// tolerance values and a non-qualifying --bucket-column throw before
|
|
@@ -909,6 +1044,7 @@ function cmdExport(args) {
|
|
|
909
1044
|
options: {
|
|
910
1045
|
data: { type: "string" },
|
|
911
1046
|
out: { type: "string" },
|
|
1047
|
+
bundle: { type: "boolean" },
|
|
912
1048
|
},
|
|
913
1049
|
});
|
|
914
1050
|
const chainPath = positionals[0];
|
|
@@ -951,6 +1087,29 @@ function cmdExport(args) {
|
|
|
951
1087
|
return 1;
|
|
952
1088
|
}
|
|
953
1089
|
|
|
1090
|
+
if (values.bundle) {
|
|
1091
|
+
// Verified bundle: the original data file plus chain.json and its receipts,
|
|
1092
|
+
// packaged so a recipient can `tamper-signal verify chain.json` offline.
|
|
1093
|
+
// Entry bytes are stored verbatim (chain.json's receipt_hashes commit to the
|
|
1094
|
+
// raw receipt bytes), mirroring the on-disk chain_dir layout flat at the root.
|
|
1095
|
+
const dataName = basename(values.data);
|
|
1096
|
+
const receiptNames = chain.receipts ?? [];
|
|
1097
|
+
const entries = [
|
|
1098
|
+
{ name: "README.md", bytes: new TextEncoder().encode(BUNDLE_README) }, // verify instructions
|
|
1099
|
+
{ name: dataName, bytes: readFileSync(values.data) },
|
|
1100
|
+
{ name: CHAIN_FILENAME, bytes: readFileSync(chainPath) },
|
|
1101
|
+
...receiptNames.map((name) => ({ name, bytes: readFileSync(join(chainDir, name)) })),
|
|
1102
|
+
];
|
|
1103
|
+
const stem = dataName.replace(/\.[^.]+$/, "");
|
|
1104
|
+
const bundlePath = values.out || join(chainDir, `${stem}-verified.zip`);
|
|
1105
|
+
writeFileSync(bundlePath, makeStoredZip(entries));
|
|
1106
|
+
console.log(`Exported verified bundle: ${bundlePath}`);
|
|
1107
|
+
console.log(` data ${dataName}, ${receiptNames.length} receipts + ${CHAIN_FILENAME}`);
|
|
1108
|
+
console.log(` semantic_hash ${dataHash} (matches final receipt)`);
|
|
1109
|
+
console.log(` recipient: unzip, then \`tamper-signal verify ${CHAIN_FILENAME}\``);
|
|
1110
|
+
return 0;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
954
1113
|
const outPath = values.out || join(chainDir, "table.json");
|
|
955
1114
|
writeFileSync(outPath, JSON.stringify(document, null, 2) + "\n");
|
|
956
1115
|
console.log(`Exported verified table: ${outPath}`);
|
package/node/index.d.ts
CHANGED
|
@@ -446,6 +446,45 @@ export interface RebuildChainOptions {
|
|
|
446
446
|
*/
|
|
447
447
|
export function rebuildChain(options: RebuildChainOptions): Promise<DataRecord[]>;
|
|
448
448
|
|
|
449
|
+
/** Thrown by appendPeriod when the importer's key is not trusted by the chain. */
|
|
450
|
+
export class UntrustedSignerError extends Error {}
|
|
451
|
+
|
|
452
|
+
export interface AppendPeriodOptions {
|
|
453
|
+
/** Path to the source file (.csv/.tsv/.json/.ndjson/.jsonl). */
|
|
454
|
+
file: string;
|
|
455
|
+
declaredOrigin?: string;
|
|
456
|
+
chainDir?: string;
|
|
457
|
+
keyPath?: string;
|
|
458
|
+
/** Public key hexes to trust in addition to the chain's own key (--pub). */
|
|
459
|
+
trustedPubHexes?: string[];
|
|
460
|
+
/** Override the inherited tolerance band, e.g. "5%" or "0.05". */
|
|
461
|
+
band?: string | null;
|
|
462
|
+
/** Override the inherited settling window, e.g. "72h" or "3d". */
|
|
463
|
+
settle?: string | null;
|
|
464
|
+
/** Override the inherited bucket column. */
|
|
465
|
+
bucketColumn?: string | null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface AppendPeriodResult extends IngestFileResult {
|
|
469
|
+
/** Cross-run judgment caveats (yellow), empty when in band. */
|
|
470
|
+
caveats: string[];
|
|
471
|
+
/** Additive caveat detail records. */
|
|
472
|
+
details: unknown[];
|
|
473
|
+
/** Baseline-advancement guard recorded in the archived snapshot. */
|
|
474
|
+
breached: Record<string, string[]>;
|
|
475
|
+
/** True when a prior trusted snapshot was found to compare against. */
|
|
476
|
+
compared: boolean;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Import a file as the next period of an existing chain's run history. Continues
|
|
481
|
+
* history only under a trusted signer (the chain's key or one in
|
|
482
|
+
* `trustedPubHexes`); an untrusted signer throws UntrustedSignerError. Inherits
|
|
483
|
+
* the prior run's signed tolerance unless overridden, judges the new run against
|
|
484
|
+
* prior trusted snapshots, and archives the snapshot with the breached guard.
|
|
485
|
+
*/
|
|
486
|
+
export function appendPeriod(options: AppendPeriodOptions): AppendPeriodResult;
|
|
487
|
+
|
|
449
488
|
// --- CLI --json payload shapes ---------------------------------------------
|
|
450
489
|
// Documented contracts for the `diff --json` and `log --json` stdout payloads.
|
|
451
490
|
// Byte-identical across the Node and Python CLIs.
|
package/node/index.js
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Append-period import (U5): JS parity for continuing an existing chain's run
|
|
2
|
+
// history under a trusted signer, judging against prior snapshots; refusing an
|
|
3
|
+
// untrusted signer. Mirrors tests/test_append_period.py.
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import { archiveRunSnapshot } from "../history.js";
|
|
14
|
+
import { generateKeys, loadPrivateKey, publicHexFromPrivate } from "../keys.js";
|
|
15
|
+
import { loadReceipts, readChain } from "../receipts.js";
|
|
16
|
+
import { UntrustedSignerError, appendPeriod, ingestFile } from "../wrapper.js";
|
|
17
|
+
|
|
18
|
+
const P1 = "day,amount\n2026-05-01,10\n2026-05-02,20\n";
|
|
19
|
+
const P2_INBAND = "day,amount\n2026-05-01,10\n2026-05-02,20\n2026-05-03,30\n";
|
|
20
|
+
const P2_BREACH = "day,amount\n2026-05-01,1000\n2026-05-02,20\n";
|
|
21
|
+
|
|
22
|
+
// Keys + an ingested chain with a band and bucket column, plus a first-period
|
|
23
|
+
// snapshot archived to history/ for the next period to compare against.
|
|
24
|
+
function seedPeriodOne(csv = P1) {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "append-"));
|
|
26
|
+
generateKeys(dir);
|
|
27
|
+
const keyPath = join(dir, "signing.key");
|
|
28
|
+
const chainDir = join(dir, "receipts/");
|
|
29
|
+
const dataPath = join(dir, "data.csv");
|
|
30
|
+
writeFileSync(dataPath, csv);
|
|
31
|
+
ingestFile({ file: dataPath, declaredOrigin: "t", chainDir, keyPath, band: "5%", bucketColumn: "day" });
|
|
32
|
+
const chain = readChain(join(chainDir, "chain.json"));
|
|
33
|
+
const receipts = loadReceipts(chainDir);
|
|
34
|
+
const privateKey = loadPrivateKey(keyPath);
|
|
35
|
+
archiveRunSnapshot(chainDir, chain, receipts, {
|
|
36
|
+
privateKey,
|
|
37
|
+
trustedKeys: [publicHexFromPrivate(privateKey)],
|
|
38
|
+
});
|
|
39
|
+
return { dir, keyPath, chainDir, dataPath };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("appendPeriod under the chain key compares and inherits the band", () => {
|
|
43
|
+
const { keyPath, chainDir, dataPath } = seedPeriodOne();
|
|
44
|
+
writeFileSync(dataPath, P2_INBAND);
|
|
45
|
+
const res = appendPeriod({ file: dataPath, declaredOrigin: "t", chainDir, keyPath });
|
|
46
|
+
|
|
47
|
+
assert.equal(res.compared, true);
|
|
48
|
+
assert.equal(res.records.length, 3);
|
|
49
|
+
assert.equal(res.manifest.tolerance.band, "0.05");
|
|
50
|
+
assert.equal(res.manifest.tolerance.bucket_column, "day");
|
|
51
|
+
assert.deepEqual(res.caveats, []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("appendPeriod surfaces a moved settled bucket as a caveat", () => {
|
|
55
|
+
const { keyPath, chainDir, dataPath } = seedPeriodOne();
|
|
56
|
+
writeFileSync(dataPath, P2_BREACH);
|
|
57
|
+
const res = appendPeriod({ file: dataPath, declaredOrigin: "t", chainDir, keyPath });
|
|
58
|
+
|
|
59
|
+
assert.equal(res.compared, true);
|
|
60
|
+
assert.ok(res.caveats.length > 0, "expected a drift caveat for the changed settled bucket");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("appendPeriod refuses an untrusted signer and leaves the chain intact", () => {
|
|
64
|
+
const { dir, chainDir, dataPath } = seedPeriodOne();
|
|
65
|
+
const originalKey = readChain(join(chainDir, "chain.json")).public_key;
|
|
66
|
+
const otherDir = mkdtempSync(join(tmpdir(), "other-"));
|
|
67
|
+
generateKeys(otherDir);
|
|
68
|
+
writeFileSync(dataPath, P2_INBAND);
|
|
69
|
+
|
|
70
|
+
assert.throws(
|
|
71
|
+
() => appendPeriod({ file: dataPath, declaredOrigin: "t", chainDir, keyPath: join(otherDir, "signing.key") }),
|
|
72
|
+
UntrustedSignerError,
|
|
73
|
+
);
|
|
74
|
+
assert.equal(readChain(join(chainDir, "chain.json")).public_key, originalKey);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("appendPeriod trusts a signer passed via trustedPubHexes", () => {
|
|
78
|
+
const { chainDir, dataPath } = seedPeriodOne();
|
|
79
|
+
const otherDir = mkdtempSync(join(tmpdir(), "other-"));
|
|
80
|
+
generateKeys(otherDir);
|
|
81
|
+
const otherKeyPath = join(otherDir, "signing.key");
|
|
82
|
+
const otherHex = publicHexFromPrivate(loadPrivateKey(otherKeyPath));
|
|
83
|
+
writeFileSync(dataPath, P2_INBAND);
|
|
84
|
+
|
|
85
|
+
const res = appendPeriod({
|
|
86
|
+
file: dataPath,
|
|
87
|
+
declaredOrigin: "t",
|
|
88
|
+
chainDir,
|
|
89
|
+
keyPath: otherKeyPath,
|
|
90
|
+
trustedPubHexes: [otherHex],
|
|
91
|
+
});
|
|
92
|
+
assert.equal(res.compared, true);
|
|
93
|
+
assert.equal(readChain(join(chainDir, "chain.json")).public_key, otherHex);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the period CLI archives the prior chain and reports evidence_hash", () => {
|
|
97
|
+
const { keyPath, chainDir, dataPath } = seedPeriodOne();
|
|
98
|
+
writeFileSync(dataPath, P2_INBAND);
|
|
99
|
+
const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
100
|
+
const out = execFileSync(
|
|
101
|
+
"node",
|
|
102
|
+
[cli, "ingest", dataPath, "--origin", "t", "--as", "period", "--key", keyPath, "--out", chainDir],
|
|
103
|
+
{ encoding: "utf8" },
|
|
104
|
+
);
|
|
105
|
+
assert.match(out, /evidence_hash/); // parity with replace output
|
|
106
|
+
const archiveDir = join(chainDir, "archive");
|
|
107
|
+
assert.ok(existsSync(archiveDir) && readdirSync(archiveDir).length > 0, "prior chain archived");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the export --bundle CLI ships a README with verify instructions", () => {
|
|
111
|
+
const { chainDir, dataPath } = seedPeriodOne();
|
|
112
|
+
const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
113
|
+
execFileSync(
|
|
114
|
+
"node",
|
|
115
|
+
[cli, "export", join(chainDir, "chain.json"), "--data", dataPath, "--bundle"],
|
|
116
|
+
{ encoding: "utf8" },
|
|
117
|
+
);
|
|
118
|
+
// data.csv -> data-verified.zip beside the chain; entries are stored
|
|
119
|
+
// uncompressed, so the README text appears verbatim in the archive bytes.
|
|
120
|
+
const zip = readFileSync(join(chainDir, "data-verified.zip")).toString("latin1");
|
|
121
|
+
assert.ok(zip.includes("README.md"), "bundle lists README.md");
|
|
122
|
+
assert.ok(zip.includes("receipts verify chain.json"), "README carries verify instructions");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("appendPeriod refuses when no chain exists", () => {
|
|
126
|
+
const dir = mkdtempSync(join(tmpdir(), "append-empty-"));
|
|
127
|
+
generateKeys(dir);
|
|
128
|
+
const dataPath = join(dir, "data.csv");
|
|
129
|
+
writeFileSync(dataPath, P1);
|
|
130
|
+
assert.throws(
|
|
131
|
+
() => appendPeriod({ file: dataPath, declaredOrigin: "t", chainDir: join(dir, "receipts/"), keyPath: join(dir, "signing.key") }),
|
|
132
|
+
UntrustedSignerError,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The store-only zip writer used by `tamper-signal export --bundle`. The bundle
|
|
2
|
+
// must preserve entry bytes exactly (chain.json's receipt_hashes commit to raw
|
|
3
|
+
// receipt bytes), so these tests round-trip bytes through a minimal store-only
|
|
4
|
+
// reader and assert the archive structure is well-formed.
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
|
|
10
|
+
import { makeStoredZip } from "../zip.js";
|
|
11
|
+
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
const dec = new TextDecoder();
|
|
14
|
+
|
|
15
|
+
// Minimal store-only extractor: walk local file headers and slice out the
|
|
16
|
+
// (uncompressed) bytes. Sufficient to prove the writer's offsets and sizes.
|
|
17
|
+
function extractStored(zip) {
|
|
18
|
+
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
|
19
|
+
const out = {};
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i + 4 <= zip.length && view.getUint32(i, true) === 0x04034b50) {
|
|
22
|
+
const method = view.getUint16(i + 8, true);
|
|
23
|
+
const size = view.getUint32(i + 22, true);
|
|
24
|
+
const nameLen = view.getUint16(i + 26, true);
|
|
25
|
+
const extraLen = view.getUint16(i + 28, true);
|
|
26
|
+
const nameStart = i + 30;
|
|
27
|
+
const name = dec.decode(zip.subarray(nameStart, nameStart + nameLen));
|
|
28
|
+
const dataStart = nameStart + nameLen + extraLen;
|
|
29
|
+
assert.equal(method, 0, "entries are stored, not compressed");
|
|
30
|
+
out[name] = zip.subarray(dataStart, dataStart + size);
|
|
31
|
+
i = dataStart + size;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("makeStoredZip preserves entry bytes exactly", () => {
|
|
37
|
+
const entries = [
|
|
38
|
+
{ name: "report.csv", bytes: enc.encode("a,b\n1,2\n") },
|
|
39
|
+
{ name: "chain.json", bytes: enc.encode('{"receipts":["000_source.json"]}\n') },
|
|
40
|
+
{ name: "000_source.json", bytes: enc.encode('{"kind":"source"}\n') },
|
|
41
|
+
];
|
|
42
|
+
const zip = makeStoredZip(entries);
|
|
43
|
+
const extracted = extractStored(zip);
|
|
44
|
+
|
|
45
|
+
for (const { name, bytes } of entries) {
|
|
46
|
+
assert.deepEqual(extracted[name], bytes, `bytes round-trip for ${name}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("makeStoredZip writes a valid end-of-central-directory record", () => {
|
|
51
|
+
const entries = [{ name: "a.txt", bytes: enc.encode("hello") }];
|
|
52
|
+
const zip = makeStoredZip(entries);
|
|
53
|
+
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
|
54
|
+
|
|
55
|
+
// EOCD signature sits 22 bytes from the end (no archive comment).
|
|
56
|
+
const eocd = zip.length - 22;
|
|
57
|
+
assert.equal(view.getUint32(eocd, true), 0x06054b50, "EOCD signature present");
|
|
58
|
+
assert.equal(view.getUint16(eocd + 10, true), entries.length, "total entry count");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("makeStoredZip is deterministic for identical input", () => {
|
|
62
|
+
const entries = [{ name: "x", bytes: enc.encode("same") }];
|
|
63
|
+
assert.deepEqual(makeStoredZip(entries), makeStoredZip(entries));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The browser Data tab inlines its own copy of this writer (badge/table.js can't
|
|
67
|
+
// import from node/), so guard against the two drifting: the inline copy must
|
|
68
|
+
// produce byte-identical archives. This caught a central-directory date-field
|
|
69
|
+
// offset bug the DOM-less unit tests could not reach.
|
|
70
|
+
test("the inline badge/table.js ZIP writer matches node/zip.js byte for byte", async () => {
|
|
71
|
+
const src = readFileSync(new URL("../../badge/table.js", import.meta.url), "utf8");
|
|
72
|
+
const start = src.indexOf("const _CRC_TABLE");
|
|
73
|
+
const end = src.indexOf("function cellText");
|
|
74
|
+
assert.ok(start !== -1 && end > start, "could not locate the inline ZIP writer in badge/table.js");
|
|
75
|
+
const code = src.slice(start, end) + "\nexport { makeStoredZip };\n";
|
|
76
|
+
const inline = await import("data:text/javascript," + encodeURIComponent(code));
|
|
77
|
+
|
|
78
|
+
const entries = [
|
|
79
|
+
{ name: "report.csv", bytes: enc.encode("a,b\n1,2\n3,4\n") },
|
|
80
|
+
{ name: "chain.json", bytes: enc.encode('{"receipts":["000_source.json"]}\n') },
|
|
81
|
+
{ name: "000_source.json", bytes: enc.encode('{"kind":"source"}\n') },
|
|
82
|
+
];
|
|
83
|
+
assert.deepEqual(inline.makeStoredZip(entries), makeStoredZip(entries));
|
|
84
|
+
});
|
package/node/wrapper.js
CHANGED
|
@@ -286,3 +286,84 @@ export async function rebuildChain({
|
|
|
286
286
|
}
|
|
287
287
|
return current;
|
|
288
288
|
}
|
|
289
|
+
|
|
290
|
+
export class UntrustedSignerError extends Error {}
|
|
291
|
+
|
|
292
|
+
// Import a file as the next period of an existing chain's run history. Mirrors
|
|
293
|
+
// tamper_signal.wrapper.append_period: continues history only under a trusted
|
|
294
|
+
// signer (the chain's key or one passed via trustedPubHexes); a snapshot signed
|
|
295
|
+
// under an untrusted key would be silently dropped by verification, so an
|
|
296
|
+
// untrusted signer is refused rather than appended. Inherits the prior run's
|
|
297
|
+
// signed tolerance unless overridden, re-ingests the file, judges the new run
|
|
298
|
+
// against prior trusted snapshots, and archives the snapshot with the breached
|
|
299
|
+
// guard computed before the write.
|
|
300
|
+
export function appendPeriod({
|
|
301
|
+
file,
|
|
302
|
+
declaredOrigin = "",
|
|
303
|
+
chainDir = "receipts/",
|
|
304
|
+
keyPath = "keys/signing.key",
|
|
305
|
+
trustedPubHexes = [],
|
|
306
|
+
band = null,
|
|
307
|
+
settle = null,
|
|
308
|
+
bucketColumn = null,
|
|
309
|
+
} = {}) {
|
|
310
|
+
if (!file) throw new TypeError("appendPeriod requires a `file` path.");
|
|
311
|
+
const chainPath = join(chainDir, CHAIN_FILENAME);
|
|
312
|
+
let priorChain;
|
|
313
|
+
try {
|
|
314
|
+
priorChain = readChain(chainPath);
|
|
315
|
+
} catch {
|
|
316
|
+
throw new UntrustedSignerError(
|
|
317
|
+
`no existing chain at ${chainPath} to continue; use replace to start a chain`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const priorKey = priorChain.public_key;
|
|
321
|
+
const importerHex = publicHexFromPrivate(loadPrivateKey(keyPath));
|
|
322
|
+
const trusted = new Set([priorKey, ...trustedPubHexes].filter(Boolean));
|
|
323
|
+
if (!trusted.has(importerHex)) {
|
|
324
|
+
throw new UntrustedSignerError(
|
|
325
|
+
"append-period continues history only under a trusted signer; the importer key " +
|
|
326
|
+
"is neither the chain's key nor passed as trusted. Use replace to re-attest " +
|
|
327
|
+
"under a new identity, or pass the prior signer's public key.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Inherit the prior run's signed tolerance unless the caller overrides it.
|
|
332
|
+
if (band === null && settle === null && bucketColumn === null) {
|
|
333
|
+
let priorTol = null;
|
|
334
|
+
try {
|
|
335
|
+
priorTol = loadReceipts(chainDir)[0]?.tolerance ?? null;
|
|
336
|
+
} catch {
|
|
337
|
+
priorTol = null;
|
|
338
|
+
}
|
|
339
|
+
if (priorTol && typeof priorTol === "object") {
|
|
340
|
+
if (typeof priorTol.band === "string") band = priorTol.band;
|
|
341
|
+
if (Number.isInteger(priorTol.settle_hours)) settle = `${priorTol.settle_hours}h`;
|
|
342
|
+
if (typeof priorTol.bucket_column === "string") bucketColumn = priorTol.bucket_column;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const result = ingestFile({ file, declaredOrigin, chainDir, keyPath, band, settle, bucketColumn });
|
|
347
|
+
|
|
348
|
+
// Judge against prior trusted snapshots BEFORE archiving this run, so the
|
|
349
|
+
// breached guard is recorded in the snapshot we write.
|
|
350
|
+
const trustedKeys = [...new Set([...trusted, importerHex])].sort();
|
|
351
|
+
const chain = readChain(chainPath);
|
|
352
|
+
const receipts = loadReceipts(chainDir);
|
|
353
|
+
const items = loadSnapshots(chainDir, { trustedKeys });
|
|
354
|
+
const judgment = judgeCrossRun(receipts, chain, items.map((item) => item.snapshot));
|
|
355
|
+
const breached = Object.keys(judgment.breached).length ? judgment.breached : null;
|
|
356
|
+
archiveRunSnapshot(chainDir, chain, receipts, {
|
|
357
|
+
privateKey: loadPrivateKey(keyPath),
|
|
358
|
+
trustedKeys,
|
|
359
|
+
breached,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
...result,
|
|
364
|
+
caveats: judgment.caveats,
|
|
365
|
+
details: judgment.details,
|
|
366
|
+
breached: judgment.breached,
|
|
367
|
+
compared: items.length > 0,
|
|
368
|
+
};
|
|
369
|
+
}
|
package/node/zip.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Minimal store-only (no compression) ZIP writer. Pure JS, zero dependencies,
|
|
2
|
+
// usable in both Node and the browser. Entry bytes are stored verbatim: a
|
|
3
|
+
// verified bundle carries chain.json plus receipt files whose bytes are
|
|
4
|
+
// committed by chain.json's receipt_hashes, so any transform (compression,
|
|
5
|
+
// re-encoding, line-ending rewrite) would verify as a broken chain. Store-only
|
|
6
|
+
// keeps the bytes exact.
|
|
7
|
+
|
|
8
|
+
const CRC_TABLE = (() => {
|
|
9
|
+
const table = new Uint32Array(256);
|
|
10
|
+
for (let n = 0; n < 256; n++) {
|
|
11
|
+
let c = n;
|
|
12
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
table[n] = c >>> 0;
|
|
14
|
+
}
|
|
15
|
+
return table;
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
function crc32(bytes) {
|
|
19
|
+
let c = 0xffffffff;
|
|
20
|
+
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
21
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
|
|
26
|
+
// Fixed 1980-01-01 timestamp keeps the zip deterministic (same inputs -> same
|
|
27
|
+
// bytes), which matters for reproducible bundles and stable tests.
|
|
28
|
+
const DOS_TIME = 0;
|
|
29
|
+
const DOS_DATE = 0x21; // (year 1980 << 9) | (month 1 << 5) | day 1
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a store-only zip from `entries` ([{ name, bytes: Uint8Array }]).
|
|
33
|
+
* Returns the zip as a Uint8Array. Names use forward slashes; callers pass the
|
|
34
|
+
* exact in-archive path. Bytes are stored uncompressed and unmodified.
|
|
35
|
+
*/
|
|
36
|
+
export function makeStoredZip(entries) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
const central = [];
|
|
39
|
+
let offset = 0;
|
|
40
|
+
|
|
41
|
+
for (const { name, bytes } of entries) {
|
|
42
|
+
const nameBytes = encoder.encode(name);
|
|
43
|
+
const crc = crc32(bytes);
|
|
44
|
+
const size = bytes.length;
|
|
45
|
+
|
|
46
|
+
const local = new DataView(new ArrayBuffer(30));
|
|
47
|
+
local.setUint32(0, 0x04034b50, true); // local file header signature
|
|
48
|
+
local.setUint16(4, 20, true); // version needed to extract
|
|
49
|
+
local.setUint16(6, 0, true); // general purpose flags
|
|
50
|
+
local.setUint16(8, 0, true); // method 0 = store
|
|
51
|
+
local.setUint16(10, DOS_TIME, true);
|
|
52
|
+
local.setUint16(12, DOS_DATE, true);
|
|
53
|
+
local.setUint32(14, crc, true);
|
|
54
|
+
local.setUint32(18, size, true); // compressed size
|
|
55
|
+
local.setUint32(22, size, true); // uncompressed size
|
|
56
|
+
local.setUint16(26, nameBytes.length, true);
|
|
57
|
+
local.setUint16(28, 0, true); // extra field length
|
|
58
|
+
const localHeader = new Uint8Array(local.buffer);
|
|
59
|
+
parts.push(localHeader, nameBytes, bytes);
|
|
60
|
+
|
|
61
|
+
const cd = new DataView(new ArrayBuffer(46));
|
|
62
|
+
cd.setUint32(0, 0x02014b50, true); // central directory header signature
|
|
63
|
+
cd.setUint16(4, 20, true); // version made by
|
|
64
|
+
cd.setUint16(6, 20, true); // version needed to extract
|
|
65
|
+
cd.setUint16(8, 0, true); // flags
|
|
66
|
+
cd.setUint16(10, 0, true); // method 0 = store
|
|
67
|
+
cd.setUint16(12, DOS_TIME, true);
|
|
68
|
+
cd.setUint16(14, DOS_DATE, true);
|
|
69
|
+
cd.setUint32(16, crc, true);
|
|
70
|
+
cd.setUint32(20, size, true);
|
|
71
|
+
cd.setUint32(24, size, true);
|
|
72
|
+
cd.setUint16(28, nameBytes.length, true);
|
|
73
|
+
cd.setUint16(30, 0, true); // extra length
|
|
74
|
+
cd.setUint16(32, 0, true); // comment length
|
|
75
|
+
cd.setUint16(34, 0, true); // disk number start
|
|
76
|
+
cd.setUint16(36, 0, true); // internal attributes
|
|
77
|
+
cd.setUint32(38, 0, true); // external attributes
|
|
78
|
+
cd.setUint32(42, offset, true); // relative offset of local header
|
|
79
|
+
central.push(new Uint8Array(cd.buffer), nameBytes);
|
|
80
|
+
|
|
81
|
+
offset += localHeader.length + nameBytes.length + size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const centralStart = offset;
|
|
85
|
+
let centralSize = 0;
|
|
86
|
+
for (const c of central) centralSize += c.length;
|
|
87
|
+
|
|
88
|
+
const eocd = new DataView(new ArrayBuffer(22));
|
|
89
|
+
eocd.setUint32(0, 0x06054b50, true); // end of central directory signature
|
|
90
|
+
eocd.setUint16(4, 0, true); // number of this disk
|
|
91
|
+
eocd.setUint16(6, 0, true); // disk with central directory
|
|
92
|
+
eocd.setUint16(8, entries.length, true); // entries on this disk
|
|
93
|
+
eocd.setUint16(10, entries.length, true); // total entries
|
|
94
|
+
eocd.setUint32(12, centralSize, true);
|
|
95
|
+
eocd.setUint32(16, centralStart, true);
|
|
96
|
+
eocd.setUint16(20, 0, true); // comment length
|
|
97
|
+
|
|
98
|
+
const all = [...parts, ...central, new Uint8Array(eocd.buffer)];
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (const c of all) total += c.length;
|
|
101
|
+
const out = new Uint8Array(total);
|
|
102
|
+
let p = 0;
|
|
103
|
+
for (const c of all) {
|
|
104
|
+
out.set(c, p);
|
|
105
|
+
p += c.length;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamper-signal",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"AGENTS.md"
|
|
65
65
|
],
|
|
66
66
|
"scripts": {
|
|
67
|
-
"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 node/test/totals.test.js node/test/pipeline.test.js node/test/history.test.js node/test/diff.test.js node/test/log.test.js node/test/judgment.test.js node/test/theme.test.js"
|
|
67
|
+
"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 node/test/totals.test.js node/test/pipeline.test.js node/test/history.test.js node/test/diff.test.js node/test/log.test.js node/test/judgment.test.js node/test/theme.test.js node/test/zip.test.js node/test/append_period.test.js"
|
|
68
68
|
},
|
|
69
69
|
"engines": {
|
|
70
70
|
"node": ">=18.17"
|