基于 AES 的对称加密类,用于本地存储数据的混淆保护
import { AesCrypto } from '@skyroc/utils/crypto';AesCrypto<T> 是一个泛型加密类,基于 crypto-js 的 AES 实现。它将任意可 JSON 序列化的对象加密为字符串,并支持还原回原始类型——常用于在 localStorage 或 sessionStorage 中存储 token、用户信息等数据时的混淆层。
它不是安全边界。 crypto-js 的 passphrase 模式走的是 OpenSSL EVP_BytesToKey(MD5 单轮派生),
密钥强度取决于口令本身,不适合用来保护真正的机密。 它的合理用途是本地存储混淆——让 localStorage /
缓存里的内容不可直接肉眼读取。 需要真实加密强度时请改用 Web Crypto(crypto.subtle)或服务端加密; 接口传输加密用
@skyroc/service 的信封加密。
它只在 @skyroc/utils/crypto 子入口导出,不在主入口——不用加密的应用不必把 crypto-js 拉进包里。
import { AesCrypto } from '@skyroc/utils/crypto';
// 定义要加密的数据类型
type TokenPayload = {
accessToken: string;
refreshToken: string;
expiresAt: number;
};
// 实例化,传入密钥
const crypto = new AesCrypto<TokenPayload>('your-secret-key');
// 加密
const cipher = crypto.encrypt({
accessToken: 'eyJhbGci...',
refreshToken: 'dGhpcyBp...',
expiresAt: Date.now() + 3600_000
});
// 'U2FsdGVkX1+...'(密文字符串,可安全存储)
// 解密
const payload = crypto.decrypt(cipher);
// { accessToken: 'eyJhbGci...', refreshToken: 'dGhpcyBp...', expiresAt: 1745... }
// 或 null(解密失败时)加密流程:
T(对象)→ JSON.stringify → 明文字符串 → AES.encrypt(key) → 密文字符串
解密流程:
密文字符串 → AES.decrypt(key) → 明文字符串 → JSON.parse → T(对象)
解密过程发生任何错误 → 捕获异常 → 返回 nullAES 采用 crypto-js 默认模式(CBC + PKCS7 填充),密钥长度取决于传入字符串的哈希派生结果(crypto-js 内部使用 MD5 派生密钥)。
new AesCrypto<T>(secret)const crypto = new AesCrypto<MyDataType>('your-secret-key');| 参数 | 类型 | 说明 |
|---|---|---|
secret | string | 加密密钥,建议使用足够复杂的字符串,不要硬编码在代码中 |
secret 是 private readonly 的,构造之后不可更换。需要换密钥就新建一个实例。
encrypt(data) → string将对象加密为密文字符串。内部步骤:
JSON.stringify(data) 序列化CryptoJS.AES.encrypt(json, secret) 加密.toString() 转为 Base64 格式密文const cipher = crypto.encrypt({ userId: 'alice', role: 'admin' });
console.log(cipher); // 'U2FsdGVkX1+R5...'(每次加密结果不同,含随机盐值)注意:AES-CBC 模式每次加密会生成随机 IV,所以同一明文每次加密结果不同,这是正常且安全的行为。
decrypt(encrypted) → T | null将密文字符串解密还原为原始对象。内部步骤:
CryptoJS.AES.decrypt(encrypted, secret) 解密toString(CryptoJS.enc.Utf8) 转为 UTF-8 字符串JSON.parse(...) 还原为 T解密失败的情况(均返回 null,不抛出异常):
const data = crypto.decrypt(cipher);
if (data === null) {
// 解密失败:token 可能已失效或被篡改,重新登录
redirectToLogin();
}tryDecrypt(encrypted) → 结构化结果需要区分失败原因时用它,decrypt 就是它的薄封装:
type TryDecryptResult<T> = { ok: true; data: T } | { ok: false; reason: 'decrypt' | 'parse' };reason | 含义 | 典型处置 |
|---|---|---|
'decrypt' | 密钥不匹配,或密文损坏 / 被篡改 | 清掉这条缓存并重新登录 |
'parse' | 解得开,但明文本身不是合法 JSON | 多半是换过数据结构的旧缓存,丢弃即可 |
const result = crypto.tryDecrypt(cipher);
if (!result.ok) {
if (result.reason === 'decrypt') redirectToLogin();
else storage.remove('tokenCipher'); // 旧结构的残留,静默丢弃
return null;
}
return result.data;最常见的用法是在 createStorage 读写时加一层加密,防止用户直接从 DevTools 读取明文:
import { AesCrypto } from '@skyroc/utils/crypto';
import { createStorage } from '@skyroc/utils/web';
type AppStorage = {
tokenCipher: string; // 存储密文
};
const storage = createStorage<AppStorage>('local', 'app__');
const crypto = new AesCrypto<{ token: string; role: string }>('APP_SECRET_2024');
// 写入时加密
function saveToken(token: string, role: string) {
const cipher = crypto.encrypt({ token, role });
storage.set('tokenCipher', cipher);
}
// 读取时解密
function getToken() {
const cipher = storage.get('tokenCipher');
if (!cipher) return null;
return crypto.decrypt(cipher); // { token, role } | null
}密钥管理:
// ❌ 避免硬编码在源码中
const crypto = new AesCrypto('my-secret-key');
// ✅ 从环境变量读取
const crypto = new AesCrypto(import.meta.env.VITE_CRYPTO_SECRET);适用范围:
AesCrypto 适合本地存储数据的混淆加密,不适合替代 HTTPS 传输层加密:
| 场景 | 适用? | 说明 |
|---|---|---|
| localStorage 加密存储 | ✅ | 防止普通用户直接读取 |
| Cookie 加密存储 | ✅ | 同上 |
| 客户端到服务端传输 | ❌ | 应使用 HTTPS,不应在前端加密 |
| 替代 JWT 验签 | ❌ | AES 是对称加密,无法实现签名验证 |
关于"安全强度":
除了「前端代码可被审查、密钥本质上藏不住」这一点,passphrase 模式的密钥派生本身也只有 MD5 单轮, 强度完全取决于口令。把它当混淆层用,不要当安全边界用。真正需要保密的数据应由服务端持有密钥并负责加解密。
建议统一封装解密访问,避免 null 检查遗漏:
class TokenStorage {
private crypto = new AesCrypto<TokenPayload>(import.meta.env.VITE_CRYPTO_SECRET);
private storage = createStorage<{ token: string }>('local', 'app__');
save(payload: TokenPayload) {
this.storage.set('token', this.crypto.encrypt(payload));
}
load(): TokenPayload | null {
const cipher = this.storage.get('token');
if (!cipher) return null;
return this.crypto.decrypt(cipher);
}
clear() {
this.storage.remove('token');
}
}
export const tokenStorage = new TokenStorage();Last updated on