思畅 AI 如何做到对话数据的端到端加密保护

0 阅读10分钟

平台:sichang.xyz
本文适合有基础前端/全栈经验的读者


为什么要写这篇文章

"你说不存我的对话,我怎么相信你?"

这是我在开发思畅AI时被问到最多的问题。承诺本身没有意义——任何产品都可以在落地页写上"我们重视您的隐私"。

所以这篇文章不讲承诺,讲实现。我会拆解每一层:对话怎么在浏览器里加密存储、发送 API 请求时到底传了什么、Venice 和 OpenRouter 是怎么处理这些数据的,以及我们服务器的数据库里实际记录了什么。

读完这篇文章,你应该能用 DevTools 自己验证这些声明。


整体架构

先看宏观数据流:

你的浏览器
    │
    ├─ 对话历史 ──────► IndexedDB(AES-256-GCM 加密存储,密钥只在内存 / IndexedDB)
    │
    └─ 发起推理请求
           │
           ▼
    思畅 代理层(/api/chat)
           │  ← 只做转发,记录用量计数(数字),不写 prompt/response
           │
           ├─ Venice AI(GLM-4.7)
           │       └─ 处理完丢弃,不持久化
           │
           └─ OpenRouter(DeepSeek-V3.2 / Gemma / MiniMax / GLM-5)
                   └─ 默认不记录 prompt,仅保留计费元数据

关键点:对话内容从未以明文出现在思畅的服务器数据库里。服务器唯一知道的是"你今天发了多少条消息"——一个整数。


第一层:浏览器本地加密存储

加密算法选型

我们使用浏览器原生的 Web Crypto API,算法是 AES-256-GCM(AES Galois/Counter Mode):

  • 256-bit 密钥:当前最高强度对称加密
  • GCM 认证模式:加密的同时提供完整性校验,篡改密文会被检测到
  • 每次加密随机 12-byte IV:即使加密相同内容,密文也完全不同
  • 零第三方依赖:所有操作调用浏览器自带的 crypto.subtle,不引入任何外部加密库

完整实现(src/lib/crypto.ts):

const ALGO = 'AES-GCM'
const KEY_LENGTH = 256
const IV_LENGTH = 12  // 96-bit IV,GCM 推荐长度

/** 加密字符串,返回 base64(iv[12B] + ciphertext) */
export async function encrypt(plaintext: string, key: CryptoKey): Promise<string> {
  const enc = new TextEncoder()
  const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))  // 每次随机
  const ciphertext = await crypto.subtle.encrypt(
    { name: ALGO, iv },
    key,
    enc.encode(plaintext)
  )
  // IV 和密文拼接后 base64 编码
  const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength)
  combined.set(iv, 0)
  combined.set(new Uint8Array(ciphertext), iv.byteLength)
  return btoa(String.fromCharCode(...combined))
}

存储格式示意:

IndexedDB messages 
┌─────────────────────────────────────────────────────────────┐
 id: "msg_abc123"  (明文,用于索引)                           
 conversationId: "conv_xyz" (明文)                            
 role: "user"  (明文)                                         
 createdAt: 1745123456789  (明文时间戳)                       
 contentCipher: Uint8Array[IV(12B) + AES-GCM密文]   这是内容 
└─────────────────────────────────────────────────────────────┘

消息 ID、时间戳、会话 ID 以明文存储(需要用来排序和检索),只有消息内容被加密。即使有人拿到你的 IndexedDB 文件,看到的消息内容也是一段无法解读的二进制数据。

两种密钥管理方案

方案 A:已登录用户——从密码派生密钥

登录后,密钥通过 PBKDF2(Password-Based Key Derivation Function 2)从你的密码派生:

export async function deriveKey(password: string, saltHex: string): Promise<CryptoKey> {
  const enc = new TextEncoder()
  // Step 1: 将密码导入为原始密钥材料
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey'],
  )

  // Step 2: PBKDF2 派生 AES-GCM 密钥
  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: hexToBytes(saltHex).buffer,
      iterations: 310000,  // NIST 2023 推荐值,SHA-256 下至少 210,000 次
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,  // extractable: false — 密钥无法被 JS 代码导出!
    ['encrypt', 'decrypt'],
  )
}

extractable: false 这个参数很关键:

  • 派生出的 CryptoKey 对象被浏览器标记为"不可导出"
  • 即使攻击者注入了 JS 代码,也无法通过 crypto.subtle.exportKey() 提取密钥
  • 密钥以不可导出的形式存储在 IndexedDB 的 sichang_crypto 数据库中

saltHex 来自服务器(每个用户唯一,登录时随 session 返回)。密钥推导公式:

CryptoKey = PBKDF2(SHA-256, password, userSalt, iterations=310_000, keyLen=256bit)

服务器存储的是:

  • userSalt(随机 32 字节,16 进制)
  • bcrypt(password)(密码 hash)

服务器永远不知道派生出的 CryptoKey 是什么。 服务器有 salt,但没有密码,所以无法自己执行 PBKDF2。

方案 B:匿名用户——随机生成密钥

export async function generateAnonKey(): Promise<CryptoKey> {
  return crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,  // 匿名密钥可导出,需要存入 localStorage
    ['encrypt', 'decrypt'],
  )
}

// 导出为 JWK 格式存入 localStorage['sichang_anon_key']
const jwk = await crypto.subtle.exportKey('jwk', key)
localStorage.setItem('sichang_anon_key', JSON.stringify(jwk))

匿名密钥以 JWK(JSON Web Key)格式存在 localStorage。这个密钥从不上传服务器——它的防护目标是服务端,不是本地物理访问(本地攻击者如果能访问你的 localStorage,安全边界已经被突破了)。

⚠️ 注意:清除浏览器缓存 = 匿名密钥丢失 = 对话永久无法恢复。界面有明确提示。


第二层:API 请求——传了什么,没传什么

这是很多人担心的核心问题:你们的服务器作为中间代理,会不会偷偷记录消息?

来看实际的 /api/chat 处理代码(关键逻辑):

export async function POST(req: Request) {
  const { messages, model, veniceParameters, characterId } = body

  // 1. 验证 session、检查用量配额
  const session = await getServerSession(authOptions)
  // ...

  // 2. 转发给 AI 提供商(Venice 或 OpenRouter)
  const apiRes = isVeniceModel
    ? await fetch('https://api.venice.ai/api/v1/chat/completions', {
        method: 'POST',
        headers: { Authorization: `Bearer ${process.env.VENICE_API_KEY}` },
        body: JSON.stringify({ model, messages, stream: true }),
      })
    : await fetch('https://openrouter.ai/api/v1/chat/completions', {
        method: 'POST',
        headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` },
        body: JSON.stringify({ model, messages, stream: true }),
      })

  // 3. 更新用量计数(只记一个数字,不记内容)
  await prisma.usageLog.upsert({
    where: { userId_date: { userId, date: today } },
    update: { messageCount: { increment: 1 } },  // 只加 1
    create: { userId, date: today, messageCount: 1 },
    // ← 没有 messages 字段,没有 model 字段,只有一个计数
  })

  // 4. 把 AI 的流式响应直接透传给浏览器
  return new Response(apiRes.body, {
    headers: { 'Content-Type': 'text/event-stream' },
  })
}

数据库写入只有一行:messageCount + 1

对于匿名用户,我们甚至不存 IP 地址本身:

// 匿名用量限制:IP 哈希后存储,无法反查原始 IP
const ipHash = createHash('sha256').update(ip).digest('hex')
await prisma.anonymousUsage.upsert({
  where: { ipHash_date: { ipHash, date: today } },
  update: { messageCount: { increment: 1 } },
  create: { ipHash, date: today, messageCount: 1 },
})

你可以自己验证:

  1. 打开 DevTools → Network
  2. 发送一条消息
  3. 找到 /api/chat 的请求
  4. 查看 Request Payload:你会看到 messages 数组(明文发给我们服务器,这是必须的)
  5. 没有任何 Response 存储操作——响应直接 stream 回浏览器,服务器不缓存

如果你想更严格地验证,可以在发送消息前后查询数据库——你只会看到 messageCount 变化了 1。


第三层:推理提供商的隐私保障

消息会以明文从我们的代理层转发给 Venice 或 OpenRouter——这是不可避免的,AI 推理必须看到明文才能处理。那这两个提供商会存储你的对话吗?

Venice AI

Venice 的架构设计从根源上避免了内容存储:

你的请求
    │
    ▼
Venice 代理层(剥离 IP、用户标识符等元数据)
    │
    ▼
去中心化 GPU 池(Akash 等网络,多个独立 provider)
    │  每个 GPU provider 只看到单次请求,不知道用户身份
    ▼
开源模型推理(DeepSeek / Qwen / GLM)
    │  推理完成后,prompt 从 GPU 内存中清除
    ▼
响应返回

Venice 的官方隐私政策明确:在 Private Mode(默认模式)下,不存储任何 prompt 或 response

他们还提供了更高级别的隐私模式:

  • TEE 模式:在可信执行环境(Trusted Execution Environment)中运行,即使 Venice 自己也无法访问推理过程
  • E2EE 模式:端到端加密,解密只在经过验证的 TEE 内部发生

OpenRouter

OpenRouter 默认不记录 prompt 和 completion,只保留计费所需的元数据(时间戳、模型、token 数量、延迟)。

他们提供 Zero Data Retention(ZDR) 选项,可以针对每个请求或全局开启,确保连元数据也不被底层模型提供商保留。思畅已全局开启 ZDR,所有经 OpenRouter 转发的请求均带有零数据留存标记。

需要诚实说明的一点:OpenRouter 的数据处理最终也取决于底层模型提供商(DeepSeek、Google 等)各自的隐私政策。OpenRouter 不能改变这些提供商自己的数据政策,但 ZDR 模式会在 API 层面要求提供商不保留数据。


整体威胁模型总结

攻击场景思畅服务器被攻破思畅数据库泄露第三方提供商泄露你的设备被物理访问
对话内容暴露❌ 无❌ 无⚠️ 取决于提供商⚠️ IndexedDB 可读
账号信息暴露⚠️ 邮箱 + bcrypt hash⚠️ 邮箱 + bcrypt hash❌ 无❌ 无
可解密历史对话❌ 没有密钥❌ 没有密钥❌ 不存储⚠️ 若能提取密钥

对话内容对思畅服务器来说是不可读的——不是因为我们道德高尚,而是因为我们物理上没有解密所需的密钥


已知局限性(诚实说明)

1. 推理阶段是明文的

消息在 Venice/OpenRouter 推理时必须是明文——这是所有云端 AI 推理产品的共同限制,不是思畅特有的。如果你需要绝对零信任的推理,只能本地跑模型(Ollama 等)。

2. 传输层依赖 HTTPS

消息从你的浏览器到思畅代理层是 HTTPS 加密传输,不是端到端加密——思畅代理看到明文后再转发给提供商。我们没有在传输层做额外的客户端加密(那会要求你在浏览器里管理 TLS 证书,极度影响用户体验)。

3. IndexedDB 本地可访问

如果攻击者能在你的浏览器中执行任意 JS(XSS 攻击),他们可能在密钥加载进内存时读取内容。这是浏览器端加密的通用局限,不是思畅特有的问题。

4. 匿名密钥无需密码保护

匿名用户的密钥以 JWK 明文存在 localStorage,防护目标是服务端,不是本地攻击者。


怎么验证我们说的是真的

打开 sichang.xyz,发几条消息,然后:

验证 1:服务器没有存对话内容

  • DevTools → Network → 找 /api/chat
  • 查看 Response(是 SSE 流)
  • 全局搜索 Network 请求,没有任何 POST /api/conversations 或类似的存储接口

验证 2:对话确实存在本地

  • DevTools → Application → IndexedDB → sichang-dbmessages
  • 你会看到 contentCipher 字段是一段 Uint8Array 二进制,不是可读文字

验证 3:密钥不会上传

  • DevTools → Network,过滤所有请求
  • 你不会看到任何包含加密密钥的请求

为什么选择这个架构

说实话,"对话存本地 + 服务器仅做代理"这个架构对产品开发来说有很多不便:

  • 对话无法云同步(我们后来加了可选的加密云同步功能)
  • 无法做基于对话内容的个性化推荐
  • 无法训练自己的模型
  • 用量统计只能做聚合数字,没有细粒度分析

但这正是我们希望做出的取舍。我们物理上没有能力出卖你的数据,即使将来遭遇法律要求也没有内容可以交出。

这是思畅面向海外华人的核心承诺:不是"我们承诺不看",而是"我们根本看不到"


最后

如果你发现上述任何描述与实际实现不符,或者有安全问题想反馈,欢迎发邮件或在评论区指出。我们会认真对待每一条安全相关的反馈。

思畅AI 目前处于早期阶段,欢迎体验:sichang.xyz


如果觉得这篇文章有帮助,点个赞让更多人看到。
如果你在做类似隐私优先产品,也欢迎交流技术实现。