provared 0.5.0 → 0.7.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/src/signatures.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // not built in, which is never a pass.
15
15
 
16
16
  import { Refusal } from './encoding.js';
17
- import { checkKey, importPublicKey, thumbprint, weakEd25519 } from './keys.js';
17
+ import { badEd25519Signature, checkKey, importPublicKey, thumbprint } from './keys.js';
18
18
 
19
19
  const SLH = 'SLH-DSA-SHA2-256s';
20
20
 
@@ -99,10 +99,11 @@ export async function verifySignature(jwk, signature, data, without = []) {
99
99
  if (!Object.hasOwn(VERIFY_PARAMS, jwk.alg)) return 'invalid';
100
100
  const params = VERIFY_PARAMS[jwk.alg];
101
101
  if (without.includes(jwk.alg) || !(await methodAvailable(jwk.alg))) return 'unavailable';
102
- // An Ed25519 signature whose first half is a point of small order is
103
- // refused here, whatever the device's own library would say: libraries
104
- // differ on it (format description, section 3.8).
105
- if (jwk.alg === 'Ed25519' && (signature.length !== 64 || weakEd25519(signature.subarray(0, 32)))) return 'invalid';
102
+ // An Ed25519 signature whose first half is a point of small order, or
103
+ // whose second half, the number S, is not below the order L, is refused
104
+ // here, whatever the device's own library would say: libraries differ on
105
+ // both (format description, section 3.8).
106
+ if (jwk.alg === 'Ed25519' && badEd25519Signature(signature)) return 'invalid';
106
107
  try {
107
108
  const key = await importPublicKey(jwk);
108
109
  return (await globalThis.crypto.subtle.verify(params, key, signature, data)) ? 'valid' : 'invalid';
@@ -180,7 +181,12 @@ const signerShape = (alg) =>
180
181
  function writtenSlhKey(item, where) {
181
182
  let made;
182
183
  try {
183
- made = nodeCrypto.createPrivateKey(item);
184
+ // A Web Crypto CryptoKey of another method is refused here, without
185
+ // handing it to Node.js: createPrivateKey would turn it into a KeyObject
186
+ // of that method, refused below all the same, but handing it a CryptoKey
187
+ // is deprecated (DEP0203) and prints a warning.
188
+ if (typeof globalThis.CryptoKey === 'function' && item instanceof globalThis.CryptoKey && item.algorithm.name !== SLH) made = null;
189
+ else made = nodeCrypto.createPrivateKey(item);
184
190
  } catch {
185
191
  made = null; // not a private key Node.js can read, or no Node.js here
186
192
  }
package/src/slip.js CHANGED
@@ -22,21 +22,52 @@ const MAX_PERIOD_SECONDS = 366 * 86400;
22
22
  // begins or ends with a hyphen, and the last is not all digits. A browser
23
23
  // writes a page's address in exactly this form, with a name in another
24
24
  // script written in its "xn--" form, which the rule takes as it is.
25
+ // eslint-disable-next-line security/detect-unsafe-regex -- each part has a bounded length, and a full stop or colon separates the parts.
25
26
  const ORIGIN = /^(https|http):\/\/([a-z0-9.-]{1,253})(?::([1-9][0-9]{0,4}))?$/;
27
+ // eslint-disable-next-line security/detect-unsafe-regex -- a label has at most 63 characters.
26
28
  const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
27
29
 
30
+ // The scheme and the host of a text in the form above, or null where the
31
+ // text is not in that form.
32
+ function pageAddressParts(text) {
33
+ if (typeof text !== 'string') return null;
34
+ const m = ORIGIN.exec(text);
35
+ if (!m) return null;
36
+ const labels = m[2].split('.');
37
+ const port = m[3] !== undefined ? Number(m[3]) : null;
38
+ if (!labels.every((l) => LABEL.test(l)) || /^[0-9]+$/.test(labels[labels.length - 1]) || (port !== null && (port > 65535 || port === (m[1] === 'https' ? 443 : 80)))) {
39
+ return null;
40
+ }
41
+ return { scheme: m[1], host: m[2] };
42
+ }
43
+
44
+ /**
45
+ * Whether a text is the address of a page in the one form the format
46
+ * allows for a slip's "issuer.origin" (format description, section 4.1):
47
+ * "https://" (or "http://localhost"), a website name in lower case, a port
48
+ * only where it is needed, and nothing more. The addresses a reader names
49
+ * as the ones a permission must have been signed at (the option
50
+ * "issuerOrigins") must be in this form too, so that an address with a
51
+ * capital letter or a path is refused, and does not quietly match nothing.
52
+ * @param {unknown} text
53
+ * @returns {boolean}
54
+ */
55
+ export function isPageAddress(text) {
56
+ const parts = pageAddressParts(text);
57
+ return parts !== null && (parts.scheme === 'https' || (parts.scheme === 'http' && parts.host === 'localhost'));
58
+ }
59
+
28
60
  function checkOrigin(origin, rpId) {
29
61
  f.text(origin, 1, 300, 'issuer.origin');
62
+ // eslint-disable-next-line security/detect-unsafe-regex -- a name has at most 253 characters.
30
63
  if (typeof rpId !== 'string' || !/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/.test(rpId)) {
31
64
  throw f.fail('issuer.rpId', 'must be a website name in lower case, such as example.org.');
32
65
  }
33
- const m = ORIGIN.exec(origin);
34
- const labels = m ? m[2].split('.') : [];
35
- const port = m && m[3] !== undefined ? Number(m[3]) : null;
36
- if (!m || !labels.every((l) => LABEL.test(l)) || /^[0-9]+$/.test(labels[labels.length - 1]) || (port !== null && (port > 65535 || port === (m[1] === 'https' ? 443 : 80)))) {
66
+ const parts = pageAddressParts(origin);
67
+ if (parts === null) {
37
68
  throw f.fail('issuer.origin', 'must be the address of a page: https://, a website name in lower case, a port only if it is needed, and nothing more, such as https://sign.example.org.');
38
69
  }
39
- const [, scheme, host] = m;
70
+ const { scheme, host } = parts;
40
71
  if (!(scheme === 'https' || (scheme === 'http' && host === 'localhost'))) {
41
72
  throw f.fail('issuer.origin', 'must begin https://, or http://localhost.');
42
73
  }
@@ -48,6 +79,29 @@ function checkOrigin(origin, rpId) {
48
79
  /** Not part of the public interface: for the shared test files. */
49
80
  export const checkOriginForTests = checkOrigin;
50
81
 
82
+ /** The most addresses a reader may name as the ones a permission must have been signed at. */
83
+ export const MAX_ISSUER_ORIGINS = 64;
84
+
85
+ /**
86
+ * The addresses a reader names as the ones a permission must have been
87
+ * signed at (the option "issuerOrigins"; format description, section 4.4):
88
+ * a list of at most 64, each in the form isPageAddress accepts. Anything
89
+ * else is refused, never set aside: an address with a capital letter or a
90
+ * path would match nothing, and every permission would then read as signed
91
+ * elsewhere. Every place in the list is read by its number, a gap included.
92
+ * @param {object} options the checker's options
93
+ * @throws {Refusal} "bad-field"
94
+ */
95
+ export function checkOriginsOption(options) {
96
+ const { issuerOrigins } = options;
97
+ if (issuerOrigins === undefined) return;
98
+ let sound = Array.isArray(issuerOrigins) && issuerOrigins.length <= MAX_ISSUER_ORIGINS;
99
+ for (let i = 0; sound && i < issuerOrigins.length; i++) sound = isPageAddress(issuerOrigins[i]);
100
+ if (!sound) {
101
+ throw f.fail('issuerOrigins', 'must be a list of at most 64 page addresses, each https://, a website name in lower case, a port only if it is needed, and nothing more, such as https://sign.example.org.');
102
+ }
103
+ }
104
+
51
105
  /**
52
106
  * Confirm a list of limits against the actions it may name (format
53
107
  * description, section 4.1). A slip and a pass share it.
@@ -279,6 +333,7 @@ export async function preparePasskeyRecord(kind, content) {
279
333
  * @param {any} record the slip as a JavaScript object
280
334
  * @param {object} [options]
281
335
  * @param {string[]} [options.issuerKeys] thumbprints of the issuer keys the checker expects
336
+ * @param {string[]} [options.issuerOrigins] the addresses of the pages at which the checker expects the slip to have been signed, each in the form of section 4.1 (see isPageAddress)
282
337
  * @param {string[]} [options.withoutMethods] see verifySignature
283
338
  * @param {Record<string, string[]>} [options.disclosures] for covered fields: the disclosures handed over, by the fingerprint of the record they belong to
284
339
  * @param {string[]} [disclosures] the disclosures for this slip, where they come with it (a page of a Show)
@@ -349,6 +404,18 @@ export async function checkSlip(record, options = {}, disclosures) {
349
404
  } else {
350
405
  result.notes.push('The issuer\'s key was not compared with a key you already trust. The name on the slip is only a label.');
351
406
  }
407
+
408
+ // The address the slip was signed at, compared with the ones the reader
409
+ // named, where they named any (section 4.4). An approval and a
410
+ // cancellation are signed with the same passkey, and the passkey check
411
+ // holds each to the slip's address, so the slip's is the one compared.
412
+ if (options.issuerOrigins !== undefined) {
413
+ checkOriginsOption(options);
414
+ let named = false;
415
+ for (let i = 0; i < options.issuerOrigins.length; i++) if (options.issuerOrigins[i] === issuer.origin) named = true;
416
+ if (!named) throw new Refusal('issuer-origin-not-expected', 'The slip was signed at a page address other than the ones expected.');
417
+ result.notes.push(`This slip was signed at ${issuer.origin}, an address you named.`);
418
+ }
352
419
  } catch (e) {
353
420
  result.problems.push(problemFrom(e));
354
421
  }
package/src/timestamp.js CHANGED
@@ -9,9 +9,9 @@
9
9
  // service's certificate. It follows no chain of certificates and asks no
10
10
  // outside party: the person checking decides whom to trust.
11
11
 
12
- import { TAG, badStamp, children, contentOf, ecdsaToRaw, expect, hexOf, readElement, timeOf, validateDer, wholeOf } from './der.js';
12
+ import { TAG, badStamp, children, contentOf, ecdsaToRaw, expect, hexOf, readElement, setInOrder, timeOf, validateDer, wholeOf } from './der.js';
13
13
  import { Refusal, equalBytes, formatTime, sha256, toBase64url } from './encoding.js';
14
- import { weakEd25519 } from './keys.js';
14
+ import { badEd25519Signature, weakEd25519 } from './keys.js';
15
15
 
16
16
  /** The largest time-stamp, in bytes. */
17
17
  export const MAX_STAMP_BYTES = 12288;
@@ -162,6 +162,8 @@ function checkKeyEncoding(bytes, certificate, method, size) {
162
162
  // force, and its public key.
163
163
  function readCertificate(bytes, element) {
164
164
  const whole = wholeOf(bytes, expect(element, TAG.SEQUENCE, 'a certificate'));
165
+ // A certificate is DER (RFC 5280 section 4.1), so the order of every set inside it is held here.
166
+ validateDer(bytes, element);
165
167
  const tbs = expect(children(bytes, element, 3)[0], TAG.SEQUENCE, 'the body of a certificate');
166
168
  const parts = children(bytes, tbs, 12);
167
169
  // The version, in [0], is absent only in the oldest kind of certificate.
@@ -204,14 +206,17 @@ function signingCertificateOf(bytes, value, v2) {
204
206
  }
205
207
 
206
208
  const KIND_NOT_ACCEPTED = 'A time-stamp was signed with a kind of key that is not accepted: RSA, ECDSA on the curves P-256 and P-384, and Ed25519 are.';
209
+ const ED25519_DIGEST = 'A time-stamp signed with Ed25519 names a fingerprint method other than SHA-512, or gives the method parameters, which RFC 8419 does not allow.';
210
+ const ED25519_PARAMETERS = 'A time-stamp signed with Ed25519 gives its signing method parameters, which RFC 8419 does not allow.';
207
211
 
208
212
  // Check the service's signature with the public key of its certificate.
209
213
  // Returns "valid", "invalid", or "unavailable" where this device has no
210
214
  // built-in support for the method. The fingerprint method is the one the
211
215
  // signer names (RFC 5652 section 5.4); a signing method that names another
212
216
  // is refused; a key of a kind that is not accepted is refused in words that
213
- // say so.
214
- async function verifyWith(certificate, bytes, signatureOid, digestName, signature, signed, without) {
217
+ // say so. "parameters" holds the parameters, if any, of the signing method
218
+ // and of the signer's fingerprint method.
219
+ async function verifyWith(certificate, bytes, signatureOid, digestName, signature, signed, without, parameters) {
215
220
  const subtle = globalThis.crypto.subtle;
216
221
  let importParams;
217
222
  let verifyParams;
@@ -237,7 +242,11 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
237
242
  return 'invalid';
238
243
  }
239
244
  } else if (certificate.keyOid === OID.ed25519) {
245
+ // With Ed25519 the signer's fingerprint method is SHA-512 with no
246
+ // parameters, and the signing method carries none (RFC 8419, section 3.1).
240
247
  if (signatureOid !== OID.ed25519) return 'invalid';
248
+ if (digestName !== 'SHA-512' || parameters.digest !== undefined) throw invalid(ED25519_DIGEST);
249
+ if (parameters.signature !== undefined) throw invalid(ED25519_PARAMETERS);
241
250
  checkKeyEncoding(bytes, certificate, 'Ed25519');
242
251
  method = 'Ed25519';
243
252
  importParams = { name: 'Ed25519' };
@@ -253,8 +262,8 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
253
262
  // A device with no Ed25519 says so when the key is loaded.
254
263
  return method === 'Ed25519' && e && e.name === 'NotSupportedError' ? 'unavailable' : 'invalid';
255
264
  }
256
- // An Ed25519 signature whose first half is a point of small order is refused, as everywhere (format description, section 3.8).
257
- if (method === 'Ed25519' && (raw.length !== 64 || weakEd25519(raw.subarray(0, 32)))) return 'invalid';
265
+ // An Ed25519 signature whose first half is a point of small order, or whose number S is not below L, is refused, as everywhere (format description, section 3.8).
266
+ if (method === 'Ed25519' && badEd25519Signature(raw)) return 'invalid';
258
267
  try {
259
268
  return (await subtle.verify(verifyParams, key, raw, signed)) ? 'valid' : 'invalid';
260
269
  } catch {
@@ -275,7 +284,8 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
275
284
  * soundly, by a service the checker did not name. "unavailable": this
276
285
  * device cannot check the service's signing method; no time is given.
277
286
  * "authority" is the fingerprint of the certificate that signed. "when"
278
- * is the stated time, to the second, rounded down.
287
+ * is the stated time, to the second, rounded down; "time" is it in
288
+ * milliseconds, a fraction of a second kept to the millisecond.
279
289
  * @throws {Refusal} "stamp-bad-data", "stamp-wrong-data" or "stamp-invalid"
280
290
  */
281
291
  export async function checkStamp(token, fingerprintBytes, options) {
@@ -287,10 +297,18 @@ export async function checkStamp(token, fingerprintBytes, options) {
287
297
  if (!(fingerprintBytes instanceof Uint8Array)) throw badStamp('there is no fingerprint to compare it with.');
288
298
 
289
299
  // The outer wrapper, and the signed data inside it (RFC 5652 sections 3
290
- // and 5.1). Every part is read, and must be strict DER.
300
+ // and 5.1). Every part the reader can tell apart is read, and must be
301
+ // strict DER, except that the order of a set is held only where a standard
302
+ // requires DER: the signed attributes (RFC 5652 section 5.3), the statement
303
+ // (RFC 3161 section 2.4.2) and each certificate (RFC 5280 section 4.1),
304
+ // each walked again below with the order held. The signed data's own sets
305
+ // (the fingerprint methods, the certificates, the withdrawn certificates,
306
+ // the signers) and the rest of the signer may be in any order: CMS does not
307
+ // require DER of them, and real services list their certificates in chain
308
+ // order, the signing certificate before its issuer.
291
309
  const wrapper = expect(readElement(token, 0), TAG.SEQUENCE, 'the wrapper');
292
310
  if (wrapper.end !== token.length) throw badStamp('it has bytes left over.');
293
- validateDer(token, wrapper);
311
+ validateDer(token, wrapper, false);
294
312
  const outer = children(token, wrapper, 2);
295
313
  if (outer.length !== 2 || hexOf(token, expect(outer[0], TAG.OID, 'the kind of content')) !== OID.signedData) throw badStamp('it is not signed data.');
296
314
  const inner = children(token, expect(outer[1], TAG.CONTEXT_0, 'the signed data'), 1);
@@ -309,7 +327,11 @@ export async function checkStamp(token, fingerprintBytes, options) {
309
327
  let next = 3;
310
328
  const certificates = [];
311
329
  if (parts[next] && parts[next].tag === TAG.CONTEXT_0) {
312
- for (const c of children(token, parts[next], 8)) {
330
+ // A set of the signed data's own (RFC 5652 section 10.2.3), which CMS does
331
+ // not require to be in DER order; each certificate inside it is DER (RFC
332
+ // 5280 section 4.1), and is held to it when it is read.
333
+ const items = children(token, parts[next], 8);
334
+ for (const c of items) {
313
335
  // Other kinds of certificate may sit here; only the ordinary kind is read.
314
336
  if (c.tag === TAG.SEQUENCE) certificates.push(readCertificate(token, c));
315
337
  }
@@ -324,9 +346,16 @@ export async function checkStamp(token, fingerprintBytes, options) {
324
346
  if (!signer[1] || !((signerVersion === '01' && signer[1].tag === TAG.SEQUENCE) || (signerVersion === '03' && signer[1].tag === 0x80))) {
325
347
  throw badStamp('the signer is not named as its standard sets out.');
326
348
  }
327
- const digestName = HASHES[algorithmOf(token, signer[2], 'the signer\'s fingerprint method').oid];
349
+ const digesting = algorithmOf(token, signer[2], 'the signer\'s fingerprint method');
350
+ const digestName = HASHES[digesting.oid];
328
351
  const attributes = expect(signer[3], TAG.CONTEXT_0, 'the signed attributes');
329
- const signatureOid = algorithmOf(token, signer[4], 'the signing method').oid;
352
+ // A set under a tag of its own, in DER (RFC 5652 section 5.3), so its order,
353
+ // and the order of every set inside it, is held to DER here.
354
+ const attributeList = children(token, attributes, 16);
355
+ if (!setInOrder(token, attributeList)) throw badStamp('the signed attributes are not in the order DER requires.');
356
+ for (const a of attributeList) validateDer(token, a);
357
+ const signing = algorithmOf(token, signer[4], 'the signing method');
358
+ const signatureOid = signing.oid;
330
359
  const signature = contentOf(token, expect(signer[5], TAG.OCTET_STRING, 'the signature'));
331
360
  if (signer.length > 7 || (signer.length === 7 && signer[6].tag !== TAG.CONTEXT_1)) throw badStamp('the signer holds parts that are not in the order its standard sets out.');
332
361
 
@@ -344,7 +373,9 @@ export async function checkStamp(token, fingerprintBytes, options) {
344
373
  let kindNamed = false;
345
374
  let digestNamed = false;
346
375
  let named = null;
347
- for (const attribute of children(token, attributes, 16)) {
376
+ let newerNamed = false;
377
+ let olderNamed = false;
378
+ for (const attribute of attributeList) {
348
379
  const pair = children(token, expect(attribute, TAG.SEQUENCE, 'an attribute'), 2);
349
380
  const name = hexOf(token, expect(pair[0], TAG.OID, 'the name of an attribute'));
350
381
  const values = children(token, expect(pair[1], TAG.SET, 'the value of an attribute'), 4);
@@ -360,8 +391,16 @@ export async function checkStamp(token, fingerprintBytes, options) {
360
391
  digestNamed = true;
361
392
  } else if (name === OID.signingCertificateV2 || name === OID.signingCertificate) {
362
393
  if (values.length !== 1) throw invalid('A time-stamp does not say, in one way, which certificate signed it.');
363
- // Where both forms are present, the newer one is used.
364
- if (named === null || name === OID.signingCertificateV2) named = signingCertificateOf(token, values[0], name === OID.signingCertificateV2);
394
+ // Each form at most once: RFC 5035 section 3 for the newer form, and
395
+ // for the older section 5.4.2 of RFC 2634 as RFC 5035 section 5 writes
396
+ // it. Where both forms are present, the newer one is used: this
397
+ // format's choice, where RFC 5035 section 7 lets a reader refuse a
398
+ // time-stamp whose two forms disagree.
399
+ const newer = name === OID.signingCertificateV2;
400
+ if (newer ? newerNamed : olderNamed) throw invalid('A time-stamp names the certificate that signed it more than once in the same form, which its standard does not allow.');
401
+ if (newer) newerNamed = true;
402
+ else olderNamed = true;
403
+ if (named === null || newer) named = signingCertificateOf(token, values[0], newer);
365
404
  }
366
405
  }
367
406
  if (!kindNamed || !digestNamed) throw invalid('A time-stamp\'s signature does not cover its statement.');
@@ -382,7 +421,7 @@ export async function checkStamp(token, fingerprintBytes, options) {
382
421
  // 5.4). The bytes are copied, so that the caller's time-stamp is not changed.
383
422
  const signed = new Uint8Array(wholeOf(token, attributes));
384
423
  signed[0] = TAG.SET;
385
- const state = await verifyWith(certificate, token, signatureOid, digestName, signature, signed, without);
424
+ const state = await verifyWith(certificate, token, signatureOid, digestName, signature, signed, without, { signature: signing.parameters, digest: digesting.parameters });
386
425
  if (state === 'unavailable') return { state: 'unavailable', when: null, time: NaN, authority: null };
387
426
  if (state !== 'valid') throw invalid('A time-stamp\'s signature does not fit the certificate it names.');
388
427
  if (statement.time < certificate.notBefore || statement.time > certificate.notAfter) {
package/src/tools.js CHANGED
@@ -5,13 +5,16 @@
5
5
  // every call asks first, is run only if the slip allows it, and leaves a
6
6
  // stub. The agent's software then calls the tools as before.
7
7
  //
8
- // By default the stub of a call names one document, "arguments": the
9
- // fingerprint of the call's arguments. So the record fixes exactly what was
10
- // asked for, without holding it: whoever kept the arguments can show them
11
- // later, and anyone can work out the fingerprint again.
8
+ // By default the stub of a call names one document, "provared.arguments"
9
+ // (a reserved name, format description section 27): the fingerprint of the
10
+ // call's arguments. So the record fixes exactly what was asked for, without
11
+ // holding it: whoever kept the arguments can show them later, and anyone
12
+ // can work out the fingerprint again. Before version 0.6.0 the document was
13
+ // named "arguments"; a checker reads both.
12
14
  //
13
15
  // It names no agent software and depends on none: a tool is any function.
14
16
 
17
+ import { denialCodes } from './denial.js';
15
18
  import { Refusal, fingerprint, utf8 } from './encoding.js';
16
19
  import * as f from './fields.js';
17
20
 
@@ -27,18 +30,22 @@ const NOT_A_GENERATOR = 'must not be a generator function: calling one does not
27
30
  * A call through a recorded tool that was not run: the check before acting
28
31
  * did not allow it, or the record so far has a problem. "answer" is what
29
32
  * the check before acting said (allowed, problems, breaches, needs).
33
+ * "denial", where the stub writer was opened with "denials: true", is what
34
+ * it said of the denial it wrote, or did not write, for the call.
30
35
  */
31
36
  export class NotTaken extends Error {
32
37
  /**
33
38
  * @param {string} tool the name of the tool
34
39
  * @param {{allowed: boolean, problems: object[], breaches: object[], needs: string[]}} answer
40
+ * @param {object} [denial]
35
41
  */
36
- constructor(tool, answer) {
42
+ constructor(tool, answer, denial) {
37
43
  const first = [...answer.problems, ...answer.breaches][0];
38
44
  super(`The tool "${tool}" was not run. ${first ? first.message : 'The check before acting did not allow it.'}`);
39
45
  this.name = 'NotTaken';
40
46
  this.tool = tool;
41
47
  this.answer = answer;
48
+ if (denial !== undefined) this.denial = denial;
42
49
  }
43
50
  }
44
51
 
@@ -74,7 +81,7 @@ function oneForm(value, depth = 0) {
74
81
  /**
75
82
  * The fingerprint of the arguments of a call: SHA-256, in base64url, of the
76
83
  * arguments written in the one form of RFC 8785. It is what a recorded
77
- * tool puts into its stub, as the document "arguments".
84
+ * tool puts into its stub, as the document "provared.arguments".
78
85
  * @param {unknown} args JSON: objects, lists, text, numbers, true, false and null
79
86
  * @returns {Promise<string>}
80
87
  * @throws {Refusal} "bad-field" if the arguments are not JSON
@@ -118,8 +125,8 @@ const needsOnlyApproval = (answer) => answer.problems.length === 0 && answer.bre
118
125
  * - "with" (optional): the id of the service the tool deals with, as the slip names it, or a function of the arguments that gives it;
119
126
  * - "amount" (optional): {unit, value}, or a function of the arguments that gives it;
120
127
  * - "details" (optional): a function of the arguments that gives the documents to name in the stub
121
- * ({name, sha256} each), or false for none. By default: one document, "arguments", the fingerprint
122
- * of the arguments (argumentsFingerprint);
128
+ * ({name, sha256} each), or false for none. By default: one document, "provared.arguments", the
129
+ * fingerprint of the arguments (argumentsFingerprint);
123
130
  * - "countersign" (optional): asks the other side to countersign the stub; a function of the stub and the arguments;
124
131
  * - "approve" (optional): asks the person for their approval where the slip asks for one; a function of the
125
132
  * request ({action, amount?, with?, details?}) and the arguments, that gives {record} or nothing.
@@ -139,6 +146,8 @@ export function recordTools(recorder, tools, options = {}) {
139
146
  if (options === null || typeof options !== 'object') options = {};
140
147
  // What the tools are put behind is fixed here too.
141
148
  const act = recorder.act.bind(recorder);
149
+ // Whether the writer writes denials, read once: only then are denials asked for.
150
+ const deny = recorder.denials === true && typeof recorder.deny === 'function' ? recorder.deny.bind(recorder) : null;
142
151
  const onStub = typeof options.onStub === 'function' ? options.onStub : null;
143
152
  const recorded = {};
144
153
  for (const name of Object.keys(tools)) {
@@ -157,12 +166,12 @@ export function recordTools(recorder, tools, options = {}) {
157
166
  for (const member of ['amount', 'with']) if (typeof fixed[member] !== 'function') fixed[member] = copied(fixed[member], `tools.${name}.${member}`);
158
167
  for (const member of ['amount', 'with']) if (fixed[member] !== null && typeof fixed[member] === 'object') Object.freeze(fixed[member]);
159
168
  Object.freeze(fixed);
160
- Object.defineProperty(recorded, name, { enumerable: true, value: (args) => call(act, onStub, name, fixed, args) });
169
+ Object.defineProperty(recorded, name, { enumerable: true, value: (args) => call(act, deny, onStub, name, fixed, args) });
161
170
  }
162
171
  return recorded;
163
172
  }
164
173
 
165
- async function call(act, onStub, name, spec, args) {
174
+ async function call(act, deny, onStub, name, spec, args) {
166
175
  // The arguments are copied when the call is made. The fingerprint is made
167
176
  // from that copy, and the tool is run with a copy of it. Each of the
168
177
  // tool's other functions is handed a copy of its own.
@@ -177,7 +186,7 @@ async function call(act, onStub, name, spec, args) {
177
186
  let details;
178
187
  if (spec.details === false) details = [];
179
188
  else if (typeof spec.details === 'function') details = (await (0, spec.details)(structuredClone(given))) ?? [];
180
- else details = given === undefined ? [] : [{ name: 'arguments', sha256: await argumentsFingerprint(given) }];
189
+ else details = given === undefined ? [] : [{ name: 'provared.arguments', sha256: await argumentsFingerprint(given) }];
181
190
  if (!Array.isArray(details)) throw f.fail(`tools.${name}.details`, 'must give a list of documents.');
182
191
  details = copied(details, `tools.${name}.details`);
183
192
  if (details.length > 0) request.details = details;
@@ -185,14 +194,22 @@ async function call(act, onStub, name, spec, args) {
185
194
  const more = {};
186
195
  if (typeof spec.countersign === 'function') more.countersign = (stub) => (0, spec.countersign)(stub, structuredClone(given));
187
196
  const perform = () => (0, spec.run)(structuredClone(given));
188
- let outcome = await act(request, perform, more);
197
+ // Where the person may be asked for their approval, the first call
198
+ // writes no denial: the action may yet be taken. A denial is written once
199
+ // it is known that it will not be, so that one call never leaves both a
200
+ // stub and a denial.
201
+ const asking = typeof spec.approve === 'function' && deny !== null;
202
+ let outcome = await act(request, perform, asking ? { ...more, denial: false } : more);
189
203
  // Where the slip asks for the person's own approval of this action, and
190
204
  // that is the one thing missing, the person is asked, once.
191
205
  if (!outcome.done && typeof spec.approve === 'function' && needsOnlyApproval(outcome.answer)) {
192
206
  const approval = await (0, spec.approve)(structuredClone(request), structuredClone(given));
193
207
  if (approval) outcome = await act(request, perform, { ...more, approval });
208
+ else if (asking) outcome = { ...outcome, denial: await deny(request, { source: 'person' }) };
209
+ } else if (!outcome.done && asking) {
210
+ outcome = { ...outcome, denial: await deny(request, { source: 'check', codes: denialCodes(outcome.answer) }) };
194
211
  }
195
- if (!outcome.done) throw new NotTaken(name, outcome.answer);
212
+ if (!outcome.done) throw new NotTaken(name, outcome.answer, outcome.denial);
196
213
  if (onStub) await onStub(outcome.stub, name);
197
214
  return outcome.result;
198
215
  }
package/src/tree.js CHANGED
@@ -7,8 +7,13 @@
7
7
  //
8
8
  // The proof that one leaf is in the tree is the audit path of section
9
9
  // 2.1.1. It is checked with the steps of RFC 9162, section 2.1.3.2.
10
+ //
11
+ // The proof that a tree of m leaves is the first part of a tree of n leaves
12
+ // (a consistency proof) is made as RFC 9162, section 2.1.4.1, sets out, and
13
+ // checked with the steps of section 2.1.4.2. RFC 9162 builds the same tree
14
+ // as RFC 6962.
10
15
 
11
- import { concatBytes, equalBytes, sha256 } from './encoding.js';
16
+ import { Refusal, concatBytes, equalBytes, sha256 } from './encoding.js';
12
17
 
13
18
  const LEAF = new Uint8Array([0x00]);
14
19
  const NODE = new Uint8Array([0x01]);
@@ -61,6 +66,15 @@ export async function treeRoot(leaves) {
61
66
  export function treeBuilder(from = []) {
62
67
  /** @type {{hash: Uint8Array, size: number}[]} */
63
68
  const parts = from.slice();
69
+ const addLeafHash = async (hash) => {
70
+ if (!(hash instanceof Uint8Array) || hash.length !== 32) throw new Refusal('bad-field', 'leaf: must be a leaf fingerprint: 32 bytes.');
71
+ let node = { hash: hash.slice(), size: 1 };
72
+ while (parts.length && parts[parts.length - 1].size === node.size) {
73
+ const left = parts.pop();
74
+ node = { hash: await nodeHash(left.hash, node.hash), size: left.size * 2 };
75
+ }
76
+ parts.push(node);
77
+ };
64
78
  return {
65
79
  /** A builder that carries on from here by itself: what is added to one is not added to the other. */
66
80
  fork() {
@@ -68,12 +82,17 @@ export function treeBuilder(from = []) {
68
82
  },
69
83
  /** @param {Uint8Array} leaf the data of the next leaf */
70
84
  async add(leaf) {
71
- let node = { hash: await leafHash(leaf), size: 1 };
72
- while (parts.length && parts[parts.length - 1].size === node.size) {
73
- const left = parts.pop();
74
- node = { hash: await nodeHash(left.hash, node.hash), size: left.size * 2 };
75
- }
76
- parts.push(node);
85
+ await addLeafHash(await leafHash(leaf));
86
+ },
87
+ /**
88
+ * Add the next leaf by its fingerprint alone (leafHash of its data), as a
89
+ * sealer that never sees the lines does.
90
+ * @param {Uint8Array} hash 32 bytes
91
+ */
92
+ addLeafHash,
93
+ /** One fingerprint for each complete part of the tree, the largest first: what a sealer saves. */
94
+ parts() {
95
+ return parts.map((p) => ({ hash: p.hash.slice(), size: p.size }));
77
96
  },
78
97
  /** @returns {Promise<Uint8Array>} the root of the leaves added so far */
79
98
  async root() {
@@ -141,3 +160,116 @@ export async function verifyInclusion(index, size, leaf, path, root) {
141
160
  }
142
161
  return sn === 0 && equalBytes(r, root);
143
162
  }
163
+
164
+ // The largest power of two smaller than n, for n of 2 or more (RFC 9162,
165
+ // section 2.1.1: "k be the largest power of two smaller than n").
166
+ function splitAt(n) {
167
+ let k = 1;
168
+ while (k * 2 < n) k *= 2;
169
+ return k;
170
+ }
171
+
172
+ // The root of the tree over the leaves whose hashes are hashes[lo] to
173
+ // hashes[hi - 1], for hi > lo: MTH of RFC 9162, section 2.1.1.
174
+ async function rangeRoot(hashes, lo, hi) {
175
+ if (hi - lo === 1) return hashes[lo];
176
+ const k = splitAt(hi - lo);
177
+ return nodeHash(await rangeRoot(hashes, lo, lo + k), await rangeRoot(hashes, lo + k, hi));
178
+ }
179
+
180
+ // SUBPROOF(m, D[lo:hi], b) of RFC 9162, section 2.1.4.1, for 0 < m <= hi - lo.
181
+ async function subproof(hashes, m, lo, hi, b) {
182
+ const n = hi - lo;
183
+ if (m === n) return b ? [] : [await rangeRoot(hashes, lo, hi)];
184
+ const k = splitAt(n);
185
+ if (m <= k) return [...(await subproof(hashes, m, lo, lo + k, b)), await rangeRoot(hashes, lo + k, hi)];
186
+ return [...(await subproof(hashes, m - k, lo + k, hi, false)), await rangeRoot(hashes, lo, lo + k)];
187
+ }
188
+
189
+ /**
190
+ * The proof that the first m leaves form a tree that the whole tree extends:
191
+ * the consistency proof of RFC 9162, section 2.1.4.1, from the tree of the
192
+ * first m leaves to the tree of all of them. For m equal to the number of
193
+ * leaves, and for m of 0, the proof is empty (RFC 9162 defines a proof only
194
+ * for 0 < m; every tree extends the empty one).
195
+ * @param {Uint8Array[]} leaves the data of each leaf
196
+ * @param {number} m how many leaves the earlier tree has, from 0 to the number of leaves
197
+ * @returns {Promise<Uint8Array[]>}
198
+ */
199
+ export async function consistencyProof(leaves, m) {
200
+ if (!Array.isArray(leaves)) throw new TypeError('the leaves must be a list');
201
+ if (!Number.isSafeInteger(m) || m < 0 || m > leaves.length) throw new RangeError('no such earlier tree');
202
+ if (m === 0 || m === leaves.length) return [];
203
+ const hashes = [];
204
+ for (const leaf of leaves) hashes.push(await leafHash(leaf));
205
+ return subproof(hashes, m, 0, hashes.length, true);
206
+ }
207
+
208
+ const isHash = (value) => value instanceof Uint8Array && value.length === 32;
209
+
210
+ /**
211
+ * Check a consistency proof (RFC 9162, section 2.1.4.2): that the tree of m
212
+ * leaves whose root is rootM is the first part of the tree of n leaves whose
213
+ * root is rootN. Where m equals n the proof must be empty and the two roots
214
+ * the same; where m is 0 the proof must be empty and rootM the root of the
215
+ * empty tree (the SHA-256 hash of nothing). Anything that is not a proof of
216
+ * these sizes gives false: the function never throws for a bad proof.
217
+ * @param {number} m
218
+ * @param {number} n
219
+ * @param {Uint8Array} rootM 32 bytes
220
+ * @param {Uint8Array} rootN 32 bytes
221
+ * @param {Uint8Array[]} proof each 32 bytes
222
+ * @returns {Promise<boolean>}
223
+ */
224
+ export async function verifyConsistency(m, n, rootM, rootN, proof) {
225
+ if (!Number.isSafeInteger(m) || !Number.isSafeInteger(n) || m < 0 || m > n) return false;
226
+ if (!isHash(rootM) || !isHash(rootN) || !Array.isArray(proof)) return false;
227
+ // Read place by place, a gap included: a gap is not a fingerprint.
228
+ const path = [];
229
+ for (let i = 0; i < proof.length; i++) {
230
+ if (!isHash(proof[i])) return false;
231
+ path.push(proof[i]);
232
+ }
233
+ if (m === n) return path.length === 0 && equalBytes(rootM, rootN);
234
+ if (m === 0) return path.length === 0 && equalBytes(rootM, await sha256(new Uint8Array(0)));
235
+ // The steps of RFC 9162, section 2.1.4.2, in their order.
236
+ // 1. An empty proof fails.
237
+ if (path.length === 0) return false;
238
+ // 2. Where m is an exact power of two, the root of the earlier tree is the first value.
239
+ // (Worked out by doubling, not with the bit operators, which hold only 32 bits.)
240
+ let power = 1;
241
+ while (power < m) power *= 2;
242
+ if (power === m) path.unshift(rootM);
243
+ // 3.
244
+ let fn = m - 1;
245
+ let sn = n - 1;
246
+ // 4. Sizes are below 2 to the power 53, so halving with Math.floor is exact.
247
+ while (fn % 2 === 1) {
248
+ fn = Math.floor(fn / 2);
249
+ sn = Math.floor(sn / 2);
250
+ }
251
+ // 5.
252
+ let fr = path[0];
253
+ let sr = path[0];
254
+ // 6.
255
+ for (let i = 1; i < path.length; i++) {
256
+ const c = path[i];
257
+ if (sn === 0) return false;
258
+ if (fn % 2 === 1 || fn === sn) {
259
+ fr = await nodeHash(c, fr);
260
+ sr = await nodeHash(c, sr);
261
+ if (fn % 2 === 0) {
262
+ while (fn % 2 === 0 && fn !== 0) {
263
+ fn = Math.floor(fn / 2);
264
+ sn = Math.floor(sn / 2);
265
+ }
266
+ }
267
+ } else {
268
+ sr = await nodeHash(sr, c);
269
+ }
270
+ fn = Math.floor(fn / 2);
271
+ sn = Math.floor(sn / 2);
272
+ }
273
+ // 7.
274
+ return equalBytes(fr, rootM) && equalBytes(sr, rootN) && sn === 0;
275
+ }