Base32编码并不是一种常见的编码方式。一般我们会熟悉hex、Base64等这些常用标准的基础编码方式,其他的编码方式一般情况下基于特殊的业务需求。如Base58,是比特币使用的编码方式。
其实所有这些各种BaseX的编码方式,它们的基础概念是一样的。这里的X本质上就是一个进制, HEX是16进制,58就是58进制,而所有信息的通用表示形式-字节数组,其实是256进制,只不过字节数组使用数字(Uint8)来表示,而BaseX可以简化使用对应进制的字符集合来表示,更容易使用而已。所以这里BaseX的编码,看起来是一个字符串,其实是一个数组,数组的每个元素,都是其对应进制的数字。
对于BaseX数据的处理方法,我们先讨论一下普遍的情况和一般过程,然后再详细探讨Base32编码的具体实现方式。
BaseX编解码的一般过程
首先明确一下基本概念和规则,所谓编码,就是将一段文字(UTF8字符集),转换成为BaseX字符串;解码是编码的逆运算,输入为BaseX字符串,转换还原为原始文本。当然,这是一个狭义的定义,真正的转换有一个中间过程,就是先将文本转换为字节数组,然后再进行编码,因为字节数组才是信息在软件中的基础形式。
假设,我们有一个字符串: "China中国Hello",要将其编码为BaseX编码,应该这么做:
1 基于编码规则,定义一个有序的字符集
BaseX编码,使用字符集合来表达信息,所以首先是需要确定这个字符集合。X就表示打算使用多少个字符来进行表达。如Base32的"标准"字符集为:
ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
同理,HEX的编码字符集是0123456789abcdef,而Base64是: AB...XYZabc...xyz0123456789+/;它的本质是使用一个字符来代替一个数字(字符在字符串中的顺序)。要注意这个字符集合的定义“有序”的,因为实际上需要使用某个字符在集合中的位置,来表示进制数字的值。
2 生成字节数组
然后,就可以开始编码了。第一步应当是将其编码为标准的字节数组,即比较基础和通用的信息形式,在nodejs和浏览器中,可以使用Buffer或者TextEncoder都可以:
const s = "China中国@1234!";
// browser
let d1 = new TextEncoder().encode(s);
// nodejs
let d2 = Buffer.from(s);
// 命令行
> new TextEncoder().encode("China中国@1234!")
Uint8Array(17) [
67, 104, 105, 110, 97, 228,
184, 173, 229, 155, 189, 64,
49, 50, 51, 52, 33
]
> Buffer.from("China中国@1234!")
这样,我们就会得到一个21个字节的字节数组,这里buffer和uint8array本质上都是字节数组,就是一个smallint类型(8位二进制)数据组成的数组。Buffer使用16进制表示,所以0x43其实就是67(4x16+3),是一样的。
3 循环整除求余
如果是通用的算法,即basex,理论上应该将这个字节数组转换成一个大整数,然后从低位开始,对X进行循环整除求余,就获得一个余数的序列,然后查找这些余数在字符串中对应位置的字符,构成的字符串就是原始信息BaseX的编码。
这个算法的核心是大整数操作。在这个意义上,任何信息都可以用一个大整数来表示。而字节数组无非是大整数的256进制表示的形式。
但通用的大整数操作的算法实现并不容易,在很多时候也没有必要。这时候,通常可以用工程的方式来简化问题。
4 特例处理
我们先来看两个熟悉的BaseX的特例。 hex和Base64。
Hex本质上就是Base16,它使用16个字符表示一个数组,每个字节数组的元素,都可以表达为两个hex字符表示的字符串。由于256可以被16整除,所以在字节数组中,每个元素都和其他的元素是无关的,可以单独处理。
Base64的情况稍微复杂一点点。 但考虑到 64是2的6次方,256是2的8次方。三个字节(3x8),“刚好”可以用四个Base64字符(4x6)来表达。 所以,对于base64,我们可以把问题简化,将字节数组分为三个三个一组,对于这个组,可以在组内进行转换,为4个Base64字符,然后将所有小组连接起来,就是最后的Base64编码了。
5 Base32
所以,Base32其实也是一个特例。32是只是2的整数倍而已。当然你也可以认为8个Base32字符,可以用于表达5个字节,显然并不如64那么方便。但可以提供给我们一种处理的思路,进一步使用二进制的方式来进行操作。这一部分的实现我们会在后面详细的分析和讨论。
6 解码
检查编码的算法实现是否正确,其实也很简单,就是基本按照逆运算的方式,提供一个解码程序,能够将编码的结果,解码成为原始的信息就可以了。
上面就是BaseX编解码的一般过程。下面我们详细的分析一下Base32的编解码实现,为了方便讨论,我们使用Javascript语言和标准程序方法。
Base32编码实现
我们在前面已经知道,除了上面的大整数处理方式之外,Base32是可以作为一个特例来处理的。这里我们可以提供两种处理的思路。
- 二进制字符串方式
从上面的过程可以看到,这种处理方式属于“简单粗暴”的方式,本质上并不是在处理数字,而是利用其字符串形式在处理字符。算是一种工程化的处理。但优点是容易理解和实现。
为了方便讨论,我们只看前五个字节(十进制表示):
[67, 104, 105, 110, 97 ]
转为二进制字符串的数组,注意需要补全8位:
['01000011', '01101000', '01101001', '01101110', '01100001' ]
合并后的结果如下,并将其每五个字符分为一组,共8个二进制字符串:
01000,011 01,10100,0 0110,1001 0,11011,10 011,00001
将这个八个二进制数,转为整数为:
8,13,20,6,18,27,19,1
然后使用标准字符串,以其作为索引,查找对应的字符为:
B,N,U,G,S,3,T,1, 所以 BNUGS3T1就是这5个字节的编码。
以此类推,我们可以计算剩余的字节,得到完整信息的编码,如果不足5个字节,可以使用0字节补位的方式补足0位进行计算。
解码基本上就是编码过程的逆操作。将编码字符分隔一一计算其字符对应的索引索引数值;将此数值转换为二进制字符串(5位);连接字符串后按8位进行分组;然后将此8位的二进制字符串转换为整数;得到的字节数组就是解码的结果。然后可以使用TextEncoder就可以还原为原始信息了。
- 位移处理方式
前面讨论的字符串方式,可以说是一种比较“取巧”而且不是那么正规的方式,虽然也可以解决问题,但并不是建议的方式,当然也不是本文讨论的重点。我们当然希望使用“数学”和更本质的计算方式来进行处理。其实前面的方法是可以给我们一些思路的。就是可以将复杂的问题合理的进行分拆分步处理,我们下面就来尝试一下。
1 还是先转为一个二进制数构成的数组,注意这里已经不是字符串了:
[0b01000011, 0b01101000, 0b01101001, 0b01101110, 0b01100001 ]
2 先取出最高的5位
现在的构想是,希望从高到低取前5位作为新的编码数值。比如要从 0b01000011,取出前5位,可以使用位移计算方式,方式是向右移动3(8-5)位:
0b01000 = 0b01000011 >> 3, 得到第一位为: 8
3 宽度不够,加入下一个字节
下一步,就有两个问题,高位阶段后应该得到一个剩余的 0b011,要继续截位,这个位数已经不够了,需要将下一个字节0b01101000加进来才能继续,这时需要做几个操作。
首先要截断前面的高位,得到剩下的低位,可以使用掩码操作(要留几位掩几位):
0b011 = 0b01000011 & 0b111
然后加入下一个字节,方法是将当前位进8位(一个字节的宽度),然后和下一位相加, :
0b01101101000 = (0b011 > 6, 得到第二位为: 13
截断高位,保留低6位:
0b101000 = 0b01101101000 & 0b111111
5 当前位数宽度足够,可以直接继续取出高5位
0b10100 = 0b101000 >> 1, 得到第三位为: 20
到了这里,相信大家应该已经可以看出来规律了。就是如果当前的值够大,就可以直接取高5位,不够的话加入下一个字节来取高5位,直到所有的字节都编码完成。
6 补位
这个算法的要点和容易出问题的地方,就是在最后一个字节的补位操作。在编码的时候这个问题不大,直接把最后计算剩余的余数转换为字符添加即可。主要问题是解码的时候,需要将最后一个字节还原。大体上有两种情况,一种是信息的二进制长度刚好是40的整数倍,还有一种普通情况,都需要不同的处理,详见代码。
这个方法的优势在于,不管原始信息有多大,都可以直接开始编码计算,而且参与计算的信息总是有限的,不会造成太大或者不可控制的资源占用,保证了算法的稳定和高效。
这部分的内容比较重要,所以笔者给出了相关可执行的参考代码,方便大家在调试中理解:
相关要点和说明如下:
- 使用循环来消耗数组中的数值,并使用一个数组来记录所有阶段的编码
- 需要独立处理最后一个字节,退出条件为数组遍历完成并且剩余值小于32,作为最后的余数加入结果数组
- 取高位可以使用 >> 位移操作(直观的意思是截断右边n位数据),但需要移多少,取决于当前数值的名义宽度
- 使用w来记录当前数值的名义宽度,初始为8,第一个字节的宽度
- 每次截断的数据长度为5(二进制)
- 可以使用((1 (c[i%4] ^= v,c), Array(4).fill(0))
.reduce((c,v,i)=> c + v*256**(3-i), 0)
.toString()
.slice(-6)
.padStart(6,"0");但一个设计良好的OPT算法要能够实现良好的离散性,所以其具体实现也是具备一定的理论依据,精选设计和实现,并且经过相关的检验和测试的。GA的验证码的实现肯定也是这样的。
上面是算法的问题,另一个问题是所使用的密钥。笔者使用时,简单的理解就是OPT密钥可以随便设置,就和普通密码规则一样,只要保证长度和复杂性就可以了。但其实不然,经过资料查证,发现GA密钥的选择是有相关规范的,简单而言就是:
“16个Base32字符”。
所以如果不注意就不能正确设置(如图)。
怎么就有非法字符了呢?请大家思考一下。因此,GA的密钥设置,不是简单的口令设置,而是真正的密钥设置。这个概念上的差别,也可以很好的帮大家理解: 口令和密码主要是给人使用的,目的是容易记忆;而密钥是给程序使用的,它本质上是基础的二进制编码,而且不用考虑可记忆性。
这里为什么不能展示截图?因为有一个简单的安全机制,就是在GA的程序界面,是不能使用程序截图的。本来这个也不是一个太大的问题,基于Base32编码规则,我们可以将任何信息都编码成Base32,但这里要考虑和GA程序的兼容,就需要使用和它一样的算法,才能得到匹配的结果。
Google Authcode 兼容编码实现
内容既然都已经写到了这里,笔者觉得索性送佛到西,进一步讨论和分析一下Google Authticator编码算法的实现。
其实其算法核心就只有以下几行(其他的可执行的代码,详见前面的示例程序):
// convert to hex of period value add offset
let time = Math.floor(epoch / options.period) + options.ofset;// convert time to 8 byte buffer
let i = 8, timeBuf = Buffer.alloc(i);
while (i-- && time) {
timeBuf[i] = time & 0xFF;
time >>= 8;
};// hmac of time use key and counter (use buf)
let hmac = createHmac(options.algorithm,key).update(timeBuf).digest();// offset of hmac
const offset = hmac[hmac.length - 1] & 0xf;let icode =
((hmac[offset ] & 0x7f) (c | (v