@datumesh/verify 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/README.md +54 -2
- package/dist/cjs/ed25519.d.ts +2 -0
- package/dist/cjs/ed25519.js +82 -0
- package/dist/cjs/errors.d.ts +14 -0
- package/dist/cjs/errors.js +61 -0
- package/dist/cjs/hash.d.ts +6 -0
- package/dist/cjs/hash.js +27 -0
- package/dist/cjs/index.d.ts +27 -0
- package/dist/cjs/index.js +83 -0
- package/dist/cjs/jcs.d.ts +5 -0
- package/dist/cjs/jcs.js +25 -0
- package/dist/cjs/json.d.ts +29 -0
- package/dist/cjs/json.js +321 -0
- package/dist/cjs/keys.d.ts +73 -0
- package/dist/cjs/keys.js +257 -0
- package/dist/cjs/lexical.d.ts +59 -0
- package/dist/cjs/lexical.js +207 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/unicode-data.d.ts +8 -0
- package/dist/cjs/unicode-data.js +14 -0
- package/dist/cjs/unicode.d.ts +12 -0
- package/dist/cjs/unicode.js +192 -0
- package/dist/cjs/uri.d.ts +31 -0
- package/dist/cjs/uri.js +201 -0
- package/dist/cjs/utf8.d.ts +10 -0
- package/dist/cjs/utf8.js +101 -0
- package/dist/cjs/validate.d.ts +17 -0
- package/dist/cjs/validate.js +349 -0
- package/dist/cjs/verify.d.ts +38 -0
- package/dist/cjs/verify.js +192 -0
- package/dist/esm/ed25519.d.ts +2 -0
- package/dist/esm/ed25519.js +79 -0
- package/dist/esm/errors.d.ts +14 -0
- package/dist/esm/errors.js +56 -0
- package/dist/esm/hash.d.ts +6 -0
- package/dist/esm/hash.js +21 -0
- package/dist/esm/index.d.ts +27 -0
- package/dist/esm/index.js +63 -0
- package/dist/esm/jcs.d.ts +5 -0
- package/dist/esm/jcs.js +18 -0
- package/dist/esm/json.d.ts +29 -0
- package/dist/esm/json.js +309 -0
- package/dist/esm/keys.d.ts +73 -0
- package/dist/esm/keys.js +249 -0
- package/dist/esm/lexical.d.ts +59 -0
- package/dist/esm/lexical.js +187 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/unicode-data.d.ts +8 -0
- package/dist/esm/unicode-data.js +11 -0
- package/dist/esm/unicode.d.ts +12 -0
- package/dist/esm/unicode.js +184 -0
- package/dist/esm/uri.d.ts +31 -0
- package/dist/esm/uri.js +196 -0
- package/dist/esm/utf8.d.ts +10 -0
- package/dist/esm/utf8.js +96 -0
- package/dist/esm/validate.d.ts +17 -0
- package/dist/esm/validate.js +340 -0
- package/dist/esm/verify.d.ts +38 -0
- package/dist/esm/verify.js +189 -0
- package/package.json +54 -4
package/dist/cjs/json.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_DEPTH = void 0;
|
|
4
|
+
exports.isObject = isObject;
|
|
5
|
+
exports.newObject = newObject;
|
|
6
|
+
exports.has = has;
|
|
7
|
+
exports.sortedKeys = sortedKeys;
|
|
8
|
+
exports.memberPath = memberPath;
|
|
9
|
+
exports.parseJsonText = parseJsonText;
|
|
10
|
+
exports.parseJsonBytes = parseJsonBytes;
|
|
11
|
+
exports.normalizeValue = normalizeValue;
|
|
12
|
+
exports.toJson = toJson;
|
|
13
|
+
// Strict JSON parsing (SPEC.md 6.1, I-JSON / RFC 7493). JSON.parse is not used: it accepts
|
|
14
|
+
// duplicate members (last wins) and lone surrogates, both of which must be rejected.
|
|
15
|
+
const errors_js_1 = require("./errors.js");
|
|
16
|
+
const unicode_js_1 = require("./unicode.js");
|
|
17
|
+
const utf8_js_1 = require("./utf8.js");
|
|
18
|
+
/**
|
|
19
|
+
* Nesting deeper than this is rejected with E_PARSE_JSON. SPEC.md sets no depth limit; this is
|
|
20
|
+
* an implementation limit (Datumesh objects nest at most five levels) that keeps parsing and
|
|
21
|
+
* canonicalisation from exhausting the call stack.
|
|
22
|
+
*/
|
|
23
|
+
exports.MAX_DEPTH = 1000;
|
|
24
|
+
function isObject(v) {
|
|
25
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
26
|
+
}
|
|
27
|
+
function newObject() {
|
|
28
|
+
return Object.create(null);
|
|
29
|
+
}
|
|
30
|
+
function has(o, k) {
|
|
31
|
+
return Object.prototype.hasOwnProperty.call(o, k);
|
|
32
|
+
}
|
|
33
|
+
/** Member names in JCS order (UTF-16 code units, which is JavaScript's default sort). */
|
|
34
|
+
function sortedKeys(o) {
|
|
35
|
+
return Object.keys(o).sort();
|
|
36
|
+
}
|
|
37
|
+
function memberPath(path, name) {
|
|
38
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? `${path}.${name}` : `${path}[${JSON.stringify(name)}]`;
|
|
39
|
+
}
|
|
40
|
+
class Parser {
|
|
41
|
+
constructor(s) {
|
|
42
|
+
this.s = s;
|
|
43
|
+
this.i = 0;
|
|
44
|
+
/** Path of the first string holding an escaped lone surrogate; reported after parsing. */
|
|
45
|
+
this.surrogatePath = null;
|
|
46
|
+
}
|
|
47
|
+
err(msg) {
|
|
48
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', `${msg} at offset ${this.i}`, '$');
|
|
49
|
+
}
|
|
50
|
+
ws() {
|
|
51
|
+
const s = this.s;
|
|
52
|
+
while (this.i < s.length) {
|
|
53
|
+
const c = s.charCodeAt(this.i);
|
|
54
|
+
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d)
|
|
55
|
+
this.i++;
|
|
56
|
+
else
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
parseDocument() {
|
|
61
|
+
this.ws();
|
|
62
|
+
const v = this.value('$', 0);
|
|
63
|
+
this.ws();
|
|
64
|
+
if (this.i !== this.s.length)
|
|
65
|
+
this.err('unexpected data after the JSON value');
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
value(path, depth) {
|
|
69
|
+
if (depth > exports.MAX_DEPTH)
|
|
70
|
+
this.err(`nesting deeper than ${exports.MAX_DEPTH} levels (implementation limit)`);
|
|
71
|
+
const s = this.s;
|
|
72
|
+
const c = s[this.i];
|
|
73
|
+
if (c === '{')
|
|
74
|
+
return this.object(path, depth);
|
|
75
|
+
if (c === '[')
|
|
76
|
+
return this.array(path, depth);
|
|
77
|
+
if (c === '"')
|
|
78
|
+
return this.string(path);
|
|
79
|
+
if (c === 't' && s.startsWith('true', this.i)) {
|
|
80
|
+
this.i += 4;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (c === 'f' && s.startsWith('false', this.i)) {
|
|
84
|
+
this.i += 5;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (c === 'n' && s.startsWith('null', this.i)) {
|
|
88
|
+
this.i += 4;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (c === '-' || (c >= '0' && c <= '9'))
|
|
92
|
+
return this.number();
|
|
93
|
+
if (c === undefined)
|
|
94
|
+
this.err('unexpected end of input');
|
|
95
|
+
if (s.startsWith('NaN', this.i) || s.startsWith('Infinity', this.i) || s.startsWith('-Infinity', this.i)) {
|
|
96
|
+
this.err('NaN and Infinity are not JSON');
|
|
97
|
+
}
|
|
98
|
+
this.err(`unexpected character ${JSON.stringify(c)}`);
|
|
99
|
+
}
|
|
100
|
+
object(path, depth) {
|
|
101
|
+
this.i++; // {
|
|
102
|
+
const o = newObject();
|
|
103
|
+
let dup = null;
|
|
104
|
+
this.ws();
|
|
105
|
+
if (this.s[this.i] === '}') {
|
|
106
|
+
this.i++;
|
|
107
|
+
return o;
|
|
108
|
+
}
|
|
109
|
+
for (;;) {
|
|
110
|
+
this.ws();
|
|
111
|
+
if (this.s[this.i] !== '"')
|
|
112
|
+
this.err('expected a member name');
|
|
113
|
+
const k = this.string(path);
|
|
114
|
+
this.ws();
|
|
115
|
+
if (this.s[this.i] !== ':')
|
|
116
|
+
this.err("expected ':'");
|
|
117
|
+
this.i++;
|
|
118
|
+
this.ws();
|
|
119
|
+
const v = this.value(memberPath(path, k), depth + 1);
|
|
120
|
+
if (has(o, k)) {
|
|
121
|
+
if (dup === null)
|
|
122
|
+
dup = k;
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
o[k] = v;
|
|
126
|
+
this.ws();
|
|
127
|
+
const c = this.s[this.i];
|
|
128
|
+
if (c === ',') {
|
|
129
|
+
this.i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (c === '}') {
|
|
133
|
+
this.i++;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
this.err("expected ',' or '}'");
|
|
137
|
+
}
|
|
138
|
+
// Like an object-pairs hook, duplicates are reported once the object is complete.
|
|
139
|
+
if (dup !== null)
|
|
140
|
+
(0, errors_js_1.fail)('E_PARSE_DUPLICATE_KEY', `duplicate member ${JSON.stringify(dup)}`, path);
|
|
141
|
+
return o;
|
|
142
|
+
}
|
|
143
|
+
array(path, depth) {
|
|
144
|
+
this.i++; // [
|
|
145
|
+
const a = [];
|
|
146
|
+
this.ws();
|
|
147
|
+
if (this.s[this.i] === ']') {
|
|
148
|
+
this.i++;
|
|
149
|
+
return a;
|
|
150
|
+
}
|
|
151
|
+
for (;;) {
|
|
152
|
+
this.ws();
|
|
153
|
+
a.push(this.value(`${path}[${a.length}]`, depth + 1));
|
|
154
|
+
this.ws();
|
|
155
|
+
const c = this.s[this.i];
|
|
156
|
+
if (c === ',') {
|
|
157
|
+
this.i++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (c === ']') {
|
|
161
|
+
this.i++;
|
|
162
|
+
return a;
|
|
163
|
+
}
|
|
164
|
+
this.err("expected ',' or ']'");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
string(path) {
|
|
168
|
+
const s = this.s;
|
|
169
|
+
this.i++; // opening quote
|
|
170
|
+
let out = '';
|
|
171
|
+
let start = this.i;
|
|
172
|
+
for (;;) {
|
|
173
|
+
if (this.i >= s.length)
|
|
174
|
+
this.err('unterminated string');
|
|
175
|
+
const c = s.charCodeAt(this.i);
|
|
176
|
+
if (c === 0x22) {
|
|
177
|
+
out += s.slice(start, this.i);
|
|
178
|
+
this.i++;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
if (c < 0x20)
|
|
182
|
+
this.err('control character in string');
|
|
183
|
+
if (c === 0x5c) {
|
|
184
|
+
out += s.slice(start, this.i);
|
|
185
|
+
const e = s[this.i + 1];
|
|
186
|
+
let ch;
|
|
187
|
+
switch (e) {
|
|
188
|
+
case '"':
|
|
189
|
+
ch = '"';
|
|
190
|
+
break;
|
|
191
|
+
case '\\':
|
|
192
|
+
ch = '\\';
|
|
193
|
+
break;
|
|
194
|
+
case '/':
|
|
195
|
+
ch = '/';
|
|
196
|
+
break;
|
|
197
|
+
case 'b':
|
|
198
|
+
ch = '\b';
|
|
199
|
+
break;
|
|
200
|
+
case 'f':
|
|
201
|
+
ch = '\f';
|
|
202
|
+
break;
|
|
203
|
+
case 'n':
|
|
204
|
+
ch = '\n';
|
|
205
|
+
break;
|
|
206
|
+
case 'r':
|
|
207
|
+
ch = '\r';
|
|
208
|
+
break;
|
|
209
|
+
case 't':
|
|
210
|
+
ch = '\t';
|
|
211
|
+
break;
|
|
212
|
+
case 'u': {
|
|
213
|
+
const h = s.slice(this.i + 2, this.i + 6);
|
|
214
|
+
if (!/^[0-9A-Fa-f]{4}$/.test(h))
|
|
215
|
+
this.err('bad \\u escape');
|
|
216
|
+
ch = String.fromCharCode(parseInt(h, 16));
|
|
217
|
+
this.i += 4;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
this.err('bad escape');
|
|
222
|
+
}
|
|
223
|
+
out += ch;
|
|
224
|
+
this.i += 2;
|
|
225
|
+
start = this.i;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
this.i++;
|
|
229
|
+
}
|
|
230
|
+
if (this.surrogatePath === null && (0, unicode_js_1.hasLoneSurrogate)(out))
|
|
231
|
+
this.surrogatePath = path;
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
number() {
|
|
235
|
+
const s = this.s;
|
|
236
|
+
const m = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
|
|
237
|
+
m.lastIndex = this.i;
|
|
238
|
+
const r = m.exec(s);
|
|
239
|
+
if (!r || r[0] === '-' || r[0] === '')
|
|
240
|
+
this.err('bad number');
|
|
241
|
+
const text = r[0];
|
|
242
|
+
// A digit right after "0" (e.g. 01) is not JSON.
|
|
243
|
+
const next = s[this.i + text.length];
|
|
244
|
+
if (next !== undefined && /[0-9.eE+-]/.test(next))
|
|
245
|
+
this.err('bad number');
|
|
246
|
+
this.i += text.length;
|
|
247
|
+
const n = Number(text);
|
|
248
|
+
if (!Number.isFinite(n))
|
|
249
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', `number out of IEEE 754 range: ${text.slice(0, 40)}`, '$');
|
|
250
|
+
return n;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Parse JSON text strictly. */
|
|
254
|
+
function parseJsonText(text) {
|
|
255
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
256
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'byte order mark', '$');
|
|
257
|
+
if ((0, unicode_js_1.hasLoneSurrogate)(text))
|
|
258
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'lone surrogate in the text', '$');
|
|
259
|
+
const p = new Parser(text);
|
|
260
|
+
const v = p.parseDocument();
|
|
261
|
+
if (p.surrogatePath !== null)
|
|
262
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'lone surrogate', p.surrogatePath);
|
|
263
|
+
return v;
|
|
264
|
+
}
|
|
265
|
+
/** Parse UTF-8 JSON bytes strictly: no BOM, no invalid UTF-8. */
|
|
266
|
+
function parseJsonBytes(bytes) {
|
|
267
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
268
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'byte order mark', '$');
|
|
269
|
+
}
|
|
270
|
+
const text = (0, utf8_js_1.utf8Decode)(bytes);
|
|
271
|
+
if (text === null)
|
|
272
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'invalid UTF-8', '$');
|
|
273
|
+
return parseJsonText(text);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Check an already-parsed JavaScript value against the JSON data model and return a private
|
|
277
|
+
* deep copy (plain data with null-prototype objects), so later checks cannot be affected by
|
|
278
|
+
* getters, prototypes or mutation. Duplicate members cannot be detected here: pass JSON text
|
|
279
|
+
* or bytes for that.
|
|
280
|
+
*/
|
|
281
|
+
function normalizeValue(v, path = '$', depth = 0) {
|
|
282
|
+
if (depth > exports.MAX_DEPTH)
|
|
283
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', `nesting deeper than ${exports.MAX_DEPTH} levels (or a cycle)`, path);
|
|
284
|
+
if (v === null || typeof v === 'boolean')
|
|
285
|
+
return v;
|
|
286
|
+
if (typeof v === 'number') {
|
|
287
|
+
if (!Number.isFinite(v))
|
|
288
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'NaN and Infinity are not JSON', path);
|
|
289
|
+
return v;
|
|
290
|
+
}
|
|
291
|
+
if (typeof v === 'string') {
|
|
292
|
+
if ((0, unicode_js_1.hasLoneSurrogate)(v))
|
|
293
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'lone surrogate', path);
|
|
294
|
+
return v;
|
|
295
|
+
}
|
|
296
|
+
if (Array.isArray(v))
|
|
297
|
+
return v.map((x, i) => normalizeValue(x, `${path}[${i}]`, depth + 1));
|
|
298
|
+
if (typeof v === 'object') {
|
|
299
|
+
const proto = Object.getPrototypeOf(v);
|
|
300
|
+
if (proto !== Object.prototype && proto !== null)
|
|
301
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'not a plain JSON object', path);
|
|
302
|
+
const o = newObject();
|
|
303
|
+
for (const k of Object.keys(v)) {
|
|
304
|
+
if ((0, unicode_js_1.hasLoneSurrogate)(k))
|
|
305
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', 'lone surrogate in a member name', path);
|
|
306
|
+
o[k] = normalizeValue(v[k], memberPath(path, k), depth + 1);
|
|
307
|
+
}
|
|
308
|
+
return o;
|
|
309
|
+
}
|
|
310
|
+
(0, errors_js_1.fail)('E_PARSE_JSON', `${typeof v} is not a JSON value`, path);
|
|
311
|
+
}
|
|
312
|
+
/** JSON text (string), UTF-8 bytes (Uint8Array) or an already-parsed value. */
|
|
313
|
+
function toJson(input) {
|
|
314
|
+
if (typeof input === 'string')
|
|
315
|
+
return parseJsonText(input);
|
|
316
|
+
if (input instanceof Uint8Array)
|
|
317
|
+
return parseJsonBytes(input);
|
|
318
|
+
if (input instanceof ArrayBuffer)
|
|
319
|
+
return parseJsonBytes(new Uint8Array(input));
|
|
320
|
+
return normalizeValue(input);
|
|
321
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ErrorCode } from './errors.js';
|
|
2
|
+
/** One validated key from a key document. */
|
|
3
|
+
export interface KeyEntry {
|
|
4
|
+
alg: 'ed25519';
|
|
5
|
+
id: string;
|
|
6
|
+
public: string;
|
|
7
|
+
valid_from: string;
|
|
8
|
+
valid_to?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The outcome of resolving one host's keys (SPEC 9.3). */
|
|
11
|
+
export interface KeysResult {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
/** "OK", "E_KEY_UNAVAILABLE" or "E_KEY_DOC". */
|
|
14
|
+
code: 'OK' | 'E_KEY_UNAVAILABLE' | 'E_KEY_DOC';
|
|
15
|
+
host: string;
|
|
16
|
+
detail: string;
|
|
17
|
+
/** The validated keys, in document order, when ok. */
|
|
18
|
+
keys?: KeyEntry[];
|
|
19
|
+
}
|
|
20
|
+
/** A mapping of key id to 64-hex public key: usable for any host, with no validity windows. */
|
|
21
|
+
export type KeyMap = Record<string, string>;
|
|
22
|
+
/** Anything `verify` accepts as keys. */
|
|
23
|
+
export type KeySource = KeysResult | KeyMap | Record<string, unknown> | KeySource[];
|
|
24
|
+
/**
|
|
25
|
+
* Interpret the HTTPS response for https://<host>/.well-known/datumesh.json (SPEC 9.3).
|
|
26
|
+
* `status` is the HTTP status, or null for a network, DNS, TLS or timeout failure. `body` is the
|
|
27
|
+
* response body as text or bytes. Never throws.
|
|
28
|
+
*/
|
|
29
|
+
export declare function keysFromResponse(host: string, status: number | null, body?: string | Uint8Array | null): KeysResult;
|
|
30
|
+
/**
|
|
31
|
+
* Validate an already-parsed key document (its `host` member names the host it covers).
|
|
32
|
+
* Never throws.
|
|
33
|
+
*/
|
|
34
|
+
export declare function keysFromDocument(doc: unknown): KeysResult;
|
|
35
|
+
/** Default timeout of fetchKeys, in milliseconds. */
|
|
36
|
+
export declare const FETCH_TIMEOUT_MS = 10000;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch https://<host>/.well-known/datumesh.json once, with TLS verified by the platform, no
|
|
39
|
+
* redirects followed and a timeout, then interpret it with keysFromResponse. The only function
|
|
40
|
+
* in this library that touches the network. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fetchKeys(host: string, timeoutMs?: number): Promise<KeysResult>;
|
|
43
|
+
interface HostSource {
|
|
44
|
+
host: string;
|
|
45
|
+
result: KeysResult;
|
|
46
|
+
}
|
|
47
|
+
interface AnySource {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
code: ErrorCode | 'OK';
|
|
50
|
+
detail: string;
|
|
51
|
+
keys: KeyEntry[];
|
|
52
|
+
}
|
|
53
|
+
export interface Sources {
|
|
54
|
+
hosts: HostSource[];
|
|
55
|
+
any: AnySource[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Normalise the `keys` argument of verify. Throws TypeError only for arguments that are not key
|
|
59
|
+
* sources at all (programmer errors); malformed key material becomes an error source.
|
|
60
|
+
*/
|
|
61
|
+
export declare function collectSources(keys: unknown, into?: Sources, depth?: number): Sources;
|
|
62
|
+
export type KeyLookup = {
|
|
63
|
+
ok: true;
|
|
64
|
+
key: KeyEntry;
|
|
65
|
+
windows: boolean;
|
|
66
|
+
} | {
|
|
67
|
+
ok: false;
|
|
68
|
+
code: ErrorCode;
|
|
69
|
+
detail: string;
|
|
70
|
+
};
|
|
71
|
+
/** SPEC 10.1 step 5 for key reference host#id at time `issued`. */
|
|
72
|
+
export declare function lookupKey(sources: Sources, host: string, id: string, issued: string): KeyLookup;
|
|
73
|
+
export {};
|
package/dist/cjs/keys.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FETCH_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.keysFromResponse = keysFromResponse;
|
|
5
|
+
exports.keysFromDocument = keysFromDocument;
|
|
6
|
+
exports.fetchKeys = fetchKeys;
|
|
7
|
+
exports.collectSources = collectSources;
|
|
8
|
+
exports.lookupKey = lookupKey;
|
|
9
|
+
// Key documents (SPEC.md 9.2), their retrieval (9.3) and the key sources `verify` accepts.
|
|
10
|
+
const errors_js_1 = require("./errors.js");
|
|
11
|
+
const hash_js_1 = require("./hash.js");
|
|
12
|
+
const json_js_1 = require("./json.js");
|
|
13
|
+
const lexical_js_1 = require("./lexical.js");
|
|
14
|
+
class KeyDocError extends Error {
|
|
15
|
+
constructor(msg, path) {
|
|
16
|
+
super(msg);
|
|
17
|
+
this.path = path;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function bad(msg, path) {
|
|
21
|
+
throw new KeyDocError(msg, path);
|
|
22
|
+
}
|
|
23
|
+
/** Validate a parsed key document for `host` (SPEC 9.2). Throws KeyDocError. */
|
|
24
|
+
function validateKeyDocument(doc, host) {
|
|
25
|
+
if (!(0, json_js_1.isObject)(doc))
|
|
26
|
+
bad('expected an object', '$');
|
|
27
|
+
for (const k of (0, json_js_1.sortedKeys)(doc))
|
|
28
|
+
if (!['host', 'keys', 'proto'].includes(k))
|
|
29
|
+
bad(`unexpected member ${JSON.stringify(k)}`, '$');
|
|
30
|
+
for (const k of ['host', 'keys', 'proto'])
|
|
31
|
+
if (!(0, json_js_1.has)(doc, k))
|
|
32
|
+
bad(`missing member ${JSON.stringify(k)}`, '$');
|
|
33
|
+
if (doc.proto !== 'datumesh/1')
|
|
34
|
+
bad("proto must be 'datumesh/1'", '$.proto');
|
|
35
|
+
if (doc.host !== host)
|
|
36
|
+
bad(`host must be ${JSON.stringify(host)}`, '$.host');
|
|
37
|
+
const keys = doc.keys;
|
|
38
|
+
if (!Array.isArray(keys) || keys.length < 1 || keys.length > lexical_js_1.LIMITS.keys)
|
|
39
|
+
bad(`keys must be an array of 1 to ${lexical_js_1.LIMITS.keys} keys`, '$.keys');
|
|
40
|
+
const out = [];
|
|
41
|
+
const seenIds = new Set();
|
|
42
|
+
const seenPublic = new Set();
|
|
43
|
+
keys.forEach((k, i) => {
|
|
44
|
+
const p = `$.keys[${i}]`;
|
|
45
|
+
if (!(0, json_js_1.isObject)(k))
|
|
46
|
+
bad('expected an object', p);
|
|
47
|
+
for (const m of (0, json_js_1.sortedKeys)(k))
|
|
48
|
+
if (!['alg', 'id', 'public', 'valid_from', 'valid_to'].includes(m))
|
|
49
|
+
bad(`unexpected member ${JSON.stringify(m)}`, p);
|
|
50
|
+
for (const m of ['alg', 'id', 'public', 'valid_from'])
|
|
51
|
+
if (!(0, json_js_1.has)(k, m))
|
|
52
|
+
bad(`missing member ${JSON.stringify(m)}`, p);
|
|
53
|
+
if (k.alg !== 'ed25519')
|
|
54
|
+
bad('alg must be ed25519', (0, json_js_1.memberPath)(p, 'alg'));
|
|
55
|
+
if (typeof k.id !== 'string' || !(0, lexical_js_1.isHex)(k.id, 16))
|
|
56
|
+
bad('id must be 16 lowercase hex digits', (0, json_js_1.memberPath)(p, 'id'));
|
|
57
|
+
if (typeof k.public !== 'string' || !(0, lexical_js_1.isHex)(k.public, 64))
|
|
58
|
+
bad('public must be 64 lowercase hex digits', (0, json_js_1.memberPath)(p, 'public'));
|
|
59
|
+
if (typeof k.valid_from !== 'string' || !(0, lexical_js_1.isInstant)(k.valid_from))
|
|
60
|
+
bad('valid_from must be an instant', (0, json_js_1.memberPath)(p, 'valid_from'));
|
|
61
|
+
if ((0, json_js_1.has)(k, 'valid_to')) {
|
|
62
|
+
if (typeof k.valid_to !== 'string' || !(0, lexical_js_1.isInstant)(k.valid_to))
|
|
63
|
+
bad('valid_to must be an instant', (0, json_js_1.memberPath)(p, 'valid_to'));
|
|
64
|
+
if (!(k.valid_to > k.valid_from))
|
|
65
|
+
bad('valid_to must be later than valid_from', p);
|
|
66
|
+
}
|
|
67
|
+
if ((0, hash_js_1.keyIdOf)((0, hash_js_1.fromHex)(k.public)) !== k.id)
|
|
68
|
+
bad('id is not the key id of public', (0, json_js_1.memberPath)(p, 'id'));
|
|
69
|
+
if (seenIds.has(k.id) || seenPublic.has(k.public))
|
|
70
|
+
bad('key appears twice', p);
|
|
71
|
+
seenIds.add(k.id);
|
|
72
|
+
seenPublic.add(k.public);
|
|
73
|
+
const e = { alg: 'ed25519', id: k.id, public: k.public, valid_from: k.valid_from };
|
|
74
|
+
if ((0, json_js_1.has)(k, 'valid_to'))
|
|
75
|
+
e.valid_to = k.valid_to;
|
|
76
|
+
out.push(e);
|
|
77
|
+
});
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function docResult(host, parse) {
|
|
81
|
+
let doc;
|
|
82
|
+
try {
|
|
83
|
+
doc = parse();
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const d = e instanceof errors_js_1.DatumeshError ? `${e.code}: ${e.detail}` : String(e);
|
|
87
|
+
return { ok: false, code: 'E_KEY_DOC', host, detail: `key document is not strict JSON (${d})` };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const keys = validateKeyDocument(doc, host);
|
|
91
|
+
return { ok: true, code: 'OK', host, detail: `${keys.length} key(s) for ${host}`, keys };
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (e instanceof KeyDocError)
|
|
95
|
+
return { ok: false, code: 'E_KEY_DOC', host, detail: `${e.message} at ${e.path}` };
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Interpret the HTTPS response for https://<host>/.well-known/datumesh.json (SPEC 9.3).
|
|
101
|
+
* `status` is the HTTP status, or null for a network, DNS, TLS or timeout failure. `body` is the
|
|
102
|
+
* response body as text or bytes. Never throws.
|
|
103
|
+
*/
|
|
104
|
+
function keysFromResponse(host, status, body) {
|
|
105
|
+
const h = typeof host === 'string' ? host : String(host);
|
|
106
|
+
try {
|
|
107
|
+
if (status === null || status === undefined) {
|
|
108
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', host: h, detail: 'network, DNS, TLS or timeout failure' };
|
|
109
|
+
}
|
|
110
|
+
if (status !== 200) {
|
|
111
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', host: h, detail: `HTTP status ${String(status)} (only 200 is accepted; redirects are not followed)` };
|
|
112
|
+
}
|
|
113
|
+
if (typeof body !== 'string' && !(body instanceof Uint8Array)) {
|
|
114
|
+
return { ok: false, code: 'E_KEY_DOC', host: h, detail: '200 response without a body' };
|
|
115
|
+
}
|
|
116
|
+
return docResult(h, () => (0, json_js_1.toJson)(body));
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', host: h, detail: `could not interpret the response: ${String(e)}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Validate an already-parsed key document (its `host` member names the host it covers).
|
|
124
|
+
* Never throws.
|
|
125
|
+
*/
|
|
126
|
+
function keysFromDocument(doc) {
|
|
127
|
+
const host = (0, json_js_1.isObject)(doc) && typeof doc.host === 'string' ? doc.host : '';
|
|
128
|
+
try {
|
|
129
|
+
return docResult(host, () => (0, json_js_1.toJson)(doc));
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { ok: false, code: 'E_KEY_DOC', host, detail: String(e) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Default timeout of fetchKeys, in milliseconds. */
|
|
136
|
+
exports.FETCH_TIMEOUT_MS = 10000;
|
|
137
|
+
/**
|
|
138
|
+
* Fetch https://<host>/.well-known/datumesh.json once, with TLS verified by the platform, no
|
|
139
|
+
* redirects followed and a timeout, then interpret it with keysFromResponse. The only function
|
|
140
|
+
* in this library that touches the network. Never throws.
|
|
141
|
+
*/
|
|
142
|
+
async function fetchKeys(host, timeoutMs = exports.FETCH_TIMEOUT_MS) {
|
|
143
|
+
try {
|
|
144
|
+
(0, lexical_js_1.checkDnsName)(host, '$');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', host: String(host), detail: 'host is not a lowercase DNS name (SPEC 2.1); nothing was fetched' };
|
|
148
|
+
}
|
|
149
|
+
const f = globalThis.fetch;
|
|
150
|
+
if (typeof f !== 'function')
|
|
151
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', host, detail: 'no global fetch in this runtime' };
|
|
152
|
+
let status;
|
|
153
|
+
let body;
|
|
154
|
+
try {
|
|
155
|
+
const res = await f(`https://${host}/.well-known/datumesh.json`, {
|
|
156
|
+
method: 'GET',
|
|
157
|
+
redirect: 'manual',
|
|
158
|
+
credentials: 'omit',
|
|
159
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
160
|
+
});
|
|
161
|
+
status = res.status;
|
|
162
|
+
// A redirect under redirect:"manual" is a 3xx (Node) or an opaque-redirect response with
|
|
163
|
+
// status 0 (browsers); either way it is not 200.
|
|
164
|
+
if (res.type === 'opaqueredirect')
|
|
165
|
+
status = 0;
|
|
166
|
+
if (status !== 200)
|
|
167
|
+
return keysFromResponse(host, status);
|
|
168
|
+
body = new Uint8Array(await res.arrayBuffer());
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', host, detail: `retrieval failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
172
|
+
}
|
|
173
|
+
return keysFromResponse(host, status, body);
|
|
174
|
+
}
|
|
175
|
+
function isKeysResult(v) {
|
|
176
|
+
return typeof v.ok === 'boolean' && typeof v.code === 'string' && typeof v.host === 'string';
|
|
177
|
+
}
|
|
178
|
+
function fromMap(m) {
|
|
179
|
+
const keys = [];
|
|
180
|
+
for (const id of Object.keys(m)) {
|
|
181
|
+
const pub = m[id];
|
|
182
|
+
if (!(0, lexical_js_1.isHex)(id, 16) || typeof pub !== 'string' || !(0, lexical_js_1.isHex)(pub, 64) || (0, hash_js_1.keyIdOf)((0, hash_js_1.fromHex)(pub)) !== id) {
|
|
183
|
+
return { ok: false, code: 'E_KEY_DOC', detail: `key map entry ${JSON.stringify(id)} is not <key id>: <64-hex public key> with a matching id`, keys: [] };
|
|
184
|
+
}
|
|
185
|
+
keys.push({ alg: 'ed25519', id, public: pub, valid_from: '0001-01-01T00:00:00.000000Z' });
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, code: 'OK', detail: 'key map (any host, no validity windows)', keys };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Normalise the `keys` argument of verify. Throws TypeError only for arguments that are not key
|
|
191
|
+
* sources at all (programmer errors); malformed key material becomes an error source.
|
|
192
|
+
*/
|
|
193
|
+
function collectSources(keys, into = { hosts: [], any: [] }, depth = 0) {
|
|
194
|
+
if (Array.isArray(keys)) {
|
|
195
|
+
if (depth > 8)
|
|
196
|
+
throw new TypeError('keys: arrays nested too deeply');
|
|
197
|
+
for (const k of keys)
|
|
198
|
+
collectSources(k, into, depth + 1);
|
|
199
|
+
return into;
|
|
200
|
+
}
|
|
201
|
+
if (typeof keys !== 'object' || keys === null) {
|
|
202
|
+
throw new TypeError('keys must be a key document, a KeysResult, a {keyId: publicKeyHex} map, or an array of these');
|
|
203
|
+
}
|
|
204
|
+
const o = keys;
|
|
205
|
+
if (isKeysResult(o)) {
|
|
206
|
+
const r = o;
|
|
207
|
+
if (r.ok) {
|
|
208
|
+
// Re-check what a caller may have built by hand.
|
|
209
|
+
const ok = Array.isArray(r.keys) && r.keys.every((k) => k && (0, lexical_js_1.isHex)(k.id, 16) && (0, lexical_js_1.isHex)(k.public, 64) && (0, lexical_js_1.isInstant)(k.valid_from) && (k.valid_to === undefined || (0, lexical_js_1.isInstant)(k.valid_to)));
|
|
210
|
+
into.hosts.push({ host: r.host, result: ok ? r : { ok: false, code: 'E_KEY_DOC', host: r.host, detail: 'KeysResult has malformed keys' } });
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const code = r.code === 'E_KEY_DOC' ? 'E_KEY_DOC' : 'E_KEY_UNAVAILABLE';
|
|
214
|
+
into.hosts.push({ host: r.host, result: { ok: false, code, host: r.host, detail: String(r.detail ?? '') } });
|
|
215
|
+
}
|
|
216
|
+
return into;
|
|
217
|
+
}
|
|
218
|
+
if ('keys' in o || 'host' in o || 'proto' in o) {
|
|
219
|
+
const r = keysFromDocument(o);
|
|
220
|
+
if (r.host)
|
|
221
|
+
into.hosts.push({ host: r.host, result: r });
|
|
222
|
+
else
|
|
223
|
+
into.any.push({ ok: false, code: 'E_KEY_DOC', detail: `key document without a host: ${r.detail}`, keys: [] });
|
|
224
|
+
return into;
|
|
225
|
+
}
|
|
226
|
+
into.any.push(fromMap(o));
|
|
227
|
+
return into;
|
|
228
|
+
}
|
|
229
|
+
/** SPEC 10.1 step 5 for key reference host#id at time `issued`. */
|
|
230
|
+
function lookupKey(sources, host, id, issued) {
|
|
231
|
+
const h = sources.hosts.find((s) => s.host === host);
|
|
232
|
+
let keys;
|
|
233
|
+
let windows;
|
|
234
|
+
if (h) {
|
|
235
|
+
if (!h.result.ok)
|
|
236
|
+
return { ok: false, code: h.result.code, detail: `${host}: ${h.result.detail}` };
|
|
237
|
+
keys = h.result.keys ?? [];
|
|
238
|
+
windows = true;
|
|
239
|
+
}
|
|
240
|
+
else if (sources.any.length) {
|
|
241
|
+
const a = sources.any[0];
|
|
242
|
+
if (!a.ok)
|
|
243
|
+
return { ok: false, code: a.code, detail: a.detail };
|
|
244
|
+
keys = a.keys;
|
|
245
|
+
windows = false;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
return { ok: false, code: 'E_KEY_UNAVAILABLE', detail: `no key source covers ${host}` };
|
|
249
|
+
}
|
|
250
|
+
const key = keys.find((k) => k.id === id);
|
|
251
|
+
if (!key)
|
|
252
|
+
return { ok: false, code: 'E_KEY_UNKNOWN', detail: `${host} publishes no key ${id}` };
|
|
253
|
+
if (windows && !(key.valid_from <= issued && (key.valid_to === undefined || issued < key.valid_to))) {
|
|
254
|
+
return { ok: false, code: 'E_KEY_NOT_VALID', detail: `issued ${issued} is outside the window of key ${id}` };
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, key, windows };
|
|
257
|
+
}
|