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/seal.js ADDED
@@ -0,0 +1,133 @@
1
+ // The Seal: whoever keeps a book signs its top fingerprint and its number of
2
+ // entries, and an outside time-stamp service states when that was.
3
+ // Format description, section 21.
4
+
5
+ import { Refusal, fingerprint, formatTime, fromBase64url, randomId, toBase64url, utf8 } from './encoding.js';
6
+ import * as f from './fields.js';
7
+ import { KINDS, encodeContent, protectedHeaders, signingInput, withinSize } from './jws.js';
8
+ import { SEAL_METHODS, checkSealKeySet } from './keys.js';
9
+ import { sign } from './signatures.js';
10
+ import { MAX_STAMP_BYTES } from './timestamp.js';
11
+ import { treeRoot } from './tree.js';
12
+
13
+ /** The most time-stamps one seal may carry. */
14
+ export const MAX_STAMPS = 4;
15
+
16
+ /**
17
+ * How far a clock may differ, in milliseconds. A seal may be dated up to this
18
+ * much after its time-stamp, and an entry up to this much after the
19
+ * time-stamp that covers it, before the checker reports it.
20
+ */
21
+ export const CLOCK_ALLOWANCE_MS = 300 * 1000;
22
+
23
+ /**
24
+ * Confirm every member of a seal's content.
25
+ * @param {any} c
26
+ */
27
+ export function validateSealContent(c) {
28
+ f.members(c, ['by', 'id', 'root', 'size', 'type', 'when'], ['previous'], 'seal');
29
+ f.id(c.id, 'id');
30
+ f.members(c.by, ['keys', 'name'], [], 'by');
31
+ f.label(c.by.name, 'by.name');
32
+ checkSealKeySet(c.by.keys, 'by.keys');
33
+ f.wholeNumber(c.size, 'size');
34
+ if (c.size < 1) throw f.fail('size', 'must be 1 or more.');
35
+ f.fingerprintText(c.root, 'root');
36
+ if (Object.hasOwn(c, 'previous')) f.fingerprintText(c.previous, 'previous');
37
+ f.time(c.when, 'when');
38
+ }
39
+
40
+ /**
41
+ * Confirm the time-stamps that sit beside a seal or a cancellation in an
42
+ * entry, and decode them. A time-stamp from a service is text: the
43
+ * service's answer in base64url. A block time-stamp is an object: the
44
+ * proof and the header of its block, each in base64url.
45
+ * @param {any} stamps
46
+ * @returns {({kind: 'service', token: Uint8Array}|{kind: 'block', proof: Uint8Array, header: Uint8Array})[]}
47
+ */
48
+ export function decodeStamps(stamps) {
49
+ f.list(stamps, 1, MAX_STAMPS, 'stamps');
50
+ const seen = new Set();
51
+ const items = [];
52
+ // Every place in the list is read by its number, a gap included, so that a gap is refused as an empty
53
+ // place, not skipped; a list's own way of walking through itself is not used.
54
+ for (let i = 0; i < stamps.length; i++) {
55
+ const s = stamps[i];
56
+ const path = `stamps[${i}]`;
57
+ const decode = (text, most, what) => {
58
+ if (typeof text !== 'string' || text.length === 0 || text.length > Math.ceil((most * 4) / 3)) throw f.fail(path, what);
59
+ try {
60
+ return fromBase64url(text);
61
+ } catch {
62
+ throw new Refusal('bad-base64url', 'A time-stamp is not base64url.');
63
+ }
64
+ };
65
+ let item;
66
+ let key;
67
+ if (s !== null && typeof s === 'object' && !Array.isArray(s)) {
68
+ f.members(s, ['block', 'proof'], [], path);
69
+ item = {
70
+ kind: 'block',
71
+ header: decode(s.block, 1024, 'must hold a block header of 80 bytes, in base64url.'),
72
+ proof: decode(s.proof, MAX_STAMP_BYTES, 'must hold a proof in base64url, of at most 12,288 bytes.'),
73
+ };
74
+ key = `${s.block} ${s.proof}`;
75
+ } else {
76
+ item = { kind: 'service', token: decode(s, MAX_STAMP_BYTES, 'must be a time-stamp in base64url, of at most 12,288 bytes.') };
77
+ key = s;
78
+ }
79
+ if (seen.has(key)) throw f.fail('stamps', 'the same time-stamp is given twice.');
80
+ seen.add(key);
81
+ items.push(item);
82
+ }
83
+ return items;
84
+ }
85
+
86
+ /**
87
+ * The fingerprint of a recorder's key set: how a person checking says which
88
+ * recorder they expect. It is the fingerprint of the list of keys, written
89
+ * in the canonical form.
90
+ * @param {object[]} keys a key set that has passed its check
91
+ * @returns {Promise<string>}
92
+ */
93
+ export async function keySetFingerprint(keys) {
94
+ return fingerprint(encodeContent(keys).contentBytes);
95
+ }
96
+
97
+ /**
98
+ * Seal a book as it stands: sign its top fingerprint and its number of
99
+ * entries. The seal is then added to the book as its next entry, with any
100
+ * time-stamps beside it.
101
+ *
102
+ * @param {string} book the book so far
103
+ * @param {object} fields
104
+ * @param {{keys: object[], name: string}} fields.by the recorder's public key set and its name (a label)
105
+ * @param {string} [fields.previous] the fingerprint of the seal before this one in the book, if there is one
106
+ * @param {number|Date} [fields.when] defaults to now
107
+ * @param {any[]} privateKeys the recorder's private keys, in the order of the key set
108
+ * @returns {Promise<{record: object, fingerprint: string, fingerprintBytes: Uint8Array}>}
109
+ * "fingerprintBytes" is what a time-stamp service is asked to stamp
110
+ */
111
+ export async function writeSeal(book, fields, privateKeys) {
112
+ const lines = book.split('\n');
113
+ if (lines[lines.length - 1] === '') lines.pop();
114
+ const content = {
115
+ type: KINDS.seal.type,
116
+ id: fields.id ?? randomId(),
117
+ by: fields.by,
118
+ size: lines.length,
119
+ root: toBase64url(await treeRoot(lines.map(utf8))),
120
+ when: formatTime(fields.when ?? Date.now()),
121
+ };
122
+ if (fields.previous) content.previous = fields.previous;
123
+ validateSealContent(content);
124
+ const { contentBytes, payloadB64 } = encodeContent(content);
125
+ const headers = protectedHeaders('seal');
126
+ const signatures = [];
127
+ for (let i = 0; i < SEAL_METHODS.length; i++) {
128
+ const signature = await sign(SEAL_METHODS[i], privateKeys[i], signingInput(headers[i], payloadB64));
129
+ signatures.push({ protected: headers[i], signature: toBase64url(signature) });
130
+ }
131
+ const name = await fingerprint(contentBytes);
132
+ return { record: withinSize({ payload: payloadB64, signatures }), fingerprint: name, fingerprintBytes: fromBase64url(name) };
133
+ }
package/src/service.js ADDED
@@ -0,0 +1,124 @@
1
+ // The other side's voice: two records a service writes and signs itself.
2
+ // A refusal says "this agent asked for something, and we refused". Terms
3
+ // say which actions the service accepts from agents.
4
+ // Format description, sections 18 and 19.
5
+
6
+ import { actionKind, isNeverName } from './actions.js';
7
+ import { formatTime, randomId } from './encoding.js';
8
+ import * as f from './fields.js';
9
+ import { KINDS } from './jws.js';
10
+ import { checkKeySet } from './keys.js';
11
+ import { signWithKeySet, validateRequest } from './stub.js';
12
+
13
+ /** Why a service refused. A refusal gives exactly one of these. */
14
+ export const REFUSAL_REASONS = {
15
+ 'not-in-slip': 'The slip does not cover the action, or does not name this service.',
16
+ 'over-limit': 'The action would pass a limit of the slip.',
17
+ 'outside-valid-time': 'The slip was not in force at that time.',
18
+ 'slip-not-sound': 'The slip did not pass its check.',
19
+ 'needs-approval': 'The slip asks for the person\'s approval, and none was shown.',
20
+ 'against-terms': 'The service\'s terms for agents do not accept the action.',
21
+ other: 'Another reason.',
22
+ };
23
+
24
+ function validateBy(by) {
25
+ f.members(by, ['keys', 'name'], [], 'by');
26
+ f.label(by.name, 'by.name');
27
+ checkKeySet(by.keys, 'by.keys');
28
+ }
29
+
30
+ /**
31
+ * Confirm every member of a refusal's content.
32
+ * @param {any} c
33
+ */
34
+ export function validateRefusalContent(c) {
35
+ f.members(c, ['action', 'by', 'id', 'reason', 'slip', 'type', 'when'], ['amount'], 'refusal');
36
+ f.id(c.id, 'id');
37
+ f.fingerprintText(c.slip, 'slip');
38
+ validateBy(c.by);
39
+ // What was asked for is written down as it was asked, even a reserved
40
+ // name that is not on the shared list: that may be why it was refused.
41
+ f.shortName(c.action, 'action');
42
+ if (Object.hasOwn(c, 'amount')) validateRequest({ action: 'x', amount: c.amount });
43
+ if (typeof c.reason !== 'string' || !Object.hasOwn(REFUSAL_REASONS, c.reason)) throw f.fail('reason', 'must be one of the listed reasons.');
44
+ f.time(c.when, 'when');
45
+ }
46
+
47
+ /**
48
+ * Confirm every member of a service's terms for agents.
49
+ * @param {any} c
50
+ */
51
+ export function validateTermsContent(c) {
52
+ f.members(c, ['accepts', 'by', 'id', 'never', 'type', 'validFrom', 'validUntil'], [], 'terms');
53
+ f.id(c.id, 'id');
54
+ validateBy(c.by);
55
+ f.list(c.accepts, 1, 64, 'accepts');
56
+ c.accepts.forEach((a, i) => f.actionName(a, `accepts[${i}]`));
57
+ if (new Set(c.accepts).size !== c.accepts.length) throw f.fail('accepts', 'an action name is repeated.');
58
+ f.list(c.never, 0, 16, 'never');
59
+ c.never.forEach((n, i) => {
60
+ if (!isNeverName(n)) throw f.fail(`never[${i}]`, 'must be a listed kind of action or a listed rule of conduct.');
61
+ });
62
+ if (new Set(c.never).size !== c.never.length) throw f.fail('never', 'a name is repeated.');
63
+ for (const a of c.accepts) {
64
+ if (c.never.includes(actionKind(a))) throw f.fail('never', 'forbids a kind of action that the terms also accept.');
65
+ }
66
+ const from = f.time(c.validFrom, 'validFrom');
67
+ const until = f.time(c.validUntil, 'validUntil');
68
+ if (!(from < until)) throw f.fail('validUntil', 'must be later than validFrom.');
69
+ }
70
+
71
+ /**
72
+ * Write and sign a refusal: the service's statement that an agent asked for
73
+ * something under a slip, and was refused.
74
+ *
75
+ * @param {object} fields
76
+ * @param {string} fields.slip the fingerprint of the slip the agent showed
77
+ * @param {{keys: object[], name: string}} fields.by the service's public key set and its name (a label)
78
+ * @param {string} fields.action what the agent asked for
79
+ * @param {{unit: string, value: number}} [fields.amount]
80
+ * @param {string} fields.reason one of REFUSAL_REASONS
81
+ * @param {number|Date} [fields.when] defaults to now
82
+ * @param {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
83
+ * @returns {Promise<{record: object, fingerprint: string}>}
84
+ */
85
+ export async function writeRefusal(fields, privateKeys) {
86
+ const content = {
87
+ type: KINDS.refusal.type,
88
+ id: fields.id ?? randomId(),
89
+ slip: fields.slip,
90
+ by: fields.by,
91
+ action: fields.action,
92
+ reason: fields.reason,
93
+ when: formatTime(fields.when ?? Date.now()),
94
+ };
95
+ if (fields.amount) content.amount = fields.amount;
96
+ validateRefusalContent(content);
97
+ return signWithKeySet('refusal', content, privateKeys);
98
+ }
99
+
100
+ /**
101
+ * Write and sign a service's terms for agents.
102
+ *
103
+ * @param {object} fields
104
+ * @param {{keys: object[], name: string}} fields.by the service's public key set and its name (a label)
105
+ * @param {string[]} fields.accepts the actions the service accepts from agents
106
+ * @param {string[]} [fields.never] kinds of action and rules of conduct the service asks agents to keep
107
+ * @param {string} fields.validFrom
108
+ * @param {string} fields.validUntil
109
+ * @param {CryptoKey[]} privateKeys the service's private keys: Ed25519, then ML-DSA-87
110
+ * @returns {Promise<{record: object, fingerprint: string}>}
111
+ */
112
+ export async function writeTerms(fields, privateKeys) {
113
+ const content = {
114
+ type: KINDS.terms.type,
115
+ id: fields.id ?? randomId(),
116
+ by: fields.by,
117
+ accepts: fields.accepts,
118
+ never: fields.never ?? [],
119
+ validFrom: fields.validFrom,
120
+ validUntil: fields.validUntil,
121
+ };
122
+ validateTermsContent(content);
123
+ return signWithKeySet('terms', content, privateKeys);
124
+ }
@@ -0,0 +1,147 @@
1
+ // Checking and making signatures with the cryptography built into the
2
+ // browser or Node.js (Web Crypto). No outside code.
3
+ //
4
+ // Methods:
5
+ // Ed25519 RFC 8032
6
+ // ML-DSA-87 FIPS 204, plain method, empty context (RFC 9964 section 5)
7
+ // ES256 ECDSA with P-256 and SHA-256 (FIPS 186-5), passkeys only
8
+ // RS256 RSASSA-PKCS1-v1_5 with SHA-256 (RFC 8017), passkeys only
9
+ // SLH-DSA-SHA2-256s FIPS 205, plain method, empty context; seals only
10
+ //
11
+ // SLH-DSA is not yet in Web Crypto anywhere. Node.js has it in its own
12
+ // cryptography module, which is asked for here without an import, so that
13
+ // this file still loads in a browser. In a browser the method is reported as
14
+ // not built in, which is never a pass.
15
+
16
+ import { importPublicKey } from './keys.js';
17
+
18
+ const SLH = 'SLH-DSA-SHA2-256s';
19
+ const nodeCrypto = globalThis.process && typeof globalThis.process.getBuiltinModule === 'function' ? globalThis.process.getBuiltinModule('node:crypto') : null;
20
+
21
+ function slhKey(pub) {
22
+ return nodeCrypto.createPublicKey({ key: { kty: 'AKP', alg: SLH, pub }, format: 'jwk' });
23
+ }
24
+
25
+ /** @typedef {'valid'|'invalid'|'unavailable'} SignatureState */
26
+
27
+ const VERIFY_PARAMS = {
28
+ Ed25519: { name: 'Ed25519' },
29
+ 'ML-DSA-87': { name: 'ML-DSA-87' },
30
+ ES256: { name: 'ECDSA', hash: 'SHA-256' },
31
+ RS256: { name: 'RSASSA-PKCS1-v1_5' },
32
+ };
33
+
34
+ // Whether this device has a method built in. Ed25519 and ML-DSA are newer
35
+ // additions to Web Crypto, so they are probed once by making a key. ES256 and
36
+ // RS256 have been in Web Crypto from the start.
37
+ const available = new Map();
38
+
39
+ /**
40
+ * @param {string} alg
41
+ * @returns {Promise<boolean>} whether this device can check signatures made with this method
42
+ */
43
+ export function methodAvailable(alg) {
44
+ if (alg === 'ES256' || alg === 'RS256') return Promise.resolve(true);
45
+ if (alg === SLH && !available.has(alg)) {
46
+ let has = false;
47
+ try {
48
+ // An all-zero key is a key of the right shape: loading it shows whether the method is there.
49
+ slhKey('A'.repeat(86));
50
+ has = true;
51
+ } catch {
52
+ has = false;
53
+ }
54
+ available.set(alg, Promise.resolve(has));
55
+ }
56
+ if (!available.has(alg)) {
57
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
58
+ const probe = subtle
59
+ ? Promise.resolve()
60
+ .then(() => subtle.generateKey({ name: alg }, false, ['sign', 'verify']))
61
+ .then(() => true, () => false)
62
+ : Promise.resolve(false);
63
+ available.set(alg, probe);
64
+ }
65
+ return available.get(alg);
66
+ }
67
+
68
+ /**
69
+ * Check one signature.
70
+ *
71
+ * "unavailable" means this device has no built-in support for the method. It
72
+ * is never treated as a pass. On a device that has the method, any failure,
73
+ * including a key the device refuses to load, is "invalid".
74
+ *
75
+ * @param {any} jwk a public key that has passed checkKey
76
+ * @param {Uint8Array} signature
77
+ * @param {Uint8Array} data the bytes that were signed
78
+ * @param {string[]} [without] methods to treat as not built in, to see how a
79
+ * check behaves on a device without them; it can only withhold a pass
80
+ * @returns {Promise<SignatureState>}
81
+ */
82
+ export async function verifySignature(jwk, signature, data, without = []) {
83
+ if (jwk.alg === SLH) {
84
+ if (without.includes(SLH) || !(await methodAvailable(SLH))) return 'unavailable';
85
+ try {
86
+ return nodeCrypto.verify(null, data, slhKey(jwk.pub), signature) ? 'valid' : 'invalid';
87
+ } catch {
88
+ return 'invalid';
89
+ }
90
+ }
91
+ if (!Object.hasOwn(VERIFY_PARAMS, jwk.alg)) return 'invalid';
92
+ const params = VERIFY_PARAMS[jwk.alg];
93
+ if (without.includes(jwk.alg) || !(await methodAvailable(jwk.alg))) return 'unavailable';
94
+ try {
95
+ const key = await importPublicKey(jwk);
96
+ return (await globalThis.crypto.subtle.verify(params, key, signature, data)) ? 'valid' : 'invalid';
97
+ } catch {
98
+ return 'invalid';
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Sign with one private key of a key set.
104
+ * @param {string} alg 'Ed25519', 'ML-DSA-87' or, for a seal, 'SLH-DSA-SHA2-256s'
105
+ * @param {any} privateKey
106
+ * @param {Uint8Array} data
107
+ * @returns {Promise<Uint8Array>}
108
+ */
109
+ export async function sign(alg, privateKey, data) {
110
+ if (alg === SLH) return new Uint8Array(nodeCrypto.sign(null, data, privateKey));
111
+ return new Uint8Array(await globalThis.crypto.subtle.sign({ name: alg }, privateKey, data));
112
+ }
113
+
114
+ /**
115
+ * Make a new key set for a recorder, which signs seals: an Ed25519 key, an
116
+ * ML-DSA-87 key and an SLH-DSA-SHA2-256s key. It needs Node.js: no browser
117
+ * has the third method.
118
+ * @returns {Promise<{keys: object[], privateKeys: any[]}>}
119
+ */
120
+ export async function generateSealKeySet() {
121
+ if (!(await methodAvailable(SLH))) throw new Error('SLH-DSA is not built into this device.');
122
+ const two = await generateKeySet();
123
+ const slh = nodeCrypto.generateKeyPairSync('slh-dsa-sha2-256s');
124
+ const { pub } = slh.publicKey.export({ format: 'jwk' });
125
+ return { keys: [...two.keys, { alg: SLH, kty: 'AKP', pub }], privateKeys: [...two.privateKeys, slh.privateKey] };
126
+ }
127
+
128
+ /**
129
+ * Make a new key set for an agent or a service: an Ed25519 key and an
130
+ * ML-DSA-87 key. The private halves cannot be exported from the returned
131
+ * objects; they stay in the memory of this process.
132
+ * @returns {Promise<{keys: object[], privateKeys: CryptoKey[]}>} the public key set, and the private keys in the same order
133
+ */
134
+ export async function generateKeySet() {
135
+ const subtle = globalThis.crypto.subtle;
136
+ const ed = await subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);
137
+ const ml = await subtle.generateKey({ name: 'ML-DSA-87' }, false, ['sign', 'verify']);
138
+ const edJwk = await subtle.exportKey('jwk', ed.publicKey);
139
+ const mlJwk = await subtle.exportKey('jwk', ml.publicKey);
140
+ return {
141
+ keys: [
142
+ { alg: 'Ed25519', crv: 'Ed25519', kty: 'OKP', x: edJwk.x },
143
+ { alg: 'ML-DSA-87', kty: 'AKP', pub: mlJwk.pub },
144
+ ],
145
+ privateKeys: [ed.privateKey, ml.privateKey],
146
+ };
147
+ }