Saltar al contenido principal

Autenticación: mTLS y firma JWS

Próximamente

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:

  1. 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.
  2. 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:

ElementoPara 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 virtualesEl 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 permitidasUna operación no habilitada: 403 OPERACION_NO_PERMITIDA
Límite de solicitudes por segundoPor 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​

CabeceraObligatoriaContenido
X-Finora-FechaSí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-NonceSí16 bytes aleatorios en base64url sin relleno (22 caracteres). Cada nonce se acepta una sola vez (401 NONCE_REPETIDO)
X-Finora-FirmaSíJWS compacto detached: <cabecera>..<firma>
Idempotency-KeyEn los POSTDe 16 a 64 caracteres [A-Za-z0-9_-]. Ver Idempotencia, plazos y reversos
traceparentNoW3C Trace Context. Si falta, el autorizador genera uno y lo devuelve como traceId
Content-TypeEn los POSTapplication/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 GET sin 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.