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/tools.js ADDED
@@ -0,0 +1,192 @@
1
+ // A connector between an agent's tools and the stub writer.
2
+ //
3
+ // An agent acts by calling tools: functions that its software offers it.
4
+ // recordTools puts each tool behind the stub writer (recorder.js), so that
5
+ // every call asks first, is run only if the slip allows it, and leaves a
6
+ // stub. The agent's software then calls the tools as before.
7
+ //
8
+ // By default the stub of a call names one document, "arguments": the
9
+ // fingerprint of the call's arguments. So the record fixes exactly what was
10
+ // asked for, without holding it: whoever kept the arguments can show them
11
+ // later, and anyone can work out the fingerprint again.
12
+ //
13
+ // It names no agent software and depends on none: a tool is any function.
14
+
15
+ import { Refusal, fingerprint, utf8 } from './encoding.js';
16
+ import * as f from './fields.js';
17
+
18
+ /** How deep the arguments of a call may nest. */
19
+ const MAX_ARGUMENT_DEPTH = 64;
20
+
21
+ /**
22
+ * A call through a recorded tool that was not run: the check before acting
23
+ * did not allow it, or the record so far has a problem. "answer" is what
24
+ * the check before acting said (allowed, problems, breaches, needs).
25
+ */
26
+ export class NotTaken extends Error {
27
+ /**
28
+ * @param {string} tool the name of the tool
29
+ * @param {{allowed: boolean, problems: object[], breaches: object[], needs: string[]}} answer
30
+ */
31
+ constructor(tool, answer) {
32
+ const first = [...answer.problems, ...answer.breaches][0];
33
+ super(`The tool "${tool}" was not run. ${first ? first.message : 'The check before acting did not allow it.'}`);
34
+ this.name = 'NotTaken';
35
+ this.tool = tool;
36
+ this.answer = answer;
37
+ }
38
+ }
39
+
40
+ // The arguments written in one form, as RFC 8785 sets out: members sorted
41
+ // by name, no spaces, text and numbers as JSON writes them. So the same
42
+ // arguments always give the same fingerprint, whatever order their members
43
+ // were written in.
44
+ function oneForm(value, depth = 0) {
45
+ if (depth > MAX_ARGUMENT_DEPTH) throw new Refusal('bad-field', 'The arguments nest too deeply.');
46
+ if (value === null || typeof value === 'boolean') return String(value);
47
+ if (typeof value === 'string') {
48
+ if (typeof value.isWellFormed === 'function' && !value.isWellFormed()) throw new Refusal('bad-field', 'The arguments hold text that is not well-formed Unicode.');
49
+ return JSON.stringify(value);
50
+ }
51
+ if (typeof value === 'number') {
52
+ if (!Number.isFinite(value)) throw new Refusal('bad-field', 'The arguments hold a number that JSON cannot write.');
53
+ return JSON.stringify(value);
54
+ }
55
+ if (Array.isArray(value)) {
56
+ // A list is its items, first to last, and nothing else: a gap, or a member with a name, is not JSON.
57
+ if (Object.keys(value).length !== value.length) throw new Refusal('bad-field', 'The arguments hold a list with a gap in it, or with a named member. That is not JSON.');
58
+ const items = [];
59
+ for (let i = 0; i < value.length; i++) items.push(oneForm(value[i], depth + 1));
60
+ return '[' + items.join(',') + ']';
61
+ }
62
+ const proto = typeof value === 'object' ? Object.getPrototypeOf(value) : undefined;
63
+ if (proto !== Object.prototype && proto !== null) {
64
+ throw new Refusal('bad-field', 'The arguments must be JSON: objects, lists, text, numbers, true, false and null.');
65
+ }
66
+ return '{' + Object.keys(value).sort().map((name) => oneForm(name, depth + 1) + ':' + oneForm(value[name], depth + 1)).join(',') + '}';
67
+ }
68
+
69
+ /**
70
+ * The fingerprint of the arguments of a call: SHA-256, in base64url, of the
71
+ * arguments written in the one form of RFC 8785. It is what a recorded
72
+ * tool puts into its stub, as the document "arguments".
73
+ * @param {unknown} args JSON: objects, lists, text, numbers, true, false and null
74
+ * @returns {Promise<string>}
75
+ * @throws {Refusal} "bad-field" if the arguments are not JSON
76
+ */
77
+ export async function argumentsFingerprint(args) {
78
+ return fingerprint(utf8(oneForm(args)));
79
+ }
80
+
81
+ // A copy that nothing else holds.
82
+ function copied(value, what) {
83
+ try {
84
+ return structuredClone(value);
85
+ } catch {
86
+ throw new Refusal('bad-field', `${what} must be plain data.`);
87
+ }
88
+ }
89
+
90
+ // A member of a tool's description that is either a value or a function of
91
+ // the arguments. A function is handed a copy of the arguments of its own:
92
+ // nothing it does to them changes what is recorded, or what the tool is run
93
+ // with. It is called by itself, not as a member of the description: it is
94
+ // not handed the description, and cannot change it.
95
+ const from = (spec, member, args) => {
96
+ const value = spec[member];
97
+ return typeof value === 'function' ? value(structuredClone(args)) : value;
98
+ };
99
+
100
+ // The slip asks for the person's approval, none was handed over, and nothing else stands in the way.
101
+ const needsOnlyApproval = (answer) => answer.problems.length === 0 && answer.breaches.length > 0 && answer.breaches.every((b) => b.code === 'approval-missing');
102
+
103
+ /**
104
+ * Put an agent's tools behind the stub writer. Each tool is then called as
105
+ * before, with one argument; the call asks first, runs the tool only if the
106
+ * slip allows the action, and writes the stub.
107
+ *
108
+ * @param {object} recorder the stub writer (openRecorder)
109
+ * @param {Record<string, object>} tools by name. For each tool:
110
+ * - "action": the action name the slip uses for what this tool does;
111
+ * - "run": the tool itself, a function of the arguments. It must not call the stub writer;
112
+ * Each function is called by itself, without "this". "amount" and "with" must give their value at once, not a promise.
113
+ * - "with" (optional): the id of the service the tool deals with, as the slip names it, or a function of the arguments that gives it;
114
+ * - "amount" (optional): {unit, value}, or a function of the arguments that gives it;
115
+ * - "details" (optional): a function of the arguments that gives the documents to name in the stub
116
+ * ({name, sha256} each), or false for none. By default: one document, "arguments", the fingerprint
117
+ * of the arguments (argumentsFingerprint);
118
+ * - "countersign" (optional): asks the other side to countersign the stub; a function of the stub and the arguments;
119
+ * - "approve" (optional): asks the person for their approval where the slip asks for one; a function of the
120
+ * request ({action, amount?, with?, details?}) and the arguments, that gives {record} or nothing.
121
+ * @param {object} [options]
122
+ * @param {(stub: object, tool: string) => any} [options.onStub] called after each stub is written, for example to keep the book
123
+ * @returns {Record<string, (args?: any) => Promise<any>>} the tools. A call gives what the tool gives.
124
+ * A call that is not allowed throws NotTaken, and the tool is not run. If the tool itself fails,
125
+ * its error is passed on and no stub is written (see "act" of the stub writer). If "onStub" fails,
126
+ * its error is passed on too: the tool has run by then, and its stub is in the book.
127
+ * @throws {Refusal} "bad-field" if a tool is not described as set out above
128
+ */
129
+ export function recordTools(recorder, tools, options = {}) {
130
+ if (recorder === null || typeof recorder !== 'object' || typeof recorder.act !== 'function') {
131
+ throw new Refusal('bad-field', 'recorder: must be a stub writer.');
132
+ }
133
+ if (tools === null || typeof tools !== 'object' || Array.isArray(tools)) throw new Refusal('bad-field', 'tools: must be an object that holds the tools by name.');
134
+ if (options === null || typeof options !== 'object') options = {};
135
+ // What the tools are put behind is fixed here too.
136
+ const act = recorder.act.bind(recorder);
137
+ const onStub = typeof options.onStub === 'function' ? options.onStub : null;
138
+ const recorded = {};
139
+ for (const name of Object.keys(tools)) {
140
+ const spec = tools[name];
141
+ if (spec === null || typeof spec !== 'object') throw f.fail(`tools.${name}`, 'must be an object.');
142
+ // What a tool is, is fixed here, through and through: a later change to the caller's own objects changes nothing.
143
+ // Each member is read once: what is checked is what is fixed.
144
+ const fixed = { action: spec.action, run: spec.run, details: spec.details, countersign: spec.countersign, approve: spec.approve, amount: spec.amount, with: spec.with };
145
+ if (typeof fixed.run !== 'function') throw f.fail(`tools.${name}.run`, 'must be a function.');
146
+ f.actionName(fixed.action, `tools.${name}.action`);
147
+ for (const member of ['countersign', 'approve']) {
148
+ if (fixed[member] !== undefined && typeof fixed[member] !== 'function') throw f.fail(`tools.${name}.${member}`, 'must be a function.');
149
+ }
150
+ if (fixed.details !== undefined && fixed.details !== false && typeof fixed.details !== 'function') throw f.fail(`tools.${name}.details`, 'must be a function, or false.');
151
+ for (const member of ['amount', 'with']) if (typeof fixed[member] !== 'function') fixed[member] = copied(fixed[member], `tools.${name}.${member}`);
152
+ for (const member of ['amount', 'with']) if (fixed[member] !== null && typeof fixed[member] === 'object') Object.freeze(fixed[member]);
153
+ Object.freeze(fixed);
154
+ Object.defineProperty(recorded, name, { enumerable: true, value: (args) => call(act, onStub, name, fixed, args) });
155
+ }
156
+ return recorded;
157
+ }
158
+
159
+ async function call(act, onStub, name, spec, args) {
160
+ // The arguments are copied when the call is made. The fingerprint is made
161
+ // from that copy, and the tool is run with a copy of it. Each of the
162
+ // tool's other functions is handed a copy of its own.
163
+ const given = copied(args, `The arguments of the tool "${name}"`);
164
+ const request = { action: spec.action };
165
+ // What the tool's own functions give is copied too: a function that kept
166
+ // what it gave cannot change it afterwards.
167
+ const amount = copied(from(spec, 'amount', given), `tools.${name}.amount`);
168
+ if (amount !== undefined && amount !== null) request.amount = amount;
169
+ const withWhom = copied(from(spec, 'with', given), `tools.${name}.with`);
170
+ if (withWhom !== undefined && withWhom !== null) request.with = withWhom;
171
+ let details;
172
+ if (spec.details === false) details = [];
173
+ else if (typeof spec.details === 'function') details = (await (0, spec.details)(structuredClone(given))) ?? [];
174
+ else details = given === undefined ? [] : [{ name: 'arguments', sha256: await argumentsFingerprint(given) }];
175
+ if (!Array.isArray(details)) throw f.fail(`tools.${name}.details`, 'must give a list of documents.');
176
+ details = copied(details, `tools.${name}.details`);
177
+ if (details.length > 0) request.details = details;
178
+
179
+ const more = {};
180
+ if (typeof spec.countersign === 'function') more.countersign = (stub) => (0, spec.countersign)(stub, structuredClone(given));
181
+ const perform = () => (0, spec.run)(structuredClone(given));
182
+ let outcome = await act(request, perform, more);
183
+ // Where the slip asks for the person's own approval of this action, and
184
+ // that is the one thing missing, the person is asked, once.
185
+ if (!outcome.done && typeof spec.approve === 'function' && needsOnlyApproval(outcome.answer)) {
186
+ const approval = await (0, spec.approve)(structuredClone(request), structuredClone(given));
187
+ if (approval) outcome = await act(request, perform, { ...more, approval });
188
+ }
189
+ if (!outcome.done) throw new NotTaken(name, outcome.answer);
190
+ if (onStub) await onStub(outcome.stub, name);
191
+ return outcome.result;
192
+ }
package/src/tree.js ADDED
@@ -0,0 +1,143 @@
1
+ // The tree of fingerprints: a Merkle tree built exactly as RFC 6962
2
+ // (Certificate Transparency), section 2.1, sets out, with SHA-256.
3
+ //
4
+ // leaf = SHA-256(0x00 || data)
5
+ // node = SHA-256(0x01 || left || right)
6
+ // a tree of n leaves splits at the largest power of two smaller than n
7
+ //
8
+ // The proof that one leaf is in the tree is the audit path of section
9
+ // 2.1.1. It is checked with the steps of RFC 9162, section 2.1.3.2.
10
+
11
+ import { concatBytes, equalBytes, sha256 } from './encoding.js';
12
+
13
+ const LEAF = new Uint8Array([0x00]);
14
+ const NODE = new Uint8Array([0x01]);
15
+
16
+ /** @param {Uint8Array} data @returns {Promise<Uint8Array>} */
17
+ export function leafHash(data) {
18
+ return sha256(concatBytes(LEAF, data));
19
+ }
20
+
21
+ function nodeHash(left, right) {
22
+ return sha256(concatBytes(NODE, left, right));
23
+ }
24
+
25
+ // Every level of the tree, from the leaf hashes up to the root. Pairing
26
+ // neighbours and carrying a last odd node up unchanged gives the same tree
27
+ // as the recursive definition in RFC 6962: the test vectors prove it.
28
+ async function levels(leafHashes) {
29
+ const all = [leafHashes];
30
+ let level = leafHashes;
31
+ while (level.length > 1) {
32
+ const next = [];
33
+ for (let i = 0; i + 1 < level.length; i += 2) next.push(await nodeHash(level[i], level[i + 1]));
34
+ if (level.length % 2 === 1) next.push(level[level.length - 1]);
35
+ all.push(next);
36
+ level = next;
37
+ }
38
+ return all;
39
+ }
40
+
41
+ /**
42
+ * The root of the tree over the given leaves: the top fingerprint.
43
+ * @param {Uint8Array[]} leaves the data of each leaf
44
+ * @returns {Promise<Uint8Array>} 32 bytes; for no leaves, the hash of nothing, as RFC 6962 defines
45
+ */
46
+ export async function treeRoot(leaves) {
47
+ if (leaves.length === 0) return sha256(new Uint8Array(0));
48
+ const hashes = [];
49
+ for (const leaf of leaves) hashes.push(await leafHash(leaf));
50
+ const all = await levels(hashes);
51
+ return all[all.length - 1][0];
52
+ }
53
+
54
+ /**
55
+ * Work out the root as the leaves arrive, one at a time, so that the root of
56
+ * the first n leaves is at hand after each. It keeps one fingerprint for
57
+ * each complete part of the tree and folds them together from the right.
58
+ * This is the same tree as treeRoot gives: the tests compare the two at
59
+ * every size.
60
+ */
61
+ export function treeBuilder(from = []) {
62
+ /** @type {{hash: Uint8Array, size: number}[]} */
63
+ const parts = from.slice();
64
+ return {
65
+ /** A builder that carries on from here by itself: what is added to one is not added to the other. */
66
+ fork() {
67
+ return treeBuilder(parts);
68
+ },
69
+ /** @param {Uint8Array} leaf the data of the next leaf */
70
+ async add(leaf) {
71
+ let node = { hash: await leafHash(leaf), size: 1 };
72
+ while (parts.length && parts[parts.length - 1].size === node.size) {
73
+ const left = parts.pop();
74
+ node = { hash: await nodeHash(left.hash, node.hash), size: left.size * 2 };
75
+ }
76
+ parts.push(node);
77
+ },
78
+ /** @returns {Promise<Uint8Array>} the root of the leaves added so far */
79
+ async root() {
80
+ if (parts.length === 0) return sha256(new Uint8Array(0));
81
+ let hash = parts[parts.length - 1].hash;
82
+ for (let i = parts.length - 2; i >= 0; i--) hash = await nodeHash(parts[i].hash, hash);
83
+ return hash;
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The audit path for one leaf: the fingerprints needed to work out the root
90
+ * from that leaf alone.
91
+ * @param {Uint8Array[]} leaves
92
+ * @param {number} index which leaf, counting from 0
93
+ * @returns {Promise<Uint8Array[]>}
94
+ */
95
+ export async function inclusionPath(leaves, index) {
96
+ if (!Number.isInteger(index) || index < 0 || index >= leaves.length) throw new RangeError('no such leaf');
97
+ const hashes = [];
98
+ for (const leaf of leaves) hashes.push(await leafHash(leaf));
99
+ const all = await levels(hashes);
100
+ const path = [];
101
+ let at = index;
102
+ for (let depth = 0; depth < all.length - 1; depth++) {
103
+ const level = all[depth];
104
+ if (at % 2 === 1) path.push(level[at - 1]);
105
+ else if (at + 1 < level.length) path.push(level[at + 1]);
106
+ // else: a last odd node, carried up with no neighbour at this level.
107
+ at = Math.floor(at / 2);
108
+ }
109
+ return path;
110
+ }
111
+
112
+ /**
113
+ * Check an audit path (RFC 9162, section 2.1.3.2).
114
+ * @param {number} index which leaf, counting from 0
115
+ * @param {number} size how many leaves the tree has
116
+ * @param {Uint8Array} leaf the data of the leaf
117
+ * @param {Uint8Array[]} path
118
+ * @param {Uint8Array} root
119
+ * @returns {Promise<boolean>}
120
+ */
121
+ export async function verifyInclusion(index, size, leaf, path, root) {
122
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(size) || index < 0 || index >= size) return false;
123
+ let fn = index;
124
+ let sn = size - 1;
125
+ let r = await leafHash(leaf);
126
+ for (const p of path) {
127
+ if (sn === 0) return false;
128
+ if (fn % 2 === 1 || fn === sn) {
129
+ r = await nodeHash(p, r);
130
+ if (fn % 2 === 0) {
131
+ while (fn % 2 === 0 && fn !== 0) {
132
+ fn = Math.floor(fn / 2);
133
+ sn = Math.floor(sn / 2);
134
+ }
135
+ }
136
+ } else {
137
+ r = await nodeHash(r, p);
138
+ }
139
+ fn = Math.floor(fn / 2);
140
+ sn = Math.floor(sn / 2);
141
+ }
142
+ return sn === 0 && equalBytes(r, root);
143
+ }