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/src/book.js
ADDED
|
@@ -0,0 +1,2232 @@
|
|
|
1
|
+
// The book (a list of entries, one to a line), the chain, what a stub shows
|
|
2
|
+
// about the agent, the top fingerprint and the Show.
|
|
3
|
+
// Format description, sections 7 to 11 and 17 to 19.
|
|
4
|
+
//
|
|
5
|
+
// The checker keeps three questions apart:
|
|
6
|
+
// - was a problem found in the record? (problems)
|
|
7
|
+
// - was every signature checked on this device? (fullyChecked)
|
|
8
|
+
// - did the agent stay in its slip? (breaches)
|
|
9
|
+
// A breach is not a fault in the record. It is what a sound record shows.
|
|
10
|
+
// "Intact" is said only when no problem was found and every signature was
|
|
11
|
+
// checked.
|
|
12
|
+
|
|
13
|
+
import { Refusal, canonicalJson, formatTime, fromBase64url, parseCanonical, parseTime, problemFrom, toBase64url, utf8 } from './encoding.js';
|
|
14
|
+
import * as f from './fields.js';
|
|
15
|
+
import { checkBlockStamp } from './blockstamp.js';
|
|
16
|
+
import { parseRecord, signingInput } from './jws.js';
|
|
17
|
+
import { MAX_PASSES, validatePassContent } from './pass.js';
|
|
18
|
+
import { CLOCK_ALLOWANCE_MS, decodeStamps, keySetFingerprint, validateSealContent } from './seal.js';
|
|
19
|
+
import { verifySignature } from './signatures.js';
|
|
20
|
+
import { validateAcknowledgementContent, validateCancellationContent, validateVouchingContent, validateWithdrawalContent } from './standing.js';
|
|
21
|
+
import { requestOf, validateApprovalContent } from './approval.js';
|
|
22
|
+
import { compareWithSlip, newTally, settlePeriods } from './compare.js';
|
|
23
|
+
import { validateRefusalContent, validateTermsContent } from './service.js';
|
|
24
|
+
import { checkPasskeySignature, checkSlip } from './slip.js';
|
|
25
|
+
import { validateCountersignatureContent, validateStubContent } from './stub.js';
|
|
26
|
+
import { checkStamp } from './timestamp.js';
|
|
27
|
+
import { inclusionPath, treeBuilder, treeRoot, verifyInclusion } from './tree.js';
|
|
28
|
+
|
|
29
|
+
/** The most entries a book may hold. */
|
|
30
|
+
export const MAX_ENTRIES = 100000;
|
|
31
|
+
/** The longest line of a book, in bytes. */
|
|
32
|
+
export const MAX_LINE_BYTES = 131072;
|
|
33
|
+
/** The most pages a Show may hold. */
|
|
34
|
+
export const MAX_PAGES = 64;
|
|
35
|
+
/** The most cancellations that may be handed over beside a book. */
|
|
36
|
+
export const MAX_HELD = 16;
|
|
37
|
+
/** The most acknowledgements that may be handed over with one cancellation. */
|
|
38
|
+
export const MAX_ACKNOWLEDGEMENTS = 4;
|
|
39
|
+
|
|
40
|
+
// What can be said about time, with and without an outside time-stamp.
|
|
41
|
+
const NOT_STAMPED =
|
|
42
|
+
'Nothing here is time-stamped by an outside service that you named as trusted. So it does not show when the record was made, and the latest stubs under a slip or a pass, or a slip or a pass with all its stubs, could have been removed.';
|
|
43
|
+
const blockWords = (sealed) =>
|
|
44
|
+
`A block of a public blockchain, which you trust to be part of the chain, covers the first ${sealed.entries} entries: they existed by ${sealed.when}, taking the time the block states as right to within two hours. What came after is not time-stamped, and a later seal with everything after it could have been removed: compare the top fingerprint and the number of entries with a copy you trust. A block time-stamp rests on SHA-256 and on the block being part of the chain, not on a signature.`;
|
|
45
|
+
const stampedWords = (sealed) =>
|
|
46
|
+
sealed.by === 'block'
|
|
47
|
+
? blockWords(sealed)
|
|
48
|
+
: `An outside time-stamp covers the first ${sealed.entries} entries: they existed by ${sealed.when}, as the time-stamp service states. What came after is not time-stamped, and a later seal with everything after it could have been removed: compare the top fingerprint and the number of entries with a copy you trust. Time-stamp services sign with methods of today's kind, which are not quantum-safe.`;
|
|
49
|
+
|
|
50
|
+
/** What no check of a draft version 0 record can show. Stated with every result. */
|
|
51
|
+
export const LIMITS = [
|
|
52
|
+
'It shows what was recorded, not what was left out. An agent that acts and writes no stub leaves no trace here.',
|
|
53
|
+
'A one-sided stub shows what the agent\'s side said, not what the other side did.',
|
|
54
|
+
'A refusal shows what the service\'s side said, not what the agent did.',
|
|
55
|
+
'It does not show that an action was wise, lawful or wanted.',
|
|
56
|
+
'A rule of conduct in a slip, such as "never claim to be a human being", is the person\'s signed instruction. No check can show that the agent kept it.',
|
|
57
|
+
'It does not show who was holding the device when the passkey signed.',
|
|
58
|
+
'A name in a record is a label. Only keys are checked.',
|
|
59
|
+
NOT_STAMPED,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write one entry of a book as its line (without the line feed).
|
|
64
|
+
* @param {object} entry one of: {slip}, {stub, countersignature?, approval?}, {refusal}, {terms}, {seal, stamps?},
|
|
65
|
+
* {cancellation, stamps?}, {acknowledgement}, {vouching}, {withdrawal}, {pass}
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function entryLine(entry) {
|
|
69
|
+
return canonicalJson(entry);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write a book from its entries.
|
|
74
|
+
* @param {object[]} entries
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function writeBook(entries) {
|
|
78
|
+
return entries.map((e) => entryLine(e) + '\n').join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Split a book into its lines, stopping as soon as there are too many, so
|
|
82
|
+
// that a flood of line feeds costs nothing. A missing line feed after the
|
|
83
|
+
// last line is allowed.
|
|
84
|
+
function splitLines(text) {
|
|
85
|
+
const lines = [];
|
|
86
|
+
let at = 0;
|
|
87
|
+
while (at < text.length) {
|
|
88
|
+
if (lines.length >= MAX_ENTRIES) throw new Refusal('too-large', 'The book holds more than 100,000 entries.');
|
|
89
|
+
const feed = text.indexOf('\n', at);
|
|
90
|
+
const end = feed === -1 ? text.length : feed;
|
|
91
|
+
lines.push(text.slice(at, end));
|
|
92
|
+
at = end + 1;
|
|
93
|
+
}
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check the two signatures of a stub or a countersignature against a key set.
|
|
98
|
+
async function checkKeySetSignatures(parsed, keys, without) {
|
|
99
|
+
const states = [];
|
|
100
|
+
for (let i = 0; i < parsed.signatures.length; i++) {
|
|
101
|
+
const s = parsed.signatures[i];
|
|
102
|
+
const state = await verifySignature(keys[i], s.signature, signingInput(s.protectedB64, parsed.payloadB64), without);
|
|
103
|
+
states.push({ method: keys[i].alg, state });
|
|
104
|
+
}
|
|
105
|
+
return states;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function overall(states) {
|
|
109
|
+
if (states.some((s) => s.state === 'invalid')) return 'invalid';
|
|
110
|
+
if (states.some((s) => s.state === 'unavailable')) return 'unavailable';
|
|
111
|
+
return 'valid';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// How much of an entry's own signing was confirmed on this device: "all",
|
|
115
|
+
// "some" (one of two signatures; the other method is not built in) or "none".
|
|
116
|
+
function verifiedBy(states) {
|
|
117
|
+
if (states.length === 0 || states.some((s) => s.state === 'invalid')) return 'none';
|
|
118
|
+
const valid = states.filter((s) => s.state === 'valid').length;
|
|
119
|
+
return valid === states.length ? 'all' : valid > 0 ? 'some' : 'none';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a reader keeps while it reads the entries of a book, one line at a
|
|
124
|
+
* time. Everything worked out from the lines so far is here, so that one
|
|
125
|
+
* more line can be read without reading the others again.
|
|
126
|
+
*
|
|
127
|
+
* In "whole" mode the lines are a whole book: the chain and the running
|
|
128
|
+
* totals are checked. Otherwise they are single pages out of a book, and
|
|
129
|
+
* only what one page can show is checked.
|
|
130
|
+
*/
|
|
131
|
+
function newReader(options, whole) {
|
|
132
|
+
const ids = new Set();
|
|
133
|
+
// The ids of the approvals used so far: one approval is one action.
|
|
134
|
+
ids.approvals = new Set();
|
|
135
|
+
// The vouching records so far, by fingerprint; and by whom they vouch
|
|
136
|
+
// for, so that looking one up does not mean reading them all.
|
|
137
|
+
/** @type {Map<string, any>} */
|
|
138
|
+
const vouchings = new Map();
|
|
139
|
+
vouchings.index = new Map();
|
|
140
|
+
return {
|
|
141
|
+
options,
|
|
142
|
+
whole,
|
|
143
|
+
// Whether this reader was split off another one to try one more stub, and reads nothing else (forkReader).
|
|
144
|
+
forked: false,
|
|
145
|
+
entries: [],
|
|
146
|
+
/** @type {Map<string, any>} */
|
|
147
|
+
slips: new Map(),
|
|
148
|
+
/** @type {Map<string, any>} */
|
|
149
|
+
terms: new Map(),
|
|
150
|
+
ids,
|
|
151
|
+
// The passes so far, by fingerprint: permissions handed on to helper agents.
|
|
152
|
+
/** @type {Map<string, any>} */
|
|
153
|
+
passes: new Map(),
|
|
154
|
+
vouchings,
|
|
155
|
+
// The seals so far: the last one, how far an outside time-stamp reaches,
|
|
156
|
+
// and the tree as it grows, so that each seal can be compared with the
|
|
157
|
+
// entries before it.
|
|
158
|
+
seals: { count: 0, last: null, lastWhen: -Infinity, lastStampTime: -Infinity, latest: -Infinity, next: 0, sealed: null },
|
|
159
|
+
tree: whole ? treeBuilder() : null,
|
|
160
|
+
// Problems with what was handed over beside the record (disclosures), which are not faults of an entry.
|
|
161
|
+
handedProblems: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Read one more line: check it against everything read before it, and
|
|
167
|
+
* keep what later lines will need.
|
|
168
|
+
* @param {ReturnType<typeof newReader>} reader
|
|
169
|
+
* @param {{index: number, line: string, disclosures?: string[]}} item the line, its place in the book, and for a
|
|
170
|
+
* page of a Show the disclosures handed over with it
|
|
171
|
+
*/
|
|
172
|
+
async function readLine(reader, item) {
|
|
173
|
+
const { options, whole, entries, slips, terms, ids, passes, vouchings, seals, tree, handedProblems } = reader;
|
|
174
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
175
|
+
{
|
|
176
|
+
const { index, line } = item;
|
|
177
|
+
const entry = { index, kind: 'unreadable', fingerprint: null, verified: 'none', problems: [], breaches: [], notes: [] };
|
|
178
|
+
entries.push(entry);
|
|
179
|
+
try {
|
|
180
|
+
// A character is at least one byte, so the first test needs no encoding.
|
|
181
|
+
if (line.length > MAX_LINE_BYTES || utf8(line).length > MAX_LINE_BYTES) {
|
|
182
|
+
throw new Refusal('too-large', 'The line is longer than 131,072 bytes.');
|
|
183
|
+
}
|
|
184
|
+
// Three common slips get words of their own; the codes are those any
|
|
185
|
+
// other such line gets.
|
|
186
|
+
if (line === '') throw new Refusal('not-json', 'The line is empty. A book holds no empty lines, and ends with a single line feed.');
|
|
187
|
+
if (line.startsWith('')) throw new Refusal('not-json', 'The line starts with a byte-order mark, which a book may not hold. Save the file as UTF-8 without one.');
|
|
188
|
+
if (line.endsWith('\r')) {
|
|
189
|
+
throw new Refusal('line-not-canonical', 'The line ends with a carriage return. A line of a book ends with a line feed alone, with no carriage return before it.');
|
|
190
|
+
}
|
|
191
|
+
const value = parseCanonical(line, 'line-not-canonical');
|
|
192
|
+
const names = value !== null && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value).sort().join(',') : '';
|
|
193
|
+
// A reader split off to try one more stub reads nothing else: a stub
|
|
194
|
+
// is the one kind of entry that changes nothing read before it.
|
|
195
|
+
if (reader.forked && !STUB_ENTRIES.includes(names)) throw new Error('only a stub may be tried');
|
|
196
|
+
if (names === 'slip') {
|
|
197
|
+
entry.kind = 'slip';
|
|
198
|
+
const check = await checkSlip(value.slip, options, item.disclosures);
|
|
199
|
+
// (Disclosures on a page of a Show and disclosures handed over beside it are used together: see checkSlip.)
|
|
200
|
+
for (const p of check.disclosureProblems) {
|
|
201
|
+
handedProblems.push({
|
|
202
|
+
code: p.code,
|
|
203
|
+
message: `The disclosures handed over for the slip at entry ${index} did not pass their check, so they were not used: the slip was checked with those fields covered. ${p.message}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
Object.assign(entry, {
|
|
207
|
+
covered: check.covered,
|
|
208
|
+
fingerprint: check.fingerprint,
|
|
209
|
+
problems: check.problems,
|
|
210
|
+
notes: check.notes,
|
|
211
|
+
signature: check.signature,
|
|
212
|
+
issuerKey: check.issuerKey,
|
|
213
|
+
content: check.content,
|
|
214
|
+
});
|
|
215
|
+
if (check.content) {
|
|
216
|
+
if (ids.has(check.content.id)) entry.problems.push({ code: 'duplicate-id', message: 'Another record has the same unique number.' });
|
|
217
|
+
ids.add(check.content.id);
|
|
218
|
+
}
|
|
219
|
+
if (check.fingerprint) {
|
|
220
|
+
if (slips.has(check.fingerprint)) {
|
|
221
|
+
entry.problems.push({ code: 'duplicate-slip', message: 'This slip is already in the book.' });
|
|
222
|
+
} else {
|
|
223
|
+
slips.set(check.fingerprint, {
|
|
224
|
+
content: check.content,
|
|
225
|
+
usable: entry.problems.length === 0,
|
|
226
|
+
verified: entry.problems.length === 0 && check.signature.state === 'valid',
|
|
227
|
+
next: 0,
|
|
228
|
+
last: null,
|
|
229
|
+
lastWhen: -Infinity,
|
|
230
|
+
tally: newTally(),
|
|
231
|
+
// The stubs compared so far, and the cancellation, if there is one.
|
|
232
|
+
stubs: [],
|
|
233
|
+
cancelled: null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
entry.verified = check.signature.state === 'valid' ? 'all' : 'none';
|
|
238
|
+
// Who vouches for the names in the slip, if anyone does.
|
|
239
|
+
if (check.content) {
|
|
240
|
+
const s = check.content;
|
|
241
|
+
// A vouching record must be in force for the whole of the slip's time.
|
|
242
|
+
const span = { from: parseTime(s.validFrom), until: parseTime(s.validUntil) };
|
|
243
|
+
entry.vouched = {
|
|
244
|
+
issuer: vouchFor(vouchings, 'person', s.issuer.key, s.issuer.name, span),
|
|
245
|
+
agent: vouchFor(vouchings, 'agent', s.agent.keys, s.agent.name, span),
|
|
246
|
+
// Every service has a place here, so that looking one up by its id finds only what was put there.
|
|
247
|
+
services: Object.fromEntries(s.with.map((w) => [w.id, keysOf(w) ? vouchFor(vouchings, 'service', w.keys, w.name, span) : null])),
|
|
248
|
+
};
|
|
249
|
+
// A name that an organisation the checker trusts vouches for is no longer only a label.
|
|
250
|
+
if (entry.vouched.issuer && entry.vouched.issuer.counted) {
|
|
251
|
+
entry.notes = entry.notes.filter((n) => !n.startsWith('The issuer\'s key was not compared'));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} else if (STUB_ENTRIES.includes(names)) {
|
|
255
|
+
entry.kind = 'stub';
|
|
256
|
+
await checkStubEntry(entry, value, slips, terms, passes, ids, without, whole);
|
|
257
|
+
} else if (names === 'refusal') {
|
|
258
|
+
entry.kind = 'refusal';
|
|
259
|
+
await checkRefusalEntry(entry, value, slips, ids, without, whole);
|
|
260
|
+
} else if (names === 'terms') {
|
|
261
|
+
entry.kind = 'terms';
|
|
262
|
+
await checkTermsEntry(entry, value, terms, ids, without);
|
|
263
|
+
} else if (names === 'seal' || names === 'seal,stamps') {
|
|
264
|
+
entry.kind = 'seal';
|
|
265
|
+
// The top fingerprint of the entries before it is worked out only once the seal has been read.
|
|
266
|
+
const before = whole ? async () => ({ size: index, root: toBase64url(await tree.root()) }) : null;
|
|
267
|
+
await checkSealEntry(entry, value, seals, ids, options, before, entries, !whole);
|
|
268
|
+
if (entry.content) {
|
|
269
|
+
// In force at the time the seal states and, where a counted time-stamp says when the seal existed, then too.
|
|
270
|
+
const times = [parseTime(entry.content.when)];
|
|
271
|
+
if (entry.stampedAt) times.push(parseTime(entry.stampedAt));
|
|
272
|
+
entry.vouched = vouchFor(vouchings, 'recorder', entry.content.by.keys, entry.content.by.name, { from: Math.min(...times), until: Math.max(...times) + 1 });
|
|
273
|
+
if (entry.vouched && entry.vouched.counted) entry.notes = entry.notes.filter((n) => !n.startsWith('The keys that signed this seal were not compared'));
|
|
274
|
+
}
|
|
275
|
+
} else if (names === 'cancellation' || names === 'cancellation,stamps') {
|
|
276
|
+
entry.kind = 'cancellation';
|
|
277
|
+
await checkCancellationEntry(entry, value, slips, ids, options, whole);
|
|
278
|
+
} else if (names === 'acknowledgement') {
|
|
279
|
+
entry.kind = 'acknowledgement';
|
|
280
|
+
await checkAcknowledgementEntry(entry, value, slips, passes, ids, entries, without, whole);
|
|
281
|
+
} else if (names === 'pass') {
|
|
282
|
+
entry.kind = 'pass';
|
|
283
|
+
await checkPassEntry(entry, value, slips, passes, ids, without, whole);
|
|
284
|
+
if (entry.content) {
|
|
285
|
+
const span = { from: parseTime(entry.content.validFrom), until: parseTime(entry.content.validUntil) };
|
|
286
|
+
entry.vouched = vouchFor(vouchings, 'agent', entry.content.to.keys, entry.content.to.name, span);
|
|
287
|
+
}
|
|
288
|
+
} else if (names === 'vouching') {
|
|
289
|
+
entry.kind = 'vouching';
|
|
290
|
+
await checkVouchingEntry(entry, value, vouchings, ids, options);
|
|
291
|
+
} else if (names === 'withdrawal') {
|
|
292
|
+
entry.kind = 'withdrawal';
|
|
293
|
+
await checkWithdrawalEntry(entry, value, vouchings, ids, options, whole);
|
|
294
|
+
} else {
|
|
295
|
+
throw new Refusal('unknown-entry', 'The line is not one of the known kinds of entry.');
|
|
296
|
+
}
|
|
297
|
+
// Only a slip may hold covered fields: disclosures handed over with any other page belong to nothing.
|
|
298
|
+
if (item.disclosures !== undefined && entry.kind !== 'slip') {
|
|
299
|
+
entry.problems.push({ code: 'cover-invalid', message: 'Disclosures were handed over with a page that is not a slip. Only a slip may hold covered fields.' });
|
|
300
|
+
}
|
|
301
|
+
} catch (e) {
|
|
302
|
+
entry.problems.push(problemFrom(e));
|
|
303
|
+
}
|
|
304
|
+
if (entry.problems.length) entry.verified = 'none';
|
|
305
|
+
// The latest time that a sound entry so far states: a seal cannot be dated before it.
|
|
306
|
+
else if (entry.verified !== 'none' && whenOf(entry) > seals.latest) seals.latest = whenOf(entry);
|
|
307
|
+
if (tree) await tree.add(utf8(line));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The findings that can be made only once every stub so far has been read:
|
|
313
|
+
* the limits "in any period" (a stub further on in the book, in another
|
|
314
|
+
* chain, may be dated earlier), and what a cancellation handed over beside
|
|
315
|
+
* the book shows (checkHeld). They are worked out afresh for each answer
|
|
316
|
+
* and kept apart from the entries, each list under its entry's own list of
|
|
317
|
+
* findings, so that a book can be carried on after an answer was given.
|
|
318
|
+
* @returns {Map<object[], {code: string, message: string}[]>}
|
|
319
|
+
*/
|
|
320
|
+
function settleLate(reader) {
|
|
321
|
+
const late = new Map();
|
|
322
|
+
// The slips first, then the passes from the last in the book to the
|
|
323
|
+
// first, which is from the pass nearest a stub upwards: a finding
|
|
324
|
+
// already given for a stub is not given again (compare.js).
|
|
325
|
+
for (const slip of reader.slips.values()) if (slip.content) settlePeriods(slip.content, slip.tally, late);
|
|
326
|
+
for (const pass of [...reader.passes.values()].reverse()) settlePeriods(passAsSlip(pass.content), pass.tally, late);
|
|
327
|
+
return late;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// An entry as an answer gives it: with the findings that were worked out
|
|
331
|
+
// last, and with no time of its own if it did not pass its check. The
|
|
332
|
+
// entry the reader keeps is left as it is.
|
|
333
|
+
function answered(entry, late, entries) {
|
|
334
|
+
const more = late.get(entry.breaches);
|
|
335
|
+
const copy = more && more.length > 0 ? { ...entry, breaches: [...entry.breaches, ...more] } : { ...entry };
|
|
336
|
+
// A cancellation that passed when its acknowledgement was read may have
|
|
337
|
+
// failed since: a later seal's time-stamp can show that it is not dated
|
|
338
|
+
// as it says. The acknowledgement then names a cancellation that did not
|
|
339
|
+
// pass its check.
|
|
340
|
+
if (entry.kind === 'acknowledgement' && entry.cancellation !== undefined && entries[entry.cancellation].problems.length > 0) failAcknowledgement(copy);
|
|
341
|
+
if (copy.problems.length > 0) delete copy.stampedAt;
|
|
342
|
+
return copy;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function failAcknowledgement(entry) {
|
|
346
|
+
entry.problems = [...entry.problems, { code: 'cancellation-not-found', message: 'The cancellation this acknowledgement names did not pass its own check.' }];
|
|
347
|
+
entry.verified = 'none';
|
|
348
|
+
delete entry.cancellation;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A reader that carries on by itself from where this one stands, to try
|
|
353
|
+
* one more line. The reader it was split from is left exactly as it was,
|
|
354
|
+
* whatever becomes of this one.
|
|
355
|
+
*
|
|
356
|
+
* To try a stub, what a stub changes is copied (its chain, the running
|
|
357
|
+
* totals, the unique numbers, the tree); the entries read so far, the
|
|
358
|
+
* terms and the vouching records are shared, because a stub changes none
|
|
359
|
+
* of them, and such a reader refuses any other kind of line.
|
|
360
|
+
*
|
|
361
|
+
* To try a line of any kind ("whole"), the entries are copied as well,
|
|
362
|
+
* with the lists inside them that a later line may add to (a seal's
|
|
363
|
+
* time-stamp marks the entries before it; a cancellation marks stubs),
|
|
364
|
+
* and so are the terms and the vouching records. Whatever points at an
|
|
365
|
+
* entry, or at its list of findings, is pointed at the copy.
|
|
366
|
+
*
|
|
367
|
+
* @param {ReturnType<typeof newReader>} reader
|
|
368
|
+
* @param {boolean} [whole] true to read any kind of line
|
|
369
|
+
*/
|
|
370
|
+
function forkReader(reader, whole = false) {
|
|
371
|
+
const twinEntry = new Map();
|
|
372
|
+
const twinList = new Map();
|
|
373
|
+
const entries = whole
|
|
374
|
+
? reader.entries.map((e) => {
|
|
375
|
+
const copy = { ...e, problems: e.problems.slice(), breaches: e.breaches.slice(), notes: e.notes.slice() };
|
|
376
|
+
twinEntry.set(e, copy);
|
|
377
|
+
twinList.set(e.breaches, copy.breaches);
|
|
378
|
+
if (stampedTimes.has(e)) stampedTimes.set(copy, stampedTimes.get(e));
|
|
379
|
+
return copy;
|
|
380
|
+
})
|
|
381
|
+
: reader.entries.slice();
|
|
382
|
+
const copyTally = (t) => ({
|
|
383
|
+
sums: new Map(t.sums),
|
|
384
|
+
counts: new Map(t.counts),
|
|
385
|
+
periods: new Map([...t.periods].map(([i, items]) => [i, whole ? items.map((item) => ({ ...item, late: twinList.get(item.late) ?? item.late })) : items.slice()])),
|
|
386
|
+
});
|
|
387
|
+
const slips = new Map();
|
|
388
|
+
for (const [fingerprint, s] of reader.slips) {
|
|
389
|
+
slips.set(fingerprint, {
|
|
390
|
+
...s,
|
|
391
|
+
tally: copyTally(s.tally),
|
|
392
|
+
stubs: whole ? s.stubs.map((e) => twinEntry.get(e) ?? e) : s.stubs.slice(),
|
|
393
|
+
cancelled: s.cancelled === null ? null : { ...s.cancelled },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// The vouching records can be withdrawn by a later line: they are copied, and found again by the copies.
|
|
397
|
+
let { terms, vouchings } = reader;
|
|
398
|
+
if (whole) {
|
|
399
|
+
terms = new Map(reader.terms);
|
|
400
|
+
const twinVouching = new Map();
|
|
401
|
+
vouchings = new Map();
|
|
402
|
+
for (const [fingerprint, v] of reader.vouchings) {
|
|
403
|
+
const copy = { ...v };
|
|
404
|
+
twinVouching.set(v, copy);
|
|
405
|
+
vouchings.set(fingerprint, copy);
|
|
406
|
+
}
|
|
407
|
+
vouchings.index = new Map();
|
|
408
|
+
for (const [key, found] of reader.vouchings.index) {
|
|
409
|
+
vouchings.index.set(key, { counted: found.counted.map((v) => twinVouching.get(v) ?? v), other: found.other.map((v) => twinVouching.get(v) ?? v) });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// A pass points to the pass it was handed on from, which is earlier in the book: the copies point to the copies.
|
|
413
|
+
const passes = new Map();
|
|
414
|
+
const twins = new Map();
|
|
415
|
+
for (const [fingerprint, p] of reader.passes) {
|
|
416
|
+
const copy = { ...p, parent: p.parent ? twins.get(p.parent) : null, tally: copyTally(p.tally) };
|
|
417
|
+
twins.set(p, copy);
|
|
418
|
+
passes.set(fingerprint, copy);
|
|
419
|
+
}
|
|
420
|
+
const ids = new Set(reader.ids);
|
|
421
|
+
ids.approvals = new Set(reader.ids.approvals);
|
|
422
|
+
return {
|
|
423
|
+
...reader,
|
|
424
|
+
forked: !whole,
|
|
425
|
+
entries,
|
|
426
|
+
slips,
|
|
427
|
+
terms,
|
|
428
|
+
passes,
|
|
429
|
+
ids,
|
|
430
|
+
vouchings,
|
|
431
|
+
seals: { ...reader.seals },
|
|
432
|
+
tree: reader.tree.fork(),
|
|
433
|
+
handedProblems: reader.handedProblems.slice(),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The options a checker reads. Each is read once, as a whole check reads
|
|
438
|
+
// it: by its name, whether the member is the object's own, inherited, or
|
|
439
|
+
// worked out when it is asked for.
|
|
440
|
+
// The fixed copies made so far. Nothing changes one once it is made.
|
|
441
|
+
const FIXED = new WeakSet();
|
|
442
|
+
const OPTION_NAMES = ['issuerKeys', 'sealKeys', 'stampServices', 'blocks', 'vouchers', 'disclosures', 'cancellations', 'expectedRoot', 'expectedSize', 'withoutMethods'];
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The options of a check, fixed: read once and copied, so that nothing done
|
|
446
|
+
* afterwards to what was handed over changes a check that is carried on.
|
|
447
|
+
* Lists and plain objects are copied, through and through. Anything else
|
|
448
|
+
* (an object of a class, a function, or a value that cannot be looked
|
|
449
|
+
* into, such as a revoked Proxy, or a list whose length is not a whole
|
|
450
|
+
* number) is kept as it was handed over: a check refuses or ignores it
|
|
451
|
+
* exactly as a whole check does. A value met twice is copied once, so that
|
|
452
|
+
* the copy holds the same sharing, and the same loops, as what was handed
|
|
453
|
+
* over, and costs no more to make than that has values. A value more than
|
|
454
|
+
* 16 levels down cannot be read: no option nests so deep, and nothing past
|
|
455
|
+
* that depth is kept as it was handed over. A fixed copy handed in again is
|
|
456
|
+
* used as it is. Not part of the public interface.
|
|
457
|
+
* @param {any} options
|
|
458
|
+
* @returns {object}
|
|
459
|
+
*/
|
|
460
|
+
export function fixedOptions(options) {
|
|
461
|
+
if (FIXED.has(options)) return options;
|
|
462
|
+
const fixed = {};
|
|
463
|
+
FIXED.add(fixed);
|
|
464
|
+
if (options === null || typeof options !== 'object') return fixed;
|
|
465
|
+
const copies = new Map();
|
|
466
|
+
const copy = (value, depth) => {
|
|
467
|
+
if (value === null || typeof value !== 'object') return value;
|
|
468
|
+
if (depth > 16) throw new RangeError('The options nest more than 16 levels deep.');
|
|
469
|
+
if (copies.has(value)) return copies.get(value);
|
|
470
|
+
// What the value is, and which members it has, is found out first. A
|
|
471
|
+
// value that cannot be looked into is kept as it was handed over.
|
|
472
|
+
let list;
|
|
473
|
+
let made;
|
|
474
|
+
let names;
|
|
475
|
+
try {
|
|
476
|
+
list = Array.isArray(value);
|
|
477
|
+
if (list) {
|
|
478
|
+
// A gap stays a gap, so that the copy is read as a whole check reads
|
|
479
|
+
// the list. Only the places that hold an item are visited, so a long
|
|
480
|
+
// list with few items in it costs little.
|
|
481
|
+
const length = value.length;
|
|
482
|
+
if (!(Number.isInteger(length) && length >= 0 && length <= 2 ** 32 - 1)) return value;
|
|
483
|
+
names = Object.getOwnPropertyNames(value)
|
|
484
|
+
.filter((name) => /^(0|[1-9][0-9]*)$/.test(name) && Number(name) < length)
|
|
485
|
+
.map((name) => [name, true]);
|
|
486
|
+
made = new Array(length);
|
|
487
|
+
} else {
|
|
488
|
+
const proto = Object.getPrototypeOf(value);
|
|
489
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
490
|
+
// A member with a special name, such as "__proto__", stays a member like any other.
|
|
491
|
+
// A member that is not enumerable stays so: a whole check sees it where it reads a
|
|
492
|
+
// member by its name, and not where it lists the members, and so does the copy.
|
|
493
|
+
names = Object.getOwnPropertyNames(value).map((name) => [name, Object.getOwnPropertyDescriptor(value, name)?.enumerable === true]);
|
|
494
|
+
made = proto === null ? Object.create(null) : {};
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
copies.set(value, made);
|
|
500
|
+
for (const [name, enumerable] of names) take(made, value, name, depth, enumerable);
|
|
501
|
+
return made;
|
|
502
|
+
};
|
|
503
|
+
// One member or place, read once. One that cannot be read fails, with the
|
|
504
|
+
// same error, wherever a whole check reads it, and nowhere else.
|
|
505
|
+
const take = (made, value, name, depth, enumerable) => {
|
|
506
|
+
try {
|
|
507
|
+
Object.defineProperty(made, name, { value: copy(value[name], depth + 1), enumerable, writable: true, configurable: true });
|
|
508
|
+
} catch (e) {
|
|
509
|
+
Object.defineProperty(made, name, { get: () => { throw e; }, enumerable, configurable: true });
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
for (const name of OPTION_NAMES) {
|
|
513
|
+
let value;
|
|
514
|
+
try {
|
|
515
|
+
value = options[name];
|
|
516
|
+
} catch (e) {
|
|
517
|
+
Object.defineProperty(fixed, name, { get: () => { throw e; }, enumerable: true, configurable: true });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (value !== undefined) fixed[name] = copy(value, 0);
|
|
521
|
+
}
|
|
522
|
+
return fixed;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The most vouching records from organisations the checker did not name that are kept for one name and key. */
|
|
526
|
+
const MAX_UNCOUNTED = 8;
|
|
527
|
+
|
|
528
|
+
// The keys a slip gives for a service, if it gives any.
|
|
529
|
+
export const keysOf = (service) => (service !== null && typeof service === 'object' && Object.hasOwn(service, 'keys') ? service.keys : null);
|
|
530
|
+
|
|
531
|
+
// What a vouching record is found by: the kind, the key or keys, and the name, exactly.
|
|
532
|
+
const vouchedKey = (kind, key, name) => canonicalJson([kind, key, name]);
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Who vouches for a name: the vouching record earlier in the book, still
|
|
536
|
+
* standing, that names exactly this key (or these keys) and exactly this
|
|
537
|
+
* name, and was in force for the whole of the time given. One from an
|
|
538
|
+
* organisation the checker named as trusted is preferred, and only that
|
|
539
|
+
* one counts.
|
|
540
|
+
* @param {any} vouchings the vouching records so far, with their index
|
|
541
|
+
* @param {string} kind
|
|
542
|
+
* @param {any} key the key, or the keys
|
|
543
|
+
* @param {any} name the name; a covered name is not text, and nobody vouches for it
|
|
544
|
+
* @param {{from: number, until: number}} span the time: from, and until (not included)
|
|
545
|
+
* @returns {{counted: boolean, by: string, keys: string}|null}
|
|
546
|
+
*/
|
|
547
|
+
function vouchFor(vouchings, kind, key, name, span) {
|
|
548
|
+
if (typeof name !== 'string') return null;
|
|
549
|
+
const found = vouchings.index.get(vouchedKey(kind, key, name));
|
|
550
|
+
if (!found) return null;
|
|
551
|
+
const standing = (v) => !v.withdrawn && span.from >= parseTime(v.content.validFrom) && span.until <= parseTime(v.content.validUntil);
|
|
552
|
+
const v = found.counted.find(standing) ?? found.other.find(standing);
|
|
553
|
+
return v ? { counted: v.counted, by: v.content.by.name, keys: v.signer } : null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The pass a stub or a further pass relies on.
|
|
557
|
+
function findPass(passes, fingerprint, slipFingerprint, whole) {
|
|
558
|
+
const pass = passes.get(fingerprint);
|
|
559
|
+
if (!pass || !pass.usable) {
|
|
560
|
+
throw new Refusal('pass-missing', whole ? 'The pass this entry relies on is not earlier in the book, or did not pass its own check.' : 'The pass this entry relies on is not among the pages, or did not pass its own check.');
|
|
561
|
+
}
|
|
562
|
+
if (pass.content.slip !== slipFingerprint) throw new Refusal('pass-mismatch', 'The pass this entry relies on was given under another slip.');
|
|
563
|
+
return pass;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// A pass seen as a slip, so that the same comparison can be made with it.
|
|
567
|
+
export function passAsSlip(p) {
|
|
568
|
+
return { actions: p.actions, limits: p.limits, requires: [], never: [], validFrom: p.validFrom, validUntil: p.validUntil };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// A pass: an agent hands part of its permission to a helper agent.
|
|
572
|
+
async function checkPassEntry(entry, value, slips, passes, ids, without, whole) {
|
|
573
|
+
entry.signatures = [];
|
|
574
|
+
// Whether this pass was compared with what its writer holds. It is not,
|
|
575
|
+
// if the pass or the slip could not be confirmed.
|
|
576
|
+
entry.compared = false;
|
|
577
|
+
const parsed = await parseRecord(value.pass, 'pass');
|
|
578
|
+
entry.fingerprint = parsed.fingerprint;
|
|
579
|
+
validatePassContent(parsed.content);
|
|
580
|
+
const c = parsed.content;
|
|
581
|
+
entry.content = c;
|
|
582
|
+
entry.slip = c.slip;
|
|
583
|
+
noteId(entry, ids, c.id);
|
|
584
|
+
const slip = findSlip(slips, c.slip, whole);
|
|
585
|
+
// Who may pass on: the slip's own agent, or the helper named in the pass before.
|
|
586
|
+
const parent = Object.hasOwn(c, 'from') ? findPass(passes, c.from, c.slip, whole) : null;
|
|
587
|
+
entry.signatures = await checkKeySetSignatures(parsed, parent ? parent.content.to.keys : slip.content.agent.keys, without);
|
|
588
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
589
|
+
entry.problems.push({ code: 'signature-invalid', message: 'A signature on the pass does not fit the keys of the agent that passes on.' });
|
|
590
|
+
}
|
|
591
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
592
|
+
underConfirmedSlip(entry, slip);
|
|
593
|
+
const level = parent ? parent.level + 1 : 1;
|
|
594
|
+
// No slip can allow a permission to be passed on more than ten times in
|
|
595
|
+
// a row. A pass further down is refused, so that every stub can be
|
|
596
|
+
// compared with every pass above its own.
|
|
597
|
+
if (level > MAX_PASSES) {
|
|
598
|
+
entry.problems.push({ code: 'pass-too-deep', message: 'A permission may be passed on at most ten times in a row, and this pass is one more.' });
|
|
599
|
+
}
|
|
600
|
+
const allowed = Object.hasOwn(slip.content, 'passes') ? slip.content.passes : 0;
|
|
601
|
+
let notAllowed = parent ? parent.notAllowed : false;
|
|
602
|
+
|
|
603
|
+
// What the pass shows about the agent that passed on.
|
|
604
|
+
if (entry.problems.length === 0 && entry.verified !== 'none' && slip.verified) {
|
|
605
|
+
entry.compared = true;
|
|
606
|
+
const held = parent ? parent.content : slip.content;
|
|
607
|
+
if (level > allowed) {
|
|
608
|
+
notAllowed = true;
|
|
609
|
+
entry.breaches.push({
|
|
610
|
+
code: 'pass-not-allowed',
|
|
611
|
+
message: allowed === 0 ? 'The slip does not allow its permission to be passed on.' : `The slip allows its permission to be passed on ${allowed === 1 ? 'once' : `${allowed} times`}, and this is one more.`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (c.actions.some((action) => !held.actions.includes(action)) || parseTime(c.validFrom) < parseTime(held.validFrom) || parseTime(c.validUntil) > parseTime(held.validUntil)) {
|
|
615
|
+
entry.breaches.push({ code: 'pass-wider', message: 'The pass hands on more than the agent that wrote it holds: an action, or a time, outside its own permission.' });
|
|
616
|
+
}
|
|
617
|
+
const when = parseTime(c.when);
|
|
618
|
+
if (when < parseTime(held.validFrom) || when >= parseTime(held.validUntil)) {
|
|
619
|
+
entry.breaches.push({ code: 'outside-valid-time', message: 'The pass is dated outside the time its writer\'s own permission allows.' });
|
|
620
|
+
}
|
|
621
|
+
if (slip.cancelled !== null) entry.breaches.push({ code: 'after-cancellation', message: 'The person cancelled the slip before this pass.' });
|
|
622
|
+
}
|
|
623
|
+
if (passes.has(parsed.fingerprint)) entry.problems.push({ code: 'duplicate-id', message: 'This pass is already in the book.' });
|
|
624
|
+
else {
|
|
625
|
+
passes.set(parsed.fingerprint, {
|
|
626
|
+
content: c,
|
|
627
|
+
usable: entry.problems.length === 0,
|
|
628
|
+
// The pass this one was handed on from, if any.
|
|
629
|
+
parent,
|
|
630
|
+
level,
|
|
631
|
+
notAllowed,
|
|
632
|
+
// The helper's own chain of stubs, and its running totals under the pass.
|
|
633
|
+
next: 0,
|
|
634
|
+
last: null,
|
|
635
|
+
lastWhen: -Infinity,
|
|
636
|
+
tally: newTally(),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// A vouching record: an organisation states that a key belongs to a name.
|
|
642
|
+
async function checkVouchingEntry(entry, value, vouchings, ids, options) {
|
|
643
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
644
|
+
entry.signatures = [];
|
|
645
|
+
const parsed = await parseRecord(value.vouching, 'vouching');
|
|
646
|
+
entry.fingerprint = parsed.fingerprint;
|
|
647
|
+
validateVouchingContent(parsed.content);
|
|
648
|
+
const c = parsed.content;
|
|
649
|
+
entry.content = c;
|
|
650
|
+
noteId(entry, ids, c.id);
|
|
651
|
+
entry.signatures = await checkKeySetSignatures(parsed, c.by.keys, without);
|
|
652
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
653
|
+
entry.problems.push({ code: 'signature-invalid', message: 'A signature on the vouching record does not fit the keys it gives.' });
|
|
654
|
+
}
|
|
655
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
656
|
+
entry.signer = await keySetFingerprint(c.by.keys);
|
|
657
|
+
// It counts only if the checker named this organisation as one it trusts.
|
|
658
|
+
entry.counted = Array.isArray(options.vouchers) && options.vouchers.includes(entry.signer);
|
|
659
|
+
if (!entry.counted) {
|
|
660
|
+
entry.notes.push(`This vouching record is from an organisation you did not name as trusted, so it is not counted. The fingerprint of its key set is ${entry.signer}.`);
|
|
661
|
+
}
|
|
662
|
+
if (vouchings.has(parsed.fingerprint)) entry.problems.push({ code: 'duplicate-id', message: 'This vouching record is already in the book.' });
|
|
663
|
+
else {
|
|
664
|
+
const usable = entry.problems.length === 0 && entry.verified !== 'none';
|
|
665
|
+
const vouching = { content: c, signer: entry.signer, usable, counted: entry.counted && usable, withdrawn: false };
|
|
666
|
+
vouchings.set(parsed.fingerprint, vouching);
|
|
667
|
+
if (usable) {
|
|
668
|
+
const key = vouchedKey(c.for.kind, c.for.kind === 'person' ? c.for.key : c.for.keys, c.for.name);
|
|
669
|
+
const found = vouchings.index.get(key) ?? { counted: [], other: [] };
|
|
670
|
+
vouchings.index.set(key, found);
|
|
671
|
+
// Only an organisation the checker named can add to the first list.
|
|
672
|
+
// Anyone can add to the second, so it is kept short.
|
|
673
|
+
if (vouching.counted) found.counted.push(vouching);
|
|
674
|
+
else if (found.other.length < MAX_UNCOUNTED) found.other.push(vouching);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// A withdrawal: the organisation ends one of its vouching records. From
|
|
680
|
+
// this place in the book onwards that record vouches for nothing.
|
|
681
|
+
async function checkWithdrawalEntry(entry, value, vouchings, ids, options, whole) {
|
|
682
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
683
|
+
entry.signatures = [];
|
|
684
|
+
const parsed = await parseRecord(value.withdrawal, 'withdrawal');
|
|
685
|
+
entry.fingerprint = parsed.fingerprint;
|
|
686
|
+
validateWithdrawalContent(parsed.content);
|
|
687
|
+
const c = parsed.content;
|
|
688
|
+
entry.content = c;
|
|
689
|
+
noteId(entry, ids, c.id);
|
|
690
|
+
const vouching = vouchings.get(c.vouching);
|
|
691
|
+
if (!vouching) {
|
|
692
|
+
throw new Refusal('vouching-missing', whole ? 'The vouching record this withdrawal ends is not earlier in the book.' : 'The vouching record this withdrawal ends is not among the pages.');
|
|
693
|
+
}
|
|
694
|
+
// Only the organisation that vouched can withdraw.
|
|
695
|
+
entry.signatures = await checkKeySetSignatures(parsed, vouching.content.by.keys, without);
|
|
696
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
697
|
+
entry.problems.push({ code: 'signature-invalid', message: 'A signature on the withdrawal does not fit the keys of the organisation that vouched.' });
|
|
698
|
+
}
|
|
699
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
700
|
+
entry.signer = vouching.signer;
|
|
701
|
+
if (entry.problems.length === 0 && entry.verified !== 'none') vouching.withdrawn = true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// A cancellation: the person ends a slip early, with the passkey that signed it.
|
|
705
|
+
async function checkCancellationEntry(entry, value, slips, ids, options, whole) {
|
|
706
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
707
|
+
entry.stamps = [];
|
|
708
|
+
const parsed = await parseRecord(value.cancellation, 'cancellation');
|
|
709
|
+
entry.fingerprint = parsed.fingerprint;
|
|
710
|
+
validateCancellationContent(parsed.content);
|
|
711
|
+
const c = parsed.content;
|
|
712
|
+
entry.content = c;
|
|
713
|
+
entry.slip = c.slip;
|
|
714
|
+
noteId(entry, ids, c.id);
|
|
715
|
+
const slip = findSlip(slips, c.slip, whole);
|
|
716
|
+
const alg = slip.content.issuer.key.alg;
|
|
717
|
+
entry.signature = { method: `passkey (${alg})`, alg, state: 'invalid' };
|
|
718
|
+
try {
|
|
719
|
+
entry.signature.state = await checkPasskeySignature(parsed, slip.content.issuer, without);
|
|
720
|
+
} catch {
|
|
721
|
+
throw new Refusal('cancellation-invalid', 'The cancellation was not signed with the passkey the slip names, or its passkey values do not check.');
|
|
722
|
+
}
|
|
723
|
+
entry.verified = entry.signature.state === 'valid' ? 'all' : 'none';
|
|
724
|
+
// A time-stamp is held against the cancellation's own date only where this device could confirm the cancellation.
|
|
725
|
+
const confirmed = entry.verified === 'all' && slip.verified;
|
|
726
|
+
const time = Object.hasOwn(value, 'stamps') ? await checkStamps(entry, value.stamps, parsed.fingerprint, parseTime(c.when), options, confirmed) : null;
|
|
727
|
+
if (time !== null && entry.problems.length === 0 && !confirmed) {
|
|
728
|
+
entry.notes.push('The time-stamp beside this cancellation was not counted: this device could not check the cancellation\'s signature, or the slip\'s.');
|
|
729
|
+
}
|
|
730
|
+
if (entry.problems.length > 0 || entry.verified === 'none' || !slip.verified) return;
|
|
731
|
+
// Every sound cancellation counts. The slip is cancelled from the place
|
|
732
|
+
// of the first one in the book, and the earliest time that a counted
|
|
733
|
+
// time-stamp gives any of them is the time used for the stubs before it.
|
|
734
|
+
// So whoever keeps the book gains nothing by the order they stand in.
|
|
735
|
+
const first = slip.cancelled === null;
|
|
736
|
+
if (first) slip.cancelled = { index: entry.index, time: null };
|
|
737
|
+
else entry.notes.push('The slip had already been cancelled, earlier in the book. Every cancellation counts: the earliest time that a time-stamp you trust gives any of them is the one used.');
|
|
738
|
+
if (time === null) {
|
|
739
|
+
if (first) entry.notes.push('This cancellation has no time-stamp from a service you named as trusted. Only its place in the book says when it took effect.');
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// The time of a cancellation is the earliest its time-stamps allow.
|
|
743
|
+
const made = cancelledAt(entry, parseTime(c.when));
|
|
744
|
+
entry.stampedAt = formatTime(made);
|
|
745
|
+
noteLateStamp(entry, made, c.when);
|
|
746
|
+
noteBlockSetAside(entry, parseTime(c.when), CANCELLATION_BLOCK_SET_ASIDE);
|
|
747
|
+
if (!whole) {
|
|
748
|
+
entry.notes.push('This cancellation is time-stamped. Single pages cannot show which stubs existed before that time: that needs the seals of the whole book. Here only the place of each page in the book is used.');
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (slip.cancelled.time !== null && slip.cancelled.time <= made) return;
|
|
752
|
+
slip.cancelled.time = made;
|
|
753
|
+
markNotShownEarlier(slip, made, 'This stub is not shown to have existed before the person cancelled the slip: no outside time-stamp from before the cancellation covers it.');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// An acknowledgement: the agent's side states that it was handed the
|
|
757
|
+
// person's cancellation of a slip, and when. It is signed with the keys of
|
|
758
|
+
// the agent the slip names or, where it names a pass, of the helper agent
|
|
759
|
+
// that pass names. In a book it comes after the cancellation it names.
|
|
760
|
+
async function checkAcknowledgementEntry(entry, value, slips, passes, ids, entries, without, whole) {
|
|
761
|
+
entry.signatures = [];
|
|
762
|
+
const parsed = await parseRecord(value.acknowledgement, 'acknowledgement');
|
|
763
|
+
entry.fingerprint = parsed.fingerprint;
|
|
764
|
+
validateAcknowledgementContent(parsed.content);
|
|
765
|
+
const c = parsed.content;
|
|
766
|
+
entry.content = c;
|
|
767
|
+
entry.slip = c.slip;
|
|
768
|
+
noteId(entry, ids, c.id);
|
|
769
|
+
const slip = findSlip(slips, c.slip, whole);
|
|
770
|
+
const pass = Object.hasOwn(c, 'pass') ? findPass(passes, c.pass, c.slip, whole) : null;
|
|
771
|
+
if (pass) entry.pass = c.pass;
|
|
772
|
+
entry.signatures = await checkKeySetSignatures(parsed, pass ? pass.content.to.keys : slip.content.agent.keys, without);
|
|
773
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
774
|
+
entry.problems.push({
|
|
775
|
+
code: 'signature-invalid',
|
|
776
|
+
message: pass ? 'A signature on the acknowledgement does not fit the keys of the helper agent the pass names.' : 'A signature on the acknowledgement does not fit the agent\'s keys.',
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
780
|
+
underConfirmedSlip(entry, slip);
|
|
781
|
+
// The cancellation it names: of the same slip, and sound.
|
|
782
|
+
const named = entries.find((e) => e !== entry && e.kind === 'cancellation' && e.fingerprint === c.cancellation);
|
|
783
|
+
if (!named) {
|
|
784
|
+
entry.problems.push({
|
|
785
|
+
code: 'cancellation-not-found',
|
|
786
|
+
message: whole ? 'The cancellation this acknowledgement names is not earlier in the book.' : 'The cancellation this acknowledgement names is not among the pages.',
|
|
787
|
+
});
|
|
788
|
+
} else if (named.problems.length > 0 || named.slip !== c.slip) {
|
|
789
|
+
entry.problems.push({ code: 'cancellation-not-found', message: 'The cancellation this acknowledgement names did not pass its own check, or cancels another slip.' });
|
|
790
|
+
} else {
|
|
791
|
+
entry.cancellation = named.index;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// The acknowledgements handed over with the person's own copy of a
|
|
796
|
+
// cancellation. Each must be for exactly that cancellation, and signed with
|
|
797
|
+
// the keys of the slip's agent, or of a helper agent under a pass that the
|
|
798
|
+
// book holds. One that does not check is a problem of its own: it is not
|
|
799
|
+
// used, the reader is told, and the cancellation itself still counts. (The
|
|
800
|
+
// agent's side cannot spoil the person's copy by handing them an
|
|
801
|
+
// acknowledgement that does not check.)
|
|
802
|
+
// "confirmed": whether this device confirmed the slip and the copy, and so
|
|
803
|
+
// whose keys an acknowledgement is to be held against.
|
|
804
|
+
// Returns the problems found.
|
|
805
|
+
async function checkHeldAcknowledgements(held, list, fingerprintText, slipFingerprint, slip, passes, entries, without, confirmed) {
|
|
806
|
+
const problems = [];
|
|
807
|
+
try {
|
|
808
|
+
f.list(list, 1, MAX_ACKNOWLEDGEMENTS, 'acknowledgements');
|
|
809
|
+
} catch (e) {
|
|
810
|
+
return [problemFrom(e)];
|
|
811
|
+
}
|
|
812
|
+
const seen = new Set();
|
|
813
|
+
// Read place by place, never through the list's own way of walking through itself.
|
|
814
|
+
for (let i = 0; i < list.length; i++) {
|
|
815
|
+
const record = list[i];
|
|
816
|
+
try {
|
|
817
|
+
const parsed = await parseRecord(record, 'acknowledgement');
|
|
818
|
+
validateAcknowledgementContent(parsed.content);
|
|
819
|
+
const a = parsed.content;
|
|
820
|
+
if (a.cancellation !== fingerprintText || a.slip !== slipFingerprint) {
|
|
821
|
+
throw new Refusal('acknowledgement-mismatch', 'An acknowledgement handed over with it was given for another cancellation, or under another slip.');
|
|
822
|
+
}
|
|
823
|
+
if (seen.has(parsed.fingerprint)) throw f.fail('acknowledgements', 'the same acknowledgement is handed over twice.');
|
|
824
|
+
seen.add(parsed.fingerprint);
|
|
825
|
+
let pass = null;
|
|
826
|
+
if (Object.hasOwn(a, 'pass')) {
|
|
827
|
+
pass = passes.get(a.pass);
|
|
828
|
+
if (!pass || !pass.usable || pass.content.slip !== slipFingerprint) {
|
|
829
|
+
throw new Refusal('pass-missing', 'The pass an acknowledgement names is not in the book, was given under another slip, or did not pass its own check.');
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const signatures = await checkKeySetSignatures(parsed, pass ? pass.content.to.keys : slip.content.agent.keys, without);
|
|
833
|
+
let state = overall(signatures);
|
|
834
|
+
if (state === 'invalid') throw new Refusal('signature-invalid', 'A signature on an acknowledgement handed over with it does not fit the keys of the agent it is said to be from.');
|
|
835
|
+
// The keys it was held against came from the slip: where this device could not confirm the slip or the copy, it confirmed nothing here either.
|
|
836
|
+
if (!confirmed) state = 'unavailable';
|
|
837
|
+
const inBook = entries.find((e) => e.kind === 'acknowledgement' && e.fingerprint === parsed.fingerprint);
|
|
838
|
+
held.acknowledgements.push({
|
|
839
|
+
fingerprint: parsed.fingerprint,
|
|
840
|
+
by: pass ? 'helper' : 'agent',
|
|
841
|
+
pass: pass ? a.pass : null,
|
|
842
|
+
state,
|
|
843
|
+
signatures,
|
|
844
|
+
// What it says is given only where this device confirmed who signed it.
|
|
845
|
+
when: state === 'valid' ? a.when : null,
|
|
846
|
+
inBook: inBook ? inBook.index : null,
|
|
847
|
+
});
|
|
848
|
+
} catch (e) {
|
|
849
|
+
problems.push(problemFrom(e));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return problems;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// What an acknowledgement shows, where the person hands it over with their
|
|
856
|
+
// own copy of the cancellation: the agent's side says it was told at that
|
|
857
|
+
// time. A stub of that agent's own chain that is dated later was, by the
|
|
858
|
+
// agent's own dates, made after it was told. So was a pass that the agent
|
|
859
|
+
// handed on later, every pass handed on from such a pass, and every stub
|
|
860
|
+
// under any of them: an agent that was told cannot go on through a helper.
|
|
861
|
+
// Both dates are the agent's own word; the finding says so.
|
|
862
|
+
//
|
|
863
|
+
// It is applied after the time-stamp rule (markNotShownEarlier), whichever
|
|
864
|
+
// order the copies were handed over in. A stub that the time-stamp rule
|
|
865
|
+
// already reports keeps that finding, which rests on an outside
|
|
866
|
+
// time-stamp, and the finding is added to.
|
|
867
|
+
const ACKNOWLEDGED = 'the agent\'s side acknowledged that it was handed the person\'s cancellation of the slip. Both dates are the agent\'s own word. The acknowledgement was handed over beside the book, with the person\'s own copy of the cancellation.';
|
|
868
|
+
function markAfterAcknowledged(slip, slipFingerprint, passes, entries, acknowledgement, late, strict, added, underPasses) {
|
|
869
|
+
const marked = (b) => b.code === 'after-cancellation';
|
|
870
|
+
const told = parseTime(acknowledgement.when);
|
|
871
|
+
// Whether a pass was handed on by the acknowledging agent after it was told, or comes from such a pass.
|
|
872
|
+
const handedOnLate = (pass) => {
|
|
873
|
+
for (let p = pass; p; p = p.parent) {
|
|
874
|
+
const writer = Object.hasOwn(p.content, 'from') ? p.content.from : null;
|
|
875
|
+
if (writer === acknowledgement.pass && parseTime(p.content.when) > told) return true;
|
|
876
|
+
}
|
|
877
|
+
return false;
|
|
878
|
+
};
|
|
879
|
+
const mark = (entry, message) => {
|
|
880
|
+
const more = late.get(entry.breaches);
|
|
881
|
+
if (entry.breaches.some(marked)) return;
|
|
882
|
+
const already = more ? more.find(marked) : undefined;
|
|
883
|
+
if (already) {
|
|
884
|
+
// Reported by the time-stamp rule: said as well, once.
|
|
885
|
+
if (strict.has(already) && !added.has(already)) {
|
|
886
|
+
already.message += ' It is also dated after the agent\'s side acknowledged the cancellation, by the agent\'s own dates.';
|
|
887
|
+
added.add(already);
|
|
888
|
+
}
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const finding = { code: 'after-cancellation', message };
|
|
892
|
+
if (more) more.push(finding);
|
|
893
|
+
else late.set(entry.breaches, [finding]);
|
|
894
|
+
};
|
|
895
|
+
for (const stub of slip.stubs) {
|
|
896
|
+
const pass = stub.pass === undefined ? null : passes.get(stub.pass);
|
|
897
|
+
if ((stub.pass ?? null) === acknowledgement.pass && parseTime(stub.content.when) > told) {
|
|
898
|
+
if (!underPasses) mark(stub, `This stub is dated after ${ACKNOWLEDGED}`);
|
|
899
|
+
} else if (underPasses && pass && handedOnLate(pass)) mark(stub, `This stub was written under a pass that was handed on after ${ACKNOWLEDGED}`);
|
|
900
|
+
}
|
|
901
|
+
if (!underPasses) return;
|
|
902
|
+
for (const entry of entries) {
|
|
903
|
+
if (entry.kind !== 'pass' || entry.slip !== slipFingerprint || entry.compared !== true) continue;
|
|
904
|
+
const pass = passes.get(entry.fingerprint);
|
|
905
|
+
if (pass && handedOnLate(pass)) mark(entry, `This pass was handed on, or comes from a pass that was handed on, after ${ACKNOWLEDGED}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// The time-stamps stand beside a cancellation, and whoever keeps it could
|
|
910
|
+
// put a later one in the place of an earlier one. No check can show that. A
|
|
911
|
+
// gap between the person's own date and the time-stamp is what it would
|
|
912
|
+
// leave, so the gap is pointed out.
|
|
913
|
+
function noteLateStamp(entry, time, when) {
|
|
914
|
+
if (time > parseTime(when) + CLOCK_ALLOWANCE_MS) {
|
|
915
|
+
entry.notes.push(
|
|
916
|
+
'The time-stamp on this cancellation states a time more than 300 seconds after the date the person\'s device gave. Whoever keeps the book could have put a later time-stamp in the place of an earlier one. If the person holds an earlier time-stamp of this cancellation, that is the one to go by.',
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// With a time-stamp, a cancellation has a proven time. A stub counts as
|
|
922
|
+
// made before it only if an outside time-stamp shows that it existed by
|
|
923
|
+
// then. An agent cannot escape a cancellation by writing an earlier time.
|
|
924
|
+
//
|
|
925
|
+
// For a cancellation in the book the finding goes into the stub's own
|
|
926
|
+
// list. For one handed over beside the book it is gathered in "late", as
|
|
927
|
+
// the other findings are that are worked out afresh for each answer
|
|
928
|
+
// (settleLate).
|
|
929
|
+
function markNotShownEarlier(slip, time, message, late = null, strict = null) {
|
|
930
|
+
const marked = (b) => b.code === 'after-cancellation';
|
|
931
|
+
for (const stub of slip.stubs) {
|
|
932
|
+
const more = late ? late.get(stub.breaches) : undefined;
|
|
933
|
+
if (stub.breaches.some(marked) || (more && more.some(marked))) continue;
|
|
934
|
+
if (!((stampedTimes.get(stub) ?? Infinity) > time + CLOCK_ALLOWANCE_MS)) continue;
|
|
935
|
+
const finding = { code: 'after-cancellation', message };
|
|
936
|
+
// The findings made here for a copy handed over beside the book are noted, for markAfterAcknowledged.
|
|
937
|
+
if (strict) strict.add(finding);
|
|
938
|
+
if (!late) stub.breaches.push(finding);
|
|
939
|
+
else if (more) more.push(finding);
|
|
940
|
+
else late.set(stub.breaches, [finding]);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// The time of the first counted time-stamp that covers an entry, as a number.
|
|
945
|
+
const stampedTimes = new WeakMap();
|
|
946
|
+
|
|
947
|
+
// The outside time-stamps beside a record. Each states that the record
|
|
948
|
+
// existed at a time. Returns the earliest time stated by a service the
|
|
949
|
+
// checker trusts, or null.
|
|
950
|
+
async function checkStamps(entry, stamps, fingerprintText, when, options, counted = true, floor = -Infinity) {
|
|
951
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
952
|
+
const stamped = fromBase64url(fingerprintText);
|
|
953
|
+
const methods = { without: without.filter((m) => m === 'RSA' || m === 'ECDSA' || m === 'Ed25519'), trusted: options.stampServices };
|
|
954
|
+
for (const item of decodeStamps(stamps)) {
|
|
955
|
+
try {
|
|
956
|
+
const stamp = item.kind === 'block' ? await checkBlockStamp(item.proof, item.header, stamped, options.blocks) : { kind: 'service', ...(await checkStamp(item.token, stamped, methods)) };
|
|
957
|
+
// A service states one time. A block's time is loose, and has two (blockstamp.js).
|
|
958
|
+
stamp.earliest ??= stamp.time;
|
|
959
|
+
entry.stamps.push(stamp);
|
|
960
|
+
} catch (e) {
|
|
961
|
+
entry.stamps.push({ kind: item.kind, state: 'invalid', when: null, time: NaN, authority: null });
|
|
962
|
+
entry.problems.push(problemFrom(e));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
for (const s of entry.stamps) {
|
|
966
|
+
if (s.state !== 'untrusted') continue;
|
|
967
|
+
entry.notes.push(
|
|
968
|
+
s.kind === 'block'
|
|
969
|
+
? `A block time-stamp on this record leads to a block you did not name as trusted. The block's fingerprint is ${s.authority}.`
|
|
970
|
+
: `A time-stamp on this record is from a service you did not name as trusted. Its certificate's fingerprint is ${s.authority}.`,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
const giving = givingStamps(entry, when, floor);
|
|
974
|
+
const time = giving.length ? Math.min(...giving.map((s) => s.time)) : null;
|
|
975
|
+
// A record dated later than the time-stamp that covers it is not as it
|
|
976
|
+
// says. Said only where the time-stamp can be counted at all.
|
|
977
|
+
if (counted && time !== null && time < when - CLOCK_ALLOWANCE_MS) {
|
|
978
|
+
entry.problems.push({ code: 'dated-after-stamp', message: 'This record is dated later than a time-stamp that covers it.' });
|
|
979
|
+
}
|
|
980
|
+
return time;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// The time-stamps that give a seal its time: every counted one, and the
|
|
984
|
+
// earliest time any of them states is the time by which the seal existed.
|
|
985
|
+
// (A block states its time and two hours.) Two cases are set apart, both
|
|
986
|
+
// of a block that states a time far behind the true time, which a service
|
|
987
|
+
// settles. The reader is told of each (noteBlockSetAside, noteBlockBehind).
|
|
988
|
+
// - Beside a time-stamp from a service, a block that states a time more
|
|
989
|
+
// than 300 seconds before the seal's own date, even with its two hours,
|
|
990
|
+
// is set aside. With no service's time-stamp beside it, nothing settles
|
|
991
|
+
// it, and the record reads as dated after its time-stamp.
|
|
992
|
+
// - A block that states a time, even with its two hours, more than 300
|
|
993
|
+
// seconds before the latest time a service gave a seal before this one
|
|
994
|
+
// ("floor") is set aside. This seal covers that seal's line, time-stamp
|
|
995
|
+
// included, so it cannot have existed by then.
|
|
996
|
+
// (For a cancellation, the time it counts from is worked out by cancelledAt.)
|
|
997
|
+
function givingStamps(entry, when, floor = -Infinity) {
|
|
998
|
+
const valid = entry.stamps.filter((s) => s.state === 'valid' && !behindEarlierSeal(s, floor));
|
|
999
|
+
if (!valid.some((s) => s.kind === 'service')) return valid;
|
|
1000
|
+
return valid.filter((s) => s.kind === 'service' || !(s.time < when - CLOCK_ALLOWANCE_MS));
|
|
1001
|
+
}
|
|
1002
|
+
const behindEarlierSeal = (s, floor) => s.kind === 'block' && s.time < floor - CLOCK_ALLOWANCE_MS;
|
|
1003
|
+
|
|
1004
|
+
// The time of a cancellation: the earliest that any counted time-stamp
|
|
1005
|
+
// beside it gives. A later time would excuse stubs made after the person
|
|
1006
|
+
// cancelled, so every counted time-stamp is taken, of either kind: a later
|
|
1007
|
+
// one put beside an earlier one changes nothing. A service's time is
|
|
1008
|
+
// exact. A block's time is loose and is taken at its earliest, two hours
|
|
1009
|
+
// before the time the block states; but never as earlier than the date the
|
|
1010
|
+
// person signed in the cancellation itself, because by the person's own
|
|
1011
|
+
// word it was not made before then. So nobody can move a cancellation back
|
|
1012
|
+
// in time by having it put into a block before the date it gives.
|
|
1013
|
+
function cancelledAt(entry, when) {
|
|
1014
|
+
const times = entry.stamps.filter((s) => s.state === 'valid').map((s) => (s.kind === 'block' ? Math.max(s.earliest, when) : s.time));
|
|
1015
|
+
return times.length ? Math.min(...times) : null;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Two witnesses that do not agree: beside a time-stamp from a service, a
|
|
1019
|
+
// block time-stamp that states a time long before the record's own date,
|
|
1020
|
+
// even with its two hours. The block is set aside (givingStamps,
|
|
1021
|
+
// cancelledAt), and the reader is told.
|
|
1022
|
+
function noteBlockSetAside(entry, when, words) {
|
|
1023
|
+
const valid = entry.stamps.filter((s) => s.state === 'valid');
|
|
1024
|
+
if (valid.some((s) => s.kind === 'service') && valid.some((s) => s.kind === 'block' && s.time < when - CLOCK_ALLOWANCE_MS)) entry.notes.push(words);
|
|
1025
|
+
}
|
|
1026
|
+
const CANCELLATION_BLOCK_SET_ASIDE =
|
|
1027
|
+
'A block time-stamp beside this cancellation states a time more than two hours before the cancellation\'s own date. The cancellation is not taken to have been made before the date the person signed in it. Either the block states a time far behind the true time, which the chain\'s rules allow in rare cases, or the cancellation was signed with a date later than the time it was made.';
|
|
1028
|
+
const SEAL_BLOCK_SET_ASIDE =
|
|
1029
|
+
'A block time-stamp beside this seal states a time more than two hours before the seal\'s own date. It was set aside, and the time-stamp from a service was used. Either the block states a time far behind the true time, which the chain\'s rules allow in rare cases, or the seal existed before the date it gives.';
|
|
1030
|
+
|
|
1031
|
+
// A block time-stamp that states a time before a service's time-stamp on a
|
|
1032
|
+
// seal earlier in the book. It is set aside (givingStamps), and the reader
|
|
1033
|
+
// is told.
|
|
1034
|
+
function noteBlockBehind(entry, floor) {
|
|
1035
|
+
if (entry.stamps.some((s) => s.state === 'valid' && behindEarlierSeal(s, floor))) entry.notes.push(SEAL_BLOCK_BEHIND);
|
|
1036
|
+
}
|
|
1037
|
+
const SEAL_BLOCK_BEHIND =
|
|
1038
|
+
'A block time-stamp beside this seal states a time that is, even with its two hours, before the time a time-stamp service gave a seal that comes before this one. This seal covers that seal and its time-stamp, so it cannot have existed by then. The block time-stamp was set aside: the block states a time far behind the true time, which the chain\'s rules allow in rare cases.';
|
|
1039
|
+
|
|
1040
|
+
// The earliest time that a counted time-stamp from a service gives a seal,
|
|
1041
|
+
// or null where none does.
|
|
1042
|
+
function serviceTime(entry) {
|
|
1043
|
+
const times = entry.stamps.filter((s) => s.state === 'valid' && s.kind === 'service').map((s) => s.time);
|
|
1044
|
+
return times.length ? Math.min(...times) : null;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// In a Show, a seal among the pages cannot be compared with the entries
|
|
1048
|
+
// before it, and its time-stamp is credited to no page. But the seal and
|
|
1049
|
+
// the pages that come before it are each shown to be in the book, at their
|
|
1050
|
+
// places. So their dates can be held against one another as in a whole
|
|
1051
|
+
// book: where they do not fit, the whole book would fail its check, one
|
|
1052
|
+
// way or another. Which of the two is at fault, single pages cannot show:
|
|
1053
|
+
// the seal may not belong to the book at that place. So the finding is
|
|
1054
|
+
// given for the seal, which is the page that could not be compared, and
|
|
1055
|
+
// never for the page before it.
|
|
1056
|
+
function comparePageSeals(pages) {
|
|
1057
|
+
for (const seal of pages) {
|
|
1058
|
+
if (seal.kind !== 'seal' || !seal.content || seal.problems.length > 0 || seal.verified === 'none') continue;
|
|
1059
|
+
const when = parseTime(seal.content.when);
|
|
1060
|
+
const time = seal.stampedAt ? parseTime(seal.stampedAt) : null;
|
|
1061
|
+
let after = null;
|
|
1062
|
+
let before = null;
|
|
1063
|
+
for (const page of pages) {
|
|
1064
|
+
if (!(page.index < seal.index) || page.problems.length > 0 || page.verified === 'none') continue;
|
|
1065
|
+
const stated = whenOf(page);
|
|
1066
|
+
if (time !== null && stated > time + CLOCK_ALLOWANCE_MS) after ??= page.index;
|
|
1067
|
+
else if (stated > when + CLOCK_ALLOWANCE_MS) before ??= page.index;
|
|
1068
|
+
}
|
|
1069
|
+
if (after !== null) {
|
|
1070
|
+
seal.problems.push({
|
|
1071
|
+
code: 'dated-after-stamp',
|
|
1072
|
+
message: `Page ${after}, which comes before this seal in the book, is dated later than this seal's outside time-stamp. Either that page's date is not as it says, or this seal does not fit the entries before it. Single pages cannot show which.`,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
if (before !== null) {
|
|
1076
|
+
seal.problems.push({
|
|
1077
|
+
code: 'time-went-backwards',
|
|
1078
|
+
message: `This seal is dated before page ${before}, which comes before it in the book. Either one of the two dates is not as it says, or this seal does not fit the entries before it.`,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
laterThanSeals(seal, pages);
|
|
1082
|
+
if (seal.problems.length > 0) seal.verified = 'none';
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// In a Show: a seal against the seals among the pages that come before it
|
|
1087
|
+
// in the book. Its date may not be earlier than theirs, nor the time a
|
|
1088
|
+
// service gave it more than 300 seconds earlier than a service gave them.
|
|
1089
|
+
// And the seals form a chain: a seal names the seal just before it.
|
|
1090
|
+
function laterThanSeals(seal, pages) {
|
|
1091
|
+
const when = parseTime(seal.content.when);
|
|
1092
|
+
const service = seal.verified === 'all' ? serviceTime(seal) : null;
|
|
1093
|
+
let dated = false;
|
|
1094
|
+
let stamped = false;
|
|
1095
|
+
for (const p of pages) {
|
|
1096
|
+
if (p === seal || p.kind !== 'seal' || !p.content || !(p.index < seal.index) || p.problems.length > 0 || p.verified === 'none') continue;
|
|
1097
|
+
if (when < parseTime(p.content.when)) dated = true;
|
|
1098
|
+
const earlier = p.verified === 'all' ? serviceTime(p) : null;
|
|
1099
|
+
if (service !== null && earlier !== null && service < earlier - CLOCK_ALLOWANCE_MS) stamped = true;
|
|
1100
|
+
}
|
|
1101
|
+
if (dated) seal.problems.push({ code: 'time-went-backwards', message: 'This seal is dated before a seal, among the pages, that comes before it in the book. One of the two is not as it says.' });
|
|
1102
|
+
if (stamped) {
|
|
1103
|
+
seal.problems.push({ code: 'time-went-backwards', message: 'This seal\'s time-stamp is earlier than the time-stamp of a seal, among the pages, that comes before it in the book. One of the two is not as it says.' });
|
|
1104
|
+
}
|
|
1105
|
+
// The chain of seals, as far as the pages show it. Every seal that could be read has its place in it.
|
|
1106
|
+
const others = pages.filter((p) => p !== seal && p.kind === 'seal' && p.content && p.fingerprint);
|
|
1107
|
+
const earlier = others.filter((p) => p.index < seal.index);
|
|
1108
|
+
let broken;
|
|
1109
|
+
if (!Object.hasOwn(seal.content, 'previous')) {
|
|
1110
|
+
// It says it is the first seal of the book, and a seal comes before it.
|
|
1111
|
+
broken = earlier.length > 0;
|
|
1112
|
+
} else {
|
|
1113
|
+
// If the seal it names is among the pages, that seal comes before it, with no other seal between the two.
|
|
1114
|
+
const named = others.find((p) => p.fingerprint === seal.content.previous);
|
|
1115
|
+
broken = named !== undefined && (!(named.index < seal.index) || earlier.some((p) => p.index > named.index));
|
|
1116
|
+
}
|
|
1117
|
+
if (broken) {
|
|
1118
|
+
seal.problems.push({ code: 'seal-chain-broken', message: 'The seal does not name the seal before it among the pages: a seal is missing, moved or changed.' });
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// The kind of the counted time-stamp that gives a seal its time: the one
|
|
1123
|
+
// that states the earliest. A service's, where the two state the same.
|
|
1124
|
+
function countedBy(entry, floor) {
|
|
1125
|
+
const giving = givingStamps(entry, parseTime(entry.content.when), floor);
|
|
1126
|
+
if (!giving.length) return 'service';
|
|
1127
|
+
const earliest = Math.min(...giving.map((s) => s.time));
|
|
1128
|
+
return giving.some((s) => s.kind === 'service' && s.time === earliest) ? 'service' : 'block';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Whether a seal is dated before an entry it covers, where no counted
|
|
1132
|
+
// time-stamp settles which of the two dates is wrong. An entry that failed
|
|
1133
|
+
// its own check says nothing. One dated after the counted time-stamp is
|
|
1134
|
+
// marked itself (creditStamp), and is not counted here.
|
|
1135
|
+
function datedBeforeCovered(when, covered, count, creditedTime) {
|
|
1136
|
+
for (let i = 0; i < count; i++) {
|
|
1137
|
+
const e = covered[i];
|
|
1138
|
+
if (e.problems.length > 0 || e.verified === 'none') continue;
|
|
1139
|
+
const stated = whenOf(e);
|
|
1140
|
+
if (!(stated > when + CLOCK_ALLOWANCE_MS)) continue;
|
|
1141
|
+
if (creditedTime !== null && stated > creditedTime + CLOCK_ALLOWANCE_MS) continue;
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
return false;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* The latest time a checked entry states, in milliseconds; NaN if it states none.
|
|
1149
|
+
* @param {any} entry an entry of a result
|
|
1150
|
+
* @returns {number}
|
|
1151
|
+
*/
|
|
1152
|
+
export function latestWhen(entry) {
|
|
1153
|
+
return whenOf(entry);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// The latest time an entry states: its own, and that of a countersignature
|
|
1157
|
+
// or an approval on the same line. NaN if it states none.
|
|
1158
|
+
function whenOf(entry) {
|
|
1159
|
+
const times = [entry.content && entry.content.when, entry.countersignature && entry.countersignature.when, entry.approval && entry.approval.when]
|
|
1160
|
+
.filter((t) => typeof t === 'string')
|
|
1161
|
+
.map(parseTime)
|
|
1162
|
+
.filter((t) => !Number.isNaN(t));
|
|
1163
|
+
return times.length ? Math.max(...times) : NaN;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// An entry that a trusted time-stamp covers existed by the stated time. One
|
|
1167
|
+
// that is dated later is not as it says. An entry keeps the earliest time
|
|
1168
|
+
// that any seal after it shows, as "existedBy". Returns false if the entry
|
|
1169
|
+
// was already shown to have existed by then.
|
|
1170
|
+
function creditStamp(earlier, stampedAt, time) {
|
|
1171
|
+
const before = stampedTimes.get(earlier);
|
|
1172
|
+
if (before !== undefined && before <= time) return false;
|
|
1173
|
+
stampedTimes.set(earlier, time);
|
|
1174
|
+
// The time that a seal's or a cancellation's own time-stamps give it is
|
|
1175
|
+
// another matter, and is kept apart, as "stampedAt": a cancellation does
|
|
1176
|
+
// not count from the time of a seal after it.
|
|
1177
|
+
earlier.existedBy = stampedAt;
|
|
1178
|
+
if (whenOf(earlier) > time + CLOCK_ALLOWANCE_MS && !earlier.problems.some((p) => p.code === 'dated-after-stamp')) {
|
|
1179
|
+
earlier.problems.push({ code: 'dated-after-stamp', message: 'This entry is dated later than the outside time-stamp that covers it.' });
|
|
1180
|
+
earlier.verified = 'none';
|
|
1181
|
+
}
|
|
1182
|
+
return true;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* A seal: the recorder's signed statement of the top fingerprint and the
|
|
1187
|
+
* number of entries before it, with outside time-stamps beside it.
|
|
1188
|
+
*
|
|
1189
|
+
* @param {any} entry the entry being filled in
|
|
1190
|
+
* @param {any} value the line, read
|
|
1191
|
+
* @param {any} seals the state of the seals so far, or a fresh state for a seal that stands alone
|
|
1192
|
+
* @param {Set<string>} ids
|
|
1193
|
+
* @param {any} options the checker's options
|
|
1194
|
+
* @param {(() => Promise<{size: number, root: string}>)|null} before works out the entries before the seal, where they are at hand
|
|
1195
|
+
* @param {any[]} entries the entries so far, so that the ones a time-stamp covers can be marked
|
|
1196
|
+
* @param {boolean} [asPage] whether the seal is a single page of a Show, with nothing to compare it with
|
|
1197
|
+
*/
|
|
1198
|
+
async function checkSealEntry(entry, value, seals, ids, options, before, entries, asPage = false) {
|
|
1199
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
1200
|
+
entry.signatures = [];
|
|
1201
|
+
entry.stamps = [];
|
|
1202
|
+
const parsed = await parseRecord(value.seal, 'seal');
|
|
1203
|
+
entry.fingerprint = parsed.fingerprint;
|
|
1204
|
+
validateSealContent(parsed.content);
|
|
1205
|
+
const c = parsed.content;
|
|
1206
|
+
entry.content = c;
|
|
1207
|
+
noteId(entry, ids, c.id);
|
|
1208
|
+
seals.count++;
|
|
1209
|
+
|
|
1210
|
+
// The recorder's three signatures: all must check.
|
|
1211
|
+
entry.signatures = await checkKeySetSignatures(parsed, c.by.keys, without);
|
|
1212
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
1213
|
+
entry.problems.push({ code: 'signature-invalid', message: 'A signature on the seal does not fit the keys the seal gives.' });
|
|
1214
|
+
}
|
|
1215
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
1216
|
+
entry.sealer = await keySetFingerprint(c.by.keys);
|
|
1217
|
+
if (Array.isArray(options.sealKeys)) {
|
|
1218
|
+
if (!options.sealKeys.includes(entry.sealer)) {
|
|
1219
|
+
entry.problems.push({ code: 'sealer-not-expected', message: 'The seal was signed with keys other than the ones expected.' });
|
|
1220
|
+
}
|
|
1221
|
+
} else {
|
|
1222
|
+
entry.notes.push('The keys that signed this seal were not compared with keys you already trust. The name on the seal is only a label.');
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// The seal against the entries before it, and against the seal before it.
|
|
1226
|
+
const when = parseTime(c.when);
|
|
1227
|
+
const at = before ? await before() : null;
|
|
1228
|
+
// A seal sits after exactly as many entries as it covers.
|
|
1229
|
+
if (!at && c.size !== entry.index) {
|
|
1230
|
+
entry.problems.push({ code: 'seal-mismatch', message: 'A seal sits after exactly as many entries as it covers, and this one does not.' });
|
|
1231
|
+
}
|
|
1232
|
+
if (asPage) {
|
|
1233
|
+
entry.notes.push(
|
|
1234
|
+
'This seal is shown as a single page. It could not be compared with the entries before it, so its time-stamp is not taken to show when any other page existed. Its dates are still held against the pages that come before it in the book.',
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
if (at) {
|
|
1238
|
+
if (c.size !== at.size || c.root !== at.root) {
|
|
1239
|
+
entry.problems.push({ code: 'seal-mismatch', message: 'The seal does not fit the entries before it: an entry was changed, added or removed after the seal was made.' });
|
|
1240
|
+
}
|
|
1241
|
+
if ((Object.hasOwn(c, 'previous') ? c.previous : null) !== seals.last) {
|
|
1242
|
+
entry.problems.push({ code: 'seal-chain-broken', message: 'The seal does not name the seal before it: a seal is missing, moved or changed.' });
|
|
1243
|
+
}
|
|
1244
|
+
if (when < seals.lastWhen) {
|
|
1245
|
+
entry.problems.push({ code: 'time-went-backwards', message: 'This seal is dated before the seal ahead of it.' });
|
|
1246
|
+
}
|
|
1247
|
+
seals.last = parsed.fingerprint;
|
|
1248
|
+
seals.lastWhen = when;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// The outside time-stamps. Each states that this seal existed at a time.
|
|
1252
|
+
// They are read only for a seal whose own signatures did not fail. The
|
|
1253
|
+
// latest time a service gave a seal before this one is the "floor": a
|
|
1254
|
+
// block time-stamp that states a time before it is set aside (givingStamps).
|
|
1255
|
+
const floor = seals.lastStampTime ?? -Infinity;
|
|
1256
|
+
// The same floor holds for the seal's own date. A seal covers the lines of
|
|
1257
|
+
// the seals before it, time-stamps included, so it cannot have been made
|
|
1258
|
+
// before the latest time a service gave one of them.
|
|
1259
|
+
if (when < floor - CLOCK_ALLOWANCE_MS) {
|
|
1260
|
+
entry.problems.push({
|
|
1261
|
+
code: 'time-went-backwards',
|
|
1262
|
+
message: 'This seal is dated before the time that a time-stamp service gave a seal that comes before it. It covers that seal and its time-stamp, so it cannot have been made by then.',
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
const stampedTime =
|
|
1266
|
+
Object.hasOwn(value, 'stamps') && overall(entry.signatures) !== 'invalid'
|
|
1267
|
+
? await checkStamps(entry, value.stamps, parsed.fingerprint, when, options, entry.verified === 'all', floor)
|
|
1268
|
+
: null;
|
|
1269
|
+
if (Object.hasOwn(value, 'stamps') && overall(entry.signatures) === 'invalid') decodeStamps(value.stamps);
|
|
1270
|
+
|
|
1271
|
+
// A seal counts as signed only if all three of its signatures were
|
|
1272
|
+
// checked. Only then is a time-stamp on it credited.
|
|
1273
|
+
const credited = entry.problems.length === 0 && entry.verified === 'all' && stampedTime !== null;
|
|
1274
|
+
if (stampedTime !== null && entry.problems.length === 0 && entry.verified !== 'all') {
|
|
1275
|
+
entry.notes.push('The time-stamp beside this seal was not counted: this device could not check all three of the seal\'s signatures.');
|
|
1276
|
+
}
|
|
1277
|
+
if (entry.problems.length === 0 && entry.verified === 'all') noteBlockBehind(entry, floor);
|
|
1278
|
+
|
|
1279
|
+
// A seal dated before an entry it covers: one of the two dates is not as
|
|
1280
|
+
// it says. Where a counted time-stamp settles which (the entry is dated
|
|
1281
|
+
// after the time-stamp too), the entry is marked instead (below). Where
|
|
1282
|
+
// none does, the seal is where the two meet.
|
|
1283
|
+
if (at && seals.latest > when + CLOCK_ALLOWANCE_MS && datedBeforeCovered(when, entries, entries.length - 1, credited ? stampedTime : null)) {
|
|
1284
|
+
entry.problems.push({ code: 'time-went-backwards', message: 'This seal is dated before an entry it covers. One of the two dates is not as it says.' });
|
|
1285
|
+
}
|
|
1286
|
+
if (!credited || entry.problems.length > 0) return;
|
|
1287
|
+
const time = stampedTime;
|
|
1288
|
+
entry.stampedAt = formatTime(time);
|
|
1289
|
+
noteBlockSetAside(entry, when, SEAL_BLOCK_SET_ASIDE);
|
|
1290
|
+
const service = serviceTime(entry);
|
|
1291
|
+
if (!at) {
|
|
1292
|
+
// Among the pages of a Show, the services' times are held against one
|
|
1293
|
+
// another later (laterThanSeals). The floor for a block time-stamp on
|
|
1294
|
+
// a seal further on is kept here, as in a whole book.
|
|
1295
|
+
if (service !== null && service > (seals.lastStampTime ?? -Infinity)) seals.lastStampTime = service;
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
// A later seal cannot have been time-stamped by a service before an
|
|
1299
|
+
// earlier one: not before the latest time a service gave any seal so
|
|
1300
|
+
// far. A block time-stamp is not reported in this way: it is made hours
|
|
1301
|
+
// after the seal, and its time is only right to within two hours. One
|
|
1302
|
+
// that states a time before that floor is set aside (givingStamps).
|
|
1303
|
+
if (service !== null) {
|
|
1304
|
+
if (service < seals.lastStampTime - CLOCK_ALLOWANCE_MS) {
|
|
1305
|
+
entry.problems.push({ code: 'time-went-backwards', message: 'This seal\'s time-stamp is earlier than the time-stamp of a seal before it.' });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (service > seals.lastStampTime) seals.lastStampTime = service;
|
|
1309
|
+
}
|
|
1310
|
+
const by = countedBy(entry, floor);
|
|
1311
|
+
// What a trusted time-stamp covers: every entry before the seal existed
|
|
1312
|
+
// by then. An entry dated later than that is not as it says. The
|
|
1313
|
+
// earliest time that any seal after an entry shows is the one that
|
|
1314
|
+
// counts for it. Going back from the seal, the entries already shown to
|
|
1315
|
+
// have existed earlier are left as they are, with everything before them.
|
|
1316
|
+
for (let i = entries.length - 2; i >= 0; i--) if (!creditStamp(entries[i], entry.stampedAt, time)) break;
|
|
1317
|
+
seals.next = entries.length - 1;
|
|
1318
|
+
seals.sealed = { entries: c.size, when: entry.stampedAt, seal: entry.index, by };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// A stub may have with it, on the same line, the service's countersignature
|
|
1322
|
+
// and the person's approval.
|
|
1323
|
+
const STUB_ENTRIES = ['stub', 'countersignature,stub', 'approval,stub', 'approval,countersignature,stub'];
|
|
1324
|
+
|
|
1325
|
+
function findSlip(slips, fingerprint, whole) {
|
|
1326
|
+
const slip = slips.get(fingerprint);
|
|
1327
|
+
if (!slip) {
|
|
1328
|
+
throw new Refusal('slip-missing', whole ? 'The slip this entry relies on is not earlier in the book.' : 'The slip this entry relies on is not among the pages.');
|
|
1329
|
+
}
|
|
1330
|
+
if (!slip.usable) throw new Refusal('slip-unusable', 'The slip this entry relies on did not pass its own check.');
|
|
1331
|
+
return slip;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// What sits under a slip this device could not confirm is not evidence
|
|
1335
|
+
// either, whatever its own signatures say: the keys it was checked against
|
|
1336
|
+
// came from that slip.
|
|
1337
|
+
function underConfirmedSlip(entry, slip) {
|
|
1338
|
+
if (slip.verified || entry.verified === 'none') return;
|
|
1339
|
+
entry.verified = 'none';
|
|
1340
|
+
entry.notes.push('The slip this entry relies on could not be confirmed on this device, so this entry is not shown.');
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Check the person's approval of one action against that action: it must be
|
|
1345
|
+
* for exactly this request, under this slip, not used before, and signed
|
|
1346
|
+
* with the passkey the slip names. Used by the checker and by the check
|
|
1347
|
+
* before acting.
|
|
1348
|
+
* @returns {Promise<{state: 'valid'|'unavailable', when: string, alg: string}>}
|
|
1349
|
+
* @throws {Refusal}
|
|
1350
|
+
*/
|
|
1351
|
+
export async function checkApprovalRecord(record, request, slipFingerprint, slip, ids, without) {
|
|
1352
|
+
const approval = await parseRecord(record, 'approval');
|
|
1353
|
+
validateApprovalContent(approval.content);
|
|
1354
|
+
const a = approval.content;
|
|
1355
|
+
if (a.slip !== slipFingerprint || requestOf(a) !== requestOf(request)) {
|
|
1356
|
+
throw new Refusal('approval-mismatch', 'The approval was given for something else.');
|
|
1357
|
+
}
|
|
1358
|
+
if (Object.hasOwn(request, 'approval') && request.approval !== approval.fingerprint) {
|
|
1359
|
+
throw new Refusal('approval-mismatch', 'The approval with this stub is not the one the stub names.');
|
|
1360
|
+
}
|
|
1361
|
+
if (ids.approvals.has(a.id)) throw new Refusal('approval-reused', 'This approval has already been used for another stub.');
|
|
1362
|
+
if (ids.has(a.id)) throw new Refusal('duplicate-id', 'Another record has the same unique number.');
|
|
1363
|
+
ids.add(a.id);
|
|
1364
|
+
ids.approvals.add(a.id);
|
|
1365
|
+
let state;
|
|
1366
|
+
try {
|
|
1367
|
+
state = await checkPasskeySignature(approval, slip.content.issuer, without);
|
|
1368
|
+
} catch {
|
|
1369
|
+
throw new Refusal('approval-invalid', 'The approval was not signed with the passkey the slip names, or its passkey values do not check.');
|
|
1370
|
+
}
|
|
1371
|
+
return { state, when: a.when, alg: slip.content.issuer.key.alg, fingerprint: approval.fingerprint };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Check a countersignature against the stub it was made for and the keys
|
|
1376
|
+
* the slip gives for the service. Used by the checker, and by the stub
|
|
1377
|
+
* writer before it puts a countersignature into its book.
|
|
1378
|
+
* @param {any} record the countersignature
|
|
1379
|
+
* @param {string} stubFingerprint
|
|
1380
|
+
* @param {any} service the service the stub names, as the slip gives it, if it gives it
|
|
1381
|
+
* @param {string[]} without
|
|
1382
|
+
* @param {{state: string, signatures: object[], when?: string}} [into] filled in as the check goes
|
|
1383
|
+
* @returns {Promise<{state: 'valid'|'unavailable', signatures: object[], when: string}>}
|
|
1384
|
+
* @throws {Refusal}
|
|
1385
|
+
*/
|
|
1386
|
+
export async function checkCountersignature(record, stubFingerprint, service, without, into = { state: 'invalid', signatures: [] }) {
|
|
1387
|
+
const counter = await parseRecord(record, 'countersignature');
|
|
1388
|
+
validateCountersignatureContent(counter.content);
|
|
1389
|
+
if (counter.content.stub !== stubFingerprint) {
|
|
1390
|
+
throw new Refusal('countersignature-wrong-stub', 'The countersignature was made for a different stub.');
|
|
1391
|
+
}
|
|
1392
|
+
if (!keysOf(service)) {
|
|
1393
|
+
throw new Refusal('countersignature-not-possible', 'The slip names no keys for a service that could have countersigned this stub.');
|
|
1394
|
+
}
|
|
1395
|
+
into.when = counter.content.when;
|
|
1396
|
+
into.signatures = await checkKeySetSignatures(counter, service.keys, without);
|
|
1397
|
+
into.state = overall(into.signatures);
|
|
1398
|
+
if (into.state === 'invalid') {
|
|
1399
|
+
throw new Refusal('countersignature-invalid', 'A signature on the countersignature does not fit the service\'s keys.');
|
|
1400
|
+
}
|
|
1401
|
+
return into;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function noteId(entry, ids, id) {
|
|
1405
|
+
if (ids.has(id)) entry.problems.push({ code: 'duplicate-id', message: 'Another record has the same unique number.' });
|
|
1406
|
+
ids.add(id);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// A refusal: what a service says it refused. It is the service's own
|
|
1410
|
+
// statement, signed with the keys the refusal itself gives.
|
|
1411
|
+
async function checkRefusalEntry(entry, value, slips, ids, without, whole) {
|
|
1412
|
+
entry.signatures = [];
|
|
1413
|
+
const parsed = await parseRecord(value.refusal, 'refusal');
|
|
1414
|
+
entry.fingerprint = parsed.fingerprint;
|
|
1415
|
+
validateRefusalContent(parsed.content);
|
|
1416
|
+
const c = parsed.content;
|
|
1417
|
+
entry.content = c;
|
|
1418
|
+
entry.slip = c.slip;
|
|
1419
|
+
noteId(entry, ids, c.id);
|
|
1420
|
+
const slip = findSlip(slips, c.slip, whole);
|
|
1421
|
+
entry.signatures = await checkKeySetSignatures(parsed, c.by.keys, without);
|
|
1422
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
1423
|
+
entry.problems.push({ code: 'signature-invalid', message: 'A signature on the refusal does not fit the keys the refusal gives.' });
|
|
1424
|
+
}
|
|
1425
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
1426
|
+
entry.signer = await keySetFingerprint(c.by.keys);
|
|
1427
|
+
underConfirmedSlip(entry, slip);
|
|
1428
|
+
// Is this a service the slip names? Only its keys can say.
|
|
1429
|
+
const keys = canonicalJson(c.by.keys);
|
|
1430
|
+
const named = slip.content.with.find((s) => keysOf(s) && canonicalJson(s.keys) === keys);
|
|
1431
|
+
entry.service = named ? named.id : null;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// A service's terms for agents, signed with the keys the terms themselves give.
|
|
1435
|
+
async function checkTermsEntry(entry, value, terms, ids, without) {
|
|
1436
|
+
entry.signatures = [];
|
|
1437
|
+
const parsed = await parseRecord(value.terms, 'terms');
|
|
1438
|
+
entry.fingerprint = parsed.fingerprint;
|
|
1439
|
+
try {
|
|
1440
|
+
validateTermsContent(parsed.content);
|
|
1441
|
+
const c = parsed.content;
|
|
1442
|
+
entry.content = c;
|
|
1443
|
+
noteId(entry, ids, c.id);
|
|
1444
|
+
entry.signatures = await checkKeySetSignatures(parsed, c.by.keys, without);
|
|
1445
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
1446
|
+
entry.problems.push({ code: 'signature-invalid', message: 'A signature on the terms does not fit the keys the terms give.' });
|
|
1447
|
+
}
|
|
1448
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
1449
|
+
// 5. Whose keys signed these terms: a name is only a label.
|
|
1450
|
+
entry.signer = await keySetFingerprint(c.by.keys);
|
|
1451
|
+
} catch (e) {
|
|
1452
|
+
entry.problems.push(problemFrom(e));
|
|
1453
|
+
}
|
|
1454
|
+
if (terms.has(parsed.fingerprint)) entry.problems.push({ code: 'duplicate-id', message: 'These terms are already in the book.' });
|
|
1455
|
+
else terms.set(parsed.fingerprint, { content: entry.content, usable: entry.problems.length === 0, verified: entry.problems.length === 0 && entry.verified !== 'none' });
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async function checkStubEntry(entry, value, slips, terms, passes, ids, without, whole) {
|
|
1459
|
+
entry.signatures = [];
|
|
1460
|
+
// A countersignature the line holds is "unchecked" until it is checked,
|
|
1461
|
+
// so that a stub whose slip fails is not counted as one-sided.
|
|
1462
|
+
entry.countersignature = { state: Object.hasOwn(value, 'countersignature') ? 'unchecked' : 'absent', signatures: [] };
|
|
1463
|
+
entry.approval = { state: 'absent' };
|
|
1464
|
+
// Whether this stub was compared with its slip. It is not, if the stub or
|
|
1465
|
+
// the slip could not be confirmed.
|
|
1466
|
+
entry.compared = false;
|
|
1467
|
+
|
|
1468
|
+
const parsed = await parseRecord(value.stub, 'stub');
|
|
1469
|
+
entry.fingerprint = parsed.fingerprint;
|
|
1470
|
+
validateStubContent(parsed.content);
|
|
1471
|
+
const c = parsed.content;
|
|
1472
|
+
entry.content = c;
|
|
1473
|
+
entry.slip = c.slip;
|
|
1474
|
+
|
|
1475
|
+
noteId(entry, ids, c.id);
|
|
1476
|
+
const slip = findSlip(slips, c.slip, whole);
|
|
1477
|
+
|
|
1478
|
+
// A helper agent's stub names the pass it acts under: it is signed with
|
|
1479
|
+
// the helper's keys, and sits in the helper's own chain.
|
|
1480
|
+
const pass = Object.hasOwn(c, 'pass') ? findPass(passes, c.pass, c.slip, whole) : null;
|
|
1481
|
+
const chain = pass ?? slip;
|
|
1482
|
+
if (pass) entry.pass = c.pass;
|
|
1483
|
+
|
|
1484
|
+
// The agent's two signatures: both must check.
|
|
1485
|
+
entry.signatures = await checkKeySetSignatures(parsed, pass ? pass.content.to.keys : slip.content.agent.keys, without);
|
|
1486
|
+
if (overall(entry.signatures) === 'invalid') {
|
|
1487
|
+
entry.problems.push({ code: 'signature-invalid', message: pass ? 'A signature on the stub does not fit the keys of the helper agent the pass names.' : 'A signature on the stub does not fit the agent\'s keys.' });
|
|
1488
|
+
}
|
|
1489
|
+
entry.verified = verifiedBy(entry.signatures);
|
|
1490
|
+
underConfirmedSlip(entry, slip);
|
|
1491
|
+
|
|
1492
|
+
// Its place in the chain.
|
|
1493
|
+
const when = parseTime(c.when);
|
|
1494
|
+
if (whole) {
|
|
1495
|
+
if (c.seq !== chain.next || (c.seq > 0 && c.previous !== chain.last)) {
|
|
1496
|
+
entry.problems.push({ code: 'chain-broken', message: 'The chain is broken here: a stub is missing, moved, repeated or changed.' });
|
|
1497
|
+
}
|
|
1498
|
+
if (when < chain.lastWhen) {
|
|
1499
|
+
entry.problems.push({ code: 'time-went-backwards', message: 'This stub is dated before the stub ahead of it.' });
|
|
1500
|
+
}
|
|
1501
|
+
// Carry on from this stub, so that one break is reported once.
|
|
1502
|
+
chain.next = c.seq + 1;
|
|
1503
|
+
chain.last = parsed.fingerprint;
|
|
1504
|
+
chain.lastWhen = when;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// The countersignature, if there is one.
|
|
1508
|
+
const service = Object.hasOwn(c, 'with') ? slip.content.with.find((s) => s.id === c.with) : undefined;
|
|
1509
|
+
if (Object.hasOwn(value, 'countersignature')) {
|
|
1510
|
+
entry.countersignature.state = 'invalid';
|
|
1511
|
+
try {
|
|
1512
|
+
await checkCountersignature(value.countersignature, parsed.fingerprint, service, without, entry.countersignature);
|
|
1513
|
+
} catch (e) {
|
|
1514
|
+
entry.problems.push(problemFrom(e));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// The person's approval of this one action, if there is one. It is signed
|
|
1519
|
+
// with the passkey the slip names, and must be for exactly this action.
|
|
1520
|
+
if (Object.hasOwn(value, 'approval')) {
|
|
1521
|
+
entry.approval.state = 'invalid';
|
|
1522
|
+
try {
|
|
1523
|
+
// A stub must name the approval that is with it.
|
|
1524
|
+
if (!Object.hasOwn(c, 'approval')) throw new Refusal('approval-mismatch', 'The stub does not name the approval that is with it.');
|
|
1525
|
+
const checked = await checkApprovalRecord(value.approval, c, c.slip, slip, ids, without);
|
|
1526
|
+
entry.approval = { state: checked.state, when: checked.when, alg: checked.alg };
|
|
1527
|
+
// The stub names the approval, so it was signed after the approval
|
|
1528
|
+
// was written. A stub dated before its approval is not as it says.
|
|
1529
|
+
if (parseTime(checked.when) > when + CLOCK_ALLOWANCE_MS) {
|
|
1530
|
+
throw new Refusal('approval-dated-after-stub', 'The approval is dated later than the stub that carries it.');
|
|
1531
|
+
}
|
|
1532
|
+
} catch (e) {
|
|
1533
|
+
entry.problems.push(problemFrom(e));
|
|
1534
|
+
}
|
|
1535
|
+
} else if (Object.hasOwn(c, 'approval')) {
|
|
1536
|
+
entry.problems.push({ code: 'approval-not-found', message: 'The stub names an approval that is not with it.' });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// The service's terms for agents, if the stub relies on them.
|
|
1540
|
+
let termsContent;
|
|
1541
|
+
let termsConfirmed = true;
|
|
1542
|
+
if (Object.hasOwn(c, 'terms')) {
|
|
1543
|
+
const t = terms.get(c.terms);
|
|
1544
|
+
if (!t) {
|
|
1545
|
+
entry.problems.push({ code: 'terms-not-found', message: whole ? 'The terms this stub relies on are not earlier in the book.' : 'The terms this stub relies on are not among the pages.' });
|
|
1546
|
+
} else if (!t.usable) {
|
|
1547
|
+
entry.problems.push({ code: 'terms-unusable', message: 'The terms this stub relies on did not pass their own check.' });
|
|
1548
|
+
} else if (!keysOf(service) || canonicalJson(service.keys) !== canonicalJson(t.content.by.keys)) {
|
|
1549
|
+
entry.problems.push({ code: 'terms-mismatch', message: 'The terms this stub relies on were not signed with the keys the slip gives for the service it names.' });
|
|
1550
|
+
} else {
|
|
1551
|
+
termsContent = t.content;
|
|
1552
|
+
termsConfirmed = t.verified;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// What the stub shows about the agent. Only a stub that is itself sound,
|
|
1557
|
+
// that this device could confirm at least in part, under a slip this
|
|
1558
|
+
// device confirmed, is evidence of anything.
|
|
1559
|
+
if (entry.problems.length > 0 || entry.verified === 'none' || !slip.verified || !termsConfirmed) return;
|
|
1560
|
+
entry.compared = true;
|
|
1561
|
+
const shown = compareWithSlip(slip.content, whole ? slip.tally : null, c, { service, terms: termsContent, cancelled: slip.cancelled !== null, late: entry.breaches });
|
|
1562
|
+
slip.stubs.push(entry);
|
|
1563
|
+
// A helper's stub is compared with the slip, as every stub is, and also
|
|
1564
|
+
// with the pass it acts under and with every pass above that one: what a
|
|
1565
|
+
// helper holds, it holds under each of them, and their totals take in
|
|
1566
|
+
// what is done further down. A finding already given is not repeated.
|
|
1567
|
+
// (There are at most ten: a pass further down is refused.)
|
|
1568
|
+
if (pass) {
|
|
1569
|
+
const given = [...shown.breaches];
|
|
1570
|
+
for (let p = pass; p; p = p.parent) {
|
|
1571
|
+
const under = compareWithSlip(passAsSlip(p.content), whole ? p.tally : null, c, { service, what: p === pass ? 'pass' : 'earlier-pass', late: entry.breaches });
|
|
1572
|
+
for (const b of under.breaches) {
|
|
1573
|
+
if (given.some((x) => x.code === b.code)) continue;
|
|
1574
|
+
given.push(b);
|
|
1575
|
+
entry.breaches.push(b);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (pass.notAllowed) entry.breaches.push({ code: 'pass-not-allowed', message: 'This stub was written under a pass that the slip does not allow.' });
|
|
1579
|
+
}
|
|
1580
|
+
entry.breaches.push(...shown.breaches);
|
|
1581
|
+
if (shown.running) entry.running = shown.running;
|
|
1582
|
+
entry.needs = shown.needs;
|
|
1583
|
+
if (shown.needs.includes('countersignature') && entry.countersignature.state === 'absent') {
|
|
1584
|
+
entry.breaches.push({ code: 'countersignature-missing', message: 'The slip asks for the other side to countersign this action, and there is no countersignature.' });
|
|
1585
|
+
}
|
|
1586
|
+
if (shown.needs.includes('approval') && entry.approval.state === 'absent') {
|
|
1587
|
+
entry.breaches.push({ code: 'approval-missing', message: 'The slip asks for the person\'s own approval of this action, and there is none.' });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function summarise(entries, bookProblems, sealed = null, held = []) {
|
|
1591
|
+
// Methods this device lacked, and whether any signature was never reached
|
|
1592
|
+
// because its entry, or the whole record, could not be read or used.
|
|
1593
|
+
const missing = new Set();
|
|
1594
|
+
let skipped = bookProblems.length > 0 || entries.length === 0;
|
|
1595
|
+
let slipCount = 0;
|
|
1596
|
+
let stubCount = 0;
|
|
1597
|
+
let countersigned = 0;
|
|
1598
|
+
let oneSided = 0;
|
|
1599
|
+
let approved = 0;
|
|
1600
|
+
let refusals = 0;
|
|
1601
|
+
let termsCount = 0;
|
|
1602
|
+
let sealCount = 0;
|
|
1603
|
+
let cancellations = 0;
|
|
1604
|
+
let vouchingCount = 0;
|
|
1605
|
+
let passCount = 0;
|
|
1606
|
+
let acknowledgementCount = 0;
|
|
1607
|
+
const note = (signatures) => {
|
|
1608
|
+
if (signatures.length === 0) skipped = true;
|
|
1609
|
+
for (const s of signatures) if (s.state === 'unavailable') missing.add(s.method);
|
|
1610
|
+
};
|
|
1611
|
+
for (const e of entries) {
|
|
1612
|
+
if (e.kind === 'slip') {
|
|
1613
|
+
slipCount++;
|
|
1614
|
+
if (e.signature.state === 'unavailable') missing.add(e.signature.alg);
|
|
1615
|
+
else if (e.signature.state === 'unchecked') skipped = true;
|
|
1616
|
+
} else if (e.kind === 'stub') {
|
|
1617
|
+
stubCount++;
|
|
1618
|
+
note(e.signatures ?? []);
|
|
1619
|
+
const counter = e.countersignature ?? { state: 'absent', signatures: [] };
|
|
1620
|
+
if (counter.state === 'absent') oneSided++;
|
|
1621
|
+
else {
|
|
1622
|
+
note(counter.signatures);
|
|
1623
|
+
// Counted only when both of its signatures were confirmed here.
|
|
1624
|
+
if (counter.state === 'valid') countersigned++;
|
|
1625
|
+
}
|
|
1626
|
+
const approval = e.approval ?? { state: 'absent' };
|
|
1627
|
+
if (approval.state === 'unavailable') missing.add(approval.alg);
|
|
1628
|
+
else if (approval.state === 'valid') approved++;
|
|
1629
|
+
} else if (e.kind === 'refusal' || e.kind === 'terms') {
|
|
1630
|
+
if (e.kind === 'refusal') refusals++;
|
|
1631
|
+
else termsCount++;
|
|
1632
|
+
note(e.signatures ?? []);
|
|
1633
|
+
} else if (e.kind === 'seal') {
|
|
1634
|
+
sealCount++;
|
|
1635
|
+
note(e.signatures ?? []);
|
|
1636
|
+
for (const s of e.stamps ?? []) if (s.state === 'unavailable') missing.add('the time-stamp service\'s method');
|
|
1637
|
+
} else if (e.kind === 'cancellation') {
|
|
1638
|
+
cancellations++;
|
|
1639
|
+
if (!e.signature) skipped = true;
|
|
1640
|
+
else if (e.signature.state === 'unavailable') missing.add(e.signature.alg);
|
|
1641
|
+
for (const s of e.stamps ?? []) if (s.state === 'unavailable') missing.add('the time-stamp service\'s method');
|
|
1642
|
+
} else if (e.kind === 'vouching' || e.kind === 'withdrawal' || e.kind === 'pass' || e.kind === 'acknowledgement') {
|
|
1643
|
+
if (e.kind === 'vouching') vouchingCount++;
|
|
1644
|
+
if (e.kind === 'pass') passCount++;
|
|
1645
|
+
if (e.kind === 'acknowledgement') acknowledgementCount++;
|
|
1646
|
+
note(e.signatures ?? []);
|
|
1647
|
+
} else {
|
|
1648
|
+
skipped = true;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
for (const h of held) {
|
|
1652
|
+
for (const s of h.stamps) if (s.state === 'unavailable') missing.add('the time-stamp service\'s method');
|
|
1653
|
+
for (const a of h.acknowledgements) note(a.signatures);
|
|
1654
|
+
}
|
|
1655
|
+
const problemFound = bookProblems.length > 0 || entries.some((e) => e.problems.length > 0);
|
|
1656
|
+
const fullyChecked = !skipped && missing.size === 0;
|
|
1657
|
+
const firstBreach = entries.find((e) => e.breaches.length > 0);
|
|
1658
|
+
// "Within its slip" is said only if every stub, and every pass, was
|
|
1659
|
+
// compared with what it rests on, and none was found outside it.
|
|
1660
|
+
const allCompared = entries.every((e) => (e.kind === 'stub' || e.kind === 'pass' ? e.compared === true : e.kind !== 'unreadable'));
|
|
1661
|
+
return {
|
|
1662
|
+
intact: !problemFound && fullyChecked,
|
|
1663
|
+
problemFound,
|
|
1664
|
+
fullyChecked,
|
|
1665
|
+
methodsMissing: [...missing].sort(),
|
|
1666
|
+
withinSlips: !firstBreach && allCompared && !problemFound,
|
|
1667
|
+
firstBreach: firstBreach ? firstBreach.index : null,
|
|
1668
|
+
counts: {
|
|
1669
|
+
slips: slipCount,
|
|
1670
|
+
stubs: stubCount,
|
|
1671
|
+
countersigned,
|
|
1672
|
+
oneSided,
|
|
1673
|
+
approved,
|
|
1674
|
+
refusals,
|
|
1675
|
+
terms: termsCount,
|
|
1676
|
+
seals: sealCount,
|
|
1677
|
+
cancellations,
|
|
1678
|
+
acknowledgements: acknowledgementCount,
|
|
1679
|
+
vouchings: vouchingCount,
|
|
1680
|
+
passes: passCount,
|
|
1681
|
+
},
|
|
1682
|
+
// How far an outside time-stamp from a trusted service reaches, if any does.
|
|
1683
|
+
sealed: problemFound ? null : sealed,
|
|
1684
|
+
limits: sealed && !problemFound ? [...LIMITS.slice(0, -1), stampedWords(sealed)] : LIMITS,
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Disclosures are handed over as {fingerprint of a slip: [disclosures]}.
|
|
1689
|
+
// Anything else is refused, never set aside: the person handed it over to
|
|
1690
|
+
// have fields revealed. Where no such slip is among the entries, the
|
|
1691
|
+
// disclosures were not used, and the person is told.
|
|
1692
|
+
function unusedDisclosures(result, options) {
|
|
1693
|
+
const handed = options.disclosures;
|
|
1694
|
+
if (handed === undefined) return;
|
|
1695
|
+
const proto = handed === null || typeof handed !== 'object' ? undefined : Object.getPrototypeOf(handed);
|
|
1696
|
+
if (Array.isArray(handed) || (proto !== Object.prototype && proto !== null)) {
|
|
1697
|
+
throw f.fail('disclosures', 'must be laid out as {the fingerprint of a slip: [its disclosures]}.');
|
|
1698
|
+
}
|
|
1699
|
+
const slips = new Set(result.entries.filter((e) => e.kind === 'slip').map((e) => e.fingerprint));
|
|
1700
|
+
if (Object.keys(handed).some((fingerprint) => !slips.has(fingerprint))) {
|
|
1701
|
+
result.notes.push('Disclosures were handed over for a slip that is not here. They were not used.');
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* Cancellations that the person kept, handed over beside the book.
|
|
1707
|
+
*
|
|
1708
|
+
* Whoever keeps a book can leave a cancellation out of it, or put a later
|
|
1709
|
+
* time-stamp in the place of the person's own. The person's own copy, with
|
|
1710
|
+
* its time-stamp, settles both: it is checked against the slip's passkey
|
|
1711
|
+
* as a cancellation in the book is, and the earliest time a counted
|
|
1712
|
+
* time-stamp gives it is used. A stub then counts as made before the
|
|
1713
|
+
* cancellation only if an outside time-stamp shows that it existed by then.
|
|
1714
|
+
*
|
|
1715
|
+
* A copy that does not pass its check is a problem, never dropped: the
|
|
1716
|
+
* person handed it over to have it counted.
|
|
1717
|
+
*
|
|
1718
|
+
* Nothing the reader keeps is changed: what the copies show about the
|
|
1719
|
+
* stubs is gathered in "late", and the slips they cancel in "beside".
|
|
1720
|
+
*/
|
|
1721
|
+
async function checkHeld(result, reader, options, late, beside) {
|
|
1722
|
+
const { slips, passes } = reader;
|
|
1723
|
+
const handed = options.cancellations;
|
|
1724
|
+
if (handed === undefined) return;
|
|
1725
|
+
const without = Array.isArray(options.withoutMethods) ? options.withoutMethods : [];
|
|
1726
|
+
// Up to sixteen, none included: an empty list is what a stub writer that holds none hands back.
|
|
1727
|
+
f.list(handed, 0, MAX_HELD, 'cancellations');
|
|
1728
|
+
const seen = new Set();
|
|
1729
|
+
// What the acknowledgements show is applied once every copy has been through the time-stamp rule.
|
|
1730
|
+
const acknowledgedCopies = [];
|
|
1731
|
+
const strict = new Set();
|
|
1732
|
+
// The problems with acknowledgements, by the place of their copy: reported, without setting the copy aside.
|
|
1733
|
+
const acknowledgementProblems = [];
|
|
1734
|
+
for (let i = 0; i < handed.length; i++) {
|
|
1735
|
+
const value = handed[i];
|
|
1736
|
+
const held = { kind: 'cancellation', fingerprint: null, slip: null, inBook: null, used: false, problems: [], notes: [], stamps: [], acknowledgements: [] };
|
|
1737
|
+
result.held.push(held);
|
|
1738
|
+
try {
|
|
1739
|
+
f.members(value, ['cancellation'], ['acknowledgements', 'stamps'], `cancellations[${i}]`);
|
|
1740
|
+
const parsed = await parseRecord(value.cancellation, 'cancellation');
|
|
1741
|
+
held.fingerprint = parsed.fingerprint;
|
|
1742
|
+
validateCancellationContent(parsed.content);
|
|
1743
|
+
const c = parsed.content;
|
|
1744
|
+
held.content = c;
|
|
1745
|
+
held.slip = c.slip;
|
|
1746
|
+
if (seen.has(parsed.fingerprint)) throw f.fail(`cancellations[${i}]`, 'the same cancellation is handed over twice.');
|
|
1747
|
+
seen.add(parsed.fingerprint);
|
|
1748
|
+
const slip = slips.get(c.slip);
|
|
1749
|
+
if (!slip || !slip.usable) throw new Refusal('slip-missing', 'The slip it names is not in the book, or did not pass its own check.');
|
|
1750
|
+
const alg = slip.content.issuer.key.alg;
|
|
1751
|
+
held.signature = { method: `passkey (${alg})`, alg, state: 'invalid' };
|
|
1752
|
+
try {
|
|
1753
|
+
held.signature.state = await checkPasskeySignature(parsed, slip.content.issuer, without);
|
|
1754
|
+
} catch {
|
|
1755
|
+
throw new Refusal('cancellation-invalid', 'It was not signed with the passkey the slip names, or its passkey values do not check.');
|
|
1756
|
+
}
|
|
1757
|
+
const confirmed = held.signature.state === 'valid' && slip.verified;
|
|
1758
|
+
const time = Object.hasOwn(value, 'stamps') ? await checkStamps(held, value.stamps, parsed.fingerprint, parseTime(c.when), options, confirmed) : null;
|
|
1759
|
+
if (Object.hasOwn(value, 'acknowledgements')) {
|
|
1760
|
+
const found = await checkHeldAcknowledgements(held, value.acknowledgements, parsed.fingerprint, c.slip, slip, passes, result.entries, without, confirmed);
|
|
1761
|
+
for (const p of found) acknowledgementProblems.push([i, p]);
|
|
1762
|
+
if (found.length > 0) held.notes.push('An acknowledgement handed over with it did not pass its check, so that acknowledgement was not used. See the problems at the top.');
|
|
1763
|
+
}
|
|
1764
|
+
if (held.problems.length > 0) continue;
|
|
1765
|
+
if (held.signature.state !== 'valid' || !slip.verified) {
|
|
1766
|
+
held.notes.push('This device could not confirm it, or the slip it names, so it was not used.');
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
held.used = true;
|
|
1770
|
+
// The check before acting allows nothing under a slip the person has cancelled.
|
|
1771
|
+
beside.add(c.slip);
|
|
1772
|
+
const inBook = result.entries.find((e) => e.kind === 'cancellation' && e.fingerprint === parsed.fingerprint);
|
|
1773
|
+
held.inBook = inBook ? inBook.index : null;
|
|
1774
|
+
// What the agent's side acknowledged, where this device confirmed who signed.
|
|
1775
|
+
const told = held.acknowledgements.filter((a) => a.state === 'valid');
|
|
1776
|
+
if (held.acknowledgements.length > told.length) held.notes.push('This device could not check an acknowledgement handed over with it, so that acknowledgement was not used.');
|
|
1777
|
+
if (!inBook && told.length > 0) {
|
|
1778
|
+
const first = told.map((a) => a.when).sort()[0];
|
|
1779
|
+
held.notes.push(
|
|
1780
|
+
`The book does not hold this cancellation, though the agent's side acknowledged at ${first} (its own word for the time) that it was handed it. So it was left out of the book, or was still to be written into it when this copy of the book was made.`,
|
|
1781
|
+
);
|
|
1782
|
+
} else if (!inBook) held.notes.push('The book does not hold this cancellation: whoever keeps the book left it out, or was never given it.');
|
|
1783
|
+
else if (inBook.problems.length) held.notes.push(`The book holds this cancellation at entry ${inBook.index}, but that entry did not pass its check.`);
|
|
1784
|
+
for (const a of told) acknowledgedCopies.push([slip, c.slip, a]);
|
|
1785
|
+
if (time === null) {
|
|
1786
|
+
held.notes.push(
|
|
1787
|
+
told.length > 0
|
|
1788
|
+
? 'It has no time-stamp from a service you named as trusted, so it cannot be placed in time. A stub is reported because of it only where the stub is dated after the agent\'s side acknowledged it.'
|
|
1789
|
+
: 'It has no time-stamp from a service you named as trusted, so it cannot be placed in time, and no stub is marked because of it.',
|
|
1790
|
+
);
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
const made = cancelledAt(held, parseTime(c.when));
|
|
1794
|
+
held.stampedAt = formatTime(made);
|
|
1795
|
+
noteLateStamp(held, made, c.when);
|
|
1796
|
+
noteBlockSetAside(held, parseTime(c.when), CANCELLATION_BLOCK_SET_ASIDE);
|
|
1797
|
+
markNotShownEarlier(
|
|
1798
|
+
slip,
|
|
1799
|
+
made,
|
|
1800
|
+
'This stub is not shown to have existed before the person cancelled the slip: no outside time-stamp from before the cancellation covers it. The person\'s own copy of the cancellation was handed over beside the book.',
|
|
1801
|
+
late,
|
|
1802
|
+
strict,
|
|
1803
|
+
);
|
|
1804
|
+
} catch (e) {
|
|
1805
|
+
held.problems.push(problemFrom(e));
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
const added = new Set();
|
|
1809
|
+
// First what each acknowledging agent did itself after it was told, for every acknowledgement; then what was
|
|
1810
|
+
// done under a pass handed on late. So the words a stub is given do not depend on the order of the acknowledgements.
|
|
1811
|
+
for (const underPasses of [false, true]) {
|
|
1812
|
+
for (const [slip, slipFingerprint, a] of acknowledgedCopies) markAfterAcknowledged(slip, slipFingerprint, passes, result.entries, a, late, strict, added, underPasses);
|
|
1813
|
+
}
|
|
1814
|
+
for (const [i, held] of result.held.entries()) {
|
|
1815
|
+
for (const p of held.problems) result.problems.push({ code: p.code, message: `Cancellation ${i + 1} of those handed over beside the book did not pass its check, so it was not used. ${p.message}` });
|
|
1816
|
+
}
|
|
1817
|
+
for (const [i, p] of acknowledgementProblems) {
|
|
1818
|
+
result.problems.push({
|
|
1819
|
+
code: p.code,
|
|
1820
|
+
message: `An acknowledgement handed over with cancellation ${i + 1} of those beside the book did not pass its check, so it was not used. The cancellation itself was not set aside because of it. ${p.message}`,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// The blocks a person trusts are named by their fingerprints: 64 hex
|
|
1826
|
+
// characters each. Anything else is refused, never set aside.
|
|
1827
|
+
function checkBlocksOption(options) {
|
|
1828
|
+
const { blocks } = options;
|
|
1829
|
+
if (blocks === undefined) return;
|
|
1830
|
+
// At most as many as a book holds entries. Every place in the list is
|
|
1831
|
+
// read by its number, a gap included: "every" would skip a gap, and a
|
|
1832
|
+
// list's own way of walking through itself is not used.
|
|
1833
|
+
let sound = Array.isArray(blocks) && blocks.length <= MAX_ENTRIES;
|
|
1834
|
+
for (let i = 0; sound && i < blocks.length; i++) sound = typeof blocks[i] === 'string' && /^[0-9a-fA-F]{64}$/.test(blocks[i]);
|
|
1835
|
+
if (!sound) throw f.fail('blocks', 'must be a list of block fingerprints, each 64 hex characters, at most 100,000.');
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Compare the top fingerprint and the number of entries with what the
|
|
1839
|
+
// checker already trusts, if it was given either.
|
|
1840
|
+
function compareTrusted(result, options) {
|
|
1841
|
+
if (options.expectedRoot !== undefined && options.expectedRoot !== result.root) {
|
|
1842
|
+
throw new Refusal('root-mismatch', 'The top fingerprint is not the one expected.');
|
|
1843
|
+
}
|
|
1844
|
+
if (options.expectedSize !== undefined && options.expectedSize !== result.size) {
|
|
1845
|
+
throw new Refusal('root-mismatch', 'The number of entries is not the one expected.');
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Check a whole book.
|
|
1851
|
+
*
|
|
1852
|
+
* The answer always has the same shape, whatever the book holds. It never
|
|
1853
|
+
* throws for a bad record: a failed check is a normal answer.
|
|
1854
|
+
*
|
|
1855
|
+
* @param {string} text the book
|
|
1856
|
+
* @param {object} [options]
|
|
1857
|
+
* @param {string[]} [options.issuerKeys] thumbprints of the issuer keys the checker expects
|
|
1858
|
+
* @param {string} [options.expectedRoot] the top fingerprint the checker already trusts
|
|
1859
|
+
* @param {number} [options.expectedSize] the number of entries the checker already trusts
|
|
1860
|
+
* @param {string[]} [options.stampServices] the certificate fingerprints of the time-stamp services the checker trusts
|
|
1861
|
+
* @param {string[]} [options.blocks] the fingerprints (64 hex characters) of the blocks of a public blockchain that the checker trusts
|
|
1862
|
+
* @param {string[]} [options.sealKeys] the key set fingerprints of the recorders the checker expects
|
|
1863
|
+
* @param {string[]} [options.vouchers] the key set fingerprints of the organisations whose vouching the checker trusts
|
|
1864
|
+
* @param {Record<string, string[]>} [options.disclosures] for covered fields: the disclosures handed over, by the fingerprint of the slip they belong to
|
|
1865
|
+
* @param {object[]} [options.cancellations] the person's own copies of cancellations, each as the entry it would be in a book:
|
|
1866
|
+
* {cancellation, stamps?}. At most 16. They count even where the book leaves them out
|
|
1867
|
+
* @param {string[]} [options.withoutMethods] signing methods to treat as not built into this device
|
|
1868
|
+
* @returns {Promise<object>} see the README, "What the checker returns".
|
|
1869
|
+
* The content of an entry that has problems, or whose "verified" is
|
|
1870
|
+
* "none", is given as it was read, for whoever must find out what went
|
|
1871
|
+
* wrong. It is unverified: never show it as fact.
|
|
1872
|
+
*/
|
|
1873
|
+
export async function checkBook(text, options = {}) {
|
|
1874
|
+
return (await checkBookKeepingState(text, options)).result;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Check a whole book, and also hand back the running state of every slip,
|
|
1879
|
+
* for the check before acting (guard.js). Not part of the public interface.
|
|
1880
|
+
*/
|
|
1881
|
+
export async function checkBookKeepingState(text, options = {}) {
|
|
1882
|
+
// The options are read through the same fixed copy as a checker that is
|
|
1883
|
+
// carried on reads them, so that the two cannot read them differently.
|
|
1884
|
+
const carried = startCarried(fixedOptions(options));
|
|
1885
|
+
await carryOn(carried, text);
|
|
1886
|
+
return answerOf(carried);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// --- a book that is read once and then carried on ---
|
|
1890
|
+
//
|
|
1891
|
+
// A whole check is: start, read every line, give the answer. The three
|
|
1892
|
+
// steps are kept apart here, so that more lines can be read after an
|
|
1893
|
+
// answer was given, without reading the earlier ones again. checkBook does
|
|
1894
|
+
// the three in one go; openChecker and the stub writer (recorder.js) carry
|
|
1895
|
+
// a book on. There is one way of reading a line (readLine), so a book that
|
|
1896
|
+
// is carried on gets the answer that a whole check gives.
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Start reading a book. Not part of the public interface.
|
|
1900
|
+
* @param {object} [options] the checker's options, fixed from here on
|
|
1901
|
+
*/
|
|
1902
|
+
export function startCarried(options = {}) {
|
|
1903
|
+
if (options === null || typeof options !== 'object') options = {};
|
|
1904
|
+
const carried = {
|
|
1905
|
+
options,
|
|
1906
|
+
reader: newReader(options, true),
|
|
1907
|
+
// Whether any text was handed over at all, and whether it ended with a line feed.
|
|
1908
|
+
any: false,
|
|
1909
|
+
closed: true,
|
|
1910
|
+
// A problem with the book as a whole, which no later line can mend: an option that cannot be used, or too many entries.
|
|
1911
|
+
failure: null,
|
|
1912
|
+
};
|
|
1913
|
+
try {
|
|
1914
|
+
lookIntoOptions(options);
|
|
1915
|
+
checkBlocksOption(options);
|
|
1916
|
+
// Every line is read with this option, so one that cannot be read is a
|
|
1917
|
+
// problem with the whole check, found before any line is read.
|
|
1918
|
+
Array.isArray(options.withoutMethods);
|
|
1919
|
+
} catch (e) {
|
|
1920
|
+
carried.failure = problemFrom(e);
|
|
1921
|
+
}
|
|
1922
|
+
return carried;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// An option that cannot even be looked into (a revoked Proxy, for one),
|
|
1926
|
+
// which the fixed copy keeps as it was handed over, is a problem with the
|
|
1927
|
+
// whole check, wherever it would have been read. One that cannot be read
|
|
1928
|
+
// at all still fails only where a check reads it.
|
|
1929
|
+
function lookIntoOptions(options) {
|
|
1930
|
+
for (const name of OPTION_NAMES) {
|
|
1931
|
+
let value;
|
|
1932
|
+
try {
|
|
1933
|
+
value = options[name];
|
|
1934
|
+
} catch {
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
Array.isArray(value);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Read more of a book: whole lines, each ending with a line feed. (The
|
|
1943
|
+
* last line of all may lack its line feed, as in a whole check; nothing
|
|
1944
|
+
* can then be added after it.) Not part of the public interface.
|
|
1945
|
+
* @param {ReturnType<typeof startCarried>} carried
|
|
1946
|
+
* @param {string} text
|
|
1947
|
+
*/
|
|
1948
|
+
export async function carryOn(carried, text) {
|
|
1949
|
+
if (typeof text !== 'string' || text.length === 0) return;
|
|
1950
|
+
if (carried.any && !carried.closed) throw new RangeError('The book so far does not end with a line feed, so nothing can be added to it.');
|
|
1951
|
+
carried.any = true;
|
|
1952
|
+
carried.closed = text.endsWith('\n');
|
|
1953
|
+
if (carried.failure) return;
|
|
1954
|
+
const { reader } = carried;
|
|
1955
|
+
let lines;
|
|
1956
|
+
try {
|
|
1957
|
+
lines = splitLines(text);
|
|
1958
|
+
if (reader.entries.length + lines.length > MAX_ENTRIES) throw new Refusal('too-large', 'The book holds more than 100,000 entries.');
|
|
1959
|
+
} catch (e) {
|
|
1960
|
+
carried.failure = problemFrom(e);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
for (const line of lines) await readLine(reader, { index: reader.entries.length, line });
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* The answer for a book as it stands. It changes nothing that the reader
|
|
1968
|
+
* keeps, so more lines can be read afterwards, and the answer asked for
|
|
1969
|
+
* again. Not part of the public interface.
|
|
1970
|
+
*
|
|
1971
|
+
* @param {ReturnType<typeof startCarried>} carried
|
|
1972
|
+
* @param {object} [options] the options for what is handed over beside the book (cancellations, disclosures)
|
|
1973
|
+
* and for what is expected of it (expectedRoot, expectedSize); the options the lines were read with by default
|
|
1974
|
+
* @returns {Promise<{result: object, state: object, beside: Set<string>}>} the answer; the running state of every
|
|
1975
|
+
* slip, for the check before acting; and the slips that a cancellation handed over beside the book cancels
|
|
1976
|
+
*/
|
|
1977
|
+
export async function answerOf(carried, options = carried.options) {
|
|
1978
|
+
const result = { format: 'provared-book-draft-v0', size: 0, root: null, problems: [], notes: [], entries: [], held: [], summary: null };
|
|
1979
|
+
let state = { slips: new Map(), terms: new Map(), passes: new Map(), ids: new Set() };
|
|
1980
|
+
let sealed = null;
|
|
1981
|
+
let late = new Map();
|
|
1982
|
+
const beside = new Set();
|
|
1983
|
+
if (options === null || typeof options !== 'object') options = {};
|
|
1984
|
+
try {
|
|
1985
|
+
if (!carried.any) throw new Refusal('not-json', 'There is no record to check.');
|
|
1986
|
+
if (carried.failure) throw new Refusal(carried.failure.code, carried.failure.message);
|
|
1987
|
+
const { reader } = carried;
|
|
1988
|
+
result.size = reader.entries.length;
|
|
1989
|
+
// Until the answer is put together, the entries are looked at as the reader keeps them.
|
|
1990
|
+
result.entries = reader.entries;
|
|
1991
|
+
late = settleLate(reader);
|
|
1992
|
+
state = { slips: reader.slips, terms: reader.terms, passes: reader.passes, ids: reader.ids };
|
|
1993
|
+
sealed = reader.seals.sealed;
|
|
1994
|
+
result.root = toBase64url(await reader.tree.root());
|
|
1995
|
+
result.problems.push(...reader.handedProblems);
|
|
1996
|
+
unusedDisclosures(result, options);
|
|
1997
|
+
await checkHeld(result, reader, options, late, beside);
|
|
1998
|
+
compareTrusted(result, options);
|
|
1999
|
+
} catch (e) {
|
|
2000
|
+
result.problems.push(problemFrom(e));
|
|
2001
|
+
}
|
|
2002
|
+
const read = result.entries;
|
|
2003
|
+
result.entries = read.map((e) => answered(e, late, read));
|
|
2004
|
+
result.summary = summarise(result.entries, result.problems, sealed, result.held);
|
|
2005
|
+
return { result, state, beside };
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* A book that carries on by itself from where this one stands, to try one
|
|
2010
|
+
* more line: a stub or, with "whole", a line of any kind (forkReader). The
|
|
2011
|
+
* book it was split from is left exactly as it was. Not part of the public
|
|
2012
|
+
* interface.
|
|
2013
|
+
*/
|
|
2014
|
+
export function forkCarried(carried, whole = false) {
|
|
2015
|
+
return { ...carried, reader: forkReader(carried.reader, whole) };
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* A book that was split off to try a line takes the place of the one it
|
|
2020
|
+
* was split from, which is not used again. From here on it reads any kind
|
|
2021
|
+
* of entry. Not part of the public interface.
|
|
2022
|
+
*/
|
|
2023
|
+
export function promoteCarried(carried) {
|
|
2024
|
+
carried.reader.forked = false;
|
|
2025
|
+
return carried;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* A checker that can be carried on. It reads a book once, keeps what it
|
|
2030
|
+
* has worked out, and then takes more lines as the book grows, without
|
|
2031
|
+
* reading the earlier lines again. Its answer for the book so far is the
|
|
2032
|
+
* answer that checkBook gives for the same text.
|
|
2033
|
+
*
|
|
2034
|
+
* @param {string} [text] the book so far; it may be empty
|
|
2035
|
+
* @param {object} [options] as for checkBook. They are read once, when the checker is opened, as checkBook
|
|
2036
|
+
* reads them, and fixed: lists and plain objects are copied, so that nothing done to them afterwards
|
|
2037
|
+
* changes the checker
|
|
2038
|
+
* @returns {Promise<{add: (text: string) => Promise<void>, result: () => Promise<object>}>}
|
|
2039
|
+
* "add" takes more whole lines, each ending with a line feed. "result"
|
|
2040
|
+
* gives the answer for the book so far: a copy of its own each time, so
|
|
2041
|
+
* that nothing done to an answer changes the checker. The calls wait
|
|
2042
|
+
* for one another.
|
|
2043
|
+
*/
|
|
2044
|
+
export async function openChecker(text = '', options = {}) {
|
|
2045
|
+
const carried = startCarried(fixedOptions(options));
|
|
2046
|
+
await carryOn(carried, text);
|
|
2047
|
+
let turn = Promise.resolve();
|
|
2048
|
+
const inTurn = (work) => {
|
|
2049
|
+
const run = turn.then(work);
|
|
2050
|
+
turn = run.catch(() => {});
|
|
2051
|
+
return run;
|
|
2052
|
+
};
|
|
2053
|
+
return {
|
|
2054
|
+
add(more) {
|
|
2055
|
+
return inTurn(() => carryOn(carried, more));
|
|
2056
|
+
},
|
|
2057
|
+
result() {
|
|
2058
|
+
return inTurn(async () => structuredClone((await answerOf(carried)).result));
|
|
2059
|
+
},
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Make a Show: some entries of a book, each with the proof that it is in the
|
|
2065
|
+
* book (format description, section 11).
|
|
2066
|
+
* @param {string} text the book
|
|
2067
|
+
* @param {number[]} indexes which entries, counting from 0; at most 64
|
|
2068
|
+
* @param {object} [options]
|
|
2069
|
+
* @param {number} [options.seal] the entry that is a seal to show the pages under: the Show is then of
|
|
2070
|
+
* the book as it stood when that seal was made, and carries the seal and its time-stamps
|
|
2071
|
+
* @param {Record<number, string[]>} [options.disclosures] for covered fields: the disclosures to hand over
|
|
2072
|
+
* with a page, by the page's index
|
|
2073
|
+
* @returns {Promise<object>}
|
|
2074
|
+
*/
|
|
2075
|
+
export async function makeShow(text, indexes, options = {}) {
|
|
2076
|
+
let lines = splitLines(text);
|
|
2077
|
+
let seal;
|
|
2078
|
+
if (options.seal !== undefined) {
|
|
2079
|
+
if (!Number.isInteger(options.seal) || options.seal < 1 || options.seal >= lines.length) throw new RangeError('there is no such entry');
|
|
2080
|
+
seal = JSON.parse(lines[options.seal]);
|
|
2081
|
+
if (seal === null || typeof seal !== 'object' || !Object.hasOwn(seal, 'seal')) throw new RangeError('that entry is not a seal');
|
|
2082
|
+
lines = lines.slice(0, options.seal);
|
|
2083
|
+
}
|
|
2084
|
+
const leaves = lines.map(utf8);
|
|
2085
|
+
const wanted = [...new Set(indexes)].sort((a, b) => a - b);
|
|
2086
|
+
if (wanted.length < 1 || wanted.length > MAX_PAGES) throw new RangeError('a Show holds 1 to 64 pages');
|
|
2087
|
+
const pages = [];
|
|
2088
|
+
for (const index of wanted) {
|
|
2089
|
+
const path = await inclusionPath(leaves, index);
|
|
2090
|
+
const page = { index, entry: lines[index], path: path.map(toBase64url) };
|
|
2091
|
+
const reveal = options.disclosures && Object.hasOwn(options.disclosures, index) ? options.disclosures[index] : null;
|
|
2092
|
+
if (reveal) page.disclosures = reveal;
|
|
2093
|
+
pages.push(page);
|
|
2094
|
+
}
|
|
2095
|
+
const show = { type: 'provared.show.v0', size: lines.length, root: toBase64url(await treeRoot(leaves)), pages };
|
|
2096
|
+
if (seal) show.seal = seal;
|
|
2097
|
+
return show;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* Check a Show.
|
|
2102
|
+
*
|
|
2103
|
+
* A Show can show a stub outside its slip (a wrong action, a wrong party, a
|
|
2104
|
+
* wrong time, or a single stub over the limit). It cannot show that the
|
|
2105
|
+
* agent stayed within a limit: that needs every stub under the slip.
|
|
2106
|
+
*
|
|
2107
|
+
* @param {any} show the Show, as a JavaScript object or as JSON text
|
|
2108
|
+
* @param {object} [options]
|
|
2109
|
+
* @param {string} [options.expectedRoot] the top fingerprint the checker already trusts
|
|
2110
|
+
* @param {number} [options.expectedSize] the number of entries the checker already trusts
|
|
2111
|
+
* @param {string[]} [options.issuerKeys]
|
|
2112
|
+
* @param {string[]} [options.stampServices]
|
|
2113
|
+
* @param {string[]} [options.sealKeys]
|
|
2114
|
+
* @param {string[]} [options.withoutMethods]
|
|
2115
|
+
* @returns {Promise<object>} the same shape as checkBook returns. A seal that comes with the Show is the last entry.
|
|
2116
|
+
*/
|
|
2117
|
+
export async function checkShow(show, options = {}) {
|
|
2118
|
+
const result = { format: 'provared-show-draft-v0', size: 0, root: null, problems: [], notes: [], entries: [], held: [], summary: null };
|
|
2119
|
+
let sealed = null;
|
|
2120
|
+
try {
|
|
2121
|
+
// Read through the same fixed copy as a whole check reads them.
|
|
2122
|
+
options = fixedOptions(options);
|
|
2123
|
+
if (typeof show === 'string') {
|
|
2124
|
+
try {
|
|
2125
|
+
show = JSON.parse(show);
|
|
2126
|
+
} catch {
|
|
2127
|
+
throw new Refusal('not-json', 'The Show is not JSON.');
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
lookIntoOptions(options);
|
|
2131
|
+
checkBlocksOption(options);
|
|
2132
|
+
f.members(show, ['pages', 'root', 'size', 'type'], ['seal'], 'show');
|
|
2133
|
+
if (show.type !== 'provared.show.v0') throw f.fail('show.type', 'must be provared.show.v0.');
|
|
2134
|
+
f.wholeNumber(show.size, 'show.size');
|
|
2135
|
+
if (show.size < 1 || show.size > MAX_ENTRIES) throw f.fail('show.size', 'must be from 1 to 100,000.');
|
|
2136
|
+
f.fingerprintText(show.root, 'show.root');
|
|
2137
|
+
f.list(show.pages, 1, MAX_PAGES, 'show.pages');
|
|
2138
|
+
result.size = show.size;
|
|
2139
|
+
result.root = show.root;
|
|
2140
|
+
const root = fromBase64url(show.root);
|
|
2141
|
+
|
|
2142
|
+
compareTrusted(result, options);
|
|
2143
|
+
|
|
2144
|
+
const lines = [];
|
|
2145
|
+
const seen = new Set();
|
|
2146
|
+
for (let i = 0; i < show.pages.length; i++) {
|
|
2147
|
+
const page = show.pages[i];
|
|
2148
|
+
f.members(page, ['entry', 'index', 'path'], ['disclosures'], `pages[${i}]`);
|
|
2149
|
+
if (Object.hasOwn(page, 'disclosures')) f.list(page.disclosures, 1, 64, `pages[${i}].disclosures`);
|
|
2150
|
+
f.wholeNumber(page.index, `pages[${i}].index`);
|
|
2151
|
+
if (typeof page.entry !== 'string' || page.entry.length < 1 || page.entry.length > MAX_LINE_BYTES) {
|
|
2152
|
+
throw f.fail(`pages[${i}].entry`, 'must be one line of a book.');
|
|
2153
|
+
}
|
|
2154
|
+
f.list(page.path, 0, 64, `pages[${i}].path`);
|
|
2155
|
+
if (seen.has(page.index)) throw f.fail(`pages[${i}].index`, 'a page is repeated.');
|
|
2156
|
+
seen.add(page.index);
|
|
2157
|
+
const path = page.path.map((p, j) => {
|
|
2158
|
+
f.fingerprintText(p, `pages[${i}].path[${j}]`);
|
|
2159
|
+
return fromBase64url(p);
|
|
2160
|
+
});
|
|
2161
|
+
if (!(await verifyInclusion(page.index, show.size, utf8(page.entry), path, root))) {
|
|
2162
|
+
throw new Refusal('proof-invalid', `The proof for page ${page.index} does not lead to the top fingerprint.`);
|
|
2163
|
+
}
|
|
2164
|
+
lines.push({ index: page.index, line: page.entry, disclosures: page.disclosures });
|
|
2165
|
+
}
|
|
2166
|
+
lines.sort((a, b) => a.index - b.index);
|
|
2167
|
+
const reader = newReader(options, false);
|
|
2168
|
+
for (const item of lines) await readLine(reader, item);
|
|
2169
|
+
const checked = { ids: reader.ids, stampFloor: reader.seals.lastStampTime };
|
|
2170
|
+
result.entries = reader.entries;
|
|
2171
|
+
result.problems.push(...reader.handedProblems);
|
|
2172
|
+
unusedDisclosures(result, options);
|
|
2173
|
+
comparePageSeals(result.entries);
|
|
2174
|
+
|
|
2175
|
+
// A seal that comes with the Show: the recorder's signature on exactly
|
|
2176
|
+
// this top fingerprint and this number of entries, and the outside
|
|
2177
|
+
// time-stamps on that seal.
|
|
2178
|
+
if (Object.hasOwn(show, 'seal')) {
|
|
2179
|
+
const entry = { index: show.size, kind: 'seal', fingerprint: null, verified: 'none', problems: [], breaches: [], notes: [] };
|
|
2180
|
+
result.entries.push(entry);
|
|
2181
|
+
try {
|
|
2182
|
+
f.members(show.seal, ['seal'], ['stamps'], 'show.seal');
|
|
2183
|
+
// It is held to the pages as a seal in a whole book is held to the
|
|
2184
|
+
// entries before it: no unique number twice, and no block
|
|
2185
|
+
// time-stamp counted that states a time before a service's
|
|
2186
|
+
// time-stamp on a seal among the pages.
|
|
2187
|
+
await checkSealEntry(entry, show.seal, { count: 0, last: null, lastWhen: -Infinity, lastStampTime: checked.stampFloor, next: 0, sealed: null }, checked.ids, options, null, []);
|
|
2188
|
+
entry.vouched = null;
|
|
2189
|
+
if (entry.content.size !== show.size || entry.content.root !== show.root) {
|
|
2190
|
+
entry.problems.push({ code: 'seal-mismatch', message: 'The seal that comes with the Show is for another book, or for this book at another length.' });
|
|
2191
|
+
}
|
|
2192
|
+
// Against the seals among the pages, which all come before it in the book.
|
|
2193
|
+
laterThanSeals(entry, result.entries);
|
|
2194
|
+
// The seal's own date against the pages it covers, as in a whole book.
|
|
2195
|
+
if (datedBeforeCovered(parseTime(entry.content.when), result.entries, result.entries.length - 1, entry.stampedAt ? parseTime(entry.stampedAt) : null)) {
|
|
2196
|
+
entry.problems.push({ code: 'time-went-backwards', message: 'This seal is dated before a page it covers. One of the two dates is not as it says.' });
|
|
2197
|
+
}
|
|
2198
|
+
} catch (e) {
|
|
2199
|
+
entry.problems.push(problemFrom(e));
|
|
2200
|
+
}
|
|
2201
|
+
if (entry.problems.length) entry.verified = 'none';
|
|
2202
|
+
else if (entry.stampedAt) {
|
|
2203
|
+
const time = parseTime(entry.stampedAt);
|
|
2204
|
+
for (const page of result.entries) if (page !== entry) creditStamp(page, entry.stampedAt, time);
|
|
2205
|
+
sealed = { entries: show.size, when: entry.stampedAt, seal: show.size, by: countedBy(entry, checked.stampFloor) };
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
if (options.expectedRoot === undefined && !sealed) {
|
|
2209
|
+
result.notes.push('The top fingerprint was not compared with a copy you already trust, and no seal with a time-stamp from a service you trust comes with it.');
|
|
2210
|
+
} else if (options.expectedSize === undefined && !sealed) {
|
|
2211
|
+
result.notes.push('The number of entries was not compared with a number you already trust. The top fingerprint alone does not fix it.');
|
|
2212
|
+
}
|
|
2213
|
+
result.notes.push('Single pages cannot show whether the chain is whole, whether a limit was kept, or whether an approval was used only once. Those need every stub under the slip.');
|
|
2214
|
+
result.notes.push('Single pages cannot show that a slip was not cancelled, or that a vouching record was not withdrawn, on a page that is not shown.');
|
|
2215
|
+
// An empty list is none, as for a whole book.
|
|
2216
|
+
if (options.cancellations !== undefined && !(Array.isArray(options.cancellations) && options.cancellations.length === 0)) {
|
|
2217
|
+
result.notes.push('Cancellations were handed over beside this Show. They were not used: single pages cannot show which stubs existed before a cancellation. Check the whole book with them.');
|
|
2218
|
+
}
|
|
2219
|
+
} catch (e) {
|
|
2220
|
+
result.problems.push(problemFrom(e));
|
|
2221
|
+
}
|
|
2222
|
+
// An acknowledgement whose cancellation, among the pages, failed after the acknowledgement was read.
|
|
2223
|
+
for (const e of result.entries) {
|
|
2224
|
+
if (e.kind !== 'acknowledgement' || e.cancellation === undefined) continue;
|
|
2225
|
+
const named = result.entries.find((p) => p.index === e.cancellation);
|
|
2226
|
+
if (named && named.problems.length > 0) failAcknowledgement(e);
|
|
2227
|
+
}
|
|
2228
|
+
// A seal or a cancellation that did not pass its check shows no time of its own.
|
|
2229
|
+
for (const e of result.entries) if (e.problems.length > 0) delete e.stampedAt;
|
|
2230
|
+
result.summary = summarise(result.entries, result.problems, sealed);
|
|
2231
|
+
return result;
|
|
2232
|
+
}
|