provared 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +1062 -0
  2. package/LICENSE +202 -0
  3. package/LICENSE-CC-BY-4.0.txt +396 -0
  4. package/README.md +689 -0
  5. package/bin/provared-check.mjs +565 -0
  6. package/bin/provared-headers.mjs +110 -0
  7. package/docs/threat-model.md +997 -0
  8. package/net/headers.js +403 -0
  9. package/package.json +57 -0
  10. package/page/check-page.js +162 -0
  11. package/page/index.html +47 -0
  12. package/page/render.js +530 -0
  13. package/page/style.css +150 -0
  14. package/samples/office-supplies.expected.json +82 -0
  15. package/samples/office-supplies.jsonl +10 -0
  16. package/samples/office-supplies.show.json +59 -0
  17. package/spec/provared-format.md +1672 -0
  18. package/src/actions.js +90 -0
  19. package/src/approval.js +73 -0
  20. package/src/blockstamp.js +198 -0
  21. package/src/book.js +2232 -0
  22. package/src/check.d.ts +21 -0
  23. package/src/check.js +15 -0
  24. package/src/compare.js +276 -0
  25. package/src/cover.js +198 -0
  26. package/src/der.js +192 -0
  27. package/src/encoding.js +286 -0
  28. package/src/fields.js +94 -0
  29. package/src/guard.js +171 -0
  30. package/src/headers.js +380 -0
  31. package/src/index.d.ts +2 -0
  32. package/src/index.js +37 -0
  33. package/src/jws.js +235 -0
  34. package/src/keys.js +149 -0
  35. package/src/pass.js +67 -0
  36. package/src/passkey-browser.d.ts +20 -0
  37. package/src/passkey-browser.js +171 -0
  38. package/src/recorder.js +1115 -0
  39. package/src/seal.js +133 -0
  40. package/src/service.js +124 -0
  41. package/src/signatures.js +147 -0
  42. package/src/slip.js +401 -0
  43. package/src/standing.js +181 -0
  44. package/src/stub.js +140 -0
  45. package/src/timestamp.js +351 -0
  46. package/src/tools.js +192 -0
  47. package/src/tree.js +143 -0
  48. package/src/types.d.ts +628 -0
  49. package/src/webauthn.js +119 -0
package/src/slip.js ADDED
@@ -0,0 +1,401 @@
1
+ // The Slip: the permission a person signs with a passkey.
2
+ // Format description, section 4.
3
+
4
+ import { actionKind, isNeverName } from './actions.js';
5
+ import { coverMembers, uncover } from './cover.js';
6
+ import { Refusal, fromBase64url, problemFrom, randomId, toBase64url } from './encoding.js';
7
+ import * as f from './fields.js';
8
+ import { encodeContent, parseRecord, protectedHeaders, signingInput, withinSize, KINDS } from './jws.js';
9
+ import { PASSKEY_METHODS, checkKey, checkKeySet, thumbprint } from './keys.js';
10
+ import { checkAssertion } from './webauthn.js';
11
+
12
+ const SLIP_MEMBERS = ['actions', 'agent', 'id', 'issuer', 'limits', 'never', 'purpose', 'requires', 'type', 'validFrom', 'validUntil', 'with'];
13
+ const LIMIT_KINDS = ['count', 'each', 'max'];
14
+ /** The longest period a limit may name: 366 days. */
15
+ const MAX_PERIOD_SECONDS = 366 * 86400;
16
+
17
+ function checkOrigin(origin, rpId) {
18
+ f.text(origin, 1, 300, 'issuer.origin');
19
+ if (typeof rpId !== 'string' || !/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/.test(rpId)) {
20
+ throw f.fail('issuer.rpId', 'must be a website name in lower case, such as example.org.');
21
+ }
22
+ let url;
23
+ try {
24
+ url = new URL(origin);
25
+ } catch {
26
+ throw f.fail('issuer.origin', 'must be the address of a page, such as https://sign.example.org.');
27
+ }
28
+ // url.origin has no path and no default port, so this also refuses
29
+ // anything after the host.
30
+ if (url.origin !== origin) throw f.fail('issuer.origin', 'must be a bare address with no path, such as https://sign.example.org.');
31
+ if (!(url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))) {
32
+ throw f.fail('issuer.origin', 'must begin https://, or http://localhost.');
33
+ }
34
+ if (!(url.hostname === rpId || url.hostname.endsWith('.' + rpId))) {
35
+ throw f.fail('issuer.origin', 'must be on the website that issuer.rpId names.');
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Confirm a list of limits against the actions it may name (format
41
+ * description, section 4.1). A slip and a pass share it.
42
+ * @param {string[]} actions
43
+ * @param {any} limits
44
+ * @returns {(action: string, unit: any, path: string) => void} a check that every amount for one action is in one unit, to use on what follows
45
+ */
46
+ export function validateLimits(actions, limits) {
47
+ // Every amount named for one action, in a limit or a condition, is in one
48
+ // unit: a stub gives one amount.
49
+ const units = new Map();
50
+ const oneUnit = (action, unit, path) => {
51
+ f.unit(unit, path);
52
+ if (units.has(action) && units.get(action) !== unit) throw f.fail(path, 'every amount for one action must be in the same unit.');
53
+ units.set(action, unit);
54
+ };
55
+
56
+ f.list(limits, 0, 64, 'limits');
57
+ const limitsSeen = new Set();
58
+ limits.forEach((limit, i) => {
59
+ const p = `limits[${i}]`;
60
+ f.members(limit, ['action'], ['count', 'each', 'max', 'per', 'unit'], p);
61
+ if (!actions.includes(limit.action)) throw f.fail(`${p}.action`, 'must be one of the slip\'s actions.');
62
+ const kinds = LIMIT_KINDS.filter((k) => Object.hasOwn(limit, k));
63
+ if (kinds.length !== 1) throw f.fail(p, 'must hold exactly one of "max", "each" and "count".');
64
+ const kind = kinds[0];
65
+ f.wholeNumber(limit[kind], `${p}.${kind}`);
66
+ if (kind === 'count') {
67
+ if (Object.hasOwn(limit, 'unit')) throw f.fail(`${p}.unit`, 'a count of actions has no unit.');
68
+ if (limit.count < 1) throw f.fail(`${p}.count`, 'must be 1 or more.');
69
+ } else {
70
+ if (!Object.hasOwn(limit, 'unit')) throw f.fail(p, '"unit" is missing.');
71
+ oneUnit(limit.action, limit.unit, `${p}.unit`);
72
+ }
73
+ if (Object.hasOwn(limit, 'per')) {
74
+ if (kind === 'each') throw f.fail(`${p}.per`, 'a limit on each action has no period.');
75
+ f.wholeNumber(limit.per, `${p}.per`);
76
+ if (limit.per < 1 || limit.per > MAX_PERIOD_SECONDS) throw f.fail(`${p}.per`, 'must be from 1 second to 366 days, in seconds.');
77
+ }
78
+ const key = `${limit.action} ${kind} ${limit.per ?? ''}`;
79
+ if (limitsSeen.has(key)) throw f.fail(p, 'the same kind of limit is given twice for one action.');
80
+ limitsSeen.add(key);
81
+ });
82
+ return oneUnit;
83
+ }
84
+
85
+ /** What a slip may cover: the names in it, and its purpose (format description, section 24). */
86
+ export const COVERABLE = ['issuer.name', 'agent.name', 'purpose', 'with.name'];
87
+
88
+ /**
89
+ * Confirm every member of a slip's content (format description, section
90
+ * 4.1).
91
+ * @param {any} c the content, with every revealed field back in place
92
+ * @param {Map<string, {covered: number, revealed: string[]}>} [places] where the signed content held covered fields (see uncover)
93
+ * @param {boolean} [named] whether the signed content named its fingerprint method (see uncover)
94
+ * @returns {string[]} the fields that are covered and were not revealed, for example "issuer.name"
95
+ * @throws {Refusal} "bad-field" or "bad-key"
96
+ */
97
+ export function validateSlipContent(c, places = new Map(), named = false) {
98
+ // Only a name or the purpose may be covered, each in its own object. A
99
+ // list of covered fields there holds exactly one fingerprint: that of the
100
+ // one field. So a field is absent only where its fingerprint stands in
101
+ // its place, and nothing can be shown as covered that is not.
102
+ const coveredNow = [];
103
+ const mayLack = (object, path, name) => {
104
+ const place = places.get(path);
105
+ if (!place || place.covered !== 1) return [];
106
+ if (object !== null && typeof object === 'object' && Object.hasOwn(object, name)) {
107
+ throw f.fail(path || 'slip', 'holds a field and, as well, a fingerprint that stands in for it.');
108
+ }
109
+ coveredNow.push(path === '' ? name : `${path}.${name}`);
110
+ return [name];
111
+ };
112
+ for (const [path, place] of places) {
113
+ const name = path === '' ? 'purpose' : path === 'issuer' || path === 'agent' || /^with\[\d+\]$/.test(path) ? 'name' : null;
114
+ if (name === null || place.revealed.some((r) => r !== name)) {
115
+ throw f.fail(path || 'slip', 'only a name or the purpose of a slip may be covered.');
116
+ }
117
+ if (place.covered + place.revealed.length !== 1) {
118
+ throw f.fail(path || 'slip', 'a list of covered fields in a slip holds exactly one fingerprint.');
119
+ }
120
+ }
121
+ if ((places.size > 0) !== named) throw f.fail('slip', '"_sd_alg" is given where a field is covered, and only there.');
122
+ const lacksPurpose = mayLack(c, '', 'purpose');
123
+ f.members(c, SLIP_MEMBERS.filter((m) => !lacksPurpose.includes(m)), ['passes'], 'slip');
124
+ f.id(c.id, 'id');
125
+ // How many times the permission may be passed on to a helper agent. Absent: not at all.
126
+ if (Object.hasOwn(c, 'passes')) {
127
+ f.wholeNumber(c.passes, 'passes');
128
+ if (c.passes < 1 || c.passes > 10) throw f.fail('passes', 'must be from 1 to 10; leave it out if the permission may not be passed on.');
129
+ }
130
+
131
+ const lacksIssuerName = mayLack(c.issuer, 'issuer', 'name');
132
+ f.members(c.issuer, ['key', 'name', 'origin', 'rpId'].filter((m) => !lacksIssuerName.includes(m)), [], 'issuer');
133
+ if (!lacksIssuerName.length) f.label(c.issuer.name, 'issuer.name');
134
+ checkKey(c.issuer.key, PASSKEY_METHODS, 'issuer.key');
135
+ checkOrigin(c.issuer.origin, c.issuer.rpId);
136
+
137
+ const lacksAgentName = mayLack(c.agent, 'agent', 'name');
138
+ f.members(c.agent, ['keys', 'name'].filter((m) => !lacksAgentName.includes(m)), ['software'], 'agent');
139
+ if (!lacksAgentName.length) f.label(c.agent.name, 'agent.name');
140
+ checkKeySet(c.agent.keys, 'agent.keys');
141
+ if (Object.hasOwn(c.agent, 'software')) {
142
+ f.list(c.agent.software, 1, 16, 'agent.software');
143
+ c.agent.software.forEach((s, i) => {
144
+ f.members(s, ['name', 'sha256'], [], `agent.software[${i}]`);
145
+ f.label(s.name, `agent.software[${i}].name`);
146
+ f.fingerprintText(s.sha256, `agent.software[${i}].sha256`);
147
+ });
148
+ }
149
+
150
+ f.list(c.actions, 1, 64, 'actions');
151
+ c.actions.forEach((a, i) => f.actionName(a, `actions[${i}]`));
152
+ if (new Set(c.actions).size !== c.actions.length) throw f.fail('actions', 'an action name is repeated.');
153
+
154
+ const oneUnit = validateLimits(c.actions, c.limits);
155
+
156
+ f.list(c.requires, 0, 64, 'requires');
157
+ const requiresSeen = new Set();
158
+ c.requires.forEach((r, i) => {
159
+ const p = `requires[${i}]`;
160
+ f.members(r, ['need'], ['above', 'action', 'unit'], p);
161
+ if (r.need !== 'approval' && r.need !== 'countersignature') throw f.fail(`${p}.need`, 'must be "approval" or "countersignature".');
162
+ if (Object.hasOwn(r, 'action') && !c.actions.includes(r.action)) throw f.fail(`${p}.action`, 'must be one of the slip\'s actions.');
163
+ if (Object.hasOwn(r, 'above') !== Object.hasOwn(r, 'unit')) throw f.fail(p, '"above" and "unit" come together.');
164
+ if (Object.hasOwn(r, 'above')) {
165
+ if (!Object.hasOwn(r, 'action')) throw f.fail(p, '"above" needs an "action".');
166
+ f.wholeNumber(r.above, `${p}.above`);
167
+ oneUnit(r.action, r.unit, `${p}.unit`);
168
+ }
169
+ const key = `${r.need} ${r.action ?? ''}`;
170
+ if (requiresSeen.has(key)) throw f.fail(p, 'the same condition is given twice.');
171
+ requiresSeen.add(key);
172
+ });
173
+
174
+ f.list(c.never, 0, 16, 'never');
175
+ c.never.forEach((n, i) => {
176
+ if (!isNeverName(n)) throw f.fail(`never[${i}]`, 'must be a listed kind of action or a listed rule of conduct.');
177
+ });
178
+ if (new Set(c.never).size !== c.never.length) throw f.fail('never', 'a name is repeated.');
179
+ for (const a of c.actions) {
180
+ if (c.never.includes(actionKind(a))) throw f.fail('never', 'forbids a kind of action that the slip also allows.');
181
+ }
182
+
183
+ f.list(c.with, 0, 64, 'with');
184
+ const ids = new Set();
185
+ c.with.forEach((service, i) => {
186
+ const lacksName = mayLack(service, `with[${i}]`, 'name');
187
+ f.members(service, ['id', 'name'].filter((m) => !lacksName.includes(m)), ['keys'], `with[${i}]`);
188
+ f.shortName(service.id, `with[${i}].id`);
189
+ if (ids.has(service.id)) throw f.fail(`with[${i}].id`, 'a service id is repeated.');
190
+ ids.add(service.id);
191
+ if (!lacksName.length) f.label(service.name, `with[${i}].name`);
192
+ if (Object.hasOwn(service, 'keys')) checkKeySet(service.keys, `with[${i}].keys`);
193
+ });
194
+
195
+ const from = f.time(c.validFrom, 'validFrom');
196
+ const until = f.time(c.validUntil, 'validUntil');
197
+ if (!(from < until)) throw f.fail('validUntil', 'must be later than validFrom.');
198
+ if (!lacksPurpose.length) f.text(c.purpose, 1, 1000, 'purpose');
199
+ return coveredNow;
200
+ }
201
+
202
+ /**
203
+ * Check the passkey signature on a record (a slip or an approval) against
204
+ * the issuer a slip names (format description, section 4.4).
205
+ * @param {any} parsed what parseRecord returned
206
+ * @param {{key: any, rpId: string, origin: string}} issuer
207
+ * @param {string[]} [without] see verifySignature
208
+ * @returns {Promise<'valid'|'unavailable'>}
209
+ * @throws {Refusal}
210
+ */
211
+ export async function checkPasskeySignature(parsed, issuer, without) {
212
+ const only = parsed.signatures[0];
213
+ let authenticatorData;
214
+ let clientDataJSON;
215
+ try {
216
+ authenticatorData = fromBase64url(only.header.authenticatorData);
217
+ clientDataJSON = fromBase64url(only.header.clientDataJSON);
218
+ } catch {
219
+ throw new Refusal('passkey-bad-data', 'The passkey values stored with the record are not base64url.');
220
+ }
221
+ return checkAssertion(
222
+ {
223
+ key: issuer.key,
224
+ rpId: issuer.rpId,
225
+ origin: issuer.origin,
226
+ signedBytes: signingInput(only.protectedB64, parsed.payloadB64),
227
+ authenticatorData,
228
+ clientDataJSON,
229
+ signature: only.signature,
230
+ },
231
+ without,
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Build the challenge a passkey must sign for a record's content, and put
237
+ * the passkey's answer with it. Used for slips and for approvals.
238
+ * @param {'slip'|'approval'} kind
239
+ * @param {any} content
240
+ */
241
+ export async function preparePasskeyRecord(kind, content) {
242
+ const { contentBytes, payloadB64 } = encodeContent(content);
243
+ // Leave room for what the passkey returns, so the signed record still fits.
244
+ withinSize({ payload: payloadB64, signatures: [{ signature: 'A'.repeat(2048) }] });
245
+ const [protectedB64] = protectedHeaders(kind);
246
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', signingInput(protectedB64, payloadB64));
247
+ return { contentBytes, payloadB64, protectedB64, challenge: new Uint8Array(digest) };
248
+ }
249
+
250
+ /**
251
+ * @typedef {object} SlipCheck
252
+ * @property {'slip'} kind
253
+ * @property {string|null} fingerprint
254
+ * @property {{code: string, message: string}[]} problems reasons the slip cannot be relied on; empty if none
255
+ * @property {string[]} notes things a person should know that are not faults
256
+ * @property {{method: string, alg: string|null, state: 'valid'|'invalid'|'unavailable'|'unchecked'}} signature
257
+ * @property {string|null} issuerKey the thumbprint of the passkey's public key
258
+ * @property {any} content the slip's content, with revealed fields in place, or null if it could not be read
259
+ * @property {string[]} covered the fields that are covered and were not revealed, for example "issuer.name"
260
+ * @property {{code: string, message: string}[]} disclosureProblems why the disclosures handed over were not used, if they
261
+ * were not. The slip is then checked with those fields covered: what a slip is does not depend on what is handed over with it
262
+ */
263
+
264
+ /**
265
+ * Check one slip (format description, section 4.4).
266
+ *
267
+ * @param {any} record the slip as a JavaScript object
268
+ * @param {object} [options]
269
+ * @param {string[]} [options.issuerKeys] thumbprints of the issuer keys the checker expects
270
+ * @param {string[]} [options.withoutMethods] see verifySignature
271
+ * @param {Record<string, string[]>} [options.disclosures] for covered fields: the disclosures handed over, by the fingerprint of the record they belong to
272
+ * @param {string[]} [disclosures] the disclosures for this slip, where they come with it (a page of a Show)
273
+ * @returns {Promise<SlipCheck>}
274
+ */
275
+ export async function checkSlip(record, options = {}, disclosures) {
276
+ /** @type {SlipCheck} */
277
+ const result = {
278
+ kind: 'slip',
279
+ fingerprint: null,
280
+ problems: [],
281
+ notes: [],
282
+ signature: { method: 'passkey', alg: null, state: 'unchecked' },
283
+ issuerKey: null,
284
+ content: null,
285
+ covered: [],
286
+ disclosureProblems: [],
287
+ };
288
+ try {
289
+ if (options === null || typeof options !== 'object') options = {};
290
+ const parsed = await parseRecord(record, 'slip');
291
+ result.fingerprint = parsed.fingerprint;
292
+ // Covered fields: put back what was revealed, by the standard's own steps.
293
+ const handed = options.disclosures;
294
+ const beside = handed !== null && typeof handed === 'object' && Object.hasOwn(handed, parsed.fingerprint) ? handed[parsed.fingerprint] : [];
295
+ // Those that come with the slip (a page of a Show) and those handed
296
+ // over beside it are used together; one given both ways counts once.
297
+ let forThis = disclosures ?? beside;
298
+ if (disclosures !== undefined && Array.isArray(disclosures) && (!Array.isArray(beside) || beside.length > 0)) {
299
+ forThis = Array.isArray(beside) ? [...disclosures, ...beside.filter((d) => !disclosures.includes(d))] : beside;
300
+ }
301
+ // A slip narrows the standard: no covered item of a list, and every
302
+ // disclosure in the canonical form.
303
+ const reveal = async (given) => {
304
+ const { content, places, named } = await uncover(parsed.content, given, { listItems: false, canonical: true });
305
+ return { content, covered: validateSlipContent(content, places, named) };
306
+ };
307
+ let read;
308
+ try {
309
+ read = await reveal(forThis);
310
+ } catch (e) {
311
+ // What a slip is must not depend on what is handed over with it. If
312
+ // the slip is sound with its fields covered, the fault lies in the
313
+ // disclosures: they are not used, and that is reported apart.
314
+ if (Array.isArray(forThis) && forThis.length === 0) throw e;
315
+ read = await reveal([]);
316
+ result.disclosureProblems.push(problemFrom(e));
317
+ }
318
+ result.covered = read.covered;
319
+ const content = read.content;
320
+ result.content = content;
321
+ const { issuer } = content;
322
+ result.signature.method = `passkey (${issuer.key.alg})`;
323
+ result.signature.alg = issuer.key.alg;
324
+ result.issuerKey = await thumbprint(issuer.key);
325
+
326
+ result.signature.state = 'invalid';
327
+ result.signature.state = await checkPasskeySignature(parsed, issuer, options.withoutMethods);
328
+ if (result.signature.state === 'unavailable') {
329
+ result.notes.push(`This device cannot check the passkey's signing method (${issuer.key.alg}).`);
330
+ }
331
+
332
+ if (Array.isArray(options.issuerKeys)) {
333
+ if (!options.issuerKeys.includes(result.issuerKey)) {
334
+ throw new Refusal('issuer-not-expected', 'The slip was signed by a passkey other than the ones expected.');
335
+ }
336
+ } else {
337
+ result.notes.push('The issuer\'s key was not compared with a key you already trust. The name on the slip is only a label.');
338
+ }
339
+ } catch (e) {
340
+ result.problems.push(problemFrom(e));
341
+ }
342
+ return result;
343
+ }
344
+
345
+ // --- writing a slip ---
346
+
347
+ /**
348
+ * Build the content of a new slip and the challenge a passkey must sign
349
+ * (format description, section 4.2, steps 1 and 2).
350
+ *
351
+ * @param {object} fields every member of the slip except "type"; "id" is made if absent
352
+ * @param {object} [options]
353
+ * @param {string[]} [options.cover] the fields to cover, from COVERABLE: "issuer.name", "agent.name", "purpose", and "with.name" for the name of every service
354
+ * @returns {Promise<{contentBytes: Uint8Array, payloadB64: string, protectedB64: string, challenge: Uint8Array, disclosures: string[]}>}
355
+ * "disclosures" reveal the covered fields. Keep them beside the slip, and hand over only the ones a reader needs.
356
+ */
357
+ export async function prepareSlip(fields, options = {}) {
358
+ // A slip with no conditions and no prohibitions says so with empty lists.
359
+ let content = { requires: [], never: [], ...fields, type: KINDS.slip.type, id: fields.id ?? randomId() };
360
+ validateSlipContent(content);
361
+ const cover = Array.isArray(options.cover) ? options.cover : [];
362
+ if (cover.some((name) => !COVERABLE.includes(name))) throw f.fail('cover', 'only a name or the purpose of a slip may be covered.');
363
+ const disclosures = [];
364
+ const hide = async (object, names) => {
365
+ const covered = await coverMembers(object, names);
366
+ disclosures.push(...covered.disclosures);
367
+ return covered.object;
368
+ };
369
+ if (cover.includes('issuer.name')) content.issuer = await hide(content.issuer, ['name']);
370
+ if (cover.includes('agent.name')) content.agent = await hide(content.agent, ['name']);
371
+ if (cover.includes('with.name')) content.with = await Promise.all(content.with.map((service) => hide(service, ['name'])));
372
+ if (cover.includes('purpose')) content = await hide(content, ['purpose']);
373
+ // The fingerprint method is named, as the standard allows, wherever anything is covered.
374
+ if (disclosures.length) content._sd_alg = 'sha-256';
375
+ return { ...(await preparePasskeyRecord('slip', content)), disclosures };
376
+ }
377
+
378
+ /**
379
+ * Put the passkey's three values with the prepared content: the signed slip
380
+ * (format description, sections 4.2 step 4 and 4.3). The values are stored
381
+ * exactly as the passkey returned them.
382
+ *
383
+ * @param {{payloadB64: string, protectedB64: string}} prepared from prepareSlip
384
+ * @param {{authenticatorData: Uint8Array, clientDataJSON: Uint8Array, signature: Uint8Array}} assertion
385
+ * @returns {object} the slip record
386
+ */
387
+ export function assembleSlip(prepared, assertion) {
388
+ return withinSize({
389
+ payload: prepared.payloadB64,
390
+ signatures: [
391
+ {
392
+ protected: prepared.protectedB64,
393
+ header: {
394
+ authenticatorData: toBase64url(assertion.authenticatorData),
395
+ clientDataJSON: toBase64url(assertion.clientDataJSON),
396
+ },
397
+ signature: toBase64url(assertion.signature),
398
+ },
399
+ ],
400
+ });
401
+ }
@@ -0,0 +1,181 @@
1
+ // Records that begin or end someone's standing:
2
+ // - a cancellation: the person ends a slip early, with their passkey;
3
+ // - an acknowledgement: the agent's side states that it was handed a
4
+ // cancellation;
5
+ // - a vouching record: an organisation states whose key a key is;
6
+ // - a withdrawal: the organisation ends a vouching record.
7
+ // Format description, sections 22, 23 and 26.
8
+
9
+ import { fingerprint, formatTime, randomId } from './encoding.js';
10
+ import * as f from './fields.js';
11
+ import { KINDS } from './jws.js';
12
+ import { PASSKEY_METHODS, checkKey, checkKeySet, checkSealKeySet } from './keys.js';
13
+ import { assembleSlip, preparePasskeyRecord } from './slip.js';
14
+ import { signWithKeySet } from './stub.js';
15
+
16
+ /** What a vouching record can vouch for, and the keys each kind has. */
17
+ export const VOUCHED_KINDS = {
18
+ person: 'A person, known by the public key of their passkey.',
19
+ agent: 'An agent, known by its two keys.',
20
+ service: 'A service, known by its two keys.',
21
+ recorder: 'A recorder, known by its three keys.',
22
+ };
23
+
24
+ /**
25
+ * Confirm every member of a cancellation's content.
26
+ * @param {any} c
27
+ */
28
+ export function validateCancellationContent(c) {
29
+ f.members(c, ['id', 'slip', 'type', 'when'], [], 'cancellation');
30
+ f.id(c.id, 'id');
31
+ f.fingerprintText(c.slip, 'slip');
32
+ f.time(c.when, 'when');
33
+ }
34
+
35
+ /**
36
+ * Confirm every member of an acknowledgement's content.
37
+ * @param {any} c
38
+ */
39
+ export function validateAcknowledgementContent(c) {
40
+ f.members(c, ['cancellation', 'id', 'slip', 'type', 'when'], ['pass'], 'acknowledgement');
41
+ f.id(c.id, 'id');
42
+ f.fingerprintText(c.slip, 'slip');
43
+ f.fingerprintText(c.cancellation, 'cancellation');
44
+ if (Object.hasOwn(c, 'pass')) f.fingerprintText(c.pass, 'pass');
45
+ f.time(c.when, 'when');
46
+ }
47
+
48
+ /**
49
+ * Confirm every member of a vouching record's content.
50
+ * @param {any} c
51
+ */
52
+ export function validateVouchingContent(c) {
53
+ f.members(c, ['by', 'for', 'id', 'type', 'validFrom', 'validUntil', 'when'], [], 'vouching');
54
+ f.id(c.id, 'id');
55
+ f.members(c.by, ['keys', 'name'], [], 'by');
56
+ f.label(c.by.name, 'by.name');
57
+ checkKeySet(c.by.keys, 'by.keys');
58
+ const who = c.for;
59
+ if (who === null || typeof who !== 'object' || typeof who.kind !== 'string' || !Object.hasOwn(VOUCHED_KINDS, who.kind)) {
60
+ throw f.fail('for.kind', 'must be "person", "agent", "service" or "recorder".');
61
+ }
62
+ if (who.kind === 'person') {
63
+ f.members(who, ['key', 'kind', 'name'], [], 'for');
64
+ checkKey(who.key, PASSKEY_METHODS, 'for.key');
65
+ } else {
66
+ f.members(who, ['keys', 'kind', 'name'], [], 'for');
67
+ if (who.kind === 'recorder') checkSealKeySet(who.keys, 'for.keys');
68
+ else checkKeySet(who.keys, 'for.keys');
69
+ }
70
+ f.label(who.name, 'for.name');
71
+ const from = f.time(c.validFrom, 'validFrom');
72
+ const until = f.time(c.validUntil, 'validUntil');
73
+ if (!(from < until)) throw f.fail('validUntil', 'must be later than validFrom.');
74
+ f.time(c.when, 'when');
75
+ }
76
+
77
+ /**
78
+ * Confirm every member of a withdrawal's content.
79
+ * @param {any} c
80
+ */
81
+ export function validateWithdrawalContent(c) {
82
+ f.members(c, ['id', 'type', 'vouching', 'when'], [], 'withdrawal');
83
+ f.id(c.id, 'id');
84
+ f.fingerprintText(c.vouching, 'vouching');
85
+ f.time(c.when, 'when');
86
+ }
87
+
88
+ /**
89
+ * Build the content of a cancellation and the challenge the person's
90
+ * passkey must sign.
91
+ * @param {object} fields
92
+ * @param {string} fields.slip the fingerprint of the slip to cancel
93
+ * @param {number|Date} [fields.when] defaults to now
94
+ * @returns {Promise<{payloadB64: string, protectedB64: string, challenge: Uint8Array, fingerprint: string}>}
95
+ */
96
+ export async function prepareCancellation(fields) {
97
+ const content = { type: KINDS.cancellation.type, id: fields.id ?? randomId(), slip: fields.slip, when: formatTime(fields.when ?? Date.now()) };
98
+ validateCancellationContent(content);
99
+ const prepared = await preparePasskeyRecord('cancellation', content);
100
+ return { ...prepared, fingerprint: await fingerprint(prepared.contentBytes) };
101
+ }
102
+
103
+ /**
104
+ * Put the passkey's three values with the prepared content: the signed
105
+ * cancellation.
106
+ * @param {{payloadB64: string, protectedB64: string}} prepared from prepareCancellation
107
+ * @param {{authenticatorData: Uint8Array, clientDataJSON: Uint8Array, signature: Uint8Array}} assertion
108
+ * @returns {object} the cancellation record
109
+ */
110
+ export function assembleCancellation(prepared, assertion) {
111
+ return assembleSlip(prepared, assertion);
112
+ }
113
+
114
+ /**
115
+ * Write and sign an acknowledgement: the statement of the agent's side
116
+ * that it was handed the person's cancellation of a slip, and when. It is
117
+ * signed with the keys of the agent the slip names or, where the signer is
118
+ * a helper agent, with the keys the pass names.
119
+ *
120
+ * @param {object} fields
121
+ * @param {string} fields.slip the fingerprint of the slip that was cancelled
122
+ * @param {string} fields.cancellation the fingerprint of the cancellation
123
+ * @param {string} [fields.pass] for a helper agent: the fingerprint of the pass it acts under
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
126
+ * @returns {Promise<{record: object, fingerprint: string}>}
127
+ */
128
+ export async function writeAcknowledgement(fields, privateKeys) {
129
+ const content = {
130
+ type: KINDS.acknowledgement.type,
131
+ id: fields.id ?? randomId(),
132
+ slip: fields.slip,
133
+ cancellation: fields.cancellation,
134
+ when: formatTime(fields.when ?? Date.now()),
135
+ };
136
+ if (fields.pass) content.pass = fields.pass;
137
+ validateAcknowledgementContent(content);
138
+ return signWithKeySet('acknowledgement', content, privateKeys);
139
+ }
140
+
141
+ /**
142
+ * Write and sign a vouching record: an organisation's statement that a key
143
+ * belongs to a name.
144
+ *
145
+ * @param {object} fields
146
+ * @param {{keys: object[], name: string}} fields.by the organisation's public key set and its name (a label)
147
+ * @param {{kind: string, name: string, key?: object, keys?: object[]}} fields.for whom it vouches for
148
+ * @param {string} fields.validFrom
149
+ * @param {string} fields.validUntil
150
+ * @param {number|Date} [fields.when] defaults to now
151
+ * @param {CryptoKey[]} privateKeys the organisation's private keys: Ed25519, then ML-DSA-87
152
+ * @returns {Promise<{record: object, fingerprint: string}>}
153
+ */
154
+ export async function writeVouching(fields, privateKeys) {
155
+ const content = {
156
+ type: KINDS.vouching.type,
157
+ id: fields.id ?? randomId(),
158
+ by: fields.by,
159
+ for: fields.for,
160
+ validFrom: fields.validFrom,
161
+ validUntil: fields.validUntil,
162
+ when: formatTime(fields.when ?? Date.now()),
163
+ };
164
+ validateVouchingContent(content);
165
+ return signWithKeySet('vouching', content, privateKeys);
166
+ }
167
+
168
+ /**
169
+ * Write and sign a withdrawal: the organisation ends a vouching record.
170
+ *
171
+ * @param {object} fields
172
+ * @param {string} fields.vouching the fingerprint of the vouching record
173
+ * @param {number|Date} [fields.when] defaults to now
174
+ * @param {CryptoKey[]} privateKeys the private keys of the organisation that made the vouching record
175
+ * @returns {Promise<{record: object, fingerprint: string}>}
176
+ */
177
+ export async function writeWithdrawal(fields, privateKeys) {
178
+ const content = { type: KINDS.withdrawal.type, id: fields.id ?? randomId(), vouching: fields.vouching, when: formatTime(fields.when ?? Date.now()) };
179
+ validateWithdrawalContent(content);
180
+ return signWithKeySet('withdrawal', content, privateKeys);
181
+ }