From 6f9d3cdb0f530fb45f1464c27920c88632491aec Mon Sep 17 00:00:00 2001 From: SnowCat Security Bot Date: Fri, 13 Nov 2026 03:33:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20SignToken=20=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E4=B8=8E=E5=AF=86=E7=A0=81=E5=93=88=E5=B8=8C=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. HMAC 密钥: 未配置 NAPCAT_WEBUI_JWT_SECRET_KEY 时不再回落 Math.random() (可预测密钥=凭证可伪造), 改用 crypto.randomBytes(32) 强随机并稳定缓存。 行为变化: 未配置密钥时重启后所有已签发凭证失效, 用户需重新登录。 2. 密码哈希: generatePasswordHash 升级为 scrypt(随机盐16B, N=16384,r=8,p=1), 存储格式 scrypt$$。 3. comparePasswordHash 双格式兼容: 新格式逐盐 scrypt 校验; 旧格式(单轮 SHA-256 password+'.napcat')仍可校验通过, 保证存量凭证不失效、不丢功能语义。 建议用户改密码一次即迁移到 scrypt。 4. 凭证比较统一走 timingSafeEqual, 消除逐字节短路时序侧信道。 --- .../src/helper/SignToken.ts | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/snowcat-webui-backend/src/helper/SignToken.ts b/packages/snowcat-webui-backend/src/helper/SignToken.ts index 6888ad8..ec38e7c 100644 --- a/packages/snowcat-webui-backend/src/helper/SignToken.ts +++ b/packages/snowcat-webui-backend/src/helper/SignToken.ts @@ -1,8 +1,25 @@ import crypto from 'crypto'; +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto'; import store from 'snowcat-common/src/store'; import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/snowcat-webui-backend/src/types'; + +// 常量时间字符串比较,避免逐字节短路造成的时序侧信道 +function safeEqual (a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + export class AuthHelper { - private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2); + // JWT/HMAC 签名密钥:优先环境变量;未配置时用 crypto 强随机生成并稳定缓存, + // 不再回落 Math.random()(可预测密钥=凭证可伪造)。 + // 注意:随机密钥在进程重启后失效,所有已签发凭证随之失效,用户需重新登录。 + private static readonly secretKey: string = (() => { + const envKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY']; + if (envKey && envKey.length >= 16) return envKey; + return crypto.randomBytes(32).toString('hex'); + })(); private static readonly MAX_CREDENTIAL_VALID_SECONDS = 3600; public static getSecretKey (): string { @@ -37,7 +54,7 @@ export class AuthHelper { .createHmac('sha256', AuthHelper.secretKey) .update(jsonString, 'utf8') .digest('hex'); - return calculatedHmac === credentialJson.Hmac; + return safeEqual(calculatedHmac, credentialJson.Hmac); } catch (_error) { return false; } @@ -65,7 +82,7 @@ export class AuthHelper { const createdTimeSeconds = Math.floor(Number(credentialJson.Data.CreatedTime)); const timeDifferenceSeconds = currentTimeSeconds - createdTimeSeconds; const isWithinOneHour = timeDifferenceSeconds >= 0 && timeDifferenceSeconds <= AuthHelper.MAX_CREDENTIAL_VALID_SECONDS; - return isWithinOneHour && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token); + return isWithinOneHour && safeEqual(credentialJson.Data.HashEncoded, AuthHelper.generatePasswordHash(token)); } /** @@ -94,21 +111,39 @@ export class AuthHelper { } /** - * 生成密码Hash + * 生成密码Hash(scrypt,随机盐,N=16384 r=8 p=1)。 + * 存储格式: scrypt$$ * @param password 密码 - * @returns 生成的Hash值 + * @returns 生成的Hash值(含盐与参数,可自校验) */ public static generatePasswordHash (password: string): string { - return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex'); + const salt = randomBytes(16); + const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; } /** - * 对比密码和Hash值 + * 对比密码和Hash值。 + * 兼容两种输入: + * - 新格式 scrypt$$(逐盐校验,恒定时间比较) + * - 旧格式 单轮 SHA-256 hex(历史凭证,仅供校验,不用于生成) * @param password 密码 * @param hash Hash值 * @returns 布尔值,表示密码是否匹配Hash值 */ public static comparePasswordHash (password: string, hash: string): boolean { - return this.generatePasswordHash(password) === hash; + if (hash.startsWith('scrypt$')) { + const parts = hash.split('$'); + if (parts.length !== 3) return false; + const salt = Buffer.from(parts[1] ?? '', 'hex'); + const expected = Buffer.from(parts[2] ?? '', 'hex'); + if (salt.length !== 16 || expected.length !== 32) return false; + const actual = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return timingSafeEqual(actual, expected); + } + // 兼容历史数据:旧版单轮 SHA-256(password + '.napcat') + // 老凭证仍可通过校验(不丢功能语义),升级后建议改密码以迁移到 scrypt。 + const legacy = crypto.createHash('sha256').update(password + '.napcat').digest('hex'); + return safeEqual(legacy, hash); } }