所里有个项目客户端是Java开发的,服务端是C开发的,之间使用了SM2算法进行密钥交换。
Java端是在网上找的一个比较流行的基于BC的SM2实现(https://github.com/PopezLotado/SM2Java),依赖的bcprov-jdk15on,版本1.56。C端是用的OpenSSL。
服务端和客户端联调时发现了很多问题,SM2算法的公钥加解密一直没法调通,签名验签也不通,但Java应用加密的数据可以自己解密成功,C应用加密的数据自己也可以解密成功。
我们分析了一下二者之间的差异:

密文格式的问题

从Java端encrypt的实现来看,不难看出,输出格式为C1C2C3的二进制数据拼接。注意这个C1采用了Encoded进行编码

	public byte[] encrypt(String input, ECPoint publicKey) {
	//	byte[] C1Buffer;
	//	byte[] C2;
	//	byte[] C3;
	//省略
        C1Buffer = C1.getEncoded(false);
	//省略
		/* 8 输出密文 C=C1 || C2 || C3 */
		byte[] encryptResult = new byte[C1Buffer.length + C2.length + C3.length];
		System.arraycopy(C1Buffer, 0, encryptResult, 0, C1Buffer.length);
		System.arraycopy(C2, 0, encryptResult, C1Buffer.length, C2.length);
		System.arraycopy(C3, 0, encryptResult, C1Buffer.length + C2.length, C3.length);
		if (debug) {
			System.out.print("密文: ");
			printHexString(encryptResult);
		}
		return encryptResult;

通过查看C端OpenSSL接口实现,它使用i2d将结果转化为了ASN1编码输出。

typedef struct ECIES_st
{
	BIGNUM *x; /** X of C1 point on the affine coordinates */
	BIGNUM *y; /** y of C1 point on the affine coordinates */
	ASN1_OCTET_STRING *C3; /** Hash 32 bytes*/
	ASN1_OCTET_STRING *C2; /** encrypted data */
}ECIES;

unsigned char *ECIES_public_encrypt(const unsigned char *src,size_t slen,EC_KEY *eckey,size_t *dlen)
{
	ECIES *ec=NULL;
	//...
	ec=ECIES_do_public_encrypt(src,slen,eckey);
	//...
	eclen=i2d_ECIES(ec,&ret);
	/...
	return ret;
}

一个是ASN1编码后的密文,另一个是二进制拼凑的密文,联调自然无法通过。这是C和Java在SM2公钥加解密算法实现中的一处不同。我们可以在Java端将C1C2C3转换为标准C1C3C2的ASN1编码输出。
加密结果转换部分的代码实现如下:

        ASN1Integer x = new ASN1Integer(C1.getXCoord().toBigInteger());
        ASN1Integer y = new ASN1Integer(C1.getYCoord().toBigInteger());
        DEROctetString derDig = new DEROctetString(C3);
        DEROctetString derEnc = new DEROctetString(C2);
        ASN1EncodableVector v = new ASN1EncodableVector();
        v.add(x);
        v.add(y);
        v.add(derDig);
        v.add(derEnc);
        DERSequence seq = new DERSequence(v);
        byte[] ret = null;
        try {
            ret = seq.getEncoded();
        }catch (Exception e){
            e.printStackTrace();
        }
        return  ret;

相应的在解密部分,我们提前将ASN1密文数据转换为原实现中C1C2C3拼接的方式,要注意的是原实现中C1使用encode进行了编码。

    public String decrypt2(byte[] encryptData2, BigInteger privateKey) {
        byte[] encryptData;
        try{
            ASN1InputStream aIn = new ASN1InputStream(encryptData2);
            ASN1Sequence seq = (ASN1Sequence)aIn.readObject();
            BigInteger x = ASN1Integer.getInstance(seq.getObjectAt(0)).getValue();
            BigInteger y = ASN1Integer.getInstance(seq.getObjectAt(1)).getValue();
            byte[] c3 = ASN1OctetString.getInstance(seq.getObjectAt(2)).getOctets();
            byte[] c2 = ASN1OctetString.getInstance(seq.getObjectAt(3)).getOctets();
            ECPoint p = curve.validatePoint(x, y);
            //原实现中的c1是进行了encode编码的,为了正常解密,这里加一次编码转换
            byte[] c1b = p.getEncoded(false);
            ByteArrayOutputStream os = new ByteArrayOutputStream();
            os.write(c1b);
            os.write(c2);
            os.write(c3);
            encryptData = os.toByteArray();
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
        //省略

BigInteger的toByteArray()

Java算法中将大数转换为二进制数组使用了BigIneger.toByteArray(),与OpenSSL中BIGNUM不同的是,在大数的最高二进制位为1时,BigIneger.toByteArray()会额外的在返回结果前加一个字符‘\0’,标准SM2算法的计算过程中并不会使用到这个额外的字符,Java端SM2算法的实现没有考虑到这个问题。

如下代码,Java的SM2实现中,加密过程中计算C3这一步,如果不对toByteArray()的返回做处理,那SM3 Hash时偶尔就会引入额外的字符。这将导致和C端不一样的结果。

		/* 7 计算C3 = Hash(x2 || M || y2) */
		byte[] C3 = sm3hash(kpb.getXCoord().toBigInteger().toByteArray(), inputBuffer,
				kpb.getYCoord().toBigInteger().toByteArray());

另外,此处Hash计算的数据应该是个定长数据,toByteArray()返回结果的高位要补0,这都是该Java版本SM2实现中没有考虑到的地方。我们自己实现几个函数来完成这个功能:

    public byte[] p2bx(ECPoint point){
        int size = (curve.getFieldSize() + 7) / 8;
        byte[] xb = point.getXCoord().toBigInteger().toByteArray();
        if(xb.length > size){
            byte[] tmp = xb; xb = new byte[size];
            System.arraycopy(tmp, tmp.length - size, xb, 0, size);
        }
        byte[] ret = new byte[size];
        Arrays.fill(ret, (byte)0);
        System.arraycopy(xb, 0, ret, size - xb.length, xb.length);
        return ret;
    }
    public byte[] p2by(ECPoint point){
        int size = (curve.getFieldSize() + 7) / 8;
        byte[] yb = point.getYCoord().toBigInteger().toByteArray();
        if(yb.length > size){
            byte[] tmp = yb; yb = new byte[size];
            System.arraycopy(tmp, yb.length - size, yb, 0, size);
        }
        byte[] ret = new byte[size];
        Arrays.fill(ret, (byte)0);
        System.arraycopy(yb, 0, ret, size - yb.length, yb.length);
        return ret;
    }

并将以上C3计算部分替换为

        byte[] C3 = sm3hash(p2bx(kpb), inputBuffer,p2by(kpb));

对应解密部分的代码也做如下替换:

        /* 替换前
        byte[] u = sm3hash(dBC1.getXCoord().toBigInteger().toByteArray(), M,
                dBC1.getYCoord().toBigInteger().toByteArray());
                */
        byte[] u = sm3hash(p2bx(dBC1), M,p2by(dBC1));

ECPoint的encoded和decodePoint

如下代码所示,Java的SM2实现中滥用了ECPoint的encode方法,比如在加密过程中计算C1时将返回结果进行了不必要的encode。不过我们前面已经在ASN1转换的时候注意了这个问题。

			/* 2 计算椭圆曲线点C1 = [k]G = (x1, y1) */
			ECPoint C1 = G.multiply(k);
            C1Buffer = C1.getEncoded(false);

最要命的是在计算t值时,标准SM2算法要求KDF的输入是对[k]PB即(x2,y2)进行大端二进制数拼接,而这个Java实现却是对(x2,y2)进行了encode编码,如下:

			/* 4 计算 [k]PB = (x2, y2) */
			kpb = publicKey.multiply(k).normalize();
			/* 5 计算 t = KDF(x2||y2, klen) */
			byte[] kpbBytes = kpb.getEncoded(false);
			t = KDF(kpbBytes, inputBuffer.length);

我们重新编写一个函数来替代encode

    public byte[] p2bytes(ECPoint point){
        int size = (curve.getFieldSize() + 7) / 8;
        byte[] xb = point.getXCoord().toBigInteger().toByteArray();
        byte[] yb = point.getYCoord().toBigInteger().toByteArray();
        if(xb.length > size){
            byte[] tmp = xb; xb = new byte[size];
            System.arraycopy(tmp, tmp.length - size, xb, 0, size);
        }
        if(yb.length > size){
            byte[] tmp = yb; yb = new byte[size];
            System.arraycopy(tmp, tmp.length - size, yb, 0, size);
        }
        byte[] ret = new byte[size*2];
        Arrays.fill(ret, (byte)0);
        System.arraycopy(xb, 0, ret, size - xb.length, xb.length);
        System.arraycopy(yb, 0, ret, size + size - yb.length, yb.length);
        return ret;
    }

然后[k]PB的计算做一下修改:

            /* 4 计算 [k]PB = (x2, y2) */
            kpb = publicKey.multiply(k).normalize();
            /* 5 计算 t = KDF(x2||y2, klen) */
            byte[] kpbBytes = p2bytes(kpb);

对应解密的地方也要做一下修改:

        /* 计算t = KDF(x2 || y2, klen) */
        //修改前
        //byte[] dBC1Bytes = dBC1.getEncoded(false);
        byte[] dBC1Bytes = p2bytes(dBC1);
        int klen = encryptData.length - 65 - DIGEST_LENGTH;
        byte[] t = KDF(dBC1Bytes, klen);

ECPoint的归一化

OpenSSL实现中默认z值为1,所以原Java的SM2加密实现中必须实现对C1的归一化处理,修改如下:

            /* 2 计算椭圆曲线点C1 = [k]G = (x1, y1) */
            C1 = G.multiply(k).normalize();
            if (debug) {
                System.out.print("C1: ");
                printHexString(p2bytes(C1));
            }

公钥数据也要进行归一化

    public byte[] encrypt2(String input, ECPoint publicKey) {
        ECPoint C1 = null;
        publicKey = publicKey.normalize();

最后

我们在main中加入一段测试代码,记录下java加密后的密文,使用采用GmSSL的c端进行解密,通过!

        ECPoint publicKey = sm02.importPublicKeyFromDERCert("xxx\\cert-sm2.der");
        BigInteger privateKey = sm02.importPrivateKeyDER("xxx\\key-sm2.der");

        String encStr = "测试加密";
        int ct = 0;
        byte[] encbytes = sm02.encrypt(encStr, publicKey);
        String decbytes = sm02.decrypt(encbytes, privateKey);
        if(!decbytes.equals(encStr))
            System.out.println("测试失败");
-----------------加密解密-----------------
E6B58BE8AF95E58AA0E5AF8661616161616161616161613132336161616262
k: 238411EA6041271ABFCCD923693ECD8DB9F281A5D324F788AA1C148E846E8AD4
C1: A09346F82CF7811B4F20EA6D9162A8E17015FE7E45F7E3C0D3A2D788D585CC207921FF77563D74E7F63208E2084D61551CB0F8F910D32E10583CE9E93E8FCE7A
kpb: D7387C2A1DE31C64C551E1E38723D097F98A3C6A1283FED01D6059EDE25BC1159CA26FBC4B7C9BB0B9646554A62AE189D8F80D95C15ABA30CC4C1079C121077C
C2: A3A792A89FFECB372935308EE6E7B30E83ADE6FA5722431F753958A7BC2F31
C3: 1A4FEE8EA158923FA2876225B2E32A7B8FFE537092E08C3035FB49507AE1674C
encryptData2 length: 139
C1: A09346F82CF7811B4F20EA6D9162A8E17015FE7E45F7E3C0D3A2D788D585CC207921FF77563D74E7F63208E2084D61551CB0F8F910D32E10583CE9E93E8FCE7A
Disconnected from the target VM, address: '127.0.0.1:42148', transport: 'socket'
dBC1: D7387C2A1DE31C64C551E1E38723D097F98A3C6A1283FED01D6059EDE25BC1159CA26FBC4B7C9BB0B9646554A62AE189D8F80D95C15ABA30CC4C1079C121077C
M: E6B58BE8AF95E58AA0E5AF8661616161616161616161613132336161616262
M = 测试加密aaaaaaaaaaa123aaabb
C3: 1A4FEE8EA158923FA2876225B2E32A7B8FFE537092E08C3035FB49507AE1674C
解密成功
测试成功

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐