Assinatura de requisição

Toda chamada a https://client.api.corpx.com/v1/** leva, além do token, uma assinatura feita com a chave privada que ficou no seu servidor.

O token OAuth é um portador: quem o copia do log ou do proxy pode usá-lo até expirar. A assinatura resolve isso porque a chave privada não viaja. Um token vazado sem ela não move dinheiro.

Rotação de chaves e allowlist de IP estão em Chaves e IPs.

Por que o host é outro

CredencialHostAssinatura
Emitida pelo banco para a sua contahttps://client.api.corpx.comEm toda chamada
Integrador BaaS (várias contas)https://tenant.api.corpx.comNão

Usar a credencial da conta no host antigo devolve 403 signed_host_required. O host antigo guarda a decisão de autorização em cache por 300s; uma decisão em cache não pode depender da assinatura daquela requisição. O host assinado verifica a cada chamada.

O contrário também vale: o host assinado só expõe /v1/** e recusa qualquer request sem os headers de assinatura antes de olhar o token (403 signature_required).

Passo 1: o par de chaves

ECDSA P-256 (ES256) é o recomendado — chave curta, assinatura curta, suportado por qualquer linguagem:

# Privada: FICA NO SEU SERVIDOR. Nunca envie este arquivo.
openssl ecparam -genkey -name prime256v1 -noout -out corpx-signing.key
# Pública: é esta que o banco cadastrou (ou que você cadastra na rotação).
openssl ec -in corpx-signing.key -pubout -out corpx-signing.pub

RSA também é aceito (PS256, mínimo 2048 bits) se a chave já estiver num HSM:

openssl genrsa -out corpx-signing.key 3072
openssl rsa -in corpx-signing.key -pubout -out corpx-signing.pub

O publicKeyPem é o conteúdo do .pub — um bloco -----BEGIN PUBLIC KEY-----. Mandar a privada devolve 422 invalid_public_key. Trate essa chave como comprometida e gere outra.

O kid

Cada chave recebe um kid derivado do SHA-256 do DER da chave pública, truncado em 16 bytes (32 hex). Derivado — e não sorteado — para você conferir offline que cadastrou a chave certa:

openssl pkey -pubin -in corpx-signing.pub -outform DER | shasum -a 256 | cut -c1-32

O valor tem de ser igual ao kid que a API devolveu.

Passo 2: a string canônica

Cinco campos, nesta ordem, separados por \n (LF, não CRLF):

METHOD \n PATH?QUERY \n TIMESTAMP \n IDEMPOTENCY_KEY_OU_VAZIO \n X_CONTENT_SHA256
CampoRegra
METHODMaiúsculo: POST, GET, PUT, DELETE
PATH?QUERYCaminho como enviado, com a query se houver. Sem host
TIMESTAMPO mesmo valor de X-Request-Timestamp: unix em segundos
IDEMPOTENCY_KEY_OU_VAZIOValor de Idempotency-Key; string vazia quando a rota não usa
X_CONTENT_SHA256SHA-256 do corpo em hex minúsculo. Corpo vazio = hash do vazio, não string vazia

Exemplo para um PIX:

POST
/v1/accounts/acc-123/pix/payments
1789412400
4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90
b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c

O hash do corpo vazio é sempre e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Use-o em GET e DELETE.

Passo 3: assinar

A assinatura é uma JWS compacta com payload destacado (RFC 7797): <protected>..<signature> — note os dois pontos seguidos.

O header protegido é:

{ "alg": "ES256", "kid": "SEU_KID", "jti": "uuid-por-requisicao" }

O jti é opcional e serve para correlacionar log. O que é assinado é base64url(protected) + "." + base64url(stringCanônica).

const crypto = require('node:crypto');
function sign(privateKeyPem, kid, canonical) {
const b64 = (buf) => Buffer.from(buf).toString('base64url');
const protected_ = b64(JSON.stringify({ alg: 'ES256', kid, jti: crypto.randomUUID() }));
const input = `${protected_}.${b64(canonical)}`;
const signature = crypto.sign('sha256', Buffer.from(input), {
key: privateKeyPem,
// dsaEncoding é obrigatório: sem ele o Node emite DER e a API recusa.
dsaEncoding: 'ieee-p1363',
});
return `${protected_}..${b64(signature)}`;
}
function contentSha256(body) {
return require('node:crypto').createHash('sha256').update(body).digest('hex');
}
function canonicalString({ method, path, timestamp, idempotencyKey = '', body = '' }) {
return [method, path, String(timestamp), idempotencyKey, contentSha256(body)].join('\n');
}
ES256 é R\|\|S, não DER

A assinatura tem de ter exatamente 64 bytes (R e S de 32 bytes cada). A maioria das bibliotecas emite DER por padrão e a API recusa com request_signature_invalid. Em Node é dsaEncoding: 'ieee-p1363'; em Go, monte R||S a partir de ecdsa.Sign; em Python, use utils.decode_dss_signature e concatene.

Passo 4: enviar

curl -X POST "https://client.api.corpx.com/v1/accounts/$ACCOUNT_ID/pix/out" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-Id: $TENANT_ID" \
-H "Idempotency-Key: 4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90" \
-H "X-Request-Timestamp: 1789412400" \
-H "X-Content-SHA256: 612612d208fb618eb2b007d2a7f8d7a1cfb511532389298f1cc33322c3094bcc" \
-H "X-Request-Signature: $SIG" \
-H "Content-Type: application/json" \
-d '{"amount":100.00,"keyType":"CPF","key":"12345678901"}'
HeaderObrigatórioDescrição
X-Request-TimestampSimUnix em segundos. Tolerância de 300s
X-Content-SHA256SimSHA-256 do corpo em hex minúsculo
X-Request-SignatureSimA JWS detached do passo 3

O hash do corpo viaja no header — a borda verifica a assinatura sem reler o corpo. Quem confere que o corpo recebido bate com o hash é a aplicação, depois da borda. Divergência devolve 400 body_hash_mismatch.

Testar sem mover dinheiro

POST /v1/security/signature/verify devolve a string canônica que nós montamos, o hash que esperávamos e o resultado. Sempre responde 200 (exceto corpo malformado), inclusive quando a assinatura é inválida: um 401 aqui se misturaria com “token expirado”.

curl -X POST "https://client.api.corpx.com/v1/security/signature/verify" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-Id: $TENANT_ID" \
-H "X-Request-Timestamp: $TS" \
-H "X-Content-SHA256: $SHA" \
-H "X-Request-Signature: $SIG" \
-H "Content-Type: application/json" \
-d '{
"method": "POST",
"path": "/v1/accounts/acc-123/pix/payments",
"timestamp": "1789412400",
"idempotencyKey": "4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90",
"body": "{\"amount\":1000}",
"signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiJ9..MEUCIQ..."
}'
{
"valid": true,
"kid": "9f2a...",
"alg": "ES256",
"canonicalString": "POST\n/v1/accounts/acc-123/pix/payments\n1789412400\n...",
"expectedContentSha256": "b5bb9d80...",
"signedHost": "client.api.corpx.com",
"maxSkewSeconds": 300,
"usableKids": ["9f2a..."]
}

Quando valid é false, reason traz o mesmo código da requisição real. Compare canonicalString caractere a caractere.

Vetor de teste

Use estes valores para validar a implementação offline, sem credencial. A JWS abaixo confere contra a chave pública com dsaEncoding: 'ieee-p1363'.

Chave pública (SPKI PEM):

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6VuQL7n18jc/8dHENPXeHZrdCVPu
q2j496awbvsDhxWTVj3hBScPI9MioPmlfS9nNUo9MJhTDNMfVRUALXXrfg==
-----END PUBLIC KEY-----
CampoValor
kidcfe8291443215153e11b76a7a533dd3c
jti00000000-0000-4000-8000-000000000001
Corpo{"amount":1000}
X-Content-SHA256612612d208fb618eb2b007d2a7f8d7a1cfb511532389298f1cc33322c3094bcc

String canônica (LF entre as linhas, sem CRLF, sem newline no fim):

POST
/v1/accounts/acc-123/pix/payments
1789412400
4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90
612612d208fb618eb2b007d2a7f8d7a1cfb511532389298f1cc33322c3094bcc

JWS:

eyJhbGciOiJFUzI1NiIsImtpZCI6ImNmZTgyOTE0NDMyMTUxNTNlMTFiNzZhN2E1MzNkZDNjIiwianRpIjoiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIn0..hN37T9veyWLyXnEvXqnPwFIsD1GzgKfoY_bHzkc3rx3-MZCNhVaIVq1W_P77g8Dfw6IJ09wBddu6bhNQkGulvQ

A assinatura tem 64 bytes (R||S). Se a sua biblioteca emitir DER, o resultado não bate.

Erros mais comuns

  1. CRLF em vez de LF na string canônica.
  2. Path sem a query string que foi no request.
  3. Timestamp em milissegundos.
  4. Hash do vazio como string vazia, em vez do SHA-256 de zero bytes.
  5. Assinatura ES256 em DER (request_signature_invalid).
  6. Relógio fora de 300s (request_timestamp_skew) — configure NTP.
  7. kid ainda na carência de 18h ou já aposentado (unknown_kid).
  8. Credencial no host errado (signed_host_required).

Tabela completa em Erros.

Replay

Uma requisição assinada pode ser repetida dentro da janela de 300s. Isso é aceito de propósito:

  • Em mutação, Idempotency-Key é obrigatória e entra na string canônica — ela é o nonce. Repetir devolve o resultado da original.
  • Em leitura, repetir devolve a mesma leitura.

Detalhe: Idempotência.