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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Canonicalization and semantic hashing: the Node port of
|
|
2
|
+
// tamper_signal/canonical.py. Byte-identical output is the contract; the
|
|
3
|
+
// golden vectors in test/vectors.json are generated by the Python side and
|
|
4
|
+
// any divergence is a bug here. No dependencies.
|
|
5
|
+
//
|
|
6
|
+
// Exact decimal arithmetic uses BigInt: a parsed decimal is {v, exp} meaning
|
|
7
|
+
// v * 10^exp with v a signed BigInt mantissa. Quantization to six places uses
|
|
8
|
+
// round-half-even, matching Python's Decimal(_QUANT, ROUND_HALF_EVEN).
|
|
9
|
+
//
|
|
10
|
+
// Known divergences from Python, accepted as out of contract:
|
|
11
|
+
// - Python's Decimal parses non-ASCII unicode digits; this port does not.
|
|
12
|
+
// - Python's str.strip()/\s and JS trim()/\s disagree on U+FEFF.
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Evidence hash
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export function evidenceHash(rawBytes) {
|
|
20
|
+
return createHash("sha256").update(rawBytes).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Exact decimals
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const DECIMAL_RE = /^[+-]?(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/;
|
|
27
|
+
|
|
28
|
+
// Parse decimal text -> {v: BigInt, exp: number} or null when not a finite
|
|
29
|
+
// plain number. Mirrors what survives Python's _coerce_decimal (Infinity and
|
|
30
|
+
// NaN parse there but are filtered as non-finite; here they never parse).
|
|
31
|
+
export function parseDecimal(text) {
|
|
32
|
+
const t = text.trim();
|
|
33
|
+
if (t === "") return null;
|
|
34
|
+
const m = DECIMAL_RE.exec(t);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
const neg = t[0] === "-";
|
|
37
|
+
const intPart = m[1] ?? "";
|
|
38
|
+
const fracPart = m[2] ?? m[3] ?? "";
|
|
39
|
+
const exp = (m[4] ? parseInt(m[4], 10) : 0) - fracPart.length;
|
|
40
|
+
const digits = (intPart + fracPart).replace(/^0+(?=\d)/, "") || "0";
|
|
41
|
+
let v = BigInt(digits);
|
|
42
|
+
if (neg) v = -v;
|
|
43
|
+
return { v, exp };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Value * 10^6 as a BigInt, rounded half-even. The only rounding site.
|
|
47
|
+
function quantizedUnits({ v, exp }) {
|
|
48
|
+
const neg = v < 0n;
|
|
49
|
+
let mant = neg ? -v : v;
|
|
50
|
+
const shift = exp + 6;
|
|
51
|
+
let units;
|
|
52
|
+
if (shift >= 0) {
|
|
53
|
+
units = mant * 10n ** BigInt(shift);
|
|
54
|
+
} else {
|
|
55
|
+
const divisor = 10n ** BigInt(-shift);
|
|
56
|
+
const q = mant / divisor;
|
|
57
|
+
const r = mant % divisor;
|
|
58
|
+
const twice = r * 2n;
|
|
59
|
+
if (twice > divisor) units = q + 1n;
|
|
60
|
+
else if (twice < divisor) units = q;
|
|
61
|
+
else units = q % 2n === 0n ? q : q + 1n; // tie: round to even
|
|
62
|
+
}
|
|
63
|
+
return neg ? -units : units;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Plain decimal string of a {v, exp} decimal: quantize to six places, strip
|
|
67
|
+
// trailing zeros, collapse all zero forms to "0", never scientific notation.
|
|
68
|
+
export function decimalToPlainString(dec) {
|
|
69
|
+
let units = quantizedUnits(dec);
|
|
70
|
+
if (units === 0n) return "0";
|
|
71
|
+
const neg = units < 0n;
|
|
72
|
+
if (neg) units = -units;
|
|
73
|
+
const whole = units / 1000000n;
|
|
74
|
+
const frac = (units % 1000000n).toString().padStart(6, "0").replace(/0+$/, "");
|
|
75
|
+
return (neg ? "-" : "") + whole.toString() + (frac ? "." + frac : "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Exact sum of decimals (used by totals): align mantissas to the smallest
|
|
79
|
+
// exponent. Returns {v, exp}.
|
|
80
|
+
export function sumDecimals(decimals) {
|
|
81
|
+
if (!decimals.length) return { v: 0n, exp: 0 };
|
|
82
|
+
const exp = Math.min(...decimals.map((d) => d.exp));
|
|
83
|
+
let v = 0n;
|
|
84
|
+
for (const d of decimals) v += d.v * 10n ** BigInt(d.exp - exp);
|
|
85
|
+
return { v, exp };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function coerceDecimal(value) {
|
|
89
|
+
if (typeof value === "boolean") return null;
|
|
90
|
+
if (typeof value === "bigint") return { v: value, exp: 0 };
|
|
91
|
+
if (typeof value === "number") {
|
|
92
|
+
if (!Number.isFinite(value)) return null;
|
|
93
|
+
return parseDecimal(String(value));
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === "string") return parseDecimal(value);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Header normalization
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export function normalizeHeader(header) {
|
|
103
|
+
const text = header === null || header === undefined ? "" : String(header);
|
|
104
|
+
return text.normalize("NFC").trim().toLowerCase().replace(/\s+/g, "_");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function normalizeHeaders(headers) {
|
|
108
|
+
const normalized = headers.map(normalizeHeader);
|
|
109
|
+
const counts = new Map();
|
|
110
|
+
for (const name of normalized) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
111
|
+
const dupes = [...counts.keys()].filter((n) => counts.get(n) > 1).sort();
|
|
112
|
+
if (dupes.length) {
|
|
113
|
+
throw new Error("Duplicate headers after normalization: " + dupes.join(", "));
|
|
114
|
+
}
|
|
115
|
+
return normalized;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Cell normalization
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
function normalizeString(text) {
|
|
122
|
+
return text.normalize("NFC").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatUtcDate(value) {
|
|
126
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
127
|
+
const date = `${pad(value.getUTCFullYear(), 4)}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`;
|
|
128
|
+
const midnight =
|
|
129
|
+
value.getUTCHours() === 0 &&
|
|
130
|
+
value.getUTCMinutes() === 0 &&
|
|
131
|
+
value.getUTCSeconds() === 0 &&
|
|
132
|
+
value.getUTCMilliseconds() === 0;
|
|
133
|
+
if (midnight) return date; // dates round-trip through xlsx as midnight datetimes
|
|
134
|
+
return `${date}T${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}Z`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizeCell(value) {
|
|
138
|
+
if (value === null || value === undefined) return null;
|
|
139
|
+
if (typeof value === "boolean") return value;
|
|
140
|
+
if (value instanceof Date) return formatUtcDate(value);
|
|
141
|
+
if (typeof value === "number") {
|
|
142
|
+
if (!Number.isFinite(value)) {
|
|
143
|
+
// Match Python's str(float) forms: inf / -inf / nan.
|
|
144
|
+
if (Number.isNaN(value)) return "nan";
|
|
145
|
+
return value > 0 ? "inf" : "-inf";
|
|
146
|
+
}
|
|
147
|
+
return decimalToPlainString(parseDecimal(String(value)));
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === "bigint") {
|
|
150
|
+
return decimalToPlainString({ v: value, exp: 0 });
|
|
151
|
+
}
|
|
152
|
+
if (typeof value === "string") {
|
|
153
|
+
// Numeric-looking text canonicalizes as the number it parses to (spec
|
|
154
|
+
// 1.1), so format round-trips that stringify numbers keep the hash.
|
|
155
|
+
const d = parseDecimal(value);
|
|
156
|
+
if (d !== null) return decimalToPlainString(d);
|
|
157
|
+
return normalizeString(value);
|
|
158
|
+
}
|
|
159
|
+
return normalizeString(String(value));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Records -> canonical bytes
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Code-point string comparison: Python compares str by code point, while JS
|
|
166
|
+
// default sort compares UTF-16 code units. They differ above U+FFFF.
|
|
167
|
+
export function codePointCompare(a, b) {
|
|
168
|
+
if (a === b) return 0;
|
|
169
|
+
const ai = a[Symbol.iterator]();
|
|
170
|
+
const bi = b[Symbol.iterator]();
|
|
171
|
+
for (;;) {
|
|
172
|
+
const an = ai.next();
|
|
173
|
+
const bn = bi.next();
|
|
174
|
+
if (an.done && bn.done) return 0;
|
|
175
|
+
if (an.done) return -1;
|
|
176
|
+
if (bn.done) return 1;
|
|
177
|
+
const ac = an.value.codePointAt(0);
|
|
178
|
+
const bc = bn.value.codePointAt(0);
|
|
179
|
+
if (ac !== bc) return ac < bc ? -1 : 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sortToken(cell) {
|
|
184
|
+
if (cell === null) return "0:";
|
|
185
|
+
if (typeof cell === "boolean") return "1:" + (cell ? "true" : "false");
|
|
186
|
+
return "2:" + cell;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function canonicalize(records) {
|
|
190
|
+
// Union of keys across records, first-seen order, then normalized.
|
|
191
|
+
const rawHeaders = [];
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
for (const record of records) {
|
|
194
|
+
for (const key of Object.keys(record)) {
|
|
195
|
+
const marker = normalizeHeader(key);
|
|
196
|
+
if (!seen.has(marker)) {
|
|
197
|
+
seen.add(marker);
|
|
198
|
+
rawHeaders.push(key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const headers = normalizeHeaders(rawHeaders);
|
|
203
|
+
|
|
204
|
+
const order = [...headers.keys()].sort((i, j) => codePointCompare(headers[i], headers[j]));
|
|
205
|
+
const sortedHeaders = order.map((i) => headers[i]);
|
|
206
|
+
|
|
207
|
+
const rows = records.map((record) => {
|
|
208
|
+
const normalizedRecord = {};
|
|
209
|
+
for (const [k, v] of Object.entries(record)) normalizedRecord[normalizeHeader(k)] = v;
|
|
210
|
+
return order.map((i) => normalizeCell(normalizedRecord[headers[i]] ?? null));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
rows.sort((ra, rb) => {
|
|
214
|
+
for (let i = 0; i < ra.length; i++) {
|
|
215
|
+
const c = codePointCompare(sortToken(ra[i]), sortToken(rb[i]));
|
|
216
|
+
if (c !== 0) return c;
|
|
217
|
+
}
|
|
218
|
+
return 0;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return canonicalJsonBytes({ headers: sortedHeaders, rows });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function semanticHash(records) {
|
|
225
|
+
return createHash("sha256").update(canonicalize(records)).digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Minimal RFC 8785 JCS serializer (receipt bodies and the data document)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
export function canonicalJsonBytes(value) {
|
|
232
|
+
const out = [];
|
|
233
|
+
jcsWrite(value, out);
|
|
234
|
+
return Buffer.from(out.join(""), "utf-8");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function jcsWrite(value, out) {
|
|
238
|
+
if (value === null) {
|
|
239
|
+
out.push("null");
|
|
240
|
+
} else if (value === true || value === false) {
|
|
241
|
+
out.push(value ? "true" : "false");
|
|
242
|
+
} else if (typeof value === "number") {
|
|
243
|
+
// Receipt bodies carry integer leaves only (row counts, byte sizes);
|
|
244
|
+
// data leaves were pre-stringified during cell normalization.
|
|
245
|
+
if (!Number.isSafeInteger(value)) throw new TypeError("JCS: non-integer number leaf");
|
|
246
|
+
out.push(String(value));
|
|
247
|
+
} else if (typeof value === "string") {
|
|
248
|
+
out.push(JSON.stringify(value)); // minimal escaping, matches the Python serializer
|
|
249
|
+
} else if (Array.isArray(value)) {
|
|
250
|
+
out.push("[");
|
|
251
|
+
value.forEach((item, i) => {
|
|
252
|
+
if (i) out.push(",");
|
|
253
|
+
jcsWrite(item, out);
|
|
254
|
+
});
|
|
255
|
+
out.push("]");
|
|
256
|
+
} else if (typeof value === "object") {
|
|
257
|
+
out.push("{");
|
|
258
|
+
const keys = Object.keys(value).sort(codePointCompare);
|
|
259
|
+
keys.forEach((key, i) => {
|
|
260
|
+
if (i) out.push(",");
|
|
261
|
+
out.push(JSON.stringify(key));
|
|
262
|
+
out.push(":");
|
|
263
|
+
jcsWrite(value[key], out);
|
|
264
|
+
});
|
|
265
|
+
out.push("}");
|
|
266
|
+
} else {
|
|
267
|
+
throw new TypeError(`JCS cannot serialize value of type ${typeof value}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { coerceDecimal };
|
package/node/cli.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The JavaScript CLI for Tamper Signal. Mirrors the Python `receipts` CLI
|
|
3
|
+
// (which owns the short name); this binary installs as `tamper-signal`.
|
|
4
|
+
//
|
|
5
|
+
// tamper-signal keygen --out keys/
|
|
6
|
+
// tamper-signal ingest export.csv --origin "..." --key keys/signing.key --out receipts/
|
|
7
|
+
// tamper-signal verify receipts/chain.json [--pub keys/signing.pub] [--data current.csv] [--warn-drift]
|
|
8
|
+
//
|
|
9
|
+
// Exit codes are the traffic light: 0 green, 1 red, 2 yellow.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { basename, dirname } from "node:path";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
|
|
16
|
+
import { evidenceHash, semanticHash } from "./canonical.js";
|
|
17
|
+
import { generateKeys, loadPrivateKey, loadPublicKeyHex, publicHexFromPrivate } from "./keys.js";
|
|
18
|
+
import { loadRecords } from "./load.js";
|
|
19
|
+
import {
|
|
20
|
+
SOURCE_RECEIPT_NAME,
|
|
21
|
+
buildSourceManifest,
|
|
22
|
+
readChain,
|
|
23
|
+
readReceipt,
|
|
24
|
+
verifyChain,
|
|
25
|
+
writeChain,
|
|
26
|
+
writeReceipt,
|
|
27
|
+
} from "./receipts.js";
|
|
28
|
+
import { controlTotals } from "./totals.js";
|
|
29
|
+
|
|
30
|
+
const USAGE = `usage: tamper-signal <command>
|
|
31
|
+
|
|
32
|
+
commands:
|
|
33
|
+
keygen --out keys/ generate an Ed25519 signing keypair
|
|
34
|
+
ingest <file> --origin "..." [--key keys/signing.key] [--out receipts/]
|
|
35
|
+
create a signed source manifest
|
|
36
|
+
(.csv, .tsv, .json, .ndjson)
|
|
37
|
+
verify <chain.json> [--pub key.pub] [--data <file>] [--warn-drift]
|
|
38
|
+
verify a chain (exit 0 green, 1 red, 2 yellow)
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function cmdKeygen(args) {
|
|
42
|
+
const { values } = parseArgs({ args, options: { out: { type: "string", default: "keys/" } } });
|
|
43
|
+
const { privatePath, publicPath } = generateKeys(values.out);
|
|
44
|
+
console.log(`Public key written to ${publicPath}`);
|
|
45
|
+
console.error(`Private key written to ${privatePath}. Do not commit it.`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cmdIngest(args) {
|
|
50
|
+
const { values, positionals } = parseArgs({
|
|
51
|
+
args,
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
options: {
|
|
54
|
+
origin: { type: "string", default: "" },
|
|
55
|
+
key: { type: "string", default: "keys/signing.key" },
|
|
56
|
+
out: { type: "string", default: "receipts/" },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const file = positionals[0];
|
|
60
|
+
if (!file) {
|
|
61
|
+
console.error("ingest: missing source file");
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const raw = readFileSync(file);
|
|
65
|
+
const records = loadRecords(file);
|
|
66
|
+
const privateKey = loadPrivateKey(values.key);
|
|
67
|
+
const manifest = buildSourceManifest({
|
|
68
|
+
filename: basename(file),
|
|
69
|
+
evidenceHash: evidenceHash(raw),
|
|
70
|
+
byteSize: raw.length,
|
|
71
|
+
declaredOrigin: values.origin,
|
|
72
|
+
semanticHash: semanticHash(records),
|
|
73
|
+
records,
|
|
74
|
+
privateKey,
|
|
75
|
+
});
|
|
76
|
+
writeReceipt(values.out, SOURCE_RECEIPT_NAME, manifest);
|
|
77
|
+
writeChain(values.out, [SOURCE_RECEIPT_NAME], publicHexFromPrivate(privateKey));
|
|
78
|
+
const totals = manifest.control_totals;
|
|
79
|
+
console.log(`Ingested ${basename(file)}`);
|
|
80
|
+
console.log(` evidence_hash ${manifest.source.evidence_hash}`);
|
|
81
|
+
console.log(` semantic_hash ${manifest.semantic_hash}`);
|
|
82
|
+
console.log(` rows ${totals.row_count}, columns ${totals.column_count}`);
|
|
83
|
+
console.log(` source manifest -> ${values.out.replace(/\/?$/, "/")}${SOURCE_RECEIPT_NAME}`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cmdVerify(args) {
|
|
88
|
+
const { values, positionals } = parseArgs({
|
|
89
|
+
args,
|
|
90
|
+
allowPositionals: true,
|
|
91
|
+
options: {
|
|
92
|
+
pub: { type: "string" },
|
|
93
|
+
data: { type: "string" },
|
|
94
|
+
"warn-drift": { type: "boolean", default: false },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const chainPath = positionals[0];
|
|
98
|
+
if (!chainPath) {
|
|
99
|
+
console.error("verify: missing path to chain.json");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const chain = readChain(chainPath);
|
|
103
|
+
const chainDir = dirname(chainPath);
|
|
104
|
+
let receipts;
|
|
105
|
+
try {
|
|
106
|
+
receipts = (chain.receipts ?? []).map((name) => readReceipt(chainDir, name));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error(`Cannot load chain: ${err.message}`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const chainKey = chain.public_key;
|
|
113
|
+
const publicHex = values.pub ? loadPublicKeyHex(values.pub) : chainKey;
|
|
114
|
+
if (!publicHex) {
|
|
115
|
+
console.error("No public key: pass --pub or embed one in chain.json");
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let dataHash = null;
|
|
120
|
+
let dataTotals = null;
|
|
121
|
+
if (values.data) {
|
|
122
|
+
const records = loadRecords(values.data);
|
|
123
|
+
dataHash = semanticHash(records);
|
|
124
|
+
dataTotals = controlTotals(records);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = verifyChain(receipts, publicHex, dataHash, dataTotals, {
|
|
128
|
+
chainPublicHex: chainKey,
|
|
129
|
+
receiptNames: chain.receipts ?? [],
|
|
130
|
+
warnDrift: values["warn-drift"],
|
|
131
|
+
});
|
|
132
|
+
for (const line of result.lines) console.log(line);
|
|
133
|
+
return { green: 0, red: 1, yellow: 2 }[result.verdict];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const [, , command, ...rest] = process.argv;
|
|
137
|
+
const commands = { keygen: cmdKeygen, ingest: cmdIngest, verify: cmdVerify };
|
|
138
|
+
if (!command || !(command in commands)) {
|
|
139
|
+
console.error(USAGE);
|
|
140
|
+
process.exit(command ? 1 : 0);
|
|
141
|
+
}
|
|
142
|
+
process.exit(commands[command](rest));
|
package/node/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Tamper Signal for JavaScript pipelines: signed receipts at every stage,
|
|
2
|
+
// with verifiable continuity. Chains written here verify with the Python CLI
|
|
3
|
+
// and the in-browser signal, and vice versa; the canonicalization is
|
|
4
|
+
// byte-identical (see test/vectors.json).
|
|
5
|
+
//
|
|
6
|
+
// This proves continuity, not correctness. It can't tell you the data is
|
|
7
|
+
// right, but it can prove nobody changed it.
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
canonicalize,
|
|
11
|
+
canonicalJsonBytes,
|
|
12
|
+
evidenceHash,
|
|
13
|
+
normalizeHeader,
|
|
14
|
+
normalizeHeaders,
|
|
15
|
+
semanticHash,
|
|
16
|
+
} from "./canonical.js";
|
|
17
|
+
export {
|
|
18
|
+
generateKeys,
|
|
19
|
+
keyFingerprint,
|
|
20
|
+
loadPrivateKey,
|
|
21
|
+
loadPublicKeyHex,
|
|
22
|
+
publicHexFromPrivate,
|
|
23
|
+
sign,
|
|
24
|
+
verify,
|
|
25
|
+
} from "./keys.js";
|
|
26
|
+
export { loadCsv, loadJsonRecords, loadNdjson, loadRecords, parseCsv } from "./load.js";
|
|
27
|
+
export {
|
|
28
|
+
CHAIN_FILENAME,
|
|
29
|
+
SOURCE_RECEIPT_NAME,
|
|
30
|
+
SPEC_VERSION,
|
|
31
|
+
buildSourceManifest,
|
|
32
|
+
buildTransformReceipt,
|
|
33
|
+
codeHashOf,
|
|
34
|
+
inputHashOf,
|
|
35
|
+
loadReceipts,
|
|
36
|
+
outputHashOf,
|
|
37
|
+
readChain,
|
|
38
|
+
readChainFiles,
|
|
39
|
+
readReceipt,
|
|
40
|
+
stageNameOf,
|
|
41
|
+
totalsOf,
|
|
42
|
+
verifyChain,
|
|
43
|
+
verifySignature,
|
|
44
|
+
writeChain,
|
|
45
|
+
writeReceipt,
|
|
46
|
+
} from "./receipts.js";
|
|
47
|
+
export { controlTotals, totalsDelta } from "./totals.js";
|
|
48
|
+
export { ChainTailMismatch, receiptStep } from "./wrapper.js";
|
package/node/keys.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Ed25519 keys: the Node port of tamper_signal/keys.py. Same on-disk formats
|
|
2
|
+
// (PKCS8 PEM private key, raw 32-byte hex public key) so the two CLIs are
|
|
3
|
+
// interchangeable on one project.
|
|
4
|
+
|
|
5
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify } from "node:crypto";
|
|
6
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const PRIVATE_KEY_NAME = "signing.key";
|
|
10
|
+
export const PUBLIC_KEY_NAME = "signing.pub";
|
|
11
|
+
|
|
12
|
+
// SPKI DER for Ed25519 is a fixed 12-byte header followed by the raw key.
|
|
13
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
14
|
+
|
|
15
|
+
export function keyFingerprint(publicKeyBytes) {
|
|
16
|
+
return createHash("sha256").update(publicKeyBytes).digest("hex").slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rawFromPublicKey(publicKey) {
|
|
20
|
+
const der = publicKey.export({ type: "spki", format: "der" });
|
|
21
|
+
return der.subarray(der.length - 32);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function publicKeyFromHex(publicHex) {
|
|
25
|
+
const raw = Buffer.from(publicHex, "hex");
|
|
26
|
+
if (raw.length !== 32) throw new Error("Ed25519 public key must be 32 bytes");
|
|
27
|
+
return createPublicKey({
|
|
28
|
+
key: Buffer.concat([SPKI_PREFIX, raw]),
|
|
29
|
+
format: "der",
|
|
30
|
+
type: "spki",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function generateKeys(outDir) {
|
|
35
|
+
mkdirSync(outDir, { recursive: true });
|
|
36
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
37
|
+
const privatePath = join(outDir, PRIVATE_KEY_NAME);
|
|
38
|
+
const publicPath = join(outDir, PUBLIC_KEY_NAME);
|
|
39
|
+
writeFileSync(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }));
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(privatePath, 0o600);
|
|
42
|
+
} catch {
|
|
43
|
+
// best effort, matching the Python side
|
|
44
|
+
}
|
|
45
|
+
writeFileSync(publicPath, rawFromPublicKey(publicKey).toString("hex") + "\n");
|
|
46
|
+
return { privatePath, publicPath };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadPrivateKey(path) {
|
|
50
|
+
return createPrivateKey(readFileSync(path));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadPublicKeyHex(path) {
|
|
54
|
+
return readFileSync(path, "utf-8").trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function publicHexFromPrivate(privateKey) {
|
|
58
|
+
return rawFromPublicKey(createPublicKey(privateKey)).toString("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sign(privateKey, message) {
|
|
62
|
+
return cryptoSign(null, message, privateKey).toString("hex");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns false (never throws) for bad signatures OR malformed inputs:
|
|
66
|
+
// receipt JSON is attacker-controlled in the tamper-evident model.
|
|
67
|
+
export function verify(publicHex, message, signatureHex) {
|
|
68
|
+
try {
|
|
69
|
+
if (typeof signatureHex !== "string" || !/^[0-9a-fA-F]+$/.test(signatureHex)) return false;
|
|
70
|
+
return cryptoVerify(null, message, publicKeyFromHex(publicHex), Buffer.from(signatureHex, "hex"));
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/node/load.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Data file loading for the Node side: CSV, TSV, JSON (array of objects),
|
|
2
|
+
// and NDJSON. No xlsx in JavaScript; point the Python CLI at spreadsheets.
|
|
3
|
+
// Empty CSV cells load as null so the same data hashes identically to its
|
|
4
|
+
// xlsx form, mirroring tamper_signal/canonical.py's loaders.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { extname } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { normalizeHeaders } from "./canonical.js";
|
|
10
|
+
|
|
11
|
+
function stripBom(text) {
|
|
12
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Minimal RFC 4180 CSV parser: quoted fields, escaped quotes, embedded
|
|
16
|
+
// newlines and delimiters. Returns an array of string-array rows.
|
|
17
|
+
export function parseCsv(text, delimiter = ",") {
|
|
18
|
+
const rows = [];
|
|
19
|
+
let row = [];
|
|
20
|
+
let field = "";
|
|
21
|
+
let inQuotes = false;
|
|
22
|
+
let i = 0;
|
|
23
|
+
const push = () => {
|
|
24
|
+
row.push(field);
|
|
25
|
+
field = "";
|
|
26
|
+
};
|
|
27
|
+
const endRow = () => {
|
|
28
|
+
push();
|
|
29
|
+
rows.push(row);
|
|
30
|
+
row = [];
|
|
31
|
+
};
|
|
32
|
+
while (i < text.length) {
|
|
33
|
+
const ch = text[i];
|
|
34
|
+
if (inQuotes) {
|
|
35
|
+
if (ch === '"') {
|
|
36
|
+
if (text[i + 1] === '"') {
|
|
37
|
+
field += '"';
|
|
38
|
+
i += 2;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
inQuotes = false;
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
field += ch;
|
|
46
|
+
i += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === '"' && field === "") {
|
|
50
|
+
inQuotes = true;
|
|
51
|
+
i += 1;
|
|
52
|
+
} else if (ch === delimiter) {
|
|
53
|
+
push();
|
|
54
|
+
i += 1;
|
|
55
|
+
} else if (ch === "\r") {
|
|
56
|
+
i += text[i + 1] === "\n" ? 2 : 1;
|
|
57
|
+
endRow();
|
|
58
|
+
} else if (ch === "\n") {
|
|
59
|
+
i += 1;
|
|
60
|
+
endRow();
|
|
61
|
+
} else {
|
|
62
|
+
field += ch;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (field !== "" || row.length) endRow();
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadCsv(path, { delimiter = "," } = {}) {
|
|
71
|
+
const rows = parseCsv(stripBom(readFileSync(path, "utf-8")), delimiter);
|
|
72
|
+
if (!rows.length) return [];
|
|
73
|
+
const headers = normalizeHeaders(rows[0]);
|
|
74
|
+
const records = [];
|
|
75
|
+
for (const raw of rows.slice(1)) {
|
|
76
|
+
if (!raw.length || raw.every((cell) => cell === "")) continue;
|
|
77
|
+
const record = {};
|
|
78
|
+
headers.forEach((header, i) => {
|
|
79
|
+
const cell = raw[i] ?? "";
|
|
80
|
+
record[header] = cell === "" ? null : cell;
|
|
81
|
+
});
|
|
82
|
+
records.push(record);
|
|
83
|
+
}
|
|
84
|
+
return records;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function loadJsonRecords(path) {
|
|
88
|
+
const data = JSON.parse(stripBom(readFileSync(path, "utf-8")));
|
|
89
|
+
if (!Array.isArray(data) || !data.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) {
|
|
90
|
+
throw new Error(`${path}: expected a JSON array of objects (for line-delimited JSON use .ndjson/.jsonl)`);
|
|
91
|
+
}
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function loadNdjson(path) {
|
|
96
|
+
const records = [];
|
|
97
|
+
stripBom(readFileSync(path, "utf-8"))
|
|
98
|
+
.split("\n")
|
|
99
|
+
.forEach((line, index) => {
|
|
100
|
+
const text = line.trim();
|
|
101
|
+
if (!text) return;
|
|
102
|
+
const item = JSON.parse(text);
|
|
103
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
104
|
+
throw new Error(`${path}:${index + 1}: expected a JSON object per line`);
|
|
105
|
+
}
|
|
106
|
+
records.push(item);
|
|
107
|
+
});
|
|
108
|
+
return records;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadRecords(path) {
|
|
112
|
+
const ext = extname(path).toLowerCase();
|
|
113
|
+
if (ext === ".csv") return loadCsv(path);
|
|
114
|
+
if (ext === ".tsv") return loadCsv(path, { delimiter: "\t" });
|
|
115
|
+
if (ext === ".json") return loadJsonRecords(path);
|
|
116
|
+
if (ext === ".ndjson" || ext === ".jsonl") return loadNdjson(path);
|
|
117
|
+
if (ext === ".xlsx" || ext === ".xlsm") {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${path}: xlsx is not supported by the JavaScript package; ingest spreadsheets ` +
|
|
120
|
+
"with the Python CLI (pip install tamper-signal), or export to CSV first. " +
|
|
121
|
+
"The semantic hash is identical across formats."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Unsupported data file ${path}: expected .csv, .tsv, .json, .ndjson, or .jsonl`);
|
|
125
|
+
}
|