@factoidal/core 0.20.0 → 0.22.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 +82 -0
- package/README.md +38 -2
- package/authz/server.mjs +23 -2
- package/bin/engine.mjs +22 -1
- package/bin/factoidal.mjs +710 -38
- package/bin/pack-host.mjs +5 -0
- package/bin/serve.mjs +19 -3
- package/browser.d.ts +14 -0
- package/browser.js +16 -0
- package/index.d.ts +325 -9
- package/index.mjs +19 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +303 -12
- package/lib/browser-engine.mjs +28 -1
- package/lib/cid-node.mjs +77 -0
- package/lib/cid.mjs +146 -0
- package/lib/cipher-host.mjs +126 -0
- package/lib/engine-integrity.mjs +56 -0
- package/lib/errors.mjs +6 -0
- package/lib/nodes.mjs +234 -11
- package/lib/sbm11.mjs +14 -1
- package/lib/version.mjs +1 -1
- package/lws/README.md +9 -0
- package/lws/client.mjs +19 -2
- package/lws/persist.mjs +169 -0
- package/lws/server.mjs +82 -29
- package/package.json +2 -1
- package/solid/server/index.mjs +79 -13
- package/webdav/server.mjs +154 -25
- package/xmpp/client.mjs +332 -0
- package/xmpp/proxy.mjs +114 -0
package/lib/cid-node.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The Node and Deno host of cidRetrieveVerificationMethod (lib/cid.mjs,
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/1100 item 9): name resolution,
|
|
3
|
+
// and a GET that connects to the one address the engine chose, so a second
|
|
4
|
+
// answer of a name server cannot move the request (the authorization
|
|
5
|
+
// server's fetch, authz/server.mjs). Pure I/O: the engine decides which
|
|
6
|
+
// URL, which address, and what the answer means
|
|
7
|
+
// (formal/lean4/L4Factoidal/CID/Fetch.lean). No redirect is followed.
|
|
8
|
+
|
|
9
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
10
|
+
const MAX_DOCUMENT_BYTES = 1048576;
|
|
11
|
+
|
|
12
|
+
/** Every address of a name, as the system resolver gives them. */
|
|
13
|
+
async function resolve(host) {
|
|
14
|
+
try {
|
|
15
|
+
const dns = await import('node:dns');
|
|
16
|
+
const found = await dns.promises.lookup(host, { all: true, verbatim: true });
|
|
17
|
+
return found.map((a) => a.address);
|
|
18
|
+
} catch (_error) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A Response-like answer: status, headers.get, text().
|
|
24
|
+
function answer(status, contentType, body) {
|
|
25
|
+
return {
|
|
26
|
+
status,
|
|
27
|
+
headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? contentType : null) },
|
|
28
|
+
text: async () => body,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GET `url` from `init.address`, sending `init.headers`. With no address
|
|
34
|
+
* (the engine named none), the system resolver connects.
|
|
35
|
+
*/
|
|
36
|
+
async function fetch(url, init) {
|
|
37
|
+
let target;
|
|
38
|
+
try {
|
|
39
|
+
target = new URL(url);
|
|
40
|
+
} catch (_error) {
|
|
41
|
+
return answer(0, '', '');
|
|
42
|
+
}
|
|
43
|
+
const mod = target.protocol === 'https:' ? await import('node:https') : await import('node:http');
|
|
44
|
+
const address = init && typeof init.address === 'string' ? init.address : null;
|
|
45
|
+
const family = address && address.indexOf(':') >= 0 ? 6 : 4;
|
|
46
|
+
const options = { method: 'GET', headers: (init && init.headers) || {}, timeout: FETCH_TIMEOUT_MS };
|
|
47
|
+
if (address) {
|
|
48
|
+
options.lookup = (_host, opts, callback) => {
|
|
49
|
+
if (opts && opts.all) callback(null, [{ address, family }]);
|
|
50
|
+
else callback(null, address, family);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return new Promise((done) => {
|
|
54
|
+
const request = mod.request(target, options, (response) => {
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let size = 0;
|
|
57
|
+
response.on('data', (chunk) => {
|
|
58
|
+
size += chunk.length;
|
|
59
|
+
if (size > MAX_DOCUMENT_BYTES) {
|
|
60
|
+
response.destroy();
|
|
61
|
+
done(answer(0, '', ''));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
});
|
|
66
|
+
response.on('end', () => done(answer(response.statusCode || 0,
|
|
67
|
+
String(response.headers['content-type'] || ''), Buffer.concat(chunks).toString('utf8'))));
|
|
68
|
+
response.on('error', () => done(answer(0, '', '')));
|
|
69
|
+
});
|
|
70
|
+
request.on('timeout', () => request.destroy(new Error('timeout')));
|
|
71
|
+
request.on('error', () => done(answer(0, '', '')));
|
|
72
|
+
request.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The host index.mjs passes to the API. */
|
|
77
|
+
export const nodeCidHost = Object.freeze({ resolve, fetch });
|
package/lib/cid.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Controlled Identifiers v1.0 (https://www.w3.org/TR/cid-1.0/):
|
|
2
|
+
// cidValidate and cidRetrieveVerificationMethod
|
|
3
|
+
// (https://github.com/danbri/factoidal/issues/1100, items 7 and 9).
|
|
4
|
+
//
|
|
5
|
+
// The engine decides everything (formal/lean4/L4Factoidal/CID/Validate.lean,
|
|
6
|
+
// CID/Fetch.lean, over the ops cidValidate and cidRetrieve of
|
|
7
|
+
// formal/lean4/Wasm/Ops/Cid.lean): the §2 checks; which URL to fetch; whether
|
|
8
|
+
// its scheme and addresses may be fetched (the private-address guard of the
|
|
9
|
+
// authorization server); whether the status and the Content-Type of the
|
|
10
|
+
// answer are accepted; §3.3; expiry and revocation with `now`. This file
|
|
11
|
+
// moves bytes: it resolves the host name the engine names, GETs the URL the
|
|
12
|
+
// engine names from the address the engine names, and passes the status,
|
|
13
|
+
// the Content-Type and the body back.
|
|
14
|
+
//
|
|
15
|
+
// ES module, no Node built-ins: the browser loads this file as it is. The
|
|
16
|
+
// Node and Deno host functions (name resolution, a GET pinned to one
|
|
17
|
+
// address) are lib/cid-node.mjs, which index.mjs passes in.
|
|
18
|
+
|
|
19
|
+
import { FactoidalError } from './errors.mjs';
|
|
20
|
+
|
|
21
|
+
// The engine names at most one resolution and one fetch; a longer loop is
|
|
22
|
+
// a defect, not a document.
|
|
23
|
+
const MAX_STEPS = 4;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The browser host: no name resolution (a page cannot resolve names; the
|
|
27
|
+
* engine then checks only IP literals and localhost names), and the page's
|
|
28
|
+
* fetch, which does not follow redirects and sends no credentials.
|
|
29
|
+
*/
|
|
30
|
+
export const browserCidHost = Object.freeze({
|
|
31
|
+
resolve: null,
|
|
32
|
+
fetch: (url, init) => globalThis.fetch(url, {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers: init.headers,
|
|
35
|
+
redirect: 'manual',
|
|
36
|
+
credentials: 'omit',
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// The status, Content-Type and body of a fetch answer: a WHATWG Response,
|
|
41
|
+
// or an object of the same shape.
|
|
42
|
+
async function answerOf(url, response) {
|
|
43
|
+
const status = Number(response && response.status) || 0;
|
|
44
|
+
const headers = response && response.headers;
|
|
45
|
+
const contentType = headers && typeof headers.get === 'function'
|
|
46
|
+
? (headers.get('content-type') || '') : '';
|
|
47
|
+
const body = response && typeof response.text === 'function' ? String(await response.text()) : '';
|
|
48
|
+
return { url, status, contentType, body };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nowSeconds(now) {
|
|
52
|
+
if (now === undefined || now === null) return undefined;
|
|
53
|
+
if (now instanceof Date) return Math.floor(now.getTime() / 1000);
|
|
54
|
+
if (typeof now === 'number' && Number.isFinite(now)) return Math.floor(now);
|
|
55
|
+
throw new TypeError('cidRetrieveVerificationMethod: options.now must be a Date or a number of seconds');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The two functions over an entry (lib/lean-entry.mjs) and a host.
|
|
60
|
+
* @param {object} deps
|
|
61
|
+
* @param {() => Promise<object>} deps.entry
|
|
62
|
+
* @param {(jsonText: string, what: string) => object} deps.entryResult
|
|
63
|
+
* @param {(e: object, fn: string, what: string) => void} deps.requireEntryFn
|
|
64
|
+
* @param {{resolve: ((host: string) => Promise<string[]>)|null, fetch: Function}} [deps.host]
|
|
65
|
+
*/
|
|
66
|
+
export function cidFunctions({ entry, entryResult, requireEntryFn, host }) {
|
|
67
|
+
const defaults = host || browserCidHost;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* CID 1.0 §2: check a controlled identifier document.
|
|
71
|
+
* @param {string|object} doc JSON text, or a value JSON.stringify writes
|
|
72
|
+
* @returns {Promise<{valid: boolean, errors: Array<{code: string, message: string, path: string}>}>}
|
|
73
|
+
*/
|
|
74
|
+
async function cidValidate(doc) {
|
|
75
|
+
const text = typeof doc === 'string' ? doc : JSON.stringify(doc);
|
|
76
|
+
if (typeof text !== 'string') throw new TypeError('cidValidate: doc must be JSON text or a JSON value');
|
|
77
|
+
const e = await entry();
|
|
78
|
+
requireEntryFn(e, 'cidValidate', 'CID 1.0 document check');
|
|
79
|
+
const r = entryResult(e.cidValidate(text), 'cidValidate');
|
|
80
|
+
return { valid: r.valid === true, errors: Array.isArray(r.errors) ? r.errors : [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* CID 1.0 §3.3 Retrieve Verification Method, with the fetch done here.
|
|
85
|
+
* @param {string} url the verification method URL
|
|
86
|
+
* @param {string} relationship a verification relationship of §2.3
|
|
87
|
+
* @param {object} [options]
|
|
88
|
+
* @returns {Promise<{ok: true, verificationMethod: object, controllerDocument: string}
|
|
89
|
+
* | {ok: false, code: string, problem: {type: string, title: string, detail: string}}>}
|
|
90
|
+
*/
|
|
91
|
+
async function cidRetrieveVerificationMethod(url, relationship, options) {
|
|
92
|
+
if (typeof url !== 'string' || typeof relationship !== 'string') {
|
|
93
|
+
throw new TypeError('cidRetrieveVerificationMethod: url and relationship must be strings');
|
|
94
|
+
}
|
|
95
|
+
const o = options || {};
|
|
96
|
+
const fetchFn = typeof o.fetch === 'function' ? o.fetch : defaults.fetch;
|
|
97
|
+
// `resolve: null` says the caller cannot resolve names.
|
|
98
|
+
const resolveFn = 'resolve' in o ? o.resolve : defaults.resolve;
|
|
99
|
+
const request = {
|
|
100
|
+
vm: url,
|
|
101
|
+
relationship,
|
|
102
|
+
allowHttp: o.allowHttp === true,
|
|
103
|
+
allowPrivateFetch: o.allowPrivateFetch === true,
|
|
104
|
+
resolver: typeof resolveFn === 'function',
|
|
105
|
+
resolved: [],
|
|
106
|
+
fetched: [],
|
|
107
|
+
};
|
|
108
|
+
const now = nowSeconds(o.now);
|
|
109
|
+
if (now !== undefined) request.now = now;
|
|
110
|
+
const e = await entry();
|
|
111
|
+
requireEntryFn(e, 'cidRetrieve', 'CID 1.0 Retrieve Verification Method');
|
|
112
|
+
for (let i = 0; i < MAX_STEPS; i += 1) {
|
|
113
|
+
const r = entryResult(e.cidRetrieve(JSON.stringify(request)), 'cidRetrieve');
|
|
114
|
+
if (r.step === 'method') {
|
|
115
|
+
return { ok: true, verificationMethod: r.verificationMethod, controllerDocument: r.controllerDocument };
|
|
116
|
+
}
|
|
117
|
+
if (r.step === 'error') return { ok: false, code: r.code, problem: r.problem };
|
|
118
|
+
if (r.step === 'resolve') {
|
|
119
|
+
let addresses = [];
|
|
120
|
+
try {
|
|
121
|
+
const got = await resolveFn(r.host);
|
|
122
|
+
addresses = Array.isArray(got) ? got.map(String) : [];
|
|
123
|
+
} catch (_error) {
|
|
124
|
+
addresses = [];
|
|
125
|
+
}
|
|
126
|
+
request.resolved.push({ host: r.host, addresses });
|
|
127
|
+
} else if (r.step === 'fetch') {
|
|
128
|
+
let answer;
|
|
129
|
+
try {
|
|
130
|
+
const init = { method: 'GET', headers: { accept: r.accept }, redirect: 'manual' };
|
|
131
|
+
if (r.address !== null && r.address !== undefined) init.address = r.address;
|
|
132
|
+
answer = await answerOf(r.url, await fetchFn(r.url, init));
|
|
133
|
+
} catch (_error) {
|
|
134
|
+
answer = { url: r.url, status: 0, contentType: '', body: '' };
|
|
135
|
+
}
|
|
136
|
+
request.fetched.push(answer);
|
|
137
|
+
} else {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
throw new FactoidalError('cidRetrieveVerificationMethod: the engine asked for more steps than one retrieval needs',
|
|
142
|
+
{ code: 'op.failed' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { cidValidate, cidRetrieveVerificationMethod };
|
|
146
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// cipher-host.mjs — the JavaScript host side of the AES call-out
|
|
2
|
+
// (https://github.com/danbri/factoidal/issues/1081,
|
|
3
|
+
// https://github.com/danbri/factoidal/issues/677; design record
|
|
4
|
+
// docs/designissues/2026-10-27-xml-encryption-and-jwe.md, section "The AES
|
|
5
|
+
// call-out on JavaScript hosts"; request format
|
|
6
|
+
// formal/lean4/L4Factoidal/Crypto/CipherCall.lean).
|
|
7
|
+
//
|
|
8
|
+
// The engine (Lean, in the wasm) makes every decision: the algorithm, the key,
|
|
9
|
+
// the IV, the lengths (`Req.wellFormed`), the document structure. When it needs
|
|
10
|
+
// AES it answers `{need: {op, key, iv, aad, data}}` (bytes as base64). This
|
|
11
|
+
// module runs exactly that one WebCrypto operation and gives back
|
|
12
|
+
// `{ok: base64}` or `{refused: true}`. It reads no document, chooses no
|
|
13
|
+
// algorithm and checks no length. Every failure becomes the same refusal with
|
|
14
|
+
// no message, so no error text of the host's crypto library reaches the caller
|
|
15
|
+
// (padding oracle, Jager and Somorovsky 2011).
|
|
16
|
+
//
|
|
17
|
+
// Only `globalThis.crypto.subtle` is used, so the same code runs in Node 22,
|
|
18
|
+
// Deno and browsers (Node: OpenSSL; Chromium: BoringSSL; Firefox: NSS).
|
|
19
|
+
//
|
|
20
|
+
// PBKDF2 (https://github.com/danbri/factoidal/issues/1108, the login of
|
|
21
|
+
// `factoidal webdav-serve` and `factoidal webdav-user`; owner, 2026-10-28:
|
|
22
|
+
// PBKDF2-HMAC-SHA256 from WebCrypto, as AES): the engine answers
|
|
23
|
+
// `{need: {op: 'pbkdf2-hmac-sha256', key, data, iterations, bits}}` with the
|
|
24
|
+
// password as `key` and the salt as `data`. This module runs exactly that
|
|
25
|
+
// `deriveBits` call; Lean chose the salt, the count and the output size
|
|
26
|
+
// (formal/lean4/L4Factoidal/WebDAV/Login.lean) and compares the result.
|
|
27
|
+
//
|
|
28
|
+
// Key Wrap: WebCrypto wraps a CryptoKey, not bytes. The bytes to wrap are
|
|
29
|
+
// imported as an extractable raw HMAC key (any byte length), wrapped with
|
|
30
|
+
// `wrapKey('raw', ...)`; an unwrap gives an HMAC key whose raw export is the
|
|
31
|
+
// unwrapped bytes.
|
|
32
|
+
|
|
33
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
34
|
+
|
|
35
|
+
/** The most requests one engine operation may make (CipherCall.maxRequests). */
|
|
36
|
+
export const MAX_CIPHER_REQUESTS = 64;
|
|
37
|
+
|
|
38
|
+
function fromBase64(s) {
|
|
39
|
+
const bin = atob(s ?? '');
|
|
40
|
+
const out = new Uint8Array(bin.length);
|
|
41
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toBase64(buf) {
|
|
46
|
+
const b = new Uint8Array(buf);
|
|
47
|
+
let bin = '';
|
|
48
|
+
for (let i = 0; i < b.length; i += 0x8000) {
|
|
49
|
+
bin += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000));
|
|
50
|
+
}
|
|
51
|
+
return btoa(bin);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HMAC = { name: 'HMAC', hash: 'SHA-256' };
|
|
55
|
+
|
|
56
|
+
// One entry per op name of CipherCall.Op.name: the WebCrypto call, nothing more.
|
|
57
|
+
const OPS = {
|
|
58
|
+
'aes-gcm-encrypt': async (k, iv, aad, d) => {
|
|
59
|
+
const key = await subtle().importKey('raw', k, 'AES-GCM', false, ['encrypt']);
|
|
60
|
+
return subtle().encrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, d);
|
|
61
|
+
},
|
|
62
|
+
'aes-gcm-decrypt': async (k, iv, aad, d) => {
|
|
63
|
+
const key = await subtle().importKey('raw', k, 'AES-GCM', false, ['decrypt']);
|
|
64
|
+
return subtle().decrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, d);
|
|
65
|
+
},
|
|
66
|
+
'aes-kw-wrap': async (k, _iv, _aad, d) => {
|
|
67
|
+
const kek = await subtle().importKey('raw', k, 'AES-KW', false, ['wrapKey']);
|
|
68
|
+
const inner = await subtle().importKey('raw', d, HMAC, true, ['sign']);
|
|
69
|
+
return subtle().wrapKey('raw', inner, kek, 'AES-KW');
|
|
70
|
+
},
|
|
71
|
+
'aes-kw-unwrap': async (k, _iv, _aad, d) => {
|
|
72
|
+
const kek = await subtle().importKey('raw', k, 'AES-KW', false, ['unwrapKey']);
|
|
73
|
+
const inner = await subtle().unwrapKey('raw', d, kek, 'AES-KW', HMAC, true, ['sign']);
|
|
74
|
+
return subtle().exportKey('raw', inner);
|
|
75
|
+
},
|
|
76
|
+
'aes-cbc-encrypt': async (k, iv, _aad, d) => {
|
|
77
|
+
const key = await subtle().importKey('raw', k, 'AES-CBC', false, ['encrypt']);
|
|
78
|
+
return subtle().encrypt({ name: 'AES-CBC', iv }, key, d);
|
|
79
|
+
},
|
|
80
|
+
'aes-cbc-decrypt': async (k, iv, _aad, d) => {
|
|
81
|
+
const key = await subtle().importKey('raw', k, 'AES-CBC', false, ['decrypt']);
|
|
82
|
+
return subtle().decrypt({ name: 'AES-CBC', iv }, key, d);
|
|
83
|
+
},
|
|
84
|
+
'pbkdf2-hmac-sha256': async (k, _iv, _aad, d, req) => {
|
|
85
|
+
const key = await subtle().importKey('raw', k, 'PBKDF2', false, ['deriveBits']);
|
|
86
|
+
return subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: d, iterations: req.iterations }, key, req.bits);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Answer one cipher request of the engine (AES, or the PBKDF2 of a login).
|
|
92
|
+
* @param {{op: string, key: string, iv?: string, aad?: string, data: string, iterations?: number, bits?: number}} req bytes as base64
|
|
93
|
+
* @returns {Promise<{ok: string} | {refused: true}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function answerCipherRequest(req) {
|
|
96
|
+
try {
|
|
97
|
+
const run = Object.prototype.hasOwnProperty.call(OPS, req?.op) ? OPS[req.op] : null;
|
|
98
|
+
if (!run) return { refused: true };
|
|
99
|
+
const out = await run(fromBase64(req.key), fromBase64(req.iv), fromBase64(req.aad), fromBase64(req.data), req);
|
|
100
|
+
return { ok: toBase64(out) };
|
|
101
|
+
} catch {
|
|
102
|
+
return { refused: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run an engine operation that may ask for AES. `callOp(args)` calls the wasm
|
|
108
|
+
* op (sync or async) with `args` plus `answers`; while its result has `need`,
|
|
109
|
+
* the request is answered and the op is called again with the same inputs and
|
|
110
|
+
* the answer list extended by `{request, answer}`. The engine's format code is
|
|
111
|
+
* deterministic, so each call gets one request further.
|
|
112
|
+
* @param {(args: object) => any} callOp
|
|
113
|
+
* @param {object} args
|
|
114
|
+
*/
|
|
115
|
+
export async function runWithCipher(callOp, args) {
|
|
116
|
+
const answers = [];
|
|
117
|
+
for (let asked = 0; ; asked++) {
|
|
118
|
+
const res = await callOp({ ...args, answers: answers.slice() });
|
|
119
|
+
if (!res || res.need === undefined) return res;
|
|
120
|
+
// The request bound is the one check the host makes (design record step 4).
|
|
121
|
+
if (asked === MAX_CIPHER_REQUESTS) {
|
|
122
|
+
throw new Error(`runWithCipher: more than ${MAX_CIPHER_REQUESTS} cipher requests`);
|
|
123
|
+
}
|
|
124
|
+
answers.push({ request: res.need, answer: await answerCipherRequest(res.need) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The load-time check of the engine against its release stamp
|
|
2
|
+
// (https://github.com/danbri/factoidal/issues/1106, layer 1).
|
|
3
|
+
//
|
|
4
|
+
// A release stamps l4-assets/version.json with the SHA-256 and the size of
|
|
5
|
+
// l4factoidal.wasm (tools/stamp-l4-version.mjs). Before an engine is
|
|
6
|
+
// instantiated, its bytes are hashed and compared with the stamp: a stamped
|
|
7
|
+
// copy whose bytes differ does not load. An unstamped copy (a source
|
|
8
|
+
// checkout, a local build that did not stamp) loads as before.
|
|
9
|
+
//
|
|
10
|
+
// This catches accidents (a stale wasm next to a new loader, a partial
|
|
11
|
+
// upgrade, a hand edit). It is not a defence against someone who can edit
|
|
12
|
+
// the package: they can edit this file too. See issue 1106 for the checks
|
|
13
|
+
// made from outside the package.
|
|
14
|
+
//
|
|
15
|
+
// ES module, no Node built-ins: Node, Deno and the browser load it as it is.
|
|
16
|
+
// The digest is Web Crypto's SHA-256 (globalThis.crypto.subtle).
|
|
17
|
+
|
|
18
|
+
/** Lowercase hex SHA-256 of `bytes` (a Uint8Array or an ArrayBuffer). */
|
|
19
|
+
export async function sha256Hex(bytes) {
|
|
20
|
+
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
21
|
+
if (!subtle) throw new Error('factoidal: no Web Crypto SHA-256 to check the engine with');
|
|
22
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
|
|
23
|
+
let hex = '';
|
|
24
|
+
for (const b of digest) hex += b.toString(16).padStart(2, '0');
|
|
25
|
+
return hex;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check engine bytes against a version.json stamp.
|
|
30
|
+
* @param {Uint8Array|ArrayBuffer} bytes the wasm
|
|
31
|
+
* @param {object|null} stamp the parsed version.json, or null when absent
|
|
32
|
+
* @param {{where?: string, allowModified?: boolean, warn?: (msg: string) => void}} [opts]
|
|
33
|
+
* @returns {Promise<{checked: boolean, sha256?: string}>} `checked` false for
|
|
34
|
+
* an absent or unstamped version.json
|
|
35
|
+
*/
|
|
36
|
+
export async function checkEngineStamp(bytes, stamp, opts = {}) {
|
|
37
|
+
if (!stamp || stamp.stamped !== true || typeof stamp.wasmSha256 !== 'string') {
|
|
38
|
+
return { checked: false };
|
|
39
|
+
}
|
|
40
|
+
const size = bytes.byteLength;
|
|
41
|
+
const sha256 = await sha256Hex(bytes);
|
|
42
|
+
const sizeOk = !Number.isInteger(stamp.wasmBytes) || stamp.wasmBytes === size;
|
|
43
|
+
if (sha256 === stamp.wasmSha256.toLowerCase() && sizeOk) return { checked: true, sha256 };
|
|
44
|
+
const where = opts.where || 'l4factoidal.wasm';
|
|
45
|
+
const msg = `factoidal: ${where} is not the engine of this release: version.json ` +
|
|
46
|
+
`(build ${stamp.release ?? stamp.gitSha ?? '?'}) says sha256 ${stamp.wasmSha256}, ${stamp.wasmBytes ?? '?'} bytes; ` +
|
|
47
|
+
`the file has sha256 ${sha256}, ${size} bytes. Reinstall @factoidal/core, ` +
|
|
48
|
+
'or set FACTOIDAL_ALLOW_MODIFIED_ENGINE=1 to load a changed engine on purpose.';
|
|
49
|
+
if (opts.allowModified) {
|
|
50
|
+
(opts.warn || (() => {}))(`${msg} Loading it because FACTOIDAL_ALLOW_MODIFIED_ENGINE is set.`);
|
|
51
|
+
return { checked: false, sha256 };
|
|
52
|
+
}
|
|
53
|
+
const err = new Error(msg);
|
|
54
|
+
err.code = 'engine.modified';
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
package/lib/errors.mjs
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
* - `cause`: the underlying error, when there is one.
|
|
25
25
|
* - `format`, `formatInferred`: for a parse failure of text, the syntax it
|
|
26
26
|
* was read as and whether that syntax was inferred.
|
|
27
|
+
* - `qtError`: for xpath4Eval, xqueryEval and xslt4Transform, the error the
|
|
28
|
+
* XPath, XQuery or XSLT 4.0 specification names: `{code, namespace,
|
|
29
|
+
* message}` (for example code `XPST0003` in
|
|
30
|
+
* http://www.w3.org/2005/xqt-errors).
|
|
27
31
|
*/
|
|
28
32
|
export class FactoidalError extends Error {
|
|
29
33
|
constructor(message, info) {
|
|
@@ -35,6 +39,7 @@ export class FactoidalError extends Error {
|
|
|
35
39
|
this.spec = i.spec || null;
|
|
36
40
|
if (i.format !== undefined) this.format = i.format;
|
|
37
41
|
if (i.formatInferred !== undefined) this.formatInferred = i.formatInferred;
|
|
42
|
+
if (i.qtError !== undefined) this.qtError = i.qtError;
|
|
38
43
|
if (typeof Error.captureStackTrace === 'function') {
|
|
39
44
|
Error.captureStackTrace(this, FactoidalError);
|
|
40
45
|
}
|
|
@@ -81,6 +86,7 @@ export function errorFromEnvelope(env, text) {
|
|
|
81
86
|
spec: env && env.spec,
|
|
82
87
|
position,
|
|
83
88
|
format: env && env.format,
|
|
89
|
+
qtError: env && env.qtError && typeof env.qtError === 'object' ? env.qtError : undefined,
|
|
84
90
|
});
|
|
85
91
|
}
|
|
86
92
|
|