Saltar al contenido principal

Firma de solicitudes

La mayoría de los endpoints exige una firma RSA-SHA256 además del access token. Firmas con la clave privada que te entregaron cuando se aprovisionó tu organización — la que se revela una sola vez y de la que no guardamos copia para ti. Nosotros verificamos con la pública que le corresponde.

Esta página está escrita a mano: la firma de requests no es un security scheme estándar de OpenAPI, así que la referencia de API no puede describirla.

Los dos headers​

HeaderValor
signatureFirma RSA-SHA256 en base64 del mensaje que se construye abajo
nonceEpoch actual en milisegundos, como string

El nonce tiene que caer dentro de ±5 minutos de nuestro reloj, así que mantén sincronizada por NTP la máquina que firma. Es una cota de frescura, no una clave de deduplicación — para eso está X-Idempotency-Key.

Qué endpoints van firmados​

Hay dos modos, y el modo depende del endpoint:

EndpointModo
POST /quotationsbody
POST /ordersbody
GET /quotations/{id}query
GET /orders/{id}query
GET /ordersquery
GET /me/wallets/{currency}query
GET /me/wallets/{currency}/transactions/{type}query
GET /me/routesquery

POST /oauth2/token es la única excepción, y tiene que serlo: es donde consigues el token. Todo lo demás va firmado, así que trata la firma como parte de tu cliente HTTP y no como algo que agregas endpoint por endpoint.

Construir el mensaje — modo query​

Concatenas el path, ?, el query string y el nonce. Nada más, y sin separador antes del nonce:

{path}?{queryString}{nonce}

Para GET /me/routes?sourceCurrency=USDR con nonce 1787260853525:

/me/routes?sourceCurrency=USDR1787260853525

El ? va incluso cuando el query string está vacío.

Construir el mensaje — modo body​

Aquí está la parte que le cuesta una tarde a cualquiera. El mensaje no es el body crudo más el nonce. El body crudo se trata como un valor string de JSON y se codifica otra vez, y después se le pega el nonce:

JSON.stringify(rawBody) + nonce

Así que para el body {"quotation_id":"a1b2c3d4"} y el nonce 1787260853525, el mensaje que firmas es este — 46 caracteres, comillas externas incluidas:

"{\"quotation_id\":\"a1b2c3d4\"}"1787260853525

Dos consecuencias:

  • Firma los bytes que envías. No parsees el JSON para re-serializarlo antes de firmar: cambiarían el orden de las claves y los espacios, y la firma no coincidiría. Captura el string del body una vez, firma ese, envía ese.
  • No te saltes la segunda codificación. Firmar {"quotation_id":"a1b2c3d4"} + nonce directo produce una firma que rechazamos.

Ejemplo completo​

import { createSign } from "node:crypto";

const nonce = String(Date.now());
const rawBody = JSON.stringify({quotation_id: "a1b2c3d4"}); // send THIS exact string

const message = JSON.stringify(rawBody) + nonce; // body mode
// query mode: const message = `${path}?${queryString}${nonce}`;

const signer = createSign("RSA-SHA256");
signer.update(message);
signer.end();
const signature = signer.sign(privateKeyPem, "base64");

await fetch("https://api.gateway.sandbox.retorna.app/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
signature,
nonce,
},
body: rawBody,
});

En Python hay que pasar ensure_ascii=False:

import json, time, base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

nonce = str(int(time.time() * 1000))
raw_body = json.dumps({"names": "María"}, ensure_ascii=False)

# ensure_ascii=False here too: by default Python turns "María" into
# "Mar\u00eda", which signs a different message than we verify.
message = json.dumps(raw_body, ensure_ascii=False) + nonce

signature = base64.b64encode(
private_key.sign(message.encode(), padding.PKCS1v15(), hashes.SHA256())
).decode()

Ese default es la segunda forma más común de fallar esto: Python escapa los caracteres no-ASCII y nosotros no, así que un beneficiario llamado María firma un mensaje distinto del que verificamos.

Firmar desde la referencia​

No hace falta que construyas un firmador para probar un endpoint. El explorador de la referencia de API tiene un campo Private key (PEM) al lado del bearer token: pegas ahí una clave PKCS#8 y calcula el signature y el nonce de cada request que manda. La clave se queda en esa pestaña del browser — no se guarda, no se envía y no aparece en los code samples.

Usa una clave de sandbox. Pegar una clave privada de producción en una página web no es un buen hábito, ni en la nuestra.

Cuando falla​

Toda falla de firma es el mismo 401 B2B_UNAUTHORIZED — firma equivocada, firma malformada, nonce vencido, clave no aprovisionada. No decimos cuál, a propósito.

Igual puedes distinguirla de un problema de token: el 401 de firma trae code y category, mientras el de token trae error y message como string. Si ves code, deja de mirar el token y baja por esta lista:

  1. ¿El nonce está en milisegundos y la máquina que firma tiene la hora bien?
  2. ¿Estás firmando los bytes exactos del body y no una copia re-serializada?
  3. En modo body, ¿codificaste el string del body una segunda vez?
  4. En modo query, ¿tu query string coincide con el que realmente mandaste, incluido el orden de los parámetros?
  5. ¿La firma va en base64 y no en hex?
  6. ¿Estás firmando con la clave privada de tu propio aprovisionamiento?