provared 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,212 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.2.0 (8 October 2026)
4
4
 
5
- Draft version 0 of the format. Nothing is published or frozen.
5
+ - 2026-10-08: version 0.2.0, for the npm package and, for the first
6
+ time, the Python package `provared` on the Python Package Index. Both
7
+ give the same answers, as the shared test files in `test-vectors/`
8
+ show. The version rises from 0.1.0 because the format's rules changed:
9
+ the exact rule for a page address in a slip, weak Ed25519 keys and
10
+ signatures refused, and strict keys in a time-stamp service's
11
+ certificate. Version 0.1.0 accepts a forged time-stamp where the
12
+ person checking names as trusted a service whose certificate has an RSA
13
+ public number of 1; 0.2.0 refuses it. The entries below say what
14
+ changed.
15
+ - 2026-10-08: fixes from an independent review of `record_function`, the
16
+ examples and the wording for a proposed action. None made a book read
17
+ as intact, or within its slip, when it was not.
18
+ - Python: a whole number in a tool's arguments that JavaScript cannot
19
+ hold exactly (beyond 2^53 - 1, unless a float holds it exactly) is
20
+ refused. Before, `2**60 + 1` and `2**60` left the same fingerprint in
21
+ their stubs, though the tool was handed the exact number. JSON is now
22
+ read as JavaScript reads it here too: a whole number beyond 2^53 - 1
23
+ is the nearest float.
24
+ - Python: a number of a class built on int or float (an Enum of whole
25
+ numbers, say) is written as its value, so its fingerprint is that of
26
+ the same arguments kept as JSON. Before, it was written as its name,
27
+ which is not JSON.
28
+ - A generator function is refused as a tool (`recordTools` and
29
+ `record_tools`) and by `record_function`: calling one does not run
30
+ its body, so its stub was written for an action not yet taken.
31
+ - Python: a dict in a tool's arguments whose names are not all text is
32
+ refused. Before, `{101: 2, "101": 5}` reached the tool as `{"101": 5}`.
33
+ - Python: `record_function` refuses, when it is handed the function, a
34
+ parameter whose type can never be plain data (a class of its own,
35
+ such as a model or a date, a tuple or a set), an object whose
36
+ `__call__` is defined with `async def`, and a function whose name is
37
+ not text, each in words of its own. A call whose arguments are not
38
+ plain data names the argument.
39
+ - Python: the README and the docstring say that the function is run
40
+ with a copy of its arguments, and what the record leaves out (an
41
+ argument bound into a `functools.partial` in place).
42
+ - The check before acting says "the action gives no amount", not "the
43
+ stub gives no amount". The shared file `check-before.json` now holds
44
+ a case for each of the words for a proposed action.
45
+ - Examples: `offline()` also refuses the other kinds of name look-up,
46
+ messages sent without a connection, starting another program, a
47
+ connection made by Python's event loop on Windows (which raises no
48
+ audit event), and a connection to a port on this computer that the
49
+ same program does not hold, such as a proxy's. The tests check that
50
+ nothing was refused while each example ran. Its README says exactly
51
+ what it refuses.
52
+ - 2026-10-08: examples for three widely used Python agent frameworks, in
53
+ `python/examples/`, the one folder that names them. Each records an
54
+ agent's tools with `record_function`, runs the agent with the
55
+ framework's own stand-in model (no AI service is called; tracing is
56
+ switched off; any connection outside the program is refused while it
57
+ runs), and checks the book. Each is tested
58
+ (`python/examples/test_examples.py`). The library itself still names no
59
+ product.
60
+ - 2026-10-08: the check before acting speaks of an action that is only
61
+ proposed: "With this action the total would be 280 GBP", not "With this
62
+ stub the total is", since no stub exists for an action that is not
63
+ taken. Likewise for a count and a period. The codes are unchanged; the
64
+ checker's words for a stub are unchanged.
65
+ - 2026-10-08: the Python version gains `record_function`, which puts one
66
+ ordinary function behind the stub writer and keeps its name, its
67
+ parameters and its description, so that an agent framework describes
68
+ the recorded function to its model as it would the function. Python
69
+ only: a JavaScript tool takes its arguments as one object. Tested
70
+ (`python/tests/test_record_function.py`).
71
+ - 2026-10-08: `test-vectors/README.md` says what each shared file holds
72
+ and what must match: Provared's own libraries give every answer
73
+ exactly, words included; another implementation needs to match the
74
+ codes and the shape of each answer. The Python package is made ready
75
+ for the Python Package Index (version 0.1.0, both licence files); it is
76
+ not yet published there.
77
+ - 2026-10-08: faults found by a second independent review, of the
78
+ Python stub writer and of the changes above. In 1,183 random
79
+ scenarios (about 79,500 steps) played in both languages, and 140 runs
80
+ with several threads at once, no action was taken after the person's
81
+ cancellation was handed over, and the writer's view of its book always
82
+ equalled a whole check. Each fault is fixed and each is now a test
83
+ (`python/tests/test_review2.py`).
84
+ - **An Ed25519 signature whose first half (R) is a point of small
85
+ order is refused**, in both languages, whatever the key. The holder
86
+ of an honest key could make one for any message; Node.js refused it
87
+ and the OpenSSL beneath the Python version accepted it, so one
88
+ record had two answers (format description, section 3.8).
89
+ - A copy of what the stub writer is handed is refused if it nests more
90
+ than 64 levels deep, in both languages. Before, the JavaScript
91
+ library's limit depended on how much of the stack was in use. The
92
+ depth is measured on the copy, so the caller's value is still read
93
+ once.
94
+ - Python: a cancellation handed to `add` beside a member whose name is
95
+ a number, None or something stranger, or in a read-only mapping, is
96
+ read, and stops the writer, as in JavaScript. An error from the other
97
+ side that is not an `Exception` (such as `SystemExit`) leaves a
98
+ one-sided stub, as any other failure does. `snapshot()` hands back
99
+ the book and the cancellations the writer holds, read together, so
100
+ that another thread's call cannot fall between the two. A number too
101
+ large for a float is no time; options holding objects that cannot be
102
+ copied are kept as they are, as the JavaScript library keeps them;
103
+ the record writers take a datetime with a time zone, as the
104
+ JavaScript ones take a Date.
105
+ - 2026-10-08: faults found by an independent review of the Python
106
+ version, which also changed the JavaScript library.
107
+ - **Weak Ed25519 keys are refused.** Under one of the eight Ed25519
108
+ keys of small order, anyone can make a signature that checks.
109
+ Node.js refused such signatures; the OpenSSL beneath the Python
110
+ version accepted them, so with such a key trusted, a slip made by
111
+ someone holding no private key read as sound there. RFC 8032 does
112
+ not say to refuse these keys and libraries differ, so now the
113
+ checker refuses them itself, with any key written in more than one
114
+ way (y not below p): `bad-key` for a key in a record,
115
+ `stamp-invalid` for a time-stamp service's key.
116
+ - **The page address in a slip follows an exact rule.** The checker
117
+ used the URL Standard's own reader of addresses, which for a name
118
+ beginning `xn--` rests on tables of Unicode that differ from one
119
+ version of Node.js to the next, so two checkers could disagree. Now
120
+ `issuer.origin` must be written as a browser writes it, by a rule
121
+ that needs no such tables: `https://` (or `http://localhost`), a
122
+ host of lower-case letters, digits and hyphens in labels, a port
123
+ only where it is not the scheme's own, and nothing more (format
124
+ description, section 4.1). Some addresses the URL Standard allows
125
+ are now refused: underscores and other signs in a host, port 0,
126
+ addresses of numbers.
127
+ - A time-stamp service's key of another kind, or on another curve, is
128
+ refused in words that say so, not as a signature that "does not
129
+ fit".
130
+ - A time is read only from a number of milliseconds or a Date. Text,
131
+ true or a list were read by rules that differ from one device to
132
+ another (text with no time zone was taken as local time).
133
+ - `withoutMethods` that is not a list is set aside when a slip is
134
+ checked, as everywhere else. Before, it made the slip's check fail.
135
+ - 2026-10-08: the Python version gains the stub writer beside an agent
136
+ (`open_recorder`) and the connector for an agent's tools
137
+ (`record_tools`, `arguments_fingerprint`, `NotTaken`). So it now does
138
+ everything the JavaScript library does.
139
+ - They are ported one to one and give the same answers: two new
140
+ shared files, `test-vectors/recorder-scenarios.json` (32 scenarios,
141
+ about 1,000 steps) and `test-vectors/tool-scenarios.json` (8
142
+ scenarios), each a world of records made beforehand and a list of
143
+ steps (actions, records, cancellations in every form the writer
144
+ takes, time-stamps, acknowledgements, a clock that fails, writers
145
+ opened again, tools called), with the transcript of what happened.
146
+ The two libraries play each scenario (`test/helpers/scenarios.mjs`
147
+ and `python/tests/scenarios.py`) and must write the same transcript,
148
+ down to the words of every message and how many times the clock was
149
+ read.
150
+ - In Python every call is an ordinary function. The writer's calls
151
+ wait for one another across threads. A call to the writer from
152
+ inside an action it is taking, or from the other side while it
153
+ countersigns, is refused at once, as in Node.js. The other side is
154
+ asked in a thread of its own and given 30 seconds, as before. A
155
+ function handed over that gives a coroutine is refused.
156
+ - `key_set_from_seeds` and `key_set_seeds`: an agent's keys kept
157
+ between runs as their two 32-byte seeds.
158
+ - The Python reading of base64url and the copy of a check's options
159
+ are faster; they give the same answers (a value shared many times in
160
+ the options is now copied once, as the JavaScript library copies it).
161
+ - `test/helpers/world.mjs` can be given the agent's and the service's
162
+ key sets, so that a scenario can hand the same keys to Python.
163
+ - 2026-10-08: two faults in the check of a time-stamp from a service,
164
+ found while writing the Python version.
165
+ - An RSA key with the public number 1 in a service's certificate was
166
+ accepted. Under it, anyone can make a signature that checks, so a
167
+ time-stamp could be made without the service's private key. It
168
+ counted only where the person checking had named that certificate as
169
+ trusted. An even public number was accepted too. Now the public
170
+ number must be odd, from 3 to 2^32 - 1 (`stamp-invalid`).
171
+ - The public key inside a certificate was read loosely: a compressed or
172
+ "hybrid" point, RSA numbers with needless or missing sign bytes,
173
+ bytes after the key, any parameters, and unused bits all loaded. Now
174
+ the key must be written exactly as its own standard sets out
175
+ (`stamp-bad-data`), as the rest of a time-stamp already must be, so
176
+ that two checkers cannot disagree about whether it can be read.
177
+ - Each is a shared test (`test-vectors/stamps-strict-keys.json`).
178
+ - 2026-10-08: shared test files, and the start of a Python version.
179
+ - `test-vectors/` holds records and values, each with the answer this
180
+ library gives: the canonical form, base64url, times, keys, a
181
+ passkey's signature, the envelope of a record, the content of a stub
182
+ and of a countersignature, page addresses and 170 slips, sound and
183
+ faulty. `tools/make-vectors.mjs` writes them;
184
+ `test/vectors.test.mjs` checks that this library still gives every
185
+ answer. Any other implementation of the format can be tested against
186
+ them.
187
+ - `python/` holds a Python version, built in stages against the same
188
+ files. It is not yet published. It now checks everything the
189
+ JavaScript library checks: slips, stubs, countersignatures, limits,
190
+ approvals, refusals and terms, passes, cancellations and
191
+ acknowledgements, vouching records, seals and their time-stamps,
192
+ block time-stamps, the tree and its proofs, Shows, the chain of
193
+ block headers, and the check before acting. It gives the same answer
194
+ in every one of the 63 shared files, and in 6,091 random books
195
+ compared on this computer. One difference is stated: "cryptography",
196
+ the one package it needs, has no SLH-DSA yet, so a seal's third
197
+ signature is reported as not checked on this device and never as a
198
+ pass. The stub writer and the connector for an agent's tools are
199
+ still to come.
200
+ - A slip whose page address cannot be read, and one whose address is
201
+ not in its plain form, are now refused with the same words. The code
202
+ (`bad-field`) is unchanged.
203
+ - 2026-10-07: the README links to the individual Internet-Draft
204
+ `draft-izmaylov-agent-permission-receipts`, which describes the core of
205
+ the format.
206
+
207
+ ## 0.1.0 (6 October 2026)
208
+
209
+ Draft version 0 of the format, published and not frozen.
6
210
 
7
211
  - 2026-10-05: `provared-headers` is marked as experimental, in the
8
212
  README and in what the command prints.
package/README.md CHANGED
@@ -7,10 +7,15 @@ keys. People decide what it means.
7
7
 
8
8
  ## Status
9
9
 
10
- **Version 0.1.0, a draft.** Published as it is, by one author in their
10
+ **Version 0.2.0, a draft.** Published as it is, by one author in their
11
11
  own time; fixes as time allows. The format may still change, so do not
12
12
  rely on a draft record staying checkable.
13
13
 
14
+ The core of the format is also written as an individual Internet-Draft
15
+ at the IETF:
16
+ [draft-izmaylov-agent-permission-receipts](https://datatracker.ietf.org/doc/draft-izmaylov-agent-permission-receipts/).
17
+ An Internet-Draft is a working document, not a standard.
18
+
14
19
  ## The four parts
15
20
 
16
21
  | Part | What it is | In this draft |
@@ -32,12 +37,15 @@ rely on a draft record staying checkable.
32
37
  | `page/` | The checking page and the demonstration page |
33
38
  | `demo/` | The demonstration: an invented office agent works under a permission and goes outside it four times |
34
39
  | `samples/` | A sample record, written once, that the tests keep checking |
35
- | `tools/` | Two small tools: one wrote the sample record, one writes the checking page as a single file |
40
+ | `tools/` | Three small tools: one wrote the sample record, one writes the checking page as a single file, one writes the shared test files |
36
41
  | `test/` | The tests |
42
+ | `test-vectors/` | Shared test files: records and values, each with the answer this library gives. Another implementation of the format can be tested against them |
43
+ | `python/` | The Python version, `pip install provared`, which gives the same answers. See its own [README](python/README.md), and its examples with three agent frameworks in `python/examples/` |
37
44
 
38
45
  The npm package holds `src/`, `bin/`, `net/`, `spec/`, `docs/`,
39
- `samples/` and the checking page. The tests, the demonstration and the tools are in the
40
- repository only.
46
+ `samples/` and the checking page. The tests, the shared test files, the
47
+ demonstration, the tools and the Python version are in the repository
48
+ only.
41
49
 
42
50
  ## Install
43
51
 
@@ -103,10 +111,10 @@ checker and the sample record in one file, copied in unchanged. Open it
103
111
  in a browser straight from the disk; it needs no server. The file allows
104
112
  itself no request of any kind: its content security policy lets nothing
105
113
  be loaded from anywhere. The tool prints the file's SHA-256 fingerprint,
106
- so that a copy can be compared with it. (Tried on 4 October 2026 in the
114
+ so that a copy can be compared with it. (Tried on 8 October 2026 in the
107
115
  three browser builds this repository is tested with.) The file for
108
- version 0.1.0 is attached to the release on GitHub. Its SHA-256
109
- fingerprint is `ca4a15f820c9c715907952c2e7577717ffca92d87bcf633cc9a92f6dd5242746`.
116
+ version 0.2.0 is attached to the release on GitHub. Its SHA-256
117
+ fingerprint is `7e142fa119458ce00c5fdd6e1cafc75cb3bb4672624157a0a0ef4624f92812ae`.
110
118
 
111
119
  **Run the demonstration in a browser.**
112
120
 
@@ -568,6 +568,18 @@ person checking gives.
568
568
  not fail, only the one certificate it names is tried, and an RSA key
569
569
  must have a small public number, so that a small file cannot cost much
570
570
  work.
571
+ - **A key under which anyone can sign.** An RSA key with the public
572
+ number 1 lets anyone make a signature that checks. Such a key, and one
573
+ with an even public number, is refused, even in a certificate the
574
+ person named as trusted. So is an Ed25519 key of small order, there and
575
+ in every record (a passkey, an agent, a service, a recorder, an
576
+ organisation): libraries differ in whether they accept signatures under
577
+ such a key, so the checker refuses the key itself. For the same reason
578
+ it refuses an Ed25519 signature whose point R is of small order, under
579
+ any key: the key's own holder could make one for any message, which one
580
+ library accepts and another refuses. The key in a
581
+ certificate must be written exactly as its standard sets out, so that
582
+ two checkers cannot disagree about whether it can be read.
571
583
 
572
584
  **Knowing that a block is part of the chain.** The person checking can
573
585
  name the blocks they trust, or hand the checker a chain of block headers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "provared",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Proof of what an AI agent was allowed to do, and what it then did. Evidence, not a verdict.",
5
5
  "author": "Pavel Izmaylov",
6
6
  "license": "Apache-2.0 AND CC-BY-4.0",
@@ -197,6 +197,15 @@ one of these shapes, with no other member:
197
197
  | Issuer's passkey | `{"alg":"RS256","e":"AQAB","kty":"RSA","n":"…"}` (RFC 7518, section 6.3; `n` is 2,048 to 8,192 bits) |
198
198
  | Issuer's passkey | `{"alg":"Ed25519","crv":"Ed25519","kty":"OKP","x":"…"}` |
199
199
 
200
+ An Ed25519 key, wherever it stands, MUST NOT be one of the eight points
201
+ of small order, under which anyone can make a signature that checks, and
202
+ the number y it holds MUST be less than p = 2^255 - 19, so that it is
203
+ written in only one way. RFC 8032 does not say to refuse such keys, and
204
+ libraries differ in whether they do; a checker refuses them itself
205
+ (`bad-key`), so that two checkers cannot disagree. For the same reason,
206
+ an Ed25519 signature whose first 32 bytes (the point R) are such a point,
207
+ or hold a y not less than p, is invalid, whatever the key.
208
+
200
209
  A key is named for people by its **thumbprint**: the JSON Web Key
201
210
  Thumbprint with SHA-256 (RFC 7638; for ML-DSA keys, RFC 9964 section 6).
202
211
 
@@ -262,7 +271,15 @@ is the address of the page the slip was signed on, for example
262
271
  `https://sign.example.org`. The host in `origin` MUST be `rpId` or end
263
272
  with a full stop and `rpId`. `origin` MUST use `https`, or be
264
273
  `http://localhost` with or without a port number. It holds nothing after
265
- the host and the port.
274
+ the host and the port. It is written exactly as a browser writes a
275
+ page's address, so that a checker needs no reader of web addresses and
276
+ no tables of Unicode: the scheme, `://`, the host, and a port only where
277
+ it is not the scheme's own (443 for `https`, 80 for `http`), written in
278
+ digits with no leading zero, from 1 to 65535. The host is one or more
279
+ labels, separated by full stops, each of 1 to 63 lower-case letters,
280
+ digits and hyphens, neither beginning nor ending with a hyphen; the last
281
+ label is not all digits. A name in another script stands in its `xn--`
282
+ form, which the rule takes as it is.
266
283
 
267
284
  A **name** is text of 1 to 200 characters. It is a label chosen by
268
285
  whoever wrote the slip. It is not checked against anything, unless a
@@ -1002,7 +1019,13 @@ otherwise report a problem:
1002
1019
  does not otherwise use: a whole number or an object identifier that is
1003
1020
  not in its shortest form, a part that does not fill what holds it, or a
1004
1021
  part the standards do not allow in that place, is refused. The signed
1005
- data is of version 3, with exactly one signer.
1022
+ data is of version 3, with exactly one signer. The public key in the
1023
+ certificate that signed is written exactly as its own standard sets
1024
+ out, with no unused bits: RSA as RFC 3279, section 2.3.1 (the
1025
+ parameters present and NULL, two positive whole numbers in strict DER
1026
+ and nothing after them), ECDSA as RFC 5480, section 2.2 (an
1027
+ uncompressed point), Ed25519 as RFC 8410, section 4 (no parameters,
1028
+ 32 bytes).
1006
1029
  2. What it stamps is this seal's fingerprint (`stamp-wrong-data`).
1007
1030
  3. Its signed attributes name a time-stamp statement, hold the
1008
1031
  fingerprint of the statement, and name the certificate that signed, by
@@ -1015,8 +1038,14 @@ otherwise report a problem:
1015
1038
  signer names (RFC 5652, section 5.4): SHA-256, SHA-384 or SHA-512. A
1016
1039
  signing method that names another one is refused (`stamp-invalid`).
1017
1040
  The signing methods are RSA (PKCS #1 version 1.5; keys of 2,048 to
1018
- 8,192 bits, with a public number of at most 32 bits), ECDSA on the
1019
- curves P-256 and P-384, and Ed25519.
1041
+ 8,192 bits, with an odd public number from 3 to 2^32 - 1: never 1,
1042
+ under which anyone can make a signature that checks), ECDSA on the
1043
+ curves P-256 and P-384, and Ed25519 (not a key of small order, as
1044
+ section 3.8 sets out). A key of any other kind, or on another curve,
1045
+ is refused (`stamp-invalid`). The checker does not try to find out
1046
+ whether an RSA modulus can be factored: a service's key is the
1047
+ service's business, and a checker trusts a service only by the
1048
+ fingerprint of its certificate.
1020
1049
  5. The time it states lies within the time that certificate is in force
1021
1050
  (`stamp-invalid`).
1022
1051
  6. It does not say that its time is exact to no better than more than 300
package/src/compare.js CHANGED
@@ -63,10 +63,13 @@ function periodWords(seconds) {
63
63
 
64
64
  const allowedIn = (limit) => BigInt(Object.hasOwn(limit, 'count') ? limit.count : limit.max);
65
65
 
66
- function overPeriod(limit, total, W) {
66
+ // "proposed": an action that is only proposed (a check before acting), of
67
+ // which no stub exists yet.
68
+ function overPeriod(limit, total, W, proposed = false) {
69
+ const [With, are, is] = proposed ? ['With this action', 'there would be', 'would be'] : ['With this stub', 'there are', 'is'];
67
70
  const message = Object.hasOwn(limit, 'count')
68
- ? `With this stub there are ${total} such actions in ${periodWords(limit.per)}. ${W} allows ${limit.count}.`
69
- : `With this stub the total in ${periodWords(limit.per)} is ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}.`;
71
+ ? `${With} ${are} ${total} such actions in ${periodWords(limit.per)}. ${W} allows ${limit.count}.`
72
+ : `${With} the total in ${periodWords(limit.per)} ${is} ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}.`;
70
73
  return { code: 'over-period-limit', message };
71
74
  }
72
75
 
@@ -175,7 +178,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
175
178
  return;
176
179
  }
177
180
  const worst = periodWith(items, when, limit.per * 1000, value);
178
- if (worst > allowedIn(limit)) breaches.push(overPeriod(limit, worst, W));
181
+ if (worst > allowedIn(limit)) breaches.push(overPeriod(limit, worst, W, true));
179
182
  };
180
183
 
181
184
  if (!s.actions.includes(c.action)) {
@@ -209,7 +212,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
209
212
  const amountIn = (unit) => {
210
213
  if (c.amount && c.amount.unit === unit) return true;
211
214
  if (!missing) {
212
- breaches.push({ code: 'amount-missing', message: `${W} sets a limit or a condition in "${unit}" for this action, and the stub gives no amount in that unit.` });
215
+ breaches.push({ code: 'amount-missing', message: `${W} sets a limit or a condition in "${unit}" for this action, and the ${commit ? 'stub' : 'action'} gives no amount in that unit.` });
213
216
  missing = true;
214
217
  }
215
218
  return false;
@@ -225,7 +228,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
225
228
  const n = (tally.counts.get(i) ?? 0) + 1;
226
229
  if (commit) tally.counts.set(i, n);
227
230
  if (n > limit.count) {
228
- breaches.push({ code: 'over-count-limit', message: `This is action number ${n} of this kind under ${w}. ${W} allows ${limit.count}.` });
231
+ breaches.push({ code: 'over-count-limit', message: `${commit ? 'This is' : 'This would be'} action number ${n} of this kind under ${w}. ${W} allows ${limit.count}.` });
229
232
  }
230
233
  } else {
231
234
  inPeriod(i, limit, 1n);
@@ -246,7 +249,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
246
249
  if (total > BigInt(limit.max)) {
247
250
  breaches.push({
248
251
  code: 'over-limit',
249
- message: `With this stub the total is ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}: over by ${total - BigInt(limit.max)}.`,
252
+ message: `${commit ? 'With this stub the total is' : 'With this action the total would be'} ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}: over by ${total - BigInt(limit.max)}.`,
250
253
  });
251
254
  }
252
255
  } else if (value > BigInt(limit.max)) {
package/src/encoding.js CHANGED
@@ -275,8 +275,16 @@ export function parseTime(text) {
275
275
  return formatTime(ms) === text ? ms : NaN;
276
276
  }
277
277
 
278
- /** @param {number|Date} when @returns {string} */
278
+ /**
279
+ * A time, written in the one accepted form. Only a number of milliseconds
280
+ * or a Date is read: text, true or a list would be read by rules that
281
+ * differ from one device to another (text with no time zone is taken as
282
+ * local time), so they are refused.
283
+ * @param {number|Date} when
284
+ * @returns {string}
285
+ */
279
286
  export function formatTime(when) {
287
+ if (typeof when !== 'number' && !(when instanceof Date)) throw new RangeError('Invalid time value');
280
288
  return new Date(when).toISOString().slice(0, 19) + 'Z';
281
289
  }
282
290
 
package/src/keys.js CHANGED
@@ -25,6 +25,27 @@ const SHAPES = {
25
25
  RS256: ['alg', 'e', 'kty', 'n'],
26
26
  };
27
27
 
28
+ // The Ed25519 public keys under which anyone can make a signature that
29
+ // checks: the eight points of small order. RFC 8032 does not say to refuse
30
+ // them, and libraries differ, so a checker refuses them itself, with any
31
+ // key whose number y is not below p (written in more than one way), so
32
+ // that two checkers cannot disagree.
33
+ const P = 2n ** 255n - 19n;
34
+ const ORDER_8_Y = 0x7a03ac9277fdc74ec6cc392cfa53202a0f67100d760b3cba4fd84d3d706a17c7n;
35
+ const SMALL_ORDER_Y = new Set([0n, 1n, P - 1n, ORDER_8_Y, P - ORDER_8_Y]);
36
+
37
+ /**
38
+ * Whether 32 bytes are an Ed25519 public key under which anyone can sign,
39
+ * or one written in more than one way.
40
+ * @param {Uint8Array} bytes
41
+ * @returns {boolean}
42
+ */
43
+ export function weakEd25519(bytes) {
44
+ let y = 0n;
45
+ for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(i === 31 ? bytes[i] & 0x7f : bytes[i]);
46
+ return y >= P || SMALL_ORDER_Y.has(y);
47
+ }
48
+
28
49
  function bad(where, why) {
29
50
  return new Refusal('bad-key', `${where}: ${why}`);
30
51
  }
@@ -60,7 +81,9 @@ export function checkKey(jwk, allowed, where) {
60
81
  }
61
82
  if (alg === 'Ed25519') {
62
83
  if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') throw bad(where, 'an Ed25519 key has kty OKP and crv Ed25519.');
63
- if (bytesOf(jwk, 'x', where).length !== 32) throw bad(where, 'an Ed25519 public key is 32 bytes.');
84
+ const x = bytesOf(jwk, 'x', where);
85
+ if (x.length !== 32) throw bad(where, 'an Ed25519 public key is 32 bytes.');
86
+ if (weakEd25519(x)) throw bad(where, 'this Ed25519 public key is one under which anyone can sign, and is not accepted.');
64
87
  } else if (alg === 'ML-DSA-87') {
65
88
  if (jwk.kty !== 'AKP') throw bad(where, 'an ML-DSA-87 key has kty AKP.');
66
89
  // FIPS 204, table 2: an ML-DSA-87 public key is 2,592 bytes.
package/src/recorder.js CHANGED
@@ -68,10 +68,38 @@ function within(promise, ms) {
68
68
 
69
69
  const notWritten = (why) => new Refusal('record-not-sound', why);
70
70
 
71
+ // A time handed in: a number of milliseconds, or a Date. Text, true or a
72
+ // 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.
74
+ function timeOf(when) {
75
+ if (typeof when === 'number') return new Date(when).getTime();
76
+ if (when instanceof Date) return when.getTime();
77
+ throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
78
+ }
79
+
80
+ // A copy that nothing else holds, as structuredClone makes one, refused if
81
+ // it nests more than MAX_COPY_DEPTH levels deep: far more than a record
82
+ // holds. structuredClone's own limit depends on how much of the stack is in
83
+ // use, so without this two runs could give two answers. The depth is
84
+ // measured on the copy, so the caller's value is read once.
85
+ function clone(value) {
86
+ const copy = structuredClone(value);
87
+ const seen = new Set();
88
+ const stack = [[copy, 0]];
89
+ while (stack.length) {
90
+ const [v, depth] = stack.pop();
91
+ if (v === null || typeof v !== 'object' || seen.has(v)) continue;
92
+ seen.add(v);
93
+ if (depth > MAX_COPY_DEPTH) throw new TypeError('the value nests too deeply');
94
+ for (const item of Object.values(v)) stack.push([item, depth + 1]);
95
+ }
96
+ return copy;
97
+ }
98
+
71
99
  // A copy that nothing else holds.
72
100
  function plain(value, what) {
73
101
  try {
74
- return structuredClone(value);
102
+ return clone(value);
75
103
  } catch {
76
104
  throw new Refusal('bad-field', `${what} must be plain data: text, numbers, lists and objects.`);
77
105
  }
@@ -373,7 +401,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
373
401
  if (more === null || typeof more !== 'object') more = {};
374
402
  const call = {
375
403
  request: plain(request, 'The request'),
376
- when: more.when === undefined || more.when === null ? undefined : new Date(more.when).getTime(),
404
+ when: more.when === undefined || more.when === null ? undefined : timeOf(more.when),
377
405
  terms: more.terms,
378
406
  countersign: more.countersign,
379
407
  hasApproval: more.approval !== undefined && more.approval !== null,
@@ -381,7 +409,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
381
409
  };
382
410
  if (call.hasApproval) {
383
411
  try {
384
- call.approval = structuredClone(more.approval.record);
412
+ call.approval = clone(more.approval.record);
385
413
  } catch {
386
414
  call.approval = undefined; // not a record: refused when the stub is written
387
415
  }
@@ -479,7 +507,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
479
507
  // What it hands back is copied at once. The copy is what is checked, and what joins the stub.
480
508
  let kept;
481
509
  try {
482
- kept = structuredClone(given);
510
+ kept = clone(given);
483
511
  } catch {
484
512
  throw new Refusal('countersignature-invalid', 'What the other side handed back is not a signed record.');
485
513
  }
package/src/signatures.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // this file still loads in a browser. In a browser the method is reported as
14
14
  // not built in, which is never a pass.
15
15
 
16
- import { importPublicKey } from './keys.js';
16
+ import { importPublicKey, weakEd25519 } from './keys.js';
17
17
 
18
18
  const SLH = 'SLH-DSA-SHA2-256s';
19
19
  const nodeCrypto = globalThis.process && typeof globalThis.process.getBuiltinModule === 'function' ? globalThis.process.getBuiltinModule('node:crypto') : null;
@@ -91,6 +91,10 @@ export async function verifySignature(jwk, signature, data, without = []) {
91
91
  if (!Object.hasOwn(VERIFY_PARAMS, jwk.alg)) return 'invalid';
92
92
  const params = VERIFY_PARAMS[jwk.alg];
93
93
  if (without.includes(jwk.alg) || !(await methodAvailable(jwk.alg))) return 'unavailable';
94
+ // An Ed25519 signature whose first half is a point of small order is
95
+ // refused here, whatever the device's own library would say: libraries
96
+ // differ on it (format description, section 3.8).
97
+ if (jwk.alg === 'Ed25519' && (signature.length !== 64 || weakEd25519(signature.subarray(0, 32)))) return 'invalid';
94
98
  try {
95
99
  const key = await importPublicKey(jwk);
96
100
  return (await globalThis.crypto.subtle.verify(params, key, signature, data)) ? 'valid' : 'invalid';
package/src/slip.js CHANGED
@@ -14,28 +14,40 @@ const LIMIT_KINDS = ['count', 'each', 'max'];
14
14
  /** The longest period a limit may name: 366 days. */
15
15
  const MAX_PERIOD_SECONDS = 366 * 86400;
16
16
 
17
+ // The address of the page a slip was signed on, by an exact rule that any
18
+ // checker can follow without a reader of web addresses or tables of
19
+ // Unicode: "https://" (or "http://" for localhost), a host, a port only if
20
+ // it is not the scheme's own, and nothing more. A host is labels of
21
+ // lower-case letters, digits and hyphens, separated by full stops; no label
22
+ // begins or ends with a hyphen, and the last is not all digits. A browser
23
+ // writes a page's address in exactly this form, with a name in another
24
+ // script written in its "xn--" form, which the rule takes as it is.
25
+ const ORIGIN = /^(https|http):\/\/([a-z0-9.-]{1,253})(?::([1-9][0-9]{0,4}))?$/;
26
+ const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
27
+
17
28
  function checkOrigin(origin, rpId) {
18
29
  f.text(origin, 1, 300, 'issuer.origin');
19
30
  if (typeof rpId !== 'string' || !/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/.test(rpId)) {
20
31
  throw f.fail('issuer.rpId', 'must be a website name in lower case, such as example.org.');
21
32
  }
22
- let url;
23
- try {
24
- url = new URL(origin);
25
- } catch {
26
- throw f.fail('issuer.origin', 'must be the address of a page, such as https://sign.example.org.');
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)))) {
37
+ 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.');
27
38
  }
28
- // url.origin has no path and no default port, so this also refuses
29
- // anything after the host.
30
- if (url.origin !== origin) throw f.fail('issuer.origin', 'must be a bare address with no path, such as https://sign.example.org.');
31
- if (!(url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))) {
39
+ const [, scheme, host] = m;
40
+ if (!(scheme === 'https' || (scheme === 'http' && host === 'localhost'))) {
32
41
  throw f.fail('issuer.origin', 'must begin https://, or http://localhost.');
33
42
  }
34
- if (!(url.hostname === rpId || url.hostname.endsWith('.' + rpId))) {
43
+ if (!(host === rpId || host.endsWith('.' + rpId))) {
35
44
  throw f.fail('issuer.origin', 'must be on the website that issuer.rpId names.');
36
45
  }
37
46
  }
38
47
 
48
+ /** Not part of the public interface: for the shared test files. */
49
+ export const checkOriginForTests = checkOrigin;
50
+
39
51
  /**
40
52
  * Confirm a list of limits against the actions it may name (format
41
53
  * description, section 4.1). A slip and a pass share it.
@@ -324,7 +336,8 @@ export async function checkSlip(record, options = {}, disclosures) {
324
336
  result.issuerKey = await thumbprint(issuer.key);
325
337
 
326
338
  result.signature.state = 'invalid';
327
- result.signature.state = await checkPasskeySignature(parsed, issuer, options.withoutMethods);
339
+ // As everywhere else, only a list counts as a list of methods to treat as not built in.
340
+ result.signature.state = await checkPasskeySignature(parsed, issuer, Array.isArray(options.withoutMethods) ? options.withoutMethods : []);
328
341
  if (result.signature.state === 'unavailable') {
329
342
  result.notes.push(`This device cannot check the passkey's signing method (${issuer.key.alg}).`);
330
343
  }
package/src/timestamp.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { TAG, badStamp, children, contentOf, ecdsaToRaw, expect, hexOf, readElement, timeOf, validateDer, wholeOf } from './der.js';
13
13
  import { Refusal, equalBytes, formatTime, sha256, toBase64url } from './encoding.js';
14
+ import { weakEd25519 } from './keys.js';
14
15
 
15
16
  /** The largest time-stamp, in bytes. */
16
17
  export const MAX_STAMP_BYTES = 12288;
@@ -107,21 +108,53 @@ function readStatement(bytes) {
107
108
  return { hash, stamped, time, accuracySeconds, critical };
108
109
  }
109
110
 
110
- // The size of an RSA key in a certificate: 2,048 to 8,192 bits, with a
111
- // small public number, so that checking a signature cannot be made slow.
112
- function checkRsaKey(bytes, keyBits) {
113
- const inner = bytes.subarray(keyBits.start + 1, keyBits.end);
114
- const key = expect(readElement(inner, 0), TAG.SEQUENCE, 'an RSA key');
115
- const [n, e] = children(inner, key, 2);
116
- expect(n, TAG.INTEGER, 'an RSA key');
117
- expect(e, TAG.INTEGER, 'an RSA key');
111
+ // The public key of a certificate, written exactly as its standard sets
112
+ // out, so that two checkers cannot disagree about whether it can be read:
113
+ // RSA as RFC 3279 section 2.3.1 sets out (the method's parameters empty,
114
+ // the key two positive whole numbers in strict DER, nothing after them);
115
+ // ECDSA as RFC 5480 section 2.2 (an uncompressed point of the named
116
+ // curve); Ed25519 as RFC 8410 section 4 (no parameters, 32 bytes). In each,
117
+ // the string of bits has no unused bits.
118
+ //
119
+ // An RSA key is 2,048 to 8,192 bits, with an odd public number from 3 to
120
+ // 2^32 - 1: a small number, so that checking a signature cannot be made
121
+ // slow, and never 1, under which anyone can make a signature that checks.
122
+ function checkKeyEncoding(bytes, certificate, method, size) {
123
+ const why = 'the public key of a certificate is not written as its standard sets out.';
124
+ const bits = certificate.keyBits;
125
+ if (bits.end - bits.start < 1 || bytes[bits.start] !== 0) throw badStamp(why);
126
+ const inner = bytes.subarray(bits.start + 1, bits.end);
127
+ const parameters = certificate.keyParameters;
128
+ if (method === 'ECDSA') {
129
+ if (inner.length !== 1 + 2 * size || inner[0] !== 0x04) throw badStamp(why);
130
+ return;
131
+ }
132
+ if (method === 'Ed25519') {
133
+ if (parameters !== null || inner.length !== 32) throw badStamp(why);
134
+ if (weakEd25519(inner)) throw invalid('A time-stamp was signed with an Ed25519 key under which anyone can sign, which is not accepted.');
135
+ return;
136
+ }
137
+ if (parameters === null || parameters.tag !== TAG.NULL) throw badStamp(why);
138
+ let n;
139
+ let e;
140
+ try {
141
+ const key = readElement(inner, 0);
142
+ if (key.tag !== TAG.SEQUENCE || key.end !== inner.length) throw new Error();
143
+ validateDer(inner, key);
144
+ const parts = children(inner, key, 3);
145
+ [n, e] = parts;
146
+ if (parts.length !== 2 || n.tag !== TAG.INTEGER || e.tag !== TAG.INTEGER || inner[n.start] & 0x80 || inner[e.start] & 0x80) throw new Error();
147
+ } catch {
148
+ throw badStamp(why);
149
+ }
118
150
  let modulus = contentOf(inner, n);
119
151
  if (modulus[0] === 0) modulus = modulus.subarray(1);
120
- const bits = modulus.length === 0 ? 0 : (modulus.length - 1) * 8 + (32 - Math.clz32(modulus[0]));
152
+ const length = modulus.length === 0 ? 0 : (modulus.length - 1) * 8 + (32 - Math.clz32(modulus[0]));
121
153
  let exponent = contentOf(inner, e);
122
154
  if (exponent[0] === 0) exponent = exponent.subarray(1);
123
- if (bits < 2048 || bits > 8192 || exponent.length === 0 || exponent.length > 4) {
124
- throw invalid('A time-stamp was signed with an RSA key of a size that is not accepted.');
155
+ const odd = exponent.length > 0 && (exponent[exponent.length - 1] & 1) === 1;
156
+ if (length < 2048 || length > 8192 || exponent.length === 0 || exponent.length > 4 || !odd || (exponent.length === 1 && exponent[0] < 3)) {
157
+ throw invalid('A time-stamp was signed with an RSA key whose size or public number is not accepted.');
125
158
  }
126
159
  }
127
160
 
@@ -148,6 +181,7 @@ function readCertificate(bytes, element) {
148
181
  notAfter: timeOf(bytes, validity[1]),
149
182
  spki: wholeOf(bytes, keyInfo),
150
183
  keyOid: keyAlgorithm.oid,
184
+ keyParameters: keyAlgorithm.parameters ?? null,
151
185
  keyBits: expect(keyParts[1], TAG.BIT_STRING, 'the public key of a certificate'),
152
186
  curveOid: keyAlgorithm.parameters && keyAlgorithm.parameters.tag === TAG.OID ? hexOf(bytes, keyAlgorithm.parameters) : null,
153
187
  };
@@ -169,11 +203,14 @@ function signingCertificateOf(bytes, value, v2) {
169
203
  return { hash, value: contentOf(bytes, expect(first[at], TAG.OCTET_STRING, 'the signing certificate attribute')) };
170
204
  }
171
205
 
206
+ 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.';
207
+
172
208
  // Check the service's signature with the public key of its certificate.
173
209
  // Returns "valid", "invalid", or "unavailable" where this device has no
174
210
  // built-in support for the method. The fingerprint method is the one the
175
211
  // signer names (RFC 5652 section 5.4); a signing method that names another
176
- // is refused.
212
+ // is refused; a key of a kind that is not accepted is refused in words that
213
+ // say so.
177
214
  async function verifyWith(certificate, bytes, signatureOid, digestName, signature, signed, without) {
178
215
  const subtle = globalThis.crypto.subtle;
179
216
  let importParams;
@@ -182,13 +219,15 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
182
219
  let raw = signature;
183
220
  if (certificate.keyOid === OID.rsaEncryption) {
184
221
  if (signatureOid !== OID.rsaEncryption && RSA_WITH[signatureOid] !== digestName) return 'invalid';
185
- checkRsaKey(bytes, certificate.keyBits);
222
+ checkKeyEncoding(bytes, certificate, 'RSA');
186
223
  method = 'RSA';
187
224
  importParams = { name: 'RSASSA-PKCS1-v1_5', hash: digestName };
188
225
  verifyParams = { name: 'RSASSA-PKCS1-v1_5' };
189
226
  } else if (certificate.keyOid === OID.ecPublicKey) {
190
227
  const curve = certificate.curveOid === OID.p256 ? ['P-256', 32] : certificate.curveOid === OID.p384 ? ['P-384', 48] : null;
191
- if (ECDSA_WITH[signatureOid] !== digestName || !curve) return 'invalid';
228
+ if (!curve) throw invalid(KIND_NOT_ACCEPTED);
229
+ if (ECDSA_WITH[signatureOid] !== digestName) return 'invalid';
230
+ checkKeyEncoding(bytes, certificate, 'ECDSA', curve[1]);
192
231
  method = 'ECDSA';
193
232
  importParams = { name: 'ECDSA', namedCurve: curve[0] };
194
233
  verifyParams = { name: 'ECDSA', hash: digestName };
@@ -197,12 +236,14 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
197
236
  } catch {
198
237
  return 'invalid';
199
238
  }
200
- } else if (certificate.keyOid === OID.ed25519 && signatureOid === OID.ed25519) {
239
+ } else if (certificate.keyOid === OID.ed25519) {
240
+ if (signatureOid !== OID.ed25519) return 'invalid';
241
+ checkKeyEncoding(bytes, certificate, 'Ed25519');
201
242
  method = 'Ed25519';
202
243
  importParams = { name: 'Ed25519' };
203
244
  verifyParams = { name: 'Ed25519' };
204
245
  } else {
205
- return 'invalid';
246
+ throw invalid(KIND_NOT_ACCEPTED);
206
247
  }
207
248
  if (without.includes(method)) return 'unavailable';
208
249
  let key;
@@ -212,6 +253,8 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
212
253
  // A device with no Ed25519 says so when the key is loaded.
213
254
  return method === 'Ed25519' && e && e.name === 'NotSupportedError' ? 'unavailable' : 'invalid';
214
255
  }
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';
215
258
  try {
216
259
  return (await subtle.verify(verifyParams, key, raw, signed)) ? 'valid' : 'invalid';
217
260
  } catch {
package/src/tools.js CHANGED
@@ -18,6 +18,11 @@ import * as f from './fields.js';
18
18
  /** How deep the arguments of a call may nest. */
19
19
  const MAX_ARGUMENT_DEPTH = 64;
20
20
 
21
+ // Calling a generator function does not run its body, so its stub would be
22
+ // written for an action not yet taken.
23
+ const GENERATORS = new Set(['[object GeneratorFunction]', '[object AsyncGeneratorFunction]']);
24
+ const NOT_A_GENERATOR = 'must not be a generator function: calling one does not run its body, so its stub would be written for an action not yet taken.';
25
+
21
26
  /**
22
27
  * A call through a recorded tool that was not run: the check before acting
23
28
  * did not allow it, or the record so far has a problem. "answer" is what
@@ -108,7 +113,7 @@ const needsOnlyApproval = (answer) => answer.problems.length === 0 && answer.bre
108
113
  * @param {object} recorder the stub writer (openRecorder)
109
114
  * @param {Record<string, object>} tools by name. For each tool:
110
115
  * - "action": the action name the slip uses for what this tool does;
111
- * - "run": the tool itself, a function of the arguments. It must not call the stub writer;
116
+ * - "run": the tool itself, a function of the arguments, not a generator function. It must not call the stub writer;
112
117
  * Each function is called by itself, without "this". "amount" and "with" must give their value at once, not a promise.
113
118
  * - "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;
114
119
  * - "amount" (optional): {unit, value}, or a function of the arguments that gives it;
@@ -143,6 +148,7 @@ export function recordTools(recorder, tools, options = {}) {
143
148
  // Each member is read once: what is checked is what is fixed.
144
149
  const fixed = { action: spec.action, run: spec.run, details: spec.details, countersign: spec.countersign, approve: spec.approve, amount: spec.amount, with: spec.with };
145
150
  if (typeof fixed.run !== 'function') throw f.fail(`tools.${name}.run`, 'must be a function.');
151
+ if (GENERATORS.has(Object.prototype.toString.call(fixed.run))) throw f.fail(`tools.${name}.run`, NOT_A_GENERATOR);
146
152
  f.actionName(fixed.action, `tools.${name}.action`);
147
153
  for (const member of ['countersign', 'approve']) {
148
154
  if (fixed[member] !== undefined && typeof fixed[member] !== 'function') throw f.fail(`tools.${name}.${member}`, 'must be a function.');