Autenticación: mTLS y firma JWS
El alta de integradores todavía no está abierta. Esta guía describe el mecanismo implementado para que prepares tu cliente.
Cada petición al Autorizador JSON se autentica dos veces, de forma independiente:
- TLS mutuo (mTLS). Tu sistema presenta un certificado de cliente. El autorizador te identifica por la huella SHA-256 de ese certificado; si la huella no está registrada, responde 401
CERTIFICADO_NO_REGISTRADO. - Firma JWS de la petición. Cada petición lleva una firma detached de su método, ruta, fecha, nonce y cuerpo, hecha con tu llave privada. Sin firma válida, 401
FIRMA_INVALIDA.
Además, la petición debe venir de una IP de tu lista y respetar tu límite de tasa. Ningún rechazo de autenticación llega al emisor.
Credenciales que se registran
Durante la incorporación se registran en FINORA, para tu cliente:
| Elemento | Para qué sirve |
|---|---|
| Certificado de cliente (huella SHA-256 del DER) | Te identifica en el TLS. Lo emite la CA del canal |
Llave pública de firma con su kid y su algoritmo (EdDSA o ES256) | Verifica la firma JWS. FINORA nunca recibe tu llave privada |
| Lista de IP o redes permitidas (CIDR) | Una IP fuera de la lista recibe 403 IP_NO_PERMITIDA. Una lista vacía no admite ninguna |
| Comercios (código DE 42 y MCC) con sus terminales virtuales | El comercio y el MCC de cada petición deben coincidir con el registro |
Monedas habilitadas (928 VES, 840 USD) | Otra moneda: 403 MONEDA_NO_HABILITADA |
| Operaciones permitidas | Una operación no habilitada: 403 OPERACION_NO_PERMITIDA |
| Límite de solicitudes por segundo | Por encima: 429 LIMITE_DE_TASA |
| Vista ISO por data element (opcional, bajo acuerdo) | Sin ella, POST /v1/iso/messages responde 403 VISTA_ISO_NO_HABILITADA |
En una rotación conviven dos certificados y dos llaves vigentes durante el periodo de solape, para que cambies de llave sin cortar el servicio.
Cabeceras de cada petición
| Cabecera | Obligatoria | Contenido |
|---|---|---|
X-Finora-Fecha | Sí | Fecha y hora UTC en ISO 8601, por ejemplo 2026-10-20T18:05:11Z. Se rechaza si difiere del reloj del servidor más que la tolerancia de tu cliente, 60 s por defecto (401 RELOJ_FUERA_DE_TOLERANCIA) |
X-Finora-Nonce | Sí | 16 bytes aleatorios en base64url sin relleno (22 caracteres). Cada nonce se acepta una sola vez (401 NONCE_REPETIDO) |
X-Finora-Firma | Sí | JWS compacto detached: <cabecera>..<firma> |
Idempotency-Key | En los POST | De 16 a 64 caracteres [A-Za-z0-9_-]. Ver Idempotencia, plazos y reversos |
traceparent | No | W3C Trace Context. Si falta, el autorizador genera uno y lo devuelve como traceId |
Content-Type | En los POST | application/json. El cuerpo admite como máximo 16 KB |
Cómo se firma
1. Cadena canónica. Cinco líneas separadas por \n, sin salto final:
POST
/v1/authorizations
2026-10-20T18:05:11Z
q3Jv7kQ0mZ2xL5tN8aB1cD
<sha256 del cuerpo en hexadecimal, minúsculas>
- La ruta incluye la consulta tal como la envías:
/v1/authorizations?idempotencyKey=PED-2026-000123-v1. - El SHA-256 se calcula sobre los bytes exactos del cuerpo que envías. En un
GETsin cuerpo es el SHA-256 de la cadena vacía (e3b0c442…b855).
2. Cabecera JWS. Solo admite alg, kid y typ. alg debe ser exactamente el registrado para esa llave; none y HMAC no se aceptan.
{ "alg": "ES256", "kid": "integrador-1" }
3. Firma. Se firma base64url(cabecera) + "." + base64url(cadena canónica) y se envía sin la carga útil: base64url(cabecera) + ".." + base64url(firma).
ES256: ECDSA P-256 con SHA-256, firma en formato JOSE (R‖S, 64 bytes), no DER.EdDSA: Ed25519.
Ejemplo en Node.js
import crypto from 'node:crypto';
const b64url = (buf) => Buffer.from(buf).toString('base64url');
export function firmar({ metodo, ruta, cuerpo = '', llavePrivadaPem, kid, alg = 'ES256' }) {
const fecha = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const nonce = b64url(crypto.randomBytes(16)); // 22 caracteres
const hash = crypto.createHash('sha256').update(cuerpo, 'utf8').digest('hex');
const canonica = [metodo, ruta, fecha, nonce, hash].join('\n');
const cabecera = b64url(JSON.stringify({ alg, kid }));
const entrada = `${cabecera}.${b64url(canonica)}`;
const llave = crypto.createPrivateKey(llavePrivadaPem);
const firma = alg === 'ES256'
? crypto.sign('sha256', Buffer.from(entrada), { key: llave, dsaEncoding: 'ieee-p1363' })
: crypto.sign(null, Buffer.from(entrada), llave); // EdDSA (Ed25519)
return {
'X-Finora-Fecha': fecha,
'X-Finora-Nonce': nonce,
'X-Finora-Firma': `${cabecera}..${b64url(firma)}`,
};
}
Petición completa con curl
CUERPO='{"version":"2026-10","operacion":"COMPRA","monto":{"valor":"1250.00","moneda":"928"},"medio":{"tipo":"CREDENCIAL_FPW1","credencial":"FPW1.9919000000000012345.1792512000.3f9a1c0e7b2d4a68"},"comercio":{"codigo":"000000000012345","mcc":"5411"},"entrada":{"modo":"BILLETERA"},"referenciaCliente":"PED-2026-000123"}'
curl https://autorizador.example.com/v1/authorizations \
--cert cliente.crt --key cliente.key \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: PED-2026-000123-v1' \
-H "X-Finora-Fecha: $FECHA" \
-H "X-Finora-Nonce: $NONCE" \
-H "X-Finora-Firma: $FIRMA" \
--data-binary "$CUERPO"
Calcula FECHA, NONCE y FIRMA sobre exactamente el mismo CUERPO que envías (usa --data-binary para que curl no lo altere).
Orden de las comprobaciones
El kid se busca solo entre las llaves vigentes del cliente que presentó el certificado: una llave de otro cliente nunca sirve. Un cliente suspendido o dado de baja ya no puede originar operaciones, pero sí puede reversar las que tiene vivas (uno dado de baja, durante la ventana de reversos acordada en la baja), para que nunca quede un cargo sin deshacer.
Los mensajes de los 401 no dicen qué parte de la firma falló, a propósito. Si tu firma no verifica, revisa en este orden: la ruta con su consulta, la fecha exacta que enviaste, el SHA-256 de los bytes reales del cuerpo y el formato R‖S de la firma ES256.