@bakobo/fiki 0.0.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -2
- package/package.json +32 -4
- package/src/base.js +325 -0
- package/src/bytes.js +32 -0
- package/src/errors.js +91 -0
- package/src/index.js +42 -0
- package/src/keys.js +225 -0
- package/src/messages.js +796 -0
- package/src/sfv.js +219 -0
package/src/messages.js
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
// Signing and verifying whole HTTP requests and responses (`this.i` @2hwvpm42, @7xrx5evg,
|
|
2
|
+
// @67shl6c5, @6g9zjsv9, @7f28p7xk, @9enyfktu).
|
|
3
|
+
//
|
|
4
|
+
// The `keyid` is the signer's raw key unless the caller names another, so "the request carries
|
|
5
|
+
// its own verifying key" holds for every fiki-signed request whose caller did not deliberately
|
|
6
|
+
// choose otherwise — and a verifier handed a resolver never falls back to reading a key out of the
|
|
7
|
+
// keyid. A body is always covered or the signature is refused. And a verifier states a freshness
|
|
8
|
+
// policy or explicitly declines one.
|
|
9
|
+
//
|
|
10
|
+
// The bound worth stating plainly: fiki cannot cover a body it was never given. The guarantee is
|
|
11
|
+
// "hand fiki the body and it is covered, or fiki refuses" — a caller who omits `body` gets a valid
|
|
12
|
+
// signature over a request whose body nothing protects, and no library can detect that from the
|
|
13
|
+
// inside.
|
|
14
|
+
|
|
15
|
+
import { equal, toBase64, toBase64Url, fromBase64Url, utf8 } from './bytes.js';
|
|
16
|
+
import {
|
|
17
|
+
CONTENT_DIGEST,
|
|
18
|
+
DEFAULT_COVERED,
|
|
19
|
+
canonicalHeaders,
|
|
20
|
+
checkCovered,
|
|
21
|
+
component,
|
|
22
|
+
finishBase,
|
|
23
|
+
identity,
|
|
24
|
+
linesFor,
|
|
25
|
+
req,
|
|
26
|
+
requestMessage,
|
|
27
|
+
responseMessage,
|
|
28
|
+
specOf,
|
|
29
|
+
valueOf,
|
|
30
|
+
} from './base.js';
|
|
31
|
+
import {
|
|
32
|
+
DigestMismatch,
|
|
33
|
+
InsufficientCoverage,
|
|
34
|
+
MalformedDigest,
|
|
35
|
+
MalformedKey,
|
|
36
|
+
MalformedSignature,
|
|
37
|
+
MalformedSignatureInput,
|
|
38
|
+
MalformedSignatureLabel,
|
|
39
|
+
MalformedSignatureValue,
|
|
40
|
+
MissingKey,
|
|
41
|
+
MissingSignature,
|
|
42
|
+
MissingSignatureInput,
|
|
43
|
+
MissingSignatureLabel,
|
|
44
|
+
SignatureExpired,
|
|
45
|
+
SignatureMismatch,
|
|
46
|
+
SignatureTooOld,
|
|
47
|
+
Unauthenticated,
|
|
48
|
+
UncoveredBody,
|
|
49
|
+
UnknownKey,
|
|
50
|
+
UnsupportedAlgorithm,
|
|
51
|
+
} from './errors.js';
|
|
52
|
+
import { checkKey, misspelledAid, toAid, verifyWithRaw, verifyingKey } from './keys.js';
|
|
53
|
+
import { parseDictionary, serializeByteSequence, serializeInnerList } from './sfv.js';
|
|
54
|
+
|
|
55
|
+
export const ALG = 'ed25519';
|
|
56
|
+
|
|
57
|
+
// Two hosts disagreeing by a second is ordinary; a verifier that treats it as an attack is
|
|
58
|
+
// unusable. Adjustable per call, because a satellite link and a rack are not the same problem.
|
|
59
|
+
export const DEFAULT_SKEW = 5;
|
|
60
|
+
|
|
61
|
+
// RFC 9530. sha-256 on the way out; both are accepted on the way in, because fiki is not the only
|
|
62
|
+
// thing that will ever have signed a request it is asked to verify. Every one of these a header
|
|
63
|
+
// carries must match; any other algorithm is ignored (RFC 9530 section 2).
|
|
64
|
+
// Maps rather than object literals, here and below, because both are keyed by names a message
|
|
65
|
+
// supplies, and an object literal answers "constructor" or "__proto__" from Object.prototype
|
|
66
|
+
// (Copilot review of PR #5, C4).
|
|
67
|
+
const DIGEST_ALGORITHMS = new Map([
|
|
68
|
+
['sha-256', 'SHA-256'],
|
|
69
|
+
['sha-512', 'SHA-512'],
|
|
70
|
+
]);
|
|
71
|
+
const DIGEST_OUT = 'sha-256';
|
|
72
|
+
|
|
73
|
+
// RFC 9421 section 2.3's six signature parameters and the RFC 8941 type each must have. Anything
|
|
74
|
+
// else is refused rather than carried: a parameter fiki does not understand could be one whose
|
|
75
|
+
// meaning the signer relied on (@7f28p7xk).
|
|
76
|
+
const SIGNATURE_PARAMS = new Map([
|
|
77
|
+
['created', 'number'],
|
|
78
|
+
['expires', 'number'],
|
|
79
|
+
['nonce', 'string'],
|
|
80
|
+
['alg', 'string'],
|
|
81
|
+
['keyid', 'string'],
|
|
82
|
+
['tag', 'string'],
|
|
83
|
+
]);
|
|
84
|
+
const SIGNATURE_LENGTH = 64;
|
|
85
|
+
const KEY_LENGTH = 32;
|
|
86
|
+
// The RFC 8037 "x" form of a raw keyid (@7xrx5evg): 32 bytes, base64url, unpadded.
|
|
87
|
+
const RAW_KEYID = /^[A-Za-z0-9_-]{43}$/;
|
|
88
|
+
|
|
89
|
+
// The KERI profile's minimum covered sets (section 3), for a verifier's `minimum`. A body adds
|
|
90
|
+
// content-digest on top, and a response to a request that had a body adds "content-digest";req.
|
|
91
|
+
export const REQUEST_MINIMUM = Object.freeze(['@method', '@path', '@query']);
|
|
92
|
+
export const RESPONSE_MINIMUM = Object.freeze(['@status', req('@method'), req('@path'), req('@query')]);
|
|
93
|
+
|
|
94
|
+
/** A body as bytes, normalized once at the boundary, or null when none was handed over.
|
|
95
|
+
*
|
|
96
|
+
* A Uint8Array, any other ArrayBufferView, an ArrayBuffer, or a string, which is encoded as UTF-8.
|
|
97
|
+
* Anything else is a TypeError rather than "no body": a type fiki silently failed to read would be
|
|
98
|
+
* a body that escapes coverage, which is the one thing the body rule exists to prevent (@2hwvpm42).
|
|
99
|
+
*/
|
|
100
|
+
export function bodyBytes(body, name = 'body') {
|
|
101
|
+
if (body === null || body === undefined) return null;
|
|
102
|
+
if (body instanceof Uint8Array) return body;
|
|
103
|
+
if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
104
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
105
|
+
if (typeof body === 'string') return utf8(body);
|
|
106
|
+
throw new TypeError(
|
|
107
|
+
`${name} must be a Uint8Array, another ArrayBufferView, an ArrayBuffer or a string; this one is ` +
|
|
108
|
+
`${Object.prototype.toString.call(body)}, which fiki cannot read as bytes.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The request a response answers, with its body as bytes and its headers canonical, once.
|
|
113
|
+
const normalRequest = (request) =>
|
|
114
|
+
request === null || request === undefined
|
|
115
|
+
? null
|
|
116
|
+
: { ...request, headers: canonicalHeaders(request.headers, 'request.headers'), body: bodyBytes(request.body, 'request.body') };
|
|
117
|
+
|
|
118
|
+
/** The RFC 9530 `Content-Digest` header value for a body. */
|
|
119
|
+
export async function contentDigest(body) {
|
|
120
|
+
const digest = await crypto.subtle.digest(DIGEST_ALGORITHMS.get(DIGEST_OUT), bodyBytes(body));
|
|
121
|
+
return `${DIGEST_OUT}=:${toBase64(new Uint8Array(digest))}:`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
125
|
+
// Only ever handed what bodyBytes returned, so a body is null or a Uint8Array.
|
|
126
|
+
const hasContent = (body) => body !== null && body.length > 0;
|
|
127
|
+
// Both only ever see headers canonicalHeaders has already lowercased.
|
|
128
|
+
const hasName = (headers, name) => Object.hasOwn(headers, name);
|
|
129
|
+
const lowered = (headers) => new Map(Object.entries(headers));
|
|
130
|
+
|
|
131
|
+
/** A supplied minimum selects the KERI profile's policy, so it may only add to the profile's.
|
|
132
|
+
*
|
|
133
|
+
* Anything smaller is the caller's mistake rather than a message's defect, so it is a TypeError,
|
|
134
|
+
* thrown before any message is read (bakobo/fiki#4).
|
|
135
|
+
*/
|
|
136
|
+
function floored(minimum, floor) {
|
|
137
|
+
if (minimum === null || minimum === undefined) return null;
|
|
138
|
+
const given = new Set(minimum.map((spec) => identity(component(spec))));
|
|
139
|
+
const missing = floor.filter((spec) => !given.has(identity(component(spec))));
|
|
140
|
+
if (missing.length > 0) {
|
|
141
|
+
throw new TypeError(
|
|
142
|
+
`A minimum covered set must include the profile's own, ${floor.join(', ')}; this one leaves ` +
|
|
143
|
+
`out ${missing.join(', ')}. Pass null to apply no minimum at all.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return minimum;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const bindsRequestDigest = (items) => items.some((item) => identity(item) === identity(component(req(CONTENT_DIGEST))));
|
|
150
|
+
const coversBody = (items) => items.some((item) => identity(item) === identity(component(CONTENT_DIGEST)));
|
|
151
|
+
|
|
152
|
+
/** Cover a body the caller handed over, or refuse to sign (@2hwvpm42). */
|
|
153
|
+
async function coverBody(items, sending, body, chosen) {
|
|
154
|
+
if (body === null) return;
|
|
155
|
+
// Whether the caller CHOSE the covered set is the difference between fiki helping and fiki
|
|
156
|
+
// overriding. On the default path a body simply gets covered; on an explicit path, silently
|
|
157
|
+
// adding a component would mean the signature covers something the caller did not ask for, so
|
|
158
|
+
// the same situation is a refusal instead.
|
|
159
|
+
if (!coversBody(items)) {
|
|
160
|
+
if (chosen) {
|
|
161
|
+
throw new UncoveredBody(
|
|
162
|
+
'This message carries a body, but the covered components do not include ' +
|
|
163
|
+
`"${CONTENT_DIGEST}", so the signature would not bind the body. Add it to the covered ` +
|
|
164
|
+
'set, or omit the body if it is genuinely not part of what you are signing.',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
items.push(component(CONTENT_DIGEST));
|
|
168
|
+
}
|
|
169
|
+
if (!hasName(sending, CONTENT_DIGEST)) sending[CONTENT_DIGEST] = await contentDigest(body);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function signed(key, base, label, sending, given) {
|
|
173
|
+
const signature = await key.sign(base);
|
|
174
|
+
const params = new TextDecoder().decode(base).split('"@signature-params": ').at(-1);
|
|
175
|
+
const out = {
|
|
176
|
+
'Signature-Input': `${label}=${params}`,
|
|
177
|
+
Signature: `${label}=${serializeByteSequence(signature)}`,
|
|
178
|
+
};
|
|
179
|
+
if (sending[CONTENT_DIGEST] !== undefined && !hasName(given, CONTENT_DIGEST)) {
|
|
180
|
+
out['Content-Digest'] = sending[CONTENT_DIGEST];
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const createdOr = (created) => (created === null || created === undefined ? now() : created);
|
|
186
|
+
|
|
187
|
+
/** Sign a request, returning the headers to add to it.
|
|
188
|
+
*
|
|
189
|
+
* With a `body` and no explicit `covered`, fiki computes a `Content-Digest`, returns it among the
|
|
190
|
+
* headers, and covers it. With a `body` and an explicit `covered` that omits `content-digest`,
|
|
191
|
+
* fiki throws UncoveredBody rather than signing a request whose body nothing binds.
|
|
192
|
+
*
|
|
193
|
+
* `method` is signed exactly as given (@22g0xkr8), so pass it as it will go on the wire. `keyid`
|
|
194
|
+
* defaults to the key itself (@7xrx5evg); name another, such as a KERI AID, only when the verifier
|
|
195
|
+
* resolves it (@6g9zjsv9). `minimum`, such as REQUEST_MINIMUM, makes the signer refuse a covered
|
|
196
|
+
* list its verifier would refuse (@2f227n4r).
|
|
197
|
+
*/
|
|
198
|
+
export async function signRequest({
|
|
199
|
+
key,
|
|
200
|
+
method,
|
|
201
|
+
url,
|
|
202
|
+
headers,
|
|
203
|
+
body = null,
|
|
204
|
+
covered = null,
|
|
205
|
+
created = null,
|
|
206
|
+
label = 'sig',
|
|
207
|
+
expires,
|
|
208
|
+
nonce,
|
|
209
|
+
tag,
|
|
210
|
+
keyid = null,
|
|
211
|
+
minimum = null,
|
|
212
|
+
}) {
|
|
213
|
+
const floor = floored(minimum, REQUEST_MINIMUM);
|
|
214
|
+
body = bodyBytes(body);
|
|
215
|
+
headers = canonicalHeaders(headers);
|
|
216
|
+
const sending = Object.assign(Object.create(null), headers);
|
|
217
|
+
const chosen = covered !== null && covered !== undefined;
|
|
218
|
+
const items = (chosen ? covered : DEFAULT_COVERED).map(component);
|
|
219
|
+
await coverBody(items, sending, body, chosen);
|
|
220
|
+
if (floor !== null) {
|
|
221
|
+
checkMinimum(items, floor, { hasBody: requestHasBody(lowered(sending), body), requestHadBody: false });
|
|
222
|
+
}
|
|
223
|
+
checkCovered(items, { response: false });
|
|
224
|
+
const base = finishBase(items, requestMessage(method, url, sending), {
|
|
225
|
+
created: createdOr(created),
|
|
226
|
+
expires,
|
|
227
|
+
nonce,
|
|
228
|
+
alg: ALG,
|
|
229
|
+
keyid: keyid ?? key.keyid,
|
|
230
|
+
tag,
|
|
231
|
+
});
|
|
232
|
+
return signed(key, base, label, sending, headers);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Sign a response, returning the headers to add to it (RFC 9421 section 2.4).
|
|
236
|
+
*
|
|
237
|
+
* `request` is the request it answers, `{method, url, headers, body}`. By default the signature
|
|
238
|
+
* covers `@status`, a `Content-Digest` of any body, and — when the request is given — that
|
|
239
|
+
* request's method, path and query, plus its `content-digest` when it carried content, each
|
|
240
|
+
* marked `req`. That binds the response to what was asked. A request whose content was non-empty
|
|
241
|
+
* is bound by its `Content-Digest` (its headers do not count, @7p9s3g9k), and one with no digest
|
|
242
|
+
* to bind is refused as UncoveredBody rather than signed into a response every profile client
|
|
243
|
+
* refuses (@2f227n4r). A digest the request body contradicts is refused the way the verifier
|
|
244
|
+
* would refuse it.
|
|
245
|
+
*/
|
|
246
|
+
export async function signResponse({
|
|
247
|
+
key,
|
|
248
|
+
status,
|
|
249
|
+
request = null,
|
|
250
|
+
headers,
|
|
251
|
+
body = null,
|
|
252
|
+
covered = null,
|
|
253
|
+
created = null,
|
|
254
|
+
label = 'sig',
|
|
255
|
+
expires,
|
|
256
|
+
nonce,
|
|
257
|
+
tag,
|
|
258
|
+
keyid = null,
|
|
259
|
+
minimum = null,
|
|
260
|
+
}) {
|
|
261
|
+
const floor = floored(minimum, RESPONSE_MINIMUM);
|
|
262
|
+
body = bodyBytes(body);
|
|
263
|
+
request = normalRequest(request);
|
|
264
|
+
headers = canonicalHeaders(headers);
|
|
265
|
+
const sending = Object.assign(Object.create(null), headers);
|
|
266
|
+
const chosen = covered !== null && covered !== undefined;
|
|
267
|
+
// By content alone: both sides hold the whole request by now (profile section 3, @7p9s3g9k).
|
|
268
|
+
const hadBody = request !== null && hasContent(request.body);
|
|
269
|
+
const specs = chosen ? covered : ['@status', ...(request === null ? [] : [req('@method'), req('@path'), req('@query')])];
|
|
270
|
+
const items = specs.map(component);
|
|
271
|
+
await coverBody(items, sending, body, chosen);
|
|
272
|
+
if (!chosen && hadBody) {
|
|
273
|
+
if (!hasName(request.headers, CONTENT_DIGEST)) {
|
|
274
|
+
throw new UncoveredBody(
|
|
275
|
+
'The request this response answers carried a body and no Content-Digest, so the response ' +
|
|
276
|
+
'has nothing to bind that body with. Sign the request with a digest first, or name the ' +
|
|
277
|
+
'covered components yourself.',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
items.push(component(req(CONTENT_DIGEST)));
|
|
281
|
+
}
|
|
282
|
+
if (floor !== null) checkMinimum(items, floor, { hasBody: hasContent(body), requestHadBody: hadBody });
|
|
283
|
+
// The check verifyResponse will make, made first: a signer does not vouch for a request digest
|
|
284
|
+
// that the request body it was handed contradicts (bakobo/fiki#4).
|
|
285
|
+
if (request !== null && request.body !== null && bindsRequestDigest(items)) {
|
|
286
|
+
await compareDigest(readDigest(lowered(request.headers).get(CONTENT_DIGEST)), request.body);
|
|
287
|
+
}
|
|
288
|
+
checkCovered(items, { response: true });
|
|
289
|
+
const base = finishBase(items, responseMessage(status, sending, request), {
|
|
290
|
+
created: createdOr(created),
|
|
291
|
+
expires,
|
|
292
|
+
nonce,
|
|
293
|
+
alg: ALG,
|
|
294
|
+
keyid: keyid ?? key.keyid,
|
|
295
|
+
tag,
|
|
296
|
+
});
|
|
297
|
+
return signed(key, base, label, sending, headers);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Verify a signed request, returning a verdict `{aid, covered, keyid}` or throwing.
|
|
301
|
+
*
|
|
302
|
+
* `maxAge` has no default and must be given: seconds of tolerance, or `null` to decline the
|
|
303
|
+
* check. Both defaults would be wrong (@67shl6c5). An `expires` the signer declared is enforced
|
|
304
|
+
* regardless. `now` is injectable so a conformance vector can pin a freshness case.
|
|
305
|
+
*
|
|
306
|
+
* `expectedAid` is authoritative when supplied, the preregistration case. `resolve` is the other
|
|
307
|
+
* way to be authoritative (@6g9zjsv9): a function from the keyid to the 32 raw bytes of the key it
|
|
308
|
+
* names, or null when it names none, returning either directly or as a promise. Pass one or
|
|
309
|
+
* neither.
|
|
310
|
+
*
|
|
311
|
+
* `minimum` is the verifier's covered-set policy, REQUEST_MINIMUM or a superset of it: a
|
|
312
|
+
* signature covering less is refused even though it verifies, and so is a body — signalled by
|
|
313
|
+
* `Content-Length` above zero, any `Transfer-Encoding`, or simply arriving — without a covered
|
|
314
|
+
* `content-digest` (@7f28p7xk). `null` enforces no minimum, body rule included. `expectedKeyid`
|
|
315
|
+
* refuses a signature by any other keyid as UnknownKey. `authorities` is the set of `@authority`
|
|
316
|
+
* values this verifier serves; a covered `@authority` outside it is a SignatureMismatch, and
|
|
317
|
+
* supplying it makes `@authority` required, so a signature that does not cover it is
|
|
318
|
+
* InsufficientCoverage (@605z9tnw).
|
|
319
|
+
*/
|
|
320
|
+
export async function verifyRequest({
|
|
321
|
+
method,
|
|
322
|
+
url,
|
|
323
|
+
headers,
|
|
324
|
+
maxAge,
|
|
325
|
+
body = null,
|
|
326
|
+
expectedAid = null,
|
|
327
|
+
skew = DEFAULT_SKEW,
|
|
328
|
+
now: at = null,
|
|
329
|
+
resolve = null,
|
|
330
|
+
minimum = null,
|
|
331
|
+
expectedKeyid = null,
|
|
332
|
+
authorities = null,
|
|
333
|
+
}) {
|
|
334
|
+
requireMaxAge(maxAge, 'verifyRequest');
|
|
335
|
+
const floor = floored(minimum, REQUEST_MINIMUM);
|
|
336
|
+
headers = canonicalHeaders(headers);
|
|
337
|
+
return verify(requestMessage(method, url, headers), headers, bodyBytes(body), {
|
|
338
|
+
response: false,
|
|
339
|
+
request: null,
|
|
340
|
+
maxAge,
|
|
341
|
+
expectedAid,
|
|
342
|
+
skew,
|
|
343
|
+
now: at,
|
|
344
|
+
resolve,
|
|
345
|
+
minimum: floor,
|
|
346
|
+
expectedKeyid,
|
|
347
|
+
authorities: authorities === null || authorities === undefined ? null : new Set(authorities),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Verify a signed response to `request`, returning a verdict or throwing.
|
|
352
|
+
*
|
|
353
|
+
* The arguments are verifyRequest's, with `status` in place of the method and URL and the
|
|
354
|
+
* `request` the response answers, `{method, url, headers, body}`, which its `req` components are
|
|
355
|
+
* read from. Under RESPONSE_MINIMUM, a request with non-empty content obliges the response to
|
|
356
|
+
* cover `"content-digest";req`. A response's body is its content, never its `Content-Length`, so a
|
|
357
|
+
* HEAD or 304 response is bodiless whatever length it announces. A client should pass
|
|
358
|
+
* `expectedKeyid`, the AID it is talking to (profile R1). An unsigned 401 is Unauthenticated,
|
|
359
|
+
* checked before anything else, because a server that refuses before it knows the agent cannot
|
|
360
|
+
* sign the refusal (@2f227n4r). A response covering `"content-digest";req` verified against a
|
|
361
|
+
* request whose body is null is a TypeError: fiki cannot check a body it was not given.
|
|
362
|
+
*/
|
|
363
|
+
export async function verifyResponse({
|
|
364
|
+
status,
|
|
365
|
+
headers,
|
|
366
|
+
maxAge,
|
|
367
|
+
request = null,
|
|
368
|
+
body = null,
|
|
369
|
+
expectedAid = null,
|
|
370
|
+
skew = DEFAULT_SKEW,
|
|
371
|
+
now: at = null,
|
|
372
|
+
resolve = null,
|
|
373
|
+
minimum = null,
|
|
374
|
+
expectedKeyid = null,
|
|
375
|
+
}) {
|
|
376
|
+
requireMaxAge(maxAge, 'verifyResponse');
|
|
377
|
+
const floor = floored(minimum, RESPONSE_MINIMUM);
|
|
378
|
+
body = bodyBytes(body);
|
|
379
|
+
request = normalRequest(request);
|
|
380
|
+
headers = canonicalHeaders(headers);
|
|
381
|
+
if (status === 401 && !hasName(headers, 'signature')) {
|
|
382
|
+
throw new Unauthenticated(
|
|
383
|
+
'The server answered 401 without signing the answer, so the request was not authenticated ' +
|
|
384
|
+
'and the body of the refusal cannot be trusted.',
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return verify(responseMessage(status, headers, request), headers, body, {
|
|
388
|
+
response: true,
|
|
389
|
+
request,
|
|
390
|
+
maxAge,
|
|
391
|
+
expectedAid,
|
|
392
|
+
skew,
|
|
393
|
+
now: at,
|
|
394
|
+
resolve,
|
|
395
|
+
minimum: floor,
|
|
396
|
+
expectedKeyid,
|
|
397
|
+
authorities: null,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function requireMaxAge(maxAge, name) {
|
|
402
|
+
if (maxAge === undefined) {
|
|
403
|
+
throw new TypeError(
|
|
404
|
+
`${name} requires maxAge: seconds of tolerance, or null to decline the check. There is no ` +
|
|
405
|
+
'default because both defaults are wrong.',
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The KERI profile's section 9 order, so a message has exactly one correct refusal. */
|
|
411
|
+
async function verify(message, headers, body, options) {
|
|
412
|
+
const { response, request, maxAge, expectedAid, skew, now: at, resolve, minimum, expectedKeyid, authorities } = options;
|
|
413
|
+
if (expectedAid !== null && resolve !== null) {
|
|
414
|
+
throw new TypeError('Pass expectedAid or resolve, not both; each decides the key alone.');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const found = lowered(headers);
|
|
418
|
+
// keyid is REQUIRED under a minimum, which is how a caller applies the KERI profile, even when
|
|
419
|
+
// expectedAid decides the key: that argument chooses the key, not whether the message is well
|
|
420
|
+
// formed (the rust port's hostile review of PR #5).
|
|
421
|
+
const { inner, signature } = read(found, {
|
|
422
|
+
requireKeyid: expectedAid === null || minimum !== null,
|
|
423
|
+
requireCreated: minimum !== null,
|
|
424
|
+
});
|
|
425
|
+
const { items } = inner;
|
|
426
|
+
checkCovered(items, { response });
|
|
427
|
+
if (minimum !== null) {
|
|
428
|
+
checkMinimum(items, minimum, {
|
|
429
|
+
hasBody: response ? hasContent(body) : requestHasBody(found, body),
|
|
430
|
+
// By the request's content alone, as signResponse decides it (@7p9s3g9k).
|
|
431
|
+
requestHadBody: request !== null && hasContent(request.body),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
// Served authorities bind the signature to a host only if it commits to one, so supplying
|
|
435
|
+
// them makes @authority required (@605z9tnw): coverage, before the key, as section 9 orders.
|
|
436
|
+
if (authorities !== null) checkMinimum(items, ['@authority'], { hasBody: false, requestHadBody: false });
|
|
437
|
+
|
|
438
|
+
const received = inner.params.get('keyid') ?? null;
|
|
439
|
+
const { raw, aid, keyid } = await resolveKey(expectedAid, received, resolve, expectedKeyid);
|
|
440
|
+
const alg = inner.params.get('alg');
|
|
441
|
+
if (alg !== undefined && alg !== ALG) {
|
|
442
|
+
throw new UnsupportedAlgorithm(`This signature is made with "${alg}", and fiki verifies only ${ALG} signatures.`, {
|
|
443
|
+
alg,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const lines = linesFor(items, message);
|
|
448
|
+
lines.push(`"@signature-params": ${serializeInnerList(inner)}`);
|
|
449
|
+
if (!(await verifyWithRaw(raw, signature, utf8(lines.join('\n'))))) {
|
|
450
|
+
throw new SignatureMismatch(
|
|
451
|
+
"The signature does not match this message under the signer's key, so the message cannot be " +
|
|
452
|
+
'treated as authentic.',
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (authorities !== null) {
|
|
457
|
+
for (const item of items) {
|
|
458
|
+
if (item.value === '@authority' && !authorities.has(valueOf(item, message))) {
|
|
459
|
+
throw new SignatureMismatch(
|
|
460
|
+
`The signature covers the authority "${valueOf(item, message)}", which this verifier does ` +
|
|
461
|
+
'not serve, so it was signed for somebody else.',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// AFTER the signature check, deliberately. created and expires are covered by the signature, so
|
|
468
|
+
// acting on them before verifying it would enforce a policy against values an attacker could
|
|
469
|
+
// still have chosen — and would tell that attacker their forgery at least parsed.
|
|
470
|
+
checkFreshness(inner.params, { maxAge, skew, now: at });
|
|
471
|
+
|
|
472
|
+
const digests = [];
|
|
473
|
+
if (coversBody(items)) digests.push([found.get(CONTENT_DIGEST), body]);
|
|
474
|
+
// A response binding the request's digest binds a request body only if somebody hashes it
|
|
475
|
+
// (bakobo/fiki#4). A verifier handed no request body cannot, and a verdict that skipped the
|
|
476
|
+
// check would look like one that made it, so that is the caller's mistake, not a pass.
|
|
477
|
+
if (request !== null && bindsRequestDigest(items)) {
|
|
478
|
+
if (request.body === null) {
|
|
479
|
+
throw new TypeError(
|
|
480
|
+
'The response covers "content-digest";req, so the request body it binds must be supplied ' +
|
|
481
|
+
'in request.body to be checked; it was not.',
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
digests.push([lowered(request.headers).get(CONTENT_DIGEST), request.body]);
|
|
485
|
+
}
|
|
486
|
+
// Every covered digest is parsed before any is compared, so a malformed one outranks a
|
|
487
|
+
// mismatched one wherever each sits (profile section 9, bakobo/fiki#4).
|
|
488
|
+
const parsed = digests.map(([header, content]) => [readDigest(header), content]);
|
|
489
|
+
for (const [recognized, content] of parsed) await compareDigest(recognized, content);
|
|
490
|
+
|
|
491
|
+
return { aid, covered: items.map(specOf), keyid };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The profile's request body test: a length above zero, any transfer coding, or content.
|
|
495
|
+
*
|
|
496
|
+
* Requests only. A response's body is its content, since a HEAD or 304 response carries the
|
|
497
|
+
* length of a representation it does not send (@2f227n4r).
|
|
498
|
+
*/
|
|
499
|
+
function requestHasBody(found, body) {
|
|
500
|
+
if (hasContent(body)) return true;
|
|
501
|
+
if (found.has('transfer-encoding')) return true;
|
|
502
|
+
const length = found.get('content-length');
|
|
503
|
+
if (length === undefined) return false;
|
|
504
|
+
// Fail closed: a length that is not a plain decimal, negative ones included, is not evidence
|
|
505
|
+
// that there is no body.
|
|
506
|
+
// Field whitespace is SP and HTAB only (RFC 9110 section 5.5); String.prototype.trim also strips
|
|
507
|
+
// NBSP, VT and the rest, which would read such a length as a plain zero (Copilot, PR #5).
|
|
508
|
+
const trimmed = String(length).replace(/^[ \t]+|[ \t]+$/g, '');
|
|
509
|
+
return !/^[0-9]+$/.test(trimmed) || /[1-9]/.test(trimmed);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function checkMinimum(items, minimum, { hasBody, requestHadBody }) {
|
|
513
|
+
const have = new Set(items.map(identity));
|
|
514
|
+
const required = minimum.map(component);
|
|
515
|
+
if (hasBody) required.push(component(CONTENT_DIGEST));
|
|
516
|
+
if (requestHadBody) required.push(component(req(CONTENT_DIGEST)));
|
|
517
|
+
for (const item of required) {
|
|
518
|
+
if (!have.has(identity(item))) {
|
|
519
|
+
throw new InsufficientCoverage(
|
|
520
|
+
`The signature does not cover ${specOf(item)}, which this verifier requires, so it is ` +
|
|
521
|
+
'refused even though it may be valid: a signature over too little is a signature over ' +
|
|
522
|
+
'what an intermediary is free to change.',
|
|
523
|
+
{ component: specOf(item) },
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Enforce the verifier's `maxAge`, then the signer's `expires` (profile section 9). */
|
|
530
|
+
function checkFreshness(params, { maxAge, skew, now: at }) {
|
|
531
|
+
const expires = params.get('expires');
|
|
532
|
+
if (expires === undefined && maxAge === null) return;
|
|
533
|
+
const stamp = at === null || at === undefined ? now() : at;
|
|
534
|
+
|
|
535
|
+
if (maxAge !== null) {
|
|
536
|
+
const created = params.get('created');
|
|
537
|
+
if (created === undefined) {
|
|
538
|
+
throw new SignatureTooOld(
|
|
539
|
+
'This signature carries no created timestamp, so its age cannot be checked against the ' +
|
|
540
|
+
`${maxAge}-second limit you asked for.`,
|
|
541
|
+
{ created: null, now: stamp, maxAge },
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (stamp - created > maxAge + skew) {
|
|
545
|
+
throw new SignatureTooOld(
|
|
546
|
+
`This signature was created at ${created}, which is more than ${maxAge} seconds before ` +
|
|
547
|
+
`${stamp}, so it is too old to accept.`,
|
|
548
|
+
{ created, now: stamp, maxAge },
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (created - stamp > skew) {
|
|
552
|
+
throw new SignatureTooOld(
|
|
553
|
+
`This signature claims to have been created at ${created}, which is in the future ` +
|
|
554
|
+
`relative to ${stamp} by more than the ${skew}-second skew allowance.`,
|
|
555
|
+
{ created, now: stamp, maxAge },
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (expires !== undefined && stamp > expires + skew) {
|
|
561
|
+
throw new SignatureExpired(
|
|
562
|
+
`This signature expired at ${expires} and it is now ${stamp}, so the signer has already ` +
|
|
563
|
+
'declared it should not be accepted.',
|
|
564
|
+
{ expires, now: stamp },
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Pull one signature and its input out of the headers, or say what is wrong with them.
|
|
570
|
+
*
|
|
571
|
+
* In the KERI profile's section 9 order: absence before malformation, the Signature header before
|
|
572
|
+
* Signature-Input, the members' shape before the label count.
|
|
573
|
+
*/
|
|
574
|
+
function read(found, { requireKeyid, requireCreated }) {
|
|
575
|
+
const rawSignature = found.get('signature');
|
|
576
|
+
const rawInput = found.get('signature-input');
|
|
577
|
+
if (!rawSignature) {
|
|
578
|
+
throw new MissingSignature('This message has no Signature header, so there is nothing to verify.');
|
|
579
|
+
}
|
|
580
|
+
if (!rawInput) {
|
|
581
|
+
throw new MissingSignatureInput(
|
|
582
|
+
'This message has no Signature-Input header, so there is no way to know which components a ' +
|
|
583
|
+
'signature would cover.',
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const signatures = parse(rawSignature, 'Signature', MalformedSignature);
|
|
588
|
+
for (const member of signatures.values()) {
|
|
589
|
+
// Draft 6 of the KERI profile would call this malformed-signature, since such a header is
|
|
590
|
+
// neither mode's form; the class stays the one the shared vectors pin (@2f227n4r).
|
|
591
|
+
if (!(member.value instanceof Uint8Array)) {
|
|
592
|
+
throw new MalformedSignatureValue(
|
|
593
|
+
'RFC 9421 carries a signature as an RFC 8941 byte sequence, wrapped in colons; this ' +
|
|
594
|
+
'Signature header carries something else.',
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const inputs = parse(rawInput, 'Signature-Input', MalformedSignatureInput);
|
|
599
|
+
for (const member of inputs.values()) checkInput(member, { requireKeyid, requireCreated });
|
|
600
|
+
|
|
601
|
+
if (inputs.size !== 1 || signatures.size !== 1) {
|
|
602
|
+
throw new MalformedSignatureLabel(
|
|
603
|
+
'fiki verifies a message carrying exactly one signature; this one declares ' +
|
|
604
|
+
`${inputs.size} in Signature-Input and ${signatures.size} in Signature.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const [label] = inputs.keys();
|
|
608
|
+
if (!signatures.has(label)) {
|
|
609
|
+
throw new MissingSignatureLabel(
|
|
610
|
+
`The Signature header carries no entry labelled "${label}", so the covered components ` +
|
|
611
|
+
'describe a signature that is not here.',
|
|
612
|
+
{ label },
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const { value } = signatures.get(label);
|
|
617
|
+
if (value.length !== SIGNATURE_LENGTH) {
|
|
618
|
+
throw new MalformedSignatureValue(
|
|
619
|
+
'RFC 9421 carries an Ed25519 signature as a 64-byte RFC 8941 byte sequence, wrapped in ' +
|
|
620
|
+
'colons; this one is something else.',
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
return { inner: inputs.get(label), signature: value };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Refuse a Signature-Input member fiki would otherwise have to guess about. */
|
|
627
|
+
function checkInput(member, { requireKeyid, requireCreated }) {
|
|
628
|
+
if (member.items === undefined) {
|
|
629
|
+
throw new MalformedSignatureInput(
|
|
630
|
+
'A Signature-Input member is a parenthesized list of covered components; this one is a ' +
|
|
631
|
+
'single value.',
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
for (const item of member.items) {
|
|
635
|
+
if (typeof item.value !== 'string') {
|
|
636
|
+
throw new MalformedSignatureInput('Every covered component is named by a quoted string; one here is not.');
|
|
637
|
+
}
|
|
638
|
+
if (!item.value.startsWith('@') && item.value !== item.value.toLowerCase()) {
|
|
639
|
+
throw new MalformedSignatureInput(
|
|
640
|
+
`The covered field "${item.value}" is not lowercase, and RFC 9421 section 2.1 requires ` +
|
|
641
|
+
'field names in the covered list to be lowercased by the signer.',
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (requireKeyid && !member.params.has('keyid')) {
|
|
646
|
+
// Here rather than when the key is resolved: keyid is REQUIRED, so its absence belongs with
|
|
647
|
+
// the other defects of Signature-Input, ahead of the covered list (@2f227n4r).
|
|
648
|
+
throw new MissingKey(
|
|
649
|
+
'This signature carries no keyid, and this verifier needs one: either no expectedAid names ' +
|
|
650
|
+
'the key, or a minimum applies the KERI profile, where keyid is REQUIRED.',
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (requireCreated && !member.params.has('created')) {
|
|
654
|
+
// Only under a minimum, which is how a caller applies the KERI profile, where created is
|
|
655
|
+
// REQUIRED. RFC 9421 makes it optional, and without a minimum it stays so (@7p9s3g9k).
|
|
656
|
+
throw new MalformedSignatureInput(
|
|
657
|
+
"This signature carries no created timestamp, which the verifier's policy requires.",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
for (const [name, value] of member.params) {
|
|
661
|
+
const expected = SIGNATURE_PARAMS.get(name);
|
|
662
|
+
if (expected === undefined) {
|
|
663
|
+
throw new MalformedSignatureInput(
|
|
664
|
+
`The signature parameter "${name}" is not one fiki understands; it accepts ` +
|
|
665
|
+
`${[...SIGNATURE_PARAMS.keys()].join(', ')}.`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (typeof value !== expected) {
|
|
669
|
+
throw new MalformedSignatureInput(
|
|
670
|
+
`The signature parameter "${name}" must be ${expected === 'number' ? 'an integer' : 'a quoted string'}.`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function parse(raw, name, ErrorClass) {
|
|
677
|
+
try {
|
|
678
|
+
return parseDictionary(raw);
|
|
679
|
+
} catch {
|
|
680
|
+
throw new ErrorClass(`I could not parse the ${name} header; RFC 9421 spells it as an RFC 8941 dictionary.`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The key to verify with, the identity to report, and the keyid as received.
|
|
685
|
+
*
|
|
686
|
+
* In section 9's order as far as fiki can see it without asking anyone (@0ekvjgsp): the keyid's
|
|
687
|
+
* own spelling (malformed-key), then whether it is the one the client expected (unknown-key), and
|
|
688
|
+
* only then the resolver. A keyid that is not the expected one is refused without a lookup, so
|
|
689
|
+
* untrusted input never makes the verifier wait on one; the resolver's own refusals therefore
|
|
690
|
+
* apply only to the expected keyid.
|
|
691
|
+
*/
|
|
692
|
+
async function resolveKey(expectedAid, keyid, resolve, expectedKeyid) {
|
|
693
|
+
const expect = () => {
|
|
694
|
+
if (expectedKeyid !== null && keyid !== expectedKeyid) {
|
|
695
|
+
throw new UnknownKey(`This message is signed by "${keyid}", and the one expected is "${expectedKeyid}".`, {
|
|
696
|
+
keyid,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
if (expectedAid !== null) {
|
|
701
|
+
const raw = verifyingKey(expectedAid);
|
|
702
|
+
expect();
|
|
703
|
+
return { raw, aid: toAid(raw), keyid };
|
|
704
|
+
}
|
|
705
|
+
if (!keyid) {
|
|
706
|
+
throw new MissingKey(
|
|
707
|
+
'This signature carries no keyid and no expectedAid was supplied, so there is no key to ' +
|
|
708
|
+
'verify it against.',
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (resolve !== null) {
|
|
712
|
+
if (misspelledAid(keyid)) {
|
|
713
|
+
throw new MalformedKey(
|
|
714
|
+
`The keyid "${keyid}" is shaped like an AID and is not its canonical spelling, so it is ` +
|
|
715
|
+
'not an AID at all.',
|
|
716
|
+
{ keyid },
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
expect();
|
|
720
|
+
// The resolver is authoritative: fiki never falls back to decoding the keyid, because a
|
|
721
|
+
// transferable prefix that embeds a key embeds its INCEPTION key (@6g9zjsv9).
|
|
722
|
+
const raw = await resolve(keyid);
|
|
723
|
+
if (raw === null || raw === undefined) {
|
|
724
|
+
throw new UnknownKey(`No key is known for the keyid "${keyid}", so the signature cannot be checked.`, { keyid });
|
|
725
|
+
}
|
|
726
|
+
if (!(raw instanceof Uint8Array) || raw.length !== KEY_LENGTH) {
|
|
727
|
+
throw new MalformedKey(`The key resolved for "${keyid}" is not a ${KEY_LENGTH}-byte Ed25519 public key.`, {
|
|
728
|
+
keyid,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return { raw: checkKey(raw, keyid), aid: keyid, keyid };
|
|
732
|
+
}
|
|
733
|
+
// Strictly: a lenient decoder discards characters outside the alphabet and ignores trailing
|
|
734
|
+
// bits, so a keyid that is not the key's encoding could verify as whatever key it happened to
|
|
735
|
+
// decode to. Only the one canonical spelling is a key.
|
|
736
|
+
const raw = RAW_KEYID.test(keyid) ? fromBase64Url(keyid) : null;
|
|
737
|
+
if (raw === null || toBase64Url(raw) !== keyid) {
|
|
738
|
+
throw new MalformedKey(
|
|
739
|
+
`The keyid "${keyid}" is not the canonical base64url spelling of a 32-byte Ed25519 public ` +
|
|
740
|
+
'key: that is exactly 43 characters from the base64url alphabet, unpadded.',
|
|
741
|
+
{ keyid },
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
checkKey(raw, keyid);
|
|
745
|
+
expect();
|
|
746
|
+
return { raw, aid: toAid(raw), keyid };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Parse a Content-Digest into the members fiki computes, or refuse it as MalformedDigest.
|
|
750
|
+
*
|
|
751
|
+
* Separate from the comparison so that a verifier holding two covered digests can parse both
|
|
752
|
+
* before hashing either: section 9 of the KERI profile puts malformed-digest first.
|
|
753
|
+
*/
|
|
754
|
+
function readDigest(header) {
|
|
755
|
+
const parsed = parse(header ?? '', 'Content-Digest', MalformedDigest);
|
|
756
|
+
const recognized = [];
|
|
757
|
+
for (const [name, member] of parsed) {
|
|
758
|
+
const algorithm = DIGEST_ALGORITHMS.get(name);
|
|
759
|
+
if (algorithm === undefined) continue;
|
|
760
|
+
if (!(member.value instanceof Uint8Array)) {
|
|
761
|
+
throw new MalformedDigest(
|
|
762
|
+
`The ${name} Content-Digest is not an RFC 8941 byte sequence, so it cannot be compared with anything.`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
recognized.push([name, algorithm, member.value]);
|
|
766
|
+
}
|
|
767
|
+
if (recognized.length === 0) {
|
|
768
|
+
throw new MalformedDigest(
|
|
769
|
+
'The Content-Digest header names no algorithm fiki computes; it computes ' +
|
|
770
|
+
`${[...DIGEST_ALGORITHMS.keys()].sort().join(' and ')}.`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
return recognized;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Recompute the digest over the body actually received (@2hwvpm42).
|
|
777
|
+
*
|
|
778
|
+
* The header is covered by the signature, so it cannot have been tampered with — but a covered
|
|
779
|
+
* digest still only attests to a body nobody hashed until somebody hashes it.
|
|
780
|
+
*/
|
|
781
|
+
async function compareDigest(recognized, body) {
|
|
782
|
+
if (body === null) {
|
|
783
|
+
throw new DigestMismatch(
|
|
784
|
+
'The signature covers content-digest, but no body was supplied to check it against, so the ' +
|
|
785
|
+
'body is unverified.',
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
for (const [name, algorithm, expected] of recognized) {
|
|
789
|
+
const digest = new Uint8Array(await crypto.subtle.digest(algorithm, body));
|
|
790
|
+
if (!equal(digest, expected)) {
|
|
791
|
+
throw new DigestMismatch(
|
|
792
|
+
`The body does not match its ${name} Content-Digest, so the body is not the one that was signed.`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|