provared 0.3.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 {
@@ -50,9 +50,11 @@ function lineBound(c, approval) {
50
50
  * @param {object} options the same options as checkBook
51
51
  * @param {string[]} options.issuerKeys the thumbprints of the passkeys this check trusts. It must be given:
52
52
  * a slip signed by any other key allows nothing
53
- * @returns {Promise<{allowed: boolean, problems: {code: string, message: string}[], breaches: {code: string, message: string}[], needs: string[]}>}
53
+ * @returns {Promise<{allowed: boolean, problems: {code: string, message: string}[], breaches: {code: string, message: string}[], needs: string[], notChecked?: string[]}>}
54
54
  * "allowed" is true only if the record so far is sound, this device
55
- * checked every signature in it, and the action would be inside the slip.
55
+ * checked every signature in it (or every one but the third signature of
56
+ * a seal whose first two it checked, which "notChecked" then names), and
57
+ * the action would be inside the slip.
56
58
  * "needs" lists what the slip asks to go with the action. A needed
57
59
  * approval must be handed over, or the action is not allowed. A needed
58
60
  * countersignature cannot be known beforehand: the action is allowed, and
@@ -76,6 +78,54 @@ export function checkBeforeRead(read, proposal, options = {}) {
76
78
  return decide(read, proposal, options);
77
79
  }
78
80
 
81
+ // The methods not checked on this device, if the only thing not checked is
82
+ // the third signature of one or more seals whose Ed25519 and ML-DSA-87
83
+ // signatures were checked here; otherwise null. It counts as the checker
84
+ // counts for "fully checked" (book.js): a signature whose method is not
85
+ // built in, or an entry with nothing to check, is not checked; a time-stamp
86
+ // from a service the reader did not name is not "unchecked", it is uncounted.
87
+ function onlySealThirdUnchecked(result) {
88
+ if (result.summary.fullyChecked) return null;
89
+ let skipped = false;
90
+ let excused = 0;
91
+ const missing = new Set();
92
+ const note = (list) => { if (list.length === 0) skipped = true; for (const x of list) if (x.state === 'unavailable') missing.add(x.method); };
93
+ const stamps = (list) => { for (const x of list) if (x.state === 'unavailable') missing.add('stamp'); };
94
+ for (const e of result.entries) {
95
+ if (e.kind === 'seal') {
96
+ const sigs = e.signatures ?? [];
97
+ const by = (method) => sigs.find((x) => x.method === method);
98
+ const third = sigs.length === 3 && by('Ed25519')?.state === 'valid' && by('ML-DSA-87')?.state === 'valid' && by(SEAL_THIRD)?.state === 'unavailable';
99
+ if (third) excused++; else note(sigs);
100
+ stamps(e.stamps ?? []);
101
+ } else if (e.kind === 'slip') {
102
+ if (!e.signature || e.signature.state === 'unavailable') missing.add(e.signature ? e.signature.alg : 'passkey');
103
+ else if (e.signature.state === 'unchecked') skipped = true;
104
+ } else if (e.kind === 'stub') {
105
+ note(e.signatures ?? []);
106
+ const counter = e.countersignature ?? { state: 'absent', signatures: [] };
107
+ if (counter.state !== 'absent') note(counter.signatures ?? []);
108
+ const approval = e.approval ?? { state: 'absent' };
109
+ if (approval.state === 'unavailable') missing.add(approval.alg);
110
+ } else if (e.kind === 'cancellation') {
111
+ if (!e.signature) skipped = true;
112
+ else if (e.signature.state === 'unavailable') missing.add(e.signature.alg);
113
+ stamps(e.stamps ?? []);
114
+ } else if (['refusal', 'terms', 'vouching', 'withdrawal', 'pass', 'acknowledgement'].includes(e.kind)) {
115
+ note(e.signatures ?? []);
116
+ } else {
117
+ skipped = true;
118
+ }
119
+ }
120
+ for (const h of result.held ?? []) {
121
+ stamps(h.stamps ?? []);
122
+ for (const a of h.acknowledgements ?? []) note(a.signatures ?? []);
123
+ }
124
+ return !skipped && missing.size === 0 && excused > 0 ? [SEAL_THIRD] : null;
125
+ }
126
+
127
+ const SEAL_THIRD = 'SLH-DSA-SHA2-256s';
128
+
79
129
  async function decide(read, proposal, options) {
80
130
  const answer = { allowed: false, problems: [], breaches: [], needs: [] };
81
131
  try {
@@ -85,15 +135,33 @@ async function decide(read, proposal, options) {
85
135
  }
86
136
  const { result, state, beside } = await read();
87
137
  if (result.summary.problemFound) throw notSound('A problem was found in the record so far. It must be looked at before the agent acts again.');
88
- // A record this device could only partly check is not a pass, here either.
89
- if (!result.summary.fullyChecked) throw notSound('This device could not check every signature in the record so far.');
138
+ // A record this device could only partly check is not a pass, here
139
+ // either, with one stated exception: a seal's third signature
140
+ // (SLH-DSA), where its first two were checked here. A seal widens no
141
+ // permission, and a device without that method (the Python version,
142
+ // every browser) would otherwise be refused everything once the book
143
+ // holds a seal. The answer names what was not checked.
144
+ const notChecked = onlySealThirdUnchecked(result);
145
+ if (!result.summary.fullyChecked && !notChecked) throw notSound('This device could not check every signature in the record so far.');
146
+ if (notChecked) answer.notChecked = notChecked;
90
147
  if (!result.entries.every((e) => (e.kind === 'stub' || e.kind === 'pass' ? e.compared === true : true))) {
91
148
  throw notSound('This device could not compare every stub and every pass in the record so far with its slip.');
92
149
  }
93
150
  if (result.size >= MAX_ENTRIES) throw new Refusal('too-large', 'The book is full: it holds 100,000 entries.');
94
151
 
95
152
  f.members(proposal, ['action', 'slip'], ['amount', 'approval', 'details', 'pass', 'terms', 'when', 'with'], 'proposal');
96
- const c = { action: proposal.action, when: formatTime(proposal.when ?? Date.now()) };
153
+ // The time, as the stub writer takes it: a number of milliseconds or a
154
+ // Date. Text is read by rules that differ between devices.
155
+ const given = proposal.when ?? Date.now();
156
+ if (typeof given !== 'number' && !(given instanceof Date)) throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
157
+ let ms;
158
+ try {
159
+ ms = new Date(given).getTime();
160
+ } catch {
161
+ ms = NaN; // a Date wrapped in a Proxy, which Date cannot read
162
+ }
163
+ if (!Number.isFinite(ms)) throw new Refusal('bad-field', 'when: does not give a time.');
164
+ const c = { action: proposal.action, when: formatTime(ms) };
97
165
  for (const name of ['amount', 'details', 'with']) if (proposal[name] !== undefined) c[name] = proposal[name];
98
166
  validateRequest(c);
99
167
  const when = f.time(c.when, 'when');
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
 
@@ -70,10 +84,18 @@ const notWritten = (why) => new Refusal('record-not-sound', why);
70
84
 
71
85
  // A time handed in: a number of milliseconds, or a Date. Text, true or a
72
86
  // list would be read by rules that differ from one device to another (text
73
- // with no time zone is taken as local time), so they are refused.
87
+ // with no time zone is taken as local time), so they are refused. A Date
88
+ // that cannot be read (one wrapped in a Proxy) gives no time, as a Date
89
+ // that is not a time gives none, and the check before acting refuses it.
74
90
  function timeOf(when) {
75
91
  if (typeof when === 'number') return new Date(when).getTime();
76
- if (when instanceof Date) return when.getTime();
92
+ if (when instanceof Date) {
93
+ try {
94
+ return when.getTime();
95
+ } catch {
96
+ return NaN;
97
+ }
98
+ }
77
99
  throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
78
100
  }
79
101
 
@@ -196,17 +218,20 @@ function plainHeld(copy) {
196
218
  * @param {object} o
197
219
  * @param {string} o.book the book so far: at least the slip
198
220
  * @param {string} o.slip the fingerprint of the slip
199
- * @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
200
222
  * @param {string[]} o.issuerKeys the thumbprints of the passkeys this agent's software trusts; a slip from any other key allows nothing
201
223
  * @param {string} [o.pass] for a helper agent: the fingerprint of the pass it acts under
202
224
  * @param {object} [o.options] further options for the checker (sealKeys, stampServices, blocks, vouchers, cancellations).
203
225
  * They are read once, as a whole check reads them, and fixed
204
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
205
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
206
230
  * @returns {Promise<object>} the writer: before, record, act, book, check, cancellations, add
207
- * @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
208
233
  */
209
- 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 }) {
210
235
  if (typeof now !== 'function') throw new Refusal('bad-field', 'now: must be a function that gives the time in milliseconds.');
211
236
  // A clock that gives anything but a time is a clock that failed: no comparison with it would hold.
212
237
  const device = now;
@@ -218,6 +243,9 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
218
243
  if (!(Number.isFinite(countersignWithin) && countersignWithin >= 1 && countersignWithin <= MAX_WAIT_MS)) {
219
244
  throw new Refusal('bad-field', 'countersignWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
220
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
+ }
221
249
  // Whom the writer trusts is fixed when it is opened: read once, as a whole
222
250
  // check reads it, and copied. The passkeys it trusts are its own
223
251
  // "issuerKeys", whatever the options say, read through the same fixed
@@ -395,6 +423,17 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
395
423
  }
396
424
  };
397
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
+
398
437
  // What a call hands in, copied at the moment of the call.
399
438
  const asked = (request, more) => {
400
439
  if (request === null || typeof request !== 'object') throw new Refusal('bad-field', 'The request must be an object.');
@@ -404,6 +443,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
404
443
  when: more.when === undefined || more.when === null ? undefined : timeOf(more.when),
405
444
  terms: more.terms,
406
445
  countersign: more.countersign,
446
+ detailsAfter: more.detailsAfter,
407
447
  hasApproval: more.approval !== undefined && more.approval !== null,
408
448
  approval: undefined,
409
449
  };
@@ -440,6 +480,20 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
440
480
  if (call.hasApproval) proposal.approval = call.approval;
441
481
  return proposal;
442
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
+ };
443
497
  const aheadOfClock = (when) => when > now() + CLOCK_ALLOWANCE_MS;
444
498
  const behindClock = (when) => when < now() - CLOCK_ALLOWANCE_MS;
445
499
 
@@ -483,7 +537,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
483
537
  pass,
484
538
  when,
485
539
  },
486
- privateKeys,
540
+ signing,
487
541
  );
488
542
  const entry = { stub: stub.record };
489
543
  if (call.hasApproval) entry.approval = call.approval;
@@ -636,7 +690,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
636
690
  }
637
691
  let made;
638
692
  try {
639
- made = (await writeAcknowledgement({ slip, cancellation, pass, when }, privateKeys)).record;
693
+ made = (await writeAcknowledgement({ slip, cancellation, pass, when }, signing)).record;
640
694
  } catch {
641
695
  return undefined;
642
696
  }
@@ -775,6 +829,8 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
775
829
  return calling(request, more, async (call) => {
776
830
  await writeWaiting(now());
777
831
  const when = call.when ?? now();
832
+ // In the words of the check before acting, which "act" and "before" give.
833
+ if (!Number.isFinite(when)) throw new Refusal('bad-field', 'when: does not give a time.');
778
834
  if (aheadOfClock(when)) throw notWritten('The stub was not written: it is dated ahead of the clock.');
779
835
  const made = await composeStub(call, when);
780
836
  const tried = await fitsBook(made, 'The stub was not written: with it, the book would not pass its check.');
@@ -798,10 +854,28 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
798
854
  * If taking the action never ends, the writer waits: it cannot know
799
855
  * what happened.
800
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
+ *
801
873
  * @param {{action: string, amount?: object, with?: string, details?: object[]}} request
802
874
  * @param {() => Promise<any>} perform takes the action; it must not call this writer
803
875
  * @param {object} [more] as for "record"; "when" defaults to the clock and must be within 300 seconds of it
804
- * @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
805
879
  */
806
880
  act(request, perform, more = {}) {
807
881
  return calling(request, more, async (call) => {
@@ -827,6 +901,19 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
827
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.',
828
902
  });
829
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
+ }
830
917
  let made;
831
918
  let tried;
832
919
  if (answer.allowed) {
@@ -839,8 +926,57 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
839
926
  }
840
927
  if (!answer.allowed) return { done: false, answer };
841
928
  const outcome = await runInside(perform);
842
- const stub = await commit(await countersigned(made, call), made, tried);
843
- 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 };
844
980
  });
845
981
  },
846
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
  }