Cloudsea Chat 是什么
一个纯前端的端到端加密聊天程序。它的核心主张只有一句话:
三条外部链路
auth_code → 换成访问令牌。Authorization: Bearer。浏览器之间的两条通道
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)
masterKey。因此 masterKey 绝对不能用来加密消息 —— 那样等于把钥匙交给邮局。
masterKey 只干两件杂活:派生 networkId(房间门牌号)和 localStorageKey(本地保险柜钥匙)。真·机密的事它一概不碰。这叫「知道分寸」。本地保险柜钥匙
localSalt = 本机随机生成的 16 字节(只存 localStorage,从不上传) localStorageKey = HKDF(masterKey, localSalt, "local-storage", 256bit)
这一步的意义是:即使服务器知道 masterKey,也算不出 localStorageKey,因为 localSalt 是本机独有的、从未离开过这台设备的随机数。
客户端:哦,那你猜猜我的 localSalt 是多少?
服务器:……
两把身份钥匙
每个用户在本地生成两对 P-256 密钥,用 localStorageKey 做 AES-GCM 加密后存进 IndexedDB:
| 密钥 | 算法 | 用途 |
|---|---|---|
| 身份密钥 | ECDH P-256 | 参与密钥协商,算出共享秘密 |
| 签名密钥 | ECDSA P-256 | 给消息/公告签名,证明「是我发的」 |
为什么要两把?因为 ECDH 只能「商量秘密」,不能「证明身份」;ECDSA 只能「证明身份」,不能「商量秘密」。分工明确,各司其职。
peerId 是怎么来的
peerId = SHA-256( JSON(签名公钥) )[0..32] // 32 个十六进制字符
注意用的是签名公钥而不是身份公钥。原因很妙:
签名公钥是用来「证明身份」的那把,用它派生 ID 在逻辑上才自洽。而且这样一来,公钥一改,peerId 立刻变 —— 想冒充别人就得先破解 ECDSA,成本极高。
好友关系 = 房间的前置条件
Cloudsea Chat 里没有「手动填服务器地址」这回事了。房间由后端在好友关系建立后自动创建:
1 你输入对方用户名或邮箱 → POST /friends/request 2 对方在好友中心看到申请 → POST /friends/accept 3 服务器创建私聊房间 → room.id / code / password 4 双方点进房间 → POST /rooms/{id}/join 5 拿到房间凭据 + 服务器 ws_url
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 结果叠加起来。任何一段被破解都不够,必须全破解才行。
先准备:预共享密钥包
每个人上线时会广播一份 prekeyBundle:
| 字段 | 含义 |
|---|---|
signedPrekey | 签名预共享密钥(ECDH 公钥),相对长期 |
signedPrekeySig | 用签名私钥给上面那把签的名 |
oneTimePrekeys[] | 一批一次性密钥(OPK),用一次就销毁 |
为什么 SPK 要签名?防止服务器偷偷把它换成自己的公钥 —— 换了签名就对不上,客户端一眼识破。
四段 DH,合成一把钥匙
发起方 A 拿到 B 的 bundle 后,同时做四段 DH:
// 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。但一把密钥用到底是危险的:万一哪天泄漏,历史消息和未来消息全完了。
双棘轮解决的就是这个问题:每发一条消息,就换一次密钥。
小步:链式密钥推进
// 每发一条消息,推进一格 nextCK = HKDF(chainKey, "", "dr-chain", 256bit) msgKey = HKDF(chainKey, "", "dr-message-key", 256bit) chainKey = nextCK // 推进 // 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 —— 只有同时掌握两把私钥才能推出来。
消息包的格式
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 片
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 分钟。两条传输路线
走 DataChannel,分块 256 KB。不受中转上限限制,硬上限 10 GiB。
走 WebSocket,分块 256 KB。上限完全由服务器下发,客户端不设死值。
前端的选择逻辑是全自动的:先试 P2P,不行就转中转。用户不需要选模式。
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 块的位置、把别人的分块塞进你的传输、把总大小改小 —— 全都会导致解密失败。
严格的顺序 + 逐块确认
接收方: index === nextIndex → 写入,nextIndex++ index < nextIndex → 已写过,回 chunk-ack(重发无妨) index !== nextIndex → 回 chunk-nack(要求重发) 发送方: 等 chunk-ack,超时 900ms → 3 次立即重试 + 2 次间隔 2 秒重试 → 还不行就标记为「暂停,进度已保留」
为什么不支持乱序写入?因为 OPFS 的写入是追加式的,顺序写才能保证文件字节严格对齐。乱序写需要 seek,代价高且容易出错。
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%,已按疑似篡改处理"; }
一旦触发:立即中止写入 → 删除已存文件 → 通知对端 → 弹安全警告。不给任何「也许只是算错了」的机会。
为什么要做水印
端到端加密的保护对象是传输过程。但如果对方自己截图发出去,加密一点用都没有。
所以 Cloudsea Chat 在屏幕上铺了一层肉眼几乎看不见、但机器能读出来的隐形水印,里面藏着:谁的设备、什么时间、什么 IP、哪一块屏幕。
为什么不用最低有效位(LSB)
Cloudsea Chat 用的是 DCT 中频系数嵌入:
1 把 256×256 的水印块切成一个个 8×8 像素小块 2 每个小块做 DCT(离散余弦变换)→ 变成 64 个频率系数 3 挑一个中频系数,根据要藏的比特 ± 一个幅度 4 做逆变换(IDCT)→ 变回像素
为什么是中频?低频改了肉眼看得见,高频改了压缩一压就没。中频两头都躲得过。
藏在里面的到底是什么
每个 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)
指纹只有 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 度、再裁掉一半,都能还原。
提取:三阶段搜索
阶段 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)
| 表名 | 主键 | 存什么 |
|---|---|---|
keys | id | 身份密钥(加密)、预共享密钥(加密)、水印私钥、对端水印公钥 |
messages | 自增 id | 加密后的消息体 + msgId / netTs / burnAt 索引 |
approvedPeers | peerId | 信任名单:公钥 + 预共享密钥包 |
nonces | nonce | 信令防重放记录,带 TTL 索引 |
claimedMsgs | msgId | 消息 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 条比较大」。混淆包能打散一部分特征,但打不散「你们在聊天」这个事实本身。
② 对方主动传播
端到端加密保护传输,保护不了对方的手指。水印是唯一的追责手段 —— 但它只能事后追溯,不能事前阻止。
③ 恶意改造客户端
如果对方用的是自己魔改的版本,那么「自毁」「水印」「大小校验」全都可以被绕过。所有客户端侧的安全机制,在对手控制客户端时都会失效。
④ 设备被攻破
密钥在内存里是明文的。如果有恶意软件能读取进程内存,那加密再强也没用。
如果你防的是「服务器管理员闲得慌」,这套设计够了。
如果你防的是「对方拿着扳手来找你」,那什么设计都不够。🔧
这套设计的三个亮点
① 房间口令被严格限制
服务器知道 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 |
| 项目用途 | 仅用于学习使用 |
Bata 了吗?不是 Beta。这大概也是一种签名 —— 独一无二的那种。😏一句话收尾
工具可以抄,纪律得自己练。💪