引言
我们在先前的文章了解字符编码:ASCII、UTF-8中提到,从各国的文字、生僻字,到抽象老哥喜欢的 emoji ,再到各种奇奇怪怪的符号,Unicode海纳百川,无所不有。
在这篇博客中,我们的重点并不在于上面提到的 Unicode 多种多样的奇葩字符——我们将聚焦于一种特殊的字符:零宽字符,并基于此制作一款信息加密小工具。
1. 什么是零宽字符?
零宽字符是一种特殊的 Unicode 字符,正如字面意思,它的宽度为零,也就是在文本中不占用任何显示空间。
一种零宽字符是零宽连字(zero-width joiner, ZWJ),它通常用于控制文本的排版和显示,其中最常见的用途是在字符之间插入连字符,以实现特定语言(如阿拉伯语、印地语甚至 emoji )字符之间的连写。 由于它不占用显示空间,因此可以在不影响文本布局的情况下改变文本的显示方式。
例如,对于特定的 emoji,如 男人 👨、女人 👩、男孩 👦 的组合,我们可以直接显示为:
👨👩👦
但如果我们在两两之间插入一个零宽连字,即变为 👨[ZWJ]👩[ZWJ]👦 ,对应的显示效果则为:
👨👩👦
由于这篇博客通过浏览器打开,你可以对上面显示的 emoji 进行元素检查:
可以直观地看到,后者正是由前者通过零宽连字(即 ‍
)组合而成的,即使从显示效果来说,你看到的只是一个普通的 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
当对它进行元素检查时,我们就会发现它包含了大量的零宽字符,但总是 ‍
与 ‌
的组合:
我们知道,字符总是需要通过某种「编码规则」映射为二进制数等内容,使计算机可以理解。而在我们的小工具中,我们进一步地将二进制数映射为零宽字符(示例中将 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 中的一个字符,而 ‍
是 HTML 实体编码,对应的 Unicode 字符即 \u200d。
函数中使用了三个变量:
变量名 | 含义 |
---|---|
displayStr | 要显示的字符串 |
hiddenStr | 要隐藏的字符串 |
encrypted | 存储加密后的字符串 |
加密时,具体实现步骤如下:
遍历要隐藏的字符串
hiddenStr
中的每一个字符。对于每一个字符,先将其转换成对应的 ASCII 码值(开头的 128 个 Unicode 编码单元与 ASCII 字符编码等价)。
将该 ASCII 码值转换成 8 位的二进制数,不足 8 位则在前面补 0。
遍历该二进制数中的每一位。
如果该位为 1,则将零宽度空格 \u200c 添加到
encrypted
中;否则,将零宽度非连接符 \u200d 添加到encrypted
中。最终得到的
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 | 解密后的结果 |
解密时,具体实现步骤如下:
定义两个变量
decrypted
和binaryStr
,分别用于存储解密后的字符串和解析出的二进制数据。遍历要解密的字符串
resultStr
中的每一个字符。如果当前字符是零宽度空格 \u200c 或零宽度非连接符 \u200d,则将其对应的二进制位添加到
binaryStr
中。其中,\u200c 对应二进制的 1,\u200d 对应二进制的 0。如果
binaryStr
中已经解析出了 8 位二进制数据,则将其转换成对应的 ASCII 码值,并将该值对应的字符添加到decrypted
中。同时清空binaryStr
,准备解析下一个字符。如果当前字符不是零宽度空格或零宽度非连接符,则直接将其添加到
decrypted
中。最终得到的
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 加密会是不错的实践。