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 +206 -2
- package/README.md +15 -7
- package/docs/threat-model.md +12 -0
- package/package.json +1 -1
- package/spec/provared-format.md +33 -4
- package/src/compare.js +10 -7
- package/src/encoding.js +9 -1
- package/src/keys.js +24 -1
- package/src/recorder.js +32 -4
- package/src/signatures.js +5 -1
- package/src/slip.js +24 -11
- package/src/timestamp.js +59 -16
- package/src/tools.js +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,212 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.2.0 (8 October 2026)
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
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/` |
|
|
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
|
|
40
|
-
repository
|
|
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
|
|
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.
|
|
109
|
-
fingerprint is `
|
|
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
|
|
package/docs/threat-model.md
CHANGED
|
@@ -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
package/spec/provared-format.md
CHANGED
|
@@ -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
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
69
|
-
:
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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 (!(
|
|
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
|
-
|
|
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
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.');
|