Dog-Babble,把 AES 密文伪装成一串哈基米
Dog-Babble 是一个很难用一句正经话介绍的项目。
它使用 PBKDF2 和 AES-256-GCM 加密消息,然后把二进制密文重新编码成用户指定的字符。假如字典表填的是「哈基米」,最后得到的密文就会全部由「哈」「基」「米」组成。
看起来像有人突然开始发疯。
实际上里面可能藏着一句「明天下午三点开会」。
我要先说明,这个项目不是为了替代 Signal,也不是为了证明我做出了什么全新的安全通信协议。真正需要安全通讯时,应该使用经过长期审计和大量实战验证的成熟产品。
Dog-Babble 更像一个密码学实验。
我想知道,现代加密算法产生的二进制数据,能不能被包装成一种具有日常语言外观的东西。它仍然是密文,但不再是一串 Base64 字符,也不是满屏无法输入的乱码。
它甚至可以看起来有点可爱。
从一个密码得到真正的密钥
用户输入的共享密码不能直接拿去做 AES 密钥。
人类喜欢的密码通常很短,分布也不均匀。有人用生日,有人用姓名缩写,还有人坚定地认为在密码后面加一个感叹号就已经足够安全。
因此第一步是 PBKDF2。
Dog-Babble 每次加密都会生成一个随机的 16 字节盐值,再使用 PBKDF2-HMAC-SHA-256 对用户密码进行 600000 次迭代,最终派生出 256 位 AES 密钥。
盐值的作用不是保密。
它会直接和密文打包在一起。它真正解决的是重复问题。即使两个人使用相同密码加密相同内容,只要随机盐值不同,派生出的密钥过程和最终结果也会不同。
600000 次迭代则是在故意让计算变慢。
对正常用户来说,这段等待仍然可以接受。对试图批量猜测密码的人来说,每一次尝试都要支付更多计算成本。
派生出来的密钥被标记为不可导出。JavaScript 可以要求浏览器使用它加密和解密,却不能再把原始密钥字节轻易读取出来。
这当然不能消灭所有前端风险,但至少减少了一种不必要的暴露方式。
AES-256-GCM 不只负责把文字打乱
真正的加密步骤使用 AES-256-GCM。
GCM 属于认证加密模式。它不仅让明文不可读,还会为密文附带完整性认证信息。如果密文在传输过程中被修改,哪怕只改动一个字符,解密过程通常也会直接失败,而不是输出一段悄悄被篡改的内容。
每次加密还会生成一个随机的 12 字节 IV。
最后,我把数据按照下面的顺序拼起来。
16 字节随机盐值12 字节随机 IVAES-GCM 密文与认证标签这些数据本质上还是一个 Uint8Array。
到这里,密码学部分已经基本结束。接下来才是 Dog-Babble 真正开始胡闹的地方。
BigInt 和那个不能省的哨兵位
我要把任意二进制数据转换成用户指定字典表的排列组合。
这件事本质上和进制转换一样。
十进制使用十个数字。十六进制使用十六个符号。如果字典表是「哈基米」,就相当于使用一个三进制字符系统。
我先把整段二进制转换成一个巨大的 BigInt,然后不断对字典长度取余。余数决定当前应该选择字典里的哪个字符,再继续整除,直到数值变成零。
假设字典表长度是三。
余数为零就映射到「哈」。
余数为一就映射到「基」。
余数为二就映射到「米」。
解码时反过来,从左到右读取字符,不断执行「原数值乘以三,再加上当前字符的位置」,最后就能恢复出原来的 BigInt。
听起来很顺利。
然后前导零出现了。
二进制数组可能以 00 开头,但转换成数字后,数字并不会记住自己前面曾经有几个零。再把 BigInt 转回字节数组时,这些零会永久消失,后面的盐值、IV 和密文边界全部错位。
最后的结果不是偶尔失败。
是必然解密失败,而且错误信息看起来还像是密码输错了。
我为此加了一个哨兵字节。
转换前,先在原始二进制最前面放入一个值为一的字节。还原时,再把第一个字节去掉。这个字节确保整个数字拥有一个确定的最高位,从而保留后面所有前导零。
这是整个项目里我最喜欢的细节之一。
它不是什么复杂算法,只是一个很小的工程补丁。但少了它,后面的所有设计都站不住。
字典表决定了密文的性格
Dog-Babble 对字典表只有几个基础要求。
至少包含两个字符。
字符不能重复。
解密时必须使用与加密时完全相同的字典顺序。
代码按照 Unicode 码点拆分字典,因此除了汉字,也可以使用 emoji 或其他字符。这里不能简单依赖传统的字符串下标,因为部分 emoji 在 UTF-16 中占用两个代码单元。使用 [...alphabet] 展开后,才更接近用户眼里的一个字符。
不同字典会产生完全不同的视觉效果。
「哈基米」会得到一段发疯文学。
「喵呜」会得到一段看起来像猫在键盘上走过的内容。
使用几种表情符号时,密文又会变成一种意义不明的社交媒体评论。
这也是我给它取名 Dog-Babble 的原因。
Babble 本来就有含混不清、咿咿呀呀的意思。Dog-Babble 生成的内容表面上没有意义,但只要拥有正确的密码和字典,它就能重新变回原文。
我很喜欢这种反差。
严肃的 AES 加密被包在一层不太严肃的外壳里。技术没有因此失效,反而因为表现形式变得更容易被人理解和记住。
为什么一个 npm 包都没有
整个项目只有静态页面、CSS 和 JavaScript。
没有后端,没有数据库,也没有 npm install。所有密码学操作都通过浏览器原生的 window.crypto.subtle 完成。
现代前端项目很容易在还没写业务之前,就先拥有几百兆的依赖目录。打包器、转译器、组件库、状态管理、工具函数和各种插件层层叠加。
Dog-Babble 没有这个必要。
浏览器已经提供了经过实现和优化的 Web Crypto API。我不需要为了调用 AES 再引入一个第三方密码学库,也不希望自己手写 AES。
自己实现密码学算法通常不是勇敢。
通常只是危险。
零外部依赖还带来了另一个好处。项目非常容易审查。核心逻辑集中在一个 JavaScript 文件里,任何人都可以直接查看密码如何派生、数据如何打包、BigInt 如何转换。
它后来被独立部署到 babble.zylatent.com,静态托管就足够了。
纯前端加密的边界
到了这里,最容易产生一个误解。
既然明文只在浏览器里加密,服务器看不到明文,那是不是就安全了。
没有这么简单。
纯前端加密有一个根本问题。用户运行的加密代码本身,也是服务器发送给浏览器的。
如果攻击者控制了托管服务器,他可以把 JavaScript 替换成恶意版本。页面表面上仍然正常工作,背后却可以在加密前把密码和明文发送出去。
如果传输层被成功劫持,用户加载了被篡改的脚本,结果也一样。
浏览器里的 XSS 漏洞、恶意扩展、被控制的设备和过弱的共享密码,同样不是 AES 能解决的。
所以 Dog-Babble 不能被描述成真正的安全通讯产品。
它没有端到端身份验证,没有密钥交换协议,没有前向保密,没有设备管理,也没有经过独立安全审计。
它只是一个学习导向的密码学实验。
但实验并不意味着没有价值。
通过这个项目,我第一次把密钥派生、随机盐值、IV、认证加密、二进制打包、BigInt 和自定义进制编码完整地串在了一起。
我也越来越喜欢一种开发方式。
先用可靠的技术把底层问题解决,再给它套上一层足够有趣的表达。
有些项目是为了提高效率。
有些项目是为了服务用户。
还有些项目存在的理由,只是因为把 AES 密文变成「哈基米哈哈米基米」这件事,实在太有意思了。
