pi-courier 0.1.37 → 0.1.38
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/README.md
CHANGED
|
@@ -56,7 +56,7 @@ npm run build
|
|
|
56
56
|
npm link # make the `pi-courier` command available globally
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
**Do not use `--ignore-scripts`**: the Matrix E2EE library downloads its native binary via postinstall. On npm >= 11 the `allow-scripts` default may block that dependency's postinstall; `pi-courier`'s own postinstall self-checks for it and auto-downloads the missing native binary (one extra download on first install). If you still hit `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'` (e.g. the auto-download was skipped), run manually:
|
|
59
|
+
**Do not use `--ignore-scripts`**: the Matrix E2EE library downloads its native binary via postinstall. On npm >= 11 the `allow-scripts` default may block that dependency's postinstall; `pi-courier`'s own postinstall self-checks for it and auto-downloads the missing native binary (one extra download on first install; since 0.1.38 the binary is cached locally and sha256-verified, so later updates skip the 21 MB download and tampered binaries are refused). If you still hit `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'` (e.g. the auto-download was skipped), run manually:
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
cd node_modules/@matrix-org/matrix-sdk-crypto-nodejs
|
package/README.zh-CN.md
CHANGED
|
@@ -56,7 +56,7 @@ npm run build
|
|
|
56
56
|
npm link # 让 `pi-courier` 命令全局可用
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
**不要用 `--ignore-scripts`**:Matrix E2EE 库的 postinstall 会下载原生二进制。npm >= 11 的 `allow-scripts` 默认可能拦截该依赖的 postinstall;`pi-courier` 自己的 postinstall 会自检并**自动补下**缺失的原生二进制(
|
|
59
|
+
**不要用 `--ignore-scripts`**:Matrix E2EE 库的 postinstall 会下载原生二进制。npm >= 11 的 `allow-scripts` 默认可能拦截该依赖的 postinstall;`pi-courier` 自己的 postinstall 会自检并**自动补下**缺失的原生二进制(首次安装多下载一次;0.1.38 起副本缓存到本地并做 sha256 校验,后续更新免重下,校验不符的库会被拒绝加载)。若仍报 `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'`(比如自检被跳过),再手动补:
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
cd node_modules/@matrix-org/matrix-sdk-crypto-nodejs
|
package/package.json
CHANGED
|
@@ -4,6 +4,42 @@ export interface EnsureResult {
|
|
|
4
4
|
ok: boolean;
|
|
5
5
|
downloaded?: boolean;
|
|
6
6
|
reason?: string;
|
|
7
|
+
/** True when the binding was restored from the local cache (no download). */
|
|
8
|
+
restoredFromCache?: boolean;
|
|
9
|
+
/** How the binary's sha256 was checked: manifest match, TOFU accept, or rejected. */
|
|
10
|
+
verified?: "manifest" | "tofu" | "rejected";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Options for ensureCryptoNative(); all optional, defaulting to real resolution. */
|
|
14
|
+
export interface EnsureCryptoNativeOptions {
|
|
15
|
+
cryptoDir?: string | null;
|
|
16
|
+
basename?: string | null;
|
|
17
|
+
cacheRoot?: string;
|
|
18
|
+
manifestPath?: string;
|
|
19
|
+
runDownloader?: (cryptoDir: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Cache entry layout under the cache root. */
|
|
23
|
+
export interface CacheEntryPaths {
|
|
24
|
+
dir: string;
|
|
25
|
+
nodePath: string;
|
|
26
|
+
shaPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Result of evaluating a cache entry (readCacheEntry). */
|
|
30
|
+
export interface CacheEntryResult {
|
|
31
|
+
hit: boolean;
|
|
32
|
+
nodePath?: string;
|
|
33
|
+
sha256?: string;
|
|
34
|
+
verified?: "manifest" | "tofu";
|
|
35
|
+
reason?: "absent" | "unreadable" | "sidecar-missing" | "sidecar-mismatch" | "manifest-mismatch";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Verdict of verifyDownloadedBinding against the sha256 manifest. */
|
|
39
|
+
export interface VerifyDownloadedResult {
|
|
40
|
+
verdict: "manifest-match" | "manifest-mismatch" | "manifest-miss";
|
|
41
|
+
observed: string;
|
|
42
|
+
expected: string | null;
|
|
7
43
|
}
|
|
8
44
|
|
|
9
45
|
export function nativeBindingBasename(): string | null;
|
|
@@ -16,4 +52,51 @@ export function isNativeBindingMissing(cryptoDir: string, basename: string | nul
|
|
|
16
52
|
|
|
17
53
|
export function runNativeDownloader(cryptoDir: string): void;
|
|
18
54
|
|
|
19
|
-
export function
|
|
55
|
+
export function defaultCacheRoot(
|
|
56
|
+
env?: Record<string, string | undefined>,
|
|
57
|
+
home?: string
|
|
58
|
+
): string;
|
|
59
|
+
|
|
60
|
+
export function defaultManifestPath(): string;
|
|
61
|
+
|
|
62
|
+
export function cacheEntryPaths(
|
|
63
|
+
cacheRoot: string,
|
|
64
|
+
version: string,
|
|
65
|
+
basename: string
|
|
66
|
+
): CacheEntryPaths;
|
|
67
|
+
|
|
68
|
+
export function sha256FileSync(filePath: string): string;
|
|
69
|
+
|
|
70
|
+
export function readCryptoPackageVersion(cryptoDir: string): string | null;
|
|
71
|
+
|
|
72
|
+
/** Returns {} on missing/malformed manifest (warns); never throws. */
|
|
73
|
+
export function readManifest(manifestPath: string): Record<string, string>;
|
|
74
|
+
|
|
75
|
+
export function readCacheEntry(
|
|
76
|
+
cacheRoot: string,
|
|
77
|
+
version: string,
|
|
78
|
+
basename: string,
|
|
79
|
+
manifest: Record<string, string>
|
|
80
|
+
): CacheEntryResult;
|
|
81
|
+
|
|
82
|
+
export function writeCacheEntry(
|
|
83
|
+
cacheRoot: string,
|
|
84
|
+
version: string,
|
|
85
|
+
basename: string,
|
|
86
|
+
sourceNodePath: string,
|
|
87
|
+
observedSha256: string
|
|
88
|
+
): void;
|
|
89
|
+
|
|
90
|
+
export function restoreBindingFromCache(
|
|
91
|
+
cryptoDir: string,
|
|
92
|
+
basename: string,
|
|
93
|
+
cacheNodePath: string
|
|
94
|
+
): string;
|
|
95
|
+
|
|
96
|
+
export function verifyDownloadedBinding(
|
|
97
|
+
nodePath: string,
|
|
98
|
+
manifestKey: string | null,
|
|
99
|
+
manifest: Record<string, string>
|
|
100
|
+
): VerifyDownloadedResult;
|
|
101
|
+
|
|
102
|
+
export function ensureCryptoNative(options?: EnsureCryptoNativeOptions): EnsureResult;
|
|
@@ -18,15 +18,55 @@
|
|
|
18
18
|
* present before first run. Any failure here degrades to a warning; it never
|
|
19
19
|
* fails the install (the bridge can still run without E2EE crypto).
|
|
20
20
|
*
|
|
21
|
+
* LOCAL CACHE + SHA256 VERIFICATION (issue #48):
|
|
22
|
+
*
|
|
23
|
+
* Every `npm update` reinstalls the dependency tree and wipes the freshly
|
|
24
|
+
* downloaded .node, forcing a ~21MB re-download (≈1min behind slow proxies).
|
|
25
|
+
* To avoid that, a verified copy is kept under
|
|
26
|
+
* `$XDG_CACHE_HOME/pi-courier/native-crypto/<crypto version>/<basename>.node`
|
|
27
|
+
* (default `~/.cache/pi-courier/native-crypto/...`) with a `<basename>.sha256`
|
|
28
|
+
* sidecar recording the observed digest. When a later install finds the
|
|
29
|
+
* binding missing, the cache is consulted first (sidecar + manifest both
|
|
30
|
+
* verified before use) and restored with zero network traffic.
|
|
31
|
+
*
|
|
32
|
+
* Integrity: binaries are loaded by Node as native code, so a tampered
|
|
33
|
+
* download is arbitrary code execution. Every accepted binary's sha256 must
|
|
34
|
+
* match `scripts/crypto-native-hashes.json` (keyed `<version>/<basename>`).
|
|
35
|
+
* A listed version with a mismatching digest is DELETED and E2EE degrades —
|
|
36
|
+
* the binary is never loaded. An unlisted version is accepted TOFU-style with
|
|
37
|
+
* a loud warning asking the maintainer to record the digest. Cache entries
|
|
38
|
+
* whose sidecar disagrees with their bytes are treated as absent.
|
|
39
|
+
*
|
|
40
|
+
* All functions are pure-ish (explicit path/manifest params, injectable
|
|
41
|
+
* downloader) so tests can exercise every branch in temp dirs without network.
|
|
42
|
+
*
|
|
21
43
|
* Usage (from package root): node scripts/ensure-crypto-native.mjs
|
|
22
44
|
*/
|
|
23
45
|
|
|
24
46
|
import { execFileSync } from "node:child_process";
|
|
25
|
-
import {
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
import {
|
|
49
|
+
chmodSync,
|
|
50
|
+
copyFileSync,
|
|
51
|
+
existsSync,
|
|
52
|
+
mkdirSync,
|
|
53
|
+
readFileSync,
|
|
54
|
+
renameSync,
|
|
55
|
+
rmSync,
|
|
56
|
+
writeFileSync,
|
|
57
|
+
} from "node:fs";
|
|
26
58
|
import { createRequire } from "node:module";
|
|
59
|
+
import { homedir } from "node:os";
|
|
60
|
+
import { join } from "node:path";
|
|
61
|
+
import { fileURLToPath } from "node:url";
|
|
27
62
|
|
|
28
63
|
const require = createRequire(import.meta.url);
|
|
29
64
|
|
|
65
|
+
const LOG_PREFIX = "[pi-courier]";
|
|
66
|
+
const CACHE_SUBDIR = join("pi-courier", "native-crypto");
|
|
67
|
+
const MANIFEST_FILENAME = "crypto-native-hashes.json";
|
|
68
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
69
|
+
|
|
30
70
|
/** Current platform/arch names as napi-rs names them (no .node suffix). */
|
|
31
71
|
export function nativeBindingBasename() {
|
|
32
72
|
const { platform, arch } = process;
|
|
@@ -108,40 +148,328 @@ export function runNativeDownloader(cryptoDir) {
|
|
|
108
148
|
});
|
|
109
149
|
}
|
|
110
150
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Local cache + sha256 manifest (issue #48)
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** Cache root: $XDG_CACHE_HOME/pi-courier/native-crypto, else ~/.cache/… */
|
|
156
|
+
export function defaultCacheRoot(env = process.env, home = homedir()) {
|
|
157
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
158
|
+
const base = typeof xdg === "string" && xdg.trim() !== "" ? xdg : join(home, ".cache");
|
|
159
|
+
return join(base, CACHE_SUBDIR);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Manifest ships next to this script (package.json `files` covers scripts/). */
|
|
163
|
+
export function defaultManifestPath() {
|
|
164
|
+
return fileURLToPath(new URL(`./${MANIFEST_FILENAME}`, import.meta.url));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Cache layout: `<cacheRoot>/<version>/<basename>.node` + `<basename>.sha256`. */
|
|
168
|
+
export function cacheEntryPaths(cacheRoot, version, basename) {
|
|
169
|
+
const dir = join(cacheRoot, version);
|
|
170
|
+
const nodePath = join(dir, `${basename}.node`);
|
|
171
|
+
return { dir, nodePath, shaPath: join(dir, `${basename}.sha256`) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** sha256 of a file's bytes, lowercase hex. */
|
|
175
|
+
export function sha256FileSync(filePath) {
|
|
176
|
+
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The crypto package's own version (cache namespace), or null if unreadable. */
|
|
180
|
+
export function readCryptoPackageVersion(cryptoDir) {
|
|
181
|
+
try {
|
|
182
|
+
const pkg = JSON.parse(readFileSync(`${cryptoDir}/package.json`, "utf8"));
|
|
183
|
+
return typeof pkg.version === "string" && pkg.version !== "" ? pkg.version : null;
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read the sha256 manifest. Any problem (missing, malformed, wrong shape)
|
|
191
|
+
* degrades to an empty manifest — i.e. "nothing is listed", which makes the
|
|
192
|
+
* flow take the TOFU path instead of ever throwing during install.
|
|
193
|
+
*/
|
|
194
|
+
export function readManifest(manifestPath) {
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
200
|
+
console.warn(
|
|
201
|
+
`${LOG_PREFIX} crypto hash manifest unreadable at ${manifestPath} (${message}) — ` +
|
|
202
|
+
`treating every version as unlisted (TOFU).`
|
|
203
|
+
);
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
207
|
+
console.warn(
|
|
208
|
+
`${LOG_PREFIX} crypto hash manifest malformed at ${manifestPath} — ` +
|
|
209
|
+
`treating every version as unlisted (TOFU).`
|
|
210
|
+
);
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function manifestLookup(manifest, key) {
|
|
217
|
+
if (key === null) return { listed: false };
|
|
218
|
+
if (!Object.prototype.hasOwnProperty.call(manifest, key)) return { listed: false };
|
|
219
|
+
const expected = String(manifest[key]).trim().toLowerCase();
|
|
220
|
+
return { listed: true, expected: SHA256_HEX.test(expected) ? expected : null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function manifestNag(key, observedSha256) {
|
|
224
|
+
console.warn(
|
|
225
|
+
`${LOG_PREFIX} crypto binding ${key} is NOT in the sha256 manifest ` +
|
|
226
|
+
`(scripts/${MANIFEST_FILENAME}, observed sha256 ${observedSha256}) — ` +
|
|
227
|
+
`accepted this once (TOFU). ` +
|
|
228
|
+
`Please record that digest so future installs verify against it.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** tmp path for an atomic write; process-unique and random to survive races. */
|
|
233
|
+
function tmpPathFor(destPath) {
|
|
234
|
+
return `${destPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Write via temp file + atomic rename so an interrupt never leaves half a file. */
|
|
238
|
+
function atomicWrite(destPath, write, mode) {
|
|
239
|
+
const tmp = tmpPathFor(destPath);
|
|
240
|
+
try {
|
|
241
|
+
write(tmp);
|
|
242
|
+
chmodSync(tmp, mode);
|
|
243
|
+
renameSync(tmp, destPath);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
try {
|
|
246
|
+
rmSync(tmp, { force: true });
|
|
247
|
+
} catch {
|
|
248
|
+
// best effort — the tmp residue is inert
|
|
249
|
+
}
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Copy a verified .node into the cache: `<version>/<basename>.node` plus a
|
|
256
|
+
* `<basename>.sha256` sidecar with the observed digest. Dir 0700, files 0600,
|
|
257
|
+
* both written atomically (sidecar last: a torn write at worst drops the
|
|
258
|
+
* sidecar, and a cache entry without a sidecar is treated as absent).
|
|
259
|
+
*/
|
|
260
|
+
export function writeCacheEntry(cacheRoot, version, basename, sourceNodePath, observedSha256) {
|
|
261
|
+
const { dir, nodePath, shaPath } = cacheEntryPaths(cacheRoot, version, basename);
|
|
262
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
263
|
+
chmodSync(dir, 0o700); // mkdir mode is umask-filtered; pin it explicitly
|
|
264
|
+
atomicWrite(nodePath, (tmp) => copyFileSync(sourceNodePath, tmp), 0o600);
|
|
265
|
+
atomicWrite(shaPath, (tmp) => writeFileSync(tmp, `${observedSha256}\n`), 0o600);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Evaluate the cache entry for `<version>/<basename>` without using it yet.
|
|
270
|
+
*
|
|
271
|
+
* A hit requires: the .node exists, its sha256 matches the sidecar, AND (when
|
|
272
|
+
* the version is listed in the manifest) the manifest digest too. Anything
|
|
273
|
+
* else is a miss — the cached copy is not used but also not deleted here.
|
|
274
|
+
* An unlisted-but-self-consistent entry TOFU-hits with a warning (≤1 per run:
|
|
275
|
+
* this is called at most once per install).
|
|
276
|
+
*/
|
|
277
|
+
export function readCacheEntry(cacheRoot, version, basename, manifest) {
|
|
278
|
+
const { nodePath, shaPath } = cacheEntryPaths(cacheRoot, version, basename);
|
|
279
|
+
if (!existsSync(nodePath)) {
|
|
280
|
+
return { hit: false, reason: "absent" };
|
|
281
|
+
}
|
|
282
|
+
let observed;
|
|
283
|
+
try {
|
|
284
|
+
observed = sha256FileSync(nodePath);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
287
|
+
console.warn(`${LOG_PREFIX} could not hash cached crypto binding ${nodePath}: ${message}`);
|
|
288
|
+
return { hit: false, reason: "unreadable" };
|
|
289
|
+
}
|
|
290
|
+
let sidecar;
|
|
291
|
+
try {
|
|
292
|
+
sidecar = readFileSync(shaPath, "utf8").trim().toLowerCase();
|
|
293
|
+
} catch {
|
|
294
|
+
return { hit: false, reason: "sidecar-missing" };
|
|
295
|
+
}
|
|
296
|
+
if (!SHA256_HEX.test(sidecar) || sidecar !== observed) {
|
|
297
|
+
return { hit: false, reason: "sidecar-mismatch", observed };
|
|
298
|
+
}
|
|
299
|
+
const key = `${version}/${basename}`;
|
|
300
|
+
const listed = manifestLookup(manifest, key);
|
|
301
|
+
if (listed.listed) {
|
|
302
|
+
if (listed.expected === null || listed.expected !== observed) {
|
|
303
|
+
return { hit: false, reason: "manifest-mismatch", observed };
|
|
304
|
+
}
|
|
305
|
+
return { hit: true, nodePath, sha256: observed, verified: "manifest" };
|
|
306
|
+
}
|
|
307
|
+
manifestNag(key, observed);
|
|
308
|
+
return { hit: true, nodePath, sha256: observed, verified: "tofu" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Atomically copy a verified cache entry back into the crypto package dir. */
|
|
312
|
+
export function restoreBindingFromCache(cryptoDir, basename, cacheNodePath) {
|
|
313
|
+
const target = `${cryptoDir}/${basename}.node`;
|
|
314
|
+
atomicWrite(target, (tmp) => copyFileSync(cacheNodePath, tmp), 0o644);
|
|
315
|
+
return target;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Verify a freshly downloaded binding against the manifest. Pure verdict: the
|
|
320
|
+
* caller decides what to delete. Verdicts:
|
|
321
|
+
* manifest-match digest matches the manifest — accept.
|
|
322
|
+
* manifest-mismatch listed digest differs — caller must DELETE the artifact
|
|
323
|
+
* and degrade E2EE; the binary must never be loaded.
|
|
324
|
+
* manifest-miss version not listed — TOFU accept; caller warns.
|
|
325
|
+
*/
|
|
326
|
+
export function verifyDownloadedBinding(nodePath, manifestKey, manifest) {
|
|
327
|
+
const observed = sha256FileSync(nodePath);
|
|
328
|
+
const listed = manifestLookup(manifest, manifestKey);
|
|
329
|
+
if (!listed.listed) {
|
|
330
|
+
return { verdict: "manifest-miss", observed, expected: null };
|
|
331
|
+
}
|
|
332
|
+
if (listed.expected === null || listed.expected !== observed) {
|
|
333
|
+
return { verdict: "manifest-mismatch", observed, expected: listed.expected };
|
|
334
|
+
}
|
|
335
|
+
return { verdict: "manifest-match", observed, expected: listed.expected };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Orchestration. All inputs are injectable so tests run in temp dirs; the
|
|
340
|
+
* no-argument call keeps the exact pre-cache behaviour contract:
|
|
341
|
+
* `{ok, downloaded, reason?}` with failures warned, never thrown.
|
|
342
|
+
*
|
|
343
|
+
* @param {{
|
|
344
|
+
* cryptoDir?: string | null,
|
|
345
|
+
* basename?: string | null,
|
|
346
|
+
* cacheRoot?: string,
|
|
347
|
+
* manifestPath?: string,
|
|
348
|
+
* runDownloader?: (cryptoDir: string) => void,
|
|
349
|
+
* }} [options]
|
|
350
|
+
*/
|
|
351
|
+
export function ensureCryptoNative(options = {}) {
|
|
352
|
+
const {
|
|
353
|
+
cryptoDir = resolveCryptoPackageDir(),
|
|
354
|
+
basename = nativeBindingBasename(),
|
|
355
|
+
cacheRoot = defaultCacheRoot(),
|
|
356
|
+
manifestPath = defaultManifestPath(),
|
|
357
|
+
runDownloader = runNativeDownloader,
|
|
358
|
+
} = options;
|
|
114
359
|
|
|
115
360
|
if (!cryptoDir) {
|
|
116
361
|
console.warn(
|
|
117
|
-
|
|
362
|
+
`${LOG_PREFIX} crypto package not found — can't verify native binding; continuing (E2EE may be unavailable).`
|
|
118
363
|
);
|
|
119
364
|
return { ok: false, reason: "crypto-package-not-found" };
|
|
120
365
|
}
|
|
121
366
|
|
|
122
367
|
if (!isNativeBindingMissing(cryptoDir, basename)) {
|
|
123
368
|
// Already present (either downloaded once, or platform unsupported).
|
|
369
|
+
// Deliberately no post-hoc verification of an existing binding.
|
|
124
370
|
return { ok: true, downloaded: false };
|
|
125
371
|
}
|
|
126
372
|
|
|
373
|
+
const version = readCryptoPackageVersion(cryptoDir);
|
|
374
|
+
const manifest = readManifest(manifestPath);
|
|
375
|
+
const key = version !== null && basename !== null ? `${version}/${basename}` : null;
|
|
376
|
+
|
|
377
|
+
// 1) Warm cache: restore a verified copy without touching the network.
|
|
378
|
+
if (key !== null) {
|
|
379
|
+
let entry;
|
|
380
|
+
try {
|
|
381
|
+
entry = readCacheEntry(cacheRoot, version, basename, manifest);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
384
|
+
console.warn(`${LOG_PREFIX} cache lookup failed: ${message} — falling back to download.`);
|
|
385
|
+
entry = { hit: false };
|
|
386
|
+
}
|
|
387
|
+
if (entry.hit) {
|
|
388
|
+
try {
|
|
389
|
+
restoreBindingFromCache(cryptoDir, basename, entry.nodePath);
|
|
390
|
+
console.info(
|
|
391
|
+
`${LOG_PREFIX} crypto native binding (${basename}.node) restored from local cache ` +
|
|
392
|
+
`(${key}, sha256 ${entry.sha256}) — no download needed.`
|
|
393
|
+
);
|
|
394
|
+
return { ok: true, downloaded: false, restoredFromCache: true, verified: entry.verified };
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
+
console.warn(
|
|
398
|
+
`${LOG_PREFIX} could not restore crypto binding from cache: ${message} — falling back to download.`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 2) Cache miss: run the upstream downloader (inherits proxy env).
|
|
127
405
|
console.info(
|
|
128
|
-
|
|
406
|
+
`${LOG_PREFIX} crypto native binding (${basename}.node) missing — downloading (POSTINSTALL due to npm 11 allow-scripts).`
|
|
129
407
|
);
|
|
130
408
|
try {
|
|
131
|
-
|
|
132
|
-
return { ok: true, downloaded: true };
|
|
409
|
+
runDownloader(cryptoDir);
|
|
133
410
|
} catch (err) {
|
|
134
411
|
const message = err instanceof Error ? err.message : String(err);
|
|
135
412
|
console.warn(
|
|
136
|
-
|
|
413
|
+
`${LOG_PREFIX} could not auto-download crypto native binding: ${message}\n` +
|
|
137
414
|
` The bridge may still run without E2EE. To install manually:\n` +
|
|
138
415
|
` cd ${cryptoDir} && node download-lib.js`
|
|
139
416
|
);
|
|
140
417
|
return { ok: false, reason: "download-failed" };
|
|
141
418
|
}
|
|
419
|
+
|
|
420
|
+
// 3) Verify the downloaded binary before accepting it (or caching it).
|
|
421
|
+
const nodePath = `${cryptoDir}/${basename}.node`;
|
|
422
|
+
let check;
|
|
423
|
+
try {
|
|
424
|
+
check = verifyDownloadedBinding(nodePath, key, manifest);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
427
|
+
console.warn(
|
|
428
|
+
`${LOG_PREFIX} could not verify downloaded crypto binding (${nodePath}): ${message} — ` +
|
|
429
|
+
`continuing without it (E2EE may be unavailable).`
|
|
430
|
+
);
|
|
431
|
+
return { ok: false, reason: "verify-failed" };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (check.verdict === "manifest-mismatch") {
|
|
435
|
+
// Never load an unverified native binary. Delete and degrade.
|
|
436
|
+
try {
|
|
437
|
+
rmSync(nodePath, { force: true });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
440
|
+
console.warn(`${LOG_PREFIX} could not delete the unverified binding: ${message}`);
|
|
441
|
+
}
|
|
442
|
+
console.warn(
|
|
443
|
+
`${LOG_PREFIX} downloaded crypto binding ${key} FAILED sha256 verification ` +
|
|
444
|
+
`(expected ${check.expected}, observed ${check.observed}) — file deleted, ` +
|
|
445
|
+
`continuing WITHOUT native E2EE crypto (the bridge still runs, encrypted ` +
|
|
446
|
+
`rooms will be unreadable). Possible tampering or upstream asset change.`
|
|
447
|
+
);
|
|
448
|
+
return { ok: false, reason: "hash-mismatch", verified: "rejected" };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (check.verdict === "manifest-miss" && key !== null) {
|
|
452
|
+
manifestNag(key, check.observed);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 4) Best-effort: stash the verified copy for future installs.
|
|
456
|
+
if (key !== null) {
|
|
457
|
+
try {
|
|
458
|
+
writeCacheEntry(cacheRoot, version, basename, nodePath, check.observed);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
461
|
+
console.warn(`${LOG_PREFIX} could not cache the crypto binding: ${message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
downloaded: true,
|
|
468
|
+
verified: check.verdict === "manifest-match" ? "manifest" : "tofu",
|
|
469
|
+
};
|
|
142
470
|
}
|
|
143
471
|
|
|
144
472
|
// Allow running both as a bin and importing the named exports for tests.
|
|
145
473
|
if (process.argv[1] && process.argv[1].endsWith("ensure-crypto-native.mjs")) {
|
|
146
474
|
ensureCryptoNative();
|
|
147
|
-
}
|
|
475
|
+
}
|