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:
parent
7de582e4c7
commit
4e486f60bc
@ -314,22 +314,99 @@ function escapeBundleForInlineScript(bundle: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串的 sha256(hex 小写)。用 Web Crypto SubtleCrypto(浏览器/安全上下文可用)。
|
||||
* 计算字符串的 sha256(hex 小写)。
|
||||
*
|
||||
* 【双通道·修复「非安全上下文整款游戏加载失败」】
|
||||
* 1) 优先 Web Crypto SubtleCrypto(https / 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 非 localhost):subtle 缺失,回退纯 JS 实现(同步、零三方依赖)。
|
||||
return sha256BytesToHex(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯 JS SHA-256(FIPS 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 直取 URL(runtime 清单回填)
|
||||
* @param expectedChecksum 期望的整包 sha256(hex,来自运行包清单 checksum)
|
||||
* @returns 校验通过的 GamePackage
|
||||
* @throws 网络失败 / 非 JSON / 结构非法 / checksum 不匹配 / 无 crypto.subtle
|
||||
* @throws 网络失败 / 非 JSON / 结构非法 / checksum 不匹配(subtle 缺失时由纯 JS 兜底,不再因上下文不安全而抛)
|
||||
*/
|
||||
export async function fetchAndVerifyManifest(
|
||||
manifestUrl: string,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user