关于Base64的一次调试经历

Debug base64

Posted by Chinsyo on August 9, 2019

衡量代码质量的唯一标准是看到这份代码每分钟发出的「卧槽」数。

《代码整洁之道》

下班前同事突然叫住我,「晨晓,这里有个问题你帮忙看一下」。

著名佚名人士曾说过——最好的下班时间是六点,其次是现在。但我,六点没有下班,现在也没有下班。

简要复述一下问题,开发一个包含加解密报文的SDK,在SDK中测试数据可以正常加解密,而集成了SDK的应用手动输入数据加解密却总是解密失败。

我叮嘱同事先检查报文各个部分的长度是否和设计文档一致,确定数据的有效性;然后对每个步骤独立执行,确定密钥的正确性,同事检查后反馈这两项没有问题。

首先查看函数的调用,检查传入参数。

1
2
3
4
5
6
7
8
9
// 加密
NSString *plaintext = @"test";
NSData *encrypted = [SDKCryptor encrypt:plaintext];
...
// 解密
NSData *decrypted = [SDKCryptor decrypt:encrypted];
NSString *message = [[NSString alloc] initWithData:decrypted
                                          encoding:NSUTF8StringEncoding];
// 解密失败 decrypted 为空

确认传入参数没有问题后,检查SDK的实现,忽略掉无关逻辑后注意到这样一行代码。

1
2
3
4
5
6
7
8
@implementation SDKCryptor
+ (NSData *)encrypt:(NSString *)plain {
  NSData *pubKey = [Keychain pubKey];
  NSData *encoded = [[NSData alloc] initWithBase64EncodedString:plain options:0];
  NSData *encrypted = [RSAUtil encrypt:encoded withPubKey:pubKey];
  return encrypted;
}
@end

这里 initWithBase64EncodedString:options: 的用法引起了我的注意,入参原本应该是 base64EncodedString,即经过base64编码的字符串,而入参"test"显然没有经过base64编码。

SDK和测试代码在同一工程下,修改代码可以立即生效,但集成应用需要每次将SDK工程重新打包后才能够测试。代码托管在内网,私有项目所以没有采用Carthage管理framework,因此修改framework并打包给应用测试要颇费一番功夫。

同事显然不能信服这么低级的方案,坚持再次运行了SDK的测试代码,居然真的解密出来了"test"

只好继续排查。通过对-[SDKCryptor encrypt:]方法断点,在SDK的测试代码中data确实返回了值。<b5eb2d>

看到这里我确定,问题就出在这里。

我向同事解释,base64后的字节长度一定大于原始信息,且至少是原始数据的4/3倍长度。这是由base64编码方式决定的,以ascii编码为例,单个字节0x07就是发出声音,属于不可打印字符,base64编码将任意三个Byte即24bit按照每6bit一组分成四份,再将分组后的6bit映射到A-Z, a-z, 0-9, +, / 等共计64(2^6)个字符。

通过命令行可以验证”test”的base64编码后字符串。

1
2
$ echo "test" | tr -d \\n | base64
dGVzdA==

tr -d \n 作用为去掉echo句末的换行符,可以看到结果为8个ascii字符,所以编码后的字节数应该为8字节而不是<b5eb2d>所示的3字节。4字节补全为最接近的3的整数倍,即6字节,通过base64编码后长度变为4/3即8字节。

<b5eb2d>这一结果从何而来?同事的测试代码又为什么能通过呢?

1
2
3
4
5
>>> from base64 import b64encode, b64decode
>>> b64encode("test".encode("utf-8"))
b'dGVzdA=='
>>> [hex(i) for i in b64decode(b"test")]
['0xb5', '0xeb', '0x2d']

使用Python验证base64编码,首先明确的是上述的API确实存在误用。

这时回想我前面提到的base64编码长度关系,恍然大悟,尽管存在API的误用,但由于"test"长度恰好是4的整倍数,每个字符又都是合法的base64字符,因此刚好可以解码出<b5eb2d>,解密时通过base64编码又还原回原字符串"test"

在集成应用中使用时,输入的内容不是合法的base64编码字符串,加密时base64解码得到空的data,解密后自然没有数据。

验证我的猜想有两种方式,第一种对加密过程的base64解码log输出或符号断点。第二种则是修改测试数据,模拟用户输入的情况。

果然,在将"test"替换为中文输入后,SDK的测试代码也出现了解密失败。

回顾这次排查的过程,有以下几点值得注意:

  1. SDK和应用放在同一项目下可以更方便的断点调试,怕麻烦会很容易错失修复bug的机会
  2. 熟悉API和编程基础(这里指base64编码)可以加速发现代码中的错误
  3. 测试时务必保证上下文和「案发现场」一致,这里测试数据”test”和用户手动输入的数据不同始终没有被重视
  4. “test”作为测试阶段经常出现的字符串,用于测试base64的相关操作时是一个很特殊的字符串,既可以作为编码输入也可以作为解码输入,即使两者用反也可得到正确的结果

更让人哭笑不得的是,工程中另一个部分的序列号同样是12位的数字字母字符串,由于长度和字符的特殊性,同样以错误的方式正确运行至今。

很多程序员和项目经理对单元测试的态度是浪费时间,通过这个例子不难看出单元测试可以用有限的案例还两只程序猿一个准时的下班。

转载请注明原始出处 关于Base64的一次调试经历 © 晨晓 | Chinsyo