﻿# 印尼 SNAP 协议签名接入指南

::: tip  

本文档面向接入印尼 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 Text
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 Text
/** 私钥加签：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 Text
/** 加签：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 Text
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 Text
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 Text
// —— 商户加签 ——
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 Text
// —— 平台验签 ——
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 Text
// —— 商户加签 ——
String stringToSign = httpMethod + ":" + endpointUrl + ":" + accessToken
        + ":" + sha256Hex(minify(requestBody)) + ":" + timestamp;
String signature = signWithHmacSha512(stringToSign, clientSecret);

// 请求头
// Authorization : Bearer <accessToken>
// X-TIMESTAMP   : <timestamp>
// X-SIGNATURE   : <signature>
```

```Plain Text
// —— 平台验签 ——
String stringToSign = httpMethod + ":" + endpointUrl + ":" + accessToken
        + ":" + sha256Hex(minify(requestBody)) + ":" + timestamp;
boolean valid = verifyWithHmacSha512(stringToSign, xSignature, clientSecret);
```

示例 StringToSign 拼接结果：

```Plain Text
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 Text
// —— 平台加签 ——
String stringToSign = httpMethod + ":" + notifyUrl + ":"
        + sha256Hex(minify(notifyBody)) + ":" + timestamp;
String signature = signWithRsa(stringToSign, platformPrivateKey);

// 通知请求头
// X-TIMESTAMP : <timestamp>
// X-SIGNATURE  : <signature>
```

```Plain Text
// —— 商户验签 ——
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 编码传输。
