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/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
|
+
}
|