HMAC-SHA512
/api/external/* 路径下的所有 POST 请求(cash-in、cash-out、refund、webhooks、CPF 验证)都必须使用 HMAC-SHA512 对请求体进行签名,以保证载荷的完整性和真实性。
适用范围
- 仅
POST。GET和DELETE请求不需要hmac头(插件未挂载到这些请求的 pipeline)。 - 覆盖端点:
POST /pix/cash-in、POST /pix/cash-out、POST /pix/refund、POST /webhooks、POST /cpf/validate。 - 必需头:
hmac,十六进制小写格式(API 内部在比较前会归一化为小写,但请始终以小写发送以避免歧义)。
工作原理
- 将请求体序列化为 JSON 无缩进(紧凑格式),键按字母顺序排列
- 使用您的
client_secret作为密钥生成 HMAC-SHA512 签名 - 将十六进制小写格式的签名放入
hmac头中 - 发送与签名完全相同的 body 作为请求体
API 对收到的 body(经过内部归一化后)重新计算哈希,并使用恒定时间比较(constant-time comparison)与 hmac 头值比对,防止通过时序攻击逐字节推断签名。
Body 规范化(关键)
服务器在计算 HMAC 之前会对收到的 body 进行规范化。为使签名有效,客户端必须对与服务器使用的完全相同的规范化形式的 body 进行签名:
- 对收到的 body 执行
JSON.parse - 按字母顺序对键重新排序后序列化(服务器在计算预期 HMAC 之前会重新排序键)
- 执行
JSON.stringify无缩进(等同于 Python 中的json.dumps(obj, sort_keys=True, separators=(',', ':'))) - 移除每个
:或,后存在的 1 个空格(将", "转换为",",将": "转换为":")。防御性措施 —JSON.stringify(obj)和json.dumps(obj, separators=(',', ':'))从不产生这些空格;发出", "的序列化器(如 Python 默认的json.dumps)会自动兼容 - 计算
HMAC-SHA512(body_normalized, client_secret),以十六进制小写形式输出 - 将同一规范化 body(键按字母顺序排序,
:和,后无空格)作为 request body 发送,并将 HMAC 放入hmac头
按字母顺序排列键是必须的
服务器在计算预期 HMAC 之前会按字母顺序重新排序 JSON 键。如果客户端以其他顺序对 body 签名,签名将不匹配,请求会被拒绝并返回 HTTP 401 Invalid HMAC signature。
具体示例:
- 客户端发送
{"external_id":"...","amount":1000}并对该字符串进行签名 - 服务器在验证前内部重新排序为
{"amount":1000,"external_id":"..."} - HMAC 不同 → 请求被拒绝
解决方案:在序列化和签名之前按字母顺序对键排序,并在请求 body 中发送完全相同的已排序字符串。
默认不排序的库
如果您使用 Java Jackson、C# System.Text.Json 或 PHP json_encode,键的顺序默认不是字母顺序。您必须强制排序。
- Java (Jackson):使用
mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)或从TreeMap<String, Object>(保持键有序)进行序列化。 - C#:
JsonSerializerOptions不排序;在序列化前使用SortedDictionary<string, object>进行排序。 - PHP:在
json_encode($array)之前调用ksort($array)。 - Python:
json.dumps(obj, sort_keys=True, separators=(',', ':'))— 原生支持,无需额外配置。 - Go:自 Go 1.12+ 起,
json.Marshal对map[string]interface{}已经保证字母顺序。 - JavaScript/Node:
JSON.stringify保留插入顺序;在序列化之前手动排序键:javascriptconst sorted = Object.keys(obj).sort().reduce((acc, key) => { acc[key] = obj[key]; return acc; }, {}); const body = JSON.stringify(sorted);
警惕 JSON.stringify(obj, null, 2)
JSON.stringify(obj)(无缩进)生成 {"a":1,"b":2} — 无空格,已规范化。
JSON.stringify(obj, null, 2)(为便于阅读带缩进)会生成:
{
"a": 1,
"b": 2
}— 在 : 后有空格和换行符。这会破坏 HMAC。始终使用 JSON.stringify(obj) 无缩进形式来计算和发送 HMAC。
HMAC-SHA512 与 Webhook SHA256
API 使用 HMAC-SHA512 来验证您发送的请求。Owem Pay 发送的 Webhook 使用 HMAC-SHA256 进行签名(头 X-Owem-Signature)。它们是不同的算法 -- 各有其使用场景。详见 Webhooks 了解 Webhook 签名验证。
另一个重要区别:在**请求 HMAC-SHA512(客户端 → Owem 方向)**中,服务器在比对前会按字母顺序重新排序键,因此客户端必须产生已排序的 body。在 **Webhook HMAC-SHA256(Owem → 客户端方向)**中,客户端收到的 body 与发送时完全一致(无后续重排序),应基于 RAW body 验证签名 — 不要在验证前重新序列化。
必需的请求头
| 头 | 值 | 说明 |
|---|---|---|
Authorization | ApiKey {client_id}:{client_secret} 或 Basic {base64(client_id:client_secret)} | 同一凭证,接受两种格式 |
Content-Type | application/json | POST 缺少 Content-Type: application/json → 415,甚至不会走到 HMAC 校验 |
hmac | HMAC-SHA512 签名,十六进制小写格式 | 128 个十六进制字符。如果发送大写,服务器在比对前归一化为小写 — 但按约定请直接发送小写 |
示例
JavaScript (Node.js)
const crypto = require('crypto');
// 键按字母顺序(amount < description < pix_key < pix_key_type)
const payload = {
amount: 3000,
description: "Pagamento",
pix_key: "12345678901",
pix_key_type: "cpf"
};
// JSON.stringify 保留插入顺序,不按键排序。
// 即使上面的对象已按字母排列,显式强制 sort
// 能保证 body 与服务器期望的哈希匹配。
const sortedKeys = Object.keys(payload).sort();
const body = JSON.stringify(
sortedKeys.reduce((acc, key) => ({ ...acc, [key]: payload[key] }), {})
);
// body = '{"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}'
const hmac = crypto
.createHmac('sha512', 'sk_seu-client-secret')
.update(body)
.digest('hex');
// 将 `body` 作为 request body 发送,`hmac` 放入 hmac 头。Python
import hmac
import hashlib
import json
# sort_keys=True 保证字母顺序 — 必须
body = json.dumps(
{
"amount": 3000,
"pix_key": "12345678901",
"pix_key_type": "cpf",
"description": "Pagamento"
},
sort_keys=True,
separators=(',', ':')
)
# body = '{"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}'
signature = hmac.new(
b"sk_seu-client-secret",
body.encode("utf-8"),
hashlib.sha512
).hexdigest()PHP
$data = [
'amount' => 3000,
'pix_key' => '12345678901',
'pix_key_type' => 'cpf',
'description' => 'Pagamento'
];
// ksort 保证字母顺序 — 必须
ksort($data);
$body = json_encode($data, JSON_UNESCAPED_SLASHES);
$hmac = hash_hmac('sha512', $body, 'sk_seu-client-secret');Java
import java.util.TreeMap;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
// TreeMap 保持键字母顺序 — 必须
TreeMap<String, Object> bodyMap = new TreeMap<>();
bodyMap.put("amount", 3000);
bodyMap.put("pix_key", "12345678901");
bodyMap.put("pix_key_type", "cpf");
bodyMap.put("description", "Pagamento");
ObjectMapper mapper = new ObjectMapper();
String body = mapper.writeValueAsString(bodyMap);
// body = {"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}
String secret = "sk_seu-client-secret";
Mac mac = Mac.getInstance("HmacSHA512");
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA512");
mac.init(keySpec);
byte[] hash = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
String hmac = sb.toString();C#
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
// SortedDictionary 保持键字母顺序 — 必须
// (JsonSerializerOptions 默认不按键排序)
var payload = new SortedDictionary<string, object>
{
["amount"] = 3000,
["description"] = "Pagamento",
["pix_key"] = "12345678901",
["pix_key_type"] = "cpf"
};
var body = JsonSerializer.Serialize(payload);
// body = {"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}
var secret = Encoding.UTF8.GetBytes("sk_seu-client-secret");
using var hmacSha512 = new HMACSHA512(secret);
var hash = hmacSha512.ComputeHash(Encoding.UTF8.GetBytes(body));
var hmac = BitConverter.ToString(hash).Replace("-", "").ToLower();Bash (curl)
BODY='{"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}'
SECRET="sk_seu-client-secret"
HMAC=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://api.owem.com.br/api/external/pix/cash-out \
-H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/json" \
-H "hmac: $HMAC" \
-d "$BODY"验证
API 对收到的 body(规范化后)重新计算 HMAC-SHA512,并使用恒定时间比较与 hmac 头中收到的值比对。HmacValidation 插件的所有错误均遵循 {"worked": false, "detail": "..."} 格式:
401 -- 签名无效或头缺失
{
"worked": false,
"detail": "Invalid HMAC signature"
}{
"worked": false,
"detail": "Missing HMAC header"
}400 -- Body 缺失或 JSON 无效
{
"worked": false,
"detail": "Request body is required for HMAC validation"
}{
"worked": false,
"detail": "Request body must be valid JSON for HMAC validation"
}403 -- API Key 未配置 HMAC secret
{
"worked": false,
"detail": "HMAC secret not configured for this API key"
}HMAC 集成检查清单
- 使用与请求发送时完全一致的 body(相同的 JSON 序列化,键按字母顺序,
:和,后无空格)。 - API Key 的
client_secret就是 HMAC 密钥 — 与Authorization: ApiKey {client_id}:{client_secret}中发送的值相同(或使用Authorization: Basic时base64内部的值)。 - 签名必须是 128 个十六进制小写字符(SHA-512 产生 64 字节 = 128 个十六进制字符)。API 在比对前将头归一化为小写,但按约定请直接发送小写。大写或混合大小写(如
AbCdEf)可以工作但不建议。 - 比较采用恒定时间方式 — 无法通过时序推断签名。
- 如果您的客户端发送
X-Key-Case: camelCase,请对在网络上传输的 body 形态进行签名(camelCase)。HMAC 始终看到您发送的 body,在任何内部转换之前 — 参见 身份验证 -- 可选头。