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.
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
| Header | Conteúdo |
|---|---|
Authorization | A palavra DPoP, espaço, e o access token |
DPoP | Uma prova nova, assinada para esta requisição |
Content-Type | application/json nas chamadas com corpo |
Content-Encoding | Ausente, 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.
- Node
- Python
- PHP
- Java
- C#
- Go
- Ruby
- Elixir
- Clojure
// ── 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())
# ── 2. chamada assinada ───────────────────────────────────────────────────────
url = f"{BASE}/v2/pix/cash-out"
# Serialize UMA vez: e esta string que sera hasheada E enviada.
body = json.dumps({"amount": 5000, "to": "pix@alice"}, separators=(",", ":"))
proof = jws(
{"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk},
{"htm": "POST", "htu": url, "iat": int(time.time()), "jti": str(uuid.uuid4()),
"ath": sha(access_token), "qh": sha(""), "bh": sha(body)},
)
status, resp = post(
url,
{"Authorization": f"DPoP {access_token}", "DPoP": proof,
"Content-Type": "application/json"},
body.encode(),
)
print(status, resp)
raise SystemExit(0 if status == 200 else 1)
// ── 2. chamada assinada ───────────────────────────────────────────────────────
$url = "$BASE/v2/pix/cash-out";
// Serialize UMA vez: é esta string que será hasheada E enviada.
$body = json_encode(['amount' => 5000, 'to' => 'pix@alice']);
$proof = jws(
['typ' => 'dpop+jwt', 'alg' => 'ES256', 'jwk' => $publicJwk],
['htm' => 'POST', 'htu' => $url, 'iat' => time(),
'jti' => bin2hex(random_bytes(16)), 'ath' => sha($accessToken),
'qh' => sha(''), 'bh' => sha($body)],
);
[$status, $resp] = post($url, [
"Authorization: DPoP $accessToken",
"DPoP: $proof",
'Content-Type: application/json',
], $body);
echo "$status $resp\n";
exit($status === 200 ? 0 : 1);
// ── 2. chamada assinada ───────────────────────────────────────────────
String url = base + "/v2/pix/cash-out";
// Serialize UMA vez: e esta string que sera hasheada E enviada.
String body = "{\"amount\":5000,\"to\":\"pix@alice\"}";
String proof = jws(
"{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" + publicJwk + "}",
String.format("{\"htm\":\"POST\",\"htu\":\"%s\",\"iat\":%d,\"jti\":\"%s\","
+ "\"ath\":\"%s\",\"qh\":\"%s\",\"bh\":\"%s\"}",
url, System.currentTimeMillis() / 1000, UUID.randomUUID(),
sha(accessToken), sha(""), sha(body)));
HttpResponse<String> res = post(url, body,
"Authorization", "DPoP " + accessToken,
"DPoP", proof,
"Content-Type", "application/json");
System.out.println(res.statusCode() + " " + res.body());
System.exit(res.statusCode() == 200 ? 0 : 1);
}
}
// ── 2. chamada assinada ───────────────────────────────────────────────────────
var url = $"{BASE}/v2/pix/cash-out";
// Serialize UMA vez: e esta string que sera hasheada E enviada.
var body = "{\"amount\":5000,\"to\":\"pix@alice\"}";
var proof = Jws(
new Dictionary<string, object> { ["typ"] = "dpop+jwt", ["alg"] = "ES256", ["jwk"] = publicJwk },
new Dictionary<string, object>
{
["htm"] = "POST", ["htu"] = url,
["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["jti"] = Guid.NewGuid().ToString(),
["ath"] = Sha(accessToken), ["qh"] = Sha(""), ["bh"] = Sha(body),
});
var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.Add("Authorization", $"DPoP {accessToken}");
req.Headers.Add("DPoP", proof);
var res = await http.SendAsync(req);
Console.WriteLine($"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
return res.IsSuccessStatusCode ? 0 : 1;
// ── 2. chamada assinada ───────────────────────────────────────────────────
target := base + "/v2/pix/cash-out"
// Serialize UMA vez: é esta string que será hasheada E enviada.
body := mustJSON(map[string]any{"amount": 5000, "to": "pix@alice"})
proof := jws(
map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": publicJWK},
map[string]any{"htm": "POST", "htu": target, "iat": time.Now().Unix(),
"jti": jti(), "ath": sha(token.AccessToken), "qh": sha(""), "bh": sha(string(body))},
)
status, resp = post(target, map[string]string{
"Authorization": "DPoP " + token.AccessToken,
"DPoP": proof,
"Content-Type": "application/json",
}, body)
fmt.Println(status, resp)
if status != 200 {
os.Exit(1)
}
}
# ── 2. chamada assinada ───────────────────────────────────────────────────────
url = "#{BASE}/v2/pix/cash-out"
# Serialize UMA vez: é esta string que será hasheada E enviada.
body = JSON.generate(amount: 5000, to: 'pix@alice')
proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: PUBLIC_JWK },
{ htm: 'POST', htu: url, iat: Time.now.to_i, jti: SecureRandom.uuid,
ath: sha(access_token), qh: sha(''), bh: sha(body) }
)
status, resp = post(
url,
{ 'Authorization' => "DPoP #{access_token}", 'DPoP' => proof,
'Content-Type' => 'application/json' },
body
)
puts "#{status} #{resp}"
exit(status == 200 ? 0 : 1)
# ── 2. chamada assinada ───────────────────────────────────────────────────────
url = "#{base}/v2/pix/cash-out"
# Serialize UMA vez: é esta string que será hasheada E enviada.
body = JSON.encode!(%{"amount" => 5000, "to" => "pix@alice"})
proof =
LBPay.jws(key, %{"typ" => "dpop+jwt", "alg" => "ES256", "jwk" => public_jwk}, %{
"htm" => "POST", "htu" => url, "iat" => System.system_time(:second),
"jti" => LBPay.jti(), "ath" => LBPay.sha(access_token),
"qh" => LBPay.sha(""), "bh" => LBPay.sha(body)
})
{status, resp} =
LBPay.post(url,
[{~c"Authorization", String.to_charlist("DPoP #{access_token}")},
{~c"DPoP", String.to_charlist(proof)}],
"application/json", body)
IO.puts("#{status} #{resp}")
System.halt(if status == 200, do: 0, else: 1)
;; ── 2. chamada assinada ───────────────────────────────────────────────────────
(let [access-token (second (re-find #"\"access_token\"\s*:\s*\"([^\"]+)\"" resp))
url (str base "/v2/pix/cash-out")
;; Serialize UMA vez: e esta string que sera hasheada E enviada.
body "{\"amount\":5000,\"to\":\"pix@alice\"}"
proof (jws (str "{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" public-jwk "}")
(format (str "{\"htm\":\"POST\",\"htu\":\"%s\",\"iat\":%d,\"jti\":\"%s\","
"\"ath\":\"%s\",\"qh\":\"%s\",\"bh\":\"%s\"}")
url (quot (System/currentTimeMillis) 1000) (UUID/randomUUID)
(sha access-token) (sha "") (sha body)))
[status resp] (post url body
[["Authorization" (str "DPoP " access-token)]
["DPoP" proof]
["Content-Type" "application/json"]])]
(println status resp)
(System/exit (if (= status 200) 0 1))))
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
- A assinatura do token, e que fomos nós que emitimos.
- O envelope da credencial dentro do token: a conta, o perfil e a chave para os quais ele foi assinado.
- Que os claims do token batem com o envelope.
- O perfil contra a rota que você está chamando.
- A prova DPoP: assinatura,
jti(uso único),htm,htu,ath,qh,bhe a janela doiat. - Revogação.
- 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.