@bakobo/fiki 0.7.0 → 0.8.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/README.md +13 -6
- package/package.json +1 -1
- package/src/base.js +168 -19
- package/src/index.js +3 -2
- package/src/messages.js +63 -11
- package/src/sfv.js +22 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ By default the signature binds the method, the host, the path, the query string,
|
|
|
35
35
|
```js
|
|
36
36
|
import { verifyRequest } from '@bakobo/fiki';
|
|
37
37
|
|
|
38
|
-
const { aid, covered } = await verifyRequest({
|
|
38
|
+
const { aid, keyid, covered } = await verifyRequest({
|
|
39
39
|
method: request.method,
|
|
40
40
|
url: request.url, // a full URL, or a path plus a Host header
|
|
41
41
|
headers: request.headers,
|
|
@@ -44,7 +44,15 @@ const { aid, covered } = await verifyRequest({
|
|
|
44
44
|
});
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`
|
|
47
|
+
`keyid` is the keyid exactly as it appeared on the wire, or `null` when the signature had none; `aid` is the identity that vouched for the key — the non-transferable AID of a raw key, the keyid a resolver vouched for, or the AID of `expectedAid`.
|
|
48
|
+
|
|
49
|
+
`maxAge` has no default and must be given. Both defaults would be wrong: a number guesses at somebody else's clock skew and replay window, and skipping the check silently is the thing the argument exists to prevent. An `expires` the signer declared is enforced either way. `maxAge` and `skew`, when given, are positive whole numbers of seconds.
|
|
50
|
+
|
|
51
|
+
## What is refused before it is read
|
|
52
|
+
|
|
53
|
+
`Signature`, `Signature-Input` and `Content-Digest` are each read only up to `MAX_FIELD_BYTES` (8192 bytes, measured before any trimming), `MAX_DICTIONARY_MEMBERS` (16) members, `MAX_INNER_LIST_ITEMS` (64) items in an inner list and `MAX_PARAMETERS` (16) parameters on an item; a header over any of them is that header's malformed error. All four are exported. RFC 8941 is parsed strictly: an integer of more than fifteen digits, a decimal, and a byte sequence that is not canonically padded base64 are refused. A URL whose port is not a number from 0 to 65535, or with anything but `:port` after an IP-literal's `]`, is a `SignatureMismatch` when a covered `@authority` needs it.
|
|
54
|
+
|
|
55
|
+
The signer refuses, as a `TypeError`, anything it would otherwise serialize into a header that does not belong there: a method that is not an HTTP token, a label that is not an RFC 8941 key, a `keyid`, `nonce` or `tag` outside printable ASCII, a component name that is not a field name, a `created` or `expires` outside 0 to 999999999999999, a header value that is not a string, and a supplied `Content-Digest` the body does not bear out.
|
|
48
56
|
|
|
49
57
|
## Keys in a browser
|
|
50
58
|
|
|
@@ -62,11 +70,10 @@ The safe shape is the default and the portable one is explicit, because the two
|
|
|
62
70
|
|
|
63
71
|
## Differences from the Python port
|
|
64
72
|
|
|
65
|
-
Everything is async. WebCrypto's `sign`, `verify`, `digest` and `importKey` all return promises, so `signRequest`, `signResponse`, `verifyRequest`, `verifyResponse` and the `Key` constructors do too, where the Python versions are synchronous. Names are otherwise the same in camelCase — `signatureBase`, `responseSignatureBase`, `verifyingKey`, `Key.fromSeed`, `key.aid`, `expectedKeyid` — so the two read as one library.
|
|
73
|
+
Everything is async. WebCrypto's `sign`, `verify`, `digest` and `importKey` all return promises, so `signRequest`, `signResponse`, `verifyRequest`, `verifyResponse` and the `Key` constructors do too, where the Python versions are synchronous. Names are otherwise the same in camelCase — `signatureBase`, `responseSignatureBase`, `verifyingKey`, `Key.fromSeed`, `key.aid`, `expectedKeyid` — so the two read as one library. Three more differences are deliberate (`this.i` @9enyfktu):
|
|
66
74
|
|
|
67
75
|
- A `resolve` function may return the key or a promise of it, and verification awaits either, because a KERI resolver usually reads key state from storage or a network.
|
|
68
76
|
- The request a response answers is a plain object, `{ method, url, headers, body }`, rather than an exported `Request` class.
|
|
69
|
-
- A mistake in the call rather than the message — `expectedAid` together with `resolve`, a `minimum` smaller than the profile's, a missing `maxAge`, a response binding the request's digest verified without the request body — is a `TypeError`. Python raises `TypeError` for some of these and `ValueError` for others; JavaScript has no `ValueError`. None of them is a `FikiError`.
|
|
70
|
-
- `KERI_VECTORS_FORMAT` is exported beside `VECTORS_FORMAT`, so the KERI profile's contract (`vectors/keri/`) can be read the same way as the shared one.
|
|
77
|
+
- A mistake in the call rather than the message — `expectedAid` together with `resolve`, a `minimum` smaller than the profile's, a missing or non-positive `maxAge`, a response binding the request's digest verified without the request body, and everything the signer refuses above — is a `TypeError`. Python raises `TypeError` for some of these and `ValueError` for others; JavaScript has no `ValueError`. None of them is a `FikiError`.
|
|
71
78
|
|
|
72
|
-
URLs are
|
|
79
|
+
URLs are cleaned as Python's `urlsplit` cleans them — leading control characters and spaces stripped, TAB, CR and LF removed anywhere — and then split as sent rather than parsed with `new URL`, which normalizes the path that RFC 9421 and the KERI profile sign unnormalized (`this.i` @90y0gsfx).
|
package/package.json
CHANGED
package/src/base.js
CHANGED
|
@@ -42,13 +42,40 @@ const DEFAULT_PORTS = new Map([
|
|
|
42
42
|
['wss', '443'],
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
+
// RFC 9110 section 5.6.2: a token is one or more tchar. A method is one (section 9.1), and so is a
|
|
46
|
+
// field name (section 5.1), which fiki further requires lowercased in a covered list.
|
|
47
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
48
|
+
// RFC 8941 section 3.3.3: an sf-string holds printable ASCII and nothing else.
|
|
49
|
+
const SF_STRING = /^[\x20-\x7e]*$/;
|
|
50
|
+
// RFC 8941 section 3.1.2: a dictionary key, which is what a signature label is.
|
|
51
|
+
const SF_KEY = /^[a-z*][a-z0-9_.*-]*$/;
|
|
52
|
+
// RFC 8941 section 3.3.1: at most fifteen digits.
|
|
53
|
+
const SF_INTEGER_MAX = 999_999_999_999_999;
|
|
54
|
+
|
|
55
|
+
// A caller's value in a message about it: quoted when it is a string, so a control character shows.
|
|
56
|
+
const shown = (value) => (typeof value === 'string' ? JSON.stringify(value) : String(value));
|
|
57
|
+
|
|
45
58
|
/** A component identifier from a caller's spelling of it.
|
|
46
59
|
*
|
|
47
60
|
* A plain name (`"@method"`, `"Content-Digest"`) or its RFC 8941 serialization with parameters
|
|
48
61
|
* (`'"@method";req'`). Names are lowercased as a convenience to a local caller; a name parsed from
|
|
49
|
-
* the wire is never lowercased, and is refused instead when it is not already.
|
|
62
|
+
* the wire is never lowercased, and is refused instead when it is not already. A field name that
|
|
63
|
+
* is not a token is the caller's mistake, a TypeError, because it would be serialized into
|
|
64
|
+
* Signature-Input as given (@5zrf8gjk); a derived name fiki does not build is refused later, as
|
|
65
|
+
* UnsupportedComponent, which names it.
|
|
50
66
|
*/
|
|
51
67
|
export function component(spec) {
|
|
68
|
+
const item = componentItem(spec);
|
|
69
|
+
if (!item.value.startsWith('@') && !TOKEN.test(item.value)) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
`${JSON.stringify(spec)} is not a component fiki can name: a field is named by an HTTP field ` +
|
|
72
|
+
'name, one or more token characters, and a derived component by its @ name.',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return item;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function componentItem(spec) {
|
|
52
79
|
if (!spec.startsWith('"')) return { value: spec.toLowerCase(), params: new Map() };
|
|
53
80
|
let item;
|
|
54
81
|
try {
|
|
@@ -122,30 +149,96 @@ export function checkCovered(items, { response }) {
|
|
|
122
149
|
* percent-encodes characters such as a space, so its pathname is not the path that was sent. The
|
|
123
150
|
* KERI profile requires @path "in its encoded form, percent-encoding included and unnormalized"
|
|
124
151
|
* (section 2), and so does RFC 9421 section 2.2.6, which is what fiki-py's urlsplit gives.
|
|
152
|
+
*
|
|
153
|
+
* Cleaned first exactly as urlsplit cleans it, so every port builds one base for the same URL
|
|
154
|
+
* (@0e832nug): leading C0 controls and spaces are stripped, and TAB, CR and LF are removed
|
|
155
|
+
* wherever they are. Trailing controls are kept, as urlsplit keeps them, and a covered component
|
|
156
|
+
* holding one is then refused like any other control character.
|
|
125
157
|
*/
|
|
126
158
|
export function splitUrl(url) {
|
|
127
|
-
const
|
|
159
|
+
const cleaned = url.replace(/^[\x00-\x20]+/, '').replace(/[\t\r\n]/g, '');
|
|
160
|
+
const match = /^(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?/.exec(cleaned);
|
|
128
161
|
const [, scheme = '', netloc, path, query = ''] = match;
|
|
129
162
|
return { scheme: scheme.toLowerCase(), netloc: netloc ?? '', path, query };
|
|
130
163
|
}
|
|
131
164
|
|
|
132
|
-
|
|
165
|
+
/** A URL fiki cannot read: the caller's mistake when signing, an unbuildable base when not.
|
|
166
|
+
*
|
|
167
|
+
* The profile's section 9 names a base that cannot be built a signature mismatch, so a received
|
|
168
|
+
* URL whose authority cannot be read is refused like any other base that does not verify, never
|
|
169
|
+
* thrown as an exception from outside fiki's taxonomy (@5zrf8gjk).
|
|
170
|
+
*/
|
|
171
|
+
function unreadable(message, reason) {
|
|
172
|
+
if (message.received) {
|
|
173
|
+
return new SignatureMismatch(
|
|
174
|
+
`The URL ${message.url} cannot be read: ${reason} So there is no signature base to check the ` +
|
|
175
|
+
'signature against.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return new TypeError(`The URL ${message.url} cannot be read: ${reason}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// RFC 3986 section 3.2.2's IP-literal, as Python's urlsplit checks it from 3.11.4, so a host fiki-py
|
|
182
|
+
// refuses is refused here too: IPvFuture ("v", hex digits, ".", then anything but a line feed), or
|
|
183
|
+
// an IPv6address with an optional zone after "%". The IPv6 grammar is RFC 3986's own, which accepts
|
|
184
|
+
// exactly what Python's ipaddress.IPv6Address does.
|
|
185
|
+
const H16 = '[0-9A-Fa-f]{1,4}';
|
|
186
|
+
const DEC_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])';
|
|
187
|
+
const LS32 = `(?:${H16}:${H16}|${DEC_OCTET}(?:\\.${DEC_OCTET}){3})`;
|
|
188
|
+
const IPV6 = [
|
|
189
|
+
`(?:${H16}:){6}${LS32}`,
|
|
190
|
+
`::(?:${H16}:){5}${LS32}`,
|
|
191
|
+
`(?:${H16})?::(?:${H16}:){4}${LS32}`,
|
|
192
|
+
`(?:(?:${H16}:){0,1}${H16})?::(?:${H16}:){3}${LS32}`,
|
|
193
|
+
`(?:(?:${H16}:){0,2}${H16})?::(?:${H16}:){2}${LS32}`,
|
|
194
|
+
`(?:(?:${H16}:){0,3}${H16})?::${H16}:${LS32}`,
|
|
195
|
+
`(?:(?:${H16}:){0,4}${H16})?::${LS32}`,
|
|
196
|
+
`(?:(?:${H16}:){0,5}${H16})?::${H16}`,
|
|
197
|
+
`(?:(?:${H16}:){0,6}${H16})?::`,
|
|
198
|
+
].join('|');
|
|
199
|
+
const IP_LITERAL = new RegExp(`^(?:v[0-9A-Fa-f]+\\.[^\\n]+|(?:${IPV6})(?:%[^%]+)?)$`);
|
|
200
|
+
|
|
201
|
+
/** Split an authority's host-and-port into the host as written and the port's text.
|
|
202
|
+
*
|
|
203
|
+
* An IP-literal keeps its brackets, which RFC 3986 section 3.2.2 makes part of the host, and only
|
|
204
|
+
* ":port" may follow its closing bracket. A bracket anywhere else is not a host.
|
|
205
|
+
*/
|
|
206
|
+
function hostAndPort(hostport, message) {
|
|
207
|
+
if (hostport.startsWith('[')) {
|
|
208
|
+
const close = hostport.indexOf(']');
|
|
209
|
+
const rest = close < 0 ? '' : hostport.slice(close + 1);
|
|
210
|
+
if (close < 0 || (rest !== '' && !rest.startsWith(':'))) {
|
|
211
|
+
throw unreadable(message, 'an IP-literal must close with "]", followed by nothing but ":" and a port.');
|
|
212
|
+
}
|
|
213
|
+
if (!IP_LITERAL.test(hostport.slice(1, close))) {
|
|
214
|
+
throw unreadable(message, 'its IP-literal is not an IPv6 address or IPvFuture.');
|
|
215
|
+
}
|
|
216
|
+
return [hostport.slice(0, close + 1), rest.slice(1)];
|
|
217
|
+
}
|
|
218
|
+
const colon = hostport.indexOf(':');
|
|
219
|
+
const [host, port] = colon < 0 ? [hostport, ''] : [hostport.slice(0, colon), hostport.slice(colon + 1)];
|
|
220
|
+
if (/[[\]]/.test(host)) throw unreadable(message, 'a bracket belongs only around an IP-literal.');
|
|
221
|
+
return [host, port];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function authority(message) {
|
|
133
225
|
// RFC 9421 section 2.2.3: lowercase host, default port omitted. A relative URL falls back to the
|
|
134
226
|
// Host header, which in HTTP/1.1 *is* the authority — the shape a server-side verifier actually
|
|
135
227
|
// holds. Nothing is normalized away there, because without a scheme no port is a default port.
|
|
228
|
+
const { parts, headers } = message;
|
|
136
229
|
if (parts.netloc) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
if (port === '') return host;
|
|
230
|
+
const [host, port] = hostAndPort(parts.netloc.slice(parts.netloc.lastIndexOf('@') + 1), message);
|
|
231
|
+
// An empty port is no port at all, as RFC 3986 section 6.2.3 normalizes it.
|
|
232
|
+
if (port === '') return host.toLowerCase();
|
|
140
233
|
// RFC 3986 section 3.2.3: port = *DIGIT, so any run of ASCII digits, leading zeros and all, and
|
|
141
234
|
// the value is the number: "000080" is 80 and is the default, as urlsplit reads it. The range
|
|
142
235
|
// is checked on the digits that remain, so a long run of zeros cannot hide an overflow.
|
|
143
236
|
const digits = /^[0-9]+$/.test(port) ? port.replace(/^0+(?=[0-9])/, '') : null;
|
|
144
237
|
if (digits === null || digits.length > 5 || Number(digits) > 65535) {
|
|
145
|
-
throw
|
|
238
|
+
throw unreadable(message, `its port "${port}" is not a number from 0 to 65535.`);
|
|
146
239
|
}
|
|
147
|
-
if (digits === DEFAULT_PORTS.get(parts.scheme)) return host;
|
|
148
|
-
return `${host}:${digits}`;
|
|
240
|
+
if (digits === DEFAULT_PORTS.get(parts.scheme)) return host.toLowerCase();
|
|
241
|
+
return `${host.toLowerCase()}:${digits}`;
|
|
149
242
|
}
|
|
150
243
|
const host = headers.get('host');
|
|
151
244
|
if (host === undefined) {
|
|
@@ -169,6 +262,11 @@ export function canonicalHeaders(headers, name = 'headers') {
|
|
|
169
262
|
// assignment to the prototype that silently drops it (PR #5 hostile review, H1).
|
|
170
263
|
const out = Object.create(null);
|
|
171
264
|
for (const [field, value] of Object.entries(headers ?? {})) {
|
|
265
|
+
// A name is always a string here, since an object's keys are; a value has to be checked
|
|
266
|
+
// (@5zrf8gjk), or null would be signed as the string "null".
|
|
267
|
+
if (typeof value !== 'string') {
|
|
268
|
+
throw new TypeError(`A header is a name and a string value; the value of "${field}" is ${String(value)}.`);
|
|
269
|
+
}
|
|
172
270
|
const lower = field.toLowerCase();
|
|
173
271
|
if (Object.hasOwn(out, lower)) {
|
|
174
272
|
throw new TypeError(
|
|
@@ -186,7 +284,7 @@ function lowered(headers) {
|
|
|
186
284
|
// are kept exactly as received here: they are checked for forbidden characters before any
|
|
187
285
|
// whitespace is trimmed, or a trailing CR LF would be trimmed into the value that was signed.
|
|
188
286
|
const map = new Map();
|
|
189
|
-
for (const [name, value] of Object.entries(canonicalHeaders(headers))) map.set(name,
|
|
287
|
+
for (const [name, value] of Object.entries(canonicalHeaders(headers))) map.set(name, value);
|
|
190
288
|
return map;
|
|
191
289
|
}
|
|
192
290
|
|
|
@@ -209,19 +307,31 @@ function checked(value, spec) {
|
|
|
209
307
|
return value;
|
|
210
308
|
}
|
|
211
309
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
310
|
+
/** A request's method is an RFC 9110 token, covered or not, or the call is a mistake.
|
|
311
|
+
*
|
|
312
|
+
* Its case is kept as given (@22g0xkr8). Checked wherever a request message is built, on sign and
|
|
313
|
+
* verify alike (@5zrf8gjk): an empty or spaced method is never a request anybody sent.
|
|
314
|
+
*/
|
|
315
|
+
function checkMethod(method) {
|
|
316
|
+
if (typeof method !== 'string' || !TOKEN.test(method)) {
|
|
317
|
+
throw new TypeError(
|
|
318
|
+
`The method ${shown(method)} is not an HTTP method: a method is a ` +
|
|
319
|
+
'string of one or more token characters, with no spaces, line breaks or separators.',
|
|
320
|
+
);
|
|
217
321
|
}
|
|
218
|
-
return { headers: lowered(headers), method, parts: splitUrl(url) };
|
|
219
322
|
}
|
|
220
323
|
|
|
221
|
-
export
|
|
324
|
+
export function requestMessage(method, url, headers, { received = false } = {}) {
|
|
325
|
+
checkMethod(method);
|
|
326
|
+
// `received` marks a message handed to a verifier rather than built by a signer, which decides
|
|
327
|
+
// what a URL that cannot be read is: a base that cannot be built, or a caller error.
|
|
328
|
+
return { headers: lowered(headers), method, url, parts: splitUrl(url), received };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export const responseMessage = (status, headers, request, { received = false } = {}) => ({
|
|
222
332
|
headers: lowered(headers),
|
|
223
333
|
status,
|
|
224
|
-
request: request ? requestMessage(request.method, request.url, request.headers) : null,
|
|
334
|
+
request: request ? requestMessage(request.method, request.url, request.headers, { received }) : null,
|
|
225
335
|
});
|
|
226
336
|
|
|
227
337
|
function componentValue(item, message) {
|
|
@@ -252,7 +362,7 @@ function componentValue(item, message) {
|
|
|
252
362
|
}
|
|
253
363
|
// Section 2.2.1: the method as sent, with no case transformation (@22g0xkr8).
|
|
254
364
|
if (name === '@method') return source.method;
|
|
255
|
-
if (name === '@authority') return authority(source
|
|
365
|
+
if (name === '@authority') return authority(source);
|
|
256
366
|
// An empty path is the "/" the origin server would have received.
|
|
257
367
|
if (name === '@path') return source.parts.path || '/';
|
|
258
368
|
// Section 2.2.7: the whole query string including the leading "?", percent-encoding preserved,
|
|
@@ -289,6 +399,43 @@ export function componentLines({ method, url, headers, covered }) {
|
|
|
289
399
|
return linesFor(items, requestMessage(method, url, headers));
|
|
290
400
|
}
|
|
291
401
|
|
|
402
|
+
/** What a signer serializes must be serializable, or the call is a mistake (@5zrf8gjk).
|
|
403
|
+
*
|
|
404
|
+
* created and expires are RFC 8941 integers that are not negative; keyid, alg, nonce and tag are
|
|
405
|
+
* sf-strings, printable ASCII only, so a line break can never forge a header line. An absent one
|
|
406
|
+
* (undefined or null) is not serialized and so not checked.
|
|
407
|
+
*/
|
|
408
|
+
export function checkSignerParams({ created, expires, keyid, alg, nonce, tag }) {
|
|
409
|
+
for (const [name, value] of [['created', created], ['expires', expires]]) {
|
|
410
|
+
if (value === undefined || value === null) continue;
|
|
411
|
+
if (!Number.isInteger(value) || value < 0 || value > SF_INTEGER_MAX) {
|
|
412
|
+
throw new TypeError(
|
|
413
|
+
`${name} is ${String(value)}, and RFC 8941 carries an integer of at most fifteen digits; fiki ` +
|
|
414
|
+
`signs a whole number of seconds from 0 to ${SF_INTEGER_MAX}.`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const [name, value] of [['keyid', keyid], ['alg', alg], ['nonce', nonce], ['tag', tag]]) {
|
|
419
|
+
if (value === undefined || value === null) continue;
|
|
420
|
+
if (typeof value !== 'string' || !SF_STRING.test(value)) {
|
|
421
|
+
throw new TypeError(
|
|
422
|
+
`The ${name} ${shown(value)} is not a string of printable ASCII, which is all an RFC ` +
|
|
423
|
+
'8941 string can carry; a line break there would forge a header line.',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** A signature label is an RFC 8941 dictionary key, or the call is a mistake (@5zrf8gjk). */
|
|
430
|
+
export function checkLabel(label) {
|
|
431
|
+
if (typeof label !== 'string' || !SF_KEY.test(label)) {
|
|
432
|
+
throw new TypeError(
|
|
433
|
+
`The label ${shown(label)} is not an RFC 8941 key: it starts with a lowercase letter ` +
|
|
434
|
+
'or "*" and continues with lowercase letters, digits, "_", "-", "." and "*".',
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
292
439
|
/** The whole base for already-checked items: the component lines, then fiki's own parameters. */
|
|
293
440
|
export function finishBase(items, message, values) {
|
|
294
441
|
const lines = linesFor(items, message);
|
|
@@ -307,6 +454,7 @@ export function finishBase(items, message, values) {
|
|
|
307
454
|
* the request does not carry.
|
|
308
455
|
*/
|
|
309
456
|
export function signatureBase({ method, url, headers, covered, created, keyid, alg, expires, nonce, tag }) {
|
|
457
|
+
checkSignerParams({ created, expires, keyid, alg, nonce, tag });
|
|
310
458
|
const items = covered.map(component);
|
|
311
459
|
checkCovered(items, { response: false });
|
|
312
460
|
return finishBase(items, requestMessage(method, url, headers), { created, expires, nonce, alg, keyid, tag });
|
|
@@ -319,6 +467,7 @@ export function signatureBase({ method, url, headers, covered, created, keyid, a
|
|
|
319
467
|
* MissingComponent.
|
|
320
468
|
*/
|
|
321
469
|
export function responseSignatureBase({ status, headers, covered, created, keyid, request, alg, expires, nonce, tag }) {
|
|
470
|
+
checkSignerParams({ created, expires, keyid, alg, nonce, tag });
|
|
322
471
|
const items = covered.map(component);
|
|
323
472
|
checkCovered(items, { response: true });
|
|
324
473
|
return finishBase(items, responseMessage(status, headers, request), { created, expires, nonce, alg, keyid, tag });
|
package/src/index.js
CHANGED
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
// when their declared vectors format matches, whatever their own version numbers say — so this is
|
|
11
11
|
// the number to compare, not the release. Monotonic, because a conformance contract has no
|
|
12
12
|
// meaningful minor: an implementation either satisfies the vectors or it does not.
|
|
13
|
-
export const VECTORS_FORMAT =
|
|
13
|
+
export const VECTORS_FORMAT = 2;
|
|
14
14
|
|
|
15
15
|
// The KERI profile's own conformance contract, vectors/keri/ (`this.i` @8vwrexxc, @9enyfktu). A
|
|
16
16
|
// separate number from VECTORS_FORMAT, because the two sets answer to different authorities and
|
|
17
17
|
// move independently.
|
|
18
|
-
export const KERI_VECTORS_FORMAT =
|
|
18
|
+
export const KERI_VECTORS_FORMAT = 4;
|
|
19
19
|
|
|
20
20
|
export * as errors from './errors.js';
|
|
21
21
|
export { FikiError } from './errors.js';
|
|
22
|
+
export { MAX_DICTIONARY_MEMBERS, MAX_FIELD_BYTES, MAX_INNER_LIST_ITEMS, MAX_PARAMETERS } from './sfv.js';
|
|
22
23
|
export { Key, toAid, verifyingKey, verifySignature } from './keys.js';
|
|
23
24
|
export {
|
|
24
25
|
CONTENT_DIGEST,
|
package/src/messages.js
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
DEFAULT_COVERED,
|
|
19
19
|
canonicalHeaders,
|
|
20
20
|
checkCovered,
|
|
21
|
+
checkLabel,
|
|
22
|
+
checkSignerParams,
|
|
21
23
|
component,
|
|
22
24
|
finishBase,
|
|
23
25
|
identity,
|
|
@@ -50,7 +52,7 @@ import {
|
|
|
50
52
|
UnsupportedAlgorithm,
|
|
51
53
|
} from './errors.js';
|
|
52
54
|
import { checkKey, misspelledAid, toAid, verifyWithRaw, verifyingKey } from './keys.js';
|
|
53
|
-
import { parseDictionary, serializeByteSequence, serializeInnerList } from './sfv.js';
|
|
55
|
+
import { MAX_FIELD_BYTES, parseDictionary, serializeByteSequence, serializeInnerList } from './sfv.js';
|
|
54
56
|
|
|
55
57
|
export const ALG = 'ed25519';
|
|
56
58
|
|
|
@@ -152,6 +154,19 @@ const coversBody = (items) => items.some((item) => identity(item) === identity(c
|
|
|
152
154
|
/** Cover a body the caller handed over, or refuse to sign (@2hwvpm42). */
|
|
153
155
|
async function coverBody(items, sending, body, chosen) {
|
|
154
156
|
if (body === null) return;
|
|
157
|
+
if (hasName(sending, CONTENT_DIGEST)) {
|
|
158
|
+
// A digest the caller supplied is signed as given, so it must be one the verifier will accept
|
|
159
|
+
// for this body: one that does not parse, names nothing fiki computes, or does not match is
|
|
160
|
+
// the call's mistake, not a message (@5zrf8gjk).
|
|
161
|
+
try {
|
|
162
|
+
await compareDigest(readDigest(sending[CONTENT_DIGEST]), body);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw new TypeError(
|
|
165
|
+
'The Content-Digest supplied with this body is not one a verifier would accept for it: ' +
|
|
166
|
+
`${err.message} Omit it and fiki computes one, or supply the body it describes.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
155
170
|
// Whether the caller CHOSE the covered set is the difference between fiki helping and fiki
|
|
156
171
|
// overriding. On the default path a body simply gets covered; on an explicit path, silently
|
|
157
172
|
// adding a component would mean the signature covers something the caller did not ask for, so
|
|
@@ -210,6 +225,10 @@ export async function signRequest({
|
|
|
210
225
|
keyid = null,
|
|
211
226
|
minimum = null,
|
|
212
227
|
}) {
|
|
228
|
+
checkLabel(label);
|
|
229
|
+
created = createdOr(created);
|
|
230
|
+
keyid = keyid ?? key.keyid;
|
|
231
|
+
checkSignerParams({ created, expires, keyid, alg: ALG, nonce, tag });
|
|
213
232
|
const floor = floored(minimum, REQUEST_MINIMUM);
|
|
214
233
|
body = bodyBytes(body);
|
|
215
234
|
headers = canonicalHeaders(headers);
|
|
@@ -222,11 +241,11 @@ export async function signRequest({
|
|
|
222
241
|
}
|
|
223
242
|
checkCovered(items, { response: false });
|
|
224
243
|
const base = finishBase(items, requestMessage(method, url, sending), {
|
|
225
|
-
created
|
|
244
|
+
created,
|
|
226
245
|
expires,
|
|
227
246
|
nonce,
|
|
228
247
|
alg: ALG,
|
|
229
|
-
keyid
|
|
248
|
+
keyid,
|
|
230
249
|
tag,
|
|
231
250
|
});
|
|
232
251
|
return signed(key, base, label, sending, headers);
|
|
@@ -258,6 +277,10 @@ export async function signResponse({
|
|
|
258
277
|
keyid = null,
|
|
259
278
|
minimum = null,
|
|
260
279
|
}) {
|
|
280
|
+
checkLabel(label);
|
|
281
|
+
created = createdOr(created);
|
|
282
|
+
keyid = keyid ?? key.keyid;
|
|
283
|
+
checkSignerParams({ created, expires, keyid, alg: ALG, nonce, tag });
|
|
261
284
|
const floor = floored(minimum, RESPONSE_MINIMUM);
|
|
262
285
|
body = bodyBytes(body);
|
|
263
286
|
request = normalRequest(request);
|
|
@@ -287,17 +310,24 @@ export async function signResponse({
|
|
|
287
310
|
}
|
|
288
311
|
checkCovered(items, { response: true });
|
|
289
312
|
const base = finishBase(items, responseMessage(status, sending, request), {
|
|
290
|
-
created
|
|
313
|
+
created,
|
|
291
314
|
expires,
|
|
292
315
|
nonce,
|
|
293
316
|
alg: ALG,
|
|
294
|
-
keyid
|
|
317
|
+
keyid,
|
|
295
318
|
tag,
|
|
296
319
|
});
|
|
297
320
|
return signed(key, base, label, sending, headers);
|
|
298
321
|
}
|
|
299
322
|
|
|
300
323
|
/** Verify a signed request, returning a verdict `{aid, covered, keyid}` or throwing.
|
|
324
|
+
*
|
|
325
|
+
* The verdict's `keyid` is the keyid exactly as it appeared on the wire, or null when the signature
|
|
326
|
+
* had none, so a verifier given `expectedAid` can still see what the signer claimed (@5zrf8gjk).
|
|
327
|
+
* Its `aid` is the identity that vouched for the key: the non-transferable AID of a raw key, the
|
|
328
|
+
* keyid a resolver vouched for (@6g9zjsv9), or the AID of `expectedAid`. `covered` names each
|
|
329
|
+
* component as `component` would accept it: a plain name, or its serialized form when it carries
|
|
330
|
+
* a parameter, such as `'"@path";req'`.
|
|
301
331
|
*
|
|
302
332
|
* `maxAge` has no default and must be given: seconds of tolerance, or `null` to decline the
|
|
303
333
|
* check. Both defaults would be wrong (@67shl6c5). An `expires` the signer declared is enforced
|
|
@@ -331,10 +361,10 @@ export async function verifyRequest({
|
|
|
331
361
|
expectedKeyid = null,
|
|
332
362
|
authorities = null,
|
|
333
363
|
}) {
|
|
334
|
-
|
|
364
|
+
checkWindow(maxAge, skew, 'verifyRequest');
|
|
335
365
|
const floor = floored(minimum, REQUEST_MINIMUM);
|
|
336
366
|
headers = canonicalHeaders(headers);
|
|
337
|
-
return verify(requestMessage(method, url, headers), headers, bodyBytes(body), {
|
|
367
|
+
return verify(requestMessage(method, url, headers, { received: true }), headers, bodyBytes(body), {
|
|
338
368
|
response: false,
|
|
339
369
|
request: null,
|
|
340
370
|
maxAge,
|
|
@@ -373,18 +403,19 @@ export async function verifyResponse({
|
|
|
373
403
|
minimum = null,
|
|
374
404
|
expectedKeyid = null,
|
|
375
405
|
}) {
|
|
376
|
-
|
|
406
|
+
checkWindow(maxAge, skew, 'verifyResponse');
|
|
377
407
|
const floor = floored(minimum, RESPONSE_MINIMUM);
|
|
378
408
|
body = bodyBytes(body);
|
|
379
409
|
request = normalRequest(request);
|
|
380
410
|
headers = canonicalHeaders(headers);
|
|
381
|
-
|
|
411
|
+
// An empty Signature is no signature: the same unsigned 401 (@5zrf8gjk).
|
|
412
|
+
if (status === 401 && !headers.signature) {
|
|
382
413
|
throw new Unauthenticated(
|
|
383
414
|
'The server answered 401 without signing the answer, so the request was not authenticated ' +
|
|
384
415
|
'and the body of the refusal cannot be trusted.',
|
|
385
416
|
);
|
|
386
417
|
}
|
|
387
|
-
return verify(responseMessage(status, headers, request), headers, body, {
|
|
418
|
+
return verify(responseMessage(status, headers, request, { received: true }), headers, body, {
|
|
388
419
|
response: true,
|
|
389
420
|
request,
|
|
390
421
|
maxAge,
|
|
@@ -398,13 +429,25 @@ export async function verifyResponse({
|
|
|
398
429
|
});
|
|
399
430
|
}
|
|
400
431
|
|
|
401
|
-
|
|
432
|
+
/** maxAge must be given, and a freshness window, when given, is a positive whole number of seconds.
|
|
433
|
+
*
|
|
434
|
+
* The KERI profile's section 3 says so (@5zrf8gjk), and a zero or negative one would refuse every
|
|
435
|
+
* honest message or none. `maxAge: null` still declines the age check; there is no way to decline
|
|
436
|
+
* the skew, which the expiry check uses whatever maxAge is.
|
|
437
|
+
*/
|
|
438
|
+
function checkWindow(maxAge, skew, name) {
|
|
402
439
|
if (maxAge === undefined) {
|
|
403
440
|
throw new TypeError(
|
|
404
441
|
`${name} requires maxAge: seconds of tolerance, or null to decline the check. There is no ` +
|
|
405
442
|
'default because both defaults are wrong.',
|
|
406
443
|
);
|
|
407
444
|
}
|
|
445
|
+
for (const [field, value] of [['maxAge', maxAge], ['skew', skew]]) {
|
|
446
|
+
if (value === null && field === 'maxAge') continue;
|
|
447
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
448
|
+
throw new TypeError(`${field} is ${String(value)}, and a freshness window is a positive whole number of seconds.`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
408
451
|
}
|
|
409
452
|
|
|
410
453
|
/** The KERI profile's section 9 order, so a message has exactly one correct refusal. */
|
|
@@ -673,7 +716,16 @@ function checkInput(member, { requireKeyid, requireCreated }) {
|
|
|
673
716
|
}
|
|
674
717
|
}
|
|
675
718
|
|
|
719
|
+
/** Parse one signature-related header, bounded before it is read (@5zrf8gjk).
|
|
720
|
+
*
|
|
721
|
+
* Size before shape: the raw field value is measured in UTF-8 bytes before any parsing or
|
|
722
|
+
* trimming, and the parser enforces the member, item and parameter counts as it goes.
|
|
723
|
+
*/
|
|
676
724
|
function parse(raw, name, ErrorClass) {
|
|
725
|
+
const size = utf8(raw).length;
|
|
726
|
+
if (size > MAX_FIELD_BYTES) {
|
|
727
|
+
throw new ErrorClass(`The ${name} header is ${size} bytes, and fiki reads one of at most ${MAX_FIELD_BYTES}.`);
|
|
728
|
+
}
|
|
677
729
|
try {
|
|
678
730
|
return parseDictionary(raw);
|
|
679
731
|
} catch {
|
package/src/sfv.js
CHANGED
|
@@ -22,7 +22,19 @@ const MalformedSyntax = SfvSyntaxError;
|
|
|
22
22
|
|
|
23
23
|
import { fromBase64, toBase64 } from './bytes.js';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// RFC 4648 base64 whose only "=" are the ones completing the final quantum (section 3.3), which is
|
|
26
|
+
// the only spelling RFC 8941 section 3.3.5 decodes: missing or partial padding is refused, and so
|
|
27
|
+
// is anything outside the alphabet, CR and LF included (@5zrf8gjk).
|
|
28
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
29
|
+
|
|
30
|
+
// Input bounds (@5zrf8gjk, ticks 65q7 and 6mhg), far above anything an honest signer sends and low
|
|
31
|
+
// enough that no parse is slow. The byte bound is checked by the caller before parsing; these
|
|
32
|
+
// counts are enforced as the parse reaches them, so an over-long list is refused without being
|
|
33
|
+
// read to its end.
|
|
34
|
+
export const MAX_FIELD_BYTES = 8192;
|
|
35
|
+
export const MAX_DICTIONARY_MEMBERS = 16;
|
|
36
|
+
export const MAX_INNER_LIST_ITEMS = 64;
|
|
37
|
+
export const MAX_PARAMETERS = 16;
|
|
26
38
|
|
|
27
39
|
class Cursor {
|
|
28
40
|
constructor(text) {
|
|
@@ -135,6 +147,9 @@ function parseParameters(cursor) {
|
|
|
135
147
|
} else {
|
|
136
148
|
params.set(key, true);
|
|
137
149
|
}
|
|
150
|
+
if (params.size > MAX_PARAMETERS) {
|
|
151
|
+
throw new MalformedSyntax(`An item carries more than ${MAX_PARAMETERS} parameters.`);
|
|
152
|
+
}
|
|
138
153
|
}
|
|
139
154
|
return params;
|
|
140
155
|
}
|
|
@@ -150,6 +165,9 @@ function parseInnerList(cursor) {
|
|
|
150
165
|
break;
|
|
151
166
|
}
|
|
152
167
|
items.push({ value: parseBareItem(cursor), params: parseParameters(cursor) });
|
|
168
|
+
if (items.length > MAX_INNER_LIST_ITEMS) {
|
|
169
|
+
throw new MalformedSyntax(`An inner list holds more than ${MAX_INNER_LIST_ITEMS} items.`);
|
|
170
|
+
}
|
|
153
171
|
if (!cursor.done && cursor.peek() !== ' ' && cursor.peek() !== ')') {
|
|
154
172
|
throw new MalformedSyntax(`Expected a space or ")" at offset ${cursor.at}.`);
|
|
155
173
|
}
|
|
@@ -185,6 +203,9 @@ export function parseDictionary(text) {
|
|
|
185
203
|
value = { value: true, params: parseParameters(cursor) };
|
|
186
204
|
}
|
|
187
205
|
out.set(key, value);
|
|
206
|
+
if (out.size > MAX_DICTIONARY_MEMBERS) {
|
|
207
|
+
throw new MalformedSyntax(`A dictionary holds more than ${MAX_DICTIONARY_MEMBERS} members.`);
|
|
208
|
+
}
|
|
188
209
|
cursor.skipSpace();
|
|
189
210
|
if (cursor.done) break;
|
|
190
211
|
cursor.expect(',');
|