@skyroc/utils

AesCrypto

基于 AES 的对称加密类,用于本地存储数据的混淆保护

概述

import { AesCrypto } from '@skyroc/utils/crypto';

AesCrypto<T> 是一个泛型加密类,基于 crypto-js 的 AES 实现。它将任意可 JSON 序列化的对象加密为字符串,并支持还原回原始类型——常用于在 localStoragesessionStorage 中存储 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(对象)
  解密过程发生任何错误 → 捕获异常 → 返回 null

AES 采用 crypto-js 默认模式(CBC + PKCS7 填充),密钥长度取决于传入字符串的哈希派生结果(crypto-js 内部使用 MD5 派生密钥)。

API

new AesCrypto<T>(secret)

const crypto = new AesCrypto<MyDataType>('your-secret-key');
参数类型说明
secretstring加密密钥,建议使用足够复杂的字符串,不要硬编码在代码中

secretprivate readonly 的,构造之后不可更换。需要换密钥就新建一个实例。


encrypt(data)string

将对象加密为密文字符串。内部步骤:

  1. JSON.stringify(data) 序列化
  2. CryptoJS.AES.encrypt(json, secret) 加密
  3. .toString() 转为 Base64 格式密文
const cipher = crypto.encrypt({ userId: 'alice', role: 'admin' });
console.log(cipher); // 'U2FsdGVkX1+R5...'(每次加密结果不同,含随机盐值)

注意:AES-CBC 模式每次加密会生成随机 IV,所以同一明文每次加密结果不同,这是正常且安全的行为。


decrypt(encrypted)T | null

将密文字符串解密还原为原始对象。内部步骤:

  1. CryptoJS.AES.decrypt(encrypted, secret) 解密
  2. toString(CryptoJS.enc.Utf8) 转为 UTF-8 字符串
  3. JSON.parse(...) 还原为 T

解密失败的情况(均返回 null,不抛出异常):

  • 密文被篡改或损坏
  • 密钥不匹配(crypto-js 此时不抛错,只会解出空串或乱码,这里按失败处理)
  • 解出的明文不是合法 JSON
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 配合使用

最常见的用法是在 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