Assinatura de requisição
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
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:
RSA também é aceito (PS256, mínimo 2048 bits) se a chave já estiver num HSM:
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:
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):
Exemplo para um PIX:
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 é:
O jti é opcional e serve para correlacionar log. O que é assinado é
base64url(protected) + "." + base64url(stringCanônica).
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
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”.
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):
String canônica (LF entre as linhas, sem CRLF, sem newline no fim):
JWS:
A assinatura tem 64 bytes (R||S). Se a sua biblioteca emitir DER, o resultado não bate.
Erros mais comuns
- CRLF em vez de LF na string canônica.
- Path sem a query string que foi no request.
- Timestamp em milissegundos.
- Hash do vazio como string vazia, em vez do SHA-256 de zero bytes.
- Assinatura ES256 em DER (
request_signature_invalid). - Relógio fora de 300s (
request_timestamp_skew) — configure NTP. kidainda na carência de 18h ou já aposentado (unknown_kid).- 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.