Pular para o conteúdo principal

Assine cada requisição

Toda chamada a /v2/* leva dois headers:

Authorization: DPoP <access_token>
DPoP: <jwt>

O DPoP é um JWT ES256 assinado pela sua privada, novo a cada requisição:

header:  { "typ": "dpop+jwt", "alg": "ES256", "jwk": { ...sua chave pública... } }
payload: { "htm": "POST",
"htu": "https://api.sdb.lbpay.com.br/v2/pix/cash-out",
"iat": <agora>,
"jti": "<uuid>",
"ath": "<base64url(sha256(access_token))>",
"qh": "<base64url(sha256(query canônica))>",
"bh": "<base64url(sha256(corpo exato enviado))>" }

Os três detalhes que mordem​

htu não leva query nem fragmento. É scheme://host/path e nada mais, como manda a RFC 9449. A query vai separada, em qh.

qh é o hash da query canônica: separe em &, descarte segmentos vazios, ordene os segmentos em ordem de bytes, junte de novo com &, sem decodificar percent-encoding. Sem query, é o hash da string vazia.

bh é o hash do corpo byte a byte, exatamente como você enviou. Para GET sem corpo, é o hash da string vazia. O bh nunca é opcional.

Serialize uma vez só

Hasheie os mesmos bytes que você põe no fio. Serializar o objeto duas vezes (uma para hashear, outra para enviar) dá ordem de bytes diferente no dia em que uma chave mudar de lugar, e a requisição é recusada com DPOP_BODY_MISMATCH.

Headers de cada chamada​

HeaderConteúdo
AuthorizationA palavra DPoP, espaço, e o access token
DPoPUma prova nova, assinada para esta requisição
Content-Typeapplication/json nas chamadas com corpo
Content-EncodingAusente, ou identity. Comprimido é recusado com 415

Comprimir é recusado em /v2 porque criaria duas representações do mesmo corpo, e a assinatura precisa cobrir uma só.

Exemplo completo​

Continua do passo do token, no mesmo arquivo e com os mesmos helpers.

// ── 2. chamada assinada ─────────────────────────────────────────────────────────
async function call(accessToken, method, path, payload) {
const url = `${BASE}${path}`
// Serialize UMA vez: é esta string que será hasheada E enviada.
const body = payload === undefined ? '' : JSON.stringify(payload)

const proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk },
{
htm: method,
htu: url,
iat: now(),
jti: randomUUID(),
ath: sha(accessToken),
qh: sha(''),
bh: sha(body),
},
)

return fetch(url, {
method,
headers: {
authorization: `DPoP ${accessToken}`,
dpop: proof,
...(body ? { 'content-type': 'application/json' } : {}),
},
...(body ? { body } : {}),
})
}

const token = await getToken()
const res = await call(token, 'POST', '/v2/pix/cash-out', { amount: 5000, to: 'pix@alice' })
console.log(res.status, await res.text())
Confira sua canonicalização

Para ?b=2&a=1&&c=%2F, a query canônica é a=1&b=2&c=%2F: ordenada, segmento vazio descartado, %2F sem decodificar. O hash dela é vOuB6Bjc7yvDlU5r2UPhEMFfwYfW92GA22LyuE9bM4Q, e o hash de corpo ou query vazios é sempre 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU. Se a sua implementação produz esses dois valores, ela concorda com a nossa.

Relógio​

Desvio maior que 60 segundos é recusado (DPOP_STALE). Use NTP. É de longe a causa mais comum de integração que funciona no laptop e falha no container.

Formato da assinatura​

Assinatura ECDSA vem em duas codificações: DER e R‖S cru de 64 bytes. Aceitamos as duas. Use a saída que a sua biblioteca emitir, sem converter nada.

Isso vale também para chave em KMS ou Cloud HSM: o Sign deles devolve DER, e pode ser enviado como está.

O que verificamos, em ordem​

  1. A assinatura do token, e que fomos nós que emitimos.
  2. O envelope da credencial dentro do token: a conta, o perfil e a chave para os quais ele foi assinado.
  3. Que os claims do token batem com o envelope.
  4. O perfil contra a rota que você está chamando.
  5. A prova DPoP: assinatura, jti (uso único), htm, htu, ath, qh, bh e a janela do iat.
  6. Revogação.
  7. Seu IP de origem contra a lista da credencial.

Os passos 1 a 4 são verificação pura do que o token carrega, sem consulta. Os passos 5 a 7 rodam em toda requisição, e é por isso que revogar uma credencial vale em cerca de um segundo mesmo com o token sendo autocontido.