provared 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/CHANGELOG.md +1062 -0
- package/LICENSE +202 -0
- package/LICENSE-CC-BY-4.0.txt +396 -0
- package/README.md +689 -0
- package/bin/provared-check.mjs +565 -0
- package/bin/provared-headers.mjs +110 -0
- package/docs/threat-model.md +997 -0
- package/net/headers.js +403 -0
- package/package.json +57 -0
- package/page/check-page.js +162 -0
- package/page/index.html +47 -0
- package/page/render.js +530 -0
- package/page/style.css +150 -0
- package/samples/office-supplies.expected.json +82 -0
- package/samples/office-supplies.jsonl +10 -0
- package/samples/office-supplies.show.json +59 -0
- package/spec/provared-format.md +1672 -0
- package/src/actions.js +90 -0
- package/src/approval.js +73 -0
- package/src/blockstamp.js +198 -0
- package/src/book.js +2232 -0
- package/src/check.d.ts +21 -0
- package/src/check.js +15 -0
- package/src/compare.js +276 -0
- package/src/cover.js +198 -0
- package/src/der.js +192 -0
- package/src/encoding.js +286 -0
- package/src/fields.js +94 -0
- package/src/guard.js +171 -0
- package/src/headers.js +380 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +37 -0
- package/src/jws.js +235 -0
- package/src/keys.js +149 -0
- package/src/pass.js +67 -0
- package/src/passkey-browser.d.ts +20 -0
- package/src/passkey-browser.js +171 -0
- package/src/recorder.js +1115 -0
- package/src/seal.js +133 -0
- package/src/service.js +124 -0
- package/src/signatures.js +147 -0
- package/src/slip.js +401 -0
- package/src/standing.js +181 -0
- package/src/stub.js +140 -0
- package/src/timestamp.js +351 -0
- package/src/tools.js +192 -0
- package/src/tree.js +143 -0
- package/src/types.d.ts +628 -0
- package/src/webauthn.js +119 -0
package/net/headers.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Fetching block headers from Bitcoin nodes: the only code in this package
|
|
2
|
+
// that makes a network request. The library (src/) never does. This is
|
|
3
|
+
// used by the command provared-headers alone, which the person runs.
|
|
4
|
+
//
|
|
5
|
+
// Only nodes the person names are asked. To each, this sends the network's
|
|
6
|
+
// own greeting (with no name of the software, a random number and this
|
|
7
|
+
// device's clock), its acknowledgement, answers to the node's "are you
|
|
8
|
+
// there" messages, and requests for headers: nothing else. The node sees
|
|
9
|
+
// this device's address. The connection is not encrypted; nothing secret
|
|
10
|
+
// is sent, and every header is checked here (src/headers.js), so a node can
|
|
11
|
+
// refuse or fall silent, but cannot have a false header kept.
|
|
12
|
+
|
|
13
|
+
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
|
|
14
|
+
import net from 'node:net';
|
|
15
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
+
import { BITCOIN, MIN_BLOCKS_AFTER, readHeaderChain, startHeaderChain } from '../src/headers.js';
|
|
17
|
+
|
|
18
|
+
const MAGIC = Buffer.from('f9beb4d9', 'hex');
|
|
19
|
+
const PROTOCOL = 70016;
|
|
20
|
+
// A node sends at most this many headers in one answer.
|
|
21
|
+
const PAGE = 2000;
|
|
22
|
+
// No message this program waits for is longer: a full answer of headers is 162,003 bytes.
|
|
23
|
+
const MAX_MESSAGE = 1_000_000;
|
|
24
|
+
// Answers to "are you there" stop while this much waits to be sent to a node that does not read them.
|
|
25
|
+
const MAX_WAITING = 64 * 1024;
|
|
26
|
+
const sha256d = (b) => createHash('sha256').update(createHash('sha256').update(b).digest()).digest();
|
|
27
|
+
|
|
28
|
+
function message(command, payload = Buffer.alloc(0)) {
|
|
29
|
+
const head = Buffer.alloc(24);
|
|
30
|
+
MAGIC.copy(head, 0);
|
|
31
|
+
head.write(command, 4, 'ascii');
|
|
32
|
+
head.writeUInt32LE(payload.length, 16);
|
|
33
|
+
sha256d(payload).copy(head, 20, 0, 4);
|
|
34
|
+
return Buffer.concat([head, payload]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function varint(n) {
|
|
38
|
+
if (n < 0xfd) return Buffer.from([n]);
|
|
39
|
+
const b = Buffer.alloc(3);
|
|
40
|
+
b[0] = 0xfd;
|
|
41
|
+
b.writeUInt16LE(n, 1);
|
|
42
|
+
return b;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readVarint(b, o) {
|
|
46
|
+
if (o >= b.length) throw new Error('a message ends too early');
|
|
47
|
+
const first = b[o];
|
|
48
|
+
if (first < 0xfd) return [first, o + 1];
|
|
49
|
+
if (first === 0xfd) return [b.readUInt16LE(o + 1), o + 3];
|
|
50
|
+
if (first === 0xfe) return [b.readUInt32LE(o + 1), o + 5];
|
|
51
|
+
return [Number(b.readBigUInt64LE(o + 1)), o + 9];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function versionPayload(now) {
|
|
55
|
+
const b = Buffer.alloc(86);
|
|
56
|
+
let o = 0;
|
|
57
|
+
b.writeInt32LE(PROTOCOL, o); o += 4;
|
|
58
|
+
o += 8; // services offered: none
|
|
59
|
+
b.writeBigInt64LE(BigInt(Math.floor(now / 1000)), o); o += 8;
|
|
60
|
+
o += 26; // the other node's address: left empty
|
|
61
|
+
o += 26; // this device's address: left empty
|
|
62
|
+
randomBytes(8).copy(b, o); o += 8;
|
|
63
|
+
b[o] = 0; o += 1; // the name of the software: empty
|
|
64
|
+
b.writeInt32LE(0, o); o += 4; // the latest block held: none said
|
|
65
|
+
b[o] = 0; // send no transactions
|
|
66
|
+
return b;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ask for headers after the latest blocks held: the last ten, then the
|
|
70
|
+
// first block. A node answers from the latest of them that it holds.
|
|
71
|
+
function getheadersPayload(chain) {
|
|
72
|
+
const locator = [];
|
|
73
|
+
for (let h = chain.height; h >= 0 && locator.length < 10; h--) locator.push(Buffer.from(chain.hashAt(h)));
|
|
74
|
+
if (chain.height >= 10) locator.push(Buffer.from(chain.hashAt(0)));
|
|
75
|
+
const head = Buffer.alloc(4);
|
|
76
|
+
head.writeUInt32LE(PROTOCOL, 0);
|
|
77
|
+
return Buffer.concat([head, varint(locator.length), ...locator, Buffer.alloc(32)]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Take headers from one node until it has no more, within the time each
|
|
82
|
+
* node is given. Resolves with the number of headers taken from it; rejects
|
|
83
|
+
* where it fails, falls silent, runs out of time, or gives a header that does
|
|
84
|
+
* not check (what it gave before that is kept). Where the node's chain parts
|
|
85
|
+
* from the one held within its last eleven blocks, its branch is tried on a
|
|
86
|
+
* copy, and taken on only if it carries more work.
|
|
87
|
+
*/
|
|
88
|
+
function session(node, chain, { now, waits, progress = () => {} }) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const socket = net.connect({ host: node.host, port: node.port });
|
|
91
|
+
let inbox = Buffer.alloc(0);
|
|
92
|
+
let given = 0;
|
|
93
|
+
let asked = false;
|
|
94
|
+
let ended = false;
|
|
95
|
+
const shook = { version: false, verack: false };
|
|
96
|
+
let quietTimer;
|
|
97
|
+
// Waiting is measured from the last answer this program asked for, so
|
|
98
|
+
// that a node cannot keep a session going with messages of its own.
|
|
99
|
+
const quiet = (ms) => {
|
|
100
|
+
clearTimeout(quietTimer);
|
|
101
|
+
quietTimer = setTimeout(() => finish(new Error(`no answer for ${ms / 1000} seconds`)), ms);
|
|
102
|
+
};
|
|
103
|
+
const span = waits.session >= 60000 ? `${Math.round(waits.session / 60000)} minutes` : `${Math.round(waits.session / 1000)} seconds`;
|
|
104
|
+
const deadline = setTimeout(() => finish(new Error(`more than ${span} with one node`)), waits.session);
|
|
105
|
+
const finish = (error) => {
|
|
106
|
+
if (ended) return;
|
|
107
|
+
ended = true;
|
|
108
|
+
clearTimeout(quietTimer);
|
|
109
|
+
clearTimeout(deadline);
|
|
110
|
+
socket.destroy();
|
|
111
|
+
if (error) reject(Object.assign(error, { given }));
|
|
112
|
+
else resolve(given);
|
|
113
|
+
};
|
|
114
|
+
const ask = () => {
|
|
115
|
+
asked = true;
|
|
116
|
+
socket.write(message('getheaders', getheadersPayload(chain)));
|
|
117
|
+
};
|
|
118
|
+
const take = async (payload) => {
|
|
119
|
+
let [n, o] = readVarint(payload, 0);
|
|
120
|
+
if (n > PAGE) throw new Error(`more than ${PAGE} headers in one answer`);
|
|
121
|
+
if (o + n * 81 > payload.length) throw new Error('an answer of headers ends too early');
|
|
122
|
+
const at = (i) => new Uint8Array(payload.subarray(o + i * 81, o + i * 81 + 80));
|
|
123
|
+
if (n > 0 && chain.height >= 0) {
|
|
124
|
+
const parent = payload.subarray(o + 4, o + 36);
|
|
125
|
+
if (!parent.equals(Buffer.from(chain.hashAt(chain.height)))) {
|
|
126
|
+
// The node's chain parts from the one held: find where they meet.
|
|
127
|
+
let k = chain.height - 1;
|
|
128
|
+
const lowest = Math.max(0, chain.height - 11);
|
|
129
|
+
while (k >= lowest && !parent.equals(Buffer.from(chain.hashAt(k)))) k--;
|
|
130
|
+
if (k < lowest) throw new Error('its headers do not join the chain held here');
|
|
131
|
+
const branch = chain.copy();
|
|
132
|
+
branch.keepTo(k);
|
|
133
|
+
for (let i = 0; i < n; i++) await branch.add(at(i), now());
|
|
134
|
+
if (branch.workAfter(k) <= chain.workAfter(k)) throw new Error('its branch carries no more work than the chain held here');
|
|
135
|
+
chain.adopt(branch);
|
|
136
|
+
given += n;
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (let i = 0; i < n; i++) {
|
|
141
|
+
await chain.add(at(i), now());
|
|
142
|
+
given++;
|
|
143
|
+
}
|
|
144
|
+
return n;
|
|
145
|
+
};
|
|
146
|
+
// One message at a time, in order, so that headers are checked in the order they came.
|
|
147
|
+
let work = Promise.resolve();
|
|
148
|
+
const handle = async (command, payload) => {
|
|
149
|
+
if (command === 'version' && !shook.version) {
|
|
150
|
+
shook.version = true;
|
|
151
|
+
quiet(waits.between);
|
|
152
|
+
socket.write(message('verack'));
|
|
153
|
+
} else if (command === 'verack' && !shook.verack) {
|
|
154
|
+
shook.verack = true;
|
|
155
|
+
quiet(waits.between);
|
|
156
|
+
} else if (command === 'ping') {
|
|
157
|
+
// Answered only in the network's own form, and only while the node reads the answers.
|
|
158
|
+
if (payload.length === 8 && socket.writableLength < MAX_WAITING) socket.write(message('pong', payload));
|
|
159
|
+
} else if (command === 'headers' && asked) {
|
|
160
|
+
asked = false;
|
|
161
|
+
quiet(waits.between);
|
|
162
|
+
const n = await take(payload);
|
|
163
|
+
progress();
|
|
164
|
+
if (n < PAGE) return finish();
|
|
165
|
+
ask();
|
|
166
|
+
}
|
|
167
|
+
if (shook.version && shook.verack && given === 0 && !asked && command !== 'headers') ask();
|
|
168
|
+
};
|
|
169
|
+
quiet(waits.answer);
|
|
170
|
+
socket.on('connect', () => {
|
|
171
|
+
node.connected = socket.remoteAddress;
|
|
172
|
+
socket.write(message('version', versionPayload(now())));
|
|
173
|
+
});
|
|
174
|
+
socket.on('error', (e) => finish(new Error(e.code || e.message)));
|
|
175
|
+
socket.on('close', () => finish(new Error('the node closed the connection')));
|
|
176
|
+
socket.on('data', (data) => {
|
|
177
|
+
if (ended) return;
|
|
178
|
+
inbox = Buffer.concat([inbox, data]);
|
|
179
|
+
try {
|
|
180
|
+
while (inbox.length >= 24) {
|
|
181
|
+
if (!inbox.subarray(0, 4).equals(MAGIC)) throw new Error('not a Bitcoin message');
|
|
182
|
+
const length = inbox.readUInt32LE(16);
|
|
183
|
+
if (length > MAX_MESSAGE) throw new Error('a message too long');
|
|
184
|
+
if (inbox.length < 24 + length) break;
|
|
185
|
+
const command = inbox.toString('ascii', 4, 16).replace(/\0+$/, '');
|
|
186
|
+
const payload = Buffer.from(inbox.subarray(24, 24 + length));
|
|
187
|
+
if (!sha256d(payload).subarray(0, 4).equals(inbox.subarray(20, 24))) throw new Error('a damaged message');
|
|
188
|
+
inbox = inbox.subarray(24 + length);
|
|
189
|
+
work = work.then(() => (ended ? undefined : handle(command, payload))).catch((e) => finish(e));
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
finish(e);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// An IPv6 address written out in full: eight groups.
|
|
199
|
+
function ipv6Groups(text) {
|
|
200
|
+
const [head, tail] = text.split('::');
|
|
201
|
+
const part = (s) => (s ? s.split(':') : []);
|
|
202
|
+
const groups = tail === undefined ? part(head) : [...part(head), ...Array(8 - part(head).length - part(tail).length).fill('0'), ...part(tail)];
|
|
203
|
+
return groups.map((g) => parseInt(g || '0', 16).toString(16));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The network a node is on, as Bitcoin nodes themselves group addresses:
|
|
208
|
+
* the first two numbers of an IPv4 address (also when written as IPv6),
|
|
209
|
+
* the first two groups of an IPv6 address, and otherwise the name.
|
|
210
|
+
* @param {{host: string, connected?: string}} node
|
|
211
|
+
*/
|
|
212
|
+
export function networkOf(node) {
|
|
213
|
+
let address = String(node.connected ?? node.host).toLowerCase().replace(/%.*$/, '').replace(/\.$/, '');
|
|
214
|
+
address = address.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/, '$1');
|
|
215
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(address)) return address.split('.').slice(0, 2).join('.');
|
|
216
|
+
if (address.includes(':')) return ipv6Groups(address).slice(0, 2).join(':');
|
|
217
|
+
return address;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Two nodes are apart if they are on different networks, judged by the address each was reached at. */
|
|
221
|
+
export function apart(a, b) {
|
|
222
|
+
return networkOf(a) !== networkOf(b);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read a node's address: "host:port", "[IPv6]:port", or a host alone (port
|
|
227
|
+
* 8333). Anything after "#" on a line is set aside, so that a list of
|
|
228
|
+
* nodes with notes can be read as it is. Hidden-service names are not
|
|
229
|
+
* taken: they cannot be reached directly.
|
|
230
|
+
* @param {string} text
|
|
231
|
+
* @returns {{host: string, port: number} | null}
|
|
232
|
+
*/
|
|
233
|
+
export function nodeAddress(text) {
|
|
234
|
+
const line = String(text).split('#')[0].trim();
|
|
235
|
+
const m = line.match(/^\[([0-9a-fA-F:.]+)\](?::(\d{1,5}))?$/) ?? line.match(/^([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)\.?(?::(\d{1,5}))?$/);
|
|
236
|
+
if (!m) return null;
|
|
237
|
+
const host = m[1].toLowerCase();
|
|
238
|
+
const port = m[2] === undefined ? 8333 : Number(m[2]);
|
|
239
|
+
if (port < 1 || port > 65535 || host.endsWith('.onion') || host === 'onion') return null;
|
|
240
|
+
return { host, port };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Bring a chain of headers up to date from the nodes named, one after
|
|
245
|
+
* another, in the order given: a node that fails, falls silent or runs out
|
|
246
|
+
* of time is left, and the next carries on from where the chain stands.
|
|
247
|
+
* Then a second node, on a network apart from every node that gave headers
|
|
248
|
+
* in this run, must hand back the latest blocks itself: it is asked for
|
|
249
|
+
* them from six blocks before the latest, on a copy of the chain, and must
|
|
250
|
+
* give the same latest block. Blocks it holds beyond that are not taken.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} o
|
|
253
|
+
* @param {ReturnType<import('../src/headers.js').startHeaderChain>} o.chain the chain so far, checked; it is added to
|
|
254
|
+
* @param {{host: string, port: number}[]} o.nodes the nodes the person named
|
|
255
|
+
* @param {(line: string) => void} [o.log]
|
|
256
|
+
* @param {() => number} [o.now] the clock
|
|
257
|
+
* @param {number} [o.stale] a latest block older than this, in milliseconds, means the node is behind
|
|
258
|
+
* @param {{answer?: number, between?: number, broken?: number, session?: number}} [o.waits] how long to wait, in milliseconds
|
|
259
|
+
* @returns {Promise<{from: number, to: number, by: object, agreed: object}>}
|
|
260
|
+
*/
|
|
261
|
+
export async function fetchHeaders({ chain, nodes, log = () => {}, now = Date.now, stale = 6 * 3600 * 1000, waits = {} }) {
|
|
262
|
+
const w = { answer: 15000, between: 60000, broken: 60000, session: 15 * 60000, ...waits };
|
|
263
|
+
// A chain starts with its first block, which every node knows.
|
|
264
|
+
if (chain.height < 0) await chain.add(Uint8Array.from(Buffer.from(chain.rules.first, 'hex')), now());
|
|
265
|
+
const from = chain.height;
|
|
266
|
+
let next = 0;
|
|
267
|
+
let by = null;
|
|
268
|
+
let breaks = 0;
|
|
269
|
+
// The nodes that gave headers in this run: the witness must be apart from each.
|
|
270
|
+
const gave = [];
|
|
271
|
+
const name = (node) => (node.host.includes(':') ? `[${node.host}]:${node.port}` : `${node.host}:${node.port}`);
|
|
272
|
+
while (by === null) {
|
|
273
|
+
if (next >= nodes.length) throw new Error('No node named gave the chain up to a recent block.');
|
|
274
|
+
const node = { ...nodes[next++] };
|
|
275
|
+
// A line every 100,000 blocks, so that a first fetch, which takes minutes, is seen to go on.
|
|
276
|
+
let step = Math.floor(chain.height / 100000);
|
|
277
|
+
const progress = () => {
|
|
278
|
+
if (Math.floor(chain.height / 100000) > step) {
|
|
279
|
+
step = Math.floor(chain.height / 100000);
|
|
280
|
+
log(`${name(node)}: block ${chain.height} reached`);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
const given = await session(node, chain, { now, waits: w, progress });
|
|
285
|
+
if (given > 0) gave.push(node);
|
|
286
|
+
log(`${name(node)}: ${given} headers; the latest block held is ${chain.height}`);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
if (e.given > 0) gave.push(node);
|
|
289
|
+
log(`${name(node)}: ${e.message}; ${e.given ?? 0} headers taken from it`);
|
|
290
|
+
// Where this device's own network is down, wait, then ask the same node again.
|
|
291
|
+
if (/ENETUNREACH|ENETDOWN/.test(e.message)) {
|
|
292
|
+
if (++breaks > 10) throw new Error('The network stayed unreachable from this device.');
|
|
293
|
+
await new Promise((r) => setTimeout(r, w.broken));
|
|
294
|
+
next--;
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (now() - chain.timeAt(chain.height) < stale) by = node;
|
|
299
|
+
else log(`${name(node)}: its latest block is more than ${Math.round(stale / 3600000)} hours old; asking another node`);
|
|
300
|
+
}
|
|
301
|
+
const height = chain.height;
|
|
302
|
+
const tip = chain.fingerprintAt(height);
|
|
303
|
+
const start = Math.max(0, height - MIN_BLOCKS_AFTER);
|
|
304
|
+
let agreed = null;
|
|
305
|
+
while (agreed === null && next < nodes.length) {
|
|
306
|
+
const node = { ...nodes[next++] };
|
|
307
|
+
const check = chain.copy();
|
|
308
|
+
check.keepTo(start);
|
|
309
|
+
try {
|
|
310
|
+
await session(node, check, { now, waits: w });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
log(`${name(node)}: ${e.message}`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Judged by the address reached: the same node under two names is one witness.
|
|
316
|
+
if (![by, ...gave].every((other) => apart(node, other))) {
|
|
317
|
+
log(`${name(node)}: on the same network as a node that gave headers; not counted as a second node`);
|
|
318
|
+
} else if (check.height >= height && check.fingerprintAt(height) === tip) {
|
|
319
|
+
agreed = node;
|
|
320
|
+
log(`${name(node)}: handed back blocks ${start + 1} to ${height} itself, the same as held`);
|
|
321
|
+
} else log(`${name(node)}: did not hand back the latest block ${height}`);
|
|
322
|
+
}
|
|
323
|
+
if (agreed === null) throw new Error('No second node, on another network, handed back the latest block.');
|
|
324
|
+
return { from, to: chain.height, by, agreed };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A file written beside the chain file, made sure to be on the disk, then
|
|
328
|
+
// put in its place, so that a chain file is never left half written.
|
|
329
|
+
function writeWhole(file, bytes) {
|
|
330
|
+
const temporary = `${file}.part`;
|
|
331
|
+
try {
|
|
332
|
+
const fd = openSync(temporary, 'w');
|
|
333
|
+
try {
|
|
334
|
+
writeSync(fd, bytes);
|
|
335
|
+
fsyncSync(fd);
|
|
336
|
+
} finally {
|
|
337
|
+
closeSync(fd);
|
|
338
|
+
}
|
|
339
|
+
renameSync(temporary, file);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
try {
|
|
342
|
+
rmSync(temporary, { force: true });
|
|
343
|
+
} catch {
|
|
344
|
+
// something else stands at that name; it is left as it is
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`${file} could not be written (${e.code || e.message}). The chain file is as it was.`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Read a chain file (checked in full), bring it up to date from the nodes
|
|
352
|
+
* named, and write it back: only where it grew, or a branch with more work
|
|
353
|
+
* replaced its end, and never shorter than it was read. A run that fails
|
|
354
|
+
* keeps what it fetched and checked, where that is more than the file held.
|
|
355
|
+
*
|
|
356
|
+
* @param {object} o
|
|
357
|
+
* @param {string} o.file
|
|
358
|
+
* @param {{host: string, port: number}[]} o.nodes
|
|
359
|
+
* @param {object} [o.rules] the chain's rules; Bitcoin's by default
|
|
360
|
+
* @param {(line: string) => void} [o.log]
|
|
361
|
+
* @param {() => number} [o.now]
|
|
362
|
+
* @param {number} [o.stale]
|
|
363
|
+
* @param {object} [o.waits]
|
|
364
|
+
* @returns {Promise<{chain: object, before: number}>} the chain, and the latest block the file held before
|
|
365
|
+
* @throws {Error} with a message that says what was done with the file
|
|
366
|
+
*/
|
|
367
|
+
export async function updateChainFile({ file, nodes, rules = BITCOIN, log = () => {}, now = Date.now, stale, waits }) {
|
|
368
|
+
let chain;
|
|
369
|
+
let before = -1;
|
|
370
|
+
let beforeTip = null;
|
|
371
|
+
if (existsSync(file)) {
|
|
372
|
+
log(`Checking the chain already in ${file} …`);
|
|
373
|
+
try {
|
|
374
|
+
chain = await readHeaderChain(new Uint8Array(readFileSync(file)), { partial: true, rules, now: now() });
|
|
375
|
+
} catch (e) {
|
|
376
|
+
throw new Error(`The chain in ${file} could not be used: ${e.message} Move the file away, or name another with --chain.`);
|
|
377
|
+
}
|
|
378
|
+
before = chain.height;
|
|
379
|
+
beforeTip = chain.fingerprintAt(before);
|
|
380
|
+
log(`It holds blocks 0 to ${before}, and every header checks.`);
|
|
381
|
+
} else chain = startHeaderChain(rules);
|
|
382
|
+
|
|
383
|
+
let failure = null;
|
|
384
|
+
try {
|
|
385
|
+
await fetchHeaders({ chain, nodes, log, now, stale, waits });
|
|
386
|
+
const last = rules.known.length ? rules.known.at(-1)[0] : -1;
|
|
387
|
+
if (chain.height < last) failure = `The chain ends at block ${chain.height}, before block ${last}, which is known to be part of it.`;
|
|
388
|
+
} catch (e) {
|
|
389
|
+
failure = e.message;
|
|
390
|
+
}
|
|
391
|
+
const changed = chain.height > before || (before >= 0 && chain.height === before && chain.fingerprintAt(before) !== beforeTip);
|
|
392
|
+
if (failure) {
|
|
393
|
+
// Kept only where more is held than the file held, and more than the first block alone.
|
|
394
|
+
if (chain.height > before && chain.height > 0) {
|
|
395
|
+
writeWhole(file, chain.bytes());
|
|
396
|
+
throw new Error(`${failure} The ${chain.height - Math.max(before, 0)} headers fetched and checked so far are kept in ${file}; run again to carry on.`);
|
|
397
|
+
}
|
|
398
|
+
throw new Error(`${failure} ${before >= 0 ? `The chain in ${file} is as it was.` : 'Nothing was written.'}`);
|
|
399
|
+
}
|
|
400
|
+
if (chain.height < before) throw new Error(`The nodes' chain ends at block ${chain.height}, before block ${before}, which the file already holds. The chain in ${file} is as it was.`);
|
|
401
|
+
if (changed) writeWhole(file, chain.bytes());
|
|
402
|
+
return { chain, before };
|
|
403
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "provared",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Proof of what an AI agent was allowed to do, and what it then did. Evidence, not a verdict.",
|
|
5
|
+
"author": "Pavel Izmaylov",
|
|
6
|
+
"license": "Apache-2.0 AND CC-BY-4.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/provared/provared.git"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"bin/",
|
|
14
|
+
"net/",
|
|
15
|
+
"spec/",
|
|
16
|
+
"docs/",
|
|
17
|
+
"samples/",
|
|
18
|
+
"page/index.html",
|
|
19
|
+
"page/check-page.js",
|
|
20
|
+
"page/render.js",
|
|
21
|
+
"page/style.css",
|
|
22
|
+
"LICENSE-CC-BY-4.0.txt",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./src/index.js",
|
|
27
|
+
"types": "./src/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./src/index.d.ts",
|
|
31
|
+
"default": "./src/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./check": {
|
|
34
|
+
"types": "./src/check.d.ts",
|
|
35
|
+
"default": "./src/check.js"
|
|
36
|
+
},
|
|
37
|
+
"./passkey-browser": {
|
|
38
|
+
"types": "./src/passkey-browser.d.ts",
|
|
39
|
+
"default": "./src/passkey-browser.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"bin": {
|
|
44
|
+
"provared-check": "bin/provared-check.mjs",
|
|
45
|
+
"provared-headers": "bin/provared-headers.mjs"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24.7"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --disable-warning=ExperimentalWarning --test",
|
|
52
|
+
"check": "node --disable-warning=ExperimentalWarning bin/provared-check.mjs",
|
|
53
|
+
"headers": "node bin/provared-headers.mjs",
|
|
54
|
+
"demo": "node --disable-warning=ExperimentalWarning demo/server.mjs"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {}
|
|
57
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The checking page: the same checker as the library, run in the browser.
|
|
2
|
+
// The record is read from a file the person chooses and never leaves the
|
|
3
|
+
// page. The only request this page makes is for the sample record, from the
|
|
4
|
+
// place the page itself came from. The page built as a single file
|
|
5
|
+
// (tools/make-single-page.mjs) holds the sample record itself, and makes no
|
|
6
|
+
// request at all.
|
|
7
|
+
|
|
8
|
+
import { checkBook, checkShow } from '../src/check.js';
|
|
9
|
+
import { readBlocks, readFingerprints, renderResult, shown } from './render.js';
|
|
10
|
+
|
|
11
|
+
const status = document.getElementById('status');
|
|
12
|
+
const result = document.getElementById('result');
|
|
13
|
+
|
|
14
|
+
// This page reads the whole file into memory. A larger book is checked
|
|
15
|
+
// with the command-line checker.
|
|
16
|
+
const MAX_FILE_BYTES = 200000000;
|
|
17
|
+
// A copy of a cancellation, or a file of disclosures, is small.
|
|
18
|
+
const MAX_HANDED_BYTES = 1000000;
|
|
19
|
+
const MAX_COPIES = 16;
|
|
20
|
+
|
|
21
|
+
// What the person already trusts: one fingerprint to a line. A line that
|
|
22
|
+
// is not a fingerprint stops the check, and the person is told which box.
|
|
23
|
+
const BOXES = [
|
|
24
|
+
['trust-issuer', 'issuerKeys', 'the person\'s passkey'],
|
|
25
|
+
['trust-sealer', 'sealKeys', 'whoever keeps and seals the book'],
|
|
26
|
+
['trust-stamps', 'stampServices', 'time-stamp services'],
|
|
27
|
+
['trust-vouchers', 'vouchers', 'organisations whose vouching you trust'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function trusted() {
|
|
31
|
+
const options = {};
|
|
32
|
+
for (const [id, name, words] of BOXES) {
|
|
33
|
+
const { values, bad } = readFingerprints(document.getElementById(id).value);
|
|
34
|
+
if (bad.length) throw new Error(`A line in the box for ${words} is not a fingerprint. A fingerprint is 43 letters, digits, hyphens or underscores. Correct it, or empty the box.`);
|
|
35
|
+
if (values.length) options[name] = values;
|
|
36
|
+
}
|
|
37
|
+
const blocks = readBlocks(document.getElementById('trust-blocks').value);
|
|
38
|
+
if (blocks.bad.length) {
|
|
39
|
+
throw new Error('A line in the box for blocks is not the fingerprint of a block. It is 64 characters: the digits 0 to 9 and the letters a to f. Correct it, or empty the box.');
|
|
40
|
+
}
|
|
41
|
+
if (blocks.values.length) options.blocks = blocks.values;
|
|
42
|
+
return options;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function jsonFrom(file, what) {
|
|
46
|
+
if (file.size > MAX_HANDED_BYTES) throw new Error(`The file of ${what}, ${shown(file.name)}, is too large. Nothing was checked.`);
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(await file.text());
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`The file of ${what}, ${shown(file.name)}, is not JSON. Nothing was checked. Choose another file, or clear these files.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What the person was handed with the record: their own copies of
|
|
55
|
+
// cancellations, and disclosures for covered fields. A file that cannot be
|
|
56
|
+
// read stops the check: it is never set aside silently.
|
|
57
|
+
async function handedOver() {
|
|
58
|
+
const options = {};
|
|
59
|
+
const said = [];
|
|
60
|
+
const copies = [...document.getElementById('cancellations').files];
|
|
61
|
+
if (copies.length > MAX_COPIES) throw new Error('At most 16 copies of cancellations can be handed over at once. Nothing was checked.');
|
|
62
|
+
if (copies.length) {
|
|
63
|
+
options.cancellations = [];
|
|
64
|
+
for (const file of copies) options.cancellations.push(await jsonFrom(file, 'a cancellation'));
|
|
65
|
+
said.push(copies.length === 1 ? 'one copy of a cancellation' : `${copies.length} copies of cancellations`);
|
|
66
|
+
}
|
|
67
|
+
const [disclosures] = document.getElementById('disclosures').files;
|
|
68
|
+
if (disclosures) {
|
|
69
|
+
options.disclosures = await jsonFrom(disclosures, 'disclosures');
|
|
70
|
+
said.push('disclosures for covered fields');
|
|
71
|
+
}
|
|
72
|
+
return { options, said };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function check(text, name, { beside = true, trust = null } = {}) {
|
|
76
|
+
status.textContent = `Checking ${name} …`;
|
|
77
|
+
result.replaceChildren();
|
|
78
|
+
// A Show is one JSON object with a "type". A book is entries, one to a line.
|
|
79
|
+
let isShow = false;
|
|
80
|
+
try {
|
|
81
|
+
const whole = JSON.parse(text);
|
|
82
|
+
isShow = whole !== null && typeof whole === 'object' && Object.hasOwn(whole, 'type');
|
|
83
|
+
} catch {
|
|
84
|
+
isShow = false;
|
|
85
|
+
}
|
|
86
|
+
let options;
|
|
87
|
+
let said = [];
|
|
88
|
+
try {
|
|
89
|
+
options = trust ?? trusted();
|
|
90
|
+
if (beside) {
|
|
91
|
+
const handed = await handedOver();
|
|
92
|
+
options = { ...options, ...handed.options };
|
|
93
|
+
said = handed.said;
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
status.textContent = e.message;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const r = isShow ? await checkShow(text, options) : await checkBook(text, options);
|
|
101
|
+
status.textContent = `Checked: ${name}${said.length ? `, with ${said.join(' and ')} handed over beside it` : ''}`;
|
|
102
|
+
renderResult(result, r, { isShow, options });
|
|
103
|
+
} catch {
|
|
104
|
+
status.textContent = 'The check could not be run in this browser. Try the command-line checker.';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function checkChosen(input) {
|
|
109
|
+
const file = input.files[0];
|
|
110
|
+
if (!file) return;
|
|
111
|
+
if (file.size > MAX_FILE_BYTES) {
|
|
112
|
+
status.textContent = 'That file is too large for this page. Use the command-line checker.';
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let text;
|
|
116
|
+
try {
|
|
117
|
+
text = await file.text();
|
|
118
|
+
} catch {
|
|
119
|
+
status.textContent = 'The file could not be read.';
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await check(text, shown(file.name));
|
|
123
|
+
input.value = '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const chooser = document.getElementById('file');
|
|
127
|
+
chooser.addEventListener('change', () => checkChosen(chooser));
|
|
128
|
+
// A file chosen before this script started (the page built as one file
|
|
129
|
+
// starts its script a moment after it is shown) is checked now, not lost.
|
|
130
|
+
if (chooser.files.length > 0) checkChosen(chooser);
|
|
131
|
+
|
|
132
|
+
document.getElementById('clear-beside').addEventListener('click', () => {
|
|
133
|
+
document.getElementById('cancellations').value = '';
|
|
134
|
+
document.getElementById('disclosures').value = '';
|
|
135
|
+
status.textContent = 'The files you were handed with a record are cleared.';
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The sample record and what its makers say to trust: held in the page
|
|
139
|
+
// itself where the page was built as a single file, fetched otherwise.
|
|
140
|
+
async function sampleRecord() {
|
|
141
|
+
const inPage = document.getElementById('sample-record');
|
|
142
|
+
if (inPage) return JSON.parse(inPage.textContent);
|
|
143
|
+
const response = await fetch('../samples/office-supplies.jsonl');
|
|
144
|
+
const told = await fetch('../samples/office-supplies.expected.json');
|
|
145
|
+
if (!response.ok || !told.ok) throw new Error('not found');
|
|
146
|
+
return { book: await response.text(), expected: await told.json() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
document.getElementById('sample').addEventListener('click', async () => {
|
|
150
|
+
status.textContent = 'Fetching the sample record …';
|
|
151
|
+
try {
|
|
152
|
+
// The sample comes with the fingerprints its makers say to trust. It is
|
|
153
|
+
// checked with them, and the boxes keep what the person put there, so
|
|
154
|
+
// that the next record chosen is checked against the person's own.
|
|
155
|
+
const { book, expected } = await sampleRecord();
|
|
156
|
+
const trust = { issuerKeys: [expected.issuerKey], sealKeys: expected.sealKeys, stampServices: expected.stampServices };
|
|
157
|
+
// Files handed over with some other record have nothing to do with the sample.
|
|
158
|
+
await check(book, 'the sample record (an invented office agent), trusting the fingerprints that came with it', { beside: false, trust });
|
|
159
|
+
} catch {
|
|
160
|
+
status.textContent = 'The sample record could not be fetched.';
|
|
161
|
+
}
|
|
162
|
+
});
|
package/page/index.html
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en-GB">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Provared: check a record</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main>
|
|
11
|
+
<h1>Check a record</h1>
|
|
12
|
+
<p class="motto">Provared, draft version 0. Evidence, not a verdict.</p>
|
|
13
|
+
<p>Choose a record file. It is checked here, in this browser. Nothing is sent anywhere.</p>
|
|
14
|
+
<p>
|
|
15
|
+
<label class="file">Choose a record file<input type="file" id="file" accept=".jsonl,.json,.txt"></label>
|
|
16
|
+
<button type="button" class="second" id="sample">Check the sample record</button>
|
|
17
|
+
</p>
|
|
18
|
+
<details id="trust" class="more">
|
|
19
|
+
<summary>What you already trust (optional)</summary>
|
|
20
|
+
<p class="quiet">A check says more when you tell it which keys and services you expect. Put one fingerprint on each line. Use fingerprints you obtained yourself, from the people concerned.</p>
|
|
21
|
+
<label for="trust-issuer">The person's passkey (its thumbprint)</label>
|
|
22
|
+
<textarea id="trust-issuer" rows="2" spellcheck="false"></textarea>
|
|
23
|
+
<label for="trust-sealer">Whoever keeps and seals the book (the fingerprint of its key set)</label>
|
|
24
|
+
<textarea id="trust-sealer" rows="2" spellcheck="false"></textarea>
|
|
25
|
+
<label for="trust-stamps">Time-stamp services (the fingerprint of each service's certificate)</label>
|
|
26
|
+
<textarea id="trust-stamps" rows="2" spellcheck="false"></textarea>
|
|
27
|
+
<label for="trust-vouchers">Organisations whose vouching for names you trust (the fingerprint of each one's key set)</label>
|
|
28
|
+
<textarea id="trust-vouchers" rows="2" spellcheck="false"></textarea>
|
|
29
|
+
<label for="trust-blocks">Blocks of a public blockchain that you trust to be part of the chain (the fingerprint of each block: 64 characters, the digits 0 to 9 and the letters a to f)</label>
|
|
30
|
+
<textarea id="trust-blocks" rows="2" spellcheck="false"></textarea>
|
|
31
|
+
</details>
|
|
32
|
+
<details id="beside" class="more">
|
|
33
|
+
<summary>What you were handed with the record (optional)</summary>
|
|
34
|
+
<p class="quiet">Choose these files first, then choose the record file. They stay chosen, and are used with every record you check, until you clear them. The sample record is checked without them.</p>
|
|
35
|
+
<label for="cancellations">The person's own copies of cancellations: one file for each, up to 16. A copy counts even where the book leaves the cancellation out. A file may also hold what the agent's side signed when it was handed the cancellation.</label>
|
|
36
|
+
<input type="file" id="cancellations" accept=".json,.txt" multiple>
|
|
37
|
+
<label for="disclosures">Disclosures for covered fields of a slip: one file</label>
|
|
38
|
+
<input type="file" id="disclosures" accept=".json,.txt">
|
|
39
|
+
<p><button type="button" class="second" id="clear-beside">Clear these files</button></p>
|
|
40
|
+
</details>
|
|
41
|
+
<p id="status" role="status"></p>
|
|
42
|
+
<section id="result"></section>
|
|
43
|
+
<noscript><p>This page needs JavaScript to check a record.</p></noscript>
|
|
44
|
+
</main>
|
|
45
|
+
<script type="module" src="check-page.js"></script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|