# 认证与签名

Invoice、Transfer 和 Query 使用账户级 API Key + Ed25519 请求签名。API Key 用于定位凭证，Ed25519 用于证明请求来自持有私钥的商户系统。

## 必需 Header

| Header | 必填 | 说明 |
|---|---:|---|
| `X-API-ID` | 是 | 账户级 API ID |
| `X-API-Key` | 是 | 账户级 API Key |
| `X-Timestamp` | 是 | Unix 秒级时间戳，默认允许 ±60 秒偏差 |
| `X-Signature` | 是 | Ed25519 签名的标准 Base64 编码 |
| `X-Idempotency-Key` 或 `Idempotency-Key` | 写请求二选一 | 两者同时发送时必须相等，否则返回 `400 INVALID_REQUEST` |

## 签名 Payload

```text
v1
{api_id}
{merchant_account_id}
{METHOD}
{escaped_path}
{canonical_query}
{timestamp}
{idempotency_key}
{sha256(body)}
```

字段之间使用单个 `\n`，末尾不额外追加换行。`METHOD` 使用大写；body hash 使用原始发送字节计算，不要先解析再序列化。

## 规范化要求

- `escaped_path` 必须与 HTTP 请求实际发送的 path 完全一致，不含 query。
- `canonical_query` 的生成规则见下表；没有 query 时使用空字符串。
- 空 body 也要计算 SHA-256，不要省略最后一行。
- `{idempotency_key}` 使用 `X-Idempotency-Key` 的值；未发送该 Header 时才使用 `Idempotency-Key`。两者都没有的只读请求使用空字符串。Header 值两端空白会被忽略。
- 每次重试可刷新 timestamp 和签名，但必须沿用原幂等键与完全相同的业务 payload。

## 字段取值与编码

| 项 | 规则 |
|---|---|
| `api_id` | `X-API-ID` 的值，去除两端空白 |
| `merchant_account_id` | 凭证所属商户账户的 UUID（小写、带连字符），由 PSP 在签发凭证时提供 |
| `METHOD` | 大写，如 `POST` |
| `escaped_path` | 请求 URL 中已转义的 path，按实际发送的值，不要重新编码 |
| `canonical_query` | 先对 query 做 URL 解码，同名参数的值升序排列，再按参数名升序拼成 `key=value&key=value`（按 UTF-8 字节序比较）；键和值重新做标准 query 转义：保留 `A-Z a-z 0-9 - _ . ~`，空格写 `+`，其余字节写大写 `%XX`（例如 `*` 写 `%2A`，`/` 写 `%2F`）。无值参数 `flag` 视为 `flag=`，空片段（`&&`）忽略；含非法 `%` 转义（如 `%ZZ`、结尾的 `%`、不完整的 `%2`）或未转义分号 `;` 的 query 会被拒绝并返回 `400 INVALID_REQUEST`，需要分号时请写成 `%3B` |
| `timestamp` | `X-Timestamp` 的十进制字符串，与 Header 完全一致 |
| `sha256(body)` | 对**实际发送的原始 body 字节**计算 SHA-256，输出 **64 位小写十六进制**；空 body 为 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |
| 签名 | 对整个 payload 的 UTF-8 字节做 Ed25519 签名，结果用标准 Base64（带 `=` 填充）；服务端也接受无填充的 Base64 |
| 公钥 | 上传给 PSP 的是 Ed25519 公钥原始 32 字节的标准 Base64 |

Invoice、Transfer 和 Query 使用**同一种** payload 格式；服务之间的隔离靠 Host 与凭证类型（见[服务与 Host](https://docs.potabank.com/guide/service-routing.md)），payload 中没有 service 字段。body 以实际发送的字节为准（JSON 通常是 UTF-8），不要在签名后再改动。

## 测试向量

用下面的固定输入验证你的实现。向量由 PSP 的参考签名工具生成，并用独立实现交叉验证。

> **仅用于测试**
>
下列私钥种子是公开的测试值，不要用于任何真实环境。

- 私钥种子（32 字节，Base64）：`AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=`
- 对应公钥（Base64）：`A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=`
- `api_id`：`api_demo`；`merchant_account_id`：`00000000-0000-7000-8000-000000000001`；`timestamp`：`1767225600`

**向量 1：POST，带 body 与幂等键**

body（共一行，无尾部换行）：

```json
{"payment_method":"pix","rail_variant":"pix_qr","amount":10000,"currency":"BRL","merchant_order_id":"ORDER_20260827_001"}
```

幂等键 `invoice:create:ORDER_20260827_001`，待签名 payload：

```text
v1
api_demo
00000000-0000-7000-8000-000000000001
POST
/api/v1/payments/intents

1767225600
invoice:create:ORDER_20260827_001
b78e856a2e4eb24adf9807614503946dd9fc51d2403259858c7666d46c6ffeb7
```

期望签名：

```text
pHBYN6FdiNc1as4N/doyvJO4n9jXfm4cIIcXcnS2N/mOWxxwwNJsoW2AmrjqtnZeOwgiHpfRXKfaZNy5ipXWAA==
```

**向量 2：GET，带 query，无 body、无幂等键**

实际发送的 URL 为 `/api/v1/payments/intents?status=pending&limit=20&status=paid`，规范化后的 `canonical_query` 是 `limit=20&status=paid&status=pending`。待签名 payload（第 8 行为空串）：

```text
v1
api_demo
00000000-0000-7000-8000-000000000001
GET
/api/v1/payments/intents
limit=20&status=paid&status=pending
1767225600

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

期望签名：

```text
cih5j8cxplQmgoxQjvWiitnV7dJKguV23HLCxbqENcP5W+WMc9Gl48unNTmXUQGFyBCrpXgG29lrDhlhbyYmCA==
```

## 密钥安全

- 私钥放入 KMS、HSM 或受控 Secret Manager，不要写入源码、镜像或日志。
- API Key、签名、完整请求体和敏感付款信息不得作为常规日志字段。
- 分环境、分商户账户、分 credential kind 管理密钥。
- 轮换时保留短暂双公钥窗口，并先验证新密钥再撤销旧密钥。
