From 42f7e55c7246e6f356e5e0e7067a2cb29105d899 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 30 Oct 2026 15:18:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(manager):=20=E5=8D=87=E7=BA=A7=20sm-crypto?= =?UTF-8?q?=20=E8=87=B3=200.5.8=20=E4=BF=AE=E5=A4=8D=20SM2=20=E5=BC=B1?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E6=95=B0=E6=BC=8F=E6=B4=9E=20(CVE-2026-73567?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-mobile/package.json | 2 +- .../patches/sm-crypto@0.5.8.patch | 28 +++ main/manager-mobile/pnpm-lock.yaml | 13 +- main/manager-mobile/pnpm-workspace.yaml | 2 +- .../src/pages/forgot-password/index.vue | 2 +- main/manager-mobile/src/pages/login/index.vue | 2 +- .../src/pages/register/index.vue | 2 +- main/manager-mobile/src/utils/index.ts | 33 +++- .../manager-mobile/src/utils/secure-random.ts | 170 ++++++++++++++++++ main/manager-web/package-lock.json | 8 +- main/manager-web/package.json | 2 +- 11 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 main/manager-mobile/patches/sm-crypto@0.5.8.patch create mode 100644 main/manager-mobile/src/utils/secure-random.ts diff --git a/main/manager-mobile/package.json b/main/manager-mobile/package.json index 7c85f0a69..b1e8af2e6 100644 --- a/main/manager-mobile/package.json +++ b/main/manager-mobile/package.json @@ -101,7 +101,7 @@ "js-cookie": "^3.0.5", "pinia": "2.0.36", "pinia-plugin-persistedstate": "3.2.1", - "sm-crypto": "^0.3.13", + "sm-crypto": "^0.5.8", "vue": "^3.4.21", "wot-design-uni": "^1.9.1", "z-paging": "2.8.7" diff --git a/main/manager-mobile/patches/sm-crypto@0.5.8.patch b/main/manager-mobile/patches/sm-crypto@0.5.8.patch new file mode 100644 index 000000000..0f79d7729 --- /dev/null +++ b/main/manager-mobile/patches/sm-crypto@0.5.8.patch @@ -0,0 +1,28 @@ +diff --git a/src/sm2/rng.js b/src/sm2/rng.js +index c6572c1756579fb100c0d7bdf0cec323971ec211..2ccb9f9c69b6b38b4a10d1b37dee1c50ccadb5f4 100644 +--- a/src/sm2/rng.js ++++ b/src/sm2/rng.js +@@ -55,7 +55,14 @@ function rngSeedTime() { + } + + // Initialize the pool with junk if needed. +-if (rngPool == null) { ++// [xiaozhi patch] 熵池初始化从模块加载时推迟到首次取随机字节时(见 rngGetByte)。 ++// 原因:微信小程序等环境没有同步的 crypto.getRandomValues,模块加载即取熵会直接抛异常; ++// 推迟后,调用方可在首次使用前异步准备好真随机源(见 manager-mobile/src/utils/secure-random.ts)。 ++// fail-closed 语义保持不变:首次取熵时仍要求存在可用的 crypto.getRandomValues,否则抛出异常。 ++function ensureRngPool() { ++ if (rngPool != null) ++ return ++ + rngPool = [] + rngPptr = 0 + let t +@@ -82,6 +89,7 @@ if (rngPool == null) { + + function rngGetByte() { + if (rngState == null) { ++ ensureRngPool() + rngSeedTime() + rngState = new Arcfour() + rngState.init(rngPool) diff --git a/main/manager-mobile/pnpm-lock.yaml b/main/manager-mobile/pnpm-lock.yaml index 3aa1fc476..39a9f1338 100644 --- a/main/manager-mobile/pnpm-lock.yaml +++ b/main/manager-mobile/pnpm-lock.yaml @@ -11,6 +11,9 @@ patchedDependencies: '@dcloudio/uni-h5': hash: 5763725268e9a493075be3c82d91b590b9ae30e997a04a418494dd7d6d327b9d path: patches/@dcloudio__uni-h5.patch + sm-crypto@0.5.8: + hash: 134528f8afad01074c99f1aca5fe3807eedd510809c6b845233cd81452f6177c + path: patches/sm-crypto@0.5.8.patch importers: @@ -92,8 +95,8 @@ importers: specifier: 3.2.1 version: 3.2.1(pinia@2.0.36(typescript@5.7.2)(vue@3.5.15(typescript@5.7.2))) sm-crypto: - specifier: ^0.3.13 - version: 0.3.14 + specifier: ^0.5.8 + version: 0.5.8(patch_hash=134528f8afad01074c99f1aca5fe3807eedd510809c6b845233cd81452f6177c) vue: specifier: ^3.4.21 version: 3.5.15(typescript@5.7.2) @@ -6026,8 +6029,8 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} - sm-crypto@0.3.14: - resolution: {integrity: sha512-sR7NuGAJH93Om8keAF2/rj1EqFzEiUey4aKGyDo3nQ2BiVqhgq8UkiIoIp5qhV9w4pG7qArhfqyewcG8AbhXbg==} + sm-crypto@0.5.8: + resolution: {integrity: sha512-bpz1/PthgZHcLHyIl28mbYZHmeuTDrdWpwHZ7BGzFjQuB7OOhjlbcPEMt44aqUfZ2S5YJXM8v20xYO1j9CC45Q==} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -14156,7 +14159,7 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 - sm-crypto@0.3.14: + sm-crypto@0.5.8(patch_hash=134528f8afad01074c99f1aca5fe3807eedd510809c6b845233cd81452f6177c): dependencies: jsbn: 1.1.0 diff --git a/main/manager-mobile/pnpm-workspace.yaml b/main/manager-mobile/pnpm-workspace.yaml index 82e3c6c16..c2b702da3 100644 --- a/main/manager-mobile/pnpm-workspace.yaml +++ b/main/manager-mobile/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - '**' - '!node_modules' - patchedDependencies: '@dcloudio/uni-h5': patches/@dcloudio__uni-h5.patch + sm-crypto@0.5.8: patches/sm-crypto@0.5.8.patch diff --git a/main/manager-mobile/src/pages/forgot-password/index.vue b/main/manager-mobile/src/pages/forgot-password/index.vue index 9bdd3034f..69f8a7cd2 100644 --- a/main/manager-mobile/src/pages/forgot-password/index.vue +++ b/main/manager-mobile/src/pages/forgot-password/index.vue @@ -235,7 +235,7 @@ async function handleResetPassword() { try { // 拼接图形验证码和新密码进行加密 const captchaAndPassword = formData.value.captcha + formData.value.newPassword - encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword) + encryptedPassword = await sm2Encrypt(sm2PublicKey.value, captchaAndPassword) } catch (error) { console.error('密码加密失败:', error) diff --git a/main/manager-mobile/src/pages/login/index.vue b/main/manager-mobile/src/pages/login/index.vue index f8fd6c0c0..5b15f1d42 100644 --- a/main/manager-mobile/src/pages/login/index.vue +++ b/main/manager-mobile/src/pages/login/index.vue @@ -202,7 +202,7 @@ async function handleLogin() { try { // 拼接验证码和密码 const captchaAndPassword = formData.value.captcha + formData.value.password - encryptedPassword = sm2Encrypt(sm2PublicKey, captchaAndPassword) + encryptedPassword = await sm2Encrypt(sm2PublicKey, captchaAndPassword) } catch (error) { console.error('密码加密失败:', error) diff --git a/main/manager-mobile/src/pages/register/index.vue b/main/manager-mobile/src/pages/register/index.vue index 0484b1378..d0959cd97 100644 --- a/main/manager-mobile/src/pages/register/index.vue +++ b/main/manager-mobile/src/pages/register/index.vue @@ -251,7 +251,7 @@ async function handleRegister() { try { // 拼接验证码和密码 const captchaAndPassword = formData.value.captcha + formData.value.password - encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword) + encryptedPassword = await sm2Encrypt(sm2PublicKey.value, captchaAndPassword) } catch (error) { console.error('密码加密失败:', error) diff --git a/main/manager-mobile/src/utils/index.ts b/main/manager-mobile/src/utils/index.ts index dbc3f8dcd..36bd75fae 100644 --- a/main/manager-mobile/src/utils/index.ts +++ b/main/manager-mobile/src/utils/index.ts @@ -2,6 +2,21 @@ import smCrypto from 'sm-crypto' import { pages, subPackages } from '@/pages.json' import { isMpWeixin } from './platform' +import { ensureSecureRandomReady } from './secure-random' + +/** + * 获取 sm2 模块(确保密码学安全随机源已就绪)。 + * + * sm-crypto >= 0.5.0(CVE-2026-73567 修复)强制要求 crypto.getRandomValues。 + * 小程序等环境没有该 API,项目中通过 pnpm patch(patches/sm-crypto@0.5.8.patch) + * 将 sm-crypto 的熵池初始化推迟到首次取随机字节时,并由 secure-random.ts 提供 + * 垫片。因此小程序端首次调用 sm2 前必须 await 本方法,用 wx.getRandomValues + * 预热真随机缓冲池;浏览器 / App WebView 原生支持时立即就绪。 + */ +async function getSm2(): Promise { + await ensureSecureRandomReady() + return smCrypto.sm2 +} /** * 运行时服务端地址覆盖存储键 @@ -201,11 +216,11 @@ export function getEnvBaseUploadUrl() { /** * 生成SM2密钥对(十六进制格式) - * @returns {object} 包含公钥和私钥的对象 + * @returns {Promise} 包含公钥和私钥的对象 */ -export function generateSm2KeyPairHex() { +export async function generateSm2KeyPairHex() { // 使用sm-crypto库生成SM2密钥对 - const sm2 = smCrypto.sm2 + const sm2 = await getSm2() const keypair = sm2.generateKeyPairHex() return { @@ -220,9 +235,9 @@ export function generateSm2KeyPairHex() { * SM2公钥加密 * @param {string} publicKey 公钥(十六进制格式) * @param {string} plainText 明文 - * @returns {string} 加密后的密文(十六进制格式) + * @returns {Promise} 加密后的密文(十六进制格式) */ -export function sm2Encrypt(publicKey: string, plainText: string): string { +export async function sm2Encrypt(publicKey: string, plainText: string): Promise { if (!publicKey) { throw new Error('公钥不能为null或undefined') } @@ -231,7 +246,7 @@ export function sm2Encrypt(publicKey: string, plainText: string): string { throw new Error('明文不能为空') } - const sm2 = smCrypto.sm2 + const sm2 = await getSm2() // SM2加密,添加04前缀表示未压缩公钥 const encrypted = sm2.doEncrypt(plainText, publicKey, 1) // 转换为十六进制格式(与后端保持一致,添加04前缀) @@ -244,10 +259,10 @@ export function sm2Encrypt(publicKey: string, plainText: string): string { * SM2私钥解密 * @param {string} privateKey 私钥(十六进制格式) * @param {string} cipherText 密文(十六进制格式) - * @returns {string} 解密后的明文 + * @returns {Promise} 解密后的明文(当前项目内暂无调用点,保留备用) */ -export function sm2Decrypt(privateKey: string, cipherText: string): string { - const sm2 = smCrypto.sm2 +export async function sm2Decrypt(privateKey: string, cipherText: string): Promise { + const sm2 = await getSm2() // 移除04前缀(与后端保持一致) const dataWithoutPrefix = cipherText.startsWith('04') ? cipherText.substring(2) : cipherText // SM2解密 diff --git a/main/manager-mobile/src/utils/secure-random.ts b/main/manager-mobile/src/utils/secure-random.ts new file mode 100644 index 000000000..1253b5c65 --- /dev/null +++ b/main/manager-mobile/src/utils/secure-random.ts @@ -0,0 +1,170 @@ +/** + * 为缺少 Web Crypto API 的运行环境(主要是微信小程序)提供 + * crypto.getRandomValues 兼容层,供 sm-crypto >= 0.5.0 使用。 + * + * 背景(CVE-2026-73567 / GHSA-vh45-f885-3848):sm-crypto 0.5.0 起强制要求 + * 密码学安全随机源 crypto.getRandomValues(仅在模块加载时取一次 32 字节熵, + * 之后不再重新播种),环境不支持时加载即抛异常,不会回退到不安全实现。 + * + * 小程序运行环境没有 window.crypto / globalThis.crypto,也没有同步的 + * CSPRNG API,因此本垫片的策略是: + * 1. 启动阶段通过平台提供的异步 CSPRNG API 获取真随机字节(64 字节分块、并行 + * 请求,规避单次长度上限),填入同步缓冲池。已知实现:微信 wx.getRandomValues; + * uni.getRandomValues 在 uni-app 运行时中仅有类型声明、无实现,故同时按 + * wx → uni → my → tt → swan → ks 顺序探测各平台全局对象,后续其他小程序 + * 平台若提供同名 API 可被自动覆盖; + * 2. 垫片的 getRandomValues 同步从缓冲池取字节,池不足时抛出异常 + * (fail-closed,绝不回退到 Math.random 等可预测源); + * 3. sm-crypto 的熵池初始化已通过 pnpm patch(patches/sm-crypto@0.5.8.patch) + * 推迟到首次取随机字节时,调用方只需在首次调用 sm2 前 await + * ensureSecureRandomReady(),即可保证其取到真随机熵。 + * + * 注意:若某平台既无 window.crypto 又无任何 CSPRNG API(如支付宝/百度等 + * 小程序当前普遍如此),ensureSecureRandomReady 会 reject,登录等加密流程 + * 将明确报错而非静默使用弱随机——这是 fail-closed 的预期行为。 + */ + +const CHUNK_SIZE = 64 +const CHUNK_COUNT = 8 +const LOW_WATER_MARK = 128 + +let pool: number[] = [] +let readyPromise: Promise | null = null +let refillInFlight = false + +function getGlobalObj(): Record | null { + // globalThis 在所有目标环境(现代浏览器 / 微信小程序 / Node)均存在; + // sm-crypto 读取的也是 globalThis.crypto,垫片必须装在同一个对象上 + if (typeof globalThis !== 'undefined') + return globalThis as unknown as Record + return null +} + +function hasNativeGetRandomValues(): boolean { + try { + return typeof window !== 'undefined' + && !!window.crypto + && typeof window.crypto.getRandomValues === 'function' + } + catch { + return false + } +} + +/** + * 按优先级探测当前平台提供异步 CSPRNG 的全局对象。 + * uni.getRandomValues 在 uni-app 运行时中无实现,仅为声明,因此 uni 放在 + * wx 之后仅作兜底;my/tt/swan/ks 等平台对象通过 globalThis 探测(未在 + * TS 类型中声明),若对应平台未来提供 getRandomValues 可自动适配。 + */ +function findRandomApi(): { name: string, api: any } | null { + const globalObj = getGlobalObj() + const candidates: Array<[string, any]> = [ + ['wx', typeof wx !== 'undefined' ? wx : null], + ['uni', typeof uni !== 'undefined' ? uni : null], + ['my', globalObj ? globalObj.my : null], + ['tt', globalObj ? globalObj.tt : null], + ['swan', globalObj ? globalObj.swan : null], + ['ks', globalObj ? globalObj.ks : null], + ] + for (const [name, api] of candidates) { + if (api && typeof api.getRandomValues === 'function') + return { name, api } + } + return null +} + +function fetchRandomChunk(length: number): Promise { + return new Promise((resolve, reject) => { + const found = findRandomApi() + if (!found) { + reject(new Error( + '当前平台不支持获取密码学安全随机数(getRandomValues),无法安全执行 SM2 加密。' + + '当前已适配:微信小程序(wx.getRandomValues)、浏览器、Node 环境;' + + '其余小程序/App 平台因缺少 CSPRNG API 被按 fail-closed 原则拒绝,' + + '如需支持请扩展 secure-random.ts 的 findRandomApi 适配链。', + )) + return + } + found.api.getRandomValues({ + length, + success: (res: { randomValues: ArrayBuffer | Uint8Array | number[] }) => { + resolve(new Uint8Array(res.randomValues)) + }, + fail: (err: unknown) => { + reject(err instanceof Error ? err : new Error(`getRandomValues 调用失败: ${JSON.stringify(err)}`)) + }, + }) + }) +} + +async function refillPool(totalLength: number): Promise { + const chunks = await Promise.all( + Array.from({ length: Math.ceil(totalLength / CHUNK_SIZE) }, () => fetchRandomChunk(CHUNK_SIZE)), + ) + for (const chunk of chunks) { + for (let i = 0; i < chunk.length; i++) + pool.push(chunk[i]) + } +} + +function scheduleRefill() { + if (refillInFlight) + return + refillInFlight = true + refillPool(256) + .catch(() => { + // 尽力而为的补充;缓冲池仍够用时静默失败,不足时由 getRandomValues 抛错兜底 + }) + .finally(() => { + refillInFlight = false + }) +} + +function installShim() { + const globalObj = getGlobalObj() + if (!globalObj) + throw new Error('无法获取全局对象,无法安装 crypto.getRandomValues 垫片') + + const existing = globalObj.crypto + if (existing && typeof existing.getRandomValues === 'function') + return + + globalObj.crypto = { + getRandomValues(array: T): T { + const bytes = array as unknown as Uint8Array + if (pool.length < bytes.length) { + scheduleRefill() + throw new Error('安全随机数缓冲池未就绪或已耗尽,请先等待 ensureSecureRandomReady() 完成') + } + for (let i = 0; i < bytes.length; i++) + bytes[i] = pool[i] + pool = pool.slice(bytes.length) + if (pool.length < LOW_WATER_MARK) + scheduleRefill() + return array + }, + } +} + +/** + * 确保密码学安全随机源可用(sm-crypto 加载前必须 await 本方法)。 + * - 浏览器 / App WebView 等原生支持 window.crypto 的环境:直接就绪; + * - 小程序等缺失环境:安装垫片并从系统 CSPRNG 预热缓冲池。 + * 不支持获取随机数的环境会 reject,由调用方 fail-closed 处理。 + */ +export function ensureSecureRandomReady(): Promise { + if (hasNativeGetRandomValues()) + return Promise.resolve() + if (!readyPromise) { + readyPromise = (async () => { + installShim() + await refillPool(CHUNK_SIZE * CHUNK_COUNT) + })().catch((err) => { + // 失败后重置,允许后续重试 + readyPromise = null + throw err + }) + } + return readyPromise +} diff --git a/main/manager-web/package-lock.json b/main/manager-web/package-lock.json index 9560a5507..0ae19091f 100644 --- a/main/manager-web/package-lock.json +++ b/main/manager-web/package-lock.json @@ -17,7 +17,7 @@ "normalize.css": "^8.0.1", "opus-decoder": "^0.7.7", "opus-recorder": "^8.0.5", - "sm-crypto": "^0.3.13", + "sm-crypto": "^0.5.8", "v-code-diff": "^1.16.0", "vue": "^2.6.14", "vue-axios": "^3.5.2", @@ -12485,9 +12485,9 @@ } }, "node_modules/sm-crypto": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.3.13.tgz", - "integrity": "sha512-ztNF+pZq6viCPMA1A6KKu3bgpkmYti5avykRHbcFIdSipFdkVmfUw2CnpM2kBJyppIalqvczLNM3wR8OQ0pT5w==", + "version": "0.5.8", + "resolved": "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.5.8.tgz", + "integrity": "sha512-bpz1/PthgZHcLHyIl28mbYZHmeuTDrdWpwHZ7BGzFjQuB7OOhjlbcPEMt44aqUfZ2S5YJXM8v20xYO1j9CC45Q==", "license": "MIT", "dependencies": { "jsbn": "^1.1.0" diff --git a/main/manager-web/package.json b/main/manager-web/package.json index 6e2ef3a73..30c904fb4 100644 --- a/main/manager-web/package.json +++ b/main/manager-web/package.json @@ -22,7 +22,7 @@ "normalize.css": "^8.0.1", "opus-decoder": "^0.7.7", "opus-recorder": "^8.0.5", - "sm-crypto": "^0.3.13", + "sm-crypto": "^0.5.8", "v-code-diff": "^1.16.0", "vue": "^2.6.14", "vue-axios": "^3.5.2",