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/check.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Type declarations for the checker only: everything needed to check a
2
+ // record, and nothing that can make one. See types.d.ts.
3
+ export {
4
+ checkBook,
5
+ checkShow,
6
+ openChecker,
7
+ checkSlip,
8
+ methodAvailable,
9
+ thumbprint,
10
+ keySetFingerprint,
11
+ blockFingerprint,
12
+ checkHeaderChain,
13
+ blocksInChain,
14
+ MIN_BLOCKS_AFTER,
15
+ limitWords,
16
+ conditionWords,
17
+ neverWords,
18
+ LIMITS,
19
+ REFUSAL_REASONS,
20
+ } from './types.js';
21
+ export type * from './types.js';
package/src/check.js ADDED
@@ -0,0 +1,15 @@
1
+ // The checker: everything needed to check a record, and nothing that can
2
+ // make one. This is the smallest part to read for anyone who audits the
3
+ // checking.
4
+ //
5
+ // It makes no network request and depends on no outside code.
6
+
7
+ export { checkBook, checkShow, openChecker, LIMITS } from './book.js';
8
+ export { limitWords, conditionWords, neverWords } from './compare.js';
9
+ export { REFUSAL_REASONS } from './service.js';
10
+ export { checkSlip } from './slip.js';
11
+ export { keySetFingerprint } from './seal.js';
12
+ export { blockFingerprint } from './blockstamp.js';
13
+ export { checkHeaderChain, blocksInChain, MIN_BLOCKS_AFTER } from './headers.js';
14
+ export { thumbprint } from './keys.js';
15
+ export { methodAvailable } from './signatures.js';
package/src/compare.js ADDED
@@ -0,0 +1,276 @@
1
+ // What a stub shows about the agent, compared with its slip.
2
+ // Format description, section 8.
3
+ //
4
+ // The checker and the "check before acting" function both use this one
5
+ // comparison, so that nothing is allowed beforehand that would be reported
6
+ // afterwards. In one case the check before acting refuses more: see
7
+ // periodWith.
8
+
9
+ import { ACTION_KINDS, CONDUCT_RULES, actionKind } from './actions.js';
10
+ import { parseTime } from './encoding.js';
11
+
12
+ /** The running state kept for one slip while its stubs are read in order. */
13
+ export function newTally() {
14
+ return { sums: new Map(), counts: new Map(), periods: new Map() };
15
+ }
16
+
17
+ // The total inside the period that ends at each stub. A period is counted
18
+ // back from a stub by the times the stubs state, whichever chain a stub is
19
+ // in and wherever it stands in the book: an agent and its helpers each have
20
+ // a chain, and their stubs need not stand in the book in order of time. So
21
+ // the stubs are first put in order of time (those with the same time stay
22
+ // in the order of the book), and the period of each takes in the stubs up
23
+ // to it that are later than its time minus the period.
24
+ function periodTotals(items, periodMs) {
25
+ const sorted = items.map((item, place) => ({ item, place })).sort((a, b) => a.item.when - b.item.when || a.place - b.place);
26
+ const totals = [];
27
+ let head = 0;
28
+ let sum = 0n;
29
+ for (const { item } of sorted) {
30
+ sum += item.value;
31
+ while (sorted[head].item.when <= item.when - periodMs) {
32
+ sum -= sorted[head].item.value;
33
+ head++;
34
+ }
35
+ totals.push({ item, total: sum });
36
+ }
37
+ return totals;
38
+ }
39
+
40
+ // For a check before acting: the largest total of any period that the
41
+ // proposed action would fall in. That is its own period, and the period of
42
+ // every stub dated after it and less than one period later.
43
+ //
44
+ // Where a stub dated after the proposed action is already over the limit,
45
+ // the action is refused: it would add to a period that holds too much. A
46
+ // checker, with the stub written, reports nothing new, because the finding
47
+ // stands with the later stub, which it already marks. This is the one case
48
+ // where the check before acting refuses more than a checker would report.
49
+ function periodWith(items, when, periodMs, value) {
50
+ const proposed = { when, value };
51
+ let worst = 0n;
52
+ for (const { item, total } of periodTotals([...items, proposed], periodMs)) {
53
+ if ((item === proposed || (item.when > when && item.when < when + periodMs)) && total > worst) worst = total;
54
+ }
55
+ return worst;
56
+ }
57
+
58
+ function periodWords(seconds) {
59
+ if (seconds % 86400 === 0) return seconds === 86400 ? 'any 24 hours' : `any ${seconds / 86400} days`;
60
+ if (seconds % 3600 === 0) return seconds === 3600 ? 'any hour' : `any ${seconds / 3600} hours`;
61
+ return `any ${seconds} seconds`;
62
+ }
63
+
64
+ const allowedIn = (limit) => BigInt(Object.hasOwn(limit, 'count') ? limit.count : limit.max);
65
+
66
+ function overPeriod(limit, total, W) {
67
+ const message = Object.hasOwn(limit, 'count')
68
+ ? `With this stub there are ${total} such actions in ${periodWords(limit.per)}. ${W} allows ${limit.count}.`
69
+ : `With this stub the total in ${periodWords(limit.per)} is ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}.`;
70
+ return { code: 'over-period-limit', message };
71
+ }
72
+
73
+ // The words for what a stub is compared with: a slip, the pass a helper
74
+ // acts under, or a pass further up, from which that pass was handed on.
75
+ function wordsFor(what) {
76
+ if (what === 'pass') return ['The pass', 'the pass'];
77
+ if (what === 'earlier-pass') return ['The earlier pass', 'the earlier pass'];
78
+ return ['The slip', 'the slip'];
79
+ }
80
+
81
+ /**
82
+ * Once every stub under a slip, or under a pass, has been read: settle its
83
+ * limits "in any period". Each stub whose period holds more than the limit
84
+ * allows is given the finding. It can be done only at the end, because a
85
+ * stub further on in the book may be dated earlier (see periodTotals).
86
+ *
87
+ * The findings are not put into the stubs' own lists, which stay as they
88
+ * are: they are gathered in "late", each list under the stub's own list.
89
+ * So the same stubs can be settled again after more have been read.
90
+ *
91
+ * @param {any} s the slip's content, or a pass written in the same form
92
+ * @param {ReturnType<typeof newTally>} tally
93
+ * @param {Map<object[], {code: string, message: string}[]>} late where the findings are gathered
94
+ */
95
+ export function settlePeriods(s, tally, late) {
96
+ const over = (b) => b.code === 'over-period-limit';
97
+ for (const [i, items] of tally.periods) {
98
+ const limit = s.limits[i];
99
+ const allowed = allowedIn(limit);
100
+ for (const { item, total } of periodTotals(items, limit.per * 1000)) {
101
+ if (!(total > allowed)) continue;
102
+ let found = late.get(item.late);
103
+ if (!found) late.set(item.late, (found = []));
104
+ // A pass does not repeat a finding that the slip, or a pass before it, gave for the stub.
105
+ if (item.what !== 'slip' && (item.late.some(over) || found.some(over))) continue;
106
+ found.push(overPeriod(limit, total, item.W));
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Describe a limit in words, for people.
113
+ * @param {any} limit a limit that has passed the slip's check
114
+ * @returns {string}
115
+ */
116
+ export function limitWords(limit) {
117
+ const period = Object.hasOwn(limit, 'per') ? ` in ${periodWords(limit.per)}` : ' in total';
118
+ if (Object.hasOwn(limit, 'each')) return `${limit.action}: no single action above ${limit.each} ${limit.unit}`;
119
+ if (Object.hasOwn(limit, 'count')) return `${limit.action}: no more than ${limit.count} ${limit.count === 1 ? 'action' : 'actions'}${period}`;
120
+ return `${limit.action}: no more than ${limit.max} ${limit.unit}${period}`;
121
+ }
122
+
123
+ /**
124
+ * Describe a condition ("requires") in words, for people.
125
+ * @param {any} r a condition that has passed the slip's check
126
+ * @returns {string}
127
+ */
128
+ export function conditionWords(r) {
129
+ const what = r.need === 'approval' ? 'needs the person\'s own approval' : 'needs the other side\'s countersignature';
130
+ const above = Object.hasOwn(r, 'above') ? ` above ${r.above} ${r.unit}` : '';
131
+ return `${Object.hasOwn(r, 'action') ? r.action : 'every action'}: ${what}${above}`;
132
+ }
133
+
134
+ /**
135
+ * Describe one name of a "never" list in words, for people.
136
+ * @param {string} name a name that has passed the slip's check
137
+ * @returns {string}
138
+ */
139
+ export function neverWords(name) {
140
+ if (Object.hasOwn(CONDUCT_RULES, name)) return CONDUCT_RULES[name];
141
+ return `Never do an action of the kind "${name}": ${ACTION_KINDS[name].charAt(0).toLowerCase()}${ACTION_KINDS[name].slice(1)}`;
142
+ }
143
+
144
+ /**
145
+ * Compare one stub, or one action that is only proposed, with its slip.
146
+ *
147
+ * @param {any} s the slip's content
148
+ * @param {ReturnType<typeof newTally>|null} tally the slip's running state, or null when only single pages are at hand
149
+ * @param {any} c the stub's content: "action", "when", and where present "amount" and "with"
150
+ * @param {object} facts
151
+ * @param {any} [facts.service] the service in the slip that the stub names, if it names one the slip knows
152
+ * @param {any} [facts.terms] the content of the service's terms the stub relies on, if any
153
+ * @param {boolean} [facts.cancelled] whether the slip was cancelled earlier in the book
154
+ * @param {'slip'|'pass'|'earlier-pass'} [facts.what] what "s" is: a slip, or a pass written in the same form
155
+ * @param {{code: string, message: string}[]} [facts.late] where a finding that can be made only once every stub
156
+ * has been read is to be put (see settlePeriods). Without it, such a finding joins the list this call returns.
157
+ * @param {boolean} [commit] false to leave the running state as it was (a check before acting)
158
+ * @returns {{breaches: {code: string, message: string}[], needs: string[], running: object|undefined}}
159
+ * "needs" lists what the slip asks to go with this action: "countersignature", "approval"
160
+ */
161
+ export function compareWithSlip(s, tally, c, facts, commit = true) {
162
+ // The same comparison is made with a pass: the messages then say so.
163
+ const [W, w] = wordsFor(facts.what);
164
+ const breaches = [];
165
+ const when = parseTime(c.when);
166
+
167
+ // A limit "in any period". While a book is read, the stub is only noted,
168
+ // and the limit is settled once every stub has been read (settlePeriods).
169
+ // A check before acting works it out at once, for the proposed action.
170
+ const inPeriod = (i, limit, value) => {
171
+ const items = tally.periods.get(i) ?? [];
172
+ if (commit) {
173
+ tally.periods.set(i, items);
174
+ items.push({ when, value, W, what: facts.what ?? 'slip', late: facts.late ?? breaches });
175
+ return;
176
+ }
177
+ const worst = periodWith(items, when, limit.per * 1000, value);
178
+ if (worst > allowedIn(limit)) breaches.push(overPeriod(limit, worst, W));
179
+ };
180
+
181
+ if (!s.actions.includes(c.action)) {
182
+ breaches.push({ code: 'action-not-allowed', message: `${W} does not allow the action "${c.action}".` });
183
+ }
184
+ const kind = actionKind(c.action);
185
+ if (facts.cancelled) {
186
+ breaches.push({ code: 'after-cancellation', message: 'The person cancelled the slip before this action.' });
187
+ }
188
+ if (kind && s.never.includes(kind)) {
189
+ breaches.push({ code: 'prohibited', message: `${W} says the agent must never do an action of the kind "${kind}", and "${c.action}" is of that kind.` });
190
+ }
191
+ if (Object.hasOwn(c, 'with') && !facts.service) {
192
+ breaches.push({ code: 'party-not-allowed', message: `${W} does not name "${c.with}" as someone the agent may deal with.` });
193
+ }
194
+ if (when < parseTime(s.validFrom) || when >= parseTime(s.validUntil)) {
195
+ breaches.push({ code: 'outside-valid-time', message: `The action is dated outside the time ${w} allows.` });
196
+ }
197
+ if (facts.terms) {
198
+ const t = facts.terms;
199
+ if (!t.accepts.includes(c.action)) {
200
+ breaches.push({ code: 'outside-terms', message: `The service's terms for agents do not accept the action "${c.action}".` });
201
+ } else if (when < parseTime(t.validFrom) || when >= parseTime(t.validUntil)) {
202
+ breaches.push({ code: 'outside-terms', message: 'The action is dated outside the time the service\'s terms for agents were in force.' });
203
+ }
204
+ }
205
+
206
+ // Amounts. Every amount limit for one action is in one unit (the slip's
207
+ // check makes sure of it), so a stub is asked for its amount once.
208
+ let missing = false;
209
+ const amountIn = (unit) => {
210
+ if (c.amount && c.amount.unit === unit) return true;
211
+ if (!missing) {
212
+ breaches.push({ code: 'amount-missing', message: `${W} sets a limit or a condition in "${unit}" for this action, and the stub gives no amount in that unit.` });
213
+ missing = true;
214
+ }
215
+ return false;
216
+ };
217
+
218
+ let running;
219
+ s.limits.forEach((limit, i) => {
220
+ if (limit.action !== c.action) return;
221
+ const periodMs = Object.hasOwn(limit, 'per') ? limit.per * 1000 : null;
222
+ if (Object.hasOwn(limit, 'count')) {
223
+ if (!tally) return; // a single page cannot show a count
224
+ if (periodMs === null) {
225
+ const n = (tally.counts.get(i) ?? 0) + 1;
226
+ if (commit) tally.counts.set(i, n);
227
+ if (n > limit.count) {
228
+ breaches.push({ code: 'over-count-limit', message: `This is action number ${n} of this kind under ${w}. ${W} allows ${limit.count}.` });
229
+ }
230
+ } else {
231
+ inPeriod(i, limit, 1n);
232
+ }
233
+ return;
234
+ }
235
+ if (!amountIn(limit.unit)) return;
236
+ const value = BigInt(c.amount.value);
237
+ if (Object.hasOwn(limit, 'each')) {
238
+ if (value > BigInt(limit.each)) {
239
+ breaches.push({ code: 'over-each-limit', message: `This action is ${value} ${limit.unit}. ${W} allows no single action above ${limit.each} ${limit.unit}.` });
240
+ }
241
+ } else if (periodMs === null) {
242
+ if (tally) {
243
+ const total = (tally.sums.get(i) ?? 0n) + value;
244
+ if (commit) tally.sums.set(i, total);
245
+ running = { action: c.action, unit: limit.unit, total: total.toString(), max: limit.max };
246
+ if (total > BigInt(limit.max)) {
247
+ breaches.push({
248
+ code: 'over-limit',
249
+ message: `With this stub the total is ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}: over by ${total - BigInt(limit.max)}.`,
250
+ });
251
+ }
252
+ } else if (value > BigInt(limit.max)) {
253
+ // A single page cannot show a running total, but it can show a stub
254
+ // that passes the limit all by itself.
255
+ breaches.push({ code: 'over-limit', message: `This stub alone is ${value} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit} in total.` });
256
+ }
257
+ } else if (tally) {
258
+ inPeriod(i, limit, value);
259
+ } else if (value > BigInt(limit.max)) {
260
+ breaches.push({ code: 'over-period-limit', message: `This stub alone is ${value} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit} in ${periodWords(limit.per)}.` });
261
+ }
262
+ });
263
+
264
+ // What the slip asks to go with this action.
265
+ const needs = [];
266
+ for (const r of s.requires) {
267
+ if (Object.hasOwn(r, 'action') && r.action !== c.action) continue;
268
+ if (Object.hasOwn(r, 'above')) {
269
+ if (!amountIn(r.unit)) continue;
270
+ if (!(c.amount.value > r.above)) continue;
271
+ }
272
+ if (!needs.includes(r.need)) needs.push(r.need);
273
+ }
274
+
275
+ return { breaches, needs, running };
276
+ }
package/src/cover.js ADDED
@@ -0,0 +1,198 @@
1
+ // Covered fields: a record can be shown with some fields hidden, while its
2
+ // signatures still check. The method is the standard "Selective Disclosure
3
+ // for JSON Web Tokens" (RFC 9901), as it stands:
4
+ //
5
+ // - a field to be covered is taken out of the signed content, and the
6
+ // fingerprint of a "disclosure" is put in its place, in a list named
7
+ // "_sd" (section 4.2.4.1);
8
+ // - a disclosure is a salt (a random value), the field's name and its
9
+ // value, written as a JSON list and then in base64url (section 4.2.1);
10
+ // - the fingerprint is taken over the base64url text itself (section 4.2.3);
11
+ // - to reveal the field, hand over the disclosure; to hide it, do not.
12
+ //
13
+ // Format description, section 24.
14
+
15
+ import { Refusal, canonicalJson, fromBase64url, fromUtf8, parseCanonical, randomId, sha256, toBase64url, utf8 } from './encoding.js';
16
+
17
+ /** The most disclosures one record may come with. */
18
+ export const MAX_DISCLOSURES = 64;
19
+
20
+ const MAX_DEPTH = 12;
21
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
22
+
23
+ function bad(why) {
24
+ return new Refusal('cover-invalid', `A covered field is not as its standard sets out: ${why}`);
25
+ }
26
+
27
+ /**
28
+ * The fingerprint of a disclosure: SHA-256 over the characters of its
29
+ * base64url text, written in base64url (RFC 9901, section 4.2.3).
30
+ * @param {string} disclosure
31
+ * @returns {Promise<string>}
32
+ */
33
+ export async function disclosureDigest(disclosure) {
34
+ return toBase64url(await sha256(utf8(disclosure)));
35
+ }
36
+
37
+ /**
38
+ * Make the disclosure for one member of an object (RFC 9901, section 4.2.1).
39
+ * @param {string} name the member's name
40
+ * @param {any} value the member's value
41
+ * @param {string} [salt] 128 random bits in base64url; made if absent
42
+ * @returns {Promise<{disclosure: string, digest: string}>}
43
+ */
44
+ export async function makeDisclosure(name, value, salt = randomId()) {
45
+ const disclosure = toBase64url(utf8(canonicalJson([salt, name, value])));
46
+ return { disclosure, digest: await disclosureDigest(disclosure) };
47
+ }
48
+
49
+ /**
50
+ * Take the named members out of an object and leave the fingerprints of
51
+ * their disclosures in its "_sd" list, sorted, so that their order says
52
+ * nothing (RFC 9901, section 4.2.4.1).
53
+ * @param {object} object the object; it is not changed
54
+ * @param {string[]} names the members to cover; one that is absent is skipped
55
+ * @returns {Promise<{object: object, disclosures: string[]}>}
56
+ */
57
+ export async function coverMembers(object, names) {
58
+ const out = { ...object };
59
+ const digests = Array.isArray(out._sd) ? [...out._sd] : [];
60
+ const disclosures = [];
61
+ for (const name of names) {
62
+ if (!Object.hasOwn(out, name)) continue;
63
+ const made = await makeDisclosure(name, out[name]);
64
+ delete out[name];
65
+ digests.push(made.digest);
66
+ disclosures.push(made.disclosure);
67
+ }
68
+ if (digests.length) out._sd = digests.sort();
69
+ return { object: out, disclosures };
70
+ }
71
+
72
+ /**
73
+ * Put back what the disclosures reveal, by the steps of RFC 9901, section
74
+ * 7.1, step 3 to step 5.
75
+ *
76
+ * @param {any} payload the signed content, read from JSON
77
+ * @param {string[]} [disclosures] the disclosures handed over with it
78
+ * @param {object} [rules] what a kind of record narrows. A Provared slip sets both.
79
+ * @param {boolean} [rules.listItems] false to refuse a covered item of a list, which the standard allows
80
+ * @param {boolean} [rules.canonical] true to refuse a disclosure that is not JSON in the canonical form,
81
+ * so that two checkers cannot read one disclosure in two ways
82
+ * @returns {Promise<{content: any, places: Map<string, {covered: number, revealed: string[]}>, named: boolean}>}
83
+ * "content" is the content with every revealed field back in place and
84
+ * every "_sd" list, covered list item and "_sd_alg" removed. "places"
85
+ * says, for each object that held an "_sd" list, by its path ("" for the
86
+ * top, "issuer", "with[0]"), how many fields are still covered there and
87
+ * which were revealed. "named" says whether the content named its
88
+ * fingerprint method ("_sd_alg").
89
+ * @throws {Refusal} "cover-invalid"
90
+ */
91
+ export async function uncover(payload, disclosures = [], rules = {}) {
92
+ const listItems = rules.listItems !== false;
93
+ const canonical = rules.canonical === true;
94
+ if (!Array.isArray(disclosures) || disclosures.length > MAX_DISCLOSURES) throw bad('the disclosures must be a list of at most 64.');
95
+ // Each disclosure, by its fingerprint.
96
+ const given = new Map();
97
+ // Read place by place, never through the list's own way of walking through itself.
98
+ for (let i = 0; i < disclosures.length; i++) {
99
+ const d = disclosures[i];
100
+ if (typeof d !== 'string' || d.length === 0 || d.length > 87384) throw bad('a disclosure must be text in base64url.');
101
+ let parts;
102
+ try {
103
+ const text = fromUtf8(fromBase64url(d), 'cover-invalid');
104
+ parts = canonical ? parseCanonical(text, 'cover-invalid') : JSON.parse(text);
105
+ } catch {
106
+ throw bad(canonical ? 'a disclosure is not JSON in the canonical form, written in base64url.' : 'a disclosure is not JSON written in base64url.');
107
+ }
108
+ if (!Array.isArray(parts) || (parts.length !== 2 && parts.length !== 3) || typeof parts[0] !== 'string') {
109
+ throw bad('a disclosure must be a list of a salt and a value, or of a salt, a name and a value.');
110
+ }
111
+ const digest = await disclosureDigest(d);
112
+ if (given.has(digest)) throw bad('a disclosure is handed over twice.');
113
+ given.set(digest, { parts, used: false });
114
+ }
115
+
116
+ const seen = new Set();
117
+ const places = new Map();
118
+ const once = (digest) => {
119
+ // A fingerprint is SHA-256 in base64url: 43 characters, and nothing else.
120
+ let bytes = null;
121
+ try {
122
+ bytes = typeof digest === 'string' && digest.length === 43 ? fromBase64url(digest) : null;
123
+ } catch {
124
+ bytes = null;
125
+ }
126
+ if (bytes === null || bytes.length !== 32) throw bad('a fingerprint in the content is not a SHA-256 fingerprint in base64url.');
127
+ // Step 4: a fingerprint may appear only once in the whole content.
128
+ if (seen.has(digest)) throw bad('the same fingerprint appears twice in the content.');
129
+ seen.add(digest);
130
+ return given.get(digest);
131
+ };
132
+
133
+ // A member is always put in place as a member of its own, whatever its
134
+ // name. Plain assignment would let a member named "__proto__" replace the
135
+ // object's prototype: the member would then be hidden from every check of
136
+ // which members an object holds, and what it held would be inherited.
137
+ const put = (object, name, member) => Object.defineProperty(object, name, { value: member, enumerable: true, writable: true, configurable: true });
138
+
139
+ let named = false;
140
+ const walk = (value, path, depth) => {
141
+ if (depth > MAX_DEPTH) throw bad('the content is nested too deeply.');
142
+ if (Array.isArray(value)) {
143
+ const out = [];
144
+ value.forEach((item, i) => {
145
+ const keys = isObject(item) ? Object.keys(item) : [];
146
+ if (keys.length === 1 && keys[0] === '...') {
147
+ if (!listItems) throw bad('an item of a list may not be covered in this kind of record.');
148
+ const found = once(item['...']);
149
+ // A covered list item with no disclosure is left out (step 3d).
150
+ if (!found) return;
151
+ if (found.parts.length !== 2) throw bad('the disclosure of a list item must hold a salt and a value.');
152
+ found.used = true;
153
+ out.push(walk(found.parts[1], `${path}[${i}]`, depth + 1));
154
+ } else {
155
+ out.push(walk(item, `${path}[${i}]`, depth + 1));
156
+ }
157
+ });
158
+ return out;
159
+ }
160
+ if (!isObject(value)) return value;
161
+ const out = {};
162
+ for (const [name, member] of Object.entries(value)) {
163
+ if (name === '_sd') continue;
164
+ if (name === '_sd_alg') {
165
+ // The fingerprint method is named at the top only, and is SHA-256.
166
+ if (path !== '' || member !== 'sha-256') throw bad('"_sd_alg" may stand only at the top, and must be "sha-256".');
167
+ named = true;
168
+ continue;
169
+ }
170
+ put(out, name, walk(member, path === '' ? name : `${path}.${name}`, depth + 1));
171
+ }
172
+ if (Object.hasOwn(value, '_sd')) {
173
+ if (!Array.isArray(value._sd)) throw bad('"_sd" must be a list of fingerprints.');
174
+ const place = { covered: 0, revealed: [] };
175
+ for (const digest of value._sd) {
176
+ const found = once(digest);
177
+ if (!found) {
178
+ place.covered++;
179
+ continue;
180
+ }
181
+ if (found.parts.length !== 3) throw bad('the disclosure of a named field must hold a salt, a name and a value.');
182
+ const name = found.parts[1];
183
+ if (typeof name !== 'string' || name === '_sd' || name === '...' || name === '_sd_alg') throw bad('a disclosure names a field that may not be covered.');
184
+ if (Object.hasOwn(out, name)) throw bad('a disclosure names a field that is already there.');
185
+ found.used = true;
186
+ put(out, name, walk(found.parts[2], path === '' ? name : `${path}.${name}`, depth + 1));
187
+ place.revealed.push(name);
188
+ }
189
+ places.set(path, place);
190
+ }
191
+ return out;
192
+ };
193
+
194
+ const content = walk(payload, '', 0);
195
+ // Step 5: every disclosure handed over must belong to this content.
196
+ for (const d of given.values()) if (!d.used) throw bad('a disclosure does not belong to this record.');
197
+ return { content, places, named };
198
+ }