provared 0.3.0 → 0.5.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 +340 -0
- package/README.md +26 -8
- package/bin/provared-check.mjs +3 -1
- package/docs/guide.md +195 -10
- package/docs/threat-model.md +89 -7
- package/package.json +1 -1
- package/page/index.html +1 -1
- package/page/style.css +3 -0
- package/spec/provared-format.md +153 -8
- package/src/dev.d.ts +1 -1
- package/src/dev.js +1 -1
- package/src/documents.js +149 -0
- package/src/guard.js +74 -6
- package/src/index.js +8 -3
- package/src/pass.js +1 -1
- package/src/recorder.js +149 -13
- package/src/seal.js +4 -3
- package/src/service.js +4 -4
- package/src/signatures.js +283 -1
- package/src/standing.js +4 -4
- package/src/stub.js +18 -5
- package/src/types.d.ts +60 -12
package/src/signatures.js
CHANGED
|
@@ -13,9 +13,14 @@
|
|
|
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 { checkKey, importPublicKey, thumbprint, weakEd25519 } from './keys.js';
|
|
17
18
|
|
|
18
19
|
const SLH = 'SLH-DSA-SHA2-256s';
|
|
20
|
+
|
|
21
|
+
// The length of a signature, in bytes, by method (RFC 8032, FIPS 204,
|
|
22
|
+
// FIPS 205).
|
|
23
|
+
const SIGNATURE_BYTES = { Ed25519: 64, 'ML-DSA-87': 4627, [SLH]: 29792 };
|
|
19
24
|
const nodeCrypto = globalThis.process && typeof globalThis.process.getBuiltinModule === 'function' ? globalThis.process.getBuiltinModule('node:crypto') : null;
|
|
20
25
|
|
|
21
26
|
function slhKey(pub) {
|
|
@@ -80,6 +85,9 @@ export function methodAvailable(alg) {
|
|
|
80
85
|
* @returns {Promise<SignatureState>}
|
|
81
86
|
*/
|
|
82
87
|
export async function verifySignature(jwk, signature, data, without = []) {
|
|
88
|
+
// A signature of another length than its method's can never check, so it
|
|
89
|
+
// is refused on every device, whether or not this one has the method.
|
|
90
|
+
if (Object.hasOwn(SIGNATURE_BYTES, jwk.alg) && signature.length !== SIGNATURE_BYTES[jwk.alg]) return 'invalid';
|
|
83
91
|
if (jwk.alg === SLH) {
|
|
84
92
|
if (without.includes(SLH) || !(await methodAvailable(SLH))) return 'unavailable';
|
|
85
93
|
try {
|
|
@@ -115,6 +123,280 @@ export async function sign(alg, privateKey, data) {
|
|
|
115
123
|
return new Uint8Array(await globalThis.crypto.subtle.sign({ name: alg }, privateKey, data));
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
// --- signers ---
|
|
127
|
+
//
|
|
128
|
+
// Wherever a writer takes "privateKeys", each item may be a private key of
|
|
129
|
+
// the platform (a Web Crypto CryptoKey, or a Node.js KeyObject), used as it
|
|
130
|
+
// always was, or a signer: an object with "key", the public key as a JSON
|
|
131
|
+
// Web Key in one of the shapes of the format description, section 3.8, and
|
|
132
|
+
// "sign(data)", which gives the signature over exactly those bytes. A
|
|
133
|
+
// signer lets a key live elsewhere: in a key service, or a hardware module.
|
|
134
|
+
// It changes where a signature is made, not how: the record is the same.
|
|
135
|
+
//
|
|
136
|
+
// A signer is not taken on trust. Its two members are read once. Its key
|
|
137
|
+
// must be of the method of its place in the list, a method this device can
|
|
138
|
+
// check, and, where the writer knows the key the record must be signed
|
|
139
|
+
// with, that key. All of this is settled before any signer is asked to
|
|
140
|
+
// sign. Every signature it gives is checked against its key before it is
|
|
141
|
+
// used. A signer that fails in any way leaves no record, and the error
|
|
142
|
+
// names the method and the key. Signers earlier in the list may have
|
|
143
|
+
// signed by then: a key service's own log may hold signatures that match
|
|
144
|
+
// no record.
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether an item of "privateKeys" is a private key of the platform for the
|
|
148
|
+
* method of its place: a private Web Crypto CryptoKey of that method that
|
|
149
|
+
* may sign, or for SLH-DSA a private Node.js KeyObject of that kind. What
|
|
150
|
+
* only looks like one (an object made from CryptoKey's own prototype, a
|
|
151
|
+
* public key, a key of another method) is not one.
|
|
152
|
+
* @param {any} item
|
|
153
|
+
* @param {string} alg
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function platformKey(item, alg) {
|
|
157
|
+
try {
|
|
158
|
+
if (alg === SLH) return Boolean(nodeCrypto) && item instanceof nodeCrypto.KeyObject && item.type === 'private' && item.asymmetricKeyType === 'slh-dsa-sha2-256s';
|
|
159
|
+
return typeof globalThis.CryptoKey === 'function' && item instanceof globalThis.CryptoKey && item.type === 'private' && item.algorithm.name === alg && item.usages.includes('sign');
|
|
160
|
+
} catch {
|
|
161
|
+
return false; // a value that cannot even be looked into (a revoked Proxy), or one that only looks like a key
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// What a place in "privateKeys" must hold.
|
|
166
|
+
const signerShape = (alg) =>
|
|
167
|
+
`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.`;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* In the SLH-DSA place, the seal writer has always handed the key to
|
|
171
|
+
* Node.js's own signing call, which also reads a key written out: PEM text,
|
|
172
|
+
* DER bytes with their format, or a JSON Web Key with its format. Such a
|
|
173
|
+
* key is still taken: it is read exactly as that call read it (with
|
|
174
|
+
* crypto.createPrivateKey, which the call itself uses), and then it must be
|
|
175
|
+
* a private SLH-DSA-SHA2-256s key, as a KeyObject must.
|
|
176
|
+
* @param {any} item
|
|
177
|
+
* @param {string} where
|
|
178
|
+
* @returns {any} the key, as a KeyObject
|
|
179
|
+
*/
|
|
180
|
+
function writtenSlhKey(item, where) {
|
|
181
|
+
let made;
|
|
182
|
+
try {
|
|
183
|
+
made = nodeCrypto.createPrivateKey(item);
|
|
184
|
+
} catch {
|
|
185
|
+
made = null; // not a private key Node.js can read, or no Node.js here
|
|
186
|
+
}
|
|
187
|
+
if (!platformKey(made, SLH)) throw new Refusal('bad-field', `${where}: ${signerShape(SLH)}`);
|
|
188
|
+
return made;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A signer that gave no answer in the time the stub writer gives it.
|
|
192
|
+
class NoAnswer extends Error {
|
|
193
|
+
constructor(ms) {
|
|
194
|
+
super(`no answer within ${ms} milliseconds`);
|
|
195
|
+
this.ms = ms;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Two public keys that have passed checkKey: the same key, member for member.
|
|
200
|
+
function sameKey(a, b) {
|
|
201
|
+
const names = Object.keys(b);
|
|
202
|
+
return Object.keys(a).length === names.length && names.every((n) => a[n] === b[n]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The signature bytes a signer gave, copied, or null where it gave anything
|
|
206
|
+
// else. Bytes are a Uint8Array (a Node.js Buffer is one) or an ArrayBuffer,
|
|
207
|
+
// which is what Web Crypto's own "sign" gives.
|
|
208
|
+
function bytesOf(given) {
|
|
209
|
+
try {
|
|
210
|
+
if (given instanceof Uint8Array) return new Uint8Array(given);
|
|
211
|
+
if (given instanceof ArrayBuffer) return new Uint8Array(given.slice(0));
|
|
212
|
+
} catch {
|
|
213
|
+
// fall through: not bytes that can be read
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read one item of "privateKeys" for the method of its place.
|
|
220
|
+
* @param {any} item
|
|
221
|
+
* @param {string} alg
|
|
222
|
+
* @param {string} where its place, for messages
|
|
223
|
+
* @param {object} [expected] the public key the record must be signed with, where the writer knows it
|
|
224
|
+
* @returns {Promise<{platform: boolean, item: any, key?: object, call?: (data: Uint8Array) => any, sign: (data: Uint8Array) => Promise<Uint8Array>}>}
|
|
225
|
+
*/
|
|
226
|
+
async function takeItem(item, alg, where, expected) {
|
|
227
|
+
const platform = (privateKey) => ({ platform: true, item: privateKey, sign: (data) => sign(alg, privateKey, data) });
|
|
228
|
+
if (platformKey(item, alg)) return platform(item);
|
|
229
|
+
let key;
|
|
230
|
+
let fn;
|
|
231
|
+
if (item !== null && (typeof item === 'object' || typeof item === 'function')) {
|
|
232
|
+
try {
|
|
233
|
+
key = item.key;
|
|
234
|
+
fn = item.sign;
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Refusal('bad-field', `${where}: could not be read.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (typeof fn !== 'function') {
|
|
240
|
+
// Not a signer: in the SLH-DSA place, perhaps a key written out.
|
|
241
|
+
if (alg === SLH && item !== null && item !== undefined) return platform(writtenSlhKey(item, where));
|
|
242
|
+
throw new Refusal('bad-field', `${where}: ${signerShape(alg)}`);
|
|
243
|
+
}
|
|
244
|
+
if (key === undefined || key === null) throw new Refusal('bad-field', `${where}: ${signerShape(alg)}`);
|
|
245
|
+
let copy;
|
|
246
|
+
try {
|
|
247
|
+
copy = structuredClone(key);
|
|
248
|
+
} catch {
|
|
249
|
+
throw new Refusal('bad-key', `${where}.key: must be a public key, as plain data.`);
|
|
250
|
+
}
|
|
251
|
+
checkKey(copy, [alg], `${where}.key`);
|
|
252
|
+
Object.freeze(copy);
|
|
253
|
+
const label = `the ${alg} key ${await thumbprint(copy)}`;
|
|
254
|
+
if (expected && !sameKey(copy, expected)) {
|
|
255
|
+
throw new Refusal('bad-key', `${where}: the signer holds ${label}, which is not the key this record must be signed with (${await thumbprint(expected)}).`);
|
|
256
|
+
}
|
|
257
|
+
// Nothing is signed that this device cannot check: so a signer for a
|
|
258
|
+
// method it does not have built in is refused before any signer is asked.
|
|
259
|
+
if (!(await methodAvailable(alg))) {
|
|
260
|
+
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.`);
|
|
261
|
+
}
|
|
262
|
+
const call = (data) => fn.call(item, data);
|
|
263
|
+
return { platform: false, item, key: copy, call, sign: (data) => signerSignature(call, copy, alg, label, where, data) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// One signature from a signer, checked before it is used.
|
|
267
|
+
async function signerSignature(call, key, alg, label, where, data) {
|
|
268
|
+
const failed = (why, cause) => {
|
|
269
|
+
const e = new Refusal('signature-invalid', `${where}: the signer for ${label} ${why} No record was made.`);
|
|
270
|
+
if (cause !== undefined) e.cause = cause;
|
|
271
|
+
return e;
|
|
272
|
+
};
|
|
273
|
+
let given;
|
|
274
|
+
try {
|
|
275
|
+
// The signer is handed a copy: nothing it does to it changes what is checked.
|
|
276
|
+
given = await call(data.slice());
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (e instanceof NoAnswer) throw failed(`gave no answer within ${e.ms} milliseconds.`);
|
|
279
|
+
throw failed('failed with an error.', e);
|
|
280
|
+
}
|
|
281
|
+
const signature = bytesOf(given);
|
|
282
|
+
if (signature === null) throw failed('gave something other than bytes.');
|
|
283
|
+
if (Object.hasOwn(SIGNATURE_BYTES, alg) && signature.length !== SIGNATURE_BYTES[alg]) {
|
|
284
|
+
throw failed(`gave a signature of ${signature.length} bytes, where an ${alg} signature is ${SIGNATURE_BYTES[alg]}.`);
|
|
285
|
+
}
|
|
286
|
+
// Whether the method is built in was settled before any signer was asked;
|
|
287
|
+
// a signature that cannot be checked even so is not used.
|
|
288
|
+
const state = await verifySignature(key, signature, data);
|
|
289
|
+
if (state !== 'valid') throw failed('gave a signature that does not check against its key: it holds another key, or signed other bytes.');
|
|
290
|
+
return signature;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Read every item of "privateKeys", one for each method, before anything is
|
|
295
|
+
* signed.
|
|
296
|
+
* @param {any} privateKeys
|
|
297
|
+
* @param {string[]} methods
|
|
298
|
+
* @param {object[]} [expected] the public keys the record must be signed with, where the writer knows them
|
|
299
|
+
* @returns {Promise<Awaited<ReturnType<typeof takeItem>>[]>}
|
|
300
|
+
*/
|
|
301
|
+
async function takeItems(privateKeys, methods, expected) {
|
|
302
|
+
let list;
|
|
303
|
+
try {
|
|
304
|
+
list = Array.isArray(privateKeys);
|
|
305
|
+
} catch {
|
|
306
|
+
list = false; // a revoked Proxy
|
|
307
|
+
}
|
|
308
|
+
if (!list) throw new Refusal('bad-field', `privateKeys: must be a list, one private key or signer for each of ${methods.join(', ')}.`);
|
|
309
|
+
const taken = [];
|
|
310
|
+
for (let i = 0; i < methods.length; i++) {
|
|
311
|
+
let item;
|
|
312
|
+
try {
|
|
313
|
+
item = privateKeys[i];
|
|
314
|
+
} catch {
|
|
315
|
+
throw new Refusal('bad-field', `privateKeys[${i}]: could not be read.`);
|
|
316
|
+
}
|
|
317
|
+
taken.push(await takeItem(item, methods[i], `privateKeys[${i}]`, expected ? expected[i] : undefined));
|
|
318
|
+
}
|
|
319
|
+
return taken;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The functions that sign, one for each method, for a writer that signs
|
|
324
|
+
* now. Keys of the platform sign as they always did; each signer is read
|
|
325
|
+
* and checked as set out above.
|
|
326
|
+
* @param {any} privateKeys
|
|
327
|
+
* @param {string[]} methods
|
|
328
|
+
* @param {object[]} [expected]
|
|
329
|
+
* @returns {Promise<((data: Uint8Array) => Promise<Uint8Array>)[]>}
|
|
330
|
+
*/
|
|
331
|
+
export async function signingKeys(privateKeys, methods, expected) {
|
|
332
|
+
return (await takeItems(privateKeys, methods, expected)).map((t) => t.sign);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* For a writer that holds its keys, such as the stub writer: the list to
|
|
337
|
+
* use in place of "privateKeys". Keys of the platform are kept as they
|
|
338
|
+
* are. Each signer is read once, here, and checked against the key the
|
|
339
|
+
* records must be signed with where that is known; in its place stands a
|
|
340
|
+
* signer that waits "ms" milliseconds at most for each signature, through
|
|
341
|
+
* "run". A late answer is not used.
|
|
342
|
+
* @param {any} privateKeys
|
|
343
|
+
* @param {string[]} methods
|
|
344
|
+
* @param {object[]|undefined} expected
|
|
345
|
+
* @param {number} ms
|
|
346
|
+
* @param {(work: () => Promise<any>) => Promise<any>} [run]
|
|
347
|
+
* @returns {Promise<any[]>}
|
|
348
|
+
*/
|
|
349
|
+
export async function holdSigningKeys(privateKeys, methods, expected, ms, run = (work) => work()) {
|
|
350
|
+
return (await takeItems(privateKeys, methods, expected)).map((t) => {
|
|
351
|
+
if (t.platform) return t.item;
|
|
352
|
+
const waited = (data) =>
|
|
353
|
+
new Promise((resolve, reject) => {
|
|
354
|
+
const timer = setTimeout(() => reject(new NoAnswer(ms)), ms);
|
|
355
|
+
let answer;
|
|
356
|
+
try {
|
|
357
|
+
answer = Promise.resolve(t.call(data));
|
|
358
|
+
} catch (e) {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
reject(e);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
answer.then(
|
|
364
|
+
(value) => {
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
resolve(value);
|
|
367
|
+
},
|
|
368
|
+
(e) => {
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
reject(e);
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
return Object.freeze({ key: t.key, sign: (data) => run(() => waited(data)) });
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Wrap a private key held in memory as a signer, so that a key in memory
|
|
380
|
+
* and a key held elsewhere take one path.
|
|
381
|
+
* @param {string} alg 'Ed25519', 'ML-DSA-87' or 'SLH-DSA-SHA2-256s'
|
|
382
|
+
* @param {any} privateKey a Web Crypto CryptoKey, or for SLH-DSA a Node.js KeyObject
|
|
383
|
+
* @param {object} publicJwk its public key, in one of the shapes of the format description, section 3.8
|
|
384
|
+
* @returns {{key: object, sign: (data: Uint8Array) => Promise<Uint8Array>}}
|
|
385
|
+
*/
|
|
386
|
+
export function signerFromKey(alg, privateKey, publicJwk) {
|
|
387
|
+
if (alg !== 'Ed25519' && alg !== 'ML-DSA-87' && alg !== SLH) throw new Refusal('bad-field', `alg: must be Ed25519, ML-DSA-87 or ${SLH}.`);
|
|
388
|
+
// Web Crypto holds the first two methods; Node.js's own module the third.
|
|
389
|
+
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'}.`);
|
|
390
|
+
let key;
|
|
391
|
+
try {
|
|
392
|
+
key = structuredClone(publicJwk);
|
|
393
|
+
} catch {
|
|
394
|
+
throw new Refusal('bad-key', 'key: must be a public key, as plain data.');
|
|
395
|
+
}
|
|
396
|
+
checkKey(key, [alg], 'key');
|
|
397
|
+
return Object.freeze({ key: Object.freeze(key), sign: (data) => sign(alg, privateKey, data) });
|
|
398
|
+
}
|
|
399
|
+
|
|
118
400
|
/**
|
|
119
401
|
* Make a new key set for a recorder, which signs seals: an Ed25519 key, an
|
|
120
402
|
* 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/types.d.ts
CHANGED
|
@@ -50,6 +50,21 @@ export type KeySet = [Ed25519Key, MlDsaKey];
|
|
|
50
50
|
export type SealKeySet = [Ed25519Key, MlDsaKey, SlhDsaKey];
|
|
51
51
|
export type PublicKey = PasskeyKey | MlDsaKey | SlhDsaKey;
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A signer: a key held elsewhere (a key service, a hardware module) that signs on request. Wherever
|
|
55
|
+
* "privateKeys" is taken, each item may be a key or a signer. A signer's key must be of the method of
|
|
56
|
+
* its place, and, where the writer knows the key the record must be signed with, that key. Every
|
|
57
|
+
* signature it gives is checked against its key before it is used.
|
|
58
|
+
*/
|
|
59
|
+
export interface Signer {
|
|
60
|
+
/** The public key, in one of the shapes of the format description, section 3.8. */
|
|
61
|
+
key: Ed25519Key | MlDsaKey | SlhDsaKey;
|
|
62
|
+
/** The signature over exactly these bytes: 64 bytes for Ed25519, 4,627 for ML-DSA-87, 29,792 for SLH-DSA-SHA2-256s. */
|
|
63
|
+
sign(data: Uint8Array): Promise<Uint8Array | ArrayBuffer>;
|
|
64
|
+
}
|
|
65
|
+
/** A private key of the platform (a Web Crypto CryptoKey), or a signer. */
|
|
66
|
+
export type SigningKey = CryptoKey | Signer;
|
|
67
|
+
|
|
53
68
|
// --- what records hold ---
|
|
54
69
|
|
|
55
70
|
export interface Amount {
|
|
@@ -431,6 +446,8 @@ export interface BeforeAnswer {
|
|
|
431
446
|
problems: Finding[];
|
|
432
447
|
breaches: Finding[];
|
|
433
448
|
needs: ('approval' | 'countersignature')[];
|
|
449
|
+
/** Present only where the device went on without checking a seal's third signature (SLH-DSA-SHA2-256s): the methods not checked. */
|
|
450
|
+
notChecked?: string[];
|
|
434
451
|
}
|
|
435
452
|
/** "issuerKeys" must be given: a slip signed by any other key allows nothing. */
|
|
436
453
|
export function checkBefore(book: string, proposal: Proposal, options: CheckOptions & { issuerKeys: string[] }): Promise<BeforeAnswer>;
|
|
@@ -449,8 +466,20 @@ export interface Recorder {
|
|
|
449
466
|
act<T>(
|
|
450
467
|
request: Request,
|
|
451
468
|
perform: () => Promise<T>,
|
|
452
|
-
more?: {
|
|
453
|
-
|
|
469
|
+
more?: {
|
|
470
|
+
when?: number | Date;
|
|
471
|
+
terms?: Fingerprint;
|
|
472
|
+
approval?: { record: SignedRecord };
|
|
473
|
+
countersign?: (stub: SignedRecord) => Promise<SignedRecord | null>;
|
|
474
|
+
/**
|
|
475
|
+
* Up to four further documents, from what the action gave back. The stub is then signed after the action,
|
|
476
|
+
* in the same turn, and keeps the time the action began. The request may then name at most 28 documents,
|
|
477
|
+
* and no approval may be given. If it fails, the stub is written without them, and "detailsAfter" in the
|
|
478
|
+
* answer says why.
|
|
479
|
+
*/
|
|
480
|
+
detailsAfter?: (result: T) => DocumentRef[] | null | undefined | Promise<DocumentRef[] | null | undefined>;
|
|
481
|
+
},
|
|
482
|
+
): Promise<{ done: boolean; answer: BeforeAnswer; result?: T; stub?: WrittenStub; detailsAfter?: { added: number; problem: Finding | null } }>;
|
|
454
483
|
book(): string;
|
|
455
484
|
/**
|
|
456
485
|
* What the record shows as it stands: the answer a whole check of the book gives, with the cancellations
|
|
@@ -479,7 +508,8 @@ export type WrittenStub = Written & { seq: number; line: string; countersignatur
|
|
|
479
508
|
export function openRecorder(o: {
|
|
480
509
|
book: string;
|
|
481
510
|
slip: Fingerprint;
|
|
482
|
-
|
|
511
|
+
/** The agent's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys the slip, or the pass, names. */
|
|
512
|
+
privateKeys: SigningKey[];
|
|
483
513
|
issuerKeys: string[];
|
|
484
514
|
pass?: Fingerprint;
|
|
485
515
|
options?: CheckOptions;
|
|
@@ -487,6 +517,8 @@ export function openRecorder(o: {
|
|
|
487
517
|
now?: () => number;
|
|
488
518
|
/** How long the other side is given to countersign, in milliseconds. Defaults to 30,000. */
|
|
489
519
|
countersignWithin?: number;
|
|
520
|
+
/** How long a signer is given for each signature, in milliseconds. Defaults to 30,000. After that nothing is written. */
|
|
521
|
+
signWithin?: number;
|
|
490
522
|
}): Promise<Recorder>;
|
|
491
523
|
|
|
492
524
|
/** One of an agent's tools, as it is described to recordTools. */
|
|
@@ -562,21 +594,21 @@ export function writeStub(
|
|
|
562
594
|
when?: number | Date;
|
|
563
595
|
id?: Base64url;
|
|
564
596
|
},
|
|
565
|
-
privateKeys:
|
|
597
|
+
privateKeys: SigningKey[],
|
|
566
598
|
): Promise<Written & { seq: number }>;
|
|
567
599
|
/** An agent hands part of its permission to a helper agent. */
|
|
568
600
|
export function writePass(
|
|
569
601
|
fields: { slip: Fingerprint; from?: Fingerprint; to: Party; actions: string[]; limits?: Limit[]; validFrom: Time; validUntil: Time; when?: number | Date; id?: Base64url },
|
|
570
|
-
privateKeys:
|
|
602
|
+
privateKeys: SigningKey[],
|
|
571
603
|
): Promise<Written>;
|
|
572
|
-
export function countersign(stub: SignedRecord, privateKeys:
|
|
604
|
+
export function countersign(stub: SignedRecord, privateKeys: SigningKey[], when?: number | Date): Promise<SignedRecord>;
|
|
573
605
|
export function writeRefusal(
|
|
574
606
|
fields: { slip: Fingerprint; by: Party; action: string; amount?: Amount; reason: string; when?: number | Date; id?: Base64url },
|
|
575
|
-
privateKeys:
|
|
607
|
+
privateKeys: SigningKey[],
|
|
576
608
|
): Promise<Written>;
|
|
577
609
|
export function writeTerms(
|
|
578
610
|
fields: { by: Party; accepts: string[]; never?: string[]; validFrom: Time; validUntil: Time; id?: Base64url },
|
|
579
|
-
privateKeys:
|
|
611
|
+
privateKeys: SigningKey[],
|
|
580
612
|
): Promise<Written>;
|
|
581
613
|
export type VouchedFor =
|
|
582
614
|
| { kind: 'person'; key: PasskeyKey; name: string }
|
|
@@ -584,15 +616,19 @@ export type VouchedFor =
|
|
|
584
616
|
| { kind: 'recorder'; keys: SealKeySet; name: string };
|
|
585
617
|
export function writeVouching(
|
|
586
618
|
fields: { by: Party; for: VouchedFor; validFrom: Time; validUntil: Time; when?: number | Date; id?: Base64url },
|
|
587
|
-
privateKeys:
|
|
619
|
+
privateKeys: SigningKey[],
|
|
588
620
|
): Promise<Written>;
|
|
589
|
-
export function writeWithdrawal(fields: { vouching: Fingerprint; when?: number | Date; id?: Base64url }, privateKeys:
|
|
621
|
+
export function writeWithdrawal(fields: { vouching: Fingerprint; when?: number | Date; id?: Base64url }, privateKeys: SigningKey[]): Promise<Written>;
|
|
590
622
|
/** The agent's side states that it was handed the person's cancellation of a slip, and when. */
|
|
591
623
|
export function writeAcknowledgement(
|
|
592
624
|
fields: { slip: Fingerprint; cancellation: Fingerprint; pass?: Fingerprint; when?: number | Date; id?: Base64url },
|
|
593
|
-
privateKeys:
|
|
625
|
+
privateKeys: SigningKey[],
|
|
594
626
|
): Promise<Written>;
|
|
595
|
-
/**
|
|
627
|
+
/**
|
|
628
|
+
* It needs Node.js: no browser has the seal's third signing method. "privateKeys": the recorder's three keys (two
|
|
629
|
+
* Web Crypto keys, and a Node.js KeyObject or what Node.js reads as one: PEM text, DER bytes with their format, or a
|
|
630
|
+
* JSON Web Key with its format), or signers, each holding the key "by" names in its place.
|
|
631
|
+
*/
|
|
596
632
|
export function writeSeal(
|
|
597
633
|
book: string,
|
|
598
634
|
fields: { by: { keys: SealKeySet; name: string }; previous?: Fingerprint; when?: number | Date; id?: Base64url },
|
|
@@ -610,6 +646,8 @@ export function makeShow(text: string, indexes: number[], options?: { seal?: num
|
|
|
610
646
|
|
|
611
647
|
export function generateKeySet(): Promise<{ keys: KeySet; privateKeys: CryptoKey[] }>;
|
|
612
648
|
export function generateSealKeySet(): Promise<{ keys: SealKeySet; privateKeys: unknown[] }>;
|
|
649
|
+
/** A private key held in memory, wrapped as a signer: a CryptoKey, or for SLH-DSA-SHA2-256s a Node.js KeyObject. */
|
|
650
|
+
export function signerFromKey(alg: 'Ed25519' | 'ML-DSA-87' | 'SLH-DSA-SHA2-256s', privateKey: unknown, key: Ed25519Key | MlDsaKey | SlhDsaKey): Signer;
|
|
613
651
|
export function fingerprint(contentBytes: Uint8Array): Promise<Fingerprint>;
|
|
614
652
|
export function sha256(bytes: Uint8Array): Promise<Uint8Array>;
|
|
615
653
|
export function formatTime(when: number | Date): Time;
|
|
@@ -621,6 +659,16 @@ export function utf8(text: string): Uint8Array;
|
|
|
621
659
|
// --- the shared list ---
|
|
622
660
|
|
|
623
661
|
export const RESERVED_PREFIX: 'provared.';
|
|
662
|
+
/** Document names that begin with this are reserved: only those in DOCUMENT_NAMES may be used. */
|
|
663
|
+
export const RESERVED_DOCUMENT_PREFIX: 'provared.';
|
|
664
|
+
/** The reserved document names, and the exact text whose SHA-256 each one gives (format description, section 27). */
|
|
665
|
+
export const DOCUMENT_NAMES: Readonly<Record<string, string>>;
|
|
666
|
+
/** For "provared.http-request": the method and the address exactly as sent, and the body's bytes where there is a body. */
|
|
667
|
+
export function httpRequestFingerprint(r: { method: string; url: string; body?: Uint8Array }): Promise<Fingerprint>;
|
|
668
|
+
/** For "provared.http-response": the status code, and the body's bytes where there is a body. */
|
|
669
|
+
export function httpResponseFingerprint(r: { status: number; body?: Uint8Array }): Promise<Fingerprint>;
|
|
670
|
+
/** For "provared.trace-id", "provared.request-id", "provared.event-id" and "provared.error": SHA-256 of the text's UTF-8 bytes, exactly as given. */
|
|
671
|
+
export function identifierFingerprint(id: string): Promise<Fingerprint>;
|
|
624
672
|
export const ACTION_KINDS: Record<string, string>;
|
|
625
673
|
export const SHARED_ACTIONS: Record<string, [kind: string, meaning: string]>;
|
|
626
674
|
export const CONDUCT_RULES: Record<string, string>;
|