@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.
@@ -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