印尼 SNAP 协议签名接入指南
本文档面向接入印尼 SNAP(Standar Nasional Open API Pembayaran)标准的商户/合作方,说明各类接口的加签与验签逻辑,并给出基于 JDK 原生 API 的实现代码供参考(可据此用任意语言实现)。
1. 签名机制总览
SNAP 根据接口类型使用不同的签名算法,同一个密钥全程只用于一种用途,不可混用:
| 接口类型 | 方向 | 签名算法 | 密钥 | StringToSign |
|---|---|---|---|---|
| Access Token (B2B) | 商户 → 平台 | SHA256with RSA(非对称) | 商户 RSA 私钥加签 / 平台用商户公钥验签 | clientKey || timestamp |
| 业务事务接口 | 商户 → 平台 | HMAC-SHA512(对称) | clientSecret(双方共享) | HTTPMethod:EndpointUrl:AccessToken:SHA256(minify(body)):timestamp |
| 异步通知 | 平台 → 商户 | SHA256with RSA(非对称) | 平台 RSA 私钥加签 / 商户用平台公钥验签 | HTTPMethod:EndpointUrl:SHA256(minify(body)):timestamp |
1.1 商户需要持有的凭证
| 凭证 | 用途 | 来源 |
|---|---|---|
| 商户 RSA 密钥对(推荐 2048 位) | Access Token 接口加签(用私钥) | 商户自行生成,私钥自行保管,公钥上传给平台 |
| clientKey + clientSecret | 事务接口签名(clientSecret 作 HMAC 密钥) | 平台分配 |
| 平台 RSA 公钥 | 验证平台下发的异步通知 | 平台提供 |
2. 核心规则(必读)
2.1 ⚠️ 密钥直接传入,禁止自行 decode
密钥以字符串原文形式参与运算,签名算法在内部才做 Base64.decode。调用方不要再提前 decode,否则会重复解码导致失败。
RSA 私钥 / 公钥:传 Base64 字符串(算法内部 Base64.getDecoder().decode(...) 还原字节)。
clientSecret:传 UTF-8 原文字符串(算法内部 secretKey.getBytes(UTF_8) 取字节)。
一句话:私钥/公钥传 Base64 字符串,clientSecret 传原文,都直接传,不要 decode。
2.2 StringToSign 拼接规则
字段之间用英文冒号 : 连接(Access Token 接口用竖线 |)。
所有参与拼接的字段均为原始值,不做 URL 编码、不做 trim。
HTTPMethod 一律大写(POST / GET)。
EndpointUrl 是请求路径(不含 host、不含 query string),如 /v1.0/debit/host-to-host。
2.3 HTTP Body 的 minify 规则
事务接口与异步通知的 StringToSign 中,body 部分是 Lowercase(HexEncode(SHA-256(minify(body)))),即先对 body 做 minify 再算 SHA-256。
minify 的定义:去除 JSON 中多余空白字符(空格、换行、制表符),保持字段原始顺序、保留字段原值、不丢失 null 字段。切勿对字段排序——排序会改变字节流,导致两端 SHA-256 不一致、验签失败。
2.4 时间戳与编码
时间戳格式:ISO-8601,含时区,如 2026-07-29T10:00:00+07:00(印尼西部时间 WIB,UTC+7)。
字符编码:所有字符串参与签名时统一使用 UTF-8。
摘要输出:SHA-256 输出小写十六进制字符串。
3. 签名算法实现(参考代码)
以下为各算法基于 JDK 原生 API 的实现逻辑,可直接参考。所需 import:
import com.alibaba.fastjson.JSON; // 仅 minify 用到,可用任意 JSON 库替代
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.serializer.SerializerFeature;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;3.1 RSA 私钥加签 / 公钥验签(SHA256withRSA)
/** 私钥加签:base64PrivateKey 为 Base64(PKCS8) 字符串 */
private static String signWithRsa(String content, String base64PrivateKey) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey); // 内部 decode
PrivateKey privateKey = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(content.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signature.sign()); // 签名值 Base64 输出
}
/** 公钥验签:base64PublicKey 为 Base64(X509) 字符串 */
private static boolean verifyWithRsa(String content, String sign, String base64PublicKey) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
PublicKey publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(keyBytes));
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(publicKey);
signature.update(content.getBytes(StandardCharsets.UTF_8));
return signature.verify(Base64.getDecoder().decode(sign));
}3.2 HMAC-SHA512 加签 / 验签(对称,密钥为 UTF-8 原文)
/** 加签:secretKey 为 UTF-8 原文(即 clientSecret) */
private static String signWithHmacSha512(String content, String secretKey) throws Exception {
Mac mac = Mac.getInstance("HmacSHA512");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
byte[] hash = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
}
/** 验签:用相同密钥重算后做常量时间比较,防止时序侧信道 */
private static boolean verifyWithHmacSha512(String content, String sign, String secretKey) throws Exception {
String computed = signWithHmacSha512(content, secretKey);
return MessageDigest.isEqual(
computed.getBytes(StandardCharsets.UTF_8),
sign.getBytes(StandardCharsets.UTF_8));
}3.3 SHA-256 小写十六进制摘要
private static String sha256Hex(String content) throws Exception {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(content.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format("%02x", b)); // 小写 hex
}
return sb.toString();
}3.4 JSON minify(去空白、保序、保留 null;切勿排序)
private static String minify(String body) {
if (body == null || body.trim().isEmpty()) {
return "";
}
// OrderedField:保持原始字段顺序
// WriteMapNullValue:保留值为 null 的字段
// 注意:切勿加 SortField,它会按字段名重排,破坏顺序
return JSON.toJSONString(
JSON.parse(body, Feature.OrderedField),
SerializerFeature.WriteMapNullValue,
SerializerFeature.DisableCircularReferenceDetect);
}用其他 JSON 库同理:解析为保持插入顺序的结构(如 LinkedHashMap / Jackson 的 JsonNode)再紧凑序列化即可,关键是不排序、不改值、不丢字段。
4. Access Token 接口(RSA 非对称)
场景:商户换取 B2B Access Token 时加签;平台用商户上传的公钥验签。
StringToSign:clientKey | timestamp
// —— 商户加签 ——
String stringToSign = clientKey + "|" + timestamp; // 例:"PMID0001|2026-07-29T10:00:00+07:00"
String signature = signWithRsa(stringToSign, merchantPrivateKey);
// 请求头
// X-CLIENT-KEY : <clientKey>
// X-TIMESTAMP : <timestamp>
// X-SIGNATURE : <signature>// —— 平台验签 ——
String stringToSign = clientKey + "|" + timestamp;
boolean valid = verifyWithRsa(stringToSign, xSignature, merchantPublicKey);5. 业务事务接口(HMAC-SHA512 对称)
场景:商户调用业务接口(代收、代付、查询等)时加签;平台用同一 clientSecret 验签。
StringToSign:HTTPMethod : EndpointUrl : AccessToken : Lowercase(Hex(SHA-256(minify(body)))) : timestamp
HMAC 的密钥是 clientSecret;accessToken 只是 StringToSign 里的一段明文,不是 HMAC 密钥。
// —— 商户加签 ——
String stringToSign = httpMethod + ":" + endpointUrl + ":" + accessToken
+ ":" + sha256Hex(minify(requestBody)) + ":" + timestamp;
String signature = signWithHmacSha512(stringToSign, clientSecret);
// 请求头
// Authorization : Bearer <accessToken>
// X-TIMESTAMP : <timestamp>
// X-SIGNATURE : <signature>// —— 平台验签 ——
String stringToSign = httpMethod + ":" + endpointUrl + ":" + accessToken
+ ":" + sha256Hex(minify(requestBody)) + ":" + timestamp;
boolean valid = verifyWithHmacSha512(stringToSign, xSignature, clientSecret);示例 StringToSign 拼接结果:
POST:/v1.0/debit/host-to-host:eyJhbGciOiJ...:a1b2c3...(body 的 sha256 小写 hex):2026-07-29T10:00:00+07:006. 异步通知(RSA 非对称)
场景:平台异步回调商户通知地址,平台用平台自己的 RSA 私钥加签;商户用平台公钥验签。
StringToSign(与事务接口的区别:不含 AccessToken):HTTPMethod : EndpointUrl : Lowercase(Hex(SHA-256(minify(body)))) : timestamp
// —— 平台加签 ——
String stringToSign = httpMethod + ":" + notifyUrl + ":"
+ sha256Hex(minify(notifyBody)) + ":" + timestamp;
String signature = signWithRsa(stringToSign, platformPrivateKey);
// 通知请求头
// X-TIMESTAMP : <timestamp>
// X-SIGNATURE : <signature>// —— 商户验签 ——
String stringToSign = httpMethod + ":" + notifyUrl + ":"
+ sha256Hex(minify(notifyBody)) + ":" + timestamp;
boolean valid = verifyWithRsa(stringToSign, xSignature, platformPublicKey);⚠️ 商户接收通知时:先用收到的原始 body 字符串验签,验签通过后再做业务解析,避免业务层日志/反序列化改写了 body 导致验签失败。
7. 常见问题
Q1:验签总是不通过?
请按顺序排查:
密钥是否传错(私钥/公钥/clientSecret 用错对象,或自行 decode 了)。→ 见 2.1。
endpointUrl 是否是纯路径(不含 host / query)。
httpMethod 是否大写。
body 是否被框架改写(多余空格、字段被重排)→ 先用原始 body 验签。
时间戳格式/时区是否与请求头一致。
Q2:事务接口 body 的字段顺序重要吗?
重要。minify 只去空白,不改变字段顺序。若任何一端对字段做了排序或重新格式化,SHA-256 结果会不同,导致验签失败。
Q3:clientSecret 需要解密或解码后使用吗?
不需要。clientSecret 是可打印字符串,直接作为 HMAC-SHA512 的密钥原文传入。
Q4:RSA 密钥规格要求?
推荐 RSA 2048 位,签名算法 SHA256withRSA,私钥 PKCS8、公钥 X509,Base64 编码传输。
