MENU

【代码札记】使用标准电码书写明信片

April 7, 2024 • 瞎折腾

和朋友聊起寄信,就不可避免地聊到明信片。聊到明信片,就不可避免地想要在公开的传输渠道中传送秘密。可是之前实践过使用 Base64 或 Base58 来传输加密文字,但要抄写一万个近似于随机的字母和数字,我实在是顶不住。于是想到了使用标准电码来表示数据。

何为标准电码展开目录

说来也是巧了,早年间上中学的时候买过一本翻印的《标准电码本》,是 1983 年修订的。这个手册里集中了大约七千多个汉字,每一个汉字都分配了一个唯一的四位数字编码,从 0000 到 9999,还有一些标点符号什么的。这个电码本顾名思义,就是用中文发电报用的。因为电报的莫尔斯电码只能拍发英文字母和数字,所以需要使用一种标准的方法将中文汉字转化成数字。为了满足拍发国际电报的需要,这个手册还给收录的汉字指派了唯一的三位英文字母编码,但那个不在我们今天讨论的范围里。

以维基百科为例:

  • 维:4850
  • 基:1015
  • 百:4102
  • 科:4430

为什么不用 UTF-8 呢?展开目录

其实为了解决中文在计算机中的编码问题,从古到今有好多解决方案。标准电码只是其一,我们还有 GBK 编码(主要在 Windows 上使用),还有著名的 UTF-8,所谓的万国码,就是一套字符集中能够容纳各种语言中的各种文字,中文只是其中之一。那这里为什么不用万国码呢?因为 UTF-8 平均需要使用 3 个字节来编码一个汉字(大约 8 位十进制数字),而电报码则只需要使用 4 位 10 进制数字。

但光靠电报码也不行,还不像 GBK 字符集,电报码别说外文了,就是自己的汉字还认不全呢。虽然标准电码收录了七千多汉字,但光现代的 GBK 字符集就已经能编码两万多汉字了。所以当标准电码不够用的时候,我们需要使用一种方法来将不认识的字符作为 UTF-8 编码。

编码展开目录

决定好思路之后,我们就可以开始设计了。我最初的想法就是将中文字符使用电报码编码,其他字符使用 UTF-8 编码,前缀使用 0000 标注。因为按照标准电码本的描述,如果遇到缺字,则可以使用 0000 代替,然后下方标注该字的字码。但我转念一想,首先 UTF8 是不定长的,最短的一个字节,最长的四个字节。如果全部统一按照四字节来记录的话,哪怕只是一个英文字母都要占用 10 位的十进制数字,再加上前缀的 0000,那就更长了。

后来我简单分析了一下使用场景,我认为以中文为主的情况下,英文和数字大多连续出现,所以可以采用连续编码的方式,将一长串字符使用 UTF-8 编码,然后将其二进制以 base9992 的方式表现出来,以 4 位数字为一组,按照小端序来存储。为什么是 base9992 呢?因为我看了看标准电码本,其中除了一些汉字和常用符号之外,他还定义了两组成对出现的符号:9992 和 9993、9994 和 9995。9992 和 9993 是着重号,而 9994 和 9995 是长得像破折号的书名号(而书名号本身是 9996 和 9997)。为了保证数据的透明传输,经过编码后的数据不能出现 9993 和 9995,不然解码时就无法分辨这是数据的一部分还是数据的结尾了。

同时为了减少需要抄写的电报码,在对 UTF-8 数据进行表示之前还会尝试使用 ZLIB 进行最高等级的压缩。如果压缩后的数据比 UTF-8 编码要短,那么就使用压缩后的数据进行表示。对于未经压缩的 UTF-8 数据,我们使用 9992 和 9993 来标记;对于压缩过的 UTF-8 编码,我们使用 9994 和 9995 进行标记。

再后来我翻看电码的时候,又发现了诸如这样的符号。这类符号在 Unicode 里面的分类是 “Enclosed CJK Letters and Months”,看起来应该就是单独为了月份之类的设计的缩写。那么本着尽量减少电码总量的思想,我们在处理字符串前,先将诸如 11日这样的词替换为这样的符号,然后再进行编码。

这些基本操作并不难,主体操作就是遍历字符串查表,因此我就不贴代码了。如果想看具体实现的话,可以到 GitHub 上查看:编码器解码器

至于使用效果嘛,可以看如下例子:

  • #> ./ctc encode -t "天匠染青红,花腰呈袅娜。" -w 9999
  • 1131, 0561, 2676, 7230, 4767, 9976, 5363, 5212, 0701, 5934, 1226, 9975

可以看到,程序成功地将天匠染青红,花腰呈袅娜。编码成了 1131, 0561, 2676, 7230, 4767, 9976, 5363, 5212, 0701, 5934, 1226, 9975。解码也是对的:

  • #> ./ctc decode -t "1131, 0561, 2676, 7230, 4767, 9976, 5363, 5212, 0701, 5934, 1226, 9975"
  • 天匠染青红,花腰呈袅娜。

可是这样一来,就和 base64 一样,虽然不同人看不懂了,但懂行的买一本书就能看明白。那。。。不可避免地,我们就要聊聊:

加密展开目录

说起加密,我的首选有两个:

  • 对称加密:AES 256 GCM 或 Chacha20 Poly1350
  • 密钥交换:X25519 ECDH

可是这种使用场景下,传统的加密并不是最好的选择。因为我们的目的是将文字信息编码成数字,而标准电码虽然原始,但它的效率是最高的:它可以精确地保证每个汉字只占用 4 位 10 进制数字。而二进制就不一样了,虽然 base64 或者 base58 在电脑上复制粘贴起来很简单,但写到纸上就不一样了。你可以自己试试看,随便找个什么字符串,用 base64 或 base58 编码,稍微长一点,写起来就像是随机的字母和数字,更不要说你收到信之后还要区分大小写把他们一个一个敲进电脑里。所以从表现形式来说,数字依旧是最好的选择:如果你有独立的小键盘的话,以 4 位数字为一组,很好敲的。

那么不能使用二进制的加密,我们还能怎么办呢?今天我无意中想到,可以用最古老的置换加密(Transposition Cipher),也就是打乱明文的顺序。对于文字来说,我们很难从长篇的乱序文字中拼凑出正确的意图。当然了,置换密码并不是最安全的,如果攻击者足够了解你,或者了解中文,那攻击者还是能够有可能破解出密文的。不过话说回来,我这个项目也不是为了传递什么机密信息,只是不想多花 4 毛钱寄平信,又不想自己写在明信片上的内容谁都能看到。所以你看看,四毛钱的项目你还要啥自行车呢。

对于这种密码,我选用 VMPC 随机数生成器。我们可以在初始化时使用一个种子来决定之后生成的伪随机数。而这个种子就是我们的加密密钥。加密时我们通过种子随机打乱电码的顺序,解密时我们通过通过同样的密钥生成同样的随机数序列,然后根据随机数序列重新排布。当然,我们还可以决定在打乱顺序时用多少随机数。

这个 “用多少随机数” 和 KDF(例如 PBKDF2)的迭代次数不一样。后者能够保证安全的原因是这种迭代所消耗的时间是强制性的,因此攻击者 “不得不” 等待服务器花时间来验证密码。而我们这里攻击者可以直接获得密文,而密文无论怎么捣腾,它就是一个固定长度排列组合的顺序。因此攻击者可以跳过多轮打乱顺序,而直接开始寻找正确的顺序。

那么决定好了加密方法,我们可以开始考虑如何决定加密密钥(伪随机数发生器的种子)了。

对于对称加密,我们可以直接让用户提供一个字符串作为密码,经过 UTF-8 编码后取 SHA3-256 哈希作为密钥即可。

对于密钥交换,我们可以让用户提供一个字符串,同样经过编码和哈希之后作为私钥使用,再让用户提供对方的公钥,由程序进行 X25519 ECDH,产生的结果经过 SHA3-256 运算之后作为密钥。

关于具体的实现细节我就不贴代码了,感兴趣的可以看这个文件

这里简单的讨论一下密钥的安全性问题。由于我们使用的是置换加密,并且由密钥决定打乱的顺序,因此最好每次都使用不同的密钥。虽然不同长度的密文会给出不同的打乱顺序,但 VMPC 毕竟是生成的伪随机数,它并不能严格保证不会被预测。因此如果你真的想要用这个程序在明信片上传递一些非常机密的信息,那我建议每次都使用不同的密钥。

当然了,说是密钥,其实它也是不唯一的。最简单的思路就是哈希碰撞,在设定种子之前都要经过 SHA3-256 的运算,如果这里发生了碰撞,就会有多个密码能够得到相同的种子。此外,VMPC 也可能会发生碰撞,比如两个不同的种子能够产生相同的序列。虽然是极小的可能性,但不能排除。

还是那句话,价值 4 毛钱的代码你还要啥自行车啊。

一点示例:

  • #> ./ctc encode -k password -t "天匠染青红,花腰呈袅娜。" -w 9999
  • 5212, 5934, 7230, 5363, 1226, 1131, 9976, 0561, 2676, 9975, 4767, 0701
  • #> ./ctc decode -t "5212, 5934, 7230, 5363, 1226, 1131, 9976, 0561, 2676, 9975, 4767, 0701"
  • 腰袅青花娜天,匠染。红呈
  • #> ./ctc decode -t "5212, 5934, 7230, 5363, 1226, 1131, 9976, 0561, 2676, 9975, 4767, 0701" -k password
  • 天匠染青红,花腰呈袅娜。
  • #> ./ctc pubkey alice
  • 749F5BF88AFA4928E732516E89A32C4C3082E15FA8795D2C942AB4670B28A860
  • #> ./ctc pubkey bob
  • 7D19D2508CA3D3712A35776496239C9CB4CA378586D1CFCD86BA6B2543A4766E
  • #> ./ctc encode -k alice --dh 7D19D2508CA3D3712A35776496239C9CB4CA378586D1CFCD86BA6B2543A4766E -t "天匠染青红,花腰呈袅娜。" -w 9999
  • 5363, 0561, 0701, 5934, 1226, 9976, 4767, 2676, 1131, 7230, 5212, 9975
  • #> ./ctc decode -t "5363, 0561, 0701, 5934, 1226, 9976, 4767, 2676, 1131, 7230, 5212, 9975"
  • 花匠呈袅娜,红染天青腰。
  • #> ./ctc decode -t "5363, 0561, 0701, 5934, 1226, 9976, 4767, 2676, 1131, 7230, 5212, 9975" -k bob --dh 749F5BF88AFA4928E732516E89A32C4C3082E15FA8795D2C942AB4670B28A860
  • 天匠染青红,花腰呈袅娜。

第一个示例展示了对称加密,只要找对了密码就可以解码出正确的文字。

第二个示例展示了密钥交换,alice 使用自己的密码给 bob 书写明信片,而 bob 拿到明信片之后则使用自己的密码和 alice 的公钥来解码,可以看到程序成功地解码出了 alice 书写的信息。

杂项展开目录

设计了这么多,代码也写完了,最后一个交互界面是必不可少的。毕竟这个程序不光我一个人用,我的朋友收到明信片之后也得用。我可以直接在 IDE 里面运行代码,但我可不想教别人怎么运行代码。所以还是要构建一个命令行程序。那么谈及命令行,无可避免的就要谈到字符集的问题。Windows 上默认的 GBK 字符集给我带来了不少麻烦,毕竟在 kotlin 的世界里,谈到字符集那默认就是 UTF-8,可是 Windows 的终端是 GBK 的,我按照 UTF-8 读,你猜我能读到啥?那当然是乱码。

所以经过一番挣扎,我通过判断默认 Locale 来决定使用的字符集。如果在 Windows 上的默认 Locale 是 zh-CN 的话则默认使用 GBK 编码,其他情况则使用 UTF-8 编码。

最后放一个 GitHub 连接吧,欢迎感兴趣的朋友试试看:hurui200320/utf8-ctc

- 全文完 -


知识共享许可协议
【代码札记】使用标准电码书写明信片天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code
Leave a Comment

2 Comments
  1. 这样的明信片好酷啊 (/ω\)

    1. @白熊阿丸确实挺酷的,不过上周我寄出了一张写满数字的明信片,现在还没寄到,那多半是丢了(