Skip to content
开发者工具
使用AI助手和工具加速开发

印尼 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:

Plain
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)

Plain
/** 私钥加签: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 原文)

Plain
/** 加签: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 小写十六进制摘要

Plain
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;切勿排序)

Plain
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

Plain
// —— 商户加签 ——
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>
Plain
// —— 平台验签 ——
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 密钥。

Plain
// —— 商户加签 ——
String stringToSign = httpMethod + ":" + endpointUrl + ":" + accessToken
        + ":" + sha256Hex(minify(requestBody)) + ":" + timestamp;
String signature = signWithHmacSha512(stringToSign, clientSecret);

// 请求头
// Authorization : Bearer <accessToken>
// X-TIMESTAMP   : <timestamp>
// X-SIGNATURE   : <signature>
Plain
// —— 平台验签 ——
String stringToSign = httpMethod + ":" + endpointUrl + ":" + accessToken
        + ":" + sha256Hex(minify(requestBody)) + ":" + timestamp;
boolean valid = verifyWithHmacSha512(stringToSign, xSignature, clientSecret);

示例 StringToSign 拼接结果:

Plain
POST:/v1.0/debit/host-to-host:eyJhbGciOiJ...:a1b2c3...(body 的 sha256 小写 hex):2026-07-29T10:00:00+07:00

6. 异步通知(RSA 非对称)

场景:平台异步回调商户通知地址,平台用平台自己的 RSA 私钥加签;商户用平台公钥验签。

StringToSign(与事务接口的区别:不含 AccessToken):HTTPMethod : EndpointUrl : Lowercase(Hex(SHA-256(minify(body)))) : timestamp

Plain
// —— 平台加签 ——
String stringToSign = httpMethod + ":" + notifyUrl + ":"
        + sha256Hex(minify(notifyBody)) + ":" + timestamp;
String signature = signWithRsa(stringToSign, platformPrivateKey);

// 通知请求头
// X-TIMESTAMP : <timestamp>
// X-SIGNATURE  : <signature>
Plain
// —— 商户验签 ——
String stringToSign = httpMethod + ":" + notifyUrl + ":"
        + sha256Hex(minify(notifyBody)) + ":" + timestamp;
boolean valid = verifyWithRsa(stringToSign, xSignature, platformPublicKey);

⚠️ 商户接收通知时:先用收到的原始 body 字符串验签,验签通过后再做业务解析,避免业务层日志/反序列化改写了 body 导致验签失败。

7. 常见问题

Q1:验签总是不通过?

请按顺序排查:

  1. 密钥是否传错(私钥/公钥/clientSecret 用错对象,或自行 decode 了)。→ 见 2.1。

  2. endpointUrl 是否是纯路径(不含 host / query)。

  3. httpMethod 是否大写。

  4. body 是否被框架改写(多余空格、字段被重排)→ 先用原始 body 验签。

  5. 时间戳格式/时区是否与请求头一致。

Q2:事务接口 body 的字段顺序重要吗?

重要。minify 只去空白,不改变字段顺序。若任何一端对字段做了排序或重新格式化,SHA-256 结果会不同,导致验签失败。

Q3:clientSecret 需要解密或解码后使用吗?

不需要。clientSecret 是可打印字符串,直接作为 HMAC-SHA512 的密钥原文传入。

Q4:RSA 密钥规格要求?

推荐 RSA 2048 位,签名算法 SHA256withRSA,私钥 PKCS8、公钥 X509,Base64 编码传输。

此页面的内容有帮助吗?

感谢您帮助改进 PayerMax 产品文档!

Released under the MIT License.