Skip to content

HMAC-SHA512

/api/external/* 路径下的所有 POST 请求(cash-in、cash-out、refund、webhooks、CPF 验证)都必须使用 HMAC-SHA512 对请求体进行签名,以保证载荷的完整性和真实性。

适用范围

  • POSTGETDELETE 请求不需要 hmac 头(插件未挂载到这些请求的 pipeline)。
  • 覆盖端点POST /pix/cash-inPOST /pix/cash-outPOST /pix/refundPOST /webhooksPOST /cpf/validate
  • 必需头hmac,十六进制小写格式(API 内部在比较前会归一化为小写,但请始终以小写发送以避免歧义)。

工作原理

  1. 将请求体序列化为 JSON 无缩进(紧凑格式),键按字母顺序排列
  2. 使用您的 client_secret 作为密钥生成 HMAC-SHA512 签名
  3. 将十六进制小写格式的签名放入 hmac 头中
  4. 发送与签名完全相同的 body 作为请求体

API 对收到的 body(经过内部归一化后)重新计算哈希,并使用恒定时间比较(constant-time comparison)与 hmac 头值比对,防止通过时序攻击逐字节推断签名。

Body 规范化(关键)

服务器在计算 HMAC 之前会对收到的 body 进行规范化。为使签名有效,客户端必须对与服务器使用的完全相同的规范化形式的 body 进行签名:

  1. 对收到的 body 执行 JSON.parse
  2. 按字母顺序对键重新排序后序列化(服务器在计算预期 HMAC 之前会重新排序键)
  3. 执行 JSON.stringify 无缩进(等同于 Python 中的 json.dumps(obj, sort_keys=True, separators=(',', ':'))
  4. 移除每个 :, 后存在的 1 个空格(将 ", " 转换为 ",",将 ": " 转换为 ":")。防御性措施 — JSON.stringify(obj)json.dumps(obj, separators=(',', ':')) 从不产生这些空格;发出 ", " 的序列化器(如 Python 默认的 json.dumps)会自动兼容
  5. 计算 HMAC-SHA512(body_normalized, client_secret),以十六进制小写形式输出
  6. 同一规范化 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)
  • Pythonjson.dumps(obj, sort_keys=True, separators=(',', ':')) — 原生支持,无需额外配置。
  • Go:自 Go 1.12+ 起,json.Marshalmap[string]interface{} 已经保证字母顺序。
  • JavaScript/NodeJSON.stringify 保留插入顺序;在序列化之前手动排序键:
    javascript
    const 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 验证签名 — 不要在验证前重新序列化。

必需的请求头

说明
AuthorizationApiKey {client_id}:{client_secret}Basic {base64(client_id:client_secret)}同一凭证,接受两种格式
Content-Typeapplication/jsonPOST 缺少 Content-Type: application/json → 415,甚至不会走到 HMAC 校验
hmacHMAC-SHA512 签名,十六进制小写格式128 个十六进制字符。如果发送大写,服务器在比对前归一化为小写 — 但按约定请直接发送小写

示例

JavaScript (Node.js)

javascript
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

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

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

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#

csharp
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)

bash
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 -- 签名无效或头缺失

json
{
  "worked": false,
  "detail": "Invalid HMAC signature"
}
json
{
  "worked": false,
  "detail": "Missing HMAC header"
}

400 -- Body 缺失或 JSON 无效

json
{
  "worked": false,
  "detail": "Request body is required for HMAC validation"
}
json
{
  "worked": false,
  "detail": "Request body must be valid JSON for HMAC validation"
}

403 -- API Key 未配置 HMAC secret

json
{
  "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: Basicbase64 内部的值)。
  • 签名必须是 128 个十六进制小写字符(SHA-512 产生 64 字节 = 128 个十六进制字符)。API 在比对前将头归一化为小写,但按约定请直接发送小写。大写或混合大小写(如 AbCdEf)可以工作但不建议。
  • 比较采用恒定时间方式 — 无法通过时序推断签名。
  • 如果您的客户端发送 X-Key-Case: camelCase,请对在网络上传输的 body 形态进行签名(camelCase)。HMAC 始终看到您发送的 body,在任何内部转换之前 — 参见 身份验证 -- 可选头

Owem Pay Instituição de Pagamento — ISPB 37839059