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/totals.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Control totals: the Node port of tamper_signal/totals.py. Hashes say
|
|
2
|
+
// "broken"; totals say "how broken". Numeric sums use exact decimal math
|
|
3
|
+
// (BigInt) and serialize through the same quantizer as cell normalization.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
coerceDecimal,
|
|
7
|
+
decimalToPlainString,
|
|
8
|
+
normalizeCell,
|
|
9
|
+
normalizeHeader,
|
|
10
|
+
sumDecimals,
|
|
11
|
+
} from "./canonical.js";
|
|
12
|
+
|
|
13
|
+
const TYPE_THRESHOLD = 0.9;
|
|
14
|
+
|
|
15
|
+
function columnsOf(records) {
|
|
16
|
+
const columns = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
for (const record of records) {
|
|
19
|
+
for (const key of Object.keys(record)) {
|
|
20
|
+
const name = normalizeHeader(key);
|
|
21
|
+
if (!seen.has(name)) {
|
|
22
|
+
seen.add(name);
|
|
23
|
+
columns.push(name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return columns;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function controlTotals(records) {
|
|
31
|
+
const columns = columnsOf(records);
|
|
32
|
+
const normRecords = records.map((record) => {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, v] of Object.entries(record)) out[normalizeHeader(k)] = v;
|
|
35
|
+
return out;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const nullCounts = {};
|
|
39
|
+
const numericSums = {};
|
|
40
|
+
const dateRanges = {};
|
|
41
|
+
|
|
42
|
+
for (const column of columns) {
|
|
43
|
+
const nonNull = [];
|
|
44
|
+
let nulls = 0;
|
|
45
|
+
for (const record of normRecords) {
|
|
46
|
+
const value = record[column] ?? null;
|
|
47
|
+
if (normalizeCell(value) === null) nulls += 1;
|
|
48
|
+
else nonNull.push(value);
|
|
49
|
+
}
|
|
50
|
+
if (nulls > 0) nullCounts[column] = nulls;
|
|
51
|
+
if (!nonNull.length) continue;
|
|
52
|
+
|
|
53
|
+
const decimals = nonNull.map(coerceDecimal).filter((d) => d !== null);
|
|
54
|
+
if (decimals.length / nonNull.length >= TYPE_THRESHOLD) {
|
|
55
|
+
numericSums[column] = decimalToPlainString(sumDecimals(decimals));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Date detection is a type check on real Date instances, mirroring the
|
|
60
|
+
// Python rule that date-shaped strings stay strings.
|
|
61
|
+
const isoValues = [];
|
|
62
|
+
for (const value of nonNull) {
|
|
63
|
+
if (value instanceof Date) {
|
|
64
|
+
const normalized = normalizeCell(value);
|
|
65
|
+
if (typeof normalized === "string") isoValues.push(normalized);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (isoValues.length && isoValues.length / nonNull.length >= TYPE_THRESHOLD) {
|
|
69
|
+
isoValues.sort();
|
|
70
|
+
dateRanges[column] = { min: isoValues[0], max: isoValues[isoValues.length - 1] };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
row_count: records.length,
|
|
76
|
+
column_count: columns.length,
|
|
77
|
+
numeric_sums: numericSums,
|
|
78
|
+
date_ranges: dateRanges,
|
|
79
|
+
null_counts: nullCounts,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sortedUnion = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
84
|
+
|
|
85
|
+
// Human-legible lines describing only what changed, mirroring totals_delta
|
|
86
|
+
// (including the computed numeric diff, which badge.js omits).
|
|
87
|
+
export function totalsDelta(upstream, downstream) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
const upRows = upstream.row_count ?? 0;
|
|
90
|
+
const downRows = downstream.row_count ?? 0;
|
|
91
|
+
if (upRows !== downRows) {
|
|
92
|
+
const d = downRows - upRows;
|
|
93
|
+
lines.push(`row_count ${upRows} -> ${downRows} (${d >= 0 ? "+" : ""}${d})`);
|
|
94
|
+
}
|
|
95
|
+
const upCols = upstream.column_count ?? 0;
|
|
96
|
+
const downCols = downstream.column_count ?? 0;
|
|
97
|
+
if (upCols !== downCols) {
|
|
98
|
+
const d = downCols - upCols;
|
|
99
|
+
lines.push(`column_count ${upCols} -> ${downCols} (${d >= 0 ? "+" : ""}${d})`);
|
|
100
|
+
}
|
|
101
|
+
const us = upstream.numeric_sums ?? {};
|
|
102
|
+
const ds = downstream.numeric_sums ?? {};
|
|
103
|
+
for (const column of sortedUnion(us, ds)) {
|
|
104
|
+
const before = us[column];
|
|
105
|
+
const after = ds[column];
|
|
106
|
+
if (before === after) continue;
|
|
107
|
+
if (before !== undefined && after !== undefined) {
|
|
108
|
+
const beforeDec = coerceDecimal(before);
|
|
109
|
+
const afterDec = coerceDecimal(after);
|
|
110
|
+
if (beforeDec && afterDec) {
|
|
111
|
+
const diff = sumDecimals([afterDec, { v: -beforeDec.v, exp: beforeDec.exp }]);
|
|
112
|
+
lines.push(`${column} ${before} -> ${after} (${decimalToPlainString(diff)})`);
|
|
113
|
+
} else {
|
|
114
|
+
// Sums in receipt JSON are attacker-controlled; report without a diff.
|
|
115
|
+
lines.push(`${column} ${before} -> ${after}`);
|
|
116
|
+
}
|
|
117
|
+
} else if (after === undefined) {
|
|
118
|
+
lines.push(`${column} ${before} -> (removed)`);
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(`${column} (added) -> ${after}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const un = upstream.null_counts ?? {};
|
|
124
|
+
const dn = downstream.null_counts ?? {};
|
|
125
|
+
for (const column of sortedUnion(un, dn)) {
|
|
126
|
+
const before = un[column] ?? 0;
|
|
127
|
+
const after = dn[column] ?? 0;
|
|
128
|
+
if (before !== after) {
|
|
129
|
+
const d = after - before;
|
|
130
|
+
lines.push(`null_counts[${column}] ${before} -> ${after} (${d >= 0 ? "+" : ""}${d})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
package/node/wrapper.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// receiptStep: the Node equivalent of the @receipt_step decorator. Wrap any
|
|
2
|
+
// records -> records transform (sync or async) and every call verifies the
|
|
3
|
+
// existing chain, refuses inputs that do not descend from the chain tail,
|
|
4
|
+
// then signs and appends a receipt.
|
|
5
|
+
//
|
|
6
|
+
// const clean = receiptStep(
|
|
7
|
+
// (records) => records.filter((r) => r.campaign_name),
|
|
8
|
+
// { chainDir: "receipts/", keyPath: "keys/signing.key" }
|
|
9
|
+
// );
|
|
10
|
+
// const output = await clean(records);
|
|
11
|
+
|
|
12
|
+
import { semanticHash } from "./canonical.js";
|
|
13
|
+
import { loadPrivateKey, publicHexFromPrivate } from "./keys.js";
|
|
14
|
+
import {
|
|
15
|
+
buildTransformReceipt,
|
|
16
|
+
codeHashOf,
|
|
17
|
+
loadReceipts,
|
|
18
|
+
nextReceiptFilename,
|
|
19
|
+
outputHashOf,
|
|
20
|
+
readChainFiles,
|
|
21
|
+
verifyChain,
|
|
22
|
+
writeChain,
|
|
23
|
+
writeReceipt,
|
|
24
|
+
} from "./receipts.js";
|
|
25
|
+
|
|
26
|
+
export class ChainTailMismatch extends Error {}
|
|
27
|
+
|
|
28
|
+
function toRecords(data, context) {
|
|
29
|
+
if (Array.isArray(data)) return data;
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
`receiptStep ${context} must be an array of plain objects, got ${typeof data}.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function receiptStep(fn, { chainDir = "receipts/", keyPath = "keys/signing.key", codeFile = "<inline>", name } = {}) {
|
|
36
|
+
const stageName = name ?? (fn.name || "transform"); // anonymous fns have name ""
|
|
37
|
+
return async function wrapped(records, ...args) {
|
|
38
|
+
const existing = readChainFiles(chainDir);
|
|
39
|
+
if (!existing.length) {
|
|
40
|
+
throw new ChainTailMismatch(`No chain found in ${chainDir}; run \`tamper-signal ingest\` first.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const privateKey = loadPrivateKey(keyPath);
|
|
44
|
+
const publicHex = publicHexFromPrivate(privateKey);
|
|
45
|
+
|
|
46
|
+
// Verify the existing chain BEFORE extending it.
|
|
47
|
+
const receipts = loadReceipts(chainDir);
|
|
48
|
+
const chainResult = verifyChain(receipts, publicHex);
|
|
49
|
+
if (!chainResult.ok) {
|
|
50
|
+
throw new ChainTailMismatch(
|
|
51
|
+
"Existing chain failed verification; refusing to extend it:\n" + chainResult.lines.join("\n")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Assert the input descends from the chain tail BEFORE running.
|
|
56
|
+
const tailOutput = outputHashOf(receipts[receipts.length - 1]);
|
|
57
|
+
const inputHash = semanticHash(toRecords(records, "input"));
|
|
58
|
+
if (inputHash !== tailOutput) {
|
|
59
|
+
throw new ChainTailMismatch(
|
|
60
|
+
"Input data does not match the chain tail output hash.\n" +
|
|
61
|
+
` chain tail output: ${tailOutput}\n` +
|
|
62
|
+
` provided input: ${inputHash}\n` +
|
|
63
|
+
"Refusing to append a receipt for data that did not come from the previous stage."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const output = await fn(records, ...args);
|
|
68
|
+
const outputRecords = toRecords(output, "output");
|
|
69
|
+
|
|
70
|
+
const receipt = buildTransformReceipt({
|
|
71
|
+
name: stageName,
|
|
72
|
+
codeHash: codeHashOf(fn),
|
|
73
|
+
codeFile,
|
|
74
|
+
inputSemanticHash: inputHash,
|
|
75
|
+
outputSemanticHash: semanticHash(outputRecords),
|
|
76
|
+
outputRecords,
|
|
77
|
+
privateKey,
|
|
78
|
+
});
|
|
79
|
+
const filename = nextReceiptFilename(chainDir, stageName);
|
|
80
|
+
writeReceipt(chainDir, filename, receipt);
|
|
81
|
+
writeChain(chainDir, [...existing, filename], publicHex);
|
|
82
|
+
return output;
|
|
83
|
+
};
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tamper-signal",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Signed receipts for vibe-coded data pipelines. Proves nobody changed your data, and shows the exact link if they did.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://welovejeff.github.io/tamper-evident-verification/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/welovejeff/tamper-evident-verification.git"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./node/index.js",
|
|
14
|
+
"./light": "./badge/light.js",
|
|
15
|
+
"./badge": "./badge/badge.js",
|
|
16
|
+
"./element": "./badge/element.js",
|
|
17
|
+
"./react": "./badge/light-react.js"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"tamper-signal": "node/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"node/",
|
|
24
|
+
"badge/badge.js",
|
|
25
|
+
"badge/light.js",
|
|
26
|
+
"badge/element.js",
|
|
27
|
+
"badge/light-react.js",
|
|
28
|
+
"AGENTS.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test \"node/test/*.test.js\""
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.17"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=17"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"react": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"tamper-evident",
|
|
46
|
+
"data-integrity",
|
|
47
|
+
"provenance",
|
|
48
|
+
"ed25519",
|
|
49
|
+
"signed-receipts",
|
|
50
|
+
"data-pipelines",
|
|
51
|
+
"verification",
|
|
52
|
+
"hash-chain"
|
|
53
|
+
]
|
|
54
|
+
}
|