面向医疗应用的客户端加密
Source: Dev.to
引言
我曾在法庭上被用我的健康数据对付——真实的律师、真实的法官,以及我自己的疼痛发作日志被重新包装成不稳定的证据。这段经历驱动了下面的每一个安全决策:150 000 次 PBKDF2 迭代、AES‑256‑GCM,以及永不离开设备的密钥。这不是教程;它是让健康数据不被发现动议、监护权争议和保险欺诈调查所获取的架构。
威胁模型
-
传统模型:
User → Server → Database。- 服务器解密后进行处理。
- 数据在公司基础设施中传输,员工可以访问,并可能被传票召集。
- 泄露、商业模式变现以及法律请求都会暴露数据。
-
零知识 / 本地优先模型:
- 服务器永远只看到密文(或根本不看到)。
- 数据从不离开用户设备,防止盗窃、恶意软件、共享电脑和取证分析。
- 即使硬件被扣押,攻击者也只能得到噪声。
使用 Web Crypto API
Web Crypto API 已内置于现代浏览器,支持硬件加速,且没有外部供应链依赖。
const cryptoAPI = globalThis.crypto ?? window.crypto;
const subtle = cryptoAPI.subtle;
生成密钥
// AES‑GCM 加密密钥(256 位)
async function generateEncryptionKey(): Promise {
return subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
// 用于完整性的 HMAC‑SHA‑256 密钥
async function generateHMACKey(): Promise {
return subtle.generateKey(
{ name: 'HMAC', hash: 'SHA-256' },
true,
['sign', 'verify']
);
}
加密
interface EncryptedPayload {
ciphertext: string;
iv: string;
hmac: string;
algorithm: string;
version: string;
timestamp: string;
}
/**
* 使用 AES‑GCM 加密任意数据,并用 HMAC 对密文进行签名。
*/
async function encrypt(
data: T,
encryptionKey: CryptoKey,
hmacKey: CryptoKey
): Promise {
const plaintext = JSON.stringify(data);
const plaintextBytes = new TextEncoder().encode(plaintext);
// 每次加密使用全新 IV
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertextBuffer = await subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
plaintextBytes
);
const hmacSignature = await subtle.sign('HMAC', hmacKey, ciphertextBuffer);
return {
ciphertext: arrayBufferToBase64(ciphertextBuffer),
iv: arrayBufferToBase64(iv.buffer),
hmac: arrayBufferToBase64(hmacSignature),
algorithm: 'AES-256-GCM',
version: '2.0.0',
timestamp: new Date().toISOString(),
};
}
为什么要使用 HMAC?
虽然 AES‑GCM 已经提供了认证,但额外的 HMAC 层提供了“安全带+吊带”式的保护。验证在解密 之前 进行,能够快速拒绝被篡改的负载。
解密
/**
* 在验证 HMAC 后解密负载。
*/
async function decrypt(
payload: EncryptedPayload,
encryptionKey: CryptoKey,
hmacKey: CryptoKey
): Promise {
const ciphertextBuffer = base64ToArrayBuffer(payload.ciphertext);
const iv = base64ToArrayBuffer(payload.iv);
const expectedHmac = base64ToArrayBuffer(payload.hmac);
const isValid = await subtle.verify(
'HMAC',
hmacKey,
expectedHmac,
ciphertextBuffer
);
if (!isValid) {
throw new Error('Integrity check failed');
}
const plaintextBuffer = await subtle.decrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
ciphertextBuffer
);
return JSON.parse(new TextDecoder().decode(plaintextBuffer));
}
存储考虑
| 选项 | 缺点 |
|---|---|
localStorage | 易受 XSS 攻击 |
sessionStorage | 关闭标签页后丢失 |
IndexedDB | 仍然可能受 XSS 影响 |
| 密码派生密钥(用户每次输入) | 用户体验差 |
extractable: false(不可导出密钥) | 无法备份或迁移 |
分层密钥
- 主密钥 包装每条数据的条目密钥。
- 密码派生密钥 包装备份,实现安全迁移。
/**
* 将 CryptoKey 包装后存储(例如存入 IndexedDB)。
*/
async function wrapKeyForStorage(
keyToWrap: CryptoKey,
wrappingKey: CryptoKey
): Promise {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await subtle.wrapKey(
'raw',
keyToWrap,
wrappingKey,
{ name: 'AES-GCM', iv }
);
return JSON.stringify({
wrapped: arrayBufferToBase64(wrapped),
iv: arrayBufferToBase64(iv.buffer),
});
}
密码派生密钥
/**
* 使用 PBKDF2 从密码派生 AES‑GCM 密钥。
* 默认:150 000 次迭代(为法律风险场景硬编码)。
*/
async function deriveKeyFromPassword(
password: string,
salt: Uint8Array,
iterations: number = 150_000
): Promise {
const passwordBuffer = new TextEncoder().encode(password);
const baseKey = await subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
return subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256',
},
baseKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
- 盐 与备份一起存储(不保密)。
- 迭代次数 也一起存储,便于将来提升而不破坏已有备份。
密钥轮换
定期轮换不仅是最佳实践;它是对设备丢失、泄露或法律压力的响应。
async function rotateKey(keyId: string): Promise {
const newBundle = await generateKeyBundle(); // 新的加密 + HMAC 密钥
const allEntries = await loadAllEncryptedEntries(); // 获取所有已存负载
const oldKey = await getKey(keyId); // 读取旧的密钥包
for (const entry of allEntries) {
const plaintext = await decrypt(entry.data, oldKey.enc, oldKey.hmac);
const newCiphertext = await encrypt(plaintext, newBundle.encryptionKey, newBundle.hmacKey);
await updateEntry(entry.id, newCiphertext);
}
await archiveKey(keyId, oldKey); // 如有需要保留用于恢复
await storeKey(keyId, newBundle);
await logSecurityEvent({
type: 'key_rotation',
keyId,
timestamp: new Date(),
});
}
审计
所有加密操作都在 本地 记录;不会发送到服务器。
interface SecurityEvent {
type: string;
keyId?: string;
timestamp: Date;
[prop: string]: any;
}
/**
* 将有界审计日志存入 localStorage。
*/
function logSecurityEvent(event: SecurityEvent): void {
const auditLog = JSON.parse(localStorage.getItem('security_audit') ?? '[]');
auditLog.push({ ...event, timestamp: event.timestamp.toISOString() });
// 保留最近的 1 000 条记录
if (auditLog.length > 1000) {
auditLog.splice(0, auditLog.length - 1000);
}
localStorage.setItem('security_audit', JSON.stringify(auditLog));
}
这些日志可以在不涉及任何后端的情况下提供给用户或审计员。
实现要点
- 隐私浏览:
localStorage可能不可用;回退到内存存储并提示用户数据不会持久化。 - 测试:Web Crypto 在 Vitest 下表现不同;需要 mock API 或使用
@peculiar/webcrypto。 - Base64 处理:Node.js 与浏览器行为不同。使用兼容两者的辅助函数。
function arrayBufferToBase64(buffer: ArrayBuffer): string {
if (typeof Buffer !== 'undefined') {
// Node.js 环境
return Buffer.from(buffer).toString('base64');
}
// 浏览器环境
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
if (typeof Buffer !== 'undefined') {
// Node.js
return Buffer.from(base64, 'base64').buffer;
}
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
结论
通过将加密完全放在客户端、使用强大的原语(AES‑256‑GCM、HMAC‑SHA‑256、150 k 次迭代的 PBKDF2),并在本地记录所有操作,你可以保护健康数据免受传票、泄露以及任何可能将患者个人叙事武器化的方的侵害。这套架构专为那些数据可能被用来对付他们的人而设计——残疾索赔人、慢性疼痛患者,以及任何系统已经决定不相信的人。