Cloudsea Chat
运作原理 · 图文详解

Cloudsea Chat 是什么

一个纯前端的端到端加密聊天程序。它的核心主张只有一句话:

🎯服务器只负责「让两个人找到彼此」,既不保存、也不阅读任何一条消息和文件。
服务器:我只是个送快递的,包裹上写的啥我是真不知道。😌

三条外部链路

🔐
统一登录系统 login.lbcraft.top
只证明「你是你」。跳转登录 → 带回 auth_code → 换成访问令牌。
🗂️
聊天 API chatapi.lbcraft.top
好友关系、私聊房间、房间凭据。所有请求带 Authorization: Bearer。
📡
信令 WebSocket wss://…/ws/{networkId}
房间内的节点发现、P2P 协商(SDP / ICE)、以及打不通时的加密中转。

浏览器之间的两条通道

① P2P 直连

WebRTC DataChannel。数据点对点走,根本不经过服务器。速度最快,是首选。

② 加密中转

P2P 打不通(NAT 太狠、防火墙挡 UDP)时,让信令服务器搬密文。慢,但至少能用。

注意:中转也不是明文。消息本身已经被双棘轮加密过一层,中转再加密一层 —— 服务器搬的是一坨它自己也解不开的东西。

从打开页面到说出第一句话

// 一次完整旅程
统一登录        → auth_code
换取令牌        → access_token
拉取好友/房间   → room.code + room.password
派生主密钥      → PBKDF2(1,000,000 次)
加载身份密钥    → IndexedDB 解密
连上信令        → wss://…/ws/{networkId}
广播在线状态    → announce(带签名)
互相验证        → 验签 + 验 nonce
建立 P2P        → offer / answer / ICE
X3DH 握手       → 协商出 rootKey
双棘轮就位      → 开始收发加密消息
看到没,你只是想说一句「在吗」,背后跑完了整整一条流水线。程序员真的很闲吗?不,是安全感很贵。

第一步:让系统知道「你是你」

Cloudsea Chat 不碰你的密码。它把你甩给统一登录系统,拿回来的只是一张「门票」。

// 1. 跳转登录(带 redirect 回来)
location.replace("https://login.lbcraft.top/login?app=…&redirect=…");

// 2. 回来时地址栏多了 auth_code
?auth_code=xxxxx

// 3. 用 code 换访问令牌
POST /session/exchange  { code }
→ { access_token }

// 4. 之后每个请求都带上
Authorization: Bearer <access_token>
令牌存在 localStorage 的 lbc_chat_token。任何请求收到 401 就清掉令牌、请重新登录 —— 这是最省事的会话失效处理。

主密钥:一个非常慢的密码

拿到房间后,前端会用房间口令做一次 PBKDF2,派生出主密钥 masterKey:

salt      = "cloudsea-chat-v1|" + roomCode
masterKey = PBKDF2-SHA256(password, salt, 1_000_000, 256bit)
PBKDF2 · 100 万次迭代(示意)
0
迭代次数越高,攻击者每猜一个口令就要多付出一份算力。100 万次在手机上大概要 1~2 秒 —— 你只等一次,爆破者要等一亿次。
⚠️ 关键认知:房间口令是服务器生成的,所以服务器也算得出 masterKey。因此 masterKey 绝对不能用来加密消息 —— 那样等于把钥匙交给邮局。
所以代码里 masterKey 只干两件杂活:派生 networkId(房间门牌号)和 localStorageKey(本地保险柜钥匙)。真·机密的事它一概不碰。这叫「知道分寸」。

本地保险柜钥匙

localSalt      = 本机随机生成的 16 字节(只存 localStorage,从不上传)
localStorageKey = HKDF(masterKey, localSalt, "local-storage", 256bit)

这一步的意义是:即使服务器知道 masterKey,也算不出 localStorageKey,因为 localSalt 是本机独有的、从未离开过这台设备的随机数。

服务器:我有 masterKey 了!
客户端:哦,那你猜猜我的 localSalt 是多少?
服务器:……

两把身份钥匙

每个用户在本地生成两对 P-256 密钥,用 localStorageKey 做 AES-GCM 加密后存进 IndexedDB:

密钥算法用途
身份密钥ECDH P-256参与密钥协商,算出共享秘密
签名密钥ECDSA P-256给消息/公告签名,证明「是我发的」

为什么要两把?因为 ECDH 只能「商量秘密」,不能「证明身份」;ECDSA 只能「证明身份」,不能「商量秘密」。分工明确,各司其职。

这就好比:一把钥匙开保险箱(ECDH),一枚印章证明文件是你签的(ECDSA)。你不会用印章去开保险箱,对吧?

peerId 是怎么来的

peerId = SHA-256( JSON(签名公钥) )[0..32]   // 32 个十六进制字符

注意用的是签名公钥而不是身份公钥。原因很妙:

签名公钥是用来「证明身份」的那把,用它派生 ID 在逻辑上才自洽。而且这样一来,公钥一改,peerId 立刻变 —— 想冒充别人就得先破解 ECDSA,成本极高。

SHA-256 实时演示(真的在算)
输出(逐字生成)
▍
输入任意长度,输出永远是 32 字节。改一个字符,输出面目全非 —— 这就是「雪崩效应」。
你说 SHA-256 会不会有一天被破解?会啊,量子计算机嘛。不过到那天,你现在聊的「晚上吃啥」大概也已经不重要了。

好友关系 = 房间的前置条件

Cloudsea Chat 里没有「手动填服务器地址」这回事了。房间由后端在好友关系建立后自动创建:

1 你输入对方用户名或邮箱  → POST /friends/request
2 对方在好友中心看到申请  → POST /friends/accept
3 服务器创建私聊房间      → room.id / code / password
4 双方点进房间            → POST /rooms/{id}/join
5 拿到房间凭据 + 服务器 ws_url
以前还得手动抄服务器地址和房间密码,像极了 2003 年配路由器。现在终于像 2026 年了。😌

networkId:房间的门牌号

networkId = HKDF(masterKey, "", "network-id", 128bit)  // → hex

wss://server/ws/{networkId}

同一个房间的两个人,用同样的口令算出同样的 networkId,自然进到同一个信令频道。

元数据泄露提示:房间口令是服务器生成的,所以服务器知道 networkId,也就知道哪两个 peerId 在同一个房间里。它能看见「谁和谁在聊」,只是看不见「聊了什么」。

聊天消息为什么只发给一个人

房间理论上只有两个人,但服务器可以往房间里塞人。所以发送逻辑里有一条硬规则:

// 已批准且在线的目标超过 1 个 → 直接拒绝发送
if (approvedTargets.size > 1)
  throw "当前房间检测到多个已批准节点,已阻止群发";

// 循环里也只对 targetPeerId 发,其余一律跳过
if (peerId !== targetPeerId) continue;

这是防御性设计:宁可发不出去,也不能把私聊内容群发给一个陌生节点。

服务器:我把第三个人塞进房间里!
客户端:检测到多个已批准节点,拒绝发送。
服务器:……这也行?

信任名单(approvedPeers)

每个被批准的对端都会带着三样东西存进 IndexedDB 的 approvedPeers:

{ peerId, networkId,
  pubKey,          // 身份公钥 ECDH
  signingPubKey,   // 签名公钥 ECDSA
  prekeyBundle }   // 对方的预共享密钥包

加载时会做三重校验:peerId 格式、peerId 是否真的等于签名公钥的哈希、prekeyBundle 结构是否合法。任何一项不符,直接丢弃。

顺便一提:如果某个已信任节点的公钥突然变了,客户端会忽略这次公告并提示「公钥变更」—— 因为正常换密钥不该在聊天中途发生,那更像是有人在尝试替换身份。

X3DH 想解决的问题

两个人从没见过面,也没有共享密码,怎么在一个可能被监听的服务器的帮助下,商量出一把只有他们俩知道的密钥?

X3DH(Extended Triple Diffie-Hellman)的答案是:让多个 DH 结果叠加起来。任何一段被破解都不够,必须全破解才行。

你说 X3DH 的下一代会不会叫 X4DH?不兑(对),那应该是 V4DH 啊!毕竟罗马数字才配得上这种「古老又可靠」的气质。😆

先准备:预共享密钥包

每个人上线时会广播一份 prekeyBundle:

字段含义
signedPrekey签名预共享密钥(ECDH 公钥),相对长期
signedPrekeySig用签名私钥给上面那把签的名
oneTimePrekeys[]一批一次性密钥(OPK),用一次就销毁

为什么 SPK 要签名?防止服务器偷偷把它换成自己的公钥 —— 换了签名就对不上,客户端一眼识破。

四段 DH,合成一把钥匙

发起方 A 拿到 B 的 bundle 后,同时做四段 DH:

四段 DH 如何合流
DH1 = IKA × SPKB
DH2 = EKA × IKB
DH3 = EKA × SPKB
DH4 = EKA × OPKB
↓ 拼接成 32 × 4 字节 ↓
HKDF-SHA256(salt = 32 字节 0,info = 双方公钥上下文)
↓
rootKey(32 字节)
DH4 只有在对方还有可用 OPK 时才有;没有就退化成三段。OPK 用掉即焚,所以同一份 bundle 不能重复用来握手。
// info 必须双方完全一致,否则算出来的 rootKey 不一样
info = JSON.stringify({
  protocol: "cloudsea-x3dh-v6",
  networkId,
  initiatorPeerId, responderPeerId,
  initiatorIdentityPub, responderIdentityPub
});

rootKey = HKDF(ikm = DH1‖DH2‖DH3‖[DH4],
               salt = new Uint8Array(32),
               info = info,
               256 bit);
✅设计要点:房间口令(masterKey)完全没有参与 rootKey 的派生。函数签名里那个 _masterKey 参数是历史遗留,实际已被废弃不用。
这是关键 —— 否则服务器就能算出你们所有消息的密钥。
代码里 _masterKey 前面那个下划线,是程序员的「我知道它没用了但我不敢删」标记。☠️

握手全过程(三次确认才敢用)

A → B  x3dh-init
        { identityPub, ephemeralPub, signedPrekeyPub,
          prekeyId, ratchetPub, signature }

B   校验签名 → 校验 signedPrekey 是不是自己的
    → 检查 prekeyId 是否可用 → 算出同一个 rootKey
    → 生成自己的棘轮密钥对

B → A  x3dh-reply
        { identityPub, ratchetPub, prekeyId, signature }

A   校验签名 → 算 rootKey → 初始化棘轮

A → B  x3dh-confirm  (用新密钥加密)
B → A  x3dh-confirm-ack(用新密钥加密)
    // 双向都验证通过,才算握手完成

为什么要多这两步?因为签名只能证明消息来自对方,不能证明对方真的算出了同一把密钥。只有用新密钥成功加解密一次,才算真正握手成功。

就像相亲:交换了照片(公钥)还不够,得真的聊上几句(加密往返),才知道对方是不是照片上那个人。📸

一次性密钥的「不浪费」原则

代码里 OPK 的处理非常克制:

// 握手时只「保留」,不「销毁」
peerData._pendingOneTimePrekeyId = data.prekeyId;

// 只有真正收到一条用新密钥加密且验证通过的消息,才提交销毁
async _commitPendingOneTimePrekey(peerData) { ... }

因为网络可能断、握手可能失败。如果一用就销毁,失败几次你的 OPK 就耗光了,对端再也连不上你。

这叫做「先预留、后提交」。数据库里叫事务,这里叫……好吧,也是事务。

Double Ratchet 在干什么

X3DH 给了你们一把 rootKey。但一把密钥用到底是危险的:万一哪天泄漏,历史消息和未来消息全完了。

双棘轮解决的就是这个问题:每发一条消息,就换一次密钥。

这玩意儿叫「双棘轮」,因为真的有两个棘轮:一个管 DH 换密钥(大步),一个管每条消息的密钥推进(小步)。就像自行车的前后齿轮,配合起来才能一直往上爬。🚲

小步:链式密钥推进

// 每发一条消息,推进一格
nextCK = HKDF(chainKey, "", "dr-chain",        256bit)
msgKey = HKDF(chainKey, "", "dr-message-key",  256bit)

chainKey = nextCK          // 推进
// msgKey 加密完立刻丢弃,永不保存
链式推进:CK → CK → CK,每格吐出一把消息密钥
HKDF 是单向的:知道 CKn 算不出 CKn-1。所以就算某条消息的 msgKey 泄漏,之前的和之后的都还是安全的。
这就是「前向保密」和「后向保密」的物理实现。名字听着高深,其实就是:用过就扔,扔了就当没存在过。

大步:DH 棘轮

光有链式推进还不够 —— 那条链是单向的,对方的新密钥进不来。所以每收到一次对方的新棘轮公钥,就要做一次「大步」:

// 收到对方新的 ratchetPub 时
1 dhOut  = DH(我当前的棘轮私钥, 对方的新公钥)
2 rootKey = HKDF(dhOut, rootKey, "dr-rk-v2")
3 recvChainKey = HKDF(rootKey, "", "dr-chain-v2")

// 然后立刻换一对新的棘轮密钥,再算一次
4 新密钥对 = generateKeyPair()
5 dhOut2  = DH(新私钥, 对方公钥)
6 rootKey = HKDF(dhOut2, rootKey, "dr-rk-v2")
7 sendChainKey = HKDF(rootKey, "", "dr-chain-v2")

结果就是:收发两条链都被刷新了,而且刷新用的材料是 DH —— 只有同时掌握两把私钥才能推出来。

注意第 6 步:新 rootKey 是在「旧 rootKey」基础上用新 DH 结果再派生一次。所以即使某一轮的 DH 被算出来,也推不出下一轮 —— 层层叠加。

消息包的格式

0xD1 | hlen(2B) | header(JSON) | IV(12B) | ciphertext

header = { v:1, dh:{kty,crv,x,y}, pn:上一轮发送数, n:本轮序号 }

关键细节:header 本身就是 AAD(附加认证数据)。也就是说,谁要是敢改 n 或者 dh,AES-GCM 认证直接失败,整条消息作废。

这就像快递单:单子被改过,包裹就拒收。哪怕里面的东西没动过。📦

乱序消息怎么办?

网络不是排队机,第 3 条可能比第 2 条先到。双棘轮的处理办法:

// 收到 n = 5,但当前 recvCounter = 2
中间缺的 3、4 → 先把它们的 msgKey 算出来 → 存进 recvKeys
// 等迟到的消息真的来了,直接取用

// 上限保护
MAX_RATCHET_SKIP = 200   // 最多跳 200 条,防 DoS
recvKeys 最大缓存  500 条

而且这些「跳过的密钥」是按 DH 公钥分命名空间的,所以换了 DH 之后,旧链上跳过的消息照样能解。

这就是为什么你断网十分钟后重连,那些积压的消息还能一条条蹦出来 —— 而不是报「消息太旧,解不开」。🎉

解密失败要回滚

DH 棘轮一旦推进,就是不可逆的。所以代码在推进前先拍一张「状态快照」,解密失败就整个恢复回去:

ratchetSnapshot = { rootKey, sendChainKey, recvChainKey,
                    sendCounter, recvCounter, recvKeys,
                    myRatchetKeyPair, peerRatchetPub, ... }

不这么做的话,一个伪造的坏包就能把棘轮推到错误的位置,之后所有正常消息全都解不开 —— 这是一个非常现实的拒绝服务攻击。

工程上的铁律:任何不可逆的状态变更,都要先准备好回滚路径。这条在加密协议里尤其致命。

发送优先级:先直连,再中转

1 尝试 P2P DataChannel
   ├─ 成功发出 + 收到 chat-delivery-ack  → 完成
   └─ 2.5 秒内没收到 ack              → 视为失败

2 降级到加密中转(relay-chat)
   ├─ 服务端转发                      → 完成
   └─ 载荷超过中转上限                → 报错

为什么要等 ack 而不是「发出去就算成功」?因为 DataChannel 可能已经半死 —— 写进缓冲区了,但对端根本没收到。没有 ack 就没有真相。

「我已经发出去了」和「对方已经收到了」之间的距离,叫做分布式系统。🌐

P2P 路径:双层加密

明文
  → JSON 序列化
  → 棘轮加密(AES-GCM + msgKey)
  → base64
  → DataChannel 发送 { type:'chat', payload }

// 超过 100 KB 就自动切片
chunk-start { chunkId, total }
chunk       { chunkId, index, data }
// 最多 128 片
切片是为了绕开浏览器对单条 DataChannel 消息大小的限制。片与片之间靠 chunkId 归位,收齐了再拼回一整条 base64。

中转路径:临时 ECDH

P2P 不通时,走信令 WebSocket。但不能直接用 masterKey 加密(服务器知道它)。所以每次都用一对临时密钥:

// 发送方
tempKeyPair = 临时生成的 ECDH 密钥对
shared      = DH(tempPriv, 对方身份公钥)
msgKey      = HKDF(shared, "", "relay-chat-v2|" + from + "|" + to + "|relay-chat")
密文         = AES-GCM(msgKey, 明文)

// 接收方用自己的身份私钥 + 对方的临时公钥算出同一把 msgKey

信封本身还用签名私钥签了名,服务器想改任何一个字段(包括 tempPub)都会导致验签失败。

为什么 tempPub 也必须签名?因为攻击者换掉它,就能让双方算出不同的密钥 —— 虽然读不到内容,但能让你们永远聊不上。这叫「拒绝服务」,也是攻击。🚫

流量混淆:给监听者加点噪音

每 5~15 秒,客户端会随机发一些假消息,长度模仿你最近发的消息:

// 取最近 20 条消息的平均长度,加随机抖动
targetLen = avg(最近消息长度) ± 10

// 生成随机字符串 + fake:true 标记
fakeMsg = { ..., text: "xK9mQ2", fake: true }

接收方看到 fake: true 就直接丢弃,不入库不渲染。但对监听者来说,流量里多了一堆无法区分真假的包。

🎭服务器视角:这俩人聊得真热闹,一分钟十几条。
实际情况:其中一半是 xK9mQ2、Lp4vR8 这种随机串。

消息去重:msgId 只能认领一次

// 内存 Set + IndexedDB 双重去重
claimMsgId(msgId)
  ├─ 内存里已有        → 返回 false
  ├─ IDB add 成功      → 返回 true(首次)
  └─ IDB 主键冲突      → 返回 false(重复)

// 处理失败要「归还」,否则对方重发也进不来
releaseMsgId(msgId)

为什么双重?因为 IndexedDB 是异步的,两次并发的同 ID 消息可能同时通过内存检查。IDB 的主键唯一约束才是最终的守门员。

而 nonce 表则负责信令层的重放防护:同一个签名包第二次出现,直接拒收。TTL 是 25 分钟。

两条传输路线

P2P 直发

走 DataChannel,分块 256 KB。不受中转上限限制,硬上限 10 GiB。

加密中转

走 WebSocket,分块 256 KB。上限完全由服务器下发,客户端不设死值。

前端的选择逻辑是全自动的:先试 P2P,不行就转中转。用户不需要选模式。

这体验就像打车 App:有快车叫快车,没有就自动给你叫专车,而不是弹窗问你「请问您要选哪种车型」。🚕

P2P 文件包格式

Magic "CSF1"(4B) | 版本(1B) | transferId(16B) | index(4B) | total(4B) | IV(12B) | 密文

每一块都独立用 AES-GCM 加密,但AAD 把整条传输的元信息都绑了进去:

// AAD:双向身份 + 序号 + 总数 + 总大小
"CloudseaChat-file-v2|" + transferId + "|"
  + sort(peerIdA, peerIdB).join("|") + "|"
  + index + "|" + total + "|" + size

这意味着:把第 3 块挪到第 5 块的位置、把别人的分块塞进你的传输、把总大小改小 —— 全都会导致解密失败。

给每个零件都刻上「这是 3 号车的 3 号零件,总共 50 个」。想装错?装不进去。🔧

严格的顺序 + 逐块确认

接收方:
  index === nextIndex  → 写入,nextIndex++
  index <  nextIndex  → 已写过,回 chunk-ack(重发无妨)
  index !== nextIndex → 回 chunk-nack(要求重发)

发送方:
  等 chunk-ack,超时 900ms
  → 3 次立即重试 + 2 次间隔 2 秒重试
  → 还不行就标记为「暂停,进度已保留」

为什么不支持乱序写入?因为 OPFS 的写入是追加式的,顺序写才能保证文件字节严格对齐。乱序写需要 seek,代价高且容易出错。

重发的是同一个 AEAD 包(同样的 IV 和密文),所以接收方重复收到同一块完全无害 —— 解密结果一样,只是被 index < nextIndex 分支挡掉。

中转文件:密钥为什么不能用房间口令

⚠️ 房间口令是服务器生成的,服务器当然知道 masterKey。如果用它派生文件密钥,等于把文件直接寄给服务器。
// 发送方:临时私钥 × 对方身份公钥
shared = DH(临时私钥, 对方身份公钥)

// 接收方:本机身份私钥 × 对方临时公钥
shared = DH(本机身份私钥, 对方临时公钥)

// 两边算出的 shared 完全相同
fileKey = HKDF(shared, salt="relay-file-salt|"+transferId,
                        info="cloudsea-relay-file-v2|"+transferId)

而身份私钥是用 localStorageKey 加密存在本地的,localStorageKey 又依赖本机独有的 localSalt —— 服务器推不出来。

服务器:我搬了半天文件,一个字节都看不懂。
客户端:对啊,你只是搬运工,别想着当读者。📦

中转的自我限速

// 服务器对中转分块有独立限流,客户端必须压到它下面
RELAY_FILE_CHUNK_PER_SEC = 15

minInterval = Math.max(20, Math.floor(1000 / 15))  // ≈ 66ms

如果发太快,超限的分块会被服务器静默丢弃,接收方永远等不到 —— 文件就废了。所以客户端宁可慢一点。

另外还有一套未确认分块重传机制:接收方周期性回 relay-file-ack { upTo },发送方把 upTo 之后的分块重发一遍。

🛡️边界校验很重要:如果 upTo 不做范围检查,一个恶意的超大 upTo 就能让发送方误以为全部已确认,从而停止重传 —— 传输直接卡死。代码里写的是 upTo > state.total - 1 → return。

超过声明大小怎么办?

// 实际接收量超过声明大小 2% → 判定为疑似篡改
if (receivedBytes + plain.length > Math.ceil(size * 1.02)) {
  throw "接收内容超过声明大小 2%,已按疑似篡改处理";
}

一旦触发:立即中止写入 → 删除已存文件 → 通知对端 → 弹安全警告。不给任何「也许只是算错了」的机会。

「我就多传了 3% 而已」—— 这就像说「我就多拿了 3% 的钱而已」。不是钱的问题,是规则的问题。

为什么要做水印

端到端加密的保护对象是传输过程。但如果对方自己截图发出去,加密一点用都没有。

所以 Cloudsea Chat 在屏幕上铺了一层肉眼几乎看不见、但机器能读出来的隐形水印,里面藏着:谁的设备、什么时间、什么 IP、哪一块屏幕。

加密防的是「路上有人偷看」,水印防的是「对方看完到处说」。两个方向,两套武器。⚔️

为什么不用最低有效位(LSB)

❌ LSB 是在每个像素的最低 1 位上藏数据。听起来很隐蔽 —— 但截图一次、压缩一次、缩放一次,就全没了。甚至把图拖进任何编辑器保存一下都会归零。

Cloudsea Chat 用的是 DCT 中频系数嵌入:

1 把 256×256 的水印块切成一个个 8×8 像素小块
2 每个小块做 DCT(离散余弦变换)→ 变成 64 个频率系数
3 挑一个中频系数,根据要藏的比特 ± 一个幅度
4 做逆变换(IDCT)→ 变回像素

为什么是中频?低频改了肉眼看得见,高频改了压缩一压就没。中频两头都躲得过。

DCT 中频系数像牛皮糖,扯一扯、揉一揉,还在。LSB 像便利贴,风一吹就飞了。🍬

藏在里面的到底是什么

每个 256×256 的方块里藏 65 字节的加密信封:

// 明文载荷 32 字节
magic(2) + 版本(1) + IP类型(1) + 指纹(6) + IP(6)
+ 时间戳(4) + tileX(2) + tileY(2) + nonce(2)
+ 页面哈希(2) + 保留(1) + CRC16(2)

// 再用 AES-GCM 包一层,变成 65 字节
0x4C 0x57 0x04 + 日期(4B) + 密文(32B + 16B tag) + CRC16(2B)
Magic2B
Ver1B
IP类型1B
指纹6B
IP6B
时间4B
tileX2B
tileY2B
nonce2B
页哈希2B
保留1B
CRC162B

指纹只有 6 字节(12 个 hex),是身份哈希的前缀 —— 足以区分「不同设备」,又不至于泄漏完整身份。

密钥从哪来:又是 ECDH

// 双方各自生成本日的水印密钥对,只广播公钥
A 有 (privA, pubA),B 有 (privB, pubB)

// 双方算出的共享秘密完全相同
shared = DH(privA, pubB) = DH(privB, pubA)

// 上下文由双方公钥 + 日期确定(排序后拼接,保证双方一致)
context = "cloudsea-watermark-v2|" + date + "|"
        + sort(pubA.id, pubB.id).join("|")

wmKey = HKDF(shared, salt=SHA256("cloudsea-watermark-salt-v2|"+date),
                     info=context, 256bit)

所以私钥永远不出本机(只存在 IndexedDB),服务器只经手公钥。提取水印时必须同时具备:本机私钥 + 对方公钥 + 日期。

服务器:我把公钥都存下来了!
客户端:那你有私钥吗?
服务器:……没有。
客户端:那就别看了。🔒

怎么在截图里找到水印

每个方块(tile)的三个角上各画一个 7×7 的同心方块图案 —— 和二维码的定位图案一个思路:

■ ■ ■ ■ ■ ■ ■
■ · · · · · ■
■ · ■ ■ ■ · ■
■ · ■ ■ ■ · ■
■ · ■ ■ ■ · ■
■ · · · · · ■
■ ■ ■ ■ ■ ■ ■

这三个角让提取器能算出:方块的缩放比例、旋转角度、原点位置。哪怕你把截图缩放到 63%、转 90 度、再裁掉一半,都能还原。

这几个定位图案是可见的(在 18% 透明度下像一层淡淡的纹理)。用可见图案换定位稳定性,这个取舍是值得的。

提取:三阶段搜索

阶段 1  候选定位图案 + ±16px 网格搜索,只做「无纠错」直接解码
阶段 2  如果还不成功,对前 2 个候选做 ±2~4px 逐像素微调
阶段 3  只对前 4 个候选做「有限纠错」(翻转 1~3 个比特位)

为什么要分阶段?因为解密一次 AES-GCM 挺贵的。如果一上来就对所有候选做纠错搜索,一张图能算到天荒地老。

代码里还加了 900 毫秒总预算:超时就放弃,绝不把页面卡死。

这就是工程和学术的区别:学术追求「理论上一定能解出来」,工程追求「解不出来也得把界面还给我」。😅

PN 序列:让嵌入「抖动」起来

// 每一块的位置有一个伪随机符号
sign = _locPnSign(blockIndex)   // ±1

// 嵌入时:比特值 × 伪随机符号 × 幅度
dC = bitSign(bit) × pn × 90

// 提取时:系数 × 同一个伪随机符号 → 累加平均
soft[b] = Σ(pn × coeff) / count

这个「抖动」有两个好处:一是让人眼更难察觉(噪声被打散),二是就算局部被破坏,其他地方还能通过累加平均恢复出来。

内置健康检查

每 3 分钟,程序会拿自己生成的第一块 tile 试着自己解一遍:

extractLocationWatermark(tile, { key, roomCode })
  → 校验指纹、IP 类型、tileX/Y、页面哈希
  → 全部对上才输出 WM-OK

这是为了防止「水印生成悄悄坏了但没人发现」—— 如果水印失效了,追溯能力就等于零,而且你毫不知情。

这叫做「自检」。就像电梯定期自己跑一遍,出问题会自己报警,而不是等有人掉下去才发现。🛗

IndexedDB 结构(v4)

表名主键存什么
keysid身份密钥(加密)、预共享密钥(加密)、水印私钥、对端水印公钥
messages自增 id加密后的消息体 + msgId / netTs / burnAt 索引
approvedPeerspeerId信任名单:公钥 + 预共享密钥包
noncesnonce信令防重放记录,带 TTL 索引
claimedMsgsmsgId消息 ID 去重,25 分钟后可清理
服务器端一条消息都不存,但你的浏览器里存得挺全的 —— 只是全都加了锁,钥匙还是 localStorageKey。🔐

本地消息是怎么存的

// 存:先加密,再入库存
plain = { msgId, sender, text, image, file, timestamp, burnAt, isSelf, peerId }
enc   = AES-GCM(localStorageKey, JSON(plain))
record = { msgId, encrypted: base64(enc), timestamp, networkId, burnAt? }

// 读:解密失败就跳过(密钥换了/数据坏了)
plain = JSON.parse(AES-GCM-decrypt(localStorageKey, base64(record.encrypted)))

注意 networkId 是明文存的 —— 因为要用它做索引来区分不同房间的历史。这算是刻意的取舍:泄露「你在哪些房间待过」,换取查询效率。

自毁机制

// 发消息时:默认 60 秒后自毁
burnAt = timestamp + burnAfterSeconds × 1000

// 每 30 秒扫一次
index('burnAt').openCursor(IDBKeyRange.bound(1, Date.now()))
  → 删记录 + 删 DOM + 删关联的 OPFS 文件

文件的销毁时限更长一些(5 分钟),因为文件可能很大,用户可能需要时间下载。

⚠️ 务必理解:这是「尽力而为」的销毁。浏览器和操作系统不保证磁盘上的数据块被立刻覆写。如果设备被物理取证,理论上仍可能从文件系统残余里恢复。
真正的「阅后即焚」需要全盘加密 + 安全擦除,这在纯网页环境里做不到。
「已删除」的意思是「我看不到了」,不等于「它不存在了」。这两件事之间的差距,就是数据取证行业的饭碗。🔍

文件存在哪:OPFS

// Origin Private File System —— 浏览器私有的文件系统
root   = await navigator.storage.getDirectory()
dir    = await root.getDirectoryHandle('cloudseachat-files', { create })
handle = await dir.getFileHandle(storageKey + '.bin', { create })
writer = await handle.createWritable({ keepExistingData: true })

为什么用 OPFS 而不是塞进 IndexedDB?因为大文件(几百 MB 甚至 GB)放进 IndexedDB 会让整个数据库变慢变卡,而 OPFS 是流式写入,边收边写,内存占用恒定。

keepExistingData: true 是断点续传的关键 —— 连接中断后重新打开写入器时,可以从已写入的位置继续,而不是从头再来。

断线重连不丢进度

// 连接断开 ≠ 用户取消
_abortFileTransfersForPeer(peerId, reason)
  // 不 abort writer,不删文件,只标记「中断」
  state.lastInterruptionAt = Date.now()

// 新连接建立后自动续传
_resumePendingP2PFileTransfers(peerId, peerData)
  → 从 lastAckedIndex + 1 继续

这是一个很容易写错的地方:很多实现在断线时直接把半成品删掉,用户重连后得从头再传一遍 800MB。这个实现选择保留进度。

「网络抖了一下,进度条归零」—— 这大概是当代最令人血压升高的体验之一。😤

服务器能看到什么 / 看不到什么

服务器能看到服务器看不到
谁和谁在同一个房间消息明文
双方的 peerId、昵称、公钥文件内容
消息的大小、时间、频率水印私钥
中转文件的密文本地历史消息
房间口令(因而知道 masterKey)localSalt(因而算不出 localStorageKey)
一句话总结:服务器知道你跟谁聊、多久聊一次、每次聊多大。但它不知道你聊了啥。这已经是个相当不错的位置了。😌

这套设计防不住什么

① 流量分析

服务器即使读不到内容,也能看到「晚上 11 点,A 和 B 聊了 47 条消息,其中 12 条比较大」。混淆包能打散一部分特征,但打不散「你们在聊天」这个事实本身。

② 对方主动传播

端到端加密保护传输,保护不了对方的手指。水印是唯一的追责手段 —— 但它只能事后追溯,不能事前阻止。

③ 恶意改造客户端

如果对方用的是自己魔改的版本,那么「自毁」「水印」「大小校验」全都可以被绕过。所有客户端侧的安全机制,在对手控制客户端时都会失效。

④ 设备被攻破

密钥在内存里是明文的。如果有恶意软件能读取进程内存,那加密再强也没用。

🔓 这是端到端加密的共同边界,不是 Cloudsea Chat 的缺陷。Signal、WhatsApp 也一样。
安全模型的第一原则:先明确你防的是谁。
如果你防的是「服务器管理员闲得慌」,这套设计够了。
如果你防的是「对方拿着扳手来找你」,那什么设计都不够。🔧

这套设计的三个亮点

① 房间口令被严格限制

服务器知道 masterKey,所以代码里它只用来做两件事:派生 networkId(房间门牌)和 localStorageKey(本地保险柜钥匙,还加了本机随机盐)。消息密钥、文件密钥、水印密钥全部绕开它,改用身份密钥的 ECDH。

这是整套设计里最容易被写错、也最关键的一条。写错一步,前功尽弃。✅

② 所有上下文都被签名覆盖

中转消息的 tempPub、文件分块的 AAD、棘轮消息的 header —— 全都在签名或认证范围内。任何一个字段被改,整包作废。

③ 失败必须有回滚

棘轮推进有快照、X3DH 的 OPK 是「先预留后提交」、文件中断保留进度。这套代码在设计上明显考虑过「出错之后怎么办」。

这三条加起来,就是「工程级加密」和「教学级加密」的区别。

项目信息

项目值
版本1.2.6
构建号BataCloudseaChat20261025A5
构建时间2026-10-25 13:45:00 +0800
协议版本8
水印协议版本7
域chat.lbcraft.top
部署平台Cloudflare Pages
开发者PangChiZzyc
项目用途仅用于学习使用
当前为 Beta 版本,暂不开放注册平台。任何组织或个人不得利用本项目开发任何违法或商业活动。
看到 Bata 了吗?不是 Beta。这大概也是一种签名 —— 独一无二的那种。😏

一句话收尾

✨ Cloudsea Chat 的核心思路其实非常朴素:让服务器只做它必须做的事(牵线),把它的权限压到最小(读不到内容),然后对每一件它可能搞小动作的事情都加上验证和回滚。
密码学提供工具,工程提供纪律。
工具可以抄,纪律得自己练。💪