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/jws.js ADDED
@@ -0,0 +1,235 @@
1
+ // The signed envelope: JSON Web Signature (RFC 7515) in the general JSON
2
+ // serialisation (section 7.2.1), as the format description, sections 3.1 to
3
+ // 3.5, narrows it.
4
+ //
5
+ // Each layout is built in one place, here, and used by both the writers and
6
+ // the checker.
7
+
8
+ import { Refusal, canonicalJson, fingerprint, fromBase64url, fromUtf8, parseCanonical, toBase64url, utf8 } from './encoding.js';
9
+
10
+ /** The largest record, in bytes. */
11
+ export const MAX_RECORD_BYTES = 65536;
12
+
13
+ /**
14
+ * The kinds of record. For each: the "type" of its content, and the exact
15
+ * protected header of each signature, in order. A checker compares protected
16
+ * headers byte for byte with these texts and interprets nothing else.
17
+ */
18
+ export const KINDS = {
19
+ slip: {
20
+ type: 'provared.slip.v0',
21
+ passkey: true,
22
+ protected: ['{"typ":"vnd.provared.slip.v0+json","alg":"prova.red/webauthn/v0"}'],
23
+ },
24
+ stub: {
25
+ type: 'provared.stub.v0',
26
+ protected: ['{"typ":"vnd.provared.stub.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.stub.v0+json","alg":"ML-DSA-87"}'],
27
+ },
28
+ countersignature: {
29
+ type: 'provared.countersignature.v0',
30
+ protected: [
31
+ '{"typ":"vnd.provared.countersignature.v0+json","alg":"Ed25519"}',
32
+ '{"typ":"vnd.provared.countersignature.v0+json","alg":"ML-DSA-87"}',
33
+ ],
34
+ },
35
+ approval: {
36
+ type: 'provared.approval.v0',
37
+ passkey: true,
38
+ protected: ['{"typ":"vnd.provared.approval.v0+json","alg":"prova.red/webauthn/v0"}'],
39
+ },
40
+ refusal: {
41
+ type: 'provared.refusal.v0',
42
+ protected: ['{"typ":"vnd.provared.refusal.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.refusal.v0+json","alg":"ML-DSA-87"}'],
43
+ },
44
+ terms: {
45
+ type: 'provared.terms.v0',
46
+ protected: ['{"typ":"vnd.provared.terms.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.terms.v0+json","alg":"ML-DSA-87"}'],
47
+ },
48
+ cancellation: {
49
+ type: 'provared.cancellation.v0',
50
+ passkey: true,
51
+ protected: ['{"typ":"vnd.provared.cancellation.v0+json","alg":"prova.red/webauthn/v0"}'],
52
+ },
53
+ acknowledgement: {
54
+ type: 'provared.acknowledgement.v0',
55
+ protected: ['{"typ":"vnd.provared.acknowledgement.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.acknowledgement.v0+json","alg":"ML-DSA-87"}'],
56
+ },
57
+ vouching: {
58
+ type: 'provared.vouching.v0',
59
+ protected: ['{"typ":"vnd.provared.vouching.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.vouching.v0+json","alg":"ML-DSA-87"}'],
60
+ },
61
+ withdrawal: {
62
+ type: 'provared.withdrawal.v0',
63
+ protected: ['{"typ":"vnd.provared.withdrawal.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.withdrawal.v0+json","alg":"ML-DSA-87"}'],
64
+ },
65
+ pass: {
66
+ type: 'provared.pass.v0',
67
+ protected: ['{"typ":"vnd.provared.pass.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.pass.v0+json","alg":"ML-DSA-87"}'],
68
+ },
69
+ seal: {
70
+ type: 'provared.seal.v0',
71
+ protected: [
72
+ '{"typ":"vnd.provared.seal.v0+json","alg":"Ed25519"}',
73
+ '{"typ":"vnd.provared.seal.v0+json","alg":"ML-DSA-87"}',
74
+ '{"typ":"vnd.provared.seal.v0+json","alg":"SLH-DSA-SHA2-256s"}',
75
+ ],
76
+ },
77
+ };
78
+
79
+ const ALL_PROTECTED = new Map();
80
+ for (const [kind, k] of Object.entries(KINDS)) {
81
+ for (const text of k.protected) ALL_PROTECTED.set(text, kind);
82
+ }
83
+
84
+ /**
85
+ * The protected headers of a kind, in base64url, in signature order.
86
+ * @param {'slip'|'stub'|'countersignature'} kind
87
+ * @returns {string[]}
88
+ */
89
+ export function protectedHeaders(kind) {
90
+ return KINDS[kind].protected.map((text) => toBase64url(utf8(text)));
91
+ }
92
+
93
+ /**
94
+ * The bytes a signature covers (RFC 7515 section 5.1): the protected header
95
+ * in base64url, a full stop, the content in base64url.
96
+ * @param {string} protectedB64
97
+ * @param {string} payloadB64
98
+ * @returns {Uint8Array}
99
+ */
100
+ export function signingInput(protectedB64, payloadB64) {
101
+ return utf8(protectedB64 + '.' + payloadB64);
102
+ }
103
+
104
+ /**
105
+ * The size of a record, as the format description, section 3.1, measures it:
106
+ * the lengths of its "payload" and of every text value in its signature
107
+ * entries, added up. It does not depend on how the JSON around them is
108
+ * written.
109
+ * @param {any} record
110
+ * @returns {number}
111
+ */
112
+ export function recordSize(record) {
113
+ let size = typeof record.payload === 'string' ? record.payload.length : 0;
114
+ if (!Array.isArray(record.signatures)) return size;
115
+ for (const s of record.signatures) {
116
+ if (s === null || typeof s !== 'object') continue;
117
+ for (const v of Object.values(s)) size += typeof v === 'string' ? v.length : 0;
118
+ if (s.header !== null && typeof s.header === 'object') {
119
+ for (const v of Object.values(s.header)) size += typeof v === 'string' ? v.length : 0;
120
+ }
121
+ }
122
+ return size;
123
+ }
124
+
125
+ /**
126
+ * A writer's check that what it has made is small enough to be accepted.
127
+ * @param {any} record
128
+ * @returns {any} the record
129
+ */
130
+ export function withinSize(record) {
131
+ if (recordSize(record) > MAX_RECORD_BYTES) throw new Refusal('too-large', 'The record would be larger than 65,536 bytes.');
132
+ return record;
133
+ }
134
+
135
+ function exactMembers(object, names, what) {
136
+ if (object === null || typeof object !== 'object' || Array.isArray(object)) {
137
+ throw new Refusal('bad-envelope', `${what} must be an object.`);
138
+ }
139
+ const have = Object.keys(object).sort();
140
+ const want = [...names].sort();
141
+ if (have.length !== want.length || have.some((n, i) => n !== want[i])) {
142
+ throw new Refusal('bad-envelope', `${what} must hold exactly: ${want.join(', ')}.`);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * @typedef {object} ParsedRecord
148
+ * @property {string} kind
149
+ * @property {string} payloadB64 the content exactly as carried
150
+ * @property {Uint8Array} contentBytes the content bytes, exactly as signed
151
+ * @property {any} content the content, read from those bytes
152
+ * @property {string} fingerprint the fingerprint of the record
153
+ * @property {{protectedB64: string, signature: Uint8Array, header?: any}[]} signatures
154
+ */
155
+
156
+ /**
157
+ * Read a record and confirm its envelope, its labels and the canonical form
158
+ * of its content. This checks no signature and no field of the content
159
+ * other than "type".
160
+ *
161
+ * @param {any} record the record as a JavaScript object
162
+ * @param {string} kind the kind of record expected in this place: one of the names of KINDS
163
+ * @returns {Promise<ParsedRecord>}
164
+ */
165
+ export async function parseRecord(record, kind) {
166
+ const expected = KINDS[kind];
167
+ exactMembers(record, ['payload', 'signatures'], 'A record');
168
+ if (typeof record.payload !== 'string') throw new Refusal('bad-envelope', '"payload" must be text.');
169
+ if (!Array.isArray(record.signatures)) throw new Refusal('bad-envelope', '"signatures" must be a list.');
170
+
171
+ // The size is measured before anything is decoded.
172
+ if (recordSize(record) > MAX_RECORD_BYTES) throw new Refusal('too-large', 'The record is larger than 65,536 bytes.');
173
+
174
+ // Labels first: what kind of record does each signature say this is?
175
+ const signatures = [];
176
+ const seen = [];
177
+ for (const s of record.signatures) {
178
+ exactMembers(s, expected.passkey ? ['header', 'protected', 'signature'] : ['protected', 'signature'], 'A signature entry');
179
+ if (typeof s.protected !== 'string' || typeof s.signature !== 'string') {
180
+ throw new Refusal('bad-envelope', '"protected" and "signature" must be text.');
181
+ }
182
+ const text = fromUtf8(fromBase64url(s.protected), 'unknown-type');
183
+ if (!ALL_PROTECTED.has(text)) {
184
+ throw new Refusal('unknown-type', 'A signature carries a label that is not one of the known labels.');
185
+ }
186
+ if (ALL_PROTECTED.get(text) !== kind) {
187
+ throw new Refusal('payload-type-mismatch', `A signature is labelled as a ${ALL_PROTECTED.get(text)}, but a ${kind} is expected here.`);
188
+ }
189
+ seen.push(text);
190
+ const entry = { protectedB64: s.protected, signature: fromBase64url(s.signature) };
191
+ if (expected.passkey) {
192
+ exactMembers(s.header, ['authenticatorData', 'clientDataJSON'], 'The "header" of a passkey signature');
193
+ entry.header = s.header;
194
+ }
195
+ signatures.push(entry);
196
+ }
197
+ if (seen.length !== expected.protected.length || seen.some((text, i) => text !== expected.protected[i])) {
198
+ throw new Refusal(
199
+ 'bad-signatures-layout',
200
+ expected.passkey
201
+ ? `A ${kind} carries exactly one signature.`
202
+ : kind === 'seal'
203
+ ? 'A seal carries exactly three signatures: Ed25519, ML-DSA-87, then SLH-DSA-SHA2-256s.'
204
+ : `A ${kind} carries exactly two signatures: Ed25519, then ML-DSA-87.`,
205
+ );
206
+ }
207
+
208
+ const contentBytes = fromBase64url(record.payload);
209
+ const content = parseCanonical(fromUtf8(contentBytes, 'payload-not-canonical'));
210
+ if (content === null || typeof content !== 'object' || Array.isArray(content)) {
211
+ throw new Refusal('bad-field', 'The content must be an object.');
212
+ }
213
+ if (content.type !== expected.type) {
214
+ // Nothing from the content goes into the message: it is untrusted, and need not even be text.
215
+ throw new Refusal('payload-type-mismatch', `The content does not say it is a ${kind}, which is what is expected here.`);
216
+ }
217
+ return {
218
+ kind,
219
+ payloadB64: record.payload,
220
+ contentBytes,
221
+ content,
222
+ fingerprint: await fingerprint(contentBytes),
223
+ signatures,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Write content in the canonical form and return what a signer needs.
229
+ * @param {any} content
230
+ * @returns {{contentBytes: Uint8Array, payloadB64: string}}
231
+ */
232
+ export function encodeContent(content) {
233
+ const contentBytes = utf8(canonicalJson(content));
234
+ return { contentBytes, payloadB64: toBase64url(contentBytes) };
235
+ }
package/src/keys.js ADDED
@@ -0,0 +1,149 @@
1
+ // Public keys: the exact shapes the format accepts (format description,
2
+ // section 3.8), their thumbprints (RFC 7638) and how each is loaded for
3
+ // checking a signature.
4
+
5
+ import { Refusal, fromBase64url, sha256, toBase64url, utf8 } from './encoding.js';
6
+
7
+ /** The signing methods of an agent's or a service's key set, in order. */
8
+ export const KEY_SET_METHODS = ['Ed25519', 'ML-DSA-87'];
9
+
10
+ /**
11
+ * The signing methods of a recorder's key set, which signs seals, in order.
12
+ * The third rests only on fingerprint functions (FIPS 205).
13
+ */
14
+ export const SEAL_METHODS = ['Ed25519', 'ML-DSA-87', 'SLH-DSA-SHA2-256s'];
15
+
16
+ /** The signing methods a passkey may use. */
17
+ export const PASSKEY_METHODS = ['ES256', 'RS256', 'Ed25519'];
18
+
19
+ // For each method: the exact members of its JSON Web Key, in sorted order.
20
+ const SHAPES = {
21
+ Ed25519: ['alg', 'crv', 'kty', 'x'],
22
+ 'ML-DSA-87': ['alg', 'kty', 'pub'],
23
+ 'SLH-DSA-SHA2-256s': ['alg', 'kty', 'pub'],
24
+ ES256: ['alg', 'crv', 'kty', 'x', 'y'],
25
+ RS256: ['alg', 'e', 'kty', 'n'],
26
+ };
27
+
28
+ function bad(where, why) {
29
+ return new Refusal('bad-key', `${where}: ${why}`);
30
+ }
31
+
32
+ function bytesOf(jwk, member, where) {
33
+ try {
34
+ return fromBase64url(jwk[member]);
35
+ } catch {
36
+ throw bad(where, `"${member}" is not base64url.`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Confirm that a key is exactly one of the accepted shapes.
42
+ * @param {any} jwk
43
+ * @param {string[]} allowed the methods allowed in this place
44
+ * @param {string} where which key, for the message
45
+ * @returns {string} the key's method
46
+ */
47
+ export function checkKey(jwk, allowed, where) {
48
+ if (jwk === null || typeof jwk !== 'object' || Array.isArray(jwk)) throw bad(where, 'a key must be an object.');
49
+ const alg = jwk.alg;
50
+ if (typeof alg !== 'string' || !allowed.includes(alg) || !Object.hasOwn(SHAPES, alg)) {
51
+ throw bad(where, `the signing method must be one of ${allowed.join(', ')}.`);
52
+ }
53
+ const names = Object.keys(jwk).sort();
54
+ const expected = SHAPES[alg];
55
+ if (names.length !== expected.length || names.some((n, i) => n !== expected[i])) {
56
+ throw bad(where, `a ${alg} key must hold exactly ${expected.join(', ')}.`);
57
+ }
58
+ for (const n of names) {
59
+ if (typeof jwk[n] !== 'string') throw bad(where, `"${n}" must be text.`);
60
+ }
61
+ if (alg === 'Ed25519') {
62
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') throw bad(where, 'an Ed25519 key has kty OKP and crv Ed25519.');
63
+ if (bytesOf(jwk, 'x', where).length !== 32) throw bad(where, 'an Ed25519 public key is 32 bytes.');
64
+ } else if (alg === 'ML-DSA-87') {
65
+ if (jwk.kty !== 'AKP') throw bad(where, 'an ML-DSA-87 key has kty AKP.');
66
+ // FIPS 204, table 2: an ML-DSA-87 public key is 2,592 bytes.
67
+ if (bytesOf(jwk, 'pub', where).length !== 2592) throw bad(where, 'an ML-DSA-87 public key is 2,592 bytes.');
68
+ } else if (alg === 'SLH-DSA-SHA2-256s') {
69
+ if (jwk.kty !== 'AKP') throw bad(where, 'an SLH-DSA key has kty AKP.');
70
+ // FIPS 205, table 2: an SLH-DSA-SHA2-256s public key is 64 bytes.
71
+ if (bytesOf(jwk, 'pub', where).length !== 64) throw bad(where, 'an SLH-DSA-SHA2-256s public key is 64 bytes.');
72
+ } else if (alg === 'ES256') {
73
+ if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') throw bad(where, 'an ES256 key has kty EC and crv P-256.');
74
+ if (bytesOf(jwk, 'x', where).length !== 32 || bytesOf(jwk, 'y', where).length !== 32) {
75
+ throw bad(where, 'the two halves of a P-256 public key are 32 bytes each.');
76
+ }
77
+ } else {
78
+ if (jwk.kty !== 'RSA' || jwk.e !== 'AQAB') throw bad(where, 'an RS256 key has kty RSA and e AQAB.');
79
+ const n = bytesOf(jwk, 'n', where);
80
+ // The size in bits: whole bytes after the first, and the bits of the first.
81
+ const bits = n.length === 0 || n[0] === 0 ? 0 : (n.length - 1) * 8 + (32 - Math.clz32(n[0]));
82
+ if (bits < 2048 || bits > 8192) throw bad(where, 'an RS256 key is 2,048 to 8,192 bits.');
83
+ }
84
+ return alg;
85
+ }
86
+
87
+ /**
88
+ * Confirm that a value is a key set: exactly an Ed25519 key, then an
89
+ * ML-DSA-87 key (format description, section 3.9).
90
+ * @param {any} keys
91
+ * @param {string} where
92
+ */
93
+ export function checkKeySet(keys, where) {
94
+ if (!Array.isArray(keys) || keys.length !== KEY_SET_METHODS.length) {
95
+ throw bad(where, 'a key set is exactly two keys: Ed25519, then ML-DSA-87.');
96
+ }
97
+ KEY_SET_METHODS.forEach((alg, i) => checkKey(keys[i], [alg], `${where}[${i}]`));
98
+ }
99
+
100
+ /**
101
+ * Confirm that a value is a recorder's key set: exactly an Ed25519 key, an
102
+ * ML-DSA-87 key and an SLH-DSA-SHA2-256s key, in that order.
103
+ * @param {any} keys
104
+ * @param {string} where
105
+ */
106
+ export function checkSealKeySet(keys, where) {
107
+ if (!Array.isArray(keys) || keys.length !== SEAL_METHODS.length) {
108
+ throw bad(where, 'a recorder\'s key set is exactly three keys: Ed25519, ML-DSA-87, then SLH-DSA-SHA2-256s.');
109
+ }
110
+ SEAL_METHODS.forEach((alg, i) => checkKey(keys[i], [alg], `${where}[${i}]`));
111
+ }
112
+
113
+ /**
114
+ * The thumbprint of a key: RFC 7638 with SHA-256. The members used are those
115
+ * RFC 7638 section 3.2 names, and for ML-DSA those RFC 9964 section 6 names.
116
+ * The key must already have passed checkKey, so its members are exactly the
117
+ * required ones, already known to be plain base64url text.
118
+ * @param {any} jwk
119
+ * @returns {Promise<string>}
120
+ */
121
+ export async function thumbprint(jwk) {
122
+ const required = { ...jwk };
123
+ // "alg" is part of the thumbprint only for the AKP key type.
124
+ if (jwk.kty !== 'AKP') delete required.alg;
125
+ const names = Object.keys(required).sort();
126
+ const text = '{' + names.map((n) => JSON.stringify(n) + ':' + JSON.stringify(required[n])).join(',') + '}';
127
+ return toBase64url(await sha256(utf8(text)));
128
+ }
129
+
130
+ /**
131
+ * Load a public key for checking signatures.
132
+ * @param {any} jwk a key that has passed checkKey
133
+ * @returns {Promise<CryptoKey>}
134
+ */
135
+ export function importPublicKey(jwk) {
136
+ const subtle = globalThis.crypto.subtle;
137
+ switch (jwk.alg) {
138
+ case 'Ed25519':
139
+ return subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, { name: 'Ed25519' }, false, ['verify']);
140
+ case 'ML-DSA-87':
141
+ return subtle.importKey('jwk', { kty: 'AKP', alg: 'ML-DSA-87', pub: jwk.pub }, { name: 'ML-DSA-87' }, false, ['verify']);
142
+ case 'ES256':
143
+ return subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
144
+ case 'RS256':
145
+ return subtle.importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
146
+ default:
147
+ return Promise.reject(new Error('unknown signing method'));
148
+ }
149
+ }
package/src/pass.js ADDED
@@ -0,0 +1,67 @@
1
+ // Passing a slip on: an agent hands part of its permission to a helper
2
+ // agent. Format description, section 25.
3
+
4
+ import { formatTime, randomId } from './encoding.js';
5
+ import * as f from './fields.js';
6
+ import { KINDS } from './jws.js';
7
+ import { checkKeySet } from './keys.js';
8
+ import { validateLimits } from './slip.js';
9
+ import { signWithKeySet } from './stub.js';
10
+
11
+ /** The most times a slip may let its permission be passed on. */
12
+ export const MAX_PASSES = 10;
13
+
14
+ /**
15
+ * Confirm every member of a pass's content.
16
+ * @param {any} c
17
+ */
18
+ export function validatePassContent(c) {
19
+ f.members(c, ['actions', 'id', 'limits', 'slip', 'to', 'type', 'validFrom', 'validUntil', 'when'], ['from'], 'pass');
20
+ f.id(c.id, 'id');
21
+ f.fingerprintText(c.slip, 'slip');
22
+ if (Object.hasOwn(c, 'from')) f.fingerprintText(c.from, 'from');
23
+ f.members(c.to, ['keys', 'name'], [], 'to');
24
+ f.label(c.to.name, 'to.name');
25
+ checkKeySet(c.to.keys, 'to.keys');
26
+ f.list(c.actions, 1, 64, 'actions');
27
+ c.actions.forEach((a, i) => f.actionName(a, `actions[${i}]`));
28
+ if (new Set(c.actions).size !== c.actions.length) throw f.fail('actions', 'an action name is repeated.');
29
+ validateLimits(c.actions, c.limits);
30
+ const from = f.time(c.validFrom, 'validFrom');
31
+ const until = f.time(c.validUntil, 'validUntil');
32
+ if (!(from < until)) throw f.fail('validUntil', 'must be later than validFrom.');
33
+ f.time(c.when, 'when');
34
+ }
35
+
36
+ /**
37
+ * Write and sign a pass: the agent that holds a permission hands part of it
38
+ * to a helper agent.
39
+ *
40
+ * @param {object} fields
41
+ * @param {string} fields.slip the fingerprint of the slip
42
+ * @param {string} [fields.from] the fingerprint of the pass the signer itself acts under; absent when the signer is the slip's own agent
43
+ * @param {{keys: object[], name: string}} fields.to the helper agent's public key set and its name (a label)
44
+ * @param {string[]} fields.actions what the helper may do: no more than the signer may
45
+ * @param {object[]} [fields.limits] limits for the helper, in the form a slip uses
46
+ * @param {string} fields.validFrom
47
+ * @param {string} fields.validUntil
48
+ * @param {number|Date} [fields.when] defaults to now
49
+ * @param {CryptoKey[]} privateKeys the private keys of the agent that passes on: Ed25519, then ML-DSA-87
50
+ * @returns {Promise<{record: object, fingerprint: string}>}
51
+ */
52
+ export async function writePass(fields, privateKeys) {
53
+ const content = {
54
+ type: KINDS.pass.type,
55
+ id: fields.id ?? randomId(),
56
+ slip: fields.slip,
57
+ to: fields.to,
58
+ actions: fields.actions,
59
+ limits: fields.limits ?? [],
60
+ validFrom: fields.validFrom,
61
+ validUntil: fields.validUntil,
62
+ when: formatTime(fields.when ?? Date.now()),
63
+ };
64
+ if (fields.from) content.from = fields.from;
65
+ validatePassContent(content);
66
+ return signWithKeySet('pass', content, privateKeys);
67
+ }
@@ -0,0 +1,20 @@
1
+ // Type declarations for the part of the library that asks a browser's
2
+ // passkey to sign. See types.d.ts.
3
+ import type { Assertion, Base64url, PasskeyKey, Request, SignedRecord, SlipFields, Fingerprint } from './types.js';
4
+
5
+ export class PasskeyError extends Error {
6
+ code: string;
7
+ }
8
+ /** What must be kept to sign later: the passkey's identifier, its public key, and the website. */
9
+ export interface StoredPasskey {
10
+ credentialId: string;
11
+ key: PasskeyKey;
12
+ rpId: string;
13
+ origin: string;
14
+ }
15
+ export function createPasskey(o: { name: string; site: string }): Promise<StoredPasskey>;
16
+ export function signSlipWithPasskey(fields: Omit<SlipFields, 'issuer'>, passkey: StoredPasskey, issuerName: string): Promise<SignedRecord>;
17
+ export function approveWithPasskey(request: Request & { slip: Fingerprint; when?: number | Date }, passkey: StoredPasskey): Promise<SignedRecord>;
18
+ export function cancelWithPasskey(request: { slip: Fingerprint; when?: number | Date; id?: Base64url }, passkey: StoredPasskey): Promise<SignedRecord>;
19
+ /** Sign the challenge of anything prepared for a passkey; hand the result to the matching assemble function. */
20
+ export function signChallengeWithPasskey(challenge: Uint8Array, passkey: StoredPasskey): Promise<Assertion>;
@@ -0,0 +1,171 @@
1
+ // Signing a slip with a passkey, in a browser. This is the only file that
2
+ // touches the page: it calls the browser's Web Authentication interface.
3
+ //
4
+ // Format description, section 4.2.
5
+
6
+ import { toBase64url, fromBase64url } from './encoding.js';
7
+ import { assembleApproval, prepareApproval } from './approval.js';
8
+ import { assembleSlip, prepareSlip } from './slip.js';
9
+ import { assembleCancellation, prepareCancellation } from './standing.js';
10
+
11
+ /** A plain reason a passkey step could not be done, with a code a page can act on. */
12
+ export class PasskeyError extends Error {
13
+ /** @param {'unavailable'|'cancelled'|'unsupported-key'} code @param {string} message */
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = 'PasskeyError';
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ function credentials() {
22
+ if (!globalThis.isSecureContext || !globalThis.navigator || !navigator.credentials || !globalThis.PublicKeyCredential) {
23
+ throw new PasskeyError('unavailable', 'This browser cannot use passkeys on this page.');
24
+ }
25
+ return navigator.credentials;
26
+ }
27
+
28
+ // The signing methods asked for, in order of preference, by their numbers
29
+ // in the COSE registry: ES256 (-7), Ed25519 (-8), RS256 (-257).
30
+ const METHODS = [
31
+ { type: 'public-key', alg: -7 },
32
+ { type: 'public-key', alg: -8 },
33
+ { type: 'public-key', alg: -257 },
34
+ ];
35
+
36
+ async function publicKeyToJwk(spki, coseAlg) {
37
+ const subtle = crypto.subtle;
38
+ if (coseAlg === -7) {
39
+ const key = await subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
40
+ const jwk = await subtle.exportKey('jwk', key);
41
+ return { alg: 'ES256', crv: 'P-256', kty: 'EC', x: jwk.x, y: jwk.y };
42
+ }
43
+ if (coseAlg === -257) {
44
+ const key = await subtle.importKey('spki', spki, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, true, ['verify']);
45
+ const jwk = await subtle.exportKey('jwk', key);
46
+ return { alg: 'RS256', e: jwk.e, kty: 'RSA', n: jwk.n };
47
+ }
48
+ if (coseAlg === -8) {
49
+ const key = await subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify']);
50
+ const jwk = await subtle.exportKey('jwk', key);
51
+ return { alg: 'Ed25519', crv: 'Ed25519', kty: 'OKP', x: jwk.x };
52
+ }
53
+ throw new PasskeyError('unsupported-key', 'The passkey uses a signing method this format does not accept.');
54
+ }
55
+
56
+ /**
57
+ * Make a new passkey on this device for this website.
58
+ *
59
+ * @param {object} who
60
+ * @param {string} who.name the person's name, shown by the device when it asks
61
+ * @param {string} who.site a name for this website, shown by the device
62
+ * @returns {Promise<{credentialId: string, key: object, rpId: string, origin: string}>}
63
+ * what must be kept to sign slips later: the passkey's identifier, its public key, and the website
64
+ */
65
+ export async function createPasskey({ name, site }) {
66
+ const container = credentials();
67
+ let credential;
68
+ try {
69
+ credential = await container.create({
70
+ publicKey: {
71
+ rp: { id: location.hostname, name: site },
72
+ user: { id: crypto.getRandomValues(new Uint8Array(16)), name, displayName: name },
73
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
74
+ pubKeyCredParams: METHODS,
75
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
76
+ attestation: 'none',
77
+ },
78
+ });
79
+ } catch (e) {
80
+ throw new PasskeyError('cancelled', 'The passkey was not made. The request was cancelled or the device refused it.');
81
+ }
82
+ const response = credential.response;
83
+ const spki = response.getPublicKey();
84
+ if (!spki) throw new PasskeyError('unsupported-key', 'The device did not give the passkey\'s public key.');
85
+ const key = await publicKeyToJwk(spki, response.getPublicKeyAlgorithm());
86
+ return { credentialId: toBase64url(new Uint8Array(credential.rawId)), key, rpId: location.hostname, origin: location.origin };
87
+ }
88
+
89
+ // Ask the passkey to sign a challenge: the device asks the person first.
90
+ async function signedBy(passkey, challenge, refused) {
91
+ const container = credentials();
92
+ let assertion;
93
+ try {
94
+ assertion = await container.get({
95
+ publicKey: {
96
+ challenge,
97
+ rpId: passkey.rpId,
98
+ allowCredentials: [{ type: 'public-key', id: fromBase64url(passkey.credentialId) }],
99
+ userVerification: 'required',
100
+ },
101
+ });
102
+ } catch (e) {
103
+ throw new PasskeyError('cancelled', refused);
104
+ }
105
+ const r = assertion.response;
106
+ return {
107
+ authenticatorData: new Uint8Array(r.authenticatorData),
108
+ clientDataJSON: new Uint8Array(r.clientDataJSON),
109
+ signature: new Uint8Array(r.signature),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Sign a slip with a passkey made by createPasskey.
115
+ *
116
+ * @param {object} fields the slip's members except "type", "id" and "issuer"
117
+ * @param {object} passkey what createPasskey returned
118
+ * @param {string} issuerName the label to write as the issuer's name
119
+ * @returns {Promise<object>} the signed slip record
120
+ */
121
+ export async function signSlipWithPasskey(fields, passkey, issuerName) {
122
+ credentials();
123
+ const prepared = await prepareSlip({
124
+ ...fields,
125
+ issuer: { name: issuerName, key: passkey.key, rpId: passkey.rpId, origin: passkey.origin },
126
+ });
127
+ return assembleSlip(prepared, await signedBy(passkey, prepared.challenge, 'The slip was not signed. The request was cancelled or the device refused it.'));
128
+ }
129
+
130
+ /**
131
+ * Approve one action with a passkey made by createPasskey: the person's own
132
+ * yes, signed with the same passkey as the slip.
133
+ *
134
+ * @param {object} request what prepareApproval takes: the slip's fingerprint, the action, and where present the amount, the service, the documents and the time
135
+ * @param {object} passkey what createPasskey returned
136
+ * @returns {Promise<object>} the signed approval record
137
+ */
138
+ export async function approveWithPasskey(request, passkey) {
139
+ credentials();
140
+ const prepared = await prepareApproval(request);
141
+ return assembleApproval(prepared, await signedBy(passkey, prepared.challenge, 'The action was not approved. The request was cancelled or the device refused it.'));
142
+ }
143
+
144
+ /**
145
+ * Cancel a slip with the passkey that signed it, made by createPasskey.
146
+ * Keep a copy of what this returns, with its time-stamps: it counts even
147
+ * where whoever keeps the book leaves it out.
148
+ *
149
+ * @param {object} request what prepareCancellation takes: the slip's fingerprint, and where present the time
150
+ * @param {object} passkey what createPasskey returned
151
+ * @returns {Promise<object>} the signed cancellation record
152
+ */
153
+ export async function cancelWithPasskey(request, passkey) {
154
+ credentials();
155
+ const prepared = await prepareCancellation(request);
156
+ return assembleCancellation(prepared, await signedBy(passkey, prepared.challenge, 'The slip was not cancelled. The request was cancelled or the device refused it.'));
157
+ }
158
+
159
+ /**
160
+ * Sign the challenge of anything prepared for a passkey (prepareSlip with
161
+ * covered fields, for one) with a passkey made by createPasskey. Hand what
162
+ * this returns to the matching assemble function.
163
+ *
164
+ * @param {Uint8Array} challenge the "challenge" of what was prepared
165
+ * @param {object} passkey what createPasskey returned
166
+ * @returns {Promise<{authenticatorData: Uint8Array, clientDataJSON: Uint8Array, signature: Uint8Array}>}
167
+ */
168
+ export async function signChallengeWithPasskey(challenge, passkey) {
169
+ credentials();
170
+ return signedBy(passkey, challenge, 'Nothing was signed. The request was cancelled or the device refused it.');
171
+ }