面向医疗应用的客户端加密

发布: (2025年12月7日 GMT+8 16:11)
8 min read
原文: Dev.to

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),并在本地记录所有操作,你可以保护健康数据免受传票、泄露以及任何可能将患者个人叙事武器化的方的侵害。这套架构专为那些数据可能被用来对付他们的人而设计——残疾索赔人、慢性疼痛患者,以及任何系统已经决定不相信的人。

Back to Blog

相关文章

阅读更多 »