Files
SnowCatQQ/packages/snowcat-webui-backend/src/helper/SignToken.ts
T
SnowCat Security Bot 6f9d3cdb0f fix(security): SignToken 密钥与密码哈希加固
1. HMAC 密钥: 未配置 NAPCAT_WEBUI_JWT_SECRET_KEY 时不再回落 Math.random()
   (可预测密钥=凭证可伪造), 改用 crypto.randomBytes(32) 强随机并稳定缓存。
   行为变化: 未配置密钥时重启后所有已签发凭证失效, 用户需重新登录。
2. 密码哈希: generatePasswordHash 升级为 scrypt(随机盐16B, N=16384,r=8,p=1),
   存储格式 scrypt$<saltHex>$<hashHex>。
3. comparePasswordHash 双格式兼容: 新格式逐盐 scrypt 校验; 旧格式(单轮
   SHA-256 password+'.napcat')仍可校验通过, 保证存量凭证不失效、不丢功能语义。
   建议用户改密码一次即迁移到 scrypt。
4. 凭证比较统一走 timingSafeEqual, 消除逐字节短路时序侧信道。
2026-11-13 03:33:30 +08:00

150 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
// 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 {
return AuthHelper.secretKey;
}
/**
* 签名凭证方法。
* @param hash 待签名的凭证字符串。
* @returns 签名后的凭证对象。
*/
public static signCredential (hash: string): WebUiCredentialJson {
const innerJson: WebUiCredentialInnerJson = {
// 统一使用秒级时间戳,避免与校验逻辑出现单位不一致
CreatedTime: Math.floor(Date.now() / 1000),
HashEncoded: hash,
};
const jsonString = JSON.stringify(innerJson);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return { Data: innerJson, Hmac: hmac };
}
/**
* 检查凭证是否被篡改的方法。
* @param credentialJson 凭证的JSON对象。
* @returns 布尔值,表示凭证是否有效。
*/
public static checkCredential (credentialJson: WebUiCredentialJson): boolean {
try {
const jsonString = JSON.stringify(credentialJson.Data);
const calculatedHmac = crypto
.createHmac('sha256', AuthHelper.secretKey)
.update(jsonString, 'utf8')
.digest('hex');
return safeEqual(calculatedHmac, credentialJson.Hmac);
} catch (_error) {
return false;
}
}
/**
* 验证凭证在1小时内有效且token与原始token相同。
* @param token 待验证的原始token。
* @param credentialJson 已签名的凭证JSON对象。
* @returns 布尔值,表示凭证是否有效且token匹配。
*/
public static validateCredentialWithinOneHour (token: string, credentialJson: WebUiCredentialJson): boolean {
// 首先检查凭证是否被篡改
const isValid = AuthHelper.checkCredential(credentialJson);
if (!isValid) {
return false;
}
// 检查凭证是否在黑名单中
if (AuthHelper.isCredentialRevoked(credentialJson)) {
return false;
}
const currentTimeSeconds = Math.floor(Date.now() / 1000);
const createdTimeSeconds = Math.floor(Number(credentialJson.Data.CreatedTime));
const timeDifferenceSeconds = currentTimeSeconds - createdTimeSeconds;
const isWithinOneHour = timeDifferenceSeconds >= 0 && timeDifferenceSeconds <= AuthHelper.MAX_CREDENTIAL_VALID_SECONDS;
return isWithinOneHour && safeEqual(credentialJson.Data.HashEncoded, AuthHelper.generatePasswordHash(token));
}
/**
* 注销指定的Token凭证
* @param credentialJson 凭证JSON对象
* @returns void
*/
public static revokeCredential (credentialJson: WebUiCredentialJson): void {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
// 将已注销的凭证添加到黑名单中,有效期1小时
store.set(`revoked:${hmac}`, true, 3600);
}
/**
* 检查凭证是否已被注销
* @param credentialJson 凭证JSON对象
* @returns 布尔值,表示凭证是否已被注销
*/
public static isCredentialRevoked (credentialJson: WebUiCredentialJson): boolean {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return store.exists(`revoked:${hmac}`) > 0;
}
/**
* 生成密码Hash(scrypt,随机盐,N=16384 r=8 p=1)。
* 存储格式: scrypt$<saltHex>$<hashHex>
* @param password 密码
* @returns 生成的Hash值(含盐与参数,可自校验)
*/
public static generatePasswordHash (password: string): string {
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值。
* 兼容两种输入:
* - 新格式 scrypt$<saltHex>$<hashHex>(逐盐校验,恒定时间比较)
* - 旧格式 单轮 SHA-256 hex(历史凭证,仅供校验,不用于生成)
* @param password 密码
* @param hash Hash值
* @returns 布尔值,表示密码是否匹配Hash值
*/
public static comparePasswordHash (password: string, hash: string): boolean {
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);
}
}