chainfess 0.0.0-stage → 0.1.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/src/sign.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // `chainfess sign` and `chainfess verify` (BRIEF §3.1, §5). Node >= 20, no dependencies.
2
+ import { createHash, createPublicKey, verify as edVerify } from 'node:crypto';
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { canonicalJSON } from './canonical.mjs';
5
+ import { PUBLIC_KEY_PEM } from './pubkey.mjs';
6
+
7
+ const DEFAULT_SERVER = 'https://chainfess.pro';
8
+ export const PRELAUNCH_LINE =
9
+ 'The signed-report service is not enabled yet. It switches on with the $CFESS contract address; see https://chainfess.pro/token. The free audit never needs it.';
10
+
11
+ const normPem = (s) => String(s).replace(/\s+/g, '');
12
+ const sameKey = (a, b) => normPem(a) === normPem(b);
13
+
14
+ function digestOf(statement) {
15
+ return createHash('sha256').update(canonicalJSON(statement)).digest();
16
+ }
17
+
18
+ /** Pure check: returns {ok, reason?} for {statement, signature} against a public key PEM. */
19
+ export function checkSignature(doc, publicKeyPem) {
20
+ try {
21
+ if (!doc || typeof doc !== 'object' || !doc.statement || typeof doc.signature !== 'string') return { ok: false, reason: 'not a chainfess statement file' };
22
+ const good = edVerify(null, digestOf(doc.statement), createPublicKey(publicKeyPem), Buffer.from(doc.signature, 'base64'));
23
+ return good ? { ok: true } : { ok: false, reason: 'signature does not match the statement' };
24
+ } catch (e) {
25
+ return { ok: false, reason: 'could not check signature: ' + (e && e.message ? e.message : 'error') };
26
+ }
27
+ }
28
+
29
+ function describe(st, out) {
30
+ const listed = (st.txs || []).filter((t) => t.listedByClient).length;
31
+ const notListed = (st.txs || []).length - listed;
32
+ out(`Chain ${st.chainId}, blocks ${st.fromBlock} to ${st.toBlock}, signed ${st.signedAt}`);
33
+ for (const w of st.wallets) out(`Wallet ${w}: nonce ${st.nonceAt?.[w]} at signing`);
34
+ out(`${(st.txs || []).length} transaction(s) from these wallets in range: ${listed} listed by the client, ${notListed} not listed`);
35
+ for (const t of st.txs || []) out(` nonce ${t.nonce} block ${t.block} ${t.hash} ${t.listedByClient ? 'listed' : 'not listed'}`);
36
+ if ((st.unscannedRanges || []).length) out(`Unscanned ranges: ${st.unscannedRanges.map((r) => `${r.from}-${r.to}`).join(', ')}`);
37
+ out(`Burn transaction: ${st.burnTx}`);
38
+ out(`Client log hash (claimed by the client, never seen by the server): ${st.logSha256}`);
39
+ out('The server vouches for the chain facts above and for the fact that the client listed those hashes. It never saw the log. This is not proof that an agent behaved.');
40
+ }
41
+
42
+ /** chainfess verify <statement.json> [--pubkey file.pem]; offline. Returns exit code. */
43
+ export async function runVerify(path, opts = {}) {
44
+ const out = opts.out || ((s) => console.log(s));
45
+ const err = opts.err || ((s) => console.error(s));
46
+ if (!path) { err('usage: chainfess verify <statement.json> [--pubkey <pem file>]'); return 2; }
47
+ let doc;
48
+ try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { err(`cannot read ${path}: ${e.message}`); return 2; }
49
+ if (!doc || !doc.statement || typeof doc.signature !== 'string') { err('not a chainfess statement file (expects {statement, signature})'); return 2; }
50
+ let pubkeyFile = opts.pubkey;
51
+ if (pubkeyFile === undefined) {
52
+ const i = process.argv.indexOf('--pubkey');
53
+ if (i >= 0) pubkeyFile = process.argv[i + 1];
54
+ }
55
+ let key = PUBLIC_KEY_PEM;
56
+ let nonProd = false;
57
+ if (pubkeyFile) {
58
+ try { key = readFileSync(pubkeyFile, 'utf8'); } catch (e) { err(`cannot read public key file: ${e.message}`); return 2; }
59
+ nonProd = !sameKey(key, PUBLIC_KEY_PEM);
60
+ } else if (!sameKey(doc.statement.serverPublicKey || '', PUBLIC_KEY_PEM)) {
61
+ err('REFUSED: this statement was signed with a key that is not the chainfess production key. Pass --pubkey <pem file> only if you trust that key (for example a staging key).');
62
+ return 1;
63
+ }
64
+ if (pubkeyFile && !sameKey(doc.statement.serverPublicKey || '', key)) {
65
+ err('INVALID: the key embedded in the statement is not the key you supplied.');
66
+ return 1;
67
+ }
68
+ const r = checkSignature(doc, key);
69
+ if (!r.ok) { err(`INVALID: ${r.reason}`); return 1; }
70
+ if (nonProd) out('NOTE: verified with a NON-PRODUCTION key supplied by --pubkey. This statement is not a production statement.');
71
+ out(nonProd ? 'Signature valid (non-production key).' : 'Signature valid (production key).');
72
+ describe(doc.statement, out);
73
+ return 0;
74
+ }
75
+
76
+ async function jfetch(f, url, init) {
77
+ const res = await f(url, { ...init, signal: AbortSignal.timeout(25000), headers: { 'content-type': 'application/json', 'user-agent': 'chainfess/0.1', ...(init && init.headers) } });
78
+ let body = null;
79
+ try { body = await res.json(); } catch { /* non-json */ }
80
+ return { status: res.status, body };
81
+ }
82
+
83
+ /**
84
+ * chainfess sign --burn-tx 0x.. --wallet 0x.. [--log f].. [--server url]
85
+ * opts: { burnTx, wallets, server, out, err, fetch, prepareAudit, outFile, pollMs, timeoutMs, ...auditOpts }
86
+ */
87
+ export async function runSign(opts = {}) {
88
+ const out = opts.out || ((s) => console.log(s));
89
+ const err = opts.err || ((s) => console.error(s));
90
+ const f = opts.fetch || fetch;
91
+ const server = String(opts.server || DEFAULT_SERVER).replace(/\/+$/, '');
92
+ const burnTx = String(opts.burnTx || '').toLowerCase();
93
+ if (!/^0x[0-9a-f]{64}$/.test(burnTx)) { err('usage: chainfess sign --burn-tx 0x<64 hex> --wallet 0x<address> [--log file]... [--server url]'); return 2; }
94
+
95
+ let st;
96
+ try { st = await jfetch(f, `${server}/api/sign/status`); } catch { err(`cannot reach ${server}`); return 3; }
97
+ if (st.status !== 200 || !st.body) { err(`unexpected answer from ${server} (HTTP ${st.status})`); return 3; }
98
+ if (!st.body.enabled) { out(PRELAUNCH_LINE); return 0; }
99
+
100
+ let audit;
101
+ try {
102
+ const prepare = opts.prepareAudit || (await import('./audit.mjs')).prepareAudit;
103
+ audit = await prepare({ ...opts, burnTx });
104
+ } catch (e) { err(`local audit failed: ${e && e.message ? e.message : e}`); return 2; }
105
+ const body = { burnTx, wallets: audit.wallets, fromBlock: audit.fromBlock, toBlock: audit.toBlock, hashes: audit.hashes, logSha256: audit.logSha256 };
106
+
107
+ let post;
108
+ try { post = await jfetch(f, `${server}/api/sign`, { method: 'POST', body: JSON.stringify(body) }); } catch { err(`cannot reach ${server}`); return 3; }
109
+ let jobId;
110
+ if (post.status === 202) jobId = post.body.jobId;
111
+ else if (post.status === 409 && post.body?.error === 'already_used') {
112
+ out('This burn transaction was already used for a statement (one burn, one statement). Fetching it.');
113
+ jobId = post.body.jobId;
114
+ } else if (post.status === 403 && post.body?.enabled === false) { out(PRELAUNCH_LINE); return 0; }
115
+ else { err(`server refused: HTTP ${post.status} ${post.body?.error || ''}`.trim()); return 1; }
116
+
117
+ const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60 * 1000);
118
+ let job;
119
+ for (;;) {
120
+ try { job = await jfetch(f, `${server}/api/sign/${jobId}`); } catch { err('lost connection while waiting'); return 3; }
121
+ if (job.status !== 200) { err(`unexpected answer while waiting: HTTP ${job.status}`); return 3; }
122
+ if (job.body.status === 'done') break;
123
+ if (job.body.status === 'failed') { err(`server could not finish the scan (${job.body.error}). Run the same command again to retry this burn.`); return 4; }
124
+ if (Date.now() > deadline) { err(`still ${job.body.status}; fetch later: ${server}/api/report/${burnTx}`); return 4; }
125
+ await new Promise((r) => setTimeout(r, opts.pollMs ?? 2000));
126
+ }
127
+ const doc = { statement: job.body.statement, signature: job.body.signature };
128
+ const file = opts.outFile || `chainfess-statement-${burnTx.slice(2, 10)}.json`;
129
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
130
+ out(`Signed statement saved to ${file}`);
131
+ if (!sameKey(doc.statement.serverPublicKey || '', PUBLIC_KEY_PEM)) {
132
+ const pem = file.replace(/\.json$/, '') + '.pubkey.pem';
133
+ writeFileSync(pem, doc.statement.serverPublicKey);
134
+ out(`This server's key is not the production key. To check it: chainfess verify ${file} --pubkey ${pem}`);
135
+ } else {
136
+ out(`Check it offline: chainfess verify ${file}`);
137
+ }
138
+ return 0;
139
+ }