provared 0.5.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.
- package/CHANGELOG.md +982 -0
- package/README.md +6 -3
- package/bin/provared-check.mjs +115 -11
- package/docs/guide.md +290 -17
- package/docs/threat-model.md +242 -9
- package/package.json +2 -2
- package/page/check-page.js +11 -32
- package/page/index.html +4 -0
- package/page/render.js +157 -9
- package/spec/provared-format.md +686 -22
- package/src/book.js +539 -18
- package/src/check.d.ts +3 -0
- package/src/check.js +2 -0
- package/src/denial.js +123 -0
- package/src/der.js +55 -6
- package/src/dev.d.ts +1 -1
- package/src/dev.js +1 -1
- package/src/guard.js +12 -1
- package/src/index.js +10 -0
- package/src/jws.js +8 -0
- package/src/keys.js +48 -1
- package/src/recorder.js +131 -5
- package/src/seal.js +32 -4
- package/src/sealer.js +812 -0
- package/src/signatures.js +6 -5
- package/src/timestamp.js +55 -16
- package/src/tools.js +30 -13
- package/src/tree.js +139 -7
- package/src/types.d.ts +257 -9
- package/src/witness.js +63 -0
- package/src/witnessing.js +362 -0
package/src/sealer.js
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
// The central sealer: a party, run apart from whoever runs the agent, that
|
|
2
|
+
// holds a recorder's three keys and seals books it never sees. It is sent
|
|
3
|
+
// the leaf fingerprint of each new line (RFC 6962, section 2.1: the SHA-256
|
|
4
|
+
// of the byte 0x00 and the line), never the line. It keeps for each book
|
|
5
|
+
// the same running tree the checker's tree builder keeps (one fingerprint
|
|
6
|
+
// for each complete part of the tree), extended only by what it was sent,
|
|
7
|
+
// and on request signs a seal over its own tree: its own number of entries
|
|
8
|
+
// and its own top fingerprint, naming its own latest seal of that book.
|
|
9
|
+
// Format description, section 21.7.
|
|
10
|
+
//
|
|
11
|
+
// It cannot be made to sign a number of entries and a top fingerprint
|
|
12
|
+
// handed to it, nor a history that changes a line it has sealed (choice
|
|
13
|
+
// 388). Every book that starts with the same first line is one chain: a
|
|
14
|
+
// book with a first line it already holds may start only as the next book
|
|
15
|
+
// after the latest book with that first line. A seal it signs is added to
|
|
16
|
+
// the book through the stub writer's "add", which checks it against the
|
|
17
|
+
// whole book: a sealer that was sent a wrong fingerprint signs a seal the
|
|
18
|
+
// book refuses (seal-mismatch).
|
|
19
|
+
//
|
|
20
|
+
// One sealer may serve several recorders (forRecorder): each book, and
|
|
21
|
+
// every book that follows it, belongs to the recorder that started it, and
|
|
22
|
+
// no other recorder can send lines for it, seal it, bring it up to date,
|
|
23
|
+
// start a book after it or start a book with its first line. A recorder may
|
|
24
|
+
// be given rules: the agents whose permissions it may start books with (by
|
|
25
|
+
// the fingerprint of the agent's key set), and the most books it may hold.
|
|
26
|
+
// Where any recorder has a list of agents, every book is started with its
|
|
27
|
+
// first line itself, which must be a permission; one whose agent a
|
|
28
|
+
// recorder's list names is started only by that recorder. So no recorder
|
|
29
|
+
// can start a book whose first line is a permission for another recorder's
|
|
30
|
+
// agent.
|
|
31
|
+
//
|
|
32
|
+
// What it cannot see: the lines after the first. So it cannot tell that a
|
|
33
|
+
// book which starts from another first line repeats the lines of a book it
|
|
34
|
+
// has sealed. Nor, so, that a recorder started a book from a permission for
|
|
35
|
+
// its own agent and copied into it another recorder's agent's permissions
|
|
36
|
+
// and stubs, some left out: it seals such a book, which reads as intact
|
|
37
|
+
// and within its slips where one person's passkey signed the permissions
|
|
38
|
+
// for both agents. A seal of the other recorder's own book, held elsewhere
|
|
39
|
+
// and handed to the checker with it, shows a problem (book-cut-short or
|
|
40
|
+
// seal-not-in-book; or, where the two books' numbers differ, it cannot be
|
|
41
|
+
// compared, and gives no pass); a witness statement on such a seal is not counted
|
|
42
|
+
// (section 29). Readers of books from a shared sealer hold the recorder's
|
|
43
|
+
// own seals elsewhere, or use a sealer for each recorder.
|
|
44
|
+
//
|
|
45
|
+
// This is the part that keeps the trees and signs. Keeping its state on a
|
|
46
|
+
// disk, and taking requests over a network, are the business of whoever
|
|
47
|
+
// runs it (the program in sealer/ does both). Its state is handed back with
|
|
48
|
+
// every change that must last (a book started, a seal, a book brought up
|
|
49
|
+
// to date), and MUST be saved before the answer is given out: a sealer that
|
|
50
|
+
// loses its state can be made to sign a second history. Lines that no seal
|
|
51
|
+
// covers are not kept in the state: a sealer opened again stands at each
|
|
52
|
+
// book's latest seal (or, for a book with no seal, at its first line) and
|
|
53
|
+
// asks for the lines since.
|
|
54
|
+
//
|
|
55
|
+
// It checks every seal it signs, so it needs SLH-DSA: it opens only where
|
|
56
|
+
// the device has all three methods built in (Node.js).
|
|
57
|
+
|
|
58
|
+
import { MAX_ENTRIES, MAX_LINE_BYTES, checkSealAlone, entryLine } from './book.js';
|
|
59
|
+
import { Refusal, canonicalJson, formatTime, fromBase64url, parseTime, problemFrom, toBase64url, utf8 } from './encoding.js';
|
|
60
|
+
import * as f from './fields.js';
|
|
61
|
+
import { SEAL_METHODS, checkSealKeySet } from './keys.js';
|
|
62
|
+
import { CLOCK_ALLOWANCE_MS, decodeStamps, keySetFingerprint, sealOver } from './seal.js';
|
|
63
|
+
import { holdSigningKeys, methodAvailable } from './signatures.js';
|
|
64
|
+
import { leafHash, treeBuilder } from './tree.js';
|
|
65
|
+
import { sequenceOf } from './witnessing.js';
|
|
66
|
+
|
|
67
|
+
/** The "type" of a sealer's saved state. */
|
|
68
|
+
export const SEALER_STATE_TYPE = 'provared.sealer-state.v0';
|
|
69
|
+
|
|
70
|
+
/** The most books one sealer keeps: its whole state is written at each change that must last. */
|
|
71
|
+
export const MAX_SEALER_BOOKS = 1000;
|
|
72
|
+
|
|
73
|
+
/** The most agents one recorder's rules may name. */
|
|
74
|
+
export const MAX_RECORDER_AGENTS = 64;
|
|
75
|
+
|
|
76
|
+
/** How long a signer, and the time-stamp hook, may take, in milliseconds, unless the sealer is told otherwise. */
|
|
77
|
+
export const SEALER_WAIT_MS = 30 * 1000;
|
|
78
|
+
|
|
79
|
+
const MAX_WAIT_MS = 2 ** 31 - 1;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The leaf fingerprint of one line of a book: the SHA-256 of the byte 0x00
|
|
83
|
+
* and the line's bytes, without its line feed (RFC 6962, section 2.1;
|
|
84
|
+
* format description, section 10). This is what a recorder sends a central
|
|
85
|
+
* sealer in place of the line.
|
|
86
|
+
* @param {string} line one line of a book, without its line feed
|
|
87
|
+
* @returns {Promise<Uint8Array>} 32 bytes
|
|
88
|
+
*/
|
|
89
|
+
export async function leafFingerprint(line) {
|
|
90
|
+
if (typeof line !== 'string') throw f.fail('line', 'must be text: one line of a book, without its line feed.');
|
|
91
|
+
if (line.length === 0) throw f.fail('line', 'must not be empty: no line of a book is.');
|
|
92
|
+
if (line.includes('\n')) throw f.fail('line', 'must be one line of a book, without its line feed.');
|
|
93
|
+
if (!line.isWellFormed()) throw f.fail('line', 'must be well-formed Unicode text: a line of a book is written in UTF-8.');
|
|
94
|
+
if (line.length > MAX_LINE_BYTES || utf8(line).length > MAX_LINE_BYTES) throw f.fail('line', 'must be at most 131,072 bytes, as a line of a book is.');
|
|
95
|
+
return leafHash(utf8(line));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const refused = (code, message, more = {}) => ({ refused: { code, message }, ...more });
|
|
99
|
+
|
|
100
|
+
const OTHER_RECORDER = 'This book is another recorder\'s: a book, and every book that follows it, is sealed only for the recorder that started it.';
|
|
101
|
+
const BEFORE_CLOSED = (n) => `Book ${n}, which this book was started after, is closed: another book follows it. Nothing was signed for this book, so it can be released (releaseUnsealed, or the program's command release-unsealed).`;
|
|
102
|
+
|
|
103
|
+
// A seal's book number (section 21.6): absent is the first book.
|
|
104
|
+
const numberOf = (content) => (Object.hasOwn(content, 'book') ? content.book : 1);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which book a call is about: the leaf fingerprint of its first line, in
|
|
108
|
+
* base64url, for a first book; or {first, number} for any book. A recorder
|
|
109
|
+
* that starts a new book with the same permission as its first line starts
|
|
110
|
+
* a book with the same first line, so the number keeps the two apart.
|
|
111
|
+
* @returns {{first: string, number: number}}
|
|
112
|
+
*/
|
|
113
|
+
function bookRef(book) {
|
|
114
|
+
if (typeof book === 'string') {
|
|
115
|
+
f.fingerprintText(book, 'book');
|
|
116
|
+
return { first: book, number: 1 };
|
|
117
|
+
}
|
|
118
|
+
f.members(book, ['first'], ['number'], 'book');
|
|
119
|
+
f.fingerprintText(book.first, 'book.first');
|
|
120
|
+
const number = Object.hasOwn(book, 'number') ? book.number : 1;
|
|
121
|
+
if (!Number.isSafeInteger(number) || number < 1) throw f.fail('book.number', 'must be a whole number, 1 or more.');
|
|
122
|
+
return { first: book.first, number };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const keyOf = (ref) => `${ref.number} ${ref.first}`;
|
|
126
|
+
|
|
127
|
+
// Leaf fingerprints, each 32 bytes, copied.
|
|
128
|
+
function readLeaves(leaves) {
|
|
129
|
+
if (!Array.isArray(leaves) || leaves.length > MAX_ENTRIES) throw f.fail('leaves', 'must be a list of leaf fingerprints, at most 100,000.');
|
|
130
|
+
const out = [];
|
|
131
|
+
for (let i = 0; i < leaves.length; i++) {
|
|
132
|
+
const leaf = leaves[i];
|
|
133
|
+
if (!(leaf instanceof Uint8Array) || leaf.length !== 32) throw f.fail(`leaves[${i}]`, 'must be a leaf fingerprint: 32 bytes.');
|
|
134
|
+
out.push(leaf.slice());
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The sizes of the complete parts of a tree of n leaves, the largest first: the powers of two that add up to n.
|
|
140
|
+
function partSizes(n) {
|
|
141
|
+
const sizes = [];
|
|
142
|
+
for (let bit = 2 ** 30; bit >= 1; bit /= 2) if (Math.floor(n / bit) % 2 === 1) sizes.push(bit);
|
|
143
|
+
return sizes;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Parts as saved (fingerprints in base64url) for a tree of n leaves, read strictly.
|
|
147
|
+
function readParts(hashes, n, where) {
|
|
148
|
+
const sizes = partSizes(n);
|
|
149
|
+
if (!Array.isArray(hashes) || hashes.length !== sizes.length) throw f.fail(where, `must be the ${sizes.length} fingerprints of a tree of ${n} leaves.`);
|
|
150
|
+
return hashes.map((h, i) => {
|
|
151
|
+
f.fingerprintText(h, `${where}[${i}]`);
|
|
152
|
+
return { hash: fromBase64url(h), size: sizes[i] };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const writeParts = (parts) => parts.map((p) => toBase64url(p.hash));
|
|
157
|
+
|
|
158
|
+
// A recorder's rules, read strictly: "agents", the fingerprints of the agents' key sets whose permissions it may
|
|
159
|
+
// start books with (1 to 64, none twice), and "books", the most books it may hold (1 to 1,000). Either may be left out.
|
|
160
|
+
function readRules(given) {
|
|
161
|
+
f.members(given, [], ['agents', 'books'], 'rules');
|
|
162
|
+
let agents = null;
|
|
163
|
+
if (Object.hasOwn(given, 'agents')) {
|
|
164
|
+
if (!Array.isArray(given.agents) || given.agents.length < 1 || given.agents.length > MAX_RECORDER_AGENTS) {
|
|
165
|
+
throw f.fail('rules.agents', 'must be a list of 1 to 64 fingerprints of agents\' key sets.');
|
|
166
|
+
}
|
|
167
|
+
agents = new Set();
|
|
168
|
+
for (let i = 0; i < given.agents.length; i++) {
|
|
169
|
+
f.fingerprintText(given.agents[i], `rules.agents[${i}]`);
|
|
170
|
+
if (agents.has(given.agents[i])) throw f.fail(`rules.agents[${i}]`, 'is named twice.');
|
|
171
|
+
agents.add(given.agents[i]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let books = null;
|
|
175
|
+
if (Object.hasOwn(given, 'books')) {
|
|
176
|
+
if (!Number.isSafeInteger(given.books) || given.books < 1 || given.books > MAX_SEALER_BOOKS) throw f.fail('rules.books', 'must be a whole number from 1 to 1,000.');
|
|
177
|
+
books = given.books;
|
|
178
|
+
}
|
|
179
|
+
return { agents, books, text: JSON.stringify([agents ? [...agents].sort() : null, books]) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Wait for a promise at most "ms" milliseconds.
|
|
183
|
+
function within(promise, ms, what) {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const timer = setTimeout(() => reject(new Refusal('check-failed', `${what} gave no answer within ${ms} milliseconds.`)), ms);
|
|
186
|
+
Promise.resolve(promise).then(
|
|
187
|
+
(v) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
resolve(v);
|
|
190
|
+
},
|
|
191
|
+
(e) => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
reject(e);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Open a central sealer.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} o
|
|
203
|
+
* @param {{keys: object[], name: string}} o.by the recorder's public key set (Ed25519, ML-DSA-87, SLH-DSA-SHA2-256s) and its name (a label)
|
|
204
|
+
* @param {any[]} o.signers its three private keys, or signers (signatures.js), in the order of the key set, as writeSeal takes them
|
|
205
|
+
* @param {(fingerprintBytes: Uint8Array) => any} [o.stamp] asks a time-stamp service for a stamp over a seal's fingerprint:
|
|
206
|
+
* gives an item of "stamps" (format description, section 21.2), or null for none. No service is named here
|
|
207
|
+
* @param {object} [o.state] the state a sealer handed back before, as saved
|
|
208
|
+
* @param {() => number} [o.clock] the time now, in milliseconds since 1970; Date.now by default
|
|
209
|
+
* @param {number} [o.signWithin] how long each signature, and the time-stamp hook, may take, in milliseconds (30 seconds)
|
|
210
|
+
* @returns {Promise<object>} the sealer: extend, seal, update, latest, held, state, and forRecorder
|
|
211
|
+
*/
|
|
212
|
+
export async function openSealer({ by, signers, stamp, state, clock = Date.now, signWithin = SEALER_WAIT_MS } = {}) {
|
|
213
|
+
f.members(by, ['keys', 'name'], [], 'by');
|
|
214
|
+
f.label(by.name, 'by.name');
|
|
215
|
+
checkSealKeySet(by.keys, 'by.keys');
|
|
216
|
+
for (const alg of SEAL_METHODS) {
|
|
217
|
+
if (!(await methodAvailable(alg))) throw new Refusal('bad-key', `A sealer checks every seal it signs, and this device does not have ${alg} built in.`);
|
|
218
|
+
}
|
|
219
|
+
if (!Array.isArray(signers) || signers.length !== 3) throw f.fail('signers', 'must be the recorder\'s three private keys, or signers: Ed25519, ML-DSA-87, then SLH-DSA-SHA2-256s.');
|
|
220
|
+
if (stamp !== undefined && typeof stamp !== 'function') throw f.fail('stamp', 'must be a function that asks a time-stamp service for a stamp, or be left out.');
|
|
221
|
+
if (typeof clock !== 'function') throw f.fail('clock', 'must be a function that gives the time now.');
|
|
222
|
+
if (!(Number.isSafeInteger(signWithin) && signWithin >= 1 && signWithin <= MAX_WAIT_MS)) throw f.fail('signWithin', 'must be a number of milliseconds from 1 to 2,147,483,647.');
|
|
223
|
+
// Copied once, so that nothing done to them afterwards changes the sealer.
|
|
224
|
+
const me = JSON.parse(canonicalJson({ keys: by.keys, name: by.name }));
|
|
225
|
+
const mine = canonicalJson(me.keys);
|
|
226
|
+
const meFingerprint = await keySetFingerprint(me.keys);
|
|
227
|
+
// Each signer is read once, here, and checked against the key it must hold; each signature waits "signWithin" at most.
|
|
228
|
+
const keys = await holdSigningKeys(signers, SEAL_METHODS, me.keys, signWithin);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @typedef {{entry: object, fingerprint: string, content: any, parts: {hash: Uint8Array, size: number}[]}} Latest
|
|
232
|
+
* @typedef {{first: string, number: number, follows: string|null, size: number, builder: ReturnType<typeof treeBuilder>, closed: boolean, latest: Latest|null, recorder: string|null}} Book
|
|
233
|
+
* @type {Map<string, Book>}
|
|
234
|
+
*/
|
|
235
|
+
const books = new Map();
|
|
236
|
+
// Every book held with this first line, whatever its number.
|
|
237
|
+
const withFirst = (first) => [...books.values()].filter((b) => b.first === first);
|
|
238
|
+
// Each recorder's rules (forRecorder), fixed by the first call that gives them; and the recorder each agent belongs to.
|
|
239
|
+
/** @type {Map<string, {agents: Set<string>|null, books: number|null, text: string}>} */
|
|
240
|
+
const rules = new Map();
|
|
241
|
+
/** @type {Map<string, string>} */
|
|
242
|
+
const agentOwner = new Map();
|
|
243
|
+
if (state !== undefined) await restore(state);
|
|
244
|
+
|
|
245
|
+
// The calls wait for one another, so that two calls about one book never cross.
|
|
246
|
+
let turn = Promise.resolve();
|
|
247
|
+
const inTurn = (work) => {
|
|
248
|
+
const run = turn.then(work);
|
|
249
|
+
turn = run.catch(() => {});
|
|
250
|
+
return run;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function snapshot() {
|
|
254
|
+
return {
|
|
255
|
+
type: SEALER_STATE_TYPE,
|
|
256
|
+
by: meFingerprint,
|
|
257
|
+
books: [...books.values()].map((b) => {
|
|
258
|
+
const saved = { first: b.first, number: b.number, closed: b.closed };
|
|
259
|
+
if (b.recorder !== null) saved.recorder = b.recorder;
|
|
260
|
+
if (b.follows !== null) saved.follows = b.follows;
|
|
261
|
+
// A book is kept at its latest seal, or with none at its first line: the lines since are asked for again after a start.
|
|
262
|
+
if (b.latest) saved.latest = { entry: structuredClone(b.latest.entry), parts: writeParts(b.latest.parts) };
|
|
263
|
+
return saved;
|
|
264
|
+
}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A seal entry signed with this sealer's keys, that passes its check with all three signatures checked here; and
|
|
269
|
+
// what of it is kept (choices K67, K79): the seal, which it checks, and its time-stamps, which it reads for their form
|
|
270
|
+
// only, by the rules a book reads their form by (at most four, each at most 12,288 bytes, in base64url, none twice): it
|
|
271
|
+
// checks no time-stamp's signature and trusts no service. Never the witness statements, which it does not check either.
|
|
272
|
+
// An entry as a book holds it is one
|
|
273
|
+
// line, so one longer than a line of a book may be is refused before anything in it is checked. "optional" is the
|
|
274
|
+
// members it may hold besides the seal: a book's entry may hold witness statements, a saved state's may not.
|
|
275
|
+
async function ownSeal(entry, where, optional) {
|
|
276
|
+
f.members(entry, ['seal'], optional, where);
|
|
277
|
+
// The entry is read once, into its one written form, and only that copy is used from here on (choice K76): what is
|
|
278
|
+
// measured, what is checked and what is kept are one value, even where a member gives another value at each
|
|
279
|
+
// reading (a getter, a Proxy).
|
|
280
|
+
let line;
|
|
281
|
+
try {
|
|
282
|
+
line = entryLine(entry);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
const problem = problemFrom(e);
|
|
285
|
+
throw new Refusal(problem.code, `${where}: is not an entry a line of a book can hold. ${problem.message}`);
|
|
286
|
+
}
|
|
287
|
+
if (line.length > MAX_LINE_BYTES || utf8(line).length > MAX_LINE_BYTES) throw new Refusal('too-large', `${where}: is longer than a line of a book may be (131,072 bytes).`);
|
|
288
|
+
const plain = JSON.parse(line);
|
|
289
|
+
// Its members are looked at again in the copy: an object whose members change between readings may have written others.
|
|
290
|
+
f.members(plain, ['seal'], optional, where);
|
|
291
|
+
const kept = { seal: plain.seal };
|
|
292
|
+
if (Object.hasOwn(plain, 'stamps')) {
|
|
293
|
+
decodeStamps(plain.stamps);
|
|
294
|
+
kept.stamps = plain.stamps;
|
|
295
|
+
}
|
|
296
|
+
const checked = await checkSealAlone(plain.seal);
|
|
297
|
+
// On a device that opens a sealer all three methods are built in, so every signature is checked, and one that
|
|
298
|
+
// was not would be a problem too; the two are one test.
|
|
299
|
+
if (checked.problems.length > 0 || checked.signatures.some((s) => s.state !== 'valid')) {
|
|
300
|
+
const problem = checked.problems[0] ?? { code: 'check-failed', message: 'This device could not check all three signatures of the seal.' };
|
|
301
|
+
throw new Refusal(problem.code, `The seal ${where === 'entry' ? 'handed over' : `in ${where}`} does not pass its check. ${problem.message}`);
|
|
302
|
+
}
|
|
303
|
+
if (canonicalJson(checked.content.by.keys) !== mine) throw new Refusal('sealer-not-expected', 'The seal is not signed with this sealer\'s keys.');
|
|
304
|
+
// Copied again through its one written form, so that what is kept shares nothing with what was checked or handed over.
|
|
305
|
+
return { checked, kept: JSON.parse(canonicalJson(kept)) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function restore(saved) {
|
|
309
|
+
if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) throw f.fail('state', 'must be a state a sealer handed back.');
|
|
310
|
+
f.members(saved, ['books', 'by', 'type'], [], 'state');
|
|
311
|
+
if (saved.type !== SEALER_STATE_TYPE) throw f.fail('state.type', `must be ${SEALER_STATE_TYPE}.`);
|
|
312
|
+
if (saved.by !== meFingerprint) throw f.fail('state.by', 'is the state of another sealer: its key set is not this sealer\'s.');
|
|
313
|
+
if (!Array.isArray(saved.books) || saved.books.length > MAX_SEALER_BOOKS) throw f.fail('state.books', 'must be a list of at most 1,000 books.');
|
|
314
|
+
for (let i = 0; i < saved.books.length; i++) {
|
|
315
|
+
const where = `state.books[${i}]`;
|
|
316
|
+
const b = saved.books[i];
|
|
317
|
+
f.members(b, ['closed', 'first', 'number'], ['follows', 'latest', 'recorder'], where);
|
|
318
|
+
const ref = bookRef({ first: b.first, number: b.number });
|
|
319
|
+
if (typeof b.closed !== 'boolean') throw f.fail(`${where}.closed`, 'must be true or false.');
|
|
320
|
+
let recorder = null;
|
|
321
|
+
if (Object.hasOwn(b, 'recorder')) {
|
|
322
|
+
f.label(b.recorder, `${where}.recorder`);
|
|
323
|
+
recorder = b.recorder;
|
|
324
|
+
}
|
|
325
|
+
let follows = null;
|
|
326
|
+
if (Object.hasOwn(b, 'follows')) {
|
|
327
|
+
if (ref.number === 1) throw f.fail(`${where}.follows`, 'is given for a first book, which follows none.');
|
|
328
|
+
f.fingerprintText(b.follows, `${where}.follows`);
|
|
329
|
+
follows = b.follows;
|
|
330
|
+
}
|
|
331
|
+
let latest = null;
|
|
332
|
+
let size;
|
|
333
|
+
let parts;
|
|
334
|
+
if (Object.hasOwn(b, 'latest')) {
|
|
335
|
+
// A sealed book stands at its latest seal: the lines since are not kept, so none can rewrite a sealed line.
|
|
336
|
+
f.members(b.latest, ['entry', 'parts'], [], `${where}.latest`);
|
|
337
|
+
// A saved entry holds the seal and its time-stamps only (choice K67).
|
|
338
|
+
const { checked, kept } = await ownSeal(b.latest.entry, `${where}.latest.entry`, ['stamps']);
|
|
339
|
+
const c = checked.content;
|
|
340
|
+
if (numberOf(c) !== ref.number) throw f.fail(`${where}.latest`, 'is a seal of another book number.');
|
|
341
|
+
const latestParts = readParts(b.latest.parts, c.size, `${where}.latest.parts`);
|
|
342
|
+
if (toBase64url(await treeBuilder(latestParts).root()) !== c.root) throw f.fail(`${where}.latest.parts`, 'do not give the seal\'s top fingerprint.');
|
|
343
|
+
latest = { entry: kept, fingerprint: checked.fingerprint, content: c, parts: latestParts };
|
|
344
|
+
size = c.size;
|
|
345
|
+
parts = latestParts;
|
|
346
|
+
} else {
|
|
347
|
+
// A book with no seal stands at its first line, the line it is known by.
|
|
348
|
+
parts = [{ hash: fromBase64url(ref.first), size: 1 }];
|
|
349
|
+
size = 1;
|
|
350
|
+
if (ref.number > 1 && follows === null) throw f.fail(where, 'is a later book with no seal, so it must name the book it follows ("follows").');
|
|
351
|
+
}
|
|
352
|
+
if (books.has(keyOf(ref))) throw f.fail(where, 'the same book is given twice.');
|
|
353
|
+
books.set(keyOf(ref), { first: ref.first, number: ref.number, follows, size, builder: treeBuilder(parts), closed: b.closed, latest, recorder });
|
|
354
|
+
}
|
|
355
|
+
// Every book that follows another names one this state holds, with a seal, of the same recorder; no two follow the same one.
|
|
356
|
+
const followed = new Set();
|
|
357
|
+
for (const b of books.values()) {
|
|
358
|
+
if (b.follows === null) continue;
|
|
359
|
+
const key = keyOf({ first: b.follows, number: b.number - 1 });
|
|
360
|
+
const before = books.get(key);
|
|
361
|
+
if (!before && !b.latest) throw f.fail('state.books', 'a later book follows a book that this state does not hold.');
|
|
362
|
+
if (before && !before.latest) throw f.fail('state.books', 'a later book follows a book that has no seal.');
|
|
363
|
+
if (before && before.recorder !== b.recorder) throw f.fail('state.books', 'a later book follows a book of another recorder.');
|
|
364
|
+
if (followed.has(key)) throw f.fail('state.books', 'two books follow the same book.');
|
|
365
|
+
followed.add(key);
|
|
366
|
+
}
|
|
367
|
+
// The books of one first line belong to one recorder.
|
|
368
|
+
for (const b of books.values()) {
|
|
369
|
+
if (withFirst(b.first).some((other) => other.recorder !== b.recorder)) throw f.fail('state.books', 'books with one first line belong to more than one recorder.');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The book a later book follows, if this sealer holds it.
|
|
374
|
+
const followedBy = (b) => (b.follows === null ? null : (books.get(keyOf({ first: b.follows, number: b.number - 1 })) ?? null));
|
|
375
|
+
|
|
376
|
+
// The seal the next seal of a book must name: its own latest, or for the first seal of a later book the latest of the book before.
|
|
377
|
+
const beforeOf = (b) => b.latest ?? followedBy(b)?.latest ?? null;
|
|
378
|
+
|
|
379
|
+
// The first line handed over with a call ("line"), read: it must be the line the book is known by. Gives its text,
|
|
380
|
+
// undefined where none was handed over, or throws.
|
|
381
|
+
async function readLine(more, ref) {
|
|
382
|
+
if (!Object.hasOwn(more, 'line')) return undefined;
|
|
383
|
+
const leaf = await leafFingerprint(more.line);
|
|
384
|
+
if (toBase64url(leaf) !== ref.first) throw f.fail('line', 'is not the line the book is known by: its leaf fingerprint is not the book\'s first.');
|
|
385
|
+
return more.line;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Whether "who" may start a book (a book this sealer does not hold, by extend or by update) with this first line:
|
|
389
|
+
// within its book limit; and, where any recorder has a list of agents, only with the first line itself, a permission
|
|
390
|
+
// whose agent belongs to no other recorder and, where "who" has a list, is on it. Gives a refusal, or null.
|
|
391
|
+
async function mayStart(who, line) {
|
|
392
|
+
const own = who === null ? undefined : rules.get(who);
|
|
393
|
+
if (own?.books != null && [...books.values()].filter((b) => b.recorder === who).length >= own.books) {
|
|
394
|
+
return refused('too-large', `This recorder holds ${own.books} books, the most its rules allow. A book with no seal can be released (releaseUnsealed, or the program's command release-unsealed): nothing was signed for it.`);
|
|
395
|
+
}
|
|
396
|
+
if (agentOwner.size === 0) return null;
|
|
397
|
+
if (line === undefined) return refused('bad-field', 'line: a book is started with its first line itself, the permission it starts with, so that the sealer can tell whose agent it is for.');
|
|
398
|
+
let agent;
|
|
399
|
+
try {
|
|
400
|
+
({ agent } = await sequenceOf(utf8(line)));
|
|
401
|
+
} catch (e) {
|
|
402
|
+
const problem = problemFrom(e);
|
|
403
|
+
return refused(problem.code, `line: ${problem.message}`);
|
|
404
|
+
}
|
|
405
|
+
const owner = agentOwner.get(agent);
|
|
406
|
+
if (owner !== undefined && owner !== who) return refused('other-recorder', 'The permission on this first line is for an agent that another recorder\'s rules name: only that recorder starts books with it.');
|
|
407
|
+
if (own?.agents && !own.agents.has(agent)) return refused('agent-not-allowed', 'The permission on this first line is for an agent this recorder\'s rules do not name: it starts books only with permissions for its own agents.');
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function extend(who, book, at, leaves, more = {}) {
|
|
412
|
+
return inTurn(async () => {
|
|
413
|
+
let ref;
|
|
414
|
+
let list;
|
|
415
|
+
let follows;
|
|
416
|
+
let line;
|
|
417
|
+
try {
|
|
418
|
+
ref = bookRef(book);
|
|
419
|
+
if (!Number.isSafeInteger(at) || at < 0) throw f.fail('at', 'must be a whole number: the place of the first of these lines in the book, counting from 0.');
|
|
420
|
+
list = readLeaves(leaves);
|
|
421
|
+
f.members(more, [], ['follows', 'line'], 'more');
|
|
422
|
+
if (Object.hasOwn(more, 'follows')) {
|
|
423
|
+
f.fingerprintText(more.follows, 'follows');
|
|
424
|
+
follows = more.follows;
|
|
425
|
+
}
|
|
426
|
+
line = await readLine(more, ref);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
return { refused: problemFrom(e) };
|
|
429
|
+
}
|
|
430
|
+
let b = books.get(keyOf(ref));
|
|
431
|
+
const fresh = !b;
|
|
432
|
+
if (fresh) {
|
|
433
|
+
if (at !== 0) return refused('out-of-step', 'This sealer holds no line of this book, so the first lines it is sent must start at place 0.', { size: 0 });
|
|
434
|
+
if (list.length === 0) return refused('bad-field', 'leaves: must hold at least the book\'s first line, to start a book.');
|
|
435
|
+
if (toBase64url(list[0]) !== ref.first) return refused('bad-field', 'leaves[0]: is not the leaf fingerprint the book is known by: a book is known by its first line.');
|
|
436
|
+
if (books.size >= MAX_SEALER_BOOKS) return refused('too-large', 'This sealer holds 1,000 books, the most it keeps.');
|
|
437
|
+
const barred = await mayStart(who, line);
|
|
438
|
+
if (barred) return barred;
|
|
439
|
+
let before = null;
|
|
440
|
+
if (ref.number === 1) {
|
|
441
|
+
if (follows !== undefined) return refused('bad-field', 'follows: only a book after the first follows another.');
|
|
442
|
+
} else {
|
|
443
|
+
if (follows === undefined) return refused('bad-field', `follows: book ${ref.number} must name the book it follows: the leaf fingerprint of the first line of book ${ref.number - 1}.`);
|
|
444
|
+
before = books.get(keyOf({ first: follows, number: ref.number - 1 })) ?? null;
|
|
445
|
+
if (!before) return refused('unknown-book', `This sealer holds no book ${ref.number - 1} whose first line has that leaf fingerprint.`);
|
|
446
|
+
}
|
|
447
|
+
// Every book with one first line is one chain, of one recorder (choices K33, K34).
|
|
448
|
+
const kin = withFirst(ref.first);
|
|
449
|
+
if (kin.some((k) => k.recorder !== who)) return refused('other-recorder', OTHER_RECORDER);
|
|
450
|
+
if (kin.length > 0) {
|
|
451
|
+
const top = kin.reduce((a, k) => (k.number > a.number ? k : a));
|
|
452
|
+
if (ref.number !== top.number + 1 || follows !== ref.first) {
|
|
453
|
+
return refused('not-next-book', `This sealer holds book ${top.number} with this first line already: a book with this first line may start only as book ${top.number + 1}, following it ("follows": that same first line).`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (before) {
|
|
457
|
+
if (before.recorder !== who) return refused('other-recorder', OTHER_RECORDER);
|
|
458
|
+
if (!before.latest) return refused('not-sealed', `Book ${ref.number - 1} has no seal from this sealer yet. The first seal of book ${ref.number} names its last seal: seal it first.`);
|
|
459
|
+
if (before.closed) return refused('book-closed', `Book ${ref.number - 1} is closed: another book already follows it.`);
|
|
460
|
+
for (const other of books.values()) {
|
|
461
|
+
if (other.number === ref.number && other.follows === follows) return refused('book-closed', `Book ${ref.number - 1} is closed: another book already follows it.`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
b = { first: ref.first, number: ref.number, follows: follows ?? null, size: 0, builder: treeBuilder(), closed: false, latest: null, recorder: who };
|
|
465
|
+
} else {
|
|
466
|
+
if (b.recorder !== who) return refused('other-recorder', OTHER_RECORDER);
|
|
467
|
+
if (b.closed) return refused('book-closed', 'This book is closed: a later book follows it, so this sealer seals no more of it.');
|
|
468
|
+
if (follows !== undefined && follows !== b.follows) return refused('bad-field', 'follows: is not the book this book was started after.');
|
|
469
|
+
if (at !== b.size) return refused('out-of-step', `This sealer holds ${b.size} lines of this book: send the lines from place ${b.size}.`, { size: b.size });
|
|
470
|
+
}
|
|
471
|
+
if (b.size + list.length > MAX_ENTRIES) return refused('too-large', 'A book holds at most 100,000 entries.');
|
|
472
|
+
// Taken in whole or not at all.
|
|
473
|
+
const next = b.builder.fork();
|
|
474
|
+
for (const leaf of list) await next.addLeafHash(leaf);
|
|
475
|
+
b.builder = next;
|
|
476
|
+
b.size += list.length;
|
|
477
|
+
if (!fresh) return { size: b.size };
|
|
478
|
+
// A book started binds its first line, its recorder and the book it follows: that must last, so the state is handed back.
|
|
479
|
+
books.set(keyOf(ref), b);
|
|
480
|
+
return { size: b.size, state: snapshot() };
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function seal(who, book, more) {
|
|
485
|
+
return inTurn(async () => {
|
|
486
|
+
let ref;
|
|
487
|
+
let previous;
|
|
488
|
+
try {
|
|
489
|
+
ref = bookRef(book);
|
|
490
|
+
f.members(more, ['previous'], ['id', 'when'], 'more');
|
|
491
|
+
previous = more.previous;
|
|
492
|
+
if (previous !== null) f.fingerprintText(previous, 'previous');
|
|
493
|
+
if (Object.hasOwn(more, 'id')) f.id(more.id, 'id');
|
|
494
|
+
} catch (e) {
|
|
495
|
+
return { refused: problemFrom(e) };
|
|
496
|
+
}
|
|
497
|
+
const b = books.get(keyOf(ref));
|
|
498
|
+
if (!b) return refused('unknown-book', 'This sealer holds no line of this book.');
|
|
499
|
+
if (b.recorder !== who) return refused('other-recorder', OTHER_RECORDER);
|
|
500
|
+
if (b.closed) return refused('book-closed', 'This book is closed: a later book follows it, so this sealer seals no more of it.');
|
|
501
|
+
// The first seal of a later book only where the book it was started after is still open: once another book
|
|
502
|
+
// follows that one, this book waits for nothing (a state put back can leave one waiting).
|
|
503
|
+
if (!b.latest && followedBy(b)?.closed) return refused('book-closed', BEFORE_CLOSED(b.number - 1));
|
|
504
|
+
const before = beforeOf(b);
|
|
505
|
+
const own = before ? before.fingerprint : null;
|
|
506
|
+
if (previous !== own) {
|
|
507
|
+
return refused(
|
|
508
|
+
'not-up-to-date',
|
|
509
|
+
own === null
|
|
510
|
+
? 'The book names a seal before this one, and this sealer holds none of it: its state is behind the book. Bring it up to date first (update), with the latest seal of the book and the lines since.'
|
|
511
|
+
: 'The seal before this one, as the book shows it, is not the latest this sealer holds. If the book is behind, add the sealer\'s latest seal to it (latest); if the sealer is behind, bring it up to date first (update).',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (b.latest && b.size <= b.latest.content.size) {
|
|
515
|
+
return refused('nothing-new', `This sealer has taken in no line of this book since its latest seal, at ${b.latest.content.size} entries: send the line of that seal, and any after it, first.`);
|
|
516
|
+
}
|
|
517
|
+
let now;
|
|
518
|
+
try {
|
|
519
|
+
now = clock();
|
|
520
|
+
} catch {
|
|
521
|
+
now = NaN;
|
|
522
|
+
}
|
|
523
|
+
// A time a seal can be dated with: within the years a date can be written in (section 3.6).
|
|
524
|
+
const writable = (t) => typeof t === 'number' && Number.isFinite(t) && t >= 0 && t < 253402300800000;
|
|
525
|
+
if (!writable(now)) return refused('check-failed', 'The sealer\'s clock did not give a time.');
|
|
526
|
+
let when = now;
|
|
527
|
+
if (Object.hasOwn(more, 'when')) {
|
|
528
|
+
when = more.when instanceof Date ? more.when.getTime() : more.when;
|
|
529
|
+
if (!writable(when)) return refused('bad-field', 'when: must be a time: milliseconds since 1970, or a Date.');
|
|
530
|
+
if (Math.abs(when - now) > CLOCK_ALLOWANCE_MS) return refused('bad-field', 'when: must be within 300 seconds of this sealer\'s clock: a sealer dates its own seals.');
|
|
531
|
+
}
|
|
532
|
+
if (before && parseTime(formatTime(when)) < parseTime(before.content.when)) {
|
|
533
|
+
return refused('time-went-backwards', 'The seal would be dated before the seal it names.');
|
|
534
|
+
}
|
|
535
|
+
let made;
|
|
536
|
+
let checked;
|
|
537
|
+
try {
|
|
538
|
+
const fields = { by: me, book: b.number, when };
|
|
539
|
+
if (own !== null) fields.previous = own;
|
|
540
|
+
if (Object.hasOwn(more, 'id')) fields.id = more.id;
|
|
541
|
+
made = await sealOver(b.size, await b.builder.root(), fields, keys);
|
|
542
|
+
checked = await checkSealAlone(made.record);
|
|
543
|
+
} catch (e) {
|
|
544
|
+
return refused('check-failed', `The sealer could not sign a seal, so it gave none. ${problemFrom(e).message}`);
|
|
545
|
+
}
|
|
546
|
+
// Every seal is checked before it is kept or given out.
|
|
547
|
+
if (checked.problems.length > 0 || checked.signatures.length !== 3 || checked.signatures.some((s) => s.state !== 'valid')) {
|
|
548
|
+
return refused('check-failed', 'The seal the sealer signed does not pass its own check, so it gave none.');
|
|
549
|
+
}
|
|
550
|
+
const entry = { seal: made.record };
|
|
551
|
+
let stampProblem = null;
|
|
552
|
+
if (stamp) {
|
|
553
|
+
try {
|
|
554
|
+
const item = await within(stamp(made.fingerprintBytes.slice()), signWithin, 'The time-stamp hook');
|
|
555
|
+
if (item !== null && item !== undefined) {
|
|
556
|
+
const copy = JSON.parse(canonicalJson(item));
|
|
557
|
+
decodeStamps([copy]);
|
|
558
|
+
entry.stamps = [copy];
|
|
559
|
+
}
|
|
560
|
+
} catch (e) {
|
|
561
|
+
stampProblem = problemFrom(e);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Signed and checked: kept, whatever the time-stamp did.
|
|
565
|
+
const followed = b.latest ? null : followedBy(b);
|
|
566
|
+
b.latest = { entry, fingerprint: made.fingerprint, content: checked.content, parts: b.builder.parts() };
|
|
567
|
+
if (followed) followed.closed = true;
|
|
568
|
+
const answer = { entry: structuredClone(entry), line: entryLine(entry), fingerprint: made.fingerprint, fingerprintBytes: made.fingerprintBytes, state: snapshot() };
|
|
569
|
+
if (stampProblem) answer.stampProblem = stampProblem;
|
|
570
|
+
return answer;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function update(who, book, entry, leaves, more = {}) {
|
|
575
|
+
return inTurn(async () => {
|
|
576
|
+
let ref;
|
|
577
|
+
let list;
|
|
578
|
+
let checked;
|
|
579
|
+
let kept;
|
|
580
|
+
let line;
|
|
581
|
+
try {
|
|
582
|
+
ref = bookRef(book);
|
|
583
|
+
list = readLeaves(leaves);
|
|
584
|
+
f.members(more, [], ['line'], 'more');
|
|
585
|
+
line = await readLine(more, ref);
|
|
586
|
+
// The entry as the book holds it: its witness statements are allowed and set aside (choice K67).
|
|
587
|
+
({ checked, kept } = await ownSeal(entry, 'entry', ['stamps', 'witnesses']));
|
|
588
|
+
} catch (e) {
|
|
589
|
+
return { refused: problemFrom(e) };
|
|
590
|
+
}
|
|
591
|
+
const c = checked.content;
|
|
592
|
+
if (numberOf(c) !== ref.number) return refused('seal-mismatch', `The seal is of book ${numberOf(c)}, not of book ${ref.number}.`);
|
|
593
|
+
const held = books.get(keyOf(ref)) ?? null;
|
|
594
|
+
if (held && held.recorder !== who) return refused('other-recorder', OTHER_RECORDER);
|
|
595
|
+
if (held && held.closed) return refused('book-closed', 'This book is closed: a later book follows it, so this sealer seals no more of it.');
|
|
596
|
+
const base = held?.latest ?? null;
|
|
597
|
+
if (base && base.fingerprint === checked.fingerprint) {
|
|
598
|
+
if (list.length > 0) return refused('bad-field', 'leaves: this sealer holds this seal already as the latest of the book, so no lines are taken.');
|
|
599
|
+
return { size: held.size, state: snapshot() };
|
|
600
|
+
}
|
|
601
|
+
const baseSize = base ? base.content.size : 0;
|
|
602
|
+
if (c.size <= baseSize) {
|
|
603
|
+
return refused(
|
|
604
|
+
'seal-mismatch',
|
|
605
|
+
c.size === baseSize
|
|
606
|
+
? 'This sealer holds another seal of this book at the same number of entries. It takes no second seal at one size.'
|
|
607
|
+
: `The seal is earlier than the latest seal this sealer holds of this book, at ${baseSize} entries. A sealer is never moved back: add its latest seal to the book (latest).`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (list.length !== c.size - baseSize) return refused('bad-field', `leaves: must be the ${c.size - baseSize} leaf fingerprints from place ${baseSize} to the seal, at ${c.size} entries.`);
|
|
611
|
+
if (baseSize === 0 && toBase64url(list[0]) !== ref.first) return refused('bad-field', 'leaves[0]: is not the leaf fingerprint the book is known by: a book is known by its first line.');
|
|
612
|
+
// A book this sealer does not hold, brought in from a seal it signed: where it stands among the books it holds (choice K35).
|
|
613
|
+
let follows = held ? held.follows : null;
|
|
614
|
+
let followed = held && !held.latest ? followedBy(held) : null;
|
|
615
|
+
// A book waiting to follow a book that another book already follows takes no seal (choice K48).
|
|
616
|
+
if (followed?.closed) return refused('book-closed', BEFORE_CLOSED(followed.number));
|
|
617
|
+
// The first seal of a book started after another names that book's latest seal: where this sealer holds another as
|
|
618
|
+
// its latest, that book is behind, and is brought up to date first.
|
|
619
|
+
if (followed && c.previous !== followed.latest.fingerprint) {
|
|
620
|
+
return refused('not-up-to-date', `The seal names, as the seal before it, a seal that is not the latest this sealer holds of book ${followed.number}, the book this book was started after. Bring that book up to date first (update).`);
|
|
621
|
+
}
|
|
622
|
+
let earlier = [];
|
|
623
|
+
if (!held) {
|
|
624
|
+
if (books.size >= MAX_SEALER_BOOKS) return refused('too-large', 'This sealer holds 1,000 books, the most it keeps.');
|
|
625
|
+
// A book brought in is started here as much as one sent line by line: the same rules (choices K45, K47).
|
|
626
|
+
const barred = await mayStart(who, line);
|
|
627
|
+
if (barred) return barred;
|
|
628
|
+
const kin = withFirst(ref.first);
|
|
629
|
+
if (kin.some((k) => k.recorder !== who)) return refused('other-recorder', OTHER_RECORDER);
|
|
630
|
+
if (kin.some((k) => k.number > ref.number)) return refused('book-closed', `This sealer holds a later book with this first line: book ${ref.number} is closed.`);
|
|
631
|
+
// Every earlier book with this first line is over: a later one has a seal.
|
|
632
|
+
earlier = kin;
|
|
633
|
+
if (ref.number > 1 && Object.hasOwn(c, 'previous')) {
|
|
634
|
+
// The first seal of book n names the last seal of book n - 1: where this sealer holds that seal as the latest of
|
|
635
|
+
// a book, this is it, and that book is book n - 1 (a sealer names no other as the seal before a first seal).
|
|
636
|
+
const named = [...books.values()].find((x) => x.latest && x.latest.fingerprint === c.previous) ?? null;
|
|
637
|
+
if (named) {
|
|
638
|
+
if (named.recorder !== who) return refused('other-recorder', OTHER_RECORDER);
|
|
639
|
+
if (kin.length > 0 && named.first !== ref.first) return refused('not-next-book', 'This sealer holds a book with this first line already: a book with this first line follows only the latest book with that first line.');
|
|
640
|
+
const taken = [...books.values()].some((x) => x.number === ref.number && x.follows === named.first);
|
|
641
|
+
if (named.closed || taken) return refused('book-closed', `Book ${named.number} is closed: another book already follows it.`);
|
|
642
|
+
follows = named.first;
|
|
643
|
+
followed = named;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const builder = treeBuilder(base ? base.parts : []);
|
|
648
|
+
for (const leaf of list) await builder.addLeafHash(leaf);
|
|
649
|
+
if (toBase64url(await builder.root()) !== c.root) {
|
|
650
|
+
return refused('seal-mismatch', 'The lines handed over do not give the seal\'s top fingerprint from the latest seal this sealer holds of this book.');
|
|
651
|
+
}
|
|
652
|
+
const b = held ?? { first: ref.first, number: ref.number, follows, size: 0, builder: null, closed: false, latest: null, recorder: who };
|
|
653
|
+
b.builder = builder;
|
|
654
|
+
b.size = c.size;
|
|
655
|
+
b.latest = { entry: kept, fingerprint: checked.fingerprint, content: c, parts: builder.parts() };
|
|
656
|
+
if (!held) books.set(keyOf(ref), b);
|
|
657
|
+
// A seal of a later book shows that its first seal was signed: the book before is closed, and so is every earlier book with its first line.
|
|
658
|
+
if (followed) followed.closed = true;
|
|
659
|
+
for (const k of earlier) k.closed = true;
|
|
660
|
+
return { size: b.size, state: snapshot() };
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function latest(who, book) {
|
|
665
|
+
let ref;
|
|
666
|
+
try {
|
|
667
|
+
ref = bookRef(book);
|
|
668
|
+
} catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
const b = books.get(keyOf(ref));
|
|
672
|
+
if (!b || b.recorder !== who || !b.latest) return null;
|
|
673
|
+
return { entry: structuredClone(b.latest.entry), line: entryLine(b.latest.entry), fingerprint: b.latest.fingerprint };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function held(who, book) {
|
|
677
|
+
let ref;
|
|
678
|
+
try {
|
|
679
|
+
ref = bookRef(book);
|
|
680
|
+
} catch {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
const b = books.get(keyOf(ref));
|
|
684
|
+
if (!b || b.recorder !== who) return null;
|
|
685
|
+
return { first: b.first, number: b.number, follows: b.follows, size: b.size, closed: b.closed, latest: b.latest ? b.latest.fingerprint : null, sealed: b.latest ? b.latest.content.size : 0 };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// The calls, as one recorder (or, for the sealer's own methods, as no named recorder).
|
|
689
|
+
const view = (who) =>
|
|
690
|
+
Object.freeze({
|
|
691
|
+
/**
|
|
692
|
+
* Take in the leaf fingerprints of new lines of one book, in order.
|
|
693
|
+
* "at" is the place of the first of them in the book, counting from 0:
|
|
694
|
+
* it must be the number of lines this sealer already holds of the book,
|
|
695
|
+
* so that lines sent twice are never taken in twice. A book this sealer
|
|
696
|
+
* does not hold starts at place 0, with its first line, whose leaf
|
|
697
|
+
* fingerprint is the one the book is known by. A later book (number 2 or
|
|
698
|
+
* more) names, in "follows", the book before it (the leaf fingerprint of
|
|
699
|
+
* that book's first line), which must have a seal from this sealer. A
|
|
700
|
+
* book whose first line this sealer holds already may start only as the
|
|
701
|
+
* next book after the latest book with that first line. "line" is the
|
|
702
|
+
* book's first line itself: where any recorder has a list of agents, a
|
|
703
|
+
* book is started only with it, and it must be a permission for an
|
|
704
|
+
* agent of the caller's own (forRecorder). Where the call started a
|
|
705
|
+
* book, the answer holds the state: save it first.
|
|
706
|
+
* @param {string|{first: string, number?: number}} book
|
|
707
|
+
* @param {number} at
|
|
708
|
+
* @param {Uint8Array[]} leaves
|
|
709
|
+
* @param {{follows?: string, line?: string}} [more]
|
|
710
|
+
*/
|
|
711
|
+
extend: (book, at, leaves, more) => extend(who, book, at, leaves, more),
|
|
712
|
+
/**
|
|
713
|
+
* Sign a seal over the tree this sealer holds of a book: its number of
|
|
714
|
+
* lines and their top fingerprint. "previous" is the seal the new seal
|
|
715
|
+
* must name, as the caller's book shows it: its latest seal, or, for the
|
|
716
|
+
* first seal of a later book, the last seal of the book before; null for
|
|
717
|
+
* the first seal of a first book. It must be the one this sealer holds,
|
|
718
|
+
* or nothing is signed. The state handed back already holds the new
|
|
719
|
+
* seal: save it before the seal is given out.
|
|
720
|
+
* @param {string|{first: string, number?: number}} book
|
|
721
|
+
* @param {{previous: string|null, when?: number|Date, id?: string}} more
|
|
722
|
+
*/
|
|
723
|
+
seal: (book, more) => seal(who, book, more),
|
|
724
|
+
/**
|
|
725
|
+
* Bring the sealer up to date for one book, from a seal it signed that
|
|
726
|
+
* the book holds and its own state does not: the seal's entry, and the
|
|
727
|
+
* leaf fingerprints of the lines from the latest seal this sealer holds
|
|
728
|
+
* of the book (or from the start) up to the seal. They must give the
|
|
729
|
+
* seal's top fingerprint. It is never moved back. Where the seal is the
|
|
730
|
+
* first seal of a book after the first, and names the latest seal of a
|
|
731
|
+
* book it holds, that book is closed; where the sealer holds the book
|
|
732
|
+
* this book was started after at an earlier seal than the one named,
|
|
733
|
+
* nothing is taken until that book is brought up to date. A book it
|
|
734
|
+
* does not hold is started here, under the same rules as with extend,
|
|
735
|
+
* "line" included. It signs nothing. Of the entry it keeps the seal
|
|
736
|
+
* and its time-stamps (at most four), not the witness statements; an
|
|
737
|
+
* entry longer than a line of a book may be is refused (too-large).
|
|
738
|
+
* @param {string|{first: string, number?: number}} book
|
|
739
|
+
* @param {{seal: object, stamps?: any[], witnesses?: any[]}} entry the seal's entry, as the book holds it
|
|
740
|
+
* @param {Uint8Array[]} leaves
|
|
741
|
+
* @param {{line?: string}} [more]
|
|
742
|
+
*/
|
|
743
|
+
update: (book, entry, leaves, more) => update(who, book, entry, leaves, more),
|
|
744
|
+
/** The latest seal this sealer holds of a book: its entry (the seal and its time-stamps), its line and its fingerprint; or null. */
|
|
745
|
+
latest: (book) => latest(who, book),
|
|
746
|
+
/** What this sealer holds of a book: how many lines, its number, the book it follows, whether it is closed, and its latest seal; or null. */
|
|
747
|
+
held: (book) => held(who, book),
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
...view(null),
|
|
752
|
+
/** The sealer's state as it stands, to be saved. */
|
|
753
|
+
state() {
|
|
754
|
+
return snapshot();
|
|
755
|
+
},
|
|
756
|
+
/**
|
|
757
|
+
* The same calls, made for one named recorder: a book it starts, and
|
|
758
|
+
* every book that follows it, is its own, and no other recorder (nor the
|
|
759
|
+
* sealer's own calls) can extend, seal or bring it up to date, start a
|
|
760
|
+
* book after it, or start a book with its first line. Asked about
|
|
761
|
+
* another's book, it is answered as a book the sealer does not hold.
|
|
762
|
+
* "rules", where given: "agents", the fingerprints of the key sets of
|
|
763
|
+
* the agents whose permissions it may start books with (1 to 64), and
|
|
764
|
+
* "books", the most books it may hold (1 to 1,000). A recorder's rules
|
|
765
|
+
* are fixed by the first call that gives them; an agent belongs to one
|
|
766
|
+
* recorder only.
|
|
767
|
+
* @param {string} recorder a label, 1 to 200 characters
|
|
768
|
+
* @param {{agents?: string[], books?: number}} [given]
|
|
769
|
+
*/
|
|
770
|
+
forRecorder(recorder, given) {
|
|
771
|
+
f.label(recorder, 'recorder');
|
|
772
|
+
if (given !== undefined) {
|
|
773
|
+
const r = readRules(given);
|
|
774
|
+
const held = rules.get(recorder);
|
|
775
|
+
if (held) {
|
|
776
|
+
if (held.text !== r.text) throw f.fail('rules', 'are not the rules this recorder was given before: a recorder\'s rules are fixed by the first call that gives them.');
|
|
777
|
+
} else {
|
|
778
|
+
for (const agent of r.agents ?? []) {
|
|
779
|
+
if (agentOwner.has(agent)) throw f.fail('rules.agents', 'names an agent that another recorder\'s rules name: an agent belongs to one recorder.');
|
|
780
|
+
}
|
|
781
|
+
rules.set(recorder, r);
|
|
782
|
+
for (const agent of r.agents ?? []) agentOwner.set(agent, recorder);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return view(recorder);
|
|
786
|
+
},
|
|
787
|
+
/**
|
|
788
|
+
* Release the books that hold no seal from this sealer: nothing was
|
|
789
|
+
* signed for them, so taking them off frees their places (in the limit
|
|
790
|
+
* of books, as the next book after another, and their first lines).
|
|
791
|
+
* "recorder": only that recorder's books (null for the sealer's own
|
|
792
|
+
* calls); left out, every recorder's. The state handed back is to be
|
|
793
|
+
* saved.
|
|
794
|
+
* @param {{recorder?: string|null}} [o]
|
|
795
|
+
* @returns {Promise<{released: {first: string, number: number, recorder: string|null}[], state: object}>}
|
|
796
|
+
*/
|
|
797
|
+
releaseUnsealed(o = {}) {
|
|
798
|
+
return inTurn(async () => {
|
|
799
|
+
f.members(o, [], ['recorder'], 'options');
|
|
800
|
+
const all = !Object.hasOwn(o, 'recorder');
|
|
801
|
+
if (!all && o.recorder !== null) f.label(o.recorder, 'recorder');
|
|
802
|
+
const released = [];
|
|
803
|
+
for (const [key, b] of [...books]) {
|
|
804
|
+
if (b.latest || (!all && b.recorder !== o.recorder)) continue;
|
|
805
|
+
books.delete(key);
|
|
806
|
+
released.push({ first: b.first, number: b.number, recorder: b.recorder });
|
|
807
|
+
}
|
|
808
|
+
return { released, state: snapshot() };
|
|
809
|
+
});
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
}
|