@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 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
- `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.
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. Four more differences are deliberate (`this.i` @9enyfktu):
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 split as sent, as Python's `urlsplit` does, rather than parsed with `new URL`, which normalizes the path that RFC 9421 and the KERI profile sign unnormalized (`this.i` @90y0gsfx).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakobo/fiki",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Sign and verify HTTP requests with a bare Ed25519 key as the identifier. Standard RFC 9421, no KERI dependencies.",
5
5
  "keywords": [
6
6
  "rfc9421",
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 match = /^(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?/.exec(url);
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
- function authority(parts, headers) {
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 hostport = parts.netloc.slice(parts.netloc.lastIndexOf('@') + 1).toLowerCase();
138
- const [, host, port = ''] = /^(\[[^\]]*\]|[^:]*)(?::(.*))?$/.exec(hostport);
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 new TypeError(`The URL's port "${port}" is not a port number between 0 and 65535.`);
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, String(value));
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
- export function requestMessage(method, url, headers) {
213
- // A method is the caller's to supply, and an absent one would otherwise be signed as the string
214
- // "undefined". Required here, where every request and every response's request passes.
215
- if (typeof method !== 'string' || method === '') {
216
- throw new TypeError(`A request needs its method as a non-empty string, as sent; got ${String(method) || 'an empty string'}.`);
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 const responseMessage = (status, headers, request) => ({
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.parts, source.headers);
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 = 1;
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 = 3;
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: createdOr(created),
244
+ created,
226
245
  expires,
227
246
  nonce,
228
247
  alg: ALG,
229
- keyid: keyid ?? key.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: createdOr(created),
313
+ created,
291
314
  expires,
292
315
  nonce,
293
316
  alg: ALG,
294
- keyid: keyid ?? key.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
- requireMaxAge(maxAge, 'verifyRequest');
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
- requireMaxAge(maxAge, 'verifyResponse');
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
- if (status === 401 && !hasName(headers, 'signature')) {
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
- function requireMaxAge(maxAge, name) {
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
- const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
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(',');