fix(manager): 升级 sm-crypto 至 0.5.8 修复 SM2 弱随机数漏洞 (CVE-2026-73567)

This commit is contained in:
Sakura-RanChen committed 2026-10-30 15:18:18 +08:00
1 parent 1a095d5bed
commit 42f7e55c72
11 files changed
+240 -24

No files matched your search

+1 -1
View File
@@ -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"
@@ -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)
+8 -5
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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)
@@ -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)
@@ -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)
+24 -9
View File
@@ -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<any> {
await ensureSecureRandomReady()
return smCrypto.sm2
}
/**
* 运行时服务端地址覆盖存储键
@@ -201,11 +216,11 @@ export function getEnvBaseUploadUrl() {
/**
* 生成SM2密钥对(十六进制格式)
* @returns {object} 包含公钥和私钥的对象
* @returns {Promise<object>} 包含公钥和私钥的对象
*/
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<string>} 加密后的密文(十六进制格式)
*/
export function sm2Encrypt(publicKey: string, plainText: string): string {
export async function sm2Encrypt(publicKey: string, plainText: string): Promise<string> {
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<string>} 解密后的明文(当前项目内暂无调用点,保留备用)
*/
export function sm2Decrypt(privateKey: string, cipherText: string): string {
const sm2 = smCrypto.sm2
export async function sm2Decrypt(privateKey: string, cipherText: string): Promise<string> {
const sm2 = await getSm2()
// 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith('04') ? cipherText.substring(2) : cipherText
// SM2解密
@@ -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<void> | null = null
let refillInFlight = false
function getGlobalObj(): Record<string, any> | null {
// globalThis 在所有目标环境(现代浏览器 / 微信小程序 / Node)均存在;
// sm-crypto 读取的也是 globalThis.crypto,垫片必须装在同一个对象上
if (typeof globalThis !== 'undefined')
return globalThis as unknown as Record<string, any>
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<Uint8Array> {
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<void> {
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<T extends ArrayBufferView>(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<void> {
if (hasNativeGetRandomValues())
return Promise.resolve()
if (!readyPromise) {
readyPromise = (async () => {
installShim()
await refillPool(CHUNK_SIZE * CHUNK_COUNT)
})().catch((err) => {
// 失败后重置,允许后续重试
readyPromise = null
throw err
})
}
return readyPromise
}
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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",