10.1 签名工具类(SignUtils)
平台回调商户时,请求头携带 Timestamp、Signature、Sign-Version(V1)、Timezone(UTC+8)。
签名对 data 对象(JSON 字符串)计算,不含 顶层 id、action 字段。
/**
* 签名工具类
* 算法:HMAC-SHA256,输出小写十六进制
*/
public class SignUtils {
public static String sign(String appId, String timestamp, String body, String secretKey) throws Exception {
return sign(appId, timestamp, buildBodyMap(body), secretKey);
}
public static String sign(String appId, String timestamp, Map<String, String> body, String secretKey) throws Exception {
TreeMap<String, String> params = new TreeMap<>();
params.put("appId", appId);
params.put("timestamp", timestamp);
if (body != null && !body.isEmpty()) {
params.putAll(body);
}
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
if (entry.getValue() == null) continue;
if (sb.length() > 0) sb.append("&");
sb.append(entry.getKey()).append("=").append(entry.getValue());
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return bytesToHex(mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8)));
}
public static Map<String, String> buildBodyMap(String body) {
Map<String, String> bodyMap = new TreeMap<>();
if (StringUtils.isBlank(body)) return bodyMap;
JSONObject json = JSONObject.parseObject(body);
for (Map.Entry<String, Object> e : json.entrySet()) {
if (e.getValue() == null) continue;
Object v = e.getValue();
bodyMap.put(e.getKey(), v instanceof String ? (String) v : JSONObject.toJSONString(v));
}
return bodyMap;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
- 参与签名的公共参数仅
appId、timestamp 及 data 内各字段;顶层 id、action 不参与签名。
buildBodyMap 为 public 方法,商户验签时可复用相同逻辑。
10.2 商户敏感数据加解密工具类
适用场景
| 接口 | 方向 | 说明 |
|---|
GET /card/{card_no}/private/info | 服务端 → 商户 | 响应 data 为加密字符串,商户解密后得 JSON |
POST /card/active | 商户 → 服务端 | 请求 cvv 须为加密后的 Base64 密文 |
POST /card/pin/set | 商户 → 服务端 | 请求 pin 须为加密后的 Base64 密文 |
算法说明
| 项 | 值 |
|---|
| 算法 | AES-256 |
| 模式/填充 | CBC / PKCS5Padding(每次加密随机 16 字节 IV) |
| 密钥派生 | SHA-256(apiSecret UTF-8) → 32 字节 AES 密钥(apiSecret 为变长字符串,不可直接作为 AES Key) |
| 密文格式 | Base64( IV[16字节] ‖ AES-CBC密文 ) |
| 字符集 | UTF-8(明文) |
设置 PIN 明文须 6 位数字且不可含三位及以上相同或连续数字,否则返回 4016。实体卡激活明文 CVV 须 3 位纯数字;解密失败返回 4015 / 4019。
工具类代码
/**
* 敏感字段加解密:PIN / CVV / 卡隐私 JSON
*/
public class MerchantEncryptUtil {
private static final String KEY_ALGORITHM = "AES";
private static final String CIPHER_ALGORITHM_CBC = "AES/CBC/PKCS5Padding";
private static final int IV_LENGTH = 16;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
public static String encrypt(String data, String secretKey) {
if (StringUtils.isAnyBlank(data, secretKey)) {
log.error("MerchantEncryptUtil encrypt param is blank");
return null;
}
try {
byte[] iv = new byte[IV_LENGTH];
SECURE_RANDOM.nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM_CBC);
cipher.init(Cipher.ENCRYPT_MODE, buildAesKey(secretKey), ivSpec);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[IV_LENGTH + encrypted.length];
System.arraycopy(iv, 0, combined, 0, IV_LENGTH);
System.arraycopy(encrypted, 0, combined, IV_LENGTH, encrypted.length);
return Base64.encodeBase64String(combined);
} catch (Exception e) {
return null;
}
}
public static String decrypt(String encryptedData, String secretKey) {
if (StringUtils.isAnyBlank(encryptedData, secretKey)) {
log.error("MerchantEncryptUtil decrypt param is blank");
return null;
}
byte[] combined = Base64.decodeBase64(encryptedData);
try {
byte[] iv = Arrays.copyOfRange(combined, 0, IV_LENGTH);
byte[] cipherBytes = Arrays.copyOfRange(combined, IV_LENGTH, combined.length);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM_CBC);
cipher.init(Cipher.DECRYPT_MODE, buildAesKey(secretKey), ivSpec);
byte[] decrypted = cipher.doFinal(cipherBytes);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception cbcEx) {
log.warn("MerchantEncryptUtil CBC decrypt failed, fallback to ECB (legacy data): {}", cbcEx.getMessage());
}
return null;
}
private static SecretKeySpec buildAesKey(String secretKey) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] keyBytes = digest.digest(secretKey.getBytes(StandardCharsets.UTF_8));
return new SecretKeySpec(keyBytes, KEY_ALGORITHM);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
调用示例
1)实体卡激活 — 商户加密 CVV 后上送
String apiSecret = "your_api_secret_plaintext";
String plainCvv = "123";
String encryptedCvv = MerchantEncryptUtil.encrypt(plainCvv, apiSecret);
// 请求体
// {
// "card_no": "...",
// "cardholder_no": "...",
// "cvv": "<encryptedCvv>",
// "card_last_no": "1234"
// }
2)设置实体卡 PIN — 商户加密后上送
String apiSecret = "your_api_secret_plaintext";
String plainPin = "123456";
String encryptedPin = MerchantEncryptUtil.encrypt(plainPin, apiSecret);
// 请求体
// { "card_no": "...", "cardholder_no": "...", "pin": "<encryptedPin>" }
3)获取卡隐私 JSON — 商户解密响应 data
String apiSecret = "your_api_secret_plaintext";
String encryptedData = response.getData(); // HTTP 响应中的 data 字段
String json = MerchantEncryptUtil.decrypt(encryptedData, apiSecret);
// json 为卡隐私信息 JSON 字符串
相关错误码
| code | 中文描述 |
|---|
| 1013 | 未开通 PCI 敏感卡展示能力 |
| 4015 | PIN 码解密失败,请检查加密方式与密钥 |
| 4018 | 未找到可用的 API 凭证 |
| 4019 | CVV 解密失败,请检查加密方式与密钥 |