要生成签名,您需要一对 RSA 私钥和公钥。
您可以自行生成这些密钥,或使用第三方工具(例如 cryptotools.net)来创建。
必填参数
要生成有效的签名,请包含以下参数:
| 参数 | 描述 |
|---|---|
| METHOD | 大写的 HTTP 请求方法 (e.g., POST, GET). |
| URL | 仅请求路径,以 / 开头 (e.g., /api/checkout)。不包含协议、域名或查询字符串(query string)。路径区分大小写。末尾斜杠会被忽略:/api/balance/ 按 /api/balance 进行签名和验证。 |
| PAYLOAD | 对实际发送的请求正文的原始字节进行 SHA-256 哈希运算,结果为小写十六进制字符串。对于 GET 请求(无请求正文),对空字符串进行哈希:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。 |
| TIMESTAMP | 当前时间戳,采用带有明确时区偏移的 ISO8601 格式(例如 +07:00 或 Z),且须与服务器时间相差在 ±5 分钟以内。参阅时间戳规则。 |
参数拼接
签名字符串(stringToSign)通过 : 按以下格式将参数依次拼接而成:
stringToSign = {METHOD}:{URL}:{PAYLOAD}:{TIMESTAMP}
将占位符替换为实际值:
{METHOD}→ 大写的 HTTP 请求方法。{URL}→ 仅请求路径 (e.g.,/api/checkout)。不含查询字符串,区分大小写,不含末尾斜杠。{PAYLOAD}→ 请求正文原始字节的 SHA-256 哈希值(小写十六进制)。对于 GET 请求,使用空字符串的哈希值(见上文)。{TIMESTAMP}→ 当前时间戳,采用带有明确时区偏移的 ISO8601 格式,并须与X-TIMESTAMP请求头的值完全一致
对实际发送的正文进行哈希
服务器按接收到的原始请求正文逐字节计算哈希,不会解析或重新序列化 JSON。
- 只将请求正文序列化为字符串一次。对该字符串进行哈希,并将同一字符串作为请求正文发送。
- 不要对重新序列化后的副本进行哈希(例如
JSON.stringify(JSON.parse(body))、json_encode(json_decode($body))或json.dumps(json.loads(body)))。键的顺序、空白字符、斜杠转义(\/)、Unicode 转义和数字格式都可能改变,导致签名不匹配。 - 签名后不要让 HTTP 客户端重新编码请求正文(例如传入对象而不是已签名的字符串)。
签名算法
使用私钥对 stringToSign(UTF-8 字节)进行签名,算法为 RSA + SHA-256,填充方式为 PKCS#1 v1.5(RSASSA-PKCS1-v1_5,也称为 RSA-SHA256 或 SHA256withRSA)。不要使用 PSS 填充。将结果进行 Base64 编码后放入 X-SIGNATURE 请求头。
const crypto = require('crypto');
const fs = require('fs');
const httpMethod = 'POST';
const endpointUrl = '/api/create/qris'; // 仅路径,不含查询字符串
const timestamp = new Date().toISOString(); // e.g. 2024-12-16T05:11:14.123Z
// 只序列化一次。将这个字符串原样作为请求正文发送。
// GET 请求使用: const body = '';
const body = JSON.stringify({
merchant_transaction_id: 'ORDER-001',
amount: '50000',
currency: 'IDR',
customer_name: 'John Doe',
product_name: 'Test Order',
});
const payloadHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
const stringToSign = `${httpMethod}:${endpointUrl}:${payloadHash}:${timestamp}`;
console.log('String to Sign:', stringToSign);
// RSA-SHA256,PKCS#1 v1.5 填充(Node.js 对 RSA 密钥的默认值)
const privateKey = fs.readFileSync('./path-to-private-key-file.pem');
const signature = crypto.sign('sha256', Buffer.from(stringToSign, 'utf8'), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
}).toString('base64');
console.log('Generated Signature:', signature);
// 原样发送 `body`,并附带请求头 X-TIMESTAMP: timestamp, X-SIGNATURE: signature
<?php
$httpMethod = 'POST';
$endpointUrl = '/api/create/qris'; // 仅路径,不含查询字符串
$timestamp = date('c'); // e.g. 2026-01-15T14:30:00+07:00
// 只序列化一次。将这个字符串原样作为请求正文发送。
// GET 请求使用: $body = '';
$body = json_encode([
'merchant_transaction_id' => 'ORDER-001',
'amount' => '50000',
'currency' => 'IDR',
'customer_name' => 'John Doe',
'product_name' => 'Test Order',
]);
$payloadHash = hash('sha256', $body); // 小写十六进制
$stringToSign = "{$httpMethod}:{$endpointUrl}:{$payloadHash}:{$timestamp}";
echo "String to Sign: {$stringToSign}\n";
// RSA-SHA256,PKCS#1 v1.5 填充
$privateKey = openssl_pkey_get_private('file://./path-to-private-key-file.pem');
openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$signatureB64 = base64_encode($signature);
echo "Generated Signature: {$signatureB64}\n";
// 原样发送 $body,并附带请求头 X-TIMESTAMP: $timestamp, X-SIGNATURE: $signatureB64
import hashlib
import base64
import json
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
http_method = 'POST'
endpoint_url = '/api/create/qris' # 仅路径,不含查询字符串
timestamp = datetime.now(timezone.utc).isoformat() # e.g. 2024-12-16T05:11:14.123456+00:00
# 只序列化一次。将这些字节原样作为请求正文发送。
# GET 请求使用: body = b''
body = json.dumps({
'merchant_transaction_id': 'ORDER-001',
'amount': '50000',
'currency': 'IDR',
'customer_name': 'John Doe',
'product_name': 'Test Order',
}, separators=(',', ':')).encode('utf-8')
payload_hash = hashlib.sha256(body).hexdigest()
string_to_sign = f"{http_method}:{endpoint_url}:{payload_hash}:{timestamp}"
print(f"String to Sign: {string_to_sign}")
# RSA-SHA256,PKCS#1 v1.5 填充
with open('./path-to-private-key-file.pem', 'rb') as key_file:
private_key = serialization.load_pem_private_key(key_file.read(), password=None)
signature = private_key.sign(
string_to_sign.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
signature_b64 = base64.b64encode(signature).decode()
print(f"Generated Signature: {signature_b64}")
# 原样发送 `body`(例如 requests.post(url, data=body, ...)),并附带 X-TIMESTAMP 和 X-SIGNATURE
- 请确保私钥被安全存储,且不要在公共代码库中公开。
- 每次请求都必须重新生成
TIMESTAMP,采用带有明确时区偏移的 ISO8601 格式。须与服务器时间相差在 ±5 分钟以内,超出该范围的请求可能被拒绝(参阅时间戳规则)。
测试向量
使用以下数据检查您的实现。RSA PKCS#1 v1.5 签名是确定性的,因此使用下方测试密钥时,您的代码必须生成完全相同的哈希值、stringToSign 和签名。这些测试向量已通过 OpenSSL 验证。它们仅用于离线验证,请勿发送到 API:固定的时间戳不在允许的时间范围内。
测试私钥 (PEM)
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDSH6vw8qTE2hBN
gzCPOKk0VfpJg3CBXFC0SmByTnUXJS8sQDqBBga8essnUGJgwpTVAiQ+G/H/UkfK
pPVxieAFO06JPMK2M7thj1gUgH6APpN2l/M4G3uO04wElFNpJKE+kDwZ8pQ+kWRf
DTSbNkp3iOgKogf3pKaPkC6AeDqeRe233aYmMwFaMecSMRSfjVZ6XOxZIONLStgv
t7FsxJssU9qSYkKxmFUZpSZLtsK/KqwfX3bs/Q0ueb9MTadkcAFWsZ9ll7CttgkQ
dMESpQoP1EeOuYTZqhSO+WvEokvjDNtz6vkZMnr3o7MR3RuHJ3DlelOHQHm0Q+kU
JFSVProzAgMBAAECggEAJm66kUigUI0eHD9Zm2iesj7jl5yd8FtFO6S6/U1SZ1ha
x3o7+St0AfdnvxoZ1F7iwA8zN9T+r1eKYAtKBWlVez98vudJKfBOXqrMTnuuJ6DK
cg+a4fFM6TPSUWgb1YhoobvZ8lkHOmZ3eFc+/wCwICr/XiqC0I3iFTIm/0J+XkJR
MVDqfFsdNWD7ASj6K4Sl/UauN/+VC8Dkfy2j4GlZ4XcIOPYNUfU+55mqhNc+2aNK
xZUASqsggeLtgopXQoBeXFnRqzMbky63PMu0E88mdbozV62fgWTfLwpcpV0ICcbB
r4S73D1Tk9nlD067Oxwf5c/kw+M9dcbGPvTvqSigeQKBgQDwieRX0e3VriaK2hf9
2lyWR0SmW3NxDBZEilRWVHFNMBK1RyNACvpirdjjLktLOu2zWh0efnhltZm0y1FK
vGEbVM+4zsNPKEUpoI2IXuZqKu/obENyxln08Kcnm50h8Jtxx3pj5maygB/2umO/
iOybSMB1GOQZT6KLRFCpqN7OHwKBgQDfoUvlStFdH4PH+xq0eitFzqmSfyUKo7XS
HLmigowxKHbXr0YjLTGAceTG356D8tGlkYRs0BZCQ3mFgdJmlzHMQefAgpwciJ3n
lZlAcY5abJO07ReV7g8qiJGdwaloazfeXAYR67e7tpdto4dmgPzmhyOdIK/v3ZEo
Cr7U+OIpbQKBgQCdPecxqaBKYW/8sRmh7w87hhdQkNPkgLxFzg/X55exl82DB5/5
i7OmsnLoAClkYn9lEXGbr51RD+gNEiRwMLliPo0UCMYQhwi/b59WGIOTv8HZNHzb
GONRhcgf0lBkEX/sXzSY/dMAuVUr2yi92uqhkGuPoNfr2+0sEVqRrj0IjQKBgQCe
SrjDG5tolCcehdJYT2WT6v3CsXbRuiG4ERchPdDn9uFwOCOoQJQafAPY9W5prZXR
4VfgAcbPrp008Diy33CjST821/ySge1rgEvS/s4pmVNQLMaWHMTlcXfJN8ynGcWZ
AoBVbQlHuK4lMCxmVQUuVBfUX8/JNIdL1ZdQfr7vXQKBgDBrw5daXitkNJ51leIz
pfZaP8aHHKs9Kl/DIL7prTMx8oV3Mz5fa1oLvzJkGhl0k3SIt7LKxOSxJjYOPZM2
K8fi8fGAIgBOPwpyqvDHhaQ2Jfj0uwD9+8Xy9LDzpgxN17Ru3zhyojYNXU/dCpl+
KRG7MjWhT0CuKEk1WE+LEiVC
-----END PRIVATE KEY-----
测试公钥 (PEM)
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0h+r8PKkxNoQTYMwjzip
NFX6SYNwgVxQtEpgck51FyUvLEA6gQYGvHrLJ1BiYMKU1QIkPhvx/1JHyqT1cYng
BTtOiTzCtjO7YY9YFIB+gD6TdpfzOBt7jtOMBJRTaSShPpA8GfKUPpFkXw00mzZK
d4joCqIH96Smj5AugHg6nkXtt92mJjMBWjHnEjEUn41WelzsWSDjS0rYL7exbMSb
LFPakmJCsZhVGaUmS7bCvyqsH1927P0NLnm/TE2nZHABVrGfZZewrbYJEHTBEqUK
D9RHjrmE2aoUjvlrxKJL4wzbc+r5GTJ696OzEd0bhydw5XpTh0B5tEPpFCRUlT66
MwIDAQAB
-----END PUBLIC KEY-----
向量 1:带 JSON 正文的 POST
请求正文(原始字节,共 128 字节,末尾无换行符):
{"merchant_transaction_id":"ORDER-001","amount":"50000","currency":"IDR","customer_name":"John Doe","product_name":"Test Order"}
| 字段 | 值 |
|---|---|
| METHOD | POST |
| URL | /api/create/qris |
| TIMESTAMP | 2026-01-15T14:30:00+07:00 |
| SHA-256 (正文) | fbe2d3b8024638a94ab7104abc45489faa5af28eb3d94d59457b6eab37346113 |
stringToSign:
POST:/api/create/qris:fbe2d3b8024638a94ab7104abc45489faa5af28eb3d94d59457b6eab37346113:2026-01-15T14:30:00+07:00
X-SIGNATURE:
D+2aHQbhBxIDXlSMPL62QdIonsjQvRT5QjMD86ctgmWzLSNsFfYJNC+9Q7tXBjOeKTaaOQWGta3bnPDN0E1TxE/wJdzn5nmjc0SKdLZ9O2yvR77xfYVGPaDcD308tjfcfM6utNZtvXJWUnQaZzWy8cUiMr7pAeI0cqNUaCJa6EpBVzIIpSshwhEMfaO5BWNoz15A7DqA4z0raCaf7S4acmoS/lsDw+6Dli7mZml5JCL6QVu02XXd8+fnNlCep0tL4XYakmgOJnBqV7Vm0XSVQwTb3RK6/AWSt6ESVJjr9dfTtsTT3B9GEGgPwP99spQky/kOaV0H90TkO7tR7s/EjQ==
向量 2:无正文的 GET
| 字段 | 值 |
|---|---|
| METHOD | GET |
| URL | /api/balance |
| TIMESTAMP | 2026-01-15T14:30:00+07:00 |
| SHA-256 (空正文) | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
stringToSign:
GET:/api/balance:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-01-15T14:30:00+07:00
X-SIGNATURE:
zAa91hW77Y5LHv3lGHVRtv3Z7Jy7wmhbZ/PNzB3Di5ysr4n6H2JMTefpqVy8poj7oZOyCkN3Oy7o1Tzu16L+J+jG8UTOffPKW8L95bBozyh7AggKpQ+bcQuCleNk8FwiOyFNy4z5t6rP3KIj8Ry+O9C7S39bCY/6aCSSEIGrsc5hbTh+yihsZMEgzySFu5M2x654khwxugEz57A2KqDnK1iNLIHCrWFLHJtTzH1WYwBIO6Aw0HbBuxMPahStPr4UHkQJlznyCkzM6LQURgIbcYit5mhTg/gQdOaz4/1fULmLqBtbvmBwuTPQcxr5rMGmnSlxWEVXQcT+wCm3HEpj0Q==
使用 OpenSSL 复现
将测试私钥保存为 test-private.pem,测试公钥保存为 test-public.pem,然后运行:
# 向量 1
printf '%s' '{"merchant_transaction_id":"ORDER-001","amount":"50000","currency":"IDR","customer_name":"John Doe","product_name":"Test Order"}' > body.json
HASH=$(openssl dgst -sha256 -hex body.json | awk '{print $NF}')
printf '%s' "POST:/api/create/qris:${HASH}:2026-01-15T14:30:00+07:00" > string-to-sign.txt
# 签名(RSA-SHA256,PKCS#1 v1.5)并进行 Base64 编码
openssl dgst -sha256 -sign test-private.pem string-to-sign.txt | base64 | tr -d '\n'; echo
# 验证
openssl dgst -sha256 -sign test-private.pem -out sig.bin string-to-sign.txt
openssl dgst -sha256 -verify test-public.pem -signature sig.bin string-to-sign.txt # Verified OK
# 向量 2:空字符串的哈希值
printf '' | openssl dgst -sha256 -hex
使用 printf '%s',不要使用 echo:echo 会追加换行符,从而改变哈希值。