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/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 {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
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 {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
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 { importPublicKey, weakEd25519 } from './keys.js';
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 is
102
- // refused here, whatever the device's own library would say: libraries
103
- // differ on it (format description, section 3.8).
104
- if (jwk.alg === 'Ed25519' && (signature.length !== 64 || weakEd25519(signature.subarray(0, 32)))) return 'invalid';
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 {CryptoKey[]} privateKeys the agent's private keys: Ed25519, then ML-DSA-87
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 {CryptoKey[]} privateKeys the organisation's private keys: Ed25519, then ML-DSA-87
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 {CryptoKey[]} privateKeys the private keys of the organisation that made the vouching record
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 { sign } from './signatures.js';
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
- export async function signWithKeySet(kind, content, privateKeys) {
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 sign(KEY_SET_METHODS[i], privateKeys[i], signingInput(headers[i], payloadB64));
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 {CryptoKey[]} privateKeys the agent's private keys: Ed25519, then ML-DSA-87
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 {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
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
- async function verifyWith(certificate, bytes, signatureOid, digestName, signature, signed, without) {
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.length !== 64 || weakEd25519(raw.subarray(0, 32)))) return 'invalid';
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 strict DER.
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
- for (const c of children(token, parts[next], 8)) {
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 digestName = HASHES[algorithmOf(token, signer[2], 'the signer\'s fingerprint method').oid];
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
- const signatureOid = algorithmOf(token, signer[4], 'the signing method').oid;
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
- for (const attribute of children(token, attributes, 16)) {
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
- // Where both forms are present, the newer one is used.
364
- if (named === null || name === OID.signingCertificateV2) named = signingCertificateOf(token, values[0], name === OID.signingCertificateV2);
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) {