provared 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1083 -0
- package/README.md +6 -3
- package/bin/provared-check.mjs +115 -11
- package/docs/guide.md +464 -15
- package/docs/threat-model.md +315 -9
- package/package.json +2 -2
- package/page/check-page.js +11 -32
- package/page/index.html +4 -0
- package/page/render.js +157 -9
- package/spec/provared-format.md +809 -23
- package/src/book.js +539 -18
- package/src/check.d.ts +3 -0
- package/src/check.js +2 -0
- package/src/denial.js +123 -0
- package/src/der.js +55 -6
- package/src/dev.d.ts +1 -1
- package/src/dev.js +1 -1
- package/src/documents.js +149 -0
- package/src/guard.js +13 -2
- package/src/index.js +18 -3
- package/src/jws.js +8 -0
- package/src/keys.js +48 -1
- package/src/pass.js +1 -1
- package/src/recorder.js +267 -15
- package/src/seal.js +36 -7
- package/src/sealer.js +812 -0
- package/src/service.js +4 -4
- package/src/signatures.js +281 -5
- package/src/standing.js +4 -4
- package/src/stub.js +18 -5
- package/src/timestamp.js +55 -16
- package/src/tools.js +30 -13
- package/src/tree.js +139 -7
- package/src/types.d.ts +314 -20
- package/src/witness.js +63 -0
- package/src/witnessing.js +362 -0
package/src/service.js
CHANGED
|
@@ -79,7 +79,7 @@ export function validateTermsContent(c) {
|
|
|
79
79
|
* @param {{unit: string, value: number}} [fields.amount]
|
|
80
80
|
* @param {string} fields.reason one of REFUSAL_REASONS
|
|
81
81
|
* @param {number|Date} [fields.when] defaults to now
|
|
82
|
-
* @param {
|
|
82
|
+
* @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
|
|
83
83
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
84
84
|
*/
|
|
85
85
|
export async function writeRefusal(fields, privateKeys) {
|
|
@@ -94,7 +94,7 @@ export async function writeRefusal(fields, privateKeys) {
|
|
|
94
94
|
};
|
|
95
95
|
if (fields.amount) content.amount = fields.amount;
|
|
96
96
|
validateRefusalContent(content);
|
|
97
|
-
return signWithKeySet('refusal', content, privateKeys);
|
|
97
|
+
return signWithKeySet('refusal', content, privateKeys, content.by.keys);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
@@ -106,7 +106,7 @@ export async function writeRefusal(fields, privateKeys) {
|
|
|
106
106
|
* @param {string[]} [fields.never] kinds of action and rules of conduct the service asks agents to keep
|
|
107
107
|
* @param {string} fields.validFrom
|
|
108
108
|
* @param {string} fields.validUntil
|
|
109
|
-
* @param {
|
|
109
|
+
* @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
|
|
110
110
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
111
111
|
*/
|
|
112
112
|
export async function writeTerms(fields, privateKeys) {
|
|
@@ -120,5 +120,5 @@ export async function writeTerms(fields, privateKeys) {
|
|
|
120
120
|
validUntil: fields.validUntil,
|
|
121
121
|
};
|
|
122
122
|
validateTermsContent(content);
|
|
123
|
-
return signWithKeySet('terms', content, privateKeys);
|
|
123
|
+
return signWithKeySet('terms', content, privateKeys, content.by.keys);
|
|
124
124
|
}
|
package/src/signatures.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// this file still loads in a browser. In a browser the method is reported as
|
|
14
14
|
// not built in, which is never a pass.
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { Refusal } from './encoding.js';
|
|
17
|
+
import { badEd25519Signature, checkKey, importPublicKey, thumbprint } from './keys.js';
|
|
17
18
|
|
|
18
19
|
const SLH = 'SLH-DSA-SHA2-256s';
|
|
19
20
|
|
|
@@ -98,10 +99,11 @@ export async function verifySignature(jwk, signature, data, without = []) {
|
|
|
98
99
|
if (!Object.hasOwn(VERIFY_PARAMS, jwk.alg)) return 'invalid';
|
|
99
100
|
const params = VERIFY_PARAMS[jwk.alg];
|
|
100
101
|
if (without.includes(jwk.alg) || !(await methodAvailable(jwk.alg))) return 'unavailable';
|
|
101
|
-
// An Ed25519 signature whose first half is a point of small order
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
102
|
+
// An Ed25519 signature whose first half is a point of small order, or
|
|
103
|
+
// whose second half, the number S, is not below the order L, is refused
|
|
104
|
+
// here, whatever the device's own library would say: libraries differ on
|
|
105
|
+
// both (format description, section 3.8).
|
|
106
|
+
if (jwk.alg === 'Ed25519' && badEd25519Signature(signature)) return 'invalid';
|
|
105
107
|
try {
|
|
106
108
|
const key = await importPublicKey(jwk);
|
|
107
109
|
return (await globalThis.crypto.subtle.verify(params, key, signature, data)) ? 'valid' : 'invalid';
|
|
@@ -122,6 +124,280 @@ export async function sign(alg, privateKey, data) {
|
|
|
122
124
|
return new Uint8Array(await globalThis.crypto.subtle.sign({ name: alg }, privateKey, data));
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
// --- signers ---
|
|
128
|
+
//
|
|
129
|
+
// Wherever a writer takes "privateKeys", each item may be a private key of
|
|
130
|
+
// the platform (a Web Crypto CryptoKey, or a Node.js KeyObject), used as it
|
|
131
|
+
// always was, or a signer: an object with "key", the public key as a JSON
|
|
132
|
+
// Web Key in one of the shapes of the format description, section 3.8, and
|
|
133
|
+
// "sign(data)", which gives the signature over exactly those bytes. A
|
|
134
|
+
// signer lets a key live elsewhere: in a key service, or a hardware module.
|
|
135
|
+
// It changes where a signature is made, not how: the record is the same.
|
|
136
|
+
//
|
|
137
|
+
// A signer is not taken on trust. Its two members are read once. Its key
|
|
138
|
+
// must be of the method of its place in the list, a method this device can
|
|
139
|
+
// check, and, where the writer knows the key the record must be signed
|
|
140
|
+
// with, that key. All of this is settled before any signer is asked to
|
|
141
|
+
// sign. Every signature it gives is checked against its key before it is
|
|
142
|
+
// used. A signer that fails in any way leaves no record, and the error
|
|
143
|
+
// names the method and the key. Signers earlier in the list may have
|
|
144
|
+
// signed by then: a key service's own log may hold signatures that match
|
|
145
|
+
// no record.
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether an item of "privateKeys" is a private key of the platform for the
|
|
149
|
+
* method of its place: a private Web Crypto CryptoKey of that method that
|
|
150
|
+
* may sign, or for SLH-DSA a private Node.js KeyObject of that kind. What
|
|
151
|
+
* only looks like one (an object made from CryptoKey's own prototype, a
|
|
152
|
+
* public key, a key of another method) is not one.
|
|
153
|
+
* @param {any} item
|
|
154
|
+
* @param {string} alg
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
function platformKey(item, alg) {
|
|
158
|
+
try {
|
|
159
|
+
if (alg === SLH) return Boolean(nodeCrypto) && item instanceof nodeCrypto.KeyObject && item.type === 'private' && item.asymmetricKeyType === 'slh-dsa-sha2-256s';
|
|
160
|
+
return typeof globalThis.CryptoKey === 'function' && item instanceof globalThis.CryptoKey && item.type === 'private' && item.algorithm.name === alg && item.usages.includes('sign');
|
|
161
|
+
} catch {
|
|
162
|
+
return false; // a value that cannot even be looked into (a revoked Proxy), or one that only looks like a key
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// What a place in "privateKeys" must hold.
|
|
167
|
+
const signerShape = (alg) =>
|
|
168
|
+
`must be a private key for ${alg} (${alg === SLH ? 'a Node.js KeyObject, or what Node.js reads as one: PEM text, DER bytes with their format, or a JSON Web Key with its format' : 'a Web Crypto CryptoKey'}), or a signer: an object with "key", the public key, and "sign", a function that gives the signature bytes.`;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* In the SLH-DSA place, the seal writer has always handed the key to
|
|
172
|
+
* Node.js's own signing call, which also reads a key written out: PEM text,
|
|
173
|
+
* DER bytes with their format, or a JSON Web Key with its format. Such a
|
|
174
|
+
* key is still taken: it is read exactly as that call read it (with
|
|
175
|
+
* crypto.createPrivateKey, which the call itself uses), and then it must be
|
|
176
|
+
* a private SLH-DSA-SHA2-256s key, as a KeyObject must.
|
|
177
|
+
* @param {any} item
|
|
178
|
+
* @param {string} where
|
|
179
|
+
* @returns {any} the key, as a KeyObject
|
|
180
|
+
*/
|
|
181
|
+
function writtenSlhKey(item, where) {
|
|
182
|
+
let made;
|
|
183
|
+
try {
|
|
184
|
+
made = nodeCrypto.createPrivateKey(item);
|
|
185
|
+
} catch {
|
|
186
|
+
made = null; // not a private key Node.js can read, or no Node.js here
|
|
187
|
+
}
|
|
188
|
+
if (!platformKey(made, SLH)) throw new Refusal('bad-field', `${where}: ${signerShape(SLH)}`);
|
|
189
|
+
return made;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A signer that gave no answer in the time the stub writer gives it.
|
|
193
|
+
class NoAnswer extends Error {
|
|
194
|
+
constructor(ms) {
|
|
195
|
+
super(`no answer within ${ms} milliseconds`);
|
|
196
|
+
this.ms = ms;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Two public keys that have passed checkKey: the same key, member for member.
|
|
201
|
+
function sameKey(a, b) {
|
|
202
|
+
const names = Object.keys(b);
|
|
203
|
+
return Object.keys(a).length === names.length && names.every((n) => a[n] === b[n]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The signature bytes a signer gave, copied, or null where it gave anything
|
|
207
|
+
// else. Bytes are a Uint8Array (a Node.js Buffer is one) or an ArrayBuffer,
|
|
208
|
+
// which is what Web Crypto's own "sign" gives.
|
|
209
|
+
function bytesOf(given) {
|
|
210
|
+
try {
|
|
211
|
+
if (given instanceof Uint8Array) return new Uint8Array(given);
|
|
212
|
+
if (given instanceof ArrayBuffer) return new Uint8Array(given.slice(0));
|
|
213
|
+
} catch {
|
|
214
|
+
// fall through: not bytes that can be read
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read one item of "privateKeys" for the method of its place.
|
|
221
|
+
* @param {any} item
|
|
222
|
+
* @param {string} alg
|
|
223
|
+
* @param {string} where its place, for messages
|
|
224
|
+
* @param {object} [expected] the public key the record must be signed with, where the writer knows it
|
|
225
|
+
* @returns {Promise<{platform: boolean, item: any, key?: object, call?: (data: Uint8Array) => any, sign: (data: Uint8Array) => Promise<Uint8Array>}>}
|
|
226
|
+
*/
|
|
227
|
+
async function takeItem(item, alg, where, expected) {
|
|
228
|
+
const platform = (privateKey) => ({ platform: true, item: privateKey, sign: (data) => sign(alg, privateKey, data) });
|
|
229
|
+
if (platformKey(item, alg)) return platform(item);
|
|
230
|
+
let key;
|
|
231
|
+
let fn;
|
|
232
|
+
if (item !== null && (typeof item === 'object' || typeof item === 'function')) {
|
|
233
|
+
try {
|
|
234
|
+
key = item.key;
|
|
235
|
+
fn = item.sign;
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Refusal('bad-field', `${where}: could not be read.`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (typeof fn !== 'function') {
|
|
241
|
+
// Not a signer: in the SLH-DSA place, perhaps a key written out.
|
|
242
|
+
if (alg === SLH && item !== null && item !== undefined) return platform(writtenSlhKey(item, where));
|
|
243
|
+
throw new Refusal('bad-field', `${where}: ${signerShape(alg)}`);
|
|
244
|
+
}
|
|
245
|
+
if (key === undefined || key === null) throw new Refusal('bad-field', `${where}: ${signerShape(alg)}`);
|
|
246
|
+
let copy;
|
|
247
|
+
try {
|
|
248
|
+
copy = structuredClone(key);
|
|
249
|
+
} catch {
|
|
250
|
+
throw new Refusal('bad-key', `${where}.key: must be a public key, as plain data.`);
|
|
251
|
+
}
|
|
252
|
+
checkKey(copy, [alg], `${where}.key`);
|
|
253
|
+
Object.freeze(copy);
|
|
254
|
+
const label = `the ${alg} key ${await thumbprint(copy)}`;
|
|
255
|
+
if (expected && !sameKey(copy, expected)) {
|
|
256
|
+
throw new Refusal('bad-key', `${where}: the signer holds ${label}, which is not the key this record must be signed with (${await thumbprint(expected)}).`);
|
|
257
|
+
}
|
|
258
|
+
// Nothing is signed that this device cannot check: so a signer for a
|
|
259
|
+
// method it does not have built in is refused before any signer is asked.
|
|
260
|
+
if (!(await methodAvailable(alg))) {
|
|
261
|
+
throw new Refusal('bad-key', `${where}: this device does not have ${alg} built in, so it cannot check a signature from the signer for ${label}. No record was made.`);
|
|
262
|
+
}
|
|
263
|
+
const call = (data) => fn.call(item, data);
|
|
264
|
+
return { platform: false, item, key: copy, call, sign: (data) => signerSignature(call, copy, alg, label, where, data) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// One signature from a signer, checked before it is used.
|
|
268
|
+
async function signerSignature(call, key, alg, label, where, data) {
|
|
269
|
+
const failed = (why, cause) => {
|
|
270
|
+
const e = new Refusal('signature-invalid', `${where}: the signer for ${label} ${why} No record was made.`);
|
|
271
|
+
if (cause !== undefined) e.cause = cause;
|
|
272
|
+
return e;
|
|
273
|
+
};
|
|
274
|
+
let given;
|
|
275
|
+
try {
|
|
276
|
+
// The signer is handed a copy: nothing it does to it changes what is checked.
|
|
277
|
+
given = await call(data.slice());
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (e instanceof NoAnswer) throw failed(`gave no answer within ${e.ms} milliseconds.`);
|
|
280
|
+
throw failed('failed with an error.', e);
|
|
281
|
+
}
|
|
282
|
+
const signature = bytesOf(given);
|
|
283
|
+
if (signature === null) throw failed('gave something other than bytes.');
|
|
284
|
+
if (Object.hasOwn(SIGNATURE_BYTES, alg) && signature.length !== SIGNATURE_BYTES[alg]) {
|
|
285
|
+
throw failed(`gave a signature of ${signature.length} bytes, where an ${alg} signature is ${SIGNATURE_BYTES[alg]}.`);
|
|
286
|
+
}
|
|
287
|
+
// Whether the method is built in was settled before any signer was asked;
|
|
288
|
+
// a signature that cannot be checked even so is not used.
|
|
289
|
+
const state = await verifySignature(key, signature, data);
|
|
290
|
+
if (state !== 'valid') throw failed('gave a signature that does not check against its key: it holds another key, or signed other bytes.');
|
|
291
|
+
return signature;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Read every item of "privateKeys", one for each method, before anything is
|
|
296
|
+
* signed.
|
|
297
|
+
* @param {any} privateKeys
|
|
298
|
+
* @param {string[]} methods
|
|
299
|
+
* @param {object[]} [expected] the public keys the record must be signed with, where the writer knows them
|
|
300
|
+
* @returns {Promise<Awaited<ReturnType<typeof takeItem>>[]>}
|
|
301
|
+
*/
|
|
302
|
+
async function takeItems(privateKeys, methods, expected) {
|
|
303
|
+
let list;
|
|
304
|
+
try {
|
|
305
|
+
list = Array.isArray(privateKeys);
|
|
306
|
+
} catch {
|
|
307
|
+
list = false; // a revoked Proxy
|
|
308
|
+
}
|
|
309
|
+
if (!list) throw new Refusal('bad-field', `privateKeys: must be a list, one private key or signer for each of ${methods.join(', ')}.`);
|
|
310
|
+
const taken = [];
|
|
311
|
+
for (let i = 0; i < methods.length; i++) {
|
|
312
|
+
let item;
|
|
313
|
+
try {
|
|
314
|
+
item = privateKeys[i];
|
|
315
|
+
} catch {
|
|
316
|
+
throw new Refusal('bad-field', `privateKeys[${i}]: could not be read.`);
|
|
317
|
+
}
|
|
318
|
+
taken.push(await takeItem(item, methods[i], `privateKeys[${i}]`, expected ? expected[i] : undefined));
|
|
319
|
+
}
|
|
320
|
+
return taken;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The functions that sign, one for each method, for a writer that signs
|
|
325
|
+
* now. Keys of the platform sign as they always did; each signer is read
|
|
326
|
+
* and checked as set out above.
|
|
327
|
+
* @param {any} privateKeys
|
|
328
|
+
* @param {string[]} methods
|
|
329
|
+
* @param {object[]} [expected]
|
|
330
|
+
* @returns {Promise<((data: Uint8Array) => Promise<Uint8Array>)[]>}
|
|
331
|
+
*/
|
|
332
|
+
export async function signingKeys(privateKeys, methods, expected) {
|
|
333
|
+
return (await takeItems(privateKeys, methods, expected)).map((t) => t.sign);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* For a writer that holds its keys, such as the stub writer: the list to
|
|
338
|
+
* use in place of "privateKeys". Keys of the platform are kept as they
|
|
339
|
+
* are. Each signer is read once, here, and checked against the key the
|
|
340
|
+
* records must be signed with where that is known; in its place stands a
|
|
341
|
+
* signer that waits "ms" milliseconds at most for each signature, through
|
|
342
|
+
* "run". A late answer is not used.
|
|
343
|
+
* @param {any} privateKeys
|
|
344
|
+
* @param {string[]} methods
|
|
345
|
+
* @param {object[]|undefined} expected
|
|
346
|
+
* @param {number} ms
|
|
347
|
+
* @param {(work: () => Promise<any>) => Promise<any>} [run]
|
|
348
|
+
* @returns {Promise<any[]>}
|
|
349
|
+
*/
|
|
350
|
+
export async function holdSigningKeys(privateKeys, methods, expected, ms, run = (work) => work()) {
|
|
351
|
+
return (await takeItems(privateKeys, methods, expected)).map((t) => {
|
|
352
|
+
if (t.platform) return t.item;
|
|
353
|
+
const waited = (data) =>
|
|
354
|
+
new Promise((resolve, reject) => {
|
|
355
|
+
const timer = setTimeout(() => reject(new NoAnswer(ms)), ms);
|
|
356
|
+
let answer;
|
|
357
|
+
try {
|
|
358
|
+
answer = Promise.resolve(t.call(data));
|
|
359
|
+
} catch (e) {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
reject(e);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
answer.then(
|
|
365
|
+
(value) => {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
resolve(value);
|
|
368
|
+
},
|
|
369
|
+
(e) => {
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
reject(e);
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
return Object.freeze({ key: t.key, sign: (data) => run(() => waited(data)) });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Wrap a private key held in memory as a signer, so that a key in memory
|
|
381
|
+
* and a key held elsewhere take one path.
|
|
382
|
+
* @param {string} alg 'Ed25519', 'ML-DSA-87' or 'SLH-DSA-SHA2-256s'
|
|
383
|
+
* @param {any} privateKey a Web Crypto CryptoKey, or for SLH-DSA a Node.js KeyObject
|
|
384
|
+
* @param {object} publicJwk its public key, in one of the shapes of the format description, section 3.8
|
|
385
|
+
* @returns {{key: object, sign: (data: Uint8Array) => Promise<Uint8Array>}}
|
|
386
|
+
*/
|
|
387
|
+
export function signerFromKey(alg, privateKey, publicJwk) {
|
|
388
|
+
if (alg !== 'Ed25519' && alg !== 'ML-DSA-87' && alg !== SLH) throw new Refusal('bad-field', `alg: must be Ed25519, ML-DSA-87 or ${SLH}.`);
|
|
389
|
+
// Web Crypto holds the first two methods; Node.js's own module the third.
|
|
390
|
+
if (!platformKey(privateKey, alg)) throw new Refusal('bad-field', `privateKey: must be a private key for ${alg}: ${alg === SLH ? 'a Node.js KeyObject' : 'a Web Crypto CryptoKey'}.`);
|
|
391
|
+
let key;
|
|
392
|
+
try {
|
|
393
|
+
key = structuredClone(publicJwk);
|
|
394
|
+
} catch {
|
|
395
|
+
throw new Refusal('bad-key', 'key: must be a public key, as plain data.');
|
|
396
|
+
}
|
|
397
|
+
checkKey(key, [alg], 'key');
|
|
398
|
+
return Object.freeze({ key: Object.freeze(key), sign: (data) => sign(alg, privateKey, data) });
|
|
399
|
+
}
|
|
400
|
+
|
|
125
401
|
/**
|
|
126
402
|
* Make a new key set for a recorder, which signs seals: an Ed25519 key, an
|
|
127
403
|
* ML-DSA-87 key and an SLH-DSA-SHA2-256s key. It needs Node.js: no browser
|
package/src/standing.js
CHANGED
|
@@ -122,7 +122,7 @@ export function assembleCancellation(prepared, assertion) {
|
|
|
122
122
|
* @param {string} fields.cancellation the fingerprint of the cancellation
|
|
123
123
|
* @param {string} [fields.pass] for a helper agent: the fingerprint of the pass it acts under
|
|
124
124
|
* @param {number|Date} [fields.when] when the cancellation was handed over; defaults to now
|
|
125
|
-
* @param {
|
|
125
|
+
* @param {any[]} privateKeys the agent's private keys, or signers: Ed25519, then ML-DSA-87
|
|
126
126
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
127
127
|
*/
|
|
128
128
|
export async function writeAcknowledgement(fields, privateKeys) {
|
|
@@ -148,7 +148,7 @@ export async function writeAcknowledgement(fields, privateKeys) {
|
|
|
148
148
|
* @param {string} fields.validFrom
|
|
149
149
|
* @param {string} fields.validUntil
|
|
150
150
|
* @param {number|Date} [fields.when] defaults to now
|
|
151
|
-
* @param {
|
|
151
|
+
* @param {any[]} privateKeys the organisation's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
|
|
152
152
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
153
153
|
*/
|
|
154
154
|
export async function writeVouching(fields, privateKeys) {
|
|
@@ -162,7 +162,7 @@ export async function writeVouching(fields, privateKeys) {
|
|
|
162
162
|
when: formatTime(fields.when ?? Date.now()),
|
|
163
163
|
};
|
|
164
164
|
validateVouchingContent(content);
|
|
165
|
-
return signWithKeySet('vouching', content, privateKeys);
|
|
165
|
+
return signWithKeySet('vouching', content, privateKeys, content.by.keys);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
@@ -171,7 +171,7 @@ export async function writeVouching(fields, privateKeys) {
|
|
|
171
171
|
* @param {object} fields
|
|
172
172
|
* @param {string} fields.vouching the fingerprint of the vouching record
|
|
173
173
|
* @param {number|Date} [fields.when] defaults to now
|
|
174
|
-
* @param {
|
|
174
|
+
* @param {any[]} privateKeys the private keys of the organisation that made the vouching record, or signers
|
|
175
175
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
176
176
|
*/
|
|
177
177
|
export async function writeWithdrawal(fields, privateKeys) {
|
package/src/stub.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// The Stub (the receipt for one action) and the countersignature.
|
|
2
2
|
// Format description, sections 5 and 6.
|
|
3
3
|
|
|
4
|
+
import { DOCUMENT_NAMES, REPEATABLE_DOCUMENT_NAMES, isUnknownReservedDocument } from './documents.js';
|
|
4
5
|
import { fingerprint, formatTime, randomId, toBase64url } from './encoding.js';
|
|
5
6
|
import * as f from './fields.js';
|
|
6
7
|
import { encodeContent, parseRecord, protectedHeaders, signingInput, withinSize, KINDS } from './jws.js';
|
|
7
8
|
import { KEY_SET_METHODS } from './keys.js';
|
|
8
|
-
import {
|
|
9
|
+
import { signingKeys } from './signatures.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Confirm every member of a stub's content (format description, section 5.1).
|
|
@@ -48,9 +49,17 @@ export function validateRequest(c) {
|
|
|
48
49
|
if (Object.hasOwn(c, 'with')) f.shortName(c.with, 'with');
|
|
49
50
|
if (Object.hasOwn(c, 'details')) {
|
|
50
51
|
f.list(c.details, 1, 32, 'details');
|
|
52
|
+
const once = new Set();
|
|
51
53
|
c.details.forEach((d, i) => {
|
|
52
54
|
f.members(d, ['name', 'sha256'], [], `details[${i}]`);
|
|
53
55
|
f.label(d.name, `details[${i}].name`);
|
|
56
|
+
if (isUnknownReservedDocument(d.name)) throw f.fail(`details[${i}].name`, 'names that begin "provared." are reserved, and this one is not on the list of document names.');
|
|
57
|
+
if (Object.hasOwn(DOCUMENT_NAMES, d.name) && !REPEATABLE_DOCUMENT_NAMES.includes(d.name)) {
|
|
58
|
+
if (once.has(d.name)) {
|
|
59
|
+
throw f.fail(`details[${i}].name`, 'this reserved name is named twice. Only provared.trace-id, provared.request-id and provared.event-id may be named more than once.');
|
|
60
|
+
}
|
|
61
|
+
once.add(d.name);
|
|
62
|
+
}
|
|
54
63
|
f.fingerprintText(d.sha256, `details[${i}].sha256`);
|
|
55
64
|
});
|
|
56
65
|
}
|
|
@@ -71,12 +80,16 @@ export function validateCountersignatureContent(c) {
|
|
|
71
80
|
|
|
72
81
|
// Sign content with a key set: two signatures side by side, Ed25519 then
|
|
73
82
|
// ML-DSA-87, each over its own signing input (format description, 3.9).
|
|
74
|
-
|
|
83
|
+
// Each item of "privateKeys" is a key or a signer (signatures.js);
|
|
84
|
+
// "expected" is the key set the record must be signed with, where the
|
|
85
|
+
// writer knows it, and a signer that holds another key is refused.
|
|
86
|
+
export async function signWithKeySet(kind, content, privateKeys, expected) {
|
|
75
87
|
const { contentBytes, payloadB64 } = encodeContent(content);
|
|
76
88
|
const headers = protectedHeaders(kind);
|
|
89
|
+
const signers = await signingKeys(privateKeys, KEY_SET_METHODS, expected);
|
|
77
90
|
const signatures = [];
|
|
78
91
|
for (let i = 0; i < KEY_SET_METHODS.length; i++) {
|
|
79
|
-
const signature = await
|
|
92
|
+
const signature = await signers[i](signingInput(headers[i], payloadB64));
|
|
80
93
|
signatures.push({ protected: headers[i], signature: toBase64url(signature) });
|
|
81
94
|
}
|
|
82
95
|
return { record: withinSize({ payload: payloadB64, signatures }), fingerprint: await fingerprint(contentBytes) };
|
|
@@ -96,7 +109,7 @@ export async function signWithKeySet(kind, content, privateKeys) {
|
|
|
96
109
|
* @param {string} [fields.terms] the fingerprint of the service's terms for agents that the agent relied on
|
|
97
110
|
* @param {string} [fields.pass] for a helper agent: the fingerprint of the pass it acts under; "after" is then the stub before it under that pass
|
|
98
111
|
* @param {number|Date} [fields.when] defaults to now
|
|
99
|
-
* @param {
|
|
112
|
+
* @param {any[]} privateKeys the agent's private keys, or signers (signatures.js): Ed25519, then ML-DSA-87
|
|
100
113
|
* @returns {Promise<{record: object, fingerprint: string, seq: number}>}
|
|
101
114
|
*/
|
|
102
115
|
export async function writeStub(fields, privateKeys) {
|
|
@@ -128,7 +141,7 @@ export async function writeStub(fields, privateKeys) {
|
|
|
128
141
|
* reads the stub's fingerprint; it does not judge the stub.
|
|
129
142
|
*
|
|
130
143
|
* @param {object} stubRecord the stub as the agent sent it
|
|
131
|
-
* @param {
|
|
144
|
+
* @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87
|
|
132
145
|
* @param {number|Date} [when] defaults to now
|
|
133
146
|
* @returns {Promise<object>} the countersignature record
|
|
134
147
|
*/
|
package/src/timestamp.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// service's certificate. It follows no chain of certificates and asks no
|
|
10
10
|
// outside party: the person checking decides whom to trust.
|
|
11
11
|
|
|
12
|
-
import { TAG, badStamp, children, contentOf, ecdsaToRaw, expect, hexOf, readElement, timeOf, validateDer, wholeOf } from './der.js';
|
|
12
|
+
import { TAG, badStamp, children, contentOf, ecdsaToRaw, expect, hexOf, readElement, setInOrder, timeOf, validateDer, wholeOf } from './der.js';
|
|
13
13
|
import { Refusal, equalBytes, formatTime, sha256, toBase64url } from './encoding.js';
|
|
14
|
-
import { weakEd25519 } from './keys.js';
|
|
14
|
+
import { badEd25519Signature, weakEd25519 } from './keys.js';
|
|
15
15
|
|
|
16
16
|
/** The largest time-stamp, in bytes. */
|
|
17
17
|
export const MAX_STAMP_BYTES = 12288;
|
|
@@ -162,6 +162,8 @@ function checkKeyEncoding(bytes, certificate, method, size) {
|
|
|
162
162
|
// force, and its public key.
|
|
163
163
|
function readCertificate(bytes, element) {
|
|
164
164
|
const whole = wholeOf(bytes, expect(element, TAG.SEQUENCE, 'a certificate'));
|
|
165
|
+
// A certificate is DER (RFC 5280 section 4.1), so the order of every set inside it is held here.
|
|
166
|
+
validateDer(bytes, element);
|
|
165
167
|
const tbs = expect(children(bytes, element, 3)[0], TAG.SEQUENCE, 'the body of a certificate');
|
|
166
168
|
const parts = children(bytes, tbs, 12);
|
|
167
169
|
// The version, in [0], is absent only in the oldest kind of certificate.
|
|
@@ -204,14 +206,17 @@ function signingCertificateOf(bytes, value, v2) {
|
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
const KIND_NOT_ACCEPTED = 'A time-stamp was signed with a kind of key that is not accepted: RSA, ECDSA on the curves P-256 and P-384, and Ed25519 are.';
|
|
209
|
+
const ED25519_DIGEST = 'A time-stamp signed with Ed25519 names a fingerprint method other than SHA-512, or gives the method parameters, which RFC 8419 does not allow.';
|
|
210
|
+
const ED25519_PARAMETERS = 'A time-stamp signed with Ed25519 gives its signing method parameters, which RFC 8419 does not allow.';
|
|
207
211
|
|
|
208
212
|
// Check the service's signature with the public key of its certificate.
|
|
209
213
|
// Returns "valid", "invalid", or "unavailable" where this device has no
|
|
210
214
|
// built-in support for the method. The fingerprint method is the one the
|
|
211
215
|
// signer names (RFC 5652 section 5.4); a signing method that names another
|
|
212
216
|
// is refused; a key of a kind that is not accepted is refused in words that
|
|
213
|
-
// say so.
|
|
214
|
-
|
|
217
|
+
// say so. "parameters" holds the parameters, if any, of the signing method
|
|
218
|
+
// and of the signer's fingerprint method.
|
|
219
|
+
async function verifyWith(certificate, bytes, signatureOid, digestName, signature, signed, without, parameters) {
|
|
215
220
|
const subtle = globalThis.crypto.subtle;
|
|
216
221
|
let importParams;
|
|
217
222
|
let verifyParams;
|
|
@@ -237,7 +242,11 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
|
|
|
237
242
|
return 'invalid';
|
|
238
243
|
}
|
|
239
244
|
} else if (certificate.keyOid === OID.ed25519) {
|
|
245
|
+
// With Ed25519 the signer's fingerprint method is SHA-512 with no
|
|
246
|
+
// parameters, and the signing method carries none (RFC 8419, section 3.1).
|
|
240
247
|
if (signatureOid !== OID.ed25519) return 'invalid';
|
|
248
|
+
if (digestName !== 'SHA-512' || parameters.digest !== undefined) throw invalid(ED25519_DIGEST);
|
|
249
|
+
if (parameters.signature !== undefined) throw invalid(ED25519_PARAMETERS);
|
|
241
250
|
checkKeyEncoding(bytes, certificate, 'Ed25519');
|
|
242
251
|
method = 'Ed25519';
|
|
243
252
|
importParams = { name: 'Ed25519' };
|
|
@@ -253,8 +262,8 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
|
|
|
253
262
|
// A device with no Ed25519 says so when the key is loaded.
|
|
254
263
|
return method === 'Ed25519' && e && e.name === 'NotSupportedError' ? 'unavailable' : 'invalid';
|
|
255
264
|
}
|
|
256
|
-
// An Ed25519 signature whose first half is a point of small order is refused, as everywhere (format description, section 3.8).
|
|
257
|
-
if (method === 'Ed25519' && (raw
|
|
265
|
+
// An Ed25519 signature whose first half is a point of small order, or whose number S is not below L, is refused, as everywhere (format description, section 3.8).
|
|
266
|
+
if (method === 'Ed25519' && badEd25519Signature(raw)) return 'invalid';
|
|
258
267
|
try {
|
|
259
268
|
return (await subtle.verify(verifyParams, key, raw, signed)) ? 'valid' : 'invalid';
|
|
260
269
|
} catch {
|
|
@@ -275,7 +284,8 @@ async function verifyWith(certificate, bytes, signatureOid, digestName, signatur
|
|
|
275
284
|
* soundly, by a service the checker did not name. "unavailable": this
|
|
276
285
|
* device cannot check the service's signing method; no time is given.
|
|
277
286
|
* "authority" is the fingerprint of the certificate that signed. "when"
|
|
278
|
-
* is the stated time, to the second, rounded down
|
|
287
|
+
* is the stated time, to the second, rounded down; "time" is it in
|
|
288
|
+
* milliseconds, a fraction of a second kept to the millisecond.
|
|
279
289
|
* @throws {Refusal} "stamp-bad-data", "stamp-wrong-data" or "stamp-invalid"
|
|
280
290
|
*/
|
|
281
291
|
export async function checkStamp(token, fingerprintBytes, options) {
|
|
@@ -287,10 +297,18 @@ export async function checkStamp(token, fingerprintBytes, options) {
|
|
|
287
297
|
if (!(fingerprintBytes instanceof Uint8Array)) throw badStamp('there is no fingerprint to compare it with.');
|
|
288
298
|
|
|
289
299
|
// The outer wrapper, and the signed data inside it (RFC 5652 sections 3
|
|
290
|
-
// and 5.1). Every part is read, and must be
|
|
300
|
+
// and 5.1). Every part the reader can tell apart is read, and must be
|
|
301
|
+
// strict DER, except that the order of a set is held only where a standard
|
|
302
|
+
// requires DER: the signed attributes (RFC 5652 section 5.3), the statement
|
|
303
|
+
// (RFC 3161 section 2.4.2) and each certificate (RFC 5280 section 4.1),
|
|
304
|
+
// each walked again below with the order held. The signed data's own sets
|
|
305
|
+
// (the fingerprint methods, the certificates, the withdrawn certificates,
|
|
306
|
+
// the signers) and the rest of the signer may be in any order: CMS does not
|
|
307
|
+
// require DER of them, and real services list their certificates in chain
|
|
308
|
+
// order, the signing certificate before its issuer.
|
|
291
309
|
const wrapper = expect(readElement(token, 0), TAG.SEQUENCE, 'the wrapper');
|
|
292
310
|
if (wrapper.end !== token.length) throw badStamp('it has bytes left over.');
|
|
293
|
-
validateDer(token, wrapper);
|
|
311
|
+
validateDer(token, wrapper, false);
|
|
294
312
|
const outer = children(token, wrapper, 2);
|
|
295
313
|
if (outer.length !== 2 || hexOf(token, expect(outer[0], TAG.OID, 'the kind of content')) !== OID.signedData) throw badStamp('it is not signed data.');
|
|
296
314
|
const inner = children(token, expect(outer[1], TAG.CONTEXT_0, 'the signed data'), 1);
|
|
@@ -309,7 +327,11 @@ export async function checkStamp(token, fingerprintBytes, options) {
|
|
|
309
327
|
let next = 3;
|
|
310
328
|
const certificates = [];
|
|
311
329
|
if (parts[next] && parts[next].tag === TAG.CONTEXT_0) {
|
|
312
|
-
|
|
330
|
+
// A set of the signed data's own (RFC 5652 section 10.2.3), which CMS does
|
|
331
|
+
// not require to be in DER order; each certificate inside it is DER (RFC
|
|
332
|
+
// 5280 section 4.1), and is held to it when it is read.
|
|
333
|
+
const items = children(token, parts[next], 8);
|
|
334
|
+
for (const c of items) {
|
|
313
335
|
// Other kinds of certificate may sit here; only the ordinary kind is read.
|
|
314
336
|
if (c.tag === TAG.SEQUENCE) certificates.push(readCertificate(token, c));
|
|
315
337
|
}
|
|
@@ -324,9 +346,16 @@ export async function checkStamp(token, fingerprintBytes, options) {
|
|
|
324
346
|
if (!signer[1] || !((signerVersion === '01' && signer[1].tag === TAG.SEQUENCE) || (signerVersion === '03' && signer[1].tag === 0x80))) {
|
|
325
347
|
throw badStamp('the signer is not named as its standard sets out.');
|
|
326
348
|
}
|
|
327
|
-
const
|
|
349
|
+
const digesting = algorithmOf(token, signer[2], 'the signer\'s fingerprint method');
|
|
350
|
+
const digestName = HASHES[digesting.oid];
|
|
328
351
|
const attributes = expect(signer[3], TAG.CONTEXT_0, 'the signed attributes');
|
|
329
|
-
|
|
352
|
+
// A set under a tag of its own, in DER (RFC 5652 section 5.3), so its order,
|
|
353
|
+
// and the order of every set inside it, is held to DER here.
|
|
354
|
+
const attributeList = children(token, attributes, 16);
|
|
355
|
+
if (!setInOrder(token, attributeList)) throw badStamp('the signed attributes are not in the order DER requires.');
|
|
356
|
+
for (const a of attributeList) validateDer(token, a);
|
|
357
|
+
const signing = algorithmOf(token, signer[4], 'the signing method');
|
|
358
|
+
const signatureOid = signing.oid;
|
|
330
359
|
const signature = contentOf(token, expect(signer[5], TAG.OCTET_STRING, 'the signature'));
|
|
331
360
|
if (signer.length > 7 || (signer.length === 7 && signer[6].tag !== TAG.CONTEXT_1)) throw badStamp('the signer holds parts that are not in the order its standard sets out.');
|
|
332
361
|
|
|
@@ -344,7 +373,9 @@ export async function checkStamp(token, fingerprintBytes, options) {
|
|
|
344
373
|
let kindNamed = false;
|
|
345
374
|
let digestNamed = false;
|
|
346
375
|
let named = null;
|
|
347
|
-
|
|
376
|
+
let newerNamed = false;
|
|
377
|
+
let olderNamed = false;
|
|
378
|
+
for (const attribute of attributeList) {
|
|
348
379
|
const pair = children(token, expect(attribute, TAG.SEQUENCE, 'an attribute'), 2);
|
|
349
380
|
const name = hexOf(token, expect(pair[0], TAG.OID, 'the name of an attribute'));
|
|
350
381
|
const values = children(token, expect(pair[1], TAG.SET, 'the value of an attribute'), 4);
|
|
@@ -360,8 +391,16 @@ export async function checkStamp(token, fingerprintBytes, options) {
|
|
|
360
391
|
digestNamed = true;
|
|
361
392
|
} else if (name === OID.signingCertificateV2 || name === OID.signingCertificate) {
|
|
362
393
|
if (values.length !== 1) throw invalid('A time-stamp does not say, in one way, which certificate signed it.');
|
|
363
|
-
//
|
|
364
|
-
|
|
394
|
+
// Each form at most once: RFC 5035 section 3 for the newer form, and
|
|
395
|
+
// for the older section 5.4.2 of RFC 2634 as RFC 5035 section 5 writes
|
|
396
|
+
// it. Where both forms are present, the newer one is used: this
|
|
397
|
+
// format's choice, where RFC 5035 section 7 lets a reader refuse a
|
|
398
|
+
// time-stamp whose two forms disagree.
|
|
399
|
+
const newer = name === OID.signingCertificateV2;
|
|
400
|
+
if (newer ? newerNamed : olderNamed) throw invalid('A time-stamp names the certificate that signed it more than once in the same form, which its standard does not allow.');
|
|
401
|
+
if (newer) newerNamed = true;
|
|
402
|
+
else olderNamed = true;
|
|
403
|
+
if (named === null || newer) named = signingCertificateOf(token, values[0], newer);
|
|
365
404
|
}
|
|
366
405
|
}
|
|
367
406
|
if (!kindNamed || !digestNamed) throw invalid('A time-stamp\'s signature does not cover its statement.');
|
|
@@ -382,7 +421,7 @@ export async function checkStamp(token, fingerprintBytes, options) {
|
|
|
382
421
|
// 5.4). The bytes are copied, so that the caller's time-stamp is not changed.
|
|
383
422
|
const signed = new Uint8Array(wholeOf(token, attributes));
|
|
384
423
|
signed[0] = TAG.SET;
|
|
385
|
-
const state = await verifyWith(certificate, token, signatureOid, digestName, signature, signed, without);
|
|
424
|
+
const state = await verifyWith(certificate, token, signatureOid, digestName, signature, signed, without, { signature: signing.parameters, digest: digesting.parameters });
|
|
386
425
|
if (state === 'unavailable') return { state: 'unavailable', when: null, time: NaN, authority: null };
|
|
387
426
|
if (state !== 'valid') throw invalid('A time-stamp\'s signature does not fit the certificate it names.');
|
|
388
427
|
if (statement.time < certificate.notBefore || statement.time > certificate.notAfter) {
|