跳到主要内容

敏感数据加密

对敏感数据采用 AES 加密传输,支持前端到后端、后端到前端的双向加密通信,避免数据在传输链路中被明文暴露。

注意

AES 加密传输属于纵深防御,应优先使用 HTTPS,本方案不替代 TLS。

敏感数据治理与识别​

实施加密前需先梳理系统内的敏感数据,过程分为“数据分类与发现”与“表现层脱敏”两部分。

1. 敏感数据的分类与特征​

敏感数据指一旦泄露可能对个人或企业造成危害的信息,通常分为三大类,并针对其特征采用不同的识别策略:

数据类别具体字段示例识别策略推荐
个人身份信息身份证号、手机号、邮箱、家庭住址、姓名格式固定,优先使用正则表达式进行自动化值匹配
金融与交易信息银行卡号、账户余额、交易流水、薪资部分格式固定(如银行卡号可用正则),其余依赖手动字段标记
认证与凭据信息登录密码、支付密码、API Key、JWT Token具有明确的语义特征,优先通过字段名(Key)匹配或手动标记

2. 自动化识别与手动标记的结合​

实际项目中推荐将“正则值检测”与“字段字典映射”结合,定位复杂业务对象中的敏感数据,避免“一刀切”全量加密带来的性能开销。

// 正则匹配规则(PII 及标准格式)
const sensitivePatterns = {
idCard: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/,
phone: /^1[3-9]\d{9}$/,
bankCard: /^\d{13,19}$/,
email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
};

// 业务字段字典(无固定格式的敏感信息)
const sensitiveFields = new Set([
'user.address',
'payment.balance',
'auth.password',
'auth.apiKey',
]);

/**
* 判断字段是否敏感
* 依据:字段路径是否在敏感字典中、键名是否包含敏感词、值是否匹配敏感格式正则
*/
export const isSensitiveData = (fieldPath: string, value: unknown): boolean => {
// 检查是否在手动配置的字典中
if (sensitiveFields.has(fieldPath)) return true;

// 检查键名是否包含敏感词 (如 password, secret)
if (/(password|pwd|secret|token)/i.test(fieldPath)) return true;

// 检查字符串值是否匹配正则特征
if (typeof value === 'string') {
return Object.values(sensitivePatterns).some((pattern) => pattern.test(value));
}

return false;
};

3. 表现层:数据脱敏处理​

非网络传输场景(如日志打印、页面只读展示)通过数据脱敏(Masking)保护隐私:

/**
* 身份证号脱敏:保留前6位和后4位,中间用*替换
* 示例:110101199001011234 -> 110101********1234
*/
export const maskIdCard = (idCard: string): string => {
if (!idCard || idCard.length !== 18) return idCard;
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
};

/**
* 手机号脱敏:保留前3位和后4位,中间4位用*替换
* 示例:13800138000 -> 138****8000
*/
export const maskPhone = (phone: string): string => {
if (!phone || phone.length !== 11) return phone;
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
};

/**
* 银行卡号脱敏:只保留后4位,其余位每4位用*分隔
* 示例:6225880123456789 -> **** **** **** 6789
*/
export const maskBankCard = (cardNumber: string): string => {
if (!cardNumber) return cardNumber;
const lastFour = cardNumber.slice(-4);
const maskedLength = Math.max(0, cardNumber.length - 4);
const maskedGroups = Math.ceil(maskedLength / 4);
return `${'**** '.repeat(maskedGroups)}${lastFour}`;
};

/**
* 邮箱脱敏:保留第一个字符和域名,用户名其余部分用*替换
* 示例:zhangsan@example.com -> z*******@example.com
*/
export const maskEmail = (email: string): string => {
if (!email || !email.includes('@')) return email;
const [username, domain] = email.split('@');
if (username.length <= 1) return email;
return `${username[0]}${'*'.repeat(username.length - 1)}@${domain}`;
};

/**
* 姓名脱敏:保留姓氏,名字部分用*替换
* 示例:张三 -> 张*,李四五 -> 李**
*/
export const maskName = (name: string): string => {
if (!name || name.length <= 1) return name;
return `${name[0]}${'*'.repeat(name.length - 1)}`;
};

传输层:双向混合加密架构​

采用基于 RSA-OAEP (2048) 与 AES-GCM (256) 的混合加密方案:

  • 非对称加密 (RSA-OAEP): 仅用于加密一次性随机生成的对称密钥,解决密钥分发问题。
  • 对称加密 (AES-GCM): 用于加密实际业务数据,加解密性能高,并通过 Auth Tag 提供完整性校验。

核心前端代码实现​

export interface EncryptedPayload {
encryptedAesKey: string; // Base64 编码的、经 RSA 加密的 AES 密钥
encryptedData: string; // Base64 编码的、经 AES-GCM 加密的 [密文 + 16字节 Tag]
iv: string; // Base64 编码的 12 字节初始化向量
}

// Base64 编解码工具

export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
};

export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
};

// 密钥处理

// 1. 导入服务端 RSA 公钥 (用于前端 -> 后端加密)
export const importServerPublicKey = async (
pemHeaderExcludedBase64: string,
): Promise<CryptoKey> => {
const binaryKey = base64ToArrayBuffer(pemHeaderExcludedBase64);
return crypto.subtle.importKey(
'spki',
binaryKey,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt'],
);
};

// 2. 生成客户端自有的 RSA 密钥对 (用于后端 -> 前端加密)
export const generateClientKeyPair = async (): Promise<CryptoKeyPair> => (
crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt'],
)
);

// 提取客户端公钥为 Base64
export const exportClientPublicKey = async (publicKey: CryptoKey): Promise<string> => {
const exported = await crypto.subtle.exportKey('spki', publicKey);
return arrayBufferToBase64(exported);
};

// 核心加解密

// 3. 前端 -> 后端:加密业务数据
export const encryptForServer = async (
data: Record<string, unknown>,
serverPublicKey: CryptoKey,
): Promise<EncryptedPayload> => {
// a. 随机生成 256 位 AES-GCM 密钥
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);

// b. 使用服务端的 RSA 公钥加密该 AES 密钥的 Raw 字节
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
const encryptedAesKey = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
serverPublicKey,
rawAesKey,
);

// c. 使用 AES-GCM 加密业务数据 (Web Crypto 会自动在密文后追加 16 字节的 Tag)
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedData = new TextEncoder().encode(JSON.stringify(data));
const encryptedDataAndTag = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
encodedData,
);

return {
encryptedAesKey: arrayBufferToBase64(encryptedAesKey),
encryptedData: arrayBufferToBase64(encryptedDataAndTag),
iv: arrayBufferToBase64(iv),
};
};

// 4. 后端 -> 前端:解密服务端返回的数据
export const decryptFromServer = async (
payload: EncryptedPayload,
clientPrivateKey: CryptoKey,
): Promise<unknown> => {
// a. 使用客户端私钥解密出对称 AES 密钥
const encryptedAesKeyBuf = base64ToArrayBuffer(payload.encryptedAesKey);
const rawAesKey = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
clientPrivateKey,
encryptedAesKeyBuf,
);

// b. 导入对称密钥
const aesKey = await crypto.subtle.importKey(
'raw',
rawAesKey,
{ name: 'AES-GCM' },
false,
['decrypt'],
);

// c. 解密含有 16 字节尾随 Tag 的混合数据
const ivBuf = base64ToArrayBuffer(payload.iv);
const encryptedDataAndTagBuf = base64ToArrayBuffer(payload.encryptedData);

const decryptedBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBuf },
aesKey,
encryptedDataAndTagBuf,
);

return JSON.parse(new TextDecoder().decode(decryptedBuf));
};

前端到后端加密​

前端生成随机 AES 密钥加密敏感数据,然后使用服务端公钥加密 AES 密钥,服务端接收后用私钥解密得到 AES 密钥,再用此密钥解密业务数据。

适用于前端向后端提交登录凭据、支付密码等场景,每次请求生成一次性的随机 AES 密钥。

前端实现​

import { importServerPublicKey, encryptForServer } from '@/utils/crypto';

export const submitSensitiveData = async (userData: Record<string, unknown>) => {
try {
// 1. 获取服务端公钥
const response = await fetch('/api/server-public-key');
const { publicKey } = await response.json();

// 2. 导入公钥并执行混合加密
const serverPubKey = await importServerPublicKey(publicKey);
const encryptedBody = await encryptForServer(userData, serverPubKey);

// 3. 发送加密数据给后端
const submitRes = await fetch('/api/user/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(encryptedBody),
});

return await submitRes.json();
} catch (error) {
console.error('提交加密数据失败:', error);
throw error;
}
};

后端实现​

import {
Controller,
Post,
Get,
Body,
Headers,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { EncryptionService } from './encryption.service';

@Controller('api')
export class EncryptionController {
constructor(private readonly encryptionService: EncryptionService) {}

@Get('server-public-key')
getServerPublicKey() {
return { publicKey: this.encryptionService.getServerPublicKey() };
}

@Post('secure-data')
receiveEncryptedData(
@Body() body: { encryptedAesKey: string; encryptedData: string; iv: string },
) {
try {
const { encryptedAesKey, encryptedData, iv } = body;
const businessData = this.encryptionService.decryptClientData(
encryptedAesKey,
encryptedData,
iv,
);

return { success: true, message: '数据接收成功', data: businessData };
} catch {
throw new BadRequestException('数据解密失败');
}
}

@Post('encrypted-response')
sendEncryptedData(@Headers('x-client-public-key') clientPublicKey: string) {
if (!clientPublicKey) {
throw new BadRequestException('缺少客户端公钥');
}

try {
const responseData = {
message: '这是来自服务器的敏感数据',
timestamp: new Date().toISOString(),
userInfo: { id: 123, role: 'admin' },
};

const encryptedResponse = this.encryptionService.encryptDataForClient(
responseData,
clientPublicKey,
);
return encryptedResponse;
} catch {
throw new InternalServerErrorException('服务器加密失败');
}
}
}

后端到前端加密​

后端生成随机 AES 密钥加密响应数据,然后使用前端公钥加密 AES 密钥,前端接收后用私钥解密得到 AES 密钥,再用此密钥解密业务数据。

前端实现​

import {
generateClientKeyPair,
exportClientPublicKey,
decryptFromServer,
} from '@/utils/crypto';

export const fetchSensitiveData = async () => {
try {
// 1. 动态生成客户端临时的 RSA 密钥对
const { publicKey, privateKey } = await generateClientKeyPair();
const clientPublicKeyBase64 = await exportClientPublicKey(publicKey);

// 2. 发起请求,携带前端公钥供后端加密使用
const response = await fetch('/api/finance/statements', {
method: 'GET',
headers: {
'X-Client-Public-Key': clientPublicKeyBase64,
},
});

const encryptedResponsePayload = await response.json();

// 3. 使用前端私钥解密后端返回的 Payload
const decryptedData = await decryptFromServer(encryptedResponsePayload, privateKey);
return decryptedData;
} catch (error) {
console.error('获取或解密数据失败:', error);
throw error;
}
};

性能优化​

密钥复用策略​

  • 会话复用(Session Key): 高频交互场景应避免每次请求都执行耗时的 RSA 非对称加密,可在会话开始时握手并缓存对称 AES 密钥,在一定生命周期(如 30 分钟)内复用。
  • 分块与异步流处理: 处理大数据量(如超过 5MB 的附件)时不应一次性读入内存;前端使用 Web Workers 在后台线程加密,避免阻塞 UI 主线程,后端采用流式(Stream)解密。

安全风险一览​

风险点业务隐患防范方案
中间人攻击 (MITM)攻击者在握手阶段伪造并下发假的服务器公钥,拦截解密通信内容全站启用 HTTPS,双向加密仅作为纵深防御
重放攻击 (Replay)恶意第三方拦截合法密文并重复发送给服务端在加密明文中添加时间戳(Timestamp)与随机序列(Nonce),服务端丢弃超出时间窗口(如 1 分钟)或重复使用的请求
内存及文件缓存泄露RSA 私钥在后端容器内存常驻,或直接写入磁盘配置文件生产环境不硬编码私钥,通过环境变量或密钥管理产品(如 HSM、Vault)注入并定期轮换
IV 密钥复用风险同一 AES 密钥加密不同数据时复用了相同 IV,安全性下降每次加密都应通过安全随机源生成新的 12 字节 IV(前端对应 crypto.getRandomValues)

参考资料​