Skip to main content

了解字符编码:零宽字符

· 约17分钟
Proca

引言

我们在先前的文章了解字符编码:ASCII、UTF-8中提到,从各国的文字、生僻字,到抽象老哥喜欢的 emoji ,再到各种奇奇怪怪的符号,Unicode海纳百川,无所不有。

在这篇博客中,我们的重点并不在于上面提到的 Unicode 多种多样的奇葩字符——我们将聚焦于一种特殊的字符:零宽字符,并基于此制作一款信息加密小工具。

1. 什么是零宽字符?

零宽字符是一种特殊的 Unicode 字符,正如字面意思,它的宽度为零,也就是在文本中不占用任何显示空间。

一种零宽字符是零宽连字(zero-width joiner, ZWJ),它通常用于控制文本的排版和显示,其中最常见的用途是在字符之间插入连字符,以实现特定语言(如阿拉伯语、印地语甚至 emoji )字符之间的连写。 由于它不占用显示空间,因此可以在不影响文本布局的情况下改变文本的显示方式。

例如,对于特定的 emoji,如 男人 👨、女人 👩、男孩 👦 的组合,我们可以直接显示为:

👨👩👦

但如果我们在两两之间插入一个零宽连字,即变为 👨[ZWJ]👩[ZWJ]👦 ,对应的显示效果则为:

👨‍👩‍👦

由于这篇博客通过浏览器打开,你可以对上面显示的 emoji 进行元素检查:

1

可以直观地看到,后者正是由前者通过零宽连字(即 ‍)组合而成的,即使从显示效果来说,你看到的只是一个普通的 emoji 。

除了零宽连字,还有其他类型的零宽字符,如零宽空格(zero-width space, ZWSP)和零宽不连字(zero-width non-joiner, ZWNJ)。

零宽空格与普通空格不同,它不会在文本中占用显示空间,但会强制换行或分隔单词。

而零宽不连字则是一种特殊字符,用于在某些语言中表示两个字符不应该合并为单个字符,例如在德语中,字母组合“ff”应该被视为两个单独的字母,而不是一个单独的字符。

note

零宽字符虽然在普通文本编辑器中不易察觉,但在编程中却有着广泛的应用。例如,在密码学中,零宽字符可以用来隐藏一些机密信息,包括但不限于在电子邮件或网站 URL 中嵌入加密数据。

danger

值得注意的是,由于零宽字符不会在文本中占用显示空间,因此它们可能会被用于恶意用途,例如在文本中插入恶意代码或隐藏机密信息。因此,在处理文本时,特别是在安全敏感的场合,需要格外小心。

2. 加密小工具示例

现在我们已经知道了零宽字符的基本概念,也知道了它可以用来隐藏一些机密信息,因此,让我们开始制作一款信息加密小工具吧!在实践中不断拓展对零宽字符以及字符编码的认识!

——等等,在开始之前,我们最好先来体验一下这个机密信息隐藏的过程。

在如下所示的小工具中,你可以通过 工具 1 将一段文本隐藏在另一段文本中,并通过 工具 2 解析一段这样的特殊文本。

info

该工具从简出发,具有一定的局限性,仅对英文信息加密具有良好的支持。稍后我们将对其进行一定的改进。

例如,在「可见信息」中输入 Hello,在「隐藏信息」中输入 World,我们就在「加密结果」中得到包含了 World 却又不将其进行显示的特殊字符串:

Hello‍‌‍‌‍‌‌‌‍‌‌‍‌‌‌‌‍‌‌‌‍‍‌‍‍‌‌‍‌‌‍‍‍‌‌‍‍‌‍‍

于是,在「加密字符串」中输入上方所示的特殊字符串,就可以在「解密结果」中将可见信息与隐藏信息一并显示出来:

HelloWorld

如果你不对上面的特殊字符串进行元素检查的话,大概会觉得这十分神奇!

3. 制作信息加密小工具

对于上一部分中的特殊字符串:

Hello‍‌‍‌‍‌‌‌‍‌‌‍‌‌‌‌‍‌‌‌‍‍‌‍‍‌‌‍‌‌‍‍‍‌‌‍‍‌‍‍

当对它进行元素检查时,我们就会发现它包含了大量的零宽字符,但总是 ‍‌ 的组合:

2

我们知道,字符总是需要通过某种「编码规则」映射为二进制数等内容,使计算机可以理解。而在我们的小工具中,我们进一步地将二进制数映射为零宽字符(示例中将 0 映射为 ‍,将 1 映射为 ‌),从而将信息有效地存储起来。 于是,在解密时,只需要将零宽字符重新映射为二进制数,再基于字符编码规则转换,就可以得到原信息了!

这就是机密信息隐藏中最简单的原理,现在我们终于可以开始制作我们的工具了!

info

工具的页面布局实现、UI设计等不是本文的重点,我们将着重于加密与解密逻辑的具体实现。

3.1. 加密

const zeroWidthEncrypt = () => {
let encrypted = displayStr;
for (let i = 0; i < hiddenStr.length; i++) {
const charCode = hiddenStr.charCodeAt(i);
const binary = charCode.toString(2).padStart(8, "0");
for (let j = 0; j < binary.length; j++) {
encrypted += binary[j] === "1" ? "\u200c" : "\u200d";
}
}
setResultStr(encrypted);
};
note

\u200d 是 Unicode 中的一个字符,而 &zwj; 是 HTML 实体编码,对应的 Unicode 字符即 \u200d。

函数中使用了三个变量:

变量名含义
displayStr要显示的字符串
hiddenStr要隐藏的字符串
encrypted存储加密后的字符串

加密时,具体实现步骤如下:

  1. 遍历要隐藏的字符串 hiddenStr 中的每一个字符。

  2. 对于每一个字符,先将其转换成对应的 ASCII 码值(开头的 128 个 Unicode 编码单元与 ASCII 字符编码等价)。

  3. 将该 ASCII 码值转换成 8 位的二进制数,不足 8 位则在前面补 0。

  4. 遍历该二进制数中的每一位。

  5. 如果该位为 1,则将零宽度空格 \u200c 添加到 encrypted 中;否则,将零宽度非连接符 \u200d 添加到 encrypted 中。

  6. 最终得到的 encrypted 就是加密后的字符串,将其保存到 resultStr 中。

caution

需要注意的是,该函数只是一种简单的加密方式,可以通过查看页面源代码等方式轻松破解。因此,它并不适合用于加密敏感信息等需要高安全性保护的场合。

3.2. 解密

const zeroWidthDecrypt = () => {
let decrypted = "";
let binaryStr = "";
for (const char of resultStr) {
if (char === "\u200c" || char === "\u200d") {
binaryStr += char === "\u200c" ? "1" : "0";
if (binaryStr.length === 8) {
decrypted += String.fromCharCode(parseInt(binaryStr, 2));
binaryStr = "";
}
} else {
decrypted += char;
}
}
setDecryptStr(decrypted);
};
note

你也许发现,与加密函数的中的 for 循环不同,我们在解密函数中使用了 for of 循环。这是由于我们在循环体中并未对 i 有额外的使用,使用 for of 更为简洁优雅。

函数中主要使用了三个变量:

变量名含义
resultStr要进行解密的字符串
binaryStr正在解密的字符的二进制编码
decrypted解密后的结果

解密时,具体实现步骤如下:

  1. 定义两个变量 decryptedbinaryStr,分别用于存储解密后的字符串和解析出的二进制数据。

  2. 遍历要解密的字符串 resultStr 中的每一个字符。

  3. 如果当前字符是零宽度空格 \u200c 或零宽度非连接符 \u200d,则将其对应的二进制位添加到 binaryStr 中。其中,\u200c 对应二进制的 1,\u200d 对应二进制的 0。

  4. 如果 binaryStr 中已经解析出了 8 位二进制数据,则将其转换成对应的 ASCII 码值,并将该值对应的字符添加到 decrypted 中。同时清空 binaryStr,准备解析下一个字符。

  5. 如果当前字符不是零宽度空格或零宽度非连接符,则直接将其添加到 decrypted 中。

  6. 最终得到的 decrypted 就是解密后的原始字符串,将其保存到 decryptStr 中。

4. 有什么可以改进的?

4.1. 仅支持英文

在现有的实现中,我们将 ASCII 码值转换成二进制数,再将其转换成零宽字符进行存储。 解码时,我们默认编码长度不会超过8位二进制数据。这样带来的弊端是很明显的:仅支持英文字母、英文标点符号的存储。

如果要支持中文、日语或其他Unicode字符,我们需要对这个小工具进行改进:在编码时,将二进制数据的位数增加到16位。 在解码时,我们按照零宽字符来拆分编码数据,当编码长度达到16位,则将其转换成对应的字符,否则继续等待下一个零宽字符的到来。 这样,我们就可以完整地还原出原始的字符串,且支持大部分常用的unicode字符。

例如,我们可以修改成这样:

加密函数

const zeroWidthEncrypt = () => {
let encrypted = displayStr;
for (let i = 0; i < hiddenStr.length; i++) {
const charCode = hiddenStr.charCodeAt(i);
const binary = charCode.toString(2).padStart(16, "0");
for (let j = 0; j < binary.length; j += 2) {
const zeroWidthChar
= binary
.substr(j, 2)
.replace(/./g, m => m == "0" ? "\u200b" : "\u200c");
encrypted += zeroWidthChar;
}
}
setResultStr(encrypted);
};

解密函数

const zeroWidthDecrypt = () => {
let decrypted = "";
let binaryStr = "";
for (const char of resultStr) {
if (char === "\u200b" || char === "\u200c") {
binaryStr += char === "\u200b" ? "0" : "1";
if (binaryStr.length === 16) {
decrypted += String.fromCharCode(parseInt(binaryStr, 2));
binaryStr = "";
}
} else {
decrypted += char;
}
}
setDecryptStr(decrypted);
};

现在,你可以再试试我们的小工具,它现在支持大部分常用的字符!

4.2. 隐藏后的信息过于冗长

由于我们将每一个二进制位都转换成一个零宽字符进行存储,隐藏后的信息会变得非常冗长:在我们的初代工具中,对于一个包含 100 个字符的字符串,其隐藏后的信息长度就会达到 800 个字符左右!

针对信息过于冗长的问题,我们可以考虑采用更高效的编码方式来压缩信息,从而减少隐藏后信息的长度。

一种常用的编码方式是霍夫曼编码,它可以根据字符出现的频率来确定每个字符的编码,使得出现频率高的字符的编码比出现频率低的字符的编码短,从而达到压缩信息的目的。

info

有关霍夫曼编码的压缩原理、实现过程,请参阅:一文读懂:霍夫曼编码

5. 小结

在本文中,我们简单介绍了零宽字符的概念及其基本作用,并基于我们已有的知识与零宽字符的显示特性,聚焦于信息加密小工具中加密、解密的具体实现,并对该工具进行了一定的改进。

需要注意的是,本文的小工具还是相当稚嫩的。零宽字符虽然可以隐藏信息,但是并不是一种安全的加密方法,并不适合用于加密敏感数据。 在对安全性要求较高的场景中,对我们工具的结果进行一次 AES 加密会是不错的实践。

支持一下

暂无评论,来留下友好的评论吧