provared 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1083 -0
- package/README.md +6 -3
- package/bin/provared-check.mjs +115 -11
- package/docs/guide.md +464 -15
- package/docs/threat-model.md +315 -9
- package/package.json +2 -2
- package/page/check-page.js +11 -32
- package/page/index.html +4 -0
- package/page/render.js +157 -9
- package/spec/provared-format.md +809 -23
- package/src/book.js +539 -18
- package/src/check.d.ts +3 -0
- package/src/check.js +2 -0
- package/src/denial.js +123 -0
- package/src/der.js +55 -6
- package/src/dev.d.ts +1 -1
- package/src/dev.js +1 -1
- package/src/documents.js +149 -0
- package/src/guard.js +13 -2
- package/src/index.js +18 -3
- package/src/jws.js +8 -0
- package/src/keys.js +48 -1
- package/src/pass.js +1 -1
- package/src/recorder.js +267 -15
- package/src/seal.js +36 -7
- package/src/sealer.js +812 -0
- package/src/service.js +4 -4
- package/src/signatures.js +281 -5
- package/src/standing.js +4 -4
- package/src/stub.js +18 -5
- package/src/timestamp.js +55 -16
- package/src/tools.js +30 -13
- package/src/tree.js +139 -7
- package/src/types.d.ts +314 -20
- package/src/witness.js +63 -0
- package/src/witnessing.js +362 -0
package/src/recorder.js
CHANGED
|
@@ -32,16 +32,36 @@
|
|
|
32
32
|
// objects changes nothing.
|
|
33
33
|
|
|
34
34
|
import { MAX_LINE_BYTES, answerOf, carryOn, checkCountersignature, entryLine, fixedOptions, forkCarried, latestWhen, promoteCarried, startCarried } from './book.js';
|
|
35
|
-
import { Refusal, canonicalJson, fingerprint, fromBase64url, parseTime, problemFrom, utf8 } from './encoding.js';
|
|
36
|
-
import { checkBeforeRead } from './guard.js';
|
|
35
|
+
import { Refusal, canonicalJson, fingerprint, formatTime, fromBase64url, parseTime, problemFrom, utf8 } from './encoding.js';
|
|
36
|
+
import { checkBeforeRead, lineBound } from './guard.js';
|
|
37
|
+
import { KEY_SET_METHODS } from './keys.js';
|
|
37
38
|
import { CLOCK_ALLOWANCE_MS, MAX_STAMPS } from './seal.js';
|
|
39
|
+
import { holdSigningKeys } from './signatures.js';
|
|
38
40
|
import { writeAcknowledgement } from './standing.js';
|
|
39
|
-
import {
|
|
41
|
+
import { denialCodes, writeDenial } from './denial.js';
|
|
42
|
+
import { validateRequest, writeStub } from './stub.js';
|
|
40
43
|
|
|
41
44
|
/** How long the other side is given to hand back its countersignature, unless the caller says otherwise: 30 seconds. */
|
|
42
45
|
export const COUNTERSIGN_WITHIN_MS = 30000;
|
|
46
|
+
/** How long a signer is given for each signature, unless the caller says otherwise: 30 seconds. */
|
|
47
|
+
export const SIGN_WITHIN_MS = 30000;
|
|
48
|
+
/** The most denials a writer writes in any hour, unless the caller says otherwise: 600. */
|
|
49
|
+
export const DENIALS_PER_HOUR = 600;
|
|
50
|
+
// The most a caller may set it to: as many as a book may hold entries.
|
|
51
|
+
const MAX_DENIALS_PER_HOUR = 100000;
|
|
52
|
+
const HOUR_MS = 3600000;
|
|
43
53
|
// The longest wait a timer can hold.
|
|
44
54
|
const MAX_WAIT_MS = 2 ** 31 - 1;
|
|
55
|
+
// "detailsAfter" in "act": the most documents it may add, and the most the
|
|
56
|
+
// request may name beside them, so that a stub never names more than 32.
|
|
57
|
+
const MAX_DETAILS_AFTER = 4;
|
|
58
|
+
const MAX_DETAILS_BEFORE = 32 - MAX_DETAILS_AFTER;
|
|
59
|
+
// The room kept for them: the largest documents a stub may name, a name of
|
|
60
|
+
// 200 characters that are each written as six (a control character, which
|
|
61
|
+
// the one form of RFC 8785 writes as "\u00XX"), with a fingerprint.
|
|
62
|
+
const ROOM_DOCUMENT = Object.freeze({ name: '\u0001'.repeat(200), sha256: 'A'.repeat(43) });
|
|
63
|
+
// The stub with the further documents could not be made or signed.
|
|
64
|
+
class NotAdded extends Error {}
|
|
45
65
|
// The most cancellations a check may be handed beside a book (book.js).
|
|
46
66
|
const MAX_HANDED = 16;
|
|
47
67
|
|
|
@@ -204,17 +224,38 @@ function plainHeld(copy) {
|
|
|
204
224
|
* @param {object} o
|
|
205
225
|
* @param {string} o.book the book so far: at least the slip
|
|
206
226
|
* @param {string} o.slip the fingerprint of the slip
|
|
207
|
-
* @param {
|
|
227
|
+
* @param {any[]} o.privateKeys the agent's private keys, or signers (signatures.js): Ed25519, then ML-DSA-87
|
|
208
228
|
* @param {string[]} o.issuerKeys the thumbprints of the passkeys this agent's software trusts; a slip from any other key allows nothing
|
|
209
229
|
* @param {string} [o.pass] for a helper agent: the fingerprint of the pass it acts under
|
|
210
230
|
* @param {object} [o.options] further options for the checker (sealKeys, stampServices, blocks, vouchers, cancellations).
|
|
211
231
|
* They are read once, as a whole check reads them, and fixed
|
|
212
232
|
* @param {() => number} [o.now] the clock, in milliseconds; defaults to the device's. "act" takes an action at the clock's time, and nothing is written that is dated more than 300 seconds ahead of it
|
|
213
233
|
* @param {number} [o.countersignWithin] how long the other side is given to countersign, in milliseconds; defaults to 30,000
|
|
214
|
-
* @
|
|
215
|
-
*
|
|
234
|
+
* @param {number} [o.signWithin] how long a signer is given for each signature, in milliseconds; defaults to 30,000.
|
|
235
|
+
* After that the action is not taken and nothing is written; a late answer is not used
|
|
236
|
+
* @param {boolean} [o.denials] true to write a denial (format description, section 28) whenever "act" does not take an
|
|
237
|
+
* action, and to let "deny" write one; defaults to false, and then no denial is written
|
|
238
|
+
* @param {number} [o.denialsPerHour] the most denials written in any hour, by the clock; defaults to 600. Each
|
|
239
|
+
* denial this writer writes, or is handed through "add", counts at the clock's time when it joins the book, whatever
|
|
240
|
+
* date it states; those the book held when the writer was opened count at the dates they state. Past the limit, a
|
|
241
|
+
* denial is not written, and the answers count those not written
|
|
242
|
+
* @returns {Promise<object>} the writer: before, record, act, deny, book, check, cancellations, add
|
|
243
|
+
* @throws {Refusal} "record-not-sound" if the book so far has a problem; "bad-field" or "bad-key" if an item of
|
|
244
|
+
* privateKeys is neither a key nor a signer, or is a signer that holds another key than the slip, or the pass, names
|
|
216
245
|
*/
|
|
217
|
-
export async function openRecorder({
|
|
246
|
+
export async function openRecorder({
|
|
247
|
+
book,
|
|
248
|
+
slip,
|
|
249
|
+
privateKeys,
|
|
250
|
+
issuerKeys,
|
|
251
|
+
pass,
|
|
252
|
+
options = {},
|
|
253
|
+
now = Date.now,
|
|
254
|
+
countersignWithin = COUNTERSIGN_WITHIN_MS,
|
|
255
|
+
signWithin = SIGN_WITHIN_MS,
|
|
256
|
+
denials = false,
|
|
257
|
+
denialsPerHour = DENIALS_PER_HOUR,
|
|
258
|
+
}) {
|
|
218
259
|
if (typeof now !== 'function') throw new Refusal('bad-field', 'now: must be a function that gives the time in milliseconds.');
|
|
219
260
|
// A clock that gives anything but a time is a clock that failed: no comparison with it would hold.
|
|
220
261
|
const device = now;
|
|
@@ -226,6 +267,13 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
226
267
|
if (!(Number.isFinite(countersignWithin) && countersignWithin >= 1 && countersignWithin <= MAX_WAIT_MS)) {
|
|
227
268
|
throw new Refusal('bad-field', 'countersignWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
|
|
228
269
|
}
|
|
270
|
+
if (!(Number.isFinite(signWithin) && signWithin >= 1 && signWithin <= MAX_WAIT_MS)) {
|
|
271
|
+
throw new Refusal('bad-field', 'signWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
|
|
272
|
+
}
|
|
273
|
+
if (typeof denials !== 'boolean') throw new Refusal('bad-field', 'denials: must be true or false.');
|
|
274
|
+
if (!(Number.isInteger(denialsPerHour) && denialsPerHour >= 1 && denialsPerHour <= MAX_DENIALS_PER_HOUR)) {
|
|
275
|
+
throw new Refusal('bad-field', 'denialsPerHour: must be a whole number from 1 to 100,000.');
|
|
276
|
+
}
|
|
229
277
|
// Whom the writer trusts is fixed when it is opened: read once, as a whole
|
|
230
278
|
// check reads it, and copied. The passkeys it trusts are its own
|
|
231
279
|
// "issuerKeys", whatever the options say, read through the same fixed
|
|
@@ -340,12 +388,23 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
340
388
|
const { result } = await standing();
|
|
341
389
|
if (result.summary.problemFound) throw notWritten('A problem was found in the record so far. It must be looked at before the agent acts again.');
|
|
342
390
|
let after = lastStub(result.entries);
|
|
391
|
+
// The times of the denials of this writer's own chain, which the hourly
|
|
392
|
+
// limit counts: those the book held when the writer was opened, at the
|
|
393
|
+
// dates they state; and each one that joins the book since, at the
|
|
394
|
+
// clock's time when it joined, whatever date it states (a denial may be
|
|
395
|
+
// dated up to 300 seconds back, and counting it there would let more
|
|
396
|
+
// than the limit be written in an hour of the clock).
|
|
397
|
+
const ownDenial = (e) => e.kind === 'denial' && e.problems.length === 0 && e.slip === slip && (e.pass ?? null) === (pass ?? null);
|
|
398
|
+
const denialTimes = result.entries.filter(ownDenial).map((e) => parseTime(e.content.when));
|
|
399
|
+
// How many denials were not written because of the hourly limit, since the writer was opened.
|
|
400
|
+
let notWrittenDenials = 0;
|
|
343
401
|
// What was given at opening: the first sound acknowledgement of this
|
|
344
402
|
// writer's own agent that came with a cancellation is the one it goes on
|
|
345
403
|
// handing back; a cancellation that the book does not hold waits to be
|
|
346
404
|
// written.
|
|
405
|
+
// (Seals held elsewhere, handed over with the options, come after the cancellations, and are not these.)
|
|
347
406
|
result.held.forEach((h, i) => {
|
|
348
|
-
if (!h.used) return;
|
|
407
|
+
if (h.kind !== 'cancellation' || !h.used) return;
|
|
349
408
|
// The person's cancellation of this writer's own slip, given when it was opened, stops it from the start.
|
|
350
409
|
if (h.slip === slip) stopped = true;
|
|
351
410
|
h.acknowledgements.forEach((a, j) => {
|
|
@@ -370,7 +429,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
370
429
|
// holds without it, follows the cancellation into the book: it was still
|
|
371
430
|
// waiting when the writer that signed it handed it back.
|
|
372
431
|
result.held.forEach((h, i) => {
|
|
373
|
-
if (h.used && h.slip === slip && h.inBook !== null && acknowledged.has(h.fingerprint) && !written.has(h.fingerprint)) waiting.set(h.fingerprint, given[i].cancellation);
|
|
432
|
+
if (h.kind === 'cancellation' && h.used && h.slip === slip && h.inBook !== null && acknowledged.has(h.fingerprint) && !written.has(h.fingerprint)) waiting.set(h.fingerprint, given[i].cancellation);
|
|
374
433
|
});
|
|
375
434
|
// The services the slip names, to check a countersignature before it goes into the book.
|
|
376
435
|
const slipEntry = result.entries.find((e) => e.kind === 'slip' && e.fingerprint === slip);
|
|
@@ -403,6 +462,17 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
403
462
|
}
|
|
404
463
|
};
|
|
405
464
|
|
|
465
|
+
// The agent's keys, or signers (signatures.js). A signer is read once,
|
|
466
|
+
// here, and must hold the keys that the slip, or the pass this writer
|
|
467
|
+
// acts under, names for its agent. Each of its signatures is waited for
|
|
468
|
+
// "signWithin" at most, under the same mark as an action, so that a
|
|
469
|
+
// signer that calls the writer is refused at once; after that nothing is
|
|
470
|
+
// written, and a late answer is not used. Keys of the platform are used
|
|
471
|
+
// as they always were.
|
|
472
|
+
const passEntry = pass ? result.entries.find((e) => e.kind === 'pass' && e.fingerprint === pass) : undefined;
|
|
473
|
+
const ownKeys = pass ? passEntry?.content?.to?.keys : slipEntry?.content?.agent?.keys;
|
|
474
|
+
const signing = await holdSigningKeys(privateKeys, KEY_SET_METHODS, ownKeys, signWithin, runInside);
|
|
475
|
+
|
|
406
476
|
// What a call hands in, copied at the moment of the call.
|
|
407
477
|
const asked = (request, more) => {
|
|
408
478
|
if (request === null || typeof request !== 'object') throw new Refusal('bad-field', 'The request must be an object.');
|
|
@@ -412,8 +482,13 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
412
482
|
when: more.when === undefined || more.when === null ? undefined : timeOf(more.when),
|
|
413
483
|
terms: more.terms,
|
|
414
484
|
countersign: more.countersign,
|
|
485
|
+
detailsAfter: more.detailsAfter,
|
|
415
486
|
hasApproval: more.approval !== undefined && more.approval !== null,
|
|
416
487
|
approval: undefined,
|
|
488
|
+
// For "act": false writes no denial for this call. For "deny": who said no, and why.
|
|
489
|
+
denial: more.denial,
|
|
490
|
+
source: more.source,
|
|
491
|
+
codes: more.codes === undefined ? undefined : plain(more.codes, 'codes'),
|
|
417
492
|
};
|
|
418
493
|
if (call.hasApproval) {
|
|
419
494
|
try {
|
|
@@ -448,6 +523,20 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
448
523
|
if (call.hasApproval) proposal.approval = call.approval;
|
|
449
524
|
return proposal;
|
|
450
525
|
};
|
|
526
|
+
// Whether the stub for this request, with the countersignature the check
|
|
527
|
+
// before acting keeps room for, would still fit a line with the largest
|
|
528
|
+
// further documents that "detailsAfter" may add: the same bound as the
|
|
529
|
+
// check before acting uses, over the request with those documents.
|
|
530
|
+
const roomForMore = (request, when) => {
|
|
531
|
+
try {
|
|
532
|
+
const c = { action: request.action, when: formatTime(when) };
|
|
533
|
+
for (const name of ['amount', 'with']) if (request[name] !== undefined) c[name] = request[name];
|
|
534
|
+
c.details = [...(Array.isArray(request.details) ? request.details : []), ...Array(MAX_DETAILS_AFTER).fill(ROOM_DOCUMENT)];
|
|
535
|
+
return lineBound(c, undefined) <= MAX_LINE_BYTES;
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
451
540
|
const aheadOfClock = (when) => when > now() + CLOCK_ALLOWANCE_MS;
|
|
452
541
|
const behindClock = (when) => when < now() - CLOCK_ALLOWANCE_MS;
|
|
453
542
|
|
|
@@ -491,7 +580,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
491
580
|
pass,
|
|
492
581
|
when,
|
|
493
582
|
},
|
|
494
|
-
|
|
583
|
+
signing,
|
|
495
584
|
);
|
|
496
585
|
const entry = { stub: stub.record };
|
|
497
586
|
if (call.hasApproval) entry.approval = call.approval;
|
|
@@ -569,6 +658,54 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
569
658
|
return { fits: true, checked };
|
|
570
659
|
};
|
|
571
660
|
|
|
661
|
+
// A denial: the agent's side states that it did not take an action, and
|
|
662
|
+
// why. It is written into the book only if the book still passes its check
|
|
663
|
+
// with it and it is not dated ahead of the clock; it is tried on a reader
|
|
664
|
+
// split off the book's own, as a stub is, because a denial changes nothing
|
|
665
|
+
// read before it. At most "denialsPerHour" are written in any hour, by
|
|
666
|
+
// the clock: past it, none is written and the writer counts it. Whatever
|
|
667
|
+
// happens, the answer says what was done; nothing is thrown, so a denial
|
|
668
|
+
// never stands in the way of what the writer does next. The request is the
|
|
669
|
+
// copy made when the call was made, and only its action, amount, service
|
|
670
|
+
// and documents are written: the documents are fingerprints, never the
|
|
671
|
+
// documents themselves.
|
|
672
|
+
const denialAnswer = (problem) => ({ written: false, problem, notWritten: notWrittenDenials });
|
|
673
|
+
const writeDenialLine = async (request, source, codes, when) => {
|
|
674
|
+
try {
|
|
675
|
+
const at = now();
|
|
676
|
+
const t = when ?? at;
|
|
677
|
+
if (!Number.isFinite(t)) return denialAnswer({ code: 'bad-field', message: 'when: does not give a time.' });
|
|
678
|
+
if (aheadOfClock(t) || behindClock(t)) {
|
|
679
|
+
return denialAnswer({ code: 'record-not-sound', message: 'The denial was not written: a denial is written when the action is refused, so its date must be within 300 seconds of the stub writer\'s clock.' });
|
|
680
|
+
}
|
|
681
|
+
const inHour = denialTimes.filter((d) => d > at - HOUR_MS).length;
|
|
682
|
+
if (inHour >= denialsPerHour) {
|
|
683
|
+
notWrittenDenials++;
|
|
684
|
+
return denialAnswer({
|
|
685
|
+
code: 'denials-over-limit',
|
|
686
|
+
message: `The denial was not written: this stub writer has written ${denialsPerHour} denials in the last hour, the most it may. Since it was opened, ${notWrittenDenials} were not written for that reason.`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const fields = { slip, action: request.action, source, when: t };
|
|
690
|
+
for (const name of ['amount', 'with', 'details']) if (request[name] !== undefined) fields[name] = request[name];
|
|
691
|
+
if (pass) fields.pass = pass;
|
|
692
|
+
if (codes) fields.codes = codes;
|
|
693
|
+
const made = await writeDenial(fields, signing);
|
|
694
|
+
const line = entryLine({ denial: made.record });
|
|
695
|
+
if (utf8(line).length > MAX_LINE_BYTES) return denialAnswer({ code: 'too-large', message: 'The denial is longer than a line of a book may be.' });
|
|
696
|
+
const tried = forkCarried(carried);
|
|
697
|
+
await carryOn(tried, line + '\n');
|
|
698
|
+
const checked = (await standing(tried)).result;
|
|
699
|
+
if (checked.summary.problemFound) return denialAnswer({ code: 'record-not-sound', message: 'The denial was not written: with it, the book would not pass its check.' });
|
|
700
|
+
carried = promoteCarried(tried);
|
|
701
|
+
text += line + '\n';
|
|
702
|
+
denialTimes.push(at);
|
|
703
|
+
return { written: true, fingerprint: made.fingerprint, line, notWritten: notWrittenDenials };
|
|
704
|
+
} catch (e) {
|
|
705
|
+
return denialAnswer(problemFrom(e));
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
572
709
|
// A cancellation held beside the book is written into the book as soon as
|
|
573
710
|
// it can be: once the date it gives is no longer ahead of the clock. It
|
|
574
711
|
// goes in as the entry it is, without the acknowledgements that came with
|
|
@@ -644,7 +781,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
644
781
|
}
|
|
645
782
|
let made;
|
|
646
783
|
try {
|
|
647
|
-
made = (await writeAcknowledgement({ slip, cancellation, pass, when },
|
|
784
|
+
made = (await writeAcknowledgement({ slip, cancellation, pass, when }, signing)).record;
|
|
648
785
|
} catch {
|
|
649
786
|
return undefined;
|
|
650
787
|
}
|
|
@@ -808,10 +945,28 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
808
945
|
* If taking the action never ends, the writer waits: it cannot know
|
|
809
946
|
* what happened.
|
|
810
947
|
*
|
|
948
|
+
* Where an identifier or a document comes only with what the action
|
|
949
|
+
* gives back (the identifier a platform gave the call, say), "detailsAfter"
|
|
950
|
+
* names it: a function of what the action gave, that gives up to four
|
|
951
|
+
* further documents ({name, sha256} each). The stub is then signed again
|
|
952
|
+
* after the action, with them, still in this call's turn, so that no
|
|
953
|
+
* other call comes between; it keeps the time the action began. The
|
|
954
|
+
* request may then name at most 28 documents, and no approval may be
|
|
955
|
+
* given (an approval covers exactly the documents the person saw).
|
|
956
|
+
* "detailsAfter" is given as long as the other side is given to
|
|
957
|
+
* countersign. If it fails, gives no answer in time, gives something
|
|
958
|
+
* that is not a list of at most four documents, or gives documents with
|
|
959
|
+
* which the stub would not pass the check of the book, the stub made
|
|
960
|
+
* before the action is written, without them: an action that was taken
|
|
961
|
+
* always gets its stub. "detailsAfter" in the answer says what was added
|
|
962
|
+
* and, where nothing was, why.
|
|
963
|
+
*
|
|
811
964
|
* @param {{action: string, amount?: object, with?: string, details?: object[]}} request
|
|
812
965
|
* @param {() => Promise<any>} perform takes the action; it must not call this writer
|
|
813
966
|
* @param {object} [more] as for "record"; "when" defaults to the clock and must be within 300 seconds of it
|
|
814
|
-
* @
|
|
967
|
+
* @param {(result: any) => object[]|Promise<object[]>} [more.detailsAfter] further documents, from what the action gave; it must not call this writer
|
|
968
|
+
* @returns {Promise<{done: boolean, answer: object, result?: any, stub?: object, detailsAfter?: {added: number, problem: object|null}}>}
|
|
969
|
+
* "detailsAfter" is there only where it was given and the action was taken
|
|
815
970
|
*/
|
|
816
971
|
act(request, perform, more = {}) {
|
|
817
972
|
return calling(request, more, async (call) => {
|
|
@@ -837,6 +992,19 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
837
992
|
message: 'The slip asks for the other side to countersign this action, and the stub writer was given no way to ask for a countersignature.',
|
|
838
993
|
});
|
|
839
994
|
}
|
|
995
|
+
const later = call.detailsAfter;
|
|
996
|
+
const hasLater = later !== undefined && later !== null;
|
|
997
|
+
if (hasLater) {
|
|
998
|
+
const named = Array.isArray(call.request.details) ? call.request.details.length : 0;
|
|
999
|
+
if (typeof later !== 'function') stop({ code: 'bad-field', message: 'detailsAfter: must be a function of what the action gives.' });
|
|
1000
|
+
else if (call.hasApproval) {
|
|
1001
|
+
stop({ code: 'bad-field', message: 'detailsAfter: cannot be used with an approval. An approval covers exactly the documents the person saw, so no document may be added to the stub after the action.' });
|
|
1002
|
+
} else if (named > MAX_DETAILS_BEFORE) {
|
|
1003
|
+
stop({ code: 'bad-field', message: `detailsAfter: the request may then name at most ${MAX_DETAILS_BEFORE} documents, so that there is room for ${MAX_DETAILS_AFTER} more.` });
|
|
1004
|
+
} else if (answer.allowed && !roomForMore(call.request, when)) {
|
|
1005
|
+
stop({ code: 'too-large', message: 'With room for the documents that "detailsAfter" may add, the record of this action would be longer than a line of a book may be.' });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
840
1008
|
let made;
|
|
841
1009
|
let tried;
|
|
842
1010
|
if (answer.allowed) {
|
|
@@ -847,10 +1015,91 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
847
1015
|
stop(problemFrom(e));
|
|
848
1016
|
}
|
|
849
1017
|
}
|
|
850
|
-
if (!answer.allowed)
|
|
1018
|
+
if (!answer.allowed) {
|
|
1019
|
+
if (!denials || call.denial === false) return { done: false, answer };
|
|
1020
|
+
return { done: false, answer, denial: await writeDenialLine(call.request, 'check', denialCodes(answer), undefined) };
|
|
1021
|
+
}
|
|
851
1022
|
const outcome = await runInside(perform);
|
|
852
|
-
|
|
853
|
-
|
|
1023
|
+
if (!hasLater) {
|
|
1024
|
+
const stub = await commit(await countersigned(made, call), made, tried);
|
|
1025
|
+
return { done: true, answer, result: outcome, stub };
|
|
1026
|
+
}
|
|
1027
|
+
// The further documents, from what the action gave back. The stub
|
|
1028
|
+
// with them is signed now, still in this turn, and keeps the time
|
|
1029
|
+
// the action began. If anything about them fails, the stub made
|
|
1030
|
+
// before the action, which is known to fit, is the one written: an
|
|
1031
|
+
// action that was taken always gets its stub.
|
|
1032
|
+
const further = { added: 0, problem: null };
|
|
1033
|
+
let chosen = made;
|
|
1034
|
+
let chosenTried = tried;
|
|
1035
|
+
try {
|
|
1036
|
+
const given = await runInside(() => within(Promise.resolve(later(outcome)), countersignWithin));
|
|
1037
|
+
let documents;
|
|
1038
|
+
try {
|
|
1039
|
+
documents = given === undefined || given === null ? [] : clone(given);
|
|
1040
|
+
} catch {
|
|
1041
|
+
throw new Refusal('bad-field', 'detailsAfter: must give a list of documents, as plain data.');
|
|
1042
|
+
}
|
|
1043
|
+
if (!Array.isArray(documents)) throw new Refusal('bad-field', 'detailsAfter: must give a list of documents.');
|
|
1044
|
+
if (documents.length > MAX_DETAILS_AFTER) throw new Refusal('bad-field', `detailsAfter: gave ${documents.length} documents; it may give ${MAX_DETAILS_AFTER} at most.`);
|
|
1045
|
+
if (documents.length > 0) {
|
|
1046
|
+
const base = Array.isArray(call.request.details) ? call.request.details : [];
|
|
1047
|
+
const all = [...base, ...documents];
|
|
1048
|
+
// The documents are checked first, so that a refusal of them says why in its own words.
|
|
1049
|
+
validateRequest({ action: call.request.action, details: all });
|
|
1050
|
+
const withThem = { ...call, request: { ...call.request, details: all } };
|
|
1051
|
+
let next;
|
|
1052
|
+
try {
|
|
1053
|
+
next = await composeStub(withThem, when);
|
|
1054
|
+
} catch {
|
|
1055
|
+
// The stub made before the action is the one written: say so, whatever failed.
|
|
1056
|
+
throw new NotAdded('The stub with the further documents could not be made or signed, so the stub made before the action was written, without them.');
|
|
1057
|
+
}
|
|
1058
|
+
chosenTried = await fitsBook(next, 'With the documents that "detailsAfter" gave, the stub would not pass the check of the book.');
|
|
1059
|
+
chosen = next;
|
|
1060
|
+
further.added = documents.length;
|
|
1061
|
+
}
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
chosen = made;
|
|
1064
|
+
chosenTried = tried;
|
|
1065
|
+
further.problem =
|
|
1066
|
+
e instanceof NotAdded
|
|
1067
|
+
? { code: 'details-not-added', message: e.message }
|
|
1068
|
+
: e instanceof Refusal
|
|
1069
|
+
? problemFrom(e)
|
|
1070
|
+
: { code: 'details-not-added', message: 'detailsAfter failed, or gave no answer in time, so the stub was written without further documents.' };
|
|
1071
|
+
}
|
|
1072
|
+
const stub = await commit(await countersigned(chosen, call), chosen, chosenTried);
|
|
1073
|
+
return { done: true, answer, result: outcome, stub, detailsAfter: further };
|
|
1074
|
+
});
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Write a denial for an action that was not taken, where "act" did not
|
|
1079
|
+
* write one: the person refused, or gave no approval when asked
|
|
1080
|
+
* ("person"); a rule of the software around the agent, outside the slip,
|
|
1081
|
+
* said no ("rule"); or the check before acting said no, and the caller
|
|
1082
|
+
* asked "act" to write no denial for that call ("check", with the codes
|
|
1083
|
+
* of that answer: denialCodes gives them). Nothing is taken and no stub
|
|
1084
|
+
* is written. A writer opened without "denials: true" writes none.
|
|
1085
|
+
* @param {{action: string, amount?: object, with?: string, details?: object[]}} request what was asked for
|
|
1086
|
+
* @param {{source: 'check'|'person'|'rule', codes?: string[], when?: number|Date}} more "codes" only for "check";
|
|
1087
|
+
* "when" defaults to the clock, and must be within 300 seconds of it
|
|
1088
|
+
* @returns {Promise<{written: boolean, fingerprint?: string, line?: string, problem?: object, notWritten: number}>}
|
|
1089
|
+
* "notWritten": how many denials were not written because of the hourly limit since the writer was opened
|
|
1090
|
+
*/
|
|
1091
|
+
deny(request, more = {}) {
|
|
1092
|
+
return calling(request, more, async (call) => {
|
|
1093
|
+
if (!denials) return denialAnswer({ code: 'bad-field', message: 'This stub writer was opened without "denials: true", so it writes no denial.' });
|
|
1094
|
+
if (call.source !== 'check' && call.source !== 'person' && call.source !== 'rule') return denialAnswer({ code: 'bad-field', message: 'source: must be "check", "person" or "rule".' });
|
|
1095
|
+
let at;
|
|
1096
|
+
try {
|
|
1097
|
+
at = now();
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
return denialAnswer(problemFrom(e));
|
|
1100
|
+
}
|
|
1101
|
+
await writeWaiting(at);
|
|
1102
|
+
return writeDenialLine(call.request, call.source, call.codes, call.when);
|
|
854
1103
|
});
|
|
855
1104
|
},
|
|
856
1105
|
|
|
@@ -1094,6 +1343,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
1094
1343
|
// An added stub of this writer's own chain moves its place in the chain on.
|
|
1095
1344
|
after = lastStub(whole.checked.entries);
|
|
1096
1345
|
const last = whole.checked.entries.at(-1);
|
|
1346
|
+
if (ownDenial(last)) denialTimes.push(at);
|
|
1097
1347
|
if (last.kind === 'cancellation' && last.slip === slip && last.verified === 'all') {
|
|
1098
1348
|
stopped = true;
|
|
1099
1349
|
return { acknowledgement: await acknowledge(last.fingerprint, JSON.parse(line).cancellation, true, at) };
|
|
@@ -1149,5 +1399,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
1149
1399
|
});
|
|
1150
1400
|
},
|
|
1151
1401
|
};
|
|
1402
|
+
// Whether this writer writes denials, as it was opened. It cannot be changed.
|
|
1403
|
+
Object.defineProperty(writer, 'denials', { value: denials, enumerable: true });
|
|
1152
1404
|
return writer;
|
|
1153
1405
|
}
|
package/src/seal.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Refusal, fingerprint, formatTime, fromBase64url, randomId, toBase64url,
|
|
|
6
6
|
import * as f from './fields.js';
|
|
7
7
|
import { KINDS, encodeContent, protectedHeaders, signingInput, withinSize } from './jws.js';
|
|
8
8
|
import { SEAL_METHODS, checkSealKeySet } from './keys.js';
|
|
9
|
-
import {
|
|
9
|
+
import { signingKeys } from './signatures.js';
|
|
10
10
|
import { MAX_STAMP_BYTES } from './timestamp.js';
|
|
11
11
|
import { treeRoot } from './tree.js';
|
|
12
12
|
|
|
@@ -25,7 +25,7 @@ export const CLOCK_ALLOWANCE_MS = 300 * 1000;
|
|
|
25
25
|
* @param {any} c
|
|
26
26
|
*/
|
|
27
27
|
export function validateSealContent(c) {
|
|
28
|
-
f.members(c, ['by', 'id', 'root', 'size', 'type', 'when'], ['previous'], 'seal');
|
|
28
|
+
f.members(c, ['by', 'id', 'root', 'size', 'type', 'when'], ['book', 'previous'], 'seal');
|
|
29
29
|
f.id(c.id, 'id');
|
|
30
30
|
f.members(c.by, ['keys', 'name'], [], 'by');
|
|
31
31
|
f.label(c.by.name, 'by.name');
|
|
@@ -34,6 +34,13 @@ export function validateSealContent(c) {
|
|
|
34
34
|
if (c.size < 1) throw f.fail('size', 'must be 1 or more.');
|
|
35
35
|
f.fingerprintText(c.root, 'root');
|
|
36
36
|
if (Object.hasOwn(c, 'previous')) f.fingerprintText(c.previous, 'previous');
|
|
37
|
+
// The number of the book among the books a recorder keeps under one permission (section 21.6). Absent for the
|
|
38
|
+
// first book, so that every seal written before numbers existed reads as it did; written only from 2.
|
|
39
|
+
if (Object.hasOwn(c, 'book')) {
|
|
40
|
+
f.wholeNumber(c.book, 'book');
|
|
41
|
+
if (c.book < 2) throw f.fail('book', 'must be 2 or more: the first book has no number.');
|
|
42
|
+
if (!Object.hasOwn(c, 'previous')) throw f.fail('previous', 'is missing: every seal of a book after the first names the seal before it, and the first names the last seal of the book before.');
|
|
43
|
+
}
|
|
37
44
|
f.time(c.when, 'when');
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -102,30 +109,52 @@ export async function keySetFingerprint(keys) {
|
|
|
102
109
|
* @param {string} book the book so far
|
|
103
110
|
* @param {object} fields
|
|
104
111
|
* @param {{keys: object[], name: string}} fields.by the recorder's public key set and its name (a label)
|
|
105
|
-
* @param {string} [fields.previous] the fingerprint of the seal before this one in the book, if there is one
|
|
112
|
+
* @param {string} [fields.previous] the fingerprint of the seal before this one in the book, if there is one; for
|
|
113
|
+
* the first seal of a book after the first, the fingerprint of the last seal of the book before
|
|
114
|
+
* @param {number} [fields.book] the book's number among the books the recorder keeps under one permission (section
|
|
115
|
+
* 21.6): 2 or more for a book after the first; 1 or none for the first, which carries no number
|
|
106
116
|
* @param {number|Date} [fields.when] defaults to now
|
|
107
|
-
* @param {any[]} privateKeys the recorder's private keys, in the order of the key set
|
|
117
|
+
* @param {any[]} privateKeys the recorder's private keys, or signers (signatures.js), in the order of the key set. A signer must hold the key "by" names in its place
|
|
108
118
|
* @returns {Promise<{record: object, fingerprint: string, fingerprintBytes: Uint8Array}>}
|
|
109
119
|
* "fingerprintBytes" is what a time-stamp service is asked to stamp
|
|
110
120
|
*/
|
|
111
121
|
export async function writeSeal(book, fields, privateKeys) {
|
|
112
122
|
const lines = book.split('\n');
|
|
113
123
|
if (lines[lines.length - 1] === '') lines.pop();
|
|
124
|
+
return sealOver(lines.length, await treeRoot(lines.map(utf8)), fields, privateKeys);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sign a seal over a number of entries and their top fingerprint, worked out
|
|
129
|
+
* by the caller: writeSeal from the book, and the central sealer (sealer.js)
|
|
130
|
+
* from the tree it built from the leaf fingerprints it was sent. One path, so
|
|
131
|
+
* that the two give the same seal for the same book. Not part of the public
|
|
132
|
+
* interface: a size and a top fingerprint handed in from outside are never
|
|
133
|
+
* signed (choice 388).
|
|
134
|
+
* @param {number} size
|
|
135
|
+
* @param {Uint8Array} root
|
|
136
|
+
* @param {object} fields as writeSeal takes them
|
|
137
|
+
* @param {any[]} privateKeys
|
|
138
|
+
*/
|
|
139
|
+
export async function sealOver(size, root, fields, privateKeys) {
|
|
114
140
|
const content = {
|
|
115
141
|
type: KINDS.seal.type,
|
|
116
142
|
id: fields.id ?? randomId(),
|
|
117
143
|
by: fields.by,
|
|
118
|
-
size
|
|
119
|
-
root: toBase64url(
|
|
144
|
+
size,
|
|
145
|
+
root: toBase64url(root),
|
|
120
146
|
when: formatTime(fields.when ?? Date.now()),
|
|
121
147
|
};
|
|
122
148
|
if (fields.previous) content.previous = fields.previous;
|
|
149
|
+
// The book's number: 1, or none, for the first book, which then carries no number.
|
|
150
|
+
if (fields.book !== undefined && fields.book !== null && fields.book !== 1) content.book = fields.book;
|
|
123
151
|
validateSealContent(content);
|
|
124
152
|
const { contentBytes, payloadB64 } = encodeContent(content);
|
|
125
153
|
const headers = protectedHeaders('seal');
|
|
154
|
+
const signers = await signingKeys(privateKeys, SEAL_METHODS, content.by.keys);
|
|
126
155
|
const signatures = [];
|
|
127
156
|
for (let i = 0; i < SEAL_METHODS.length; i++) {
|
|
128
|
-
const signature = await
|
|
157
|
+
const signature = await signers[i](signingInput(headers[i], payloadB64));
|
|
129
158
|
signatures.push({ protected: headers[i], signature: toBase64url(signature) });
|
|
130
159
|
}
|
|
131
160
|
const name = await fingerprint(contentBytes);
|