一.业务背景
由于业务中接触到来账平台来账消息这一业务,在其中涉及到非对称加密方式的加解密,对支付宝来的消息要核实消息来源,所有要对来源的签名进行验签。需要用到支付宝的公钥,但是我作为业务开发方没有权限直接拿到公钥,所有就要用到阿里集团的KeyCenter秘钥管理平台,它分配了我一个公钥的名称,我就能用该公钥的名称利用keyCenter的接口捞出公钥并进行验签(操作黑盒)。加密方式主要采用DSA。
二.问题出现
在支付宝发给过我一则日常环境的通知后,我就用来账平台的这则通知拿来测试。主要是本地上写了Junit单测来模拟验签,能验签成功。自己写了一个脚本,向服务器上的Servlet发起通知,却一直报错Invalid signature format。这让我一度想到环境编码,或者是keyCenter调用方式等原因。
三.排查问题的大致步骤
1. 查看报错Invalid signature format的代码位置
TranscodeUtil.rsToSignature()函数中抛出的异常
public static byte[] rsToSignature(byte[] rs) throws Exception {
if ((rs == null) || (rs.length != 40)) {
throw new Exception("Invalid signature format");
}
2. Debug分析出问题出现的位置
由此倒推verifyByTranscode()中 byte[] signedRS的长度不对
public boolean verifyByTranscode(byte[] bytesToVerify, byte[] signature, String keyRef) throws Exception {
byte[] signedRS = Base64.decodeBase64(TranscodeUtil.decodeUpperCase(new String(signature)));
byte[] signed = TranscodeUtil.rsToSignature(signedRS);
return verifyBySignature(bytesToVerify, signed, keyRef);
}
只是粗略的大概看了下传来的signature的值,本地和服务器上是一样的。也对了下服务器收到的所有参数,也都与本地一致。通过了一系列对本地Junit数据和远程服务器上的代码的Debug,检查Base64.decodeBase64()中的逻辑
@Override
public byte[] decode(final byte[] pArray) {
if (pArray == null || pArray.length == 0) {
return pArray;
}
final Context context = new Context();
decode(pArray, 0, pArray.length, context);
decode(pArray, 0, EOF, context); // Notify decoder of EOF.
final byte[] result = new byte[context.pos];
readResults(result, 0, result.length, context);
return result;
}
发现源码第387行的context里的pos值计算出来是不一样的,本地单测是40,服务器上是39,所有在389行新建的比特数组大小不一样,导致返回值的数组长度不一样,服务器上返回的数组长为39,导致图一处理这个数组时抛出了那个异常。继续跟进decode函数里的内容:
if (context.eof && context.modulus != 0) {
final byte[] buffer = ensureBufferSize(decodeSize, context);
// We have some spare bits remaining
// Output all whole multiples of 8 bits and ignore the rest
switch (context.modulus) {
// case 0 : // impossible, as excluded above
case 1 : // 6 bits - ignore entirely
// TODO not currently tested; perhaps it is impossible?
break;
case 2 : // 12 bits = 8 + 4
context.ibitWorkArea = context.ibitWorkArea >> 4; // dump the extra 4 bits
buffer[context.pos++] = (byte) ((context.ibitWorkArea) & MASK_8BITS);
break;
case 3 : // 18 bits = 8 + 8 + 2
context.ibitWorkArea = context.ibitWorkArea >> 2; // dump 2 bits
buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 8) & MASK_8BITS);
buffer[context.pos++] = (byte) ((context.ibitWorkArea) & MASK_8BITS);
break;
default:
throw new IllegalStateException("Impossible modulus "+context.modulus);
}
}
通过对比Base64中这段代码里的context变量里的值,发现是在58行之后计算出来的modulus值改变了,服务器上是1,本地是2,所以执行到case2时比case1处理时pos多加了1。于是想到了问题出现在了上面的循环代码中
for (int i = 0; i < inAvail; i++) {
final byte[] buffer = ensureBufferSize(decodeSize, context);
final byte b = in[inPos++];
if (b == PAD) {
// We're done.
context.eof = true;
break;
} else {
if (b >= 0 && b < DECODE_TABLE.length) {
final int result = DECODE_TABLE[b];
if (result >= 0) {
context.modulus = (context.modulus+1) % BYTES_PER_ENCODED_BLOCK;
context.ibitWorkArea = (context.ibitWorkArea << BITS_PER_ENCODED_BYTE) + result;
if (context.modulus == 0) {
buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 16) & MASK_8BITS);
buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 8) & MASK_8BITS);
buffer[context.pos++] = (byte) (context.ibitWorkArea & MASK_8BITS);
}
}
}
}
}
于是要对循环中的modulus值为什么修改进行思考和定位
3.对函数跑出不同结果的原因进行思考
(1)最开始考虑的是本地和线上调用的源码会不会有不一样的可能
a.首先检查了所以的蓝色高亮的变量,都是常量,本地线上并没有什么不同。
b.再考虑了会不会是调用源码不同的情况
因为调用时有两个类继承了这个抽象类,可能会由于操作系统的环境不同,调用的实现类不一样。后来确认过本地和服务器都是64位系统,并且debug也都跳进了Base64位的类里。
c.可能服务器和本地上引用的源码代码版本不一样。在师兄张霸的帮助下,确认过了本地和服务器上的org.apache.commons.codec.binary 的jar包是一致的。
由此可见本地和服务器调用的源码是同一版本,是一样的代码
(2)对代码循环中的变量变化进行跟踪
于是对循环里的代码进行跟踪,想明确是哪个步骤让context的modulus值计算的不同。我们回到图五那个循环,会发现inAvail的值是56也就是要循环56次,如果每次循环一次次去跟进记录,那就太累了。师兄提了一个好的建议给我,可以把源码搂出来,然后在源码上加上日志代码记录下每次循环里每个变量的变化情况,然后可以通过写个测试函数,把循环里变量的变化情况都打印出来。写好的源码改编版的测试代码如下
if (b >= 0 && b < DECODE_TABLE.length) {
final int result = DECODE_TABLE[b];
LoggerUtils.getBussinessLog().info("result: {}",result);
if (result >= 0) {
context.modulus = (context.modulus+1) % BYTES_PER_ENCODED_BLOCK;
LoggerUtils.getBussinessLog().info("context.modulus: {}",context.modulus);
context.ibitWorkArea = (context.ibitWorkArea << BITS_PER_ENCODED_BYTE) + result;
LoggerUtils.getBussinessLog().info("context.ibitWorkArea: {}",context.ibitWorkArea);
if (context.modulus == 0) {
buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 16) & MASK_8BITS);
LoggerUtils.getBussinessLog().info("buffer[context.pos++]: {},context.pos: {},context.ibitWorkArea {}",new Object[]{buffer[context.pos++],context.pos,context.ibitWorkArea});
buffer[context.pos++] = (byte) ((context.ibitWorkArea >> 8) & MASK_8BITS);
LoggerUtils.getBussinessLog().info("buffer[context.pos++]: {},context.pos: {},context.ibitWorkArea {}",new Object[]{buffer[context.pos++],context.pos,context.ibitWorkArea});
buffer[context.pos++] = (byte) (context.ibitWorkArea & MASK_8BITS);
LoggerUtils.getBussinessLog().info("buffer[context.pos++]: {},context.pos: {},context.ibitWorkArea {}",new Object[]{buffer[context.pos++],context.pos,context.ibitWorkArea});
}
}
}
然后在本地跑出来的日志记录样子是这样的
日志中最后的结果是对上了正确的需求结果。由于服务器上调用的是源码,一次次循环里debug数据非常麻烦,也打算写一个跑本地这次记录源码变量日志的防源码版代码。然后在服务器上用脚本触发了之后,也打出了一个专门的日志。结果服务器上的打印日志测试代码和本地上的打印日志测试代码打出来的数据一模一样!这样就更引起了深深的不解。说明本地和服务器上的同一代码对同一数据的处理结果肯定是一样的,不是应该是编码环境等操作系统的差异。
4.从循环中发现问题
于是我对线上的代码进行了耐心的debug,终于发现在第23次循环时,context的modulus的变化开始于本地上的记录不一样了。然后继续跟进产生变化的原因发现了问题所在final byte b = in[inPos++]; in这个byte数组在第23位和本地上的不一样。原来对比过本地Junit测试验签传入的数据in数组和服务器上debug的in数组并不是真的一样,一个长度为56的比特数组里只有第23位不一样,才会让我误以为传入的数据是一样的!
由此倒退前面的代码,发现获得的签名和发出的签名不一样,通过debug服务端的代码发现图二传入的字节数组的来源String和本地上测试的正确String的差异
ka4_ods_rnvsu0dc_c2q1ct_w_l+_c_z89_ozl_u_s_r3b_o_p_a_g_p_guj_nna83h_x_s7_e_a==
ka4_ods_rnvsu0dc_c2q1ct_w_l _c_z89_ozl_u_s_r3b_o_p_a_g_p_guj_nna83h_x_s7_e_a==
5.找到原因
我一开想到也考虑过了可能是编码的问题,但是我想像中的编码选择不对应该是面目全非的乱码。后来想起来支付宝的签名也是通过请求发给我的,我才拿来利用自己测试。感觉不是选错编码的问题,因为其他的参数传过来都很正常,不会只有这个参数的一个+乱码。我想起来我的请求都是get的,通过URL的方式,我去浏览器试了下这个链接,看看+,会不会是URL会对+特殊处理呢?后来网上一查,果然URL出现了有+,空格,/,?,%,#,&,=等特殊符号的时候,在服务器端无法获得正确的参数值。URL中+号表示空格其编码是%2B。原因终于找到了是因为+是URL中的特殊字符,作为参数用get请求发出时+会被干掉。
后来对URL进行encode之后问题果然解决了。
6.解决方法
在请求发出方用对请求的URL用URLEncoder.encode,如果在接受方服务器没有在sever.xml里配置或者用fliter等统一拦截处理编码的逻辑,那么接受参数时要使用URLDecoder.decode。
心得体会
这个问题的结果其实并不复杂,但过程中却需要很细心的去分析问题。因为之前关于乱码只想到了大范围的乱码的情况,所以没考虑某个字符的编码问题,所以看看差不多一样了,没考虑到单个特殊字符的编码情况。通过了大量的刨源码,对源码的运行过程监控也很辛苦,但是感觉收获颇多,对整个非对称秘钥加解密的过程有了更深的理解。于是就写下了这么一篇排查问题的日志,分享下这次分析解决问题的心得体会。