开放API接口安全设计文档

[toc]

一. 背景介绍

		开放平台的API是基于HTTP协议来调用的,系统需要验证商户上送的签名是否正确;商户收到应答,也需要验证签名是否正确,如果商户未正确验证签名,存在潜在的风险,商户自行承担因此而产生的所有损失。以下主要是针对自行封装HTTP请求进行API调用的原理进行详细解说。
		根据接口调用协议:填充参数 > 生成签名 > 拼装HTTP请求 > 发起HTTP请求> 验证签名,数据解密 > 得到HTTP响应 > 加签加密返回

二. 调用地址

调用环境服务地址(HTTP)服务地址(HTTPS)
测试环境
正式环境

三. 接口安全

3.1 公共参数

  • 调用任何一个API都必须传入的参数,目前支持的公共参数有:
参数名称参数类型是否必须参数描述
encodingString编码方式,默认UTF-8(大写)。
appIdString分配给应用的AppId
appSecretString业务接口body或响应数据加密aes秘钥(AES长度为256,工作方式为 CBC)
timestampString时间戳,格式为yyyy-MM-dd HH:mm:ss,时区为GMT+8,例如:2021-12-01 12:00:00。
versionStringAPI协议版本,当前传参:1.0。
signMethodString签名的摘要算法,当前使用MD5。
bodyString业务接口请求参数密文(对原始json字符串加密并Base64编码)
signString具体的签名串,针对摘要digest使用私钥签名

3.2 报文的签名机制

		为了防止API调用过程中被恶意篡改,调用任何一个API都需要携带签名,开放平台服务端会根据请求参数,对签名进行验证,签名不合法的请求将会被拒绝。系统目前支持的签名摘要算法有MD5(signMethod=md5),对于报文的签名加密处理机制如下: 

1. 首先,针对请求body使用生成的appSecret进行AES加密,并对加密报文进行Base64编码(Base64.encodeBase64String(
                AESUtils.encrypt(appSecret, body).getBytes(StandardCharsets.UTF_8)));

2. 然后,对加密body的AES key使用**对方的公钥**进行加密,并作为接口请求的appSecret;

3. 其次,对使用AES加密后的body使用 MD5 算法做摘要(DigestUtils.md5Hex(concatStr).toLowerCase()),再使用**自己的RSA签名私钥**对摘要做签名操作;

4. 最后,将签名串放在签名(sign)字段和其他字段一起通过HTTP Post的方式传输给开放平台。 

3.3 报文的验签机制

		对于报文的验签解密处理机制如下: 

1. 首先,获取接口请求的appSecret,并使用自己的私钥进行解密得到原始的AES秘钥;

2. 然后,对使用AES加密后的body使用 MD5 算法做摘要(DigestUtils.md5Hex(concatStr).toLowerCase();

3. 其次,使用对方的公钥解密出接口请求的签名(sign),将解密出摘要digest与步骤二计算的摘要比对签名结果;

4. 最后,对body的密文首先进行Base64解码,然后使用解密出的AES解密body报文得到原始的请求Json报文。

四. 代码参考

4.1 AES对称加密

package com.xxx.xxx.xxx;


import java.nio.charset.StandardCharsets;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.RandomStringUtils;

public class AESUtils {

    private static final String KEY_ALGORITHM = "AES";
    private static final Integer CBC_SECRET_KEY_LENGTH = 128;
    private static final Integer ECB_SECRET_KEY_LENGTH = 256;
    private static final String AES_CBC = "AES/CBC/PKCS5Padding";
    private static final String AES_ECB = "AES/ECB/PKCS5Padding";

    public AESUtils() {
    }

    public static String generateKey(int length) {
        return RandomStringUtils.randomAlphanumeric(length);
    }

    public static String encrypt(String key, String iv, String content) throws Exception {
        KeyGenerator ken = KeyGenerator.getInstance("AES");
        ken.init(CBC_SECRET_KEY_LENGTH);
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        IvParameterSpec ivp = new IvParameterSpec(iv.getBytes());
        cipher.init(1, new SecretKeySpec(key.getBytes(), "AES"), ivp);
        return Base64.encodeBase64String(cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)));
    }

    public static String encrypt(String key, String content) throws Exception {
        KeyGenerator ken = KeyGenerator.getInstance("AES");
        ken.init(ECB_SECRET_KEY_LENGTH);
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(1, new SecretKeySpec(key.getBytes(), "AES"));
        return Base64.encodeBase64String(cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)));
    }

    public static String decrypt(String key, String iv, String content) throws Exception {
        KeyGenerator ken = KeyGenerator.getInstance("AES");
        ken.init(CBC_SECRET_KEY_LENGTH);
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        IvParameterSpec ivp = new IvParameterSpec(iv.getBytes());
        cipher.init(2, new SecretKeySpec(key.getBytes(), "AES"), ivp);
        byte[] result = cipher.doFinal(Base64.decodeBase64(content));
        return new String(result, StandardCharsets.UTF_8);
    }

    public static String decrypt(String key, String content) throws Exception {
        KeyGenerator ken = KeyGenerator.getInstance("AES");
        ken.init(ECB_SECRET_KEY_LENGTH);
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(2, new SecretKeySpec(key.getBytes(), "AES"));
        byte[] result = cipher.doFinal(Base64.decodeBase64(content));
        return new String(result, StandardCharsets.UTF_8);
    }
}

4.2 RSA非对称加密

package com.xxx.xxx.xxx;


import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import javax.crypto.Cipher;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class RSAUtils {

    private static final Logger log = LoggerFactory.getLogger(RSAUtils.class);

    public RSAUtils() {
    }

    public static String encryptByPublicKey(String publicKey, String text) throws Exception {
        X509EncodedKeySpec x509EncodedKeySpec2 = new X509EncodedKeySpec(Base64.decodeBase64(publicKey));
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey key = keyFactory.generatePublic(x509EncodedKeySpec2);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(1, key);
        byte[] result = cipher.doFinal(text.getBytes());
        return Base64.encodeBase64String(result);
    }

    public static String decryptByPrivateKey(String privateKey, String text) throws Exception {
        PKCS8EncodedKeySpec pkcs8EncodedKeySpec5 = new PKCS8EncodedKeySpec(Base64.decodeBase64(privateKey));
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PrivateKey key = keyFactory.generatePrivate(pkcs8EncodedKeySpec5);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(2, key);
        byte[] result = cipher.doFinal(Base64.decodeBase64(text));
        return new String(result);
    }

    public static String encryptByPrivateKey(String privateKey, String text) throws Exception {
        PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(Base64.decodeBase64(privateKey));
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PrivateKey key = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(1, key);
        byte[] result = cipher.doFinal(text.getBytes());
        return Base64.encodeBase64String(result);
    }

    public static String decryptByPublicKey(String publicKey, String text) throws Exception {
        X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(Base64.decodeBase64(publicKey));
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey key = keyFactory.generatePublic(x509EncodedKeySpec);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(2, key);
        byte[] result = cipher.doFinal(Base64.decodeBase64(text));
        return new String(result);
    }

    public static RSAKeyPair generateKeyPair() throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        KeyPair keyPair = keyPairGenerator.generateKeyPair();
        RSAPublicKey rsaPublicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) keyPair.getPrivate();
        String publicKeyString = Base64.encodeBase64String(rsaPublicKey.getEncoded());
        String privateKeyString = Base64.encodeBase64String(rsaPrivateKey.getEncoded());
        return new RSAKeyPair(publicKeyString, privateKeyString);
    }

    public static class RSAKeyPair {

        private final String publicKey;
        private final String privateKey;

        public RSAKeyPair(String publicKey, String privateKey) {
            this.publicKey = publicKey;
            this.privateKey = privateKey;
        }

        public String getPublicKey() {
            return this.publicKey;
        }

        public String getPrivateKey() {
            return this.privateKey;
        }
    }
}

五. 商户接口请求示例

5.1 加密加签示例

package com.xxx.xxx.xxx;


import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.Test;


public class EncryptAndDecryptTest {

    public static final String appId = "wshoto_supplier";

    public static String appSecret;

    public static final String encoding = "UTF-8";

    public static final String signMethod = "MD5";

    public static final String version = "1.0";

    // 商户平台RSA秘钥对
    public static RSAKeyPair merchantRsaKeyPair;

    // 开放平台RSA秘钥对
    public static RSAKeyPair platFormRsaKeyPair;

    static {
        appSecret = AESUtils.generateKey(32);

        try {
            merchantRsaKeyPair = RSAUtils.generateKeyPair();
            platFormRsaKeyPair = RSAUtils.generateKeyPair();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private JsonObject encryptRequest(String body) throws Exception {
        // 1.对body加密(AES 256 CBC)
        String encodedBody = Base64.encodeBase64String(
                AESUtils.encrypt(appSecret, body).getBytes(StandardCharsets.UTF_8));
        System.out.println("encryptRequest01 encoded body: " + encodedBody);

        // 2.拼接报文返回
        JsonObject jsonObject = new JsonObject();
        jsonObject.addProperty("appId", appId);
        // => 商户侧与平台侧主要体现在公私钥的使用上!(外部商户公钥加密会话AES秘钥)
        String aesKey = RSAUtils.encryptByPublicKey(platFormRsaKeyPair.getPublicKey(), appSecret);
        jsonObject.addProperty("appSecret", aesKey);
        jsonObject.addProperty("body", encodedBody);
        jsonObject.addProperty("encoding", encoding);
        jsonObject.addProperty("signMethod", signMethod);
        jsonObject.addProperty("version", version);

        // 3.摘要生成
        String digest = DigestUtils.md5Hex(encodedBody).toLowerCase();
        System.out.println("encryptRequest01 digest: " + digest);

        // 4.签名生成(对摘要私钥加签)      	// => 商户侧与平台侧主要体现在公私钥的使用上!(外部商户私钥加密会话摘要Digest)
        String signStr = RSAUtils.encryptByPrivateKey(merchantRsaKeyPair.getPrivateKey(), digest);
        System.out.println("encryptRequest01 signStr: " + signStr);

        // 5.签名拼接
        jsonObject.addProperty("sign", signStr);

        return jsonObject;
    }

    @Test
    public void testEncryptRequest() throws Exception {
        String body = "{\n"
                + "    \"supplier_id\": \"olGAfxdH\",\n"
                + "    \"asset_type\": \"4ru7XS\",\n"
                + "    \"risk_level\": \"71\",\n"
                + "    \"page_size\": 47,\n"
                + "    \"page_num\": 4,\n"
                + "    \"fund_type\": \"8kZRTFC\",\n"
                + "    \"fuzzy_name\": \"654\"\n"
                + "}";

        System.out.println(this.encryptRequest(body));
    }
}

5.2 验签解密示例

package com.xxx.xxx.xxx;


import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.Test;


public class EncryptAndDecryptTest {

    public static final String appId = "wshoto_supplier";

    public static String appSecret;

    public static final String encoding = "UTF-8";

    public static final String signMethod = "MD5";

    public static final String version = "1.0";

    // 商户平台RSA秘钥对
    public static RSAKeyPair merchantRsaKeyPair;

    // 开放平台RSA秘钥对
    public static RSAKeyPair platFormRsaKeyPair;

    static {
        appSecret = AESUtils.generateKey(32);

        try {
            merchantRsaKeyPair = RSAUtils.generateKeyPair();
            platFormRsaKeyPair = RSAUtils.generateKeyPair();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public String decryptRequest(JsonObject jsonObject) throws Exception {

        // 0.平台私钥解密出aes秘钥
        String appSecretEncoded = jsonObject.getAsJsonPrimitive("appSecret").getAsString();
      	// => 商户侧与平台侧主要体现在公私钥的使用上!(外部商户公钥解密会话AES秘钥)
        String aesKey = RSAUtils.decryptByPrivateKey(merchantRsaKeyPair.getPrivateKey(), appSecretEncoded);

        // 1.获取签名信息
        String sign = jsonObject.getAsJsonPrimitive("sign").getAsString();
        System.out.println("decryptRequest01 sign: " + sign);

        // 2.签名验证 (先公钥解签, 然后再生成摘要比对)
        String digest = DigestUtils.md5Hex(jsonObject.getAsJsonPrimitive("body").getAsString()).toLowerCase();
      	// => 商户侧与平台侧主要体现在公私钥的使用上!(开放平台公钥解密会话摘要Digest)
        String reqDigest = RSAUtils.decryptByPublicKey(platFormRsaKeyPair.getPublicKey(), sign);
        System.out.println("digest check: " + reqDigest.equals(digest));

        // 3.body解密 (先Base64解码, 再AES解密, 与加密方向相反)
        String encodedBody = jsonObject.getAsJsonPrimitive("body").getAsString();
        String body = new String(Base64.decodeBase64(encodedBody), StandardCharsets.UTF_8);

        return AESUtils.decrypt(aesKey, body);
    }


    @Test
    public void testDecryptRequest() throws Exception {
        String body = "{\n"
                + "    \"supplier_id\": \"olGAfxdH\",\n"
                + "    \"asset_type\": \"4ru7XS\",\n"
                + "    \"risk_level\": \"71\",\n"
                + "    \"page_size\": 47,\n"
                + "    \"page_num\": 4,\n"
                + "    \"fund_type\": \"8kZRTFC\",\n"
                + "    \"fuzzy_name\": \"654\"\n"
                + "}";

        JsonObject res = this.encryptRequest(body);
        System.out.println("encrypt res: " + res);
        System.out.println();

        String plainText = this.decryptRequest(res);
        System.out.println("plainText: " + plainText);
    }
}

六. 开放平台接口请求示例

6.1 加密加签示例

package com.xxx.xxx.xxx;


import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.Test;


public class EncryptAndDecryptTest {

    public static final String appId = "wshoto_supplier";

    public static String appSecret;

    public static final String encoding = "UTF-8";

    public static final String signMethod = "MD5";

    public static final String version = "1.0";

    // 商户平台RSA秘钥对
    public static RSAKeyPair merchantRsaKeyPair;

    // 开放平台RSA秘钥对
    public static RSAKeyPair platFormRsaKeyPair;

    static {
        appSecret = AESUtils.generateKey(32);

        try {
            merchantRsaKeyPair = RSAUtils.generateKeyPair();
            platFormRsaKeyPair = RSAUtils.generateKeyPair();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private JsonObject encryptRequest(String body) throws Exception {
        // 1.对body加密(AES 256 CBC)
        String encodedBody = Base64.encodeBase64String(
                AESUtils.encrypt(appSecret, body).getBytes(StandardCharsets.UTF_8));
        System.out.println("encryptRequest01 encoded body: " + encodedBody);

        // 2.拼接报文返回
        JsonObject jsonObject = new JsonObject();
        jsonObject.addProperty("appId", appId);
        // => 商户侧与平台侧主要体现在公私钥的使用上!(外部商户公钥加密会话AES秘钥)
        String aesKey = RSAUtils.encryptByPublicKey(merchantRsaKeyPair.getPublicKey(), appSecret);
        jsonObject.addProperty("appSecret", aesKey);
        jsonObject.addProperty("body", encodedBody);
        jsonObject.addProperty("encoding", encoding);
        jsonObject.addProperty("signMethod", signMethod);
        jsonObject.addProperty("version", version);

        // 3.摘要生成
        String digest = DigestUtils.md5Hex(encodedBody).toLowerCase();
        System.out.println("encryptRequest01 digest: " + digest);

        // 4.签名生成(对摘要私钥加签)      	// => 商户侧与平台侧主要体现在公私钥的使用上!(开放平台私钥加密会话摘要Digest)
        String signStr = RSAUtils.encryptByPrivateKey(platFormRsaKeyPair.getPrivateKey(), digest);
        System.out.println("encryptRequest01 signStr: " + signStr);

        // 5.签名拼接
        jsonObject.addProperty("sign", signStr);

        return jsonObject;
    }
}

6.2 验签解密示例

package com.xxx.xxx.xxx;


import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.Test;


public class EncryptAndDecryptTest {

    public static final String appId = "wshoto_supplier";

    public static String appSecret;

    public static final String encoding = "UTF-8";

    public static final String signMethod = "MD5";

    public static final String version = "1.0";

    // 商户平台RSA秘钥对
    public static RSAKeyPair merchantRsaKeyPair;

    // 开放平台RSA秘钥对
    public static RSAKeyPair platFormRsaKeyPair;

    static {
        appSecret = AESUtils.generateKey(32);

        try {
            merchantRsaKeyPair = RSAUtils.generateKeyPair();
            platFormRsaKeyPair = RSAUtils.generateKeyPair();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public String decryptRequest(JsonObject jsonObject) throws Exception {

        // 0.平台私钥解密出aes秘钥
        String appSecretEncoded = jsonObject.getAsJsonPrimitive("appSecret").getAsString();
      	// => 商户侧与平台侧主要体现在公私钥的使用上!(开放平台私钥解密会话AES秘钥)
        String aesKey = RSAUtils.decryptByPrivateKey(platFormRsaKeyPair.getPrivateKey(), appSecretEncoded);

        // 1.获取签名信息
        String sign = jsonObject.getAsJsonPrimitive("sign").getAsString();
        System.out.println("decryptRequest01 sign: " + sign);

        // 2.签名验证 (先公钥解签, 然后再生成摘要比对)
        String digest = DigestUtils.md5Hex(jsonObject.getAsJsonPrimitive("body").getAsString()).toLowerCase();
      	// => 商户侧与平台侧主要体现在公私钥的使用上!(外部商户公钥解密会话摘要Digest)
        String reqDigest = RSAUtils.decryptByPublicKey(merchantRsaKeyPair.getPublicKey(), sign);
        System.out.println("digest check: " + reqDigest.equals(digest));

        // 3.body解密 (先Base64解码, 再AES解密, 与加密方向相反)
        String encodedBody = jsonObject.getAsJsonPrimitive("body").getAsString();
        String body = new String(Base64.decodeBase64(encodedBody), StandardCharsets.UTF_8);

        return AESUtils.decrypt(aesKey, body);
    }
}

Q.E.D.


谁言不解广寒情,天边一颗伴月星