下面两段话,内容完全相同。其中一段里藏着一句 issued-to:alice 2026-07-27。请找出是哪一段。
本季度财报草稿,仅限内部审阅。营收同比增长 23%,毛利率维持在 41% 左右。
本季︀︀︀︀︁︇︍︃︌️︉️︅︌︋︉︁︆️︄︍︎︎︋︀︎度财报草︀︁︌︍︃︈️︂︊︀︆︄︌︁︉︃︆︅︊︌︁︋︊︍︆︄稿,仅️️︁︄︀︃︀︆︂︄限内部︀︂︍︌︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀︀审阅。营︀︃︃︄︁︁︌︃︁︅︄︇︍︊︅︋︄︅️︁︌︉︈︁︌︃收同比︀︄︄︊︆︆︉︎︍︊︊︃︇︋︄︆︆︁︎︊︋︎︈︃︌︀增长 ️️︁︄︀︃︀︆︂︄23%,︀︅︈︎️︎︂️︎︀︌︁︉︀︉︉︀︉︋︍︀︂︅︋︋︍毛利率︀︆︁︈︈︀︇︋︂︍︁︉︅︎︊︍︎︅︃︇︂︀️︂︅︋维持在︀︇︌︆︉︁︉︎︇︆︆︍️️︅︋︄︋︊️︁︉︀︂︎︇ 41%️️︁︄︀︃︀︆︂︄ 左右︀︈︊︁︉︅︁︉︀︂︎︇︋︉︁︀︌︆︃︍︍︁︌︇︅︈。
答案是第二段。这个结论无法通过阅读得到:复制进记事本、换字体、逐字符比对,两段依然完全一致。你可以把第二段粘进这个工具验证。再进一步——先删掉它的三分之一,水印仍然能完整恢复。
这就是文本盲水印。这篇文章讲它的实现原理,以及我把它写错的每一个地方。
我的工具箱里已经有图片水印,文本水印是顺着补上的。这个方向上最知名的开源实现是 text_blind_watermark,原理一句话能说完,据说在微信、钉钉、知乎、Chrome 上都能存活。我拿它当起点,实际用下来撞出的问题足够多,最后重写了一套。
这不是一篇挑刺的文章。原版把"用零宽字符藏信息"从一个想法变成了能跑的库,我的项目是踩着它的肩膀起来的。但它踩中的坑既多又典型,值得单独复盘。顺序是:原版怎么做的,它在哪里失效,然后按失效的顺序重建一遍。
一个聪明的开头,和它看不见的脆壳
原版的核心思想一句话可以讲完:把水印加密后转成二进制,"0"写成一种不可见字符,"1"写成另一种,拼成一长串插进原文的随机位置。
代码很短,主干如下(有删节):
self.chr0 = chr(0x2060) # WORD JOINER
self.chr1 = chr(0xFEFF) # ZERO WIDTH NO-BREAK SPACE
def add_wm_rnd(self, text, wm):
idx = system_random() % len(text)
return text[:idx] + self.generate_watermark(wm) + text[idx:]
提取时找到第一段连续的不可见字符,还原比特,XOR 解密。
它简洁,也确实能跑。问题在于,它几乎每一个环节都刚好停在能跑。
五种死法,每一种都在设计层,不在代码层
死法一:用 BOM 当信使。 U+FEFF 的本职是字节序标记。编辑器、传输链路、解析器普遍把它当作编码声明处理,直接吞掉,不报错也不提示。而插入位置是随机的:如果落在文件开头附近,且第一个比特恰好是 1,存盘再打开,这个比特就不存在了。后续比特流整体错位,解出乱码。U+FEFF 的真正问题不在于它会被删掉,而在于删掉它的每一个程序都认为自己做得对。
死法二:载体本身参与排版。 U+2060 WORD JOINER 的职责是阻止断行。208 个 WJ 连成一串插在两个字之间,排版引擎会把这一段当成不可断开的整体。多数时候看不出来,直到某一行溢出容器。
死法三:整块插入。 26 字节水印加密后是 208 比特,也就是 208 个连续的不可见字符。方向键经过这一段会连续多次无响应;复制到部分环境会变成问号或方框。更关键的是删除:只要删除范围与这一整块相交,水印全部丢失。删掉三分之一文本,水印有约三分之一的概率整体消失,其余情况毫发无伤。这不是鲁棒性,是抽奖。提取端也只读第一段连续的不可见字符,一旦这条串被切断,后半部分永久丢失。
死法四:加密层不成立。 原版用线性同余发生器产生伪随机流,与水印异或。它的状态空间可以在一台普通笔记本上枚举完;在已知部分明文的情况下,种子可直接解出。更重要的是没有完整性校验:密码输错时不会得到"密码错误",只会得到一段无法解释的字节,或者解码器抛异常。"这段文本没有水印""我密码输错了""水印被人改过"这三种情况无法区分。对一个用于溯源的工具来说,无法区分就等于没有结论。
死法五:对宿主文本没有任何认知。 不可见字符插进 Markdown 链接的 ](url) 中间,链接失效;插进代码块,会原样进入 <code>,在部分等宽字体下显形;如果原文里本来就合法地存在 FEFF 或 WJ,提取时会被当作水印比特读进来。
五处失效有一个共同点:没有一处是代码写错了,全部是设计时没有考虑到。所以修复不能靠打补丁,每一层都要重做一遍。
下面就是重建的顺序:
flowchart TD
A[水印明文 · 26B] --> B[压缩<br/>ASCII 7bit / deflate 取小]
B --> C[AES-256-GCM 加密<br/>随机盐 + 认证标签]
C --> D[分片 · 12B/片 + 1B 序号]
D --> E[Reed–Solomon<br/>k 数据片 + m 校验片]
E --> F[控制帧 ×2~3<br/>交错分散]
F --> G[变体选择符编码<br/>U+FE00–FE0F]
G --> H[按位置规则插入宿主文本]
H --> I{可选 · 第二通道}
I --> J[半角/全角标点<br/>只存载荷指纹]
第一步:换掉载体
需求很明确:找一个除了占据码点之外不做任何事的字符。
- U+FEFF BOM:被普遍当作可删除的元数据,排除。
- U+2060 WJ:抑制断行,影响排版,排除。
- U+200D / U+200C:在阿拉伯文和印地语中会改变连写形态,在 emoji 序列中会把多个码点合成一个字形,插错位置就改变了可见内容。排除,保留为备选方案。
- U+200B 零宽空格:相对干净,但单独一个字符不够用。
最后落在变体选择符(Variation Selectors,U+FE00–FE0F)。这 16 个字符的职责,是跟在特定字符后面切换它的字形。当它们单独出现,或前面是一个没有定义变体的普通字符时,规范要求渲染器忽略它。不抑制断行,不参与连写,不合成 emoji,不被当作编码声明。数量恰好是 16,每个字符携带 4 比特,一个字节两个字符。
三套字母表对应不同场景:
| 字母表 | 使用的码点 | 每字符比特 | 藏 26 字节水印的开销 |
|---|---|---|---|
| compat | 四个经典零宽字符 | 2 | 300 码点 |
| standard(默认) | U+FE00–FE0F | 4 | 150 码点 |
| dense | 加上 14 号平面的 240 个补充选择符 | 8 | 75 码点 |
这三套字母表的码点集合互不重叠。解码时不需要知道用的是哪一套,出现在文本里的字符本身就说明了这一点。如果同一段数据在两套规则下都能解出内容,用 CRC 校验和决定,概率可以忽略。这是后面"自描述"体系的第一块。
第二步:分片
原版最致命的是整块插入。修复它的前提是接受一个事实:
文本会被修改,水印的一部分一定会丢;方案必须在丢失的前提下仍然可用。
所以水印载荷被切成 12 字节一片,每片前面加一个字节的序号,分散到全文各处。序号是关键——一段文本被删除之后,我确切知道丢的是第几片。仅凭这一个信息,纠错算法就从纠错模式进入了纠删模式。
二维码那类纠错不知道哪个格子被污染,10 份校验块只能纠正 5 份错误。这里因为知道丢的是哪几片,同样的 10 份校验块可以恢复 10 片丢失。相同冗余,两倍恢复能力,代价是每片一个字节的序号。
实现是 GF(2^8) 上的 Reed–Solomon,k 片数据加 m 片校验:收到的总片数不少于 k 即可完整还原,即使丢的全是数据片。冗余分四档(L/M/Q/H),分别对应 20% 到 66% 的丢片率。开头那个演示用的是 H 档,3 片数据带 6 片校验,散布在 9 个位置。删掉前三分之一后,剩余部分找回 6 片,而需要的是 3 片。原版在同样操作下,有三分之一概率整体消失。
第三步:控制帧,以及一个关于冗余的教训
分片带来新问题:解码时如何知道 k 是多少、m 是多少、有没有压缩、用的什么加密。答案是一个 5 字节的控制帧,包含魔数、版本、参数和 CRC。魔数与分片序号的保留值不冲突,扫描时看到 0xFF 开头,就知道这不是数据片。
控制帧复制两到三份,分散在文中。测试全部通过。
发布第二天我才发现,它们全部通过,是因为我恰好没有测那一种情况。
最初的代码是这样:
const frames = [ ...控制帧副本, ...数据分片 ]
副本排在数组最前面,而插入位置按文档从前往后分配,于是三份控制帧全部落在文章开头的一小段里。破坏性测试删的是中间部分,正好绕开了它们。直到我删掉开头一段:三份控制帧同时消失,解码器拿不到 k 和 m,报告"没有水印"——而每一个数据分片都完好地躺在文末。复制三份等于没有复制,因为它们的失效条件是同一个。
修复是把副本交错到分片序列的等分点上,测试补成删头、删中、删尾三个方向各来一刀。这里得到的结论比 bug 本身重要:
冗余的价值不取决于份数,取决于它们的失效是否独立。
第四步:加密
这一层没有自己发明任何东西。PBKDF2-SHA256 迭代 20 万次派生密钥(浏览器里一次约 70 毫秒),AES-256-GCM 做认证加密,每条消息一个随机的 8 字节盐。全是教科书操作,但有两个决定需要解释。
第一,GCM 的 nonce 固定为全零。nonce 复用致命的前提是密钥复用;这里每条消息用新的随机盐派生新密钥,(密钥, nonce) 组合不会重复。省下的 12 字节在这个场景里是实的:载荷每多一个字节,文本里就要多两个不可见字符。
第二,认证标签长度不写进头部。实现同时支持 64 位和 128 位标签,头部不给任何标识。解密时先派生密钥——这是唯一昂贵的步骤——然后两种长度各试一次,由 GCM 拒绝错误的那个。相比 20 万轮哈希,多一次解密的成本可以忽略。这与"从字符反推字母表"是同一个取舍:能用计算换的头部字节,全部用计算换。 换来的是一个干净的接口:解码方需要知道的只有密码。
认证加密同时解决了原版无法区分的问题。提取结果现在有五种状态:
| 状态 | 含义 |
|---|---|
ok |
解出且验证通过 |
none |
没有水印 |
damaged |
丢失过多,无法重组 |
locked |
重组成功但认证失败 |
corrupt |
解出无法解析的字节序列 |
locked 不区分"密码错误"和"内容被篡改":能精确区分这两者的方案,本身就在向调用方泄漏信息。
随机盐还带来一个副作用:同一段文字、同一个水印、同一个密码,嵌入两次得到的不可见字符完全不同。发给两个人的"同一份"文件,天然是两份不同的副本。这一点在第六步会用到。
第五步:插入位置
这一层返工最多,两次大改都发生在发布之后。
规则本身不复杂:插入点分散全文,优先选标点和空白之后。但有几类位置不能碰。链接的 ](url) 内部不能插,会破坏链接;代码块和行内代码不能插,等宽字体下容易显形。这两条第一版就有。
三类被后补进禁区的位置(每一类都由一次真实翻车带出)
行首标记。 Markdown 里 - item 是列表,# 标题 是标题,> 引用 是引用,它们的语义依赖于"标记之后紧跟空格,标记之前没有内容"。一个不可见字符插在 - 和空格之间,或者插在行首标记之前,整个块会退化成普通段落。而第一版的位置偏好算法倾向于选择"边界字符之后",- 的空格之后正好符合这个条件。修复后,所有块级标记连同其后的空白被标记为禁止插入,同时强制规定不向文本位置 0 和任何行首插入。
emoji 之后。 U+FE0F 跟在某些 emoji 后面会成为呈现形式选择符,属于原文内容——⚠ 和 ⚠️ 的差别就是这一个字符。数据插在 emoji 之后,第一个变体选择符会被渲染器吸收。反方向同样需要处理:扫描时要能判断"emoji 后面的第一个变体选择符不属于水印",否则剥离水印时会把用户的 emoji 降级。
中文。 为了避免意外构成表意文字变体序列(IVS),第一版禁止在任何汉字后插入。这条规则直到我用一篇纯中文散文测试才暴露问题:几乎每个字符都是汉字,合法插入点接近于零,所有分片被挤到文末。实际上 IVS 只能由 14 号平面的补充选择符触发,U+FE00–FE0F 跟在汉字之后不构成任何合法序列,所以这条禁令只需要对 dense 字母表生效。
一刀切的安全规则,代价往往藏在那个还没测过的场景里。
还有一点:整层插入逻辑只操作码点数组,不使用 UTF-16 下标。dense 字母表的字符位于 14 号平面,一个字符占两个 code unit,按字符串下标切分会劈开代理对,产生真正的乱码。这是 JS 处理 Unicode 的基本要求,但值得再写一遍。
第六步:第二条通道
回到那句话:冗余的价值取决于失效是否独立。
不可见字符通道有一个统一的失效条件:任何一次"清除不可见字符"的操作——编辑器的净化、安全网关的过滤、或者一个知道水印存在的人手动清理——都会一次性移除全部载荷。RS 冗余在这里不起作用,因为所有分片死于同一个原因。
第二条通道必须有完全不同的失效条件。我用的是半角与全角标点。ASCII 标点都有一个全角对应字符(! 对应 !,U+FF01 = U+0021 + 0xFEE0),空格对应 U+3000。半角读作 0,全角读作 1,一个标点携带 1 比特。这条通道不插入任何字符,只改写已有字符,因此"剥离不可见字符"对它完全无效——它身上没有不可见字符可剥。
代价是它会改变文字外观。你好! 会变成 你好!。多数人会当成输入法差异,但它确实可见。所以它是显式开关,默认关闭,开启时界面会给出被修改的字符数。
带宽很低:一段千字文大约有 40 个 ASCII 标点,也就是 40 比特,装不下完整水印。所以这条通道不承载水印内容,只存一个 3 到 8 字节的载荷指纹。指纹装不下 issued-to:alice,但足以回答两个问题:这段文本是否被打过标;以及,因为指纹计算的是包含随机盐的最终载荷,每一份副本的指纹互不相同——它是发出去的哪一份。
于是有三种对抗情形:
文本中所有格式字符被清除,第一通道失效。全半角不受影响,detect() 返回 scrubbed:水印内容已丢失,但这段文本被证明是一份打过标的副本,指纹指向具体收件人。
NFKC 归一化把全角折回半角,第二通道失效。它不处理不可见字符,第一通道完好。两条通道,两种死法,不交叠。
有人把 A 副本的不可见水印取出,拼接到 B 副本的正文中。水印正常解出 copy-a,但指纹不匹配,fingerprintMatches 为 false,拼接行为暴露。没有这一层的方案,会把这个拼接结果当成 A 副本的正常读取。
成本
以下全部是实测。同一个 26 字节水印,加密后载荷 41 字节(8 字节盐 + 8 字节认证标签 + 25 字节密文,外加 2 字节长度前缀):
| 宿主文本长度 | 自动选的档位 | 分片 (k+m) | 新增隐形码点 | 占原文比例 |
|---|---|---|---|---|
| 139 码点 | L | 4+1 | +150 | 108% |
| 1000 码点 | Q | 4+4 | +238 | 23.8% |
| 5000 码点 | H | 4+8 | +342 | 6.8% |
第一行非常难看,我不打算美化。139 个码点的短文本上,水印比正文本身更大。每字节两个不可见字符,再乘上分片头、控制帧和纠错冗余,这是隐写的下限,不是实现能优化掉的。这个工具是给文档、邮件、文章这个量级用的。给一句话打水印,比例就是这样,任何一个 Unicode 查看器都能看见连续的 U+FE0x。
这两个词的差距,就是 108% 和 6.8% 的差距。
压缩层能补回一点:纯 ASCII 水印按 7 位打包,26 字节变 25 字节;重复度高的内容走 deflate;哪个结果小用哪个,都不小就不压缩。
它防不了什么
防不了重新录入。 OCR、照着重打、朗读转写、机器翻译——任何经过语义层的复制都会同时清除两条通道。这是所有基于字符编码的水印的上限。越过它需要在措辞本身做文章,那是另一条技术路线,代价是真正改变文本内容。
防不了一个知道原理并且有动机的人。 剥离格式字符加 NFKC 归一化,两步就能同时消灭两条通道。第二条通道的意义在于,让"顺手清理"从一步变成两步,并且让清理过的文本仍然可以被指认——"这份文件被标记过,且是发给 Alice 的那一份"。面对一个有知识、有动机的对手,这套方案拦不住,同类方案都拦不住。
它的实际定位是:在正常的复制、转发、编辑、局部删改中存活;在被清理后留下可追查的痕迹;在密码正确时给出可信的"是 / 否 / 被改过"。到此为止,再多说就是吹牛。
还有几件没做完的事。控制帧里 4 比特的版本号目前只用了 1;宽度通道理论上还能从中文标点(,。、)里取出更多比特,目前只实现了 ASCII 标点;以及一个交互演示——文本框里任意删除,实时显示剩余分片数和是否仍可恢复。做完会补在工具页上。
- 在线工具:https://box.muran.tech(所有逻辑在浏览器内运行,不上传任何内容)
- 开源仓库:https://github.com/jiangmuran/veilmark
- 也集成在 TypeBox:https://github.com/jiangmuran/jmr-typebox 的
src/features/text/下,含注释和测试约两千行,本文提到的每一个坑在测试文件里都有对应的 case——包括三份控制帧埋在同一个位置那件事。
从"能跑"到"被改坏之后还能用",中间隔的不是代码量,而是一次次把设计推倒重来的记录。希望这份记录能让下一个做同类工具的人少撞几堵墙。