provared 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ // The reserved names of the documents a stub names (format description,
2
+ // section 27), and the fingerprints they stand for.
3
+ //
4
+ // A stub's "details" names documents by their fingerprints. Most names are
5
+ // labels that whoever wrote the stub chose. Names that begin "provared."
6
+ // are reserved: each one on the list below says exactly which text its
7
+ // fingerprint is made from, so that an investigator who holds that text
8
+ // (the call's arguments, the HTTP request, the identifier a platform's own
9
+ // log shows for the call) can work the fingerprint out again and match
10
+ // the stub to it, without either side's help.
11
+
12
+ import { Refusal, fingerprint, sha256, toBase64url, utf8 } from './encoding.js';
13
+
14
+ /** Every document name that begins with this is reserved for the list below. */
15
+ export const RESERVED_DOCUMENT_PREFIX = 'provared.';
16
+
17
+ /** The reserved document names, and the exact text whose SHA-256 each one gives. */
18
+ export const DOCUMENT_NAMES = Object.freeze({
19
+ 'provared.arguments': "The call's arguments, written in the one form of RFC 8785.",
20
+ 'provared.http-request': 'The request, written as the signature base of RFC 9421, section 2.5, over "@method", "@target-uri" and, where there is a body, "content-digest" (RFC 9530, with SHA-256). No header is covered.',
21
+ 'provared.http-response': 'The answer, written as the signature base of RFC 9421, section 2.5, over "@status" and, where there is a body, "content-digest" (RFC 9530, with SHA-256). No header is covered.',
22
+ 'provared.trace-id': 'The trace-id of the W3C Trace Context "traceparent" header that the agent\'s side sent, which that standard writes as 32 lower-case hexadecimal characters. Its form is not checked.',
23
+ 'provared.request-id': "An identifier that the agent's side chose and sent with the call, exactly as sent.",
24
+ 'provared.event-id': 'An identifier that the platform gave for the call, exactly as it gave it.',
25
+ 'provared.error': "The error text that the agent's software was given, exactly as given.",
26
+ });
27
+
28
+ /**
29
+ * The reserved names that may appear more than once in one record: an
30
+ * identifier from each of several systems. Every other reserved name may
31
+ * appear once at most.
32
+ */
33
+ export const REPEATABLE_DOCUMENT_NAMES = Object.freeze(['provared.trace-id', 'provared.request-id', 'provared.event-id']);
34
+
35
+ /**
36
+ * Whether a document name is reserved and not on the list: such a name is refused.
37
+ * @param {string} name
38
+ */
39
+ export function isUnknownReservedDocument(name) {
40
+ return typeof name === 'string' && name.startsWith(RESERVED_DOCUMENT_PREFIX) && !Object.hasOwn(DOCUMENT_NAMES, name);
41
+ }
42
+
43
+ // A method is a token of RFC 9110, section 5.6.2: one or more of these.
44
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
45
+ // An address is taken exactly as it was sent, never rewritten: so it must
46
+ // already be in the form a request carries, an absolute http or https
47
+ // address of visible ASCII characters, with no fragment (which is never
48
+ // sent).
49
+ const ADDRESS = /^https?:\/\/[\x21-\x22\x24-\x7e]+$/;
50
+
51
+ function body(value, path) {
52
+ // Nothing given, or null: the message has no body.
53
+ if (value === undefined || value === null) return undefined;
54
+ if (!(value instanceof Uint8Array)) throw new Refusal('bad-field', `${path}: must be the bytes of the body, as sent.`);
55
+ return value;
56
+ }
57
+
58
+ // Base64 with padding (RFC 4648, section 4), as a byte sequence of RFC 8941 is written.
59
+ function base64(bytes) {
60
+ const text = toBase64url(bytes).replace(/-/g, '+').replace(/_/g, '/');
61
+ return text + '='.repeat((4 - (text.length % 4)) % 4);
62
+ }
63
+
64
+ // The Content-Digest field value of RFC 9530, with SHA-256 only.
65
+ async function contentDigest(bytes) {
66
+ return `sha-256=:${base64(await sha256(bytes))}:`;
67
+ }
68
+
69
+ // The signature base of RFC 9421, section 2.5: a line for each component,
70
+ // "<identifier>: <value>" and a line feed, then the line
71
+ // "@signature-params" with the list of the components and no parameters,
72
+ // and no line feed after it. Nothing is signed with it: it is only an exact
73
+ // way to write a request or an answer as text.
74
+ function signatureBase(components) {
75
+ const lines = components.map(([id, value]) => `"${id}": ${value}\n`);
76
+ return lines.join('') + `"@signature-params": (${components.map(([id]) => `"${id}"`).join(' ')})`;
77
+ }
78
+
79
+ /**
80
+ * The text that the reserved name "provared.http-request" is made from.
81
+ * @param {{method: string, url: string, body?: Uint8Array}} r
82
+ * @returns {Promise<string>}
83
+ */
84
+ export async function httpRequestBase(r) {
85
+ if (r === null || typeof r !== 'object') throw new Refusal('bad-field', 'The request must be an object.');
86
+ const { method, url } = r;
87
+ const given = body(r.body, 'body');
88
+ if (typeof method !== 'string' || !TOKEN.test(method)) throw new Refusal('bad-field', 'method: must be the method of the request as sent, a token such as GET or POST.');
89
+ if (typeof url !== 'string' || !ADDRESS.test(url)) {
90
+ throw new Refusal('bad-field', 'url: must be the address of the request as sent: an absolute http or https address of visible ASCII characters, with no fragment.');
91
+ }
92
+ const components = [
93
+ ['@method', method],
94
+ ['@target-uri', url],
95
+ ];
96
+ if (given !== undefined) components.push(['content-digest', await contentDigest(given)]);
97
+ return signatureBase(components);
98
+ }
99
+
100
+ /**
101
+ * The text that the reserved name "provared.http-response" is made from.
102
+ * @param {{status: number, body?: Uint8Array}} r
103
+ * @returns {Promise<string>}
104
+ */
105
+ export async function httpResponseBase(r) {
106
+ if (r === null || typeof r !== 'object') throw new Refusal('bad-field', 'The answer must be an object.');
107
+ const { status } = r;
108
+ const given = body(r.body, 'body');
109
+ if (!Number.isInteger(status) || status < 100 || status > 599) throw new Refusal('bad-field', 'status: must be the status code of the answer, a whole number from 100 to 599.');
110
+ const components = [['@status', String(status)]];
111
+ if (given !== undefined) components.push(['content-digest', await contentDigest(given)]);
112
+ return signatureBase(components);
113
+ }
114
+
115
+ /**
116
+ * The fingerprint of an HTTP request, for the document "provared.http-request".
117
+ * @param {{method: string, url: string, body?: Uint8Array}} r the method and the address exactly as sent, and the body's bytes where there is a body
118
+ * @returns {Promise<string>}
119
+ * @throws {Refusal} "bad-field" if the request is not as set out
120
+ */
121
+ export async function httpRequestFingerprint(r) {
122
+ return fingerprint(utf8(await httpRequestBase(r)));
123
+ }
124
+
125
+ /**
126
+ * The fingerprint of an HTTP answer, for the document "provared.http-response".
127
+ * @param {{status: number, body?: Uint8Array}} r the status code, and the body's bytes where there is a body
128
+ * @returns {Promise<string>}
129
+ * @throws {Refusal} "bad-field" if the answer is not as set out
130
+ */
131
+ export async function httpResponseFingerprint(r) {
132
+ return fingerprint(utf8(await httpResponseBase(r)));
133
+ }
134
+
135
+ /**
136
+ * The fingerprint of an identifier or of an error text, exactly as given:
137
+ * SHA-256 of its UTF-8 bytes. For "provared.trace-id", "provared.request-id",
138
+ * "provared.event-id" and "provared.error".
139
+ * @param {string} id
140
+ * @returns {Promise<string>}
141
+ * @throws {Refusal} "bad-field" if it is not text, is empty, or is not well-formed Unicode
142
+ */
143
+ export async function identifierFingerprint(id) {
144
+ if (typeof id !== 'string' || id.length === 0) throw new Refusal('bad-field', 'The identifier must be text of one character or more.');
145
+ if (typeof id.isWellFormed === 'function' ? !id.isWellFormed() : /[\ud800-\udfff]/.test(id.replace(/[\ud800-\udbff][\udc00-\udfff]/g, ''))) {
146
+ throw new Refusal('bad-field', 'The identifier holds text that is not well-formed Unicode.');
147
+ }
148
+ return fingerprint(utf8(id));
149
+ }
package/src/guard.js CHANGED
@@ -21,7 +21,7 @@ const notSound = (why) => new Refusal('record-not-sound', why);
21
21
  // 131,072 bytes, and an action whose record would not fit is not allowed.
22
22
  const STUB_BOUND = 6700 + 600;
23
23
  const COUNTERSIGNATURE_BOUND = 6900;
24
- function lineBound(c, approval) {
24
+ export function lineBound(c, approval) {
25
25
  let approvalBytes = 0;
26
26
  if (approval !== undefined) {
27
27
  try {
package/src/index.js CHANGED
@@ -16,8 +16,12 @@
16
16
  // writeVouching, writeWithdrawal (an organisation vouches for a name),
17
17
  // writePass (an agent hands part of its permission to a helper agent),
18
18
  // writeBook, makeShow
19
- // Keys: generateKeySet, generateSealKeySet, thumbprint, keySetFingerprint
20
- // Names: the shared list of actions, the kinds of action, the rules of conduct
19
+ // Keys: generateKeySet, generateSealKeySet, thumbprint, keySetFingerprint,
20
+ // signerFromKey (a key in memory as a signer: wherever "privateKeys"
21
+ // is taken, each item may be a key or a signer, an object with
22
+ // "key" and "sign", for a key held elsewhere)
23
+ // Names: the shared list of actions, the kinds of action, the rules of conduct;
24
+ // the reserved document names, and the fingerprints they stand for
21
25
 
22
26
  export * from './check.js';
23
27
  export { checkBefore } from './guard.js';
@@ -32,6 +36,7 @@ export { writeSeal } from './seal.js';
32
36
  export { blockStampItem } from './blockstamp.js';
33
37
  export { prepareCancellation, assembleCancellation, writeAcknowledgement, writeVouching, writeWithdrawal, VOUCHED_KINDS } from './standing.js';
34
38
  export { entryLine, writeBook, makeShow } from './book.js';
35
- export { generateKeySet, generateSealKeySet } from './signatures.js';
39
+ export { generateKeySet, generateSealKeySet, signerFromKey } from './signatures.js';
36
40
  export { ACTION_KINDS, SHARED_ACTIONS, CONDUCT_RULES, RESERVED_PREFIX, actionKind } from './actions.js';
41
+ export { DOCUMENT_NAMES, RESERVED_DOCUMENT_PREFIX, httpRequestFingerprint, httpResponseFingerprint, identifierFingerprint } from './documents.js';
37
42
  export { fingerprint, formatTime, randomId, toBase64url, fromBase64url, utf8, sha256 } from './encoding.js';
package/src/pass.js CHANGED
@@ -46,7 +46,7 @@ export function validatePassContent(c) {
46
46
  * @param {string} fields.validFrom
47
47
  * @param {string} fields.validUntil
48
48
  * @param {number|Date} [fields.when] defaults to now
49
- * @param {CryptoKey[]} privateKeys the private keys of the agent that passes on: Ed25519, then ML-DSA-87
49
+ * @param {any[]} privateKeys the private keys of the agent that passes on, or signers: Ed25519, then ML-DSA-87
50
50
  * @returns {Promise<{record: object, fingerprint: string}>}
51
51
  */
52
52
  export async function writePass(fields, privateKeys) {
package/src/recorder.js CHANGED
@@ -32,16 +32,30 @@
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 { writeStub } from './stub.js';
41
+ import { validateRequest, writeStub } from './stub.js';
40
42
 
41
43
  /** How long the other side is given to hand back its countersignature, unless the caller says otherwise: 30 seconds. */
42
44
  export const COUNTERSIGN_WITHIN_MS = 30000;
45
+ /** How long a signer is given for each signature, unless the caller says otherwise: 30 seconds. */
46
+ export const SIGN_WITHIN_MS = 30000;
43
47
  // The longest wait a timer can hold.
44
48
  const MAX_WAIT_MS = 2 ** 31 - 1;
49
+ // "detailsAfter" in "act": the most documents it may add, and the most the
50
+ // request may name beside them, so that a stub never names more than 32.
51
+ const MAX_DETAILS_AFTER = 4;
52
+ const MAX_DETAILS_BEFORE = 32 - MAX_DETAILS_AFTER;
53
+ // The room kept for them: the largest documents a stub may name, a name of
54
+ // 200 characters that are each written as six (a control character, which
55
+ // the one form of RFC 8785 writes as "\u00XX"), with a fingerprint.
56
+ const ROOM_DOCUMENT = Object.freeze({ name: '\u0001'.repeat(200), sha256: 'A'.repeat(43) });
57
+ // The stub with the further documents could not be made or signed.
58
+ class NotAdded extends Error {}
45
59
  // The most cancellations a check may be handed beside a book (book.js).
46
60
  const MAX_HANDED = 16;
47
61
 
@@ -204,17 +218,20 @@ function plainHeld(copy) {
204
218
  * @param {object} o
205
219
  * @param {string} o.book the book so far: at least the slip
206
220
  * @param {string} o.slip the fingerprint of the slip
207
- * @param {CryptoKey[]} o.privateKeys the agent's private keys: Ed25519, then ML-DSA-87
221
+ * @param {any[]} o.privateKeys the agent's private keys, or signers (signatures.js): Ed25519, then ML-DSA-87
208
222
  * @param {string[]} o.issuerKeys the thumbprints of the passkeys this agent's software trusts; a slip from any other key allows nothing
209
223
  * @param {string} [o.pass] for a helper agent: the fingerprint of the pass it acts under
210
224
  * @param {object} [o.options] further options for the checker (sealKeys, stampServices, blocks, vouchers, cancellations).
211
225
  * They are read once, as a whole check reads them, and fixed
212
226
  * @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
227
  * @param {number} [o.countersignWithin] how long the other side is given to countersign, in milliseconds; defaults to 30,000
228
+ * @param {number} [o.signWithin] how long a signer is given for each signature, in milliseconds; defaults to 30,000.
229
+ * After that the action is not taken and nothing is written; a late answer is not used
214
230
  * @returns {Promise<object>} the writer: before, record, act, book, check, cancellations, add
215
- * @throws {Refusal} "record-not-sound" if the book so far has a problem
231
+ * @throws {Refusal} "record-not-sound" if the book so far has a problem; "bad-field" or "bad-key" if an item of
232
+ * privateKeys is neither a key nor a signer, or is a signer that holds another key than the slip, or the pass, names
216
233
  */
217
- export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass, options = {}, now = Date.now, countersignWithin = COUNTERSIGN_WITHIN_MS }) {
234
+ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass, options = {}, now = Date.now, countersignWithin = COUNTERSIGN_WITHIN_MS, signWithin = SIGN_WITHIN_MS }) {
218
235
  if (typeof now !== 'function') throw new Refusal('bad-field', 'now: must be a function that gives the time in milliseconds.');
219
236
  // A clock that gives anything but a time is a clock that failed: no comparison with it would hold.
220
237
  const device = now;
@@ -226,6 +243,9 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
226
243
  if (!(Number.isFinite(countersignWithin) && countersignWithin >= 1 && countersignWithin <= MAX_WAIT_MS)) {
227
244
  throw new Refusal('bad-field', 'countersignWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
228
245
  }
246
+ if (!(Number.isFinite(signWithin) && signWithin >= 1 && signWithin <= MAX_WAIT_MS)) {
247
+ throw new Refusal('bad-field', 'signWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
248
+ }
229
249
  // Whom the writer trusts is fixed when it is opened: read once, as a whole
230
250
  // check reads it, and copied. The passkeys it trusts are its own
231
251
  // "issuerKeys", whatever the options say, read through the same fixed
@@ -403,6 +423,17 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
403
423
  }
404
424
  };
405
425
 
426
+ // The agent's keys, or signers (signatures.js). A signer is read once,
427
+ // here, and must hold the keys that the slip, or the pass this writer
428
+ // acts under, names for its agent. Each of its signatures is waited for
429
+ // "signWithin" at most, under the same mark as an action, so that a
430
+ // signer that calls the writer is refused at once; after that nothing is
431
+ // written, and a late answer is not used. Keys of the platform are used
432
+ // as they always were.
433
+ const passEntry = pass ? result.entries.find((e) => e.kind === 'pass' && e.fingerprint === pass) : undefined;
434
+ const ownKeys = pass ? passEntry?.content?.to?.keys : slipEntry?.content?.agent?.keys;
435
+ const signing = await holdSigningKeys(privateKeys, KEY_SET_METHODS, ownKeys, signWithin, runInside);
436
+
406
437
  // What a call hands in, copied at the moment of the call.
407
438
  const asked = (request, more) => {
408
439
  if (request === null || typeof request !== 'object') throw new Refusal('bad-field', 'The request must be an object.');
@@ -412,6 +443,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
412
443
  when: more.when === undefined || more.when === null ? undefined : timeOf(more.when),
413
444
  terms: more.terms,
414
445
  countersign: more.countersign,
446
+ detailsAfter: more.detailsAfter,
415
447
  hasApproval: more.approval !== undefined && more.approval !== null,
416
448
  approval: undefined,
417
449
  };
@@ -448,6 +480,20 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
448
480
  if (call.hasApproval) proposal.approval = call.approval;
449
481
  return proposal;
450
482
  };
483
+ // Whether the stub for this request, with the countersignature the check
484
+ // before acting keeps room for, would still fit a line with the largest
485
+ // further documents that "detailsAfter" may add: the same bound as the
486
+ // check before acting uses, over the request with those documents.
487
+ const roomForMore = (request, when) => {
488
+ try {
489
+ const c = { action: request.action, when: formatTime(when) };
490
+ for (const name of ['amount', 'with']) if (request[name] !== undefined) c[name] = request[name];
491
+ c.details = [...(Array.isArray(request.details) ? request.details : []), ...Array(MAX_DETAILS_AFTER).fill(ROOM_DOCUMENT)];
492
+ return lineBound(c, undefined) <= MAX_LINE_BYTES;
493
+ } catch {
494
+ return false;
495
+ }
496
+ };
451
497
  const aheadOfClock = (when) => when > now() + CLOCK_ALLOWANCE_MS;
452
498
  const behindClock = (when) => when < now() - CLOCK_ALLOWANCE_MS;
453
499
 
@@ -491,7 +537,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
491
537
  pass,
492
538
  when,
493
539
  },
494
- privateKeys,
540
+ signing,
495
541
  );
496
542
  const entry = { stub: stub.record };
497
543
  if (call.hasApproval) entry.approval = call.approval;
@@ -644,7 +690,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
644
690
  }
645
691
  let made;
646
692
  try {
647
- made = (await writeAcknowledgement({ slip, cancellation, pass, when }, privateKeys)).record;
693
+ made = (await writeAcknowledgement({ slip, cancellation, pass, when }, signing)).record;
648
694
  } catch {
649
695
  return undefined;
650
696
  }
@@ -808,10 +854,28 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
808
854
  * If taking the action never ends, the writer waits: it cannot know
809
855
  * what happened.
810
856
  *
857
+ * Where an identifier or a document comes only with what the action
858
+ * gives back (the identifier a platform gave the call, say), "detailsAfter"
859
+ * names it: a function of what the action gave, that gives up to four
860
+ * further documents ({name, sha256} each). The stub is then signed again
861
+ * after the action, with them, still in this call's turn, so that no
862
+ * other call comes between; it keeps the time the action began. The
863
+ * request may then name at most 28 documents, and no approval may be
864
+ * given (an approval covers exactly the documents the person saw).
865
+ * "detailsAfter" is given as long as the other side is given to
866
+ * countersign. If it fails, gives no answer in time, gives something
867
+ * that is not a list of at most four documents, or gives documents with
868
+ * which the stub would not pass the check of the book, the stub made
869
+ * before the action is written, without them: an action that was taken
870
+ * always gets its stub. "detailsAfter" in the answer says what was added
871
+ * and, where nothing was, why.
872
+ *
811
873
  * @param {{action: string, amount?: object, with?: string, details?: object[]}} request
812
874
  * @param {() => Promise<any>} perform takes the action; it must not call this writer
813
875
  * @param {object} [more] as for "record"; "when" defaults to the clock and must be within 300 seconds of it
814
- * @returns {Promise<{done: boolean, answer: object, result?: any, stub?: object}>}
876
+ * @param {(result: any) => object[]|Promise<object[]>} [more.detailsAfter] further documents, from what the action gave; it must not call this writer
877
+ * @returns {Promise<{done: boolean, answer: object, result?: any, stub?: object, detailsAfter?: {added: number, problem: object|null}}>}
878
+ * "detailsAfter" is there only where it was given and the action was taken
815
879
  */
816
880
  act(request, perform, more = {}) {
817
881
  return calling(request, more, async (call) => {
@@ -837,6 +901,19 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
837
901
  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
902
  });
839
903
  }
904
+ const later = call.detailsAfter;
905
+ const hasLater = later !== undefined && later !== null;
906
+ if (hasLater) {
907
+ const named = Array.isArray(call.request.details) ? call.request.details.length : 0;
908
+ if (typeof later !== 'function') stop({ code: 'bad-field', message: 'detailsAfter: must be a function of what the action gives.' });
909
+ else if (call.hasApproval) {
910
+ 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.' });
911
+ } else if (named > MAX_DETAILS_BEFORE) {
912
+ 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.` });
913
+ } else if (answer.allowed && !roomForMore(call.request, when)) {
914
+ 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.' });
915
+ }
916
+ }
840
917
  let made;
841
918
  let tried;
842
919
  if (answer.allowed) {
@@ -849,8 +926,57 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
849
926
  }
850
927
  if (!answer.allowed) return { done: false, answer };
851
928
  const outcome = await runInside(perform);
852
- const stub = await commit(await countersigned(made, call), made, tried);
853
- return { done: true, answer, result: outcome, stub };
929
+ if (!hasLater) {
930
+ const stub = await commit(await countersigned(made, call), made, tried);
931
+ return { done: true, answer, result: outcome, stub };
932
+ }
933
+ // The further documents, from what the action gave back. The stub
934
+ // with them is signed now, still in this turn, and keeps the time
935
+ // the action began. If anything about them fails, the stub made
936
+ // before the action, which is known to fit, is the one written: an
937
+ // action that was taken always gets its stub.
938
+ const further = { added: 0, problem: null };
939
+ let chosen = made;
940
+ let chosenTried = tried;
941
+ try {
942
+ const given = await runInside(() => within(Promise.resolve(later(outcome)), countersignWithin));
943
+ let documents;
944
+ try {
945
+ documents = given === undefined || given === null ? [] : clone(given);
946
+ } catch {
947
+ throw new Refusal('bad-field', 'detailsAfter: must give a list of documents, as plain data.');
948
+ }
949
+ if (!Array.isArray(documents)) throw new Refusal('bad-field', 'detailsAfter: must give a list of documents.');
950
+ if (documents.length > MAX_DETAILS_AFTER) throw new Refusal('bad-field', `detailsAfter: gave ${documents.length} documents; it may give ${MAX_DETAILS_AFTER} at most.`);
951
+ if (documents.length > 0) {
952
+ const base = Array.isArray(call.request.details) ? call.request.details : [];
953
+ const all = [...base, ...documents];
954
+ // The documents are checked first, so that a refusal of them says why in its own words.
955
+ validateRequest({ action: call.request.action, details: all });
956
+ const withThem = { ...call, request: { ...call.request, details: all } };
957
+ let next;
958
+ try {
959
+ next = await composeStub(withThem, when);
960
+ } catch {
961
+ // The stub made before the action is the one written: say so, whatever failed.
962
+ 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.');
963
+ }
964
+ chosenTried = await fitsBook(next, 'With the documents that "detailsAfter" gave, the stub would not pass the check of the book.');
965
+ chosen = next;
966
+ further.added = documents.length;
967
+ }
968
+ } catch (e) {
969
+ chosen = made;
970
+ chosenTried = tried;
971
+ further.problem =
972
+ e instanceof NotAdded
973
+ ? { code: 'details-not-added', message: e.message }
974
+ : e instanceof Refusal
975
+ ? problemFrom(e)
976
+ : { code: 'details-not-added', message: 'detailsAfter failed, or gave no answer in time, so the stub was written without further documents.' };
977
+ }
978
+ const stub = await commit(await countersigned(chosen, call), chosen, chosenTried);
979
+ return { done: true, answer, result: outcome, stub, detailsAfter: further };
854
980
  });
855
981
  },
856
982
 
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 { sign } from './signatures.js';
9
+ import { signingKeys } from './signatures.js';
10
10
  import { MAX_STAMP_BYTES } from './timestamp.js';
11
11
  import { treeRoot } from './tree.js';
12
12
 
@@ -104,7 +104,7 @@ export async function keySetFingerprint(keys) {
104
104
  * @param {{keys: object[], name: string}} fields.by the recorder's public key set and its name (a label)
105
105
  * @param {string} [fields.previous] the fingerprint of the seal before this one in the book, if there is one
106
106
  * @param {number|Date} [fields.when] defaults to now
107
- * @param {any[]} privateKeys the recorder's private keys, in the order of the key set
107
+ * @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
108
  * @returns {Promise<{record: object, fingerprint: string, fingerprintBytes: Uint8Array}>}
109
109
  * "fingerprintBytes" is what a time-stamp service is asked to stamp
110
110
  */
@@ -123,9 +123,10 @@ export async function writeSeal(book, fields, privateKeys) {
123
123
  validateSealContent(content);
124
124
  const { contentBytes, payloadB64 } = encodeContent(content);
125
125
  const headers = protectedHeaders('seal');
126
+ const signers = await signingKeys(privateKeys, SEAL_METHODS, content.by.keys);
126
127
  const signatures = [];
127
128
  for (let i = 0; i < SEAL_METHODS.length; i++) {
128
- const signature = await sign(SEAL_METHODS[i], privateKeys[i], signingInput(headers[i], payloadB64));
129
+ const signature = await signers[i](signingInput(headers[i], payloadB64));
129
130
  signatures.push({ protected: headers[i], signature: toBase64url(signature) });
130
131
  }
131
132
  const name = await fingerprint(contentBytes);
package/src/service.js CHANGED
@@ -79,7 +79,7 @@ export function validateTermsContent(c) {
79
79
  * @param {{unit: string, value: number}} [fields.amount]
80
80
  * @param {string} fields.reason one of REFUSAL_REASONS
81
81
  * @param {number|Date} [fields.when] defaults to now
82
- * @param {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
82
+ * @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
83
83
  * @returns {Promise<{record: object, fingerprint: string}>}
84
84
  */
85
85
  export async function writeRefusal(fields, privateKeys) {
@@ -94,7 +94,7 @@ export async function writeRefusal(fields, privateKeys) {
94
94
  };
95
95
  if (fields.amount) content.amount = fields.amount;
96
96
  validateRefusalContent(content);
97
- return signWithKeySet('refusal', content, privateKeys);
97
+ return signWithKeySet('refusal', content, privateKeys, content.by.keys);
98
98
  }
99
99
 
100
100
  /**
@@ -106,7 +106,7 @@ export async function writeRefusal(fields, privateKeys) {
106
106
  * @param {string[]} [fields.never] kinds of action and rules of conduct the service asks agents to keep
107
107
  * @param {string} fields.validFrom
108
108
  * @param {string} fields.validUntil
109
- * @param {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
109
+ * @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
110
110
  * @returns {Promise<{record: object, fingerprint: string}>}
111
111
  */
112
112
  export async function writeTerms(fields, privateKeys) {
@@ -120,5 +120,5 @@ export async function writeTerms(fields, privateKeys) {
120
120
  validUntil: fields.validUntil,
121
121
  };
122
122
  validateTermsContent(content);
123
- return signWithKeySet('terms', content, privateKeys);
123
+ return signWithKeySet('terms', content, privateKeys, content.by.keys);
124
124
  }