provared 0.4.0 → 0.6.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.
@@ -0,0 +1,362 @@
1
+ // A witness: a party, with a key set of its own, that keeps, for each
2
+ // person, agent and recorder, the latest seal it has signed for, and signs
3
+ // a statement for a new seal only if the new seal extends that one: in the
4
+ // same book, or as the first seal of the next book.
5
+ // Format description, section 29.3.
6
+ //
7
+ // This is the part of a witness that decides and signs. Keeping its state
8
+ // on a disk, and taking seals over a network, are the business of whoever
9
+ // runs it. Its state is handed back with every statement, and MUST be saved
10
+ // before the statement is given out: a witness that loses its state can be
11
+ // made to sign a fork.
12
+ //
13
+ // A witness checks a seal's three signatures, so it needs SLH-DSA. Where
14
+ // the device does not have it built in, it signs nothing.
15
+
16
+ import { checkSealAlone } from './book.js';
17
+ import { Refusal, canonicalJson, fingerprint, fromBase64url, fromUtf8, parseCanonical, parseTime, problemFrom, utf8 } from './encoding.js';
18
+ import * as f from './fields.js';
19
+ import { encodeContent, parseRecord, signingInput } from './jws.js';
20
+ import { PASSKEY_METHODS, checkKey, checkKeySet, checkSealKeySet, thumbprint } from './keys.js';
21
+ import { CLOCK_ALLOWANCE_MS, keySetFingerprint } from './seal.js';
22
+ import { verifySignature } from './signatures.js';
23
+ import { consistencyProof, inclusionPath, verifyConsistency, verifyInclusion } from './tree.js';
24
+ import { MAX_WITNESSES, validateWitnessContent, writeWitness } from './witness.js';
25
+
26
+ /** The "type" of a witness's saved state. */
27
+ export const WITNESS_STATE_TYPE = 'provared.witness-state.v0';
28
+
29
+ /**
30
+ * How a witness knows which books are one sequence: by the fingerprint of
31
+ * the recorder's key set, with the passkey and the agent that the
32
+ * permission (the slip) on the book's first line names: the thumbprint of
33
+ * its passkey key (RFC 7638) and the fingerprint of the agent's key set.
34
+ * So every permission one person gives one agent, under one recorder, is
35
+ * one sequence, renewals included, and a book that starts with a later
36
+ * permission must follow on from the seal the witness holds, as any new
37
+ * book (section 21.6). This is not written into any record.
38
+ * @param {object[]} keys the recorder's key set, as a seal gives it
39
+ * @param {Uint8Array} firstLine the bytes of the book's first line, without its line feed: a permission
40
+ * @returns {Promise<string>} a fingerprint, in base64url
41
+ */
42
+ export async function bookIdentity(keys, firstLine) {
43
+ checkSealKeySet(keys, 'keys');
44
+ return identityOf(await keySetFingerprint(keys), await sequenceOf(firstLine));
45
+ }
46
+
47
+ // The fingerprint of {"agent": the agent's key set fingerprint, "passkey": the passkey's thumbprint, "recorder": the
48
+ // recorder's key set fingerprint}, written in the canonical form.
49
+ const identityOf = async (recorder, { agent, passkey }) => fingerprint(encodeContent({ agent, passkey, recorder }).contentBytes);
50
+
51
+ // The passkey and the agent that the permission on a book's first line names. The passkey's signature is not
52
+ // checked here: a witness knows a sequence by them, and a reader counts a statement only where the permission
53
+ // passed and its passkey is one they named. A central sealer reads a book's first line the same way (sealer.js).
54
+ export async function sequenceOf(firstLine) {
55
+ if (!(firstLine instanceof Uint8Array) || firstLine.length === 0) throw f.fail('firstLine', 'must be the bytes of the book\'s first line.');
56
+ if (firstLine[firstLine.length - 1] === 0x0a || firstLine[firstLine.length - 1] === 0x0d) {
57
+ throw f.fail('firstLine', 'must be the book\'s first line without its line feed.');
58
+ }
59
+ let value;
60
+ try {
61
+ value = parseCanonical(fromUtf8(firstLine, 'line-not-canonical'), 'line-not-canonical');
62
+ } catch {
63
+ throw new Refusal('slip-missing', 'The book\'s first line is not a line of a book in its one written form, so it is not a permission. A book starts with its permission.');
64
+ }
65
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1 || !Object.hasOwn(value, 'slip')) {
66
+ throw new Refusal('slip-missing', 'The book\'s first line is not a permission, an entry that holds only a slip. A book starts with its permission.');
67
+ }
68
+ const c = (await parseRecord(value.slip, 'slip')).content;
69
+ try {
70
+ checkKey(c.issuer.key, PASSKEY_METHODS, 'issuer.key');
71
+ checkKeySet(c.agent.keys, 'agent.keys');
72
+ } catch {
73
+ throw new Refusal('slip-unusable', 'The permission on the book\'s first line does not name a passkey key and an agent\'s key set in the forms a slip gives them.');
74
+ }
75
+ return { passkey: await thumbprint(c.issuer.key), agent: await keySetFingerprint(c.agent.keys) };
76
+ }
77
+
78
+ // A seal's book number (section 21.6): absent is the first book.
79
+ const numberOf = (content) => (Object.hasOwn(content, 'book') ? content.book : 1);
80
+
81
+ /**
82
+ * What a witness needs to be sent with a seal: where it holds an earlier
83
+ * seal of the same book, the consistency proof from that seal's number of
84
+ * entries to the new seal's (RFC 9162, section 2.1.4.1); where it holds
85
+ * none, or the new seal is the first of the next book, the audit path of
86
+ * the book's first line in the new seal's tree (section 2.1.3), which ties
87
+ * the first line to the seal.
88
+ * @param {string} book the book, as text
89
+ * @param {number} size the new seal's number of entries
90
+ * @param {number|null} [from] the number of entries of the seal the witness holds of this book, or null for none
91
+ * (and for the first seal of the next book)
92
+ * @returns {Promise<Uint8Array[]>}
93
+ */
94
+ export async function witnessProof(book, size, from = null) {
95
+ const lines = book.split('\n');
96
+ if (lines[lines.length - 1] === '') lines.pop();
97
+ if (!Number.isSafeInteger(size) || size < 1 || size > lines.length) throw new RangeError('the book has no such number of entries');
98
+ const leaves = lines.slice(0, size).map(utf8);
99
+ if (from === null || from === undefined) return inclusionPath(leaves, 0);
100
+ return consistencyProof(leaves, from);
101
+ }
102
+
103
+ const refused = (code, message) => ({ refused: { code, message } });
104
+
105
+ /**
106
+ * Open a witness.
107
+ *
108
+ * @param {object} o
109
+ * @param {{keys: object[], name: string}} o.by the witness's public key set (Ed25519, then ML-DSA-87) and its name (a label)
110
+ * @param {any[]} o.signers the witness's private keys, or signers (signatures.js), in the order of the key set
111
+ * @param {object} [o.state] the state a witness handed back before, as saved. A witness opened with a state signs
112
+ * nothing until it has been brought up to date (update) and told so (ready)
113
+ * @param {() => number} [o.clock] the time now, in milliseconds since 1970; Date.now by default
114
+ * @param {string[]} [o.withoutMethods] signing methods to treat as not built into this device
115
+ * @returns {Promise<object>} the witness: witness, update, ready, latest, state
116
+ */
117
+ export async function openWitness({ by, signers, state, clock = Date.now, withoutMethods = [] } = {}) {
118
+ f.members(by, ['keys', 'name'], [], 'by');
119
+ f.label(by.name, 'by.name');
120
+ checkKeySet(by.keys, 'by.keys');
121
+ if (!Array.isArray(signers) || signers.length !== 2) throw f.fail('signers', 'must be the witness\'s two private keys, or signers: Ed25519, then ML-DSA-87.');
122
+ if (typeof clock !== 'function') throw f.fail('clock', 'must be a function that gives the time now.');
123
+ if (!Array.isArray(withoutMethods)) throw f.fail('withoutMethods', 'must be a list of signing methods.');
124
+ // Copied once, so that nothing done to them afterwards changes the witness.
125
+ const me = JSON.parse(canonicalJson({ keys: by.keys, name: by.name }));
126
+ const mine = canonicalJson(me.keys);
127
+ const meFingerprint = await keySetFingerprint(me.keys);
128
+ const keys = signers.slice();
129
+ const without = withoutMethods.slice();
130
+
131
+ /** @type {Map<string, {sequence: {passkey: string, agent: string}, seal: object, statement: object, fingerprint: string, content: any}>} */
132
+ const books = new Map();
133
+ let ready = true;
134
+ if (state !== undefined) {
135
+ await restore(state);
136
+ ready = false;
137
+ }
138
+
139
+ // The calls wait for one another, so that two seals of one book are never weighed at the same time.
140
+ let turn = Promise.resolve();
141
+ const inTurn = (work) => {
142
+ const run = turn.then(work);
143
+ turn = run.catch(() => {});
144
+ return run;
145
+ };
146
+
147
+ function snapshot() {
148
+ return {
149
+ type: WITNESS_STATE_TYPE,
150
+ by: meFingerprint,
151
+ books: [...books.values()].map((b) => ({ passkey: b.sequence.passkey, agent: b.sequence.agent, seal: structuredClone(b.seal), statement: structuredClone(b.statement) })),
152
+ };
153
+ }
154
+
155
+ // A statement by this witness, for this seal, that checks: or why not.
156
+ async function ownStatement(record, seal) {
157
+ const parsed = await parseRecord(record, 'witness');
158
+ validateWitnessContent(parsed.content);
159
+ const c = parsed.content;
160
+ if (canonicalJson(c.by.keys) !== mine) throw new Refusal('witness-invalid', 'The statement is not signed with this witness\'s keys.');
161
+ if (c.seal !== seal.fingerprint || c.size !== seal.content.size || c.root !== seal.content.root) {
162
+ throw new Refusal('witness-invalid', 'The statement speaks of another seal.');
163
+ }
164
+ for (let i = 0; i < parsed.signatures.length; i++) {
165
+ const s = parsed.signatures[i];
166
+ const checked = await verifySignature(c.by.keys[i], s.signature, signingInput(s.protectedB64, parsed.payloadB64), without);
167
+ if (checked !== 'valid') throw new Refusal('witness-invalid', 'A signature on the statement does not check on this device.');
168
+ }
169
+ return parsed;
170
+ }
171
+
172
+ async function restore(saved) {
173
+ if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) throw f.fail('state', 'must be a state a witness handed back.');
174
+ f.members(saved, ['books', 'by', 'type'], [], 'state');
175
+ if (saved.type !== WITNESS_STATE_TYPE) throw f.fail('state.type', `must be ${WITNESS_STATE_TYPE}.`);
176
+ if (saved.by !== meFingerprint) throw f.fail('state.by', 'is the state of another witness: its key set is not this witness\'s.');
177
+ if (!Array.isArray(saved.books)) throw f.fail('state.books', 'must be a list.');
178
+ for (let i = 0; i < saved.books.length; i++) {
179
+ const b = saved.books[i];
180
+ f.members(b, ['agent', 'passkey', 'seal', 'statement'], [], `state.books[${i}]`);
181
+ f.fingerprintText(b.passkey, `state.books[${i}].passkey`);
182
+ f.fingerprintText(b.agent, `state.books[${i}].agent`);
183
+ const sequence = { passkey: b.passkey, agent: b.agent };
184
+ const seal = await checkSealAlone(b.seal, without);
185
+ if (seal.problems.length > 0) throw new Refusal(seal.problems[0].code, `The seal of book ${i + 1} in the state does not pass its check. ${seal.problems[0].message}`);
186
+ await ownStatement(b.statement, seal);
187
+ const identity = await identityOf(await keySetFingerprint(seal.content.by.keys), sequence);
188
+ if (books.has(identity)) throw f.fail(`state.books[${i}]`, 'the same person, agent and recorder are given twice.');
189
+ books.set(identity, { sequence, seal: structuredClone(b.seal), statement: structuredClone(b.statement), fingerprint: seal.fingerprint, content: seal.content });
190
+ }
191
+ }
192
+
193
+ // Everything that must hold before this witness takes a seal of a book,
194
+ // whether it is to sign for it (witness) or has signed for it before
195
+ // (update). Returns what it found, or a refusal.
196
+ async function weigh(entry, proof, firstLine, signing) {
197
+ try {
198
+ f.members(entry, ['seal'], ['stamps', 'witnesses'], 'entry');
199
+ } catch (e) {
200
+ return { refused: problemFrom(e) };
201
+ }
202
+ if (!(firstLine instanceof Uint8Array) || firstLine.length === 0) return refused('bad-field', 'The book\'s first line must be given as its bytes, without its line feed.');
203
+ if (!Array.isArray(proof)) return refused('proof-invalid', 'The proof must be a list of fingerprints, each 32 bytes.');
204
+ const path = [];
205
+ for (let i = 0; i < proof.length; i++) {
206
+ if (!(proof[i] instanceof Uint8Array) || proof[i].length !== 32) return refused('proof-invalid', 'The proof must be a list of fingerprints, each 32 bytes.');
207
+ path.push(proof[i]);
208
+ }
209
+ const seal = await checkSealAlone(entry.seal, without);
210
+ if (seal.problems.length > 0) return refused(seal.problems[0].code, `The seal did not pass its check. ${seal.problems[0].message}`);
211
+ if (seal.signatures.some((s) => s.state !== 'valid')) {
212
+ return refused('check-failed', 'A witness signs only for a seal whose three signatures it checked, and this device could not check them all.');
213
+ }
214
+ const c = seal.content;
215
+ let sequence;
216
+ try {
217
+ sequence = await sequenceOf(firstLine);
218
+ } catch (e) {
219
+ return { refused: problemFrom(e) };
220
+ }
221
+ const identity = await identityOf(await keySetFingerprint(c.by.keys), sequence);
222
+ const held = books.get(identity) ?? null;
223
+ if (held && held.fingerprint === seal.fingerprint) return { seal, identity, sequence, held, same: true };
224
+ const root = fromBase64url(c.root);
225
+ const firstLineTied = () => verifyInclusion(0, c.size, firstLine, path, root);
226
+ const number = numberOf(c);
227
+ if (held) {
228
+ const heldNumber = numberOf(held.content);
229
+ if (number < heldNumber) {
230
+ return refused('seal-mismatch', `The seal is of book ${number}, and this witness holds a seal of book ${heldNumber} of this person, agent and recorder. It signs for no seal of an earlier book again.`);
231
+ }
232
+ if (number > heldNumber + 1) {
233
+ return refused('seal-chain-broken', `The seal is of book ${number}, and the latest seal this witness holds of this person, agent and recorder is of book ${heldNumber}: the books between were not shown to it. Hand it their seals first, in their order.`);
234
+ }
235
+ if (number === heldNumber) {
236
+ if (c.size <= held.content.size) {
237
+ return refused('seal-mismatch', `This witness holds a seal of this book with ${held.content.size} entries. It signs for no other seal of this book that does not extend that one.`);
238
+ }
239
+ if (signing && (Object.hasOwn(c, 'previous') ? c.previous : null) !== held.fingerprint) {
240
+ return refused('seal-chain-broken', 'The seal does not name, as the seal before it, the latest seal of this book this witness signed for. Hand it the seals in between first, in their order.');
241
+ }
242
+ if (!(await verifyConsistency(held.content.size, c.size, fromBase64url(held.content.root), root, path))) {
243
+ return refused('proof-invalid', 'The consistency proof does not show that the seal extends the latest seal of this book this witness signed for.');
244
+ }
245
+ } else {
246
+ // The first seal of the next book: it starts from the latest seal the witness holds of the book before.
247
+ if (c.previous !== held.fingerprint) {
248
+ return refused('seal-chain-broken', `The first seal of book ${number} must name, as the seal before it, the latest seal this witness holds of book ${heldNumber}. Hand it the seals in between first, in their order.`);
249
+ }
250
+ if (!(await firstLineTied())) {
251
+ return refused('proof-invalid', 'The proof does not show that the line given as the book\'s first is the first entry of the book the seal covers.');
252
+ }
253
+ }
254
+ if (signing && parseTime(c.when) < parseTime(held.content.when)) return refused('time-went-backwards', 'The seal is dated before the latest seal of this person, agent and recorder this witness signed for.');
255
+ } else if (!(await firstLineTied())) {
256
+ return refused('proof-invalid', 'The proof does not show that the line given as the book\'s first is the first entry of the book the seal covers.');
257
+ }
258
+ return { seal, identity, sequence, held, same: false };
259
+ }
260
+
261
+ return {
262
+ /**
263
+ * Sign a statement for a seal, if it extends the latest seal of its book
264
+ * that this witness holds; or say why not. The state handed back already
265
+ * holds the new seal: save it before the statement is given out.
266
+ * @param {{seal: object, stamps?: any[], witnesses?: any[]}} entry the seal's entry; only "seal" is read
267
+ * @param {Uint8Array[]} proof see witnessProof
268
+ * @param {Uint8Array} firstLine the bytes of the book's first line
269
+ * @returns {Promise<{statement: object, state: object} | {refused: {code: string, message: string}}>}
270
+ */
271
+ witness(entry, proof, firstLine) {
272
+ return inTurn(async () => {
273
+ if (!ready) {
274
+ return refused('witness-not-ready', 'This witness was opened from a saved state, and has not been brought up to date and told so. It signs nothing until then.');
275
+ }
276
+ const found = await weigh(entry, proof, firstLine, true);
277
+ if (found.refused) return found;
278
+ const { seal, identity, sequence, same } = found;
279
+ // The same seal again: the same statement, with no second signature.
280
+ if (same) return { statement: structuredClone(found.held.statement), state: snapshot() };
281
+ let now;
282
+ try {
283
+ now = clock();
284
+ } catch {
285
+ now = NaN;
286
+ }
287
+ if (typeof now !== 'number' || !Number.isFinite(now)) return refused('check-failed', 'The witness\'s clock did not give a time.');
288
+ if (parseTime(seal.content.when) > now + CLOCK_ALLOWANCE_MS) return refused('time-went-backwards', 'The seal is dated more than 300 seconds after this witness\'s clock.');
289
+ let statement;
290
+ try {
291
+ ({ record: statement } = await writeWitness({ seal: seal.fingerprint, size: seal.content.size, root: seal.content.root, by: me, when: now }, keys));
292
+ // Every signature is checked before the statement is given out or kept.
293
+ await ownStatement(statement, seal);
294
+ } catch (e) {
295
+ const p = problemFrom(e);
296
+ return refused('check-failed', `The witness could not sign a statement that checks, so it signed none. ${p.message}`);
297
+ }
298
+ books.set(identity, { sequence, seal: structuredClone(entry.seal), statement, fingerprint: seal.fingerprint, content: seal.content });
299
+ return { statement: structuredClone(statement), state: snapshot() };
300
+ });
301
+ },
302
+
303
+ /**
304
+ * Bring the witness up to date: hand it a seal's entry that holds a
305
+ * statement this witness signed for that seal, from wherever what it
306
+ * gave out was kept (the book, or a copy kept apart from its state). It
307
+ * takes that seal as the latest of its book, where the seal extends the
308
+ * one it holds. It signs nothing.
309
+ * @param {{seal: object, witnesses: any[], stamps?: any[]}} entry
310
+ * @param {Uint8Array[]} proof see witnessProof
311
+ * @param {Uint8Array} firstLine
312
+ * @returns {Promise<{state: object} | {refused: {code: string, message: string}}>}
313
+ */
314
+ update(entry, proof, firstLine) {
315
+ return inTurn(async () => {
316
+ const found = await weigh(entry, proof, firstLine, false);
317
+ if (found.refused) return found;
318
+ const { seal, identity, sequence, same } = found;
319
+ const list = entry.witnesses;
320
+ if (!Array.isArray(list) || list.length < 1 || list.length > MAX_WITNESSES) return refused('witness-invalid', 'The entry holds no statement by this witness for the seal.');
321
+ let statement = null;
322
+ for (let i = 0; i < list.length && statement === null; i++) {
323
+ try {
324
+ await ownStatement(list[i], seal);
325
+ statement = JSON.parse(canonicalJson(list[i]));
326
+ } catch {
327
+ // Another witness's statement, or one that does not check: not this witness's.
328
+ }
329
+ }
330
+ if (statement === null) return refused('witness-invalid', 'The entry holds no statement by this witness for the seal.');
331
+ // The seal it holds already: nothing changes.
332
+ if (same) return { state: snapshot() };
333
+ books.set(identity, { sequence, seal: structuredClone(entry.seal), statement, fingerprint: seal.fingerprint, content: seal.content });
334
+ return { state: snapshot() };
335
+ });
336
+ },
337
+
338
+ /**
339
+ * Say that the witness, opened from a saved state, has been brought up
340
+ * to date. The witness cannot tell whether a state is the latest it
341
+ * handed back: this is the word of whoever runs it.
342
+ */
343
+ ready() {
344
+ ready = true;
345
+ },
346
+
347
+ /**
348
+ * The latest seal of a book that this witness holds, with its statement.
349
+ * @param {string} book the book's fingerprint, as bookIdentity gives it
350
+ * @returns {{seal: object, statement: object} | null}
351
+ */
352
+ latest(book) {
353
+ const held = typeof book === 'string' ? books.get(book) : undefined;
354
+ return held ? { seal: structuredClone(held.seal), statement: structuredClone(held.statement) } : null;
355
+ },
356
+
357
+ /** The witness's state as it stands, to be saved. */
358
+ state() {
359
+ return snapshot();
360
+ },
361
+ };
362
+ }