> For the complete documentation index, see [llms.txt](https://docs.topay.mobi/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.topay.mobi/cn/integration/authentication.md).

# 身份认证

配置 RSA 密钥并为 Topay API 请求生成签名。

Topay 使用 RSA 签名认证 API 请求。您需要自行生成并保管私钥，将对应的公钥上传至 Topay。Topay 无需获取您的私钥。

## 生成 RSA 密钥对 <a href="#generate-an-rsa-key-pair" id="generate-an-rsa-key-pair"></a>

生成一个 2048 位、PKCS #8 PEM 格式的 RSA 私钥，然后导出对应公钥：

```bash
openssl genpkey -algorithm RSA -out topay_secret.key -pkeyopt rsa_keygen_bits:2048
openssl pkey -in topay_secret.key -pubout -out topay_public.key
```

私钥文件格式如下。示例中间部分已脱敏，不能作为实际密钥使用：

```
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSj...REDACTED...AQAB
-----END PRIVATE KEY-----
```

{% hint style="danger" %}
请安全保存 `topay_secret.key`。不要上传该文件、发送给 Topay、提交到代码仓库、输出到日志，或嵌入客户端代码。
{% endhint %}

打开商户后台，进入 **API Key** 页签，上传 `topay_public.key` 的内容以配置商户公钥。Topay 使用此公钥验证您的 API 请求签名。

上传前，请删除 `-----BEGIN PUBLIC KEY-----` 和 `-----END PUBLIC KEY-----` 两行，再移除所有换行符。仅上传剩余的 Base64 编码公钥内容，且必须为单行。

<figure><img src="https://708501463-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F5CoqhFwqwkrLal2C588B%2Fuploads%2Fgit-blob-04ec201aa9e4577a12904e7fcc59206a63549d32%2Fimage%20(9).png?alt=media" alt="商户后台 API Key 页面，展示商户 ID 和公钥配置"><figcaption><p>在商户后台配置您的公钥，并下载 Topay 公钥。</p></figcaption></figure>

同时下载并保存 Topay 公钥。[Webhook 通知](https://github.com/musepay/musepay-public-docs/tree/main/topay-api-cn/webhook.md)的验签使用 Topay 公钥，而不是您的商户公钥。

## 必需的认证字段 <a href="#required-authentication-fields" id="required-authentication-fields"></a>

每个 API 请求体都必须包含以下字段。生成签名前，还需将接口对应的业务字段加入同一请求体。

| 名称           | 类型     | 说明                                     |
| ------------ | ------ | -------------------------------------- |
| `partner_id` | String | 商户后台显示的商户 ID。                          |
| `sign_type`  | String | 固定值：`RSA`。                             |
| `timestamp`  | String | 当前的 10 位 Unix 时间戳，单位为秒。                |
| `nonce`      | String | 本次请求的唯一随机值。请勿重复使用 nonce；60 秒内的重复值会被拒绝。 |
| `sign`       | String | 按下述步骤生成并经 Base64 编码的 RSA 签名。           |

公共请求字段的完整说明请参阅[公共参数](/cn/reference/api-reference/common-parameters.md)。

## 生成签名 <a href="#build-the-signature" id="build-the-signature"></a>

请使用最终发送给 Topay 的实际请求字段值：

1. 移除 `sign` 字段。
2. 排除值为 `null` 或空字符串的字段。
3. 按字段名进行区分大小写的字母升序排序。
4. 将每个字段拼接为 `key=value`，再用 `&` 连接各组键值对。不要对该待签名字符串进行 URL 编码。
5. 将待签名字符串编码为 UTF-8，使用 RSA 私钥和 `SHA1WithRSA` 算法签名。
6. 对签名结果进行 Base64 编码，并作为 `sign` 发送。

{% hint style="warning" %}
当前 Topay API 要求使用 `SHA1WithRSA`。除非 Topay 已确认您的接入已迁移，否则请勿更换签名算法。
{% endhint %}

### Customer 序列化 <a href="#customer-serialization" id="customer-serialization"></a>

法币代收中的 `customer` 是 JSON 字符串。生成签名前，先序列化客户信息对象：Java 使用 `JSON.toJSONString(customer)`，JavaScript 使用 `JSON.stringify(customer)`，PHP 使用 `json_encode($customer)`。签名和发送请求时必须使用同一个字符串，不要将原始对象直接传入签名函数。

例如，请求体包含：

```json
{
  "customer": "{\"name\":\"Test User\",\"email\":\"customer@example.com\"}"
}
```

该字段在待签名字符串中的内容为：

```
customer={"name":"Test User","email":"customer@example.com"}
```

请求体中的反斜杠用于对外层 JSON 中的引号进行转义，不属于解码后参与签名的 `customer` 字段值。按上述规则对顶层请求字段排序，并原样使用序列化后的 `customer` 值。

### 请求示例 <a href="#example-payload" id="example-payload"></a>

未签名的请求体：

```json
{
  "partner_id": "200001",
  "request_id": "202609110001",
  "sign_type": "RSA",
  "timestamp": "1789056000",
  "nonce": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS"
}
```

待签名字符串为：

```
nonce=5K8264ILTKCH16CQ2502SI8ZNMTM67VS&partner_id=200001&request_id=202609110001&sign_type=RSA&timestamp=1789056000
```

签名完成后，将 Base64 编码的结果加入请求体：

```json
{
  "partner_id": "200001",
  "request_id": "202609110001",
  "sign_type": "RSA",
  "timestamp": "1789056000",
  "nonce": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS",
  "sign": "<Base64-encoded RSA signature>"
}
```

## 代码示例 <a href="#code-examples" id="code-examples"></a>

以下示例从 `topay_secret.key` 读取私钥。请将该文件存放在代码仓库之外，并限制访问权限。

<details>

<summary>Java</summary>

```java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

static String canonicalize(Map<String, ?> params) {
    return params.entrySet().stream()
        .filter(entry -> !"sign".equals(entry.getKey()))
        .filter(entry -> entry.getValue() != null)
        .filter(entry -> !String.valueOf(entry.getValue()).isEmpty())
        .sorted(Map.Entry.comparingByKey())
        .map(entry -> entry.getKey() + "=" + entry.getValue())
        .collect(Collectors.joining("&"));
}

static PrivateKey loadPrivateKey(Path path) throws Exception {
    String pem = Files.readString(path, StandardCharsets.UTF_8)
        .replace("-----BEGIN PRIVATE KEY-----", "")
        .replace("-----END PRIVATE KEY-----", "")
        .replaceAll("\\s", "");
    byte[] keyBytes = Base64.getDecoder().decode(pem);
    return KeyFactory.getInstance("RSA")
        .generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}

static String sign(String content, PrivateKey privateKey) throws Exception {
    Signature signer = Signature.getInstance("SHA1WithRSA");
    signer.initSign(privateKey);
    signer.update(content.getBytes(StandardCharsets.UTF_8));
    return Base64.getEncoder().encodeToString(signer.sign());
}

Map<String, Object> requestBody = new HashMap<>();
requestBody.put("partner_id", "200001");
requestBody.put("request_id", "202609110001");
requestBody.put("sign_type", "RSA");
requestBody.put("timestamp", String.valueOf(Instant.now().getEpochSecond()));
requestBody.put("nonce", UUID.randomUUID().toString().replace("-", ""));

PrivateKey privateKey = loadPrivateKey(Path.of("topay_secret.key"));
requestBody.put("sign", sign(canonicalize(requestBody), privateKey));
```

</details>

<details>

<summary>JavaScript (Node.js)</summary>

```javascript
import { readFileSync } from 'node:fs'
import { randomUUID, sign } from 'node:crypto'

function canonicalize(params) {
  return Object.entries(params)
    .filter(([key, value]) => key !== 'sign' && value !== null && value !== undefined && value !== '')
    .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
    .map(([key, value]) => `${key}=${value}`)
    .join('&')
}

const requestBody = {
  partner_id: '200001',
  request_id: '202609110001',
  sign_type: 'RSA',
  timestamp: Math.floor(Date.now() / 1000).toString(),
  nonce: randomUUID().replaceAll('-', '')
}

const privateKey = readFileSync('topay_secret.key', 'utf8')
const content = canonicalize(requestBody)
requestBody.sign = sign('RSA-SHA1', Buffer.from(content, 'utf8'), privateKey).toString('base64')
```

</details>

<details>

<summary>PHP</summary>

```php
<?php
$requestBody = [
    "partner_id" => "200001",
    "request_id" => "202609110001",
    "sign_type" => "RSA",
    "timestamp" => (string) time(),
    "nonce" => bin2hex(random_bytes(16))
];

$signingFields = array_filter(
    $requestBody,
    fn($value, $key) => $key !== "sign" && $value !== null && $value !== "",
    ARRAY_FILTER_USE_BOTH
);
ksort($signingFields, SORT_STRING);

$pairs = [];
foreach ($signingFields as $key => $value) {
    $pairs[] = $key . "=" . $value;
}
$content = implode("&", $pairs);

$privateKeyPem = file_get_contents("topay_secret.key");
if ($privateKeyPem === false) {
    throw new RuntimeException("Unable to read the private key");
}

if (!openssl_sign($content, $signature, $privateKeyPem, OPENSSL_ALGO_SHA1)) {
    throw new RuntimeException("Unable to sign the request");
}

$requestBody["sign"] = base64_encode($signature);
```

</details>

## 签名错误排查 <a href="#troubleshooting-signature-errors" id="troubleshooting-signature-errors"></a>

如果 Topay 拒绝签名，请确认：

* 商户后台上传的公钥与签名使用的私钥匹配；
* 待签名字符串包含除 `sign` 外的所有非空请求字段；
* 字段名和字段值与最终请求体完全一致；
* 字段已按名称排序，且未进行 URL 编码；
* 内容使用 UTF-8 编码，并采用 `SHA1WithRSA` 签名；
* `timestamp` 为 10 位、单位为秒的 Unix 时间戳；
* 每次请求都生成新的 `nonce`。
