第2494篇:端到端加密的AI系统——在隐私优先的场景中保护AI对话
第2494篇:端到端加密的AI系统——在隐私优先的场景中保护AI对话
适读人群:Java工程师、安全工程师、AI工程师 | 阅读时长:约13分钟 | 核心价值:掌握在隐私敏感场景中构建端到端加密AI系统的工程实践
有个医疗行业的客户找我们做 AI 咨询。他们想做一个面向医生的 AI 助手,帮助医生快速查询医学文献、辅助诊断分析。
但有一个硬性要求:医生和 AI 的对话内容,包括服务器上的我们,都不能看到明文。
这个需求背后是真实的合规压力:医患对话属于敏感信息,即使是咨询 AI,也不能有任何未经授权的访问。
传统的 HTTPS 只能保护传输过程中的数据,数据到达服务器后就是明文的——服务器管理员是能看到的。要达到"服务器也看不到"的效果,需要端到端加密(E2E Encryption)。
这是一个很有挑战性的工程问题:AI 系统需要理解并处理文本,但加密的文本 AI 无法处理。怎么两者兼顾?
一、端到端加密 AI 系统的设计挑战
挑战一:AI 处理需要明文。LLM 必须看到明文才能理解和生成内容。这个矛盾如何化解?
挑战二:RAG 检索需要语义匹配。向量库存储的 Embedding,是从明文文本生成的。如果用户查询是加密的,无法匹配。
挑战三:对话历史的加密存储。用户的历史对话要加密存储,但下次对话时需要作为上下文解密。
挑战四:密钥管理的用户体验。端到端加密的核心是用户持有密钥,但让用户管理密钥的体验历来很差。
解决这些矛盾,需要在架构层面做出权衡:
方案一:可信执行环境(TEE) 在安全飞地(Intel SGX / ARM TrustZone)里运行 AI 推理,飞地内的代码连操作系统都无法访问。
方案二:客户端侧处理 + 服务端密文操作 把加密逻辑完全放在客户端,服务端只存储和转发密文,AI 推理在客户端的沙箱中进行(适合小模型)。
方案三:混合加密:敏感字段加密 + 脱敏处理 不需要全程加密,只对最敏感的字段(患者姓名、ID)做客户端加密,AI 处理脱敏后的内容,结果返回后客户端补全明文。
对于大多数实际场景,方案三是可操作性最强的。
二、混合加密方案的技术实现
2.1 客户端加密库
/**
* 客户端加密层(运行在用户设备上,服务端无法访问)
*/
public class ClientSideEncryptionService {
private final SecretKey userKey; // 用户密钥(只在本地,不上传)
private final NERService nerService; // 命名实体识别
public ClientSideEncryptionService(String userPassword) {
// 从用户密码派生密钥(PBKDF2,加盐)
this.userKey = deriveKeyFromPassword(userPassword);
this.nerService = new NERService();
}
// 处理用户输入:识别敏感信息,加密存储,脱敏后发给服务端
public PreparedMessage prepareForServer(String userMessage) {
// 1. 识别消息中的敏感实体(患者姓名、ID、电话等)
List<SensitiveEntity> entities = nerService.extractSensitiveEntities(userMessage);
// 2. 生成占位符并记录映射关系
Map<String, String> placeholderMap = new HashMap<>();
String desensitizedMessage = userMessage;
for (SensitiveEntity entity : entities) {
String placeholder = generatePlaceholder(entity.getType());
desensitizedMessage = desensitizedMessage.replace(
entity.getValue(), placeholder);
placeholderMap.put(placeholder, entity.getValue());
}
// 3. 加密占位符映射(服务端无法解密)
String encryptedMapping = encrypt(
serializeMap(placeholderMap), userKey);
return PreparedMessage.builder()
.desensitizedContent(desensitizedMessage) // 发给服务端
.encryptedMapping(encryptedMapping) // 加密后存在服务端,只有客户端能解
.originalContent(userMessage) // 只在本地,不发送
.build();
}
// 接收服务端响应后,还原脱敏信息
public String restoreResponse(String serverResponse, String encryptedMapping) {
// 1. 解密映射关系
String mappingJson = decrypt(encryptedMapping, userKey);
Map<String, String> placeholderMap = deserializeMap(mappingJson);
// 2. 将响应中的占位符替换回原始值
String restoredResponse = serverResponse;
for (Map.Entry<String, String> entry : placeholderMap.entrySet()) {
restoredResponse = restoredResponse.replace(entry.getKey(), entry.getValue());
}
return restoredResponse;
}
// AES-GCM 加密
private String encrypt(String plaintext, SecretKey key) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// 拼接 IV + 密文,Base64 编码
byte[] combined = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new EncryptionException("加密失败", e);
}
}
private String decrypt(String ciphertext, SecretKey key) {
try {
byte[] combined = Base64.getDecoder().decode(ciphertext);
byte[] iv = Arrays.copyOfRange(combined, 0, 12);
byte[] encryptedBytes = Arrays.copyOfRange(combined, 12, combined.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] plaintext = cipher.doFinal(encryptedBytes);
return new String(plaintext, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new EncryptionException("解密失败", e);
}
}
private SecretKey deriveKeyFromPassword(String password) {
try {
// 使用固定盐(实际应该用用户注册时生成的随机盐,存在服务端)
byte[] salt = "enterprise-ai-salt-v1".getBytes(StandardCharsets.UTF_8);
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(), salt, 310000, 256);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
return new SecretKeySpec(keyBytes, "AES");
} catch (Exception e) {
throw new RuntimeException("密钥派生失败", e);
}
}
private String generatePlaceholder(SensitiveEntity.Type type) {
return String.format("[%s_%s]", type.name(),
UUID.randomUUID().toString().substring(0, 8).toUpperCase());
}
}2.2 服务端的加密对话存储
@Service
@Slf4j
public class EncryptedConversationStorageService {
private final ConversationRepository conversationRepo;
// 服务端存储对话(只存脱敏内容和加密映射,不存明文)
public void saveConversation(String sessionId,
PreparedMessage userMessage,
String aiResponse) {
EncryptedConversationEntry entry = EncryptedConversationEntry.builder()
.sessionId(sessionId)
.userContentDesensitized(userMessage.getDesensitizedContent()) // 脱敏文本
.userEncryptedMapping(userMessage.getEncryptedMapping()) // 加密映射,服务端不解
.aiResponse(aiResponse) // AI 对脱敏文本的回复
.createdAt(Instant.now())
// 注意:没有存储明文原始内容
.build();
conversationRepo.save(entry);
}
// 获取会话历史(服务端只返回脱敏版本,客户端收到后自行解密还原)
public List<EncryptedConversationEntry> getHistory(String sessionId) {
return conversationRepo.findBySessionId(sessionId);
}
}三、密钥管理方案
密钥管理是端到端加密最难的工程问题。
@Service
@Slf4j
public class KeyManagementService {
// 方案:密钥加密后存储在服务端
// 用户主密码加密会话密钥,服务端存的是加密后的密钥(无法在不知道主密码的情况下使用)
// 生成新会话密钥并安全存储
public EncryptedKeyBundle createSessionKey(String userId, String masterPassword) {
try {
// 1. 生成随机会话密钥
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256, new SecureRandom());
SecretKey sessionKey = keyGen.generateKey();
// 2. 用主密码派生的加密密钥来加密会话密钥
SecretKey encryptionKey = deriveKeyFromPassword(masterPassword, getUserSalt(userId));
String encryptedSessionKey = encryptKey(sessionKey, encryptionKey);
// 3. 生成恢复密钥(用于忘记密码的情况)
String recoveryCode = generateRecoveryCode();
SecretKey recoveryEncryptionKey = deriveKeyFromPassword(
recoveryCode, getRecoverySalt(userId));
String encryptedKeyForRecovery = encryptKey(sessionKey, recoveryEncryptionKey);
// 4. 存储加密后的密钥(服务端无法解密这些密钥)
keyStorage.save(KeyBundle.builder()
.userId(userId)
.encryptedSessionKey(encryptedSessionKey) // 用主密码加密
.encryptedKeyForRecovery(encryptedKeyForRecovery) // 用恢复码加密
.build());
return EncryptedKeyBundle.builder()
.encryptedSessionKey(encryptedSessionKey)
.recoveryCode(recoveryCode) // 只在这次返回给用户,服务端不存明文恢复码
.build();
} catch (Exception e) {
throw new KeyManagementException("密钥创建失败", e);
}
}
// 验证:用户登录时,服务端验证密码后,返回加密密钥给客户端解密
public String getEncryptedKey(String userId, String sessionToken) {
// 验证 session token 有效
validateSessionToken(sessionToken);
// 返回加密的会话密钥(服务端不知道明文密钥)
return keyStorage.getEncryptedSessionKey(userId);
}
private String generateRecoveryCode() {
// 生成人类可读的恢复码格式(如:XXXX-XXXX-XXXX-XXXX)
SecureRandom random = new SecureRandom();
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 去掉易混淆字符
StringBuilder code = new StringBuilder();
for (int i = 0; i < 16; i++) {
if (i > 0 && i % 4 == 0) code.append('-');
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
}四、端到端加密的代价与权衡
要让用户明白:端到端加密意味着你需要为安全付出一些代价。
代价一:无法审计历史对话。如果发生安全事件,管理员无法追查对话内容(因为加密了)。需要在产品层面告知用户并获得同意。
代价二:忘记密码代价高。端到端加密系统无法"找回密码"(因为没人知道密钥)。只能用恢复码重置,会丢失历史加密数据。
代价三:跨设备体验。换设备需要导入密钥,比普通账号登录麻烦。
代价四:AI 能力可能有所限制。脱敏会损失一些上下文信息,可能影响 AI 的回答质量。
这些权衡需要在产品设计阶段就想清楚,不是工程问题,是产品决策。
对医疗、法律、心理咨询等高敏感场景,这些代价是值得的。对大多数普通应用,TLS + 严格的访问控制 + 合理的隐私政策,已经足够了。
