tamper-signal 1.2.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 +235 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/badge/badge.js +436 -0
- package/badge/element.js +77 -0
- package/badge/light-react.js +26 -0
- package/badge/light.js +430 -0
- package/node/canonical.js +271 -0
- package/node/cli.js +142 -0
- package/node/index.js +48 -0
- package/node/keys.js +74 -0
- package/node/load.js +125 -0
- package/node/receipts.js +335 -0
- package/node/test/canonical.test.js +55 -0
- package/node/test/chain.test.js +97 -0
- package/node/test/interop.test.js +46 -0
- package/node/test/vectors.json +115 -0
- package/node/totals.js +134 -0
- package/node/wrapper.js +84 -0
- package/package.json +54 -0
package/node/receipts.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Receipt creation and chain verification: the Node port of
|
|
2
|
+
// tamper_signal/receipts.py. Chains written here verify with the Python CLI
|
|
3
|
+
// and the browser, and vice versa.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { canonicalJsonBytes } from "./canonical.js";
|
|
10
|
+
import { keyFingerprint, publicHexFromPrivate, sign, verify } from "./keys.js";
|
|
11
|
+
import { controlTotals, totalsDelta } from "./totals.js";
|
|
12
|
+
|
|
13
|
+
export const SPEC_VERSION = "1.1";
|
|
14
|
+
export const CHAIN_FILENAME = "chain.json";
|
|
15
|
+
export const SOURCE_RECEIPT_NAME = "000_source.json";
|
|
16
|
+
|
|
17
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
18
|
+
|
|
19
|
+
export function codeHashOf(fn) {
|
|
20
|
+
// Node has no inspect.getsource; the function's own toString() is the
|
|
21
|
+
// closest stable equivalent. Code hashes are per-receipt metadata, not
|
|
22
|
+
// chain links, so they never need to match across languages.
|
|
23
|
+
return createHash("sha256").update(String(fn), "utf-8").digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function signBody(body, privateKey) {
|
|
27
|
+
const message = canonicalJsonBytes(body);
|
|
28
|
+
const publicHex = publicHexFromPrivate(privateKey);
|
|
29
|
+
return {
|
|
30
|
+
...body,
|
|
31
|
+
signature: {
|
|
32
|
+
alg: "ed25519",
|
|
33
|
+
key_fingerprint: keyFingerprint(Buffer.from(publicHex, "hex")),
|
|
34
|
+
value: sign(privateKey, message),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildSourceManifest({
|
|
40
|
+
filename,
|
|
41
|
+
evidenceHash,
|
|
42
|
+
byteSize,
|
|
43
|
+
declaredOrigin,
|
|
44
|
+
semanticHash,
|
|
45
|
+
records,
|
|
46
|
+
privateKey,
|
|
47
|
+
createdAt,
|
|
48
|
+
}) {
|
|
49
|
+
return signBody(
|
|
50
|
+
{
|
|
51
|
+
kind: "source_manifest",
|
|
52
|
+
spec_version: SPEC_VERSION,
|
|
53
|
+
created_at: createdAt ?? nowIso(),
|
|
54
|
+
source: {
|
|
55
|
+
filename,
|
|
56
|
+
evidence_hash: evidenceHash,
|
|
57
|
+
byte_size: byteSize,
|
|
58
|
+
declared_origin: declaredOrigin,
|
|
59
|
+
},
|
|
60
|
+
semantic_hash: semanticHash,
|
|
61
|
+
control_totals: controlTotals(records),
|
|
62
|
+
},
|
|
63
|
+
privateKey
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildTransformReceipt({
|
|
68
|
+
name,
|
|
69
|
+
codeHash,
|
|
70
|
+
codeFile,
|
|
71
|
+
inputSemanticHash,
|
|
72
|
+
outputSemanticHash,
|
|
73
|
+
outputRecords,
|
|
74
|
+
privateKey,
|
|
75
|
+
createdAt,
|
|
76
|
+
}) {
|
|
77
|
+
return signBody(
|
|
78
|
+
{
|
|
79
|
+
kind: "transform_receipt",
|
|
80
|
+
spec_version: SPEC_VERSION,
|
|
81
|
+
created_at: createdAt ?? nowIso(),
|
|
82
|
+
transform: { name, code_hash: codeHash, code_file: codeFile },
|
|
83
|
+
input_semantic_hash: inputSemanticHash,
|
|
84
|
+
output_semantic_hash: outputSemanticHash,
|
|
85
|
+
output_control_totals: controlTotals(outputRecords),
|
|
86
|
+
},
|
|
87
|
+
privateKey
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Defensive hash accessors mirroring the Python side. -------------------
|
|
92
|
+
const MISSING_OUTPUT = "<missing-output-hash>";
|
|
93
|
+
const MISSING_INPUT = "<missing-input-hash>";
|
|
94
|
+
|
|
95
|
+
const strAt = (receipt, keys, fallback) => {
|
|
96
|
+
let cur = receipt;
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur) || !(key in cur)) return fallback;
|
|
99
|
+
cur = cur[key];
|
|
100
|
+
}
|
|
101
|
+
return typeof cur === "string" ? cur : fallback;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const kindOf = (r) => (r !== null && typeof r === "object" ? r.kind : undefined);
|
|
105
|
+
|
|
106
|
+
export function outputHashOf(receipt) {
|
|
107
|
+
if (kindOf(receipt) === "source_manifest") return strAt(receipt, ["semantic_hash"], MISSING_OUTPUT);
|
|
108
|
+
return strAt(receipt, ["output_semantic_hash"], MISSING_OUTPUT);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function inputHashOf(receipt) {
|
|
112
|
+
if (kindOf(receipt) === "source_manifest") return null;
|
|
113
|
+
return strAt(receipt, ["input_semantic_hash"], MISSING_INPUT);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function totalsOf(receipt) {
|
|
117
|
+
const key = kindOf(receipt) === "source_manifest" ? "control_totals" : "output_control_totals";
|
|
118
|
+
const value = receipt !== null && typeof receipt === "object" ? receipt[key] : undefined;
|
|
119
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function stageNameOf(receipt) {
|
|
123
|
+
if (kindOf(receipt) === "source_manifest") return "source";
|
|
124
|
+
return strAt(receipt, ["transform", "name"], "") || "<unknown>";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Persistence ------------------------------------------------------------
|
|
128
|
+
function receiptBody(receipt) {
|
|
129
|
+
const body = {};
|
|
130
|
+
for (const k of Object.keys(receipt)) if (k !== "signature") body[k] = receipt[k];
|
|
131
|
+
return body;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function writeReceipt(chainDir, filename, receipt) {
|
|
135
|
+
mkdirSync(chainDir, { recursive: true });
|
|
136
|
+
const path = join(chainDir, filename);
|
|
137
|
+
writeFileSync(path, JSON.stringify(receipt, null, 2) + "\n");
|
|
138
|
+
return path;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function readReceipt(chainDir, filename) {
|
|
142
|
+
// Receipt filenames come from chain.json, which is attacker-controlled.
|
|
143
|
+
const base = resolve(chainDir);
|
|
144
|
+
const target = resolve(base, filename);
|
|
145
|
+
if (dirname(target) !== base || basename(target) !== filename) {
|
|
146
|
+
throw new Error(`Unsafe receipt path outside chain directory: ${filename}`);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(readFileSync(target, "utf-8"));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw new Error(`Could not read receipt ${filename}: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function readChain(chainPath) {
|
|
156
|
+
return JSON.parse(readFileSync(chainPath, "utf-8"));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function writeChain(chainDir, receiptFiles, publicHex) {
|
|
160
|
+
mkdirSync(chainDir, { recursive: true });
|
|
161
|
+
const chain = { spec_version: SPEC_VERSION, public_key: publicHex, receipts: receiptFiles };
|
|
162
|
+
const path = join(chainDir, CHAIN_FILENAME);
|
|
163
|
+
writeFileSync(path, JSON.stringify(chain, null, 2) + "\n");
|
|
164
|
+
return path;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function readChainFiles(chainDir) {
|
|
168
|
+
const chainPath = join(chainDir, CHAIN_FILENAME);
|
|
169
|
+
if (!existsSync(chainPath)) return [];
|
|
170
|
+
return readChain(chainPath).receipts ?? [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function loadReceipts(chainDir) {
|
|
174
|
+
return readChainFiles(chainDir).map((name) => readReceipt(chainDir, name));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function nextReceiptFilename(chainDir, transformName) {
|
|
178
|
+
const index = readChainFiles(chainDir).length;
|
|
179
|
+
return `${String(index).padStart(3, "0")}_${transformName}.json`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Verification -----------------------------------------------------------
|
|
183
|
+
export function verifySignature(receipt, publicHex) {
|
|
184
|
+
const signature = receipt?.signature;
|
|
185
|
+
if (!signature || !("value" in signature)) return false;
|
|
186
|
+
let message;
|
|
187
|
+
try {
|
|
188
|
+
message = canonicalJsonBytes(receiptBody(receipt));
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return verify(publicHex, message, signature.value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function coverageGaps(receiptNames) {
|
|
196
|
+
const indices = [];
|
|
197
|
+
for (const name of receiptNames) {
|
|
198
|
+
const prefix = String(name).split("_", 1)[0];
|
|
199
|
+
if (!/^[0-9]{3}$/.test(prefix)) return [];
|
|
200
|
+
indices.push(parseInt(prefix, 10));
|
|
201
|
+
}
|
|
202
|
+
const gaps = [];
|
|
203
|
+
if (indices.length && indices[0] !== 0) {
|
|
204
|
+
gaps.push(`chain starts at ${String(indices[0]).padStart(3, "0")}, not 000`);
|
|
205
|
+
}
|
|
206
|
+
for (let i = 1; i < indices.length; i++) {
|
|
207
|
+
if (indices[i] !== indices[i - 1] + 1) {
|
|
208
|
+
gaps.push(
|
|
209
|
+
`numbering jumps ${String(indices[i - 1]).padStart(3, "0")} -> ${String(indices[i]).padStart(3, "0")}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return gaps;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const short = (value) =>
|
|
217
|
+
value === null || value === undefined
|
|
218
|
+
? "(none)"
|
|
219
|
+
: value.length <= 10
|
|
220
|
+
? value
|
|
221
|
+
: `${value.slice(0, 4)}...${value.slice(-2)}`;
|
|
222
|
+
|
|
223
|
+
// Mirrors tamper_signal/receipts.py verify_chain, including the yellow
|
|
224
|
+
// verdict. Returns { ok, verdict, caveats, lines, brokenLink }.
|
|
225
|
+
export function verifyChain(receipts, publicHex, dataSemanticHash = null, dataTotals = null, options = {}) {
|
|
226
|
+
const { chainPublicHex = null, receiptNames = null, warnDrift = false } = options;
|
|
227
|
+
const result = { ok: true, verdict: "green", caveats: [], lines: [], brokenLink: null };
|
|
228
|
+
const fail = (...lines) => {
|
|
229
|
+
result.ok = false;
|
|
230
|
+
result.verdict = "red";
|
|
231
|
+
result.lines.push(...lines);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (!receipts.length) {
|
|
235
|
+
fail("✗ CHAIN EMPTY: no receipts to verify");
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 1) Signatures, trusted key first, chain key as fallback.
|
|
240
|
+
const unrecognized = [];
|
|
241
|
+
const useFallback = Boolean(chainPublicHex) && chainPublicHex !== publicHex;
|
|
242
|
+
receipts.forEach((receipt, index) => {
|
|
243
|
+
if (verifySignature(receipt, publicHex)) return;
|
|
244
|
+
if (useFallback && verifySignature(receipt, chainPublicHex)) {
|
|
245
|
+
unrecognized.push(index);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
fail(`✗ SIGNATURE INVALID on receipt ${index} (${stageNameOf(receipt)})`);
|
|
249
|
+
});
|
|
250
|
+
if (!result.ok) return result;
|
|
251
|
+
if (unrecognized.length) {
|
|
252
|
+
const stages = unrecognized.map((i) => stageNameOf(receipts[i])).join(", ");
|
|
253
|
+
const fp = (hex) => {
|
|
254
|
+
try {
|
|
255
|
+
return keyFingerprint(Buffer.from(hex, "hex"));
|
|
256
|
+
} catch {
|
|
257
|
+
return "<malformed key>";
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
result.caveats.push(
|
|
261
|
+
`unrecognized signing key: ${unrecognized.length} receipt(s) (${stages}) verify under ` +
|
|
262
|
+
`the chain's embedded key ${fp(chainPublicHex)}, not the trusted key ${fp(publicHex)}`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 2) Links.
|
|
267
|
+
for (let index = 1; index < receipts.length; index++) {
|
|
268
|
+
const expected = outputHashOf(receipts[index - 1]);
|
|
269
|
+
const found = inputHashOf(receipts[index]);
|
|
270
|
+
if (found !== expected) {
|
|
271
|
+
result.brokenLink = index;
|
|
272
|
+
const delta = totalsDelta(totalsOf(receipts[index - 1]), totalsOf(receipts[index]));
|
|
273
|
+
fail(
|
|
274
|
+
`✗ CHAIN BROKEN at link ${index - 1} -> ${index} (${stageNameOf(receipts[index])})`,
|
|
275
|
+
` expected input hash ${short(expected)} (output of ${stageNameOf(receipts[index - 1])})`,
|
|
276
|
+
` found input hash ${short(found)}`,
|
|
277
|
+
` Control totals delta vs upstream: ${delta.length ? delta.join(", ") : "(no totals changes detected)"}`
|
|
278
|
+
);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3) Optional current-data check.
|
|
284
|
+
if (dataSemanticHash !== null) {
|
|
285
|
+
const final = receipts[receipts.length - 1];
|
|
286
|
+
const expected = outputHashOf(final);
|
|
287
|
+
if (dataSemanticHash !== expected) {
|
|
288
|
+
const delta = dataTotals !== null ? totalsDelta(totalsOf(final), dataTotals) : null;
|
|
289
|
+
fail(
|
|
290
|
+
`✗ DATA MISMATCH against final receipt (${stageNameOf(final)})`,
|
|
291
|
+
` expected output hash ${short(expected)}`,
|
|
292
|
+
` found data hash ${short(dataSemanticHash)}`,
|
|
293
|
+
` Control totals delta vs receipt: ${
|
|
294
|
+
delta === null
|
|
295
|
+
? "(pass the data records to see which values moved)"
|
|
296
|
+
: delta.length
|
|
297
|
+
? delta.join(", ")
|
|
298
|
+
: "(no totals changes detected)"
|
|
299
|
+
}`
|
|
300
|
+
);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 4) Yellow caveats; red findings above returned early.
|
|
306
|
+
for (const gap of coverageGaps(receiptNames ?? [])) {
|
|
307
|
+
result.caveats.push(`coverage gap: receipt ${gap}; a stage may have run without leaving a receipt`);
|
|
308
|
+
}
|
|
309
|
+
if (warnDrift) {
|
|
310
|
+
for (let index = 1; index < receipts.length; index++) {
|
|
311
|
+
const delta = totalsDelta(totalsOf(receipts[index - 1]), totalsOf(receipts[index]));
|
|
312
|
+
if (delta.length) {
|
|
313
|
+
result.caveats.push(
|
|
314
|
+
`totals drift at link ${index - 1} -> ${index} (${stageNameOf(receipts[index])}): ${delta.join(", ")}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const rows = totalsOf(receipts[receipts.length - 1]).row_count ?? "?";
|
|
321
|
+
const transforms = receipts.filter((r) => r.kind === "transform_receipt").length;
|
|
322
|
+
if (result.caveats.length) {
|
|
323
|
+
result.verdict = "yellow";
|
|
324
|
+
result.lines.push(
|
|
325
|
+
`⚠ CHAIN VERIFIES, WITH CAVEATS: ${receipts.length} receipts, ${transforms} transforms, final row_count ${rows}`
|
|
326
|
+
);
|
|
327
|
+
for (const caveat of result.caveats) result.lines.push(` - ${caveat}`);
|
|
328
|
+
result.lines.push(" A human should look.");
|
|
329
|
+
} else {
|
|
330
|
+
result.lines.push(
|
|
331
|
+
`✓ CHAIN INTACT: ${receipts.length} receipts, ${transforms} transforms, final row_count ${rows}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Golden-vector tests: vectors.json is generated by the Python side
|
|
2
|
+
// (tamper_signal/canonical.py, spec 1.1). Byte equality here is the
|
|
3
|
+
// cross-language contract; any failure is a bug in the Node port.
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
|
|
9
|
+
import { canonicalize, decimalToPlainString, parseDecimal, semanticHash } from "../canonical.js";
|
|
10
|
+
|
|
11
|
+
const { vectors } = JSON.parse(readFileSync(new URL("./vectors.json", import.meta.url), "utf-8"));
|
|
12
|
+
|
|
13
|
+
// {"__date__": "..."} markers become Date instances (midnight UTC); a
|
|
14
|
+
// {"__datetime__": "..."} marker carries a real timestamp.
|
|
15
|
+
function decode(records) {
|
|
16
|
+
return records.map((record) => {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [key, value] of Object.entries(record)) {
|
|
19
|
+
if (value !== null && typeof value === "object" && "__date__" in value) {
|
|
20
|
+
out[key] = new Date(`${value.__date__}T00:00:00Z`);
|
|
21
|
+
} else if (value !== null && typeof value === "object" && "__datetime__" in value) {
|
|
22
|
+
out[key] = new Date(value.__datetime__);
|
|
23
|
+
} else {
|
|
24
|
+
out[key] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const vector of vectors) {
|
|
32
|
+
test(`vector: ${vector.name}`, () => {
|
|
33
|
+
const records = decode(vector.records);
|
|
34
|
+
assert.equal(canonicalize(records).toString("utf-8"), vector.canonical);
|
|
35
|
+
assert.equal(semanticHash(records), vector.semantic_hash);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("decimal parsing accepts the forms Python's Decimal accepts", () => {
|
|
40
|
+
assert.equal(decimalToPlainString(parseDecimal("1.")), "1");
|
|
41
|
+
assert.equal(decimalToPlainString(parseDecimal(".5")), "0.5");
|
|
42
|
+
assert.equal(decimalToPlainString(parseDecimal("+30")), "30");
|
|
43
|
+
assert.equal(decimalToPlainString(parseDecimal("1e3")), "1000");
|
|
44
|
+
assert.equal(decimalToPlainString(parseDecimal("1E+20")), "100000000000000000000");
|
|
45
|
+
assert.equal(parseDecimal("Infinity"), null);
|
|
46
|
+
assert.equal(parseDecimal("NaN"), null);
|
|
47
|
+
assert.equal(parseDecimal("1_000"), null);
|
|
48
|
+
assert.equal(parseDecimal("0x10"), null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("half-even quantization at the sixth place", () => {
|
|
52
|
+
assert.equal(decimalToPlainString(parseDecimal("0.0000005")), "0"); // ties to even (0)
|
|
53
|
+
assert.equal(decimalToPlainString(parseDecimal("0.0000015")), "0.000002"); // ties to even (2)
|
|
54
|
+
assert.equal(decimalToPlainString(parseDecimal("-0.0000005")), "0"); // negative zero collapses
|
|
55
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// End-to-end in Node: keygen -> ingest CSV -> receiptStep transform ->
|
|
2
|
+
// verify green; tamper -> red; foreign input -> ChainTailMismatch.
|
|
3
|
+
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
|
|
10
|
+
import { evidenceHash, semanticHash } from "../canonical.js";
|
|
11
|
+
import { generateKeys, loadPrivateKey, publicHexFromPrivate } from "../keys.js";
|
|
12
|
+
import { loadCsv } from "../load.js";
|
|
13
|
+
import {
|
|
14
|
+
SOURCE_RECEIPT_NAME,
|
|
15
|
+
buildSourceManifest,
|
|
16
|
+
loadReceipts,
|
|
17
|
+
readReceipt,
|
|
18
|
+
verifyChain,
|
|
19
|
+
writeChain,
|
|
20
|
+
writeReceipt,
|
|
21
|
+
} from "../receipts.js";
|
|
22
|
+
import { ChainTailMismatch, receiptStep } from "../wrapper.js";
|
|
23
|
+
|
|
24
|
+
const CSV = "date,campaign_name,spend_usd\n2026-05-01,a,10.50\n2026-05-02,b,20.00\n2026-05-03,,5\n";
|
|
25
|
+
|
|
26
|
+
function setup() {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "tamper-signal-"));
|
|
28
|
+
const keys = join(dir, "keys");
|
|
29
|
+
const chainDir = join(dir, "receipts");
|
|
30
|
+
generateKeys(keys);
|
|
31
|
+
const privateKey = loadPrivateKey(join(keys, "signing.key"));
|
|
32
|
+
const publicHex = publicHexFromPrivate(privateKey);
|
|
33
|
+
|
|
34
|
+
const csvPath = join(dir, "export.csv");
|
|
35
|
+
writeFileSync(csvPath, CSV);
|
|
36
|
+
const records = loadCsv(csvPath);
|
|
37
|
+
const manifest = buildSourceManifest({
|
|
38
|
+
filename: "export.csv",
|
|
39
|
+
evidenceHash: evidenceHash(Buffer.from(CSV)),
|
|
40
|
+
byteSize: CSV.length,
|
|
41
|
+
declaredOrigin: "test export",
|
|
42
|
+
semanticHash: semanticHash(records),
|
|
43
|
+
records,
|
|
44
|
+
privateKey,
|
|
45
|
+
});
|
|
46
|
+
writeReceipt(chainDir, SOURCE_RECEIPT_NAME, manifest);
|
|
47
|
+
writeChain(chainDir, [SOURCE_RECEIPT_NAME], publicHex);
|
|
48
|
+
return { dir, keys, chainDir, records, publicHex };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("ingest -> wrapped transform -> verify green", async () => {
|
|
52
|
+
const { keys, chainDir, records, publicHex } = setup();
|
|
53
|
+
|
|
54
|
+
const clean = receiptStep((rows) => rows.filter((r) => r.campaign_name !== null), {
|
|
55
|
+
chainDir,
|
|
56
|
+
keyPath: join(keys, "signing.key"),
|
|
57
|
+
codeFile: "test/chain.test.js",
|
|
58
|
+
});
|
|
59
|
+
const output = await clean(records);
|
|
60
|
+
assert.equal(output.length, 2);
|
|
61
|
+
|
|
62
|
+
const receipts = loadReceipts(chainDir);
|
|
63
|
+
assert.equal(receipts.length, 2);
|
|
64
|
+
const result = verifyChain(receipts, publicHex);
|
|
65
|
+
assert.equal(result.verdict, "green");
|
|
66
|
+
// The receipt caught the silent row drop in its totals.
|
|
67
|
+
assert.equal(receipts[1].output_control_totals.row_count, 2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("tampered receipt turns the chain red", async () => {
|
|
71
|
+
const { keys, chainDir, records, publicHex } = setup();
|
|
72
|
+
const step = receiptStep((rows) => rows, { chainDir, keyPath: join(keys, "signing.key") });
|
|
73
|
+
await step(records);
|
|
74
|
+
|
|
75
|
+
const name = "001_transform.json";
|
|
76
|
+
const receipt = readReceipt(chainDir, name);
|
|
77
|
+
receipt.output_semantic_hash = "0".repeat(64); // tamper without re-signing
|
|
78
|
+
writeReceipt(chainDir, name, receipt);
|
|
79
|
+
|
|
80
|
+
const result = verifyChain(loadReceipts(chainDir), publicHex);
|
|
81
|
+
assert.equal(result.verdict, "red");
|
|
82
|
+
assert.match(result.lines[0], /SIGNATURE INVALID/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("foreign input is refused before the transform runs", async () => {
|
|
86
|
+
const { keys, chainDir } = setup();
|
|
87
|
+
let ran = false;
|
|
88
|
+
const step = receiptStep(
|
|
89
|
+
(rows) => {
|
|
90
|
+
ran = true;
|
|
91
|
+
return rows;
|
|
92
|
+
},
|
|
93
|
+
{ chainDir, keyPath: join(keys, "signing.key") }
|
|
94
|
+
);
|
|
95
|
+
await assert.rejects(step([{ not: "the chain tail" }]), ChainTailMismatch);
|
|
96
|
+
assert.equal(ran, false);
|
|
97
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Cross-language interop: the chains committed under examples/chains/ were
|
|
2
|
+
// created and signed by the Python package. The Node verifier must agree
|
|
3
|
+
// with the Python verdicts exactly.
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { loadReceipts, readChain, verifyChain } from "../receipts.js";
|
|
11
|
+
|
|
12
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
13
|
+
|
|
14
|
+
function verifyDir(dir, options = {}) {
|
|
15
|
+
const chain = readChain(join(dir, "chain.json"));
|
|
16
|
+
return verifyChain(loadReceipts(dir), chain.public_key, null, null, {
|
|
17
|
+
chainPublicHex: chain.public_key,
|
|
18
|
+
receiptNames: chain.receipts,
|
|
19
|
+
...options,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("python-signed intact chain verifies green", () => {
|
|
24
|
+
const result = verifyDir(join(repoRoot, "examples", "chains", "intact"));
|
|
25
|
+
assert.equal(result.verdict, "green");
|
|
26
|
+
assert.ok(result.ok);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("python-signed tampered chain breaks at link 1 -> 2", () => {
|
|
30
|
+
const result = verifyDir(join(repoRoot, "examples", "chains", "tampered"));
|
|
31
|
+
assert.equal(result.verdict, "red");
|
|
32
|
+
assert.equal(result.brokenLink, 2);
|
|
33
|
+
assert.match(result.lines.join("\n"), /CHAIN BROKEN at link 1 -> 2/);
|
|
34
|
+
assert.match(result.lines.join("\n"), /spend_usd/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("untrusted key yields the yellow verdict", () => {
|
|
38
|
+
const dir = join(repoRoot, "examples", "chains", "intact");
|
|
39
|
+
const chain = readChain(join(dir, "chain.json"));
|
|
40
|
+
const result = verifyChain(loadReceipts(dir), "ab".repeat(32), null, null, {
|
|
41
|
+
chainPublicHex: chain.public_key,
|
|
42
|
+
receiptNames: chain.receipts,
|
|
43
|
+
});
|
|
44
|
+
assert.equal(result.verdict, "yellow");
|
|
45
|
+
assert.match(result.caveats[0], /unrecognized signing key/);
|
|
46
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generated_by": "python tamper_signal.canonical (spec 1.1); regenerate with the script in this file's git history",
|
|
3
|
+
"vectors": [
|
|
4
|
+
{
|
|
5
|
+
"name": "mixed types, shuffled rows, numeric text, dates",
|
|
6
|
+
"records": [
|
|
7
|
+
{
|
|
8
|
+
"date": {
|
|
9
|
+
"__date__": "2026-05-03"
|
|
10
|
+
},
|
|
11
|
+
"campaign_name": "c",
|
|
12
|
+
"impressions": 300,
|
|
13
|
+
"spend_usd": 30.0,
|
|
14
|
+
"active": true
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"date": {
|
|
18
|
+
"__date__": "2026-05-01"
|
|
19
|
+
},
|
|
20
|
+
"campaign_name": "a",
|
|
21
|
+
"impressions": "100",
|
|
22
|
+
"spend_usd": "10.50",
|
|
23
|
+
"active": false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"date": {
|
|
27
|
+
"__date__": "2026-05-02"
|
|
28
|
+
},
|
|
29
|
+
"campaign_name": null,
|
|
30
|
+
"impressions": 200,
|
|
31
|
+
"spend_usd": 20.25,
|
|
32
|
+
"active": null
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"canonical": "{\"headers\":[\"active\",\"campaign_name\",\"date\",\"impressions\",\"spend_usd\"],\"rows\":[[null,null,\"2026-05-02\",\"200\",\"20.25\"],[false,\"a\",\"2026-05-01\",\"100\",\"10.5\"],[true,\"c\",\"2026-05-03\",\"300\",\"30\"]]}",
|
|
36
|
+
"semantic_hash": "64b96900ef4e99d550bc3382dcfc64afb39c26c247835366bd8e0d55107d1c8c"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "float artifacts, negative zero, big ints, exponent text",
|
|
40
|
+
"records": [
|
|
41
|
+
{
|
|
42
|
+
"x": 0.30000000000000004,
|
|
43
|
+
"y": -0.0,
|
|
44
|
+
"z": 1000000000000000,
|
|
45
|
+
"w": "1e3"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"x": 0.3,
|
|
49
|
+
"y": 0,
|
|
50
|
+
"z": "1000000000000000",
|
|
51
|
+
"w": 1000
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"canonical": "{\"headers\":[\"w\",\"x\",\"y\",\"z\"],\"rows\":[[\"1000\",\"0.3\",\"0\",\"1000000000000000\"],[\"1000\",\"0.3\",\"0\",\"1000000000000000\"]]}",
|
|
55
|
+
"semantic_hash": "ef0590333cb71cf5beca635897a16b6edcfd67ff0bfcc1c6c431bf95fe5d3a5d"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "unicode NFD input, whitespace trim, empty string vs null",
|
|
59
|
+
"records": [
|
|
60
|
+
{
|
|
61
|
+
"col": "café",
|
|
62
|
+
"note": " padded ",
|
|
63
|
+
"empty": "",
|
|
64
|
+
"missing": null
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"canonical": "{\"headers\":[\"col\",\"empty\",\"missing\",\"note\"],\"rows\":[[\"café\",\"\",null,\"padded\"]]}",
|
|
68
|
+
"semantic_hash": "844ff5f0d41ddc17ee9937a59e68a43e143954c17dc1e5d2bb700d6ed887ab74"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "header normalization and union of keys",
|
|
72
|
+
"records": [
|
|
73
|
+
{
|
|
74
|
+
"Total Spend (USD)": 10,
|
|
75
|
+
"Channel": "fb"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"total spend (usd)": "20.0",
|
|
79
|
+
"Channel": "ig",
|
|
80
|
+
"extra": "x"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"canonical": "{\"headers\":[\"channel\",\"extra\",\"total_spend_(usd)\"],\"rows\":[[\"fb\",null,\"10\"],[\"ig\",\"x\",\"20\"]]}",
|
|
84
|
+
"semantic_hash": "8a41089d5444c9ce8887f654c40a768ff0472f5e16638bedf3a432b28ef3b9b2"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "midnight datetime collapses to date; real timestamp keeps time",
|
|
88
|
+
"records": [
|
|
89
|
+
{
|
|
90
|
+
"a": {
|
|
91
|
+
"__datetime__": "2026-05-01T00:00:00Z"
|
|
92
|
+
},
|
|
93
|
+
"b": {
|
|
94
|
+
"__datetime__": "2026-05-01T13:45:09Z"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"canonical": "{\"headers\":[\"a\",\"b\"],\"rows\":[[\"2026-05-01\",\"2026-05-01T13:45:09Z\"]]}",
|
|
99
|
+
"semantic_hash": "9c7c3f0f2885b729f63f7c1f1130af8f09016c2c90739d71aa98e1f1ac31e31d"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "tiny values quantize half-even at six places",
|
|
103
|
+
"records": [
|
|
104
|
+
{
|
|
105
|
+
"a": "0.0000005",
|
|
106
|
+
"b": "0.0000015",
|
|
107
|
+
"c": 4e-07,
|
|
108
|
+
"d": "-0.0000005"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"canonical": "{\"headers\":[\"a\",\"b\",\"c\",\"d\"],\"rows\":[[\"0\",\"0.000002\",\"0\",\"0\"]]}",
|
|
112
|
+
"semantic_hash": "154bbdb4556dd3f8ad90a68c9c3c07c0a32c602c357da87db9bda04198e28325"
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|