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