fix(studio-host): 非安全上下文(明文IP http)整款游戏加载失败——crypto.subtle缺失退demo误报

根因:经局域网 IP 明文 http(如 http://100.64.0.7:4173)跨内网机访问时,浏览器判为
「非安全上下文」,window.crypto.subtle 为 undefined。inject.ts 的 sha256Hex 旧实现此时
直接抛错 → fetchAndVerifyManifest 完整性校验失败 → GamePlayer 退 demo 兜底,而 W-CLEAN 后
demo 是「模板体系重构中」并发 game_error → 页面误报「游戏加载失败/网络波动」(真因是上下文
不安全,非网络)。门禁此前经 localhost(安全上下文)走查,subtle 可用,故漏过此类上下文依赖缺陷。

修复:sha256Hex 改双通道——优先 Web Crypto SubtleCrypto(安全上下文,原生最快);缺失时回退
纯 JS SHA-256(FIPS 180-4,零依赖,确定性)。两通道对同一份 UTF-8 字节摘要,结果逐字节一致,
使 manifest 完整性校验在「明文 IP http」与「安全上下文」下行为一致,整款引擎游戏正常装载。
仅改 game-studio/src/host/inject.ts(+89/-12),不动契约/不放宽 CSP/不削弱完整性校验。

验证:
① 纯 JS SHA-256 对已知向量 + 跨块边界 + 真 manifest(214KB→456c38cb,与后端 checksum 及
   node crypto 三方一致)13/13 通过;
② npm run build -- --mode staging(vue-tsc -b 真类型门 + vite build)EXIT=0;
③ CDP 经 http://100.64.0.7:4173(实测 isSecureContext=false、crypto.subtle 缺失)深链
   /play/9306/93112:真 LittleJS 引擎装载、井字棋棋盘渲染、loading 收起、无 [GamePlayer]
   兜底 warn、无错误态——修复前同路径报「游戏加载失败」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-17 01:15:00 +00:00
parent 7de582e4c7
commit 4e486f60bc

View File

@ -314,22 +314,99 @@ function escapeBundleForInlineScript(bundle: string): string {
}
/**
* sha256hex Web Crypto SubtleCrypto/
* sha256hex
*
* ·
* 1) Web Crypto SubtleCryptohttps / localhost
* 2) subtle 退 JS SHA-256 IP http 访
* `http://100.64.0.7:4173` 访
* `crypto.subtle` undefined fetchAndVerifyManifest
* GamePlayer 退 demo W-CLEAN demo game_error
* /
* 退 JS IP http
* UTF-8 + manifest
*
* @param text manifest.json
* @returns 64 sha256 crypto.subtle
* @returns 64 sha256
*/
async function sha256Hex(text: string): Promise<string> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) {
// 非安全上下文(如 http 非 localhost无 subtle抛错由 GamePlayer 走 demo 兜底
throw new Error('crypto.subtle 不可用(需安全上下文 https/localhost');
}
// 统一以 UTF-8 编码为字节,保证两通道对同一输入摘要(与后端算 checksum 的字节口径一致)。
const bytes = new TextEncoder().encode(text);
const digest = await subtle.digest('SHA-256', bytes);
// ArrayBuffer → hex
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const subtle = globalThis.crypto?.subtle;
if (subtle) {
// 安全上下文:用原生 SubtleCrypto性能最优
const digest = await subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 非安全上下文http 非 localhostsubtle 缺失,回退纯 JS 实现(同步、零三方依赖)。
return sha256BytesToHex(bytes);
}
/**
* JS SHA-256FIPS 180-4 64 hex
* `crypto.subtle` 退
* 64 32 `>>> 0` SHA-256
* @param bytes UTF-8
*/
function sha256BytesToHex(bytes: Uint8Array): string {
// 32 位循环右旋。
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
// 轮常量 K前 64 个素数立方根小数部分前 32 位)。
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
// 初始哈希 H前 8 个素数平方根小数部分前 32 位)。
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
// 预处理:附加 1 字节 0x80补 0 至长度 ≡ 56 (mod 64),末尾 8 字节大端写入比特长度。
const l = bytes.length;
const bitLen = l * 8;
// 含 0x80 与 8 字节长度后向上取整到 64 的倍数(最小满足 ≥ l+1+8 的 64 倍数)。
const padded = (((l + 8) >> 6) + 1) << 6;
const m = new Uint8Array(padded);
m.set(bytes);
m[l] = 0x80;
const dv = new DataView(m.buffer);
// 比特长度高/低 32 位分写(>512MB 输入才需高位manifest 量级用不到,分写仅为完备)。大端。
dv.setUint32(padded - 8, Math.floor(bitLen / 0x100000000));
dv.setUint32(padded - 4, bitLen >>> 0);
const w = new Uint32Array(64);
for (let off = 0; off < padded; off += 64) {
// 取 16 个大端字进消息调度,扩展到 64 字。
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
// 64 轮压缩。
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K[i] + w[i]) >>> 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0;
}
// 8 个 32 位状态拼成 64 位 hex。
const toHex = (x: number): string => (x >>> 0).toString(16).padStart(8, '0');
return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7);
}
/** 相对 API 路径 resolve 到 VITE_API_BASE联调指向 staging绝对 URL 原样mock 模式空 base=同源。§3.4 C4 */
@ -365,7 +442,7 @@ async function buildManifestHeaders(): Promise<Record<string, string>> {
* @param manifestUrl manifest.json URLruntime
* @param expectedChecksum sha256hex checksum
* @returns GamePackage
* @throws / JSON / / checksum / crypto.subtle
* @throws / JSON / / checksum subtle JS
*/
export async function fetchAndVerifyManifest(
manifestUrl: string,