QR Seguro es un sistema de verificación de identidad basado en códigos QR criptográficamente seguros. Los QR están firmados con RSA-PSS-SHA256 para garantizar autenticidad, y los resultados se transmiten cifrados con AES-256-GCM + RSA-OAEP para garantizar confidencialidad e integridad.
| Parámetro | Valor | Notas |
|---|---|---|
Base URL | http://localhost:3000 | Configurable con PUBLIC_HOST |
Content-Type | application/json | Requerido en todos los POST |
Autenticación | Ninguna | La seguridad viene de la criptografía |
Body limit | 10 MB | Para paquetes con imágenes Base64 |
SSE Accept | text/event-stream | Para GET /session/:id/events |
Swagger UI | /docs | Documentación interactiva completa |
Las llaves RSA-4096 son el fundamento del sistema. La llave privada nunca sale del servidor; la pública es accesible para cualquier app. La llave demo-key se genera automáticamente al iniciar el servidor.
/keys/generate| Campo | Tipo | Descripción |
|---|---|---|
keyId req | string | Identificador único del par de llaves |
curl -X POST http://localhost:3000/keys/generate \ -H "Content-Type: application/json" \ -d '{"keyId": "mi-llave"}' # Respuesta: { "keyId": "mi-llave", "publicKey": "-----BEGIN PUBLIC KEY-----\n..." }
const res = await fetch('http://localhost:3000/keys/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyId: 'mi-llave' }) }); const { keyId, publicKey } = await res.json();
import requests res = requests.post('http://localhost:3000/keys/generate', json={'keyId': 'mi-llave'}) data = res.json() # { keyId, publicKey }
/keys/:keyIdDevuelve la llave pública en PEM. Las apps externas usan este endpoint para verificar la firma del QR.
curl http://localhost:3000/keys/demo-key # → { "keyId": "demo-key", "publicKey": "-----BEGIN PUBLIC KEY-----\n..." }
Al crear una sesión el servidor genera automáticamente un QR firmado con el payload StartValidation.
/session| Campo body | Default | Descripción |
|---|---|---|
issuerKeyId | "demo-key" | ID de la llave con que se firma el QR |
| Campo | Tipo | Descripción |
|---|---|---|
sessionId | UUID | Úsalo en todos los endpoints /session/:id/* |
token | Base64URL | El token QR firmado |
qrImage | data URL | QR listo para un <img src="..."> |
callbackUrl | URL | La app externa debe hacer POST aquí con el resultado |
publicKey | PEM | Llave pública RSA-4096 para cifrar el resultado |
{
"action": "StartValidation",
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"callbackUrl": "http://192.168.1.10:3000/session/<uuid>/result",
"issuedAt": "2026-04-07T12:00:00.000Z"
}
curl -X POST http://localhost:3000/session \ -H "Content-Type: application/json" \ -d '{"issuerKeyId": "demo-key"}'
const session = await fetch('http://localhost:3000/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ issuerKeyId: 'demo-key' }) }).then(r => r.json()); document.getElementById('qr').src = session.qrImage; const { sessionId, callbackUrl, publicKey } = session;
res = requests.post('http://localhost:3000/session', json={'issuerKeyId': 'demo-key'}) s = res.json() session_id = s['sessionId'] callback = s['callbackUrl'] public_key = s['publicKey']
Suscríbete al stream SSE para recibir actualizaciones en tiempo real. El servidor mantiene la conexión abierta hasta que llega el resultado final.
GET /session/:id/eventssse_auth (HttpOnly, SameSite=Strict) emitida por POST /session vía Set-Cookie.
- Browser same-origin: el browser la envía automáticamente — no se necesita configuración extra en
EventSource. - Browser cross-origin: usar
new EventSource(url, { withCredentials: true })y el server debe incluirAccess-Control-Allow-Origin+Access-Control-Allow-Credentials: true. - Clientes no-browser (curl, Python): extraer el valor de
Set-Cookie: sse_auth=<valor>del response dePOST /sessiony enviarlo en el headerCookie: sse_auth=<valor>.
| type | Cuándo se emite | Cierra conexión |
|---|---|---|
progress | La app reporta avance en un paso | No |
reset | La app reinicia el flujo desde un paso ya reportado | No |
result | Resultado final recibido y desencriptado | Sí |
session_terminated | La app llamó DELETE /session/:id (ej: usuario presionó Reiniciar) | Sí |
session_expired | TTL de 10 min expirado sin resultado | Sí |
# Extraer la cookie del POST /session y usarla aquí # -N desactiva el buffering; -b pasa la cookie HttpOnly SSE_COOKIE=$(curl -si http://localhost:3000/session \ -X POST -H "Content-Type: application/json" -d '{}' \ | grep -i 'set-cookie' | sed 's/.*sse_auth=\([^;]*\).*/\1/') curl -N -b "sse_auth=$SSE_COOKIE" \ http://localhost:3000/session/<sessionId>/events # Salida de ejemplo: data: {"type":"progress","step":"qr_scanned","status":"completed"} data: {"type":"result","decryptedData":{...},"receivedAt":"..."}
// Browser same-origin: la cookie sse_auth se envía automáticamente const es = new EventSource( `http://localhost:3000/session/${sessionId}/events` ); es.addEventListener('message', (e) => { const data = JSON.parse(e.data); if (data.type === 'progress') console.log(`[${data.step}] ${data.status}`); else if (data.type === 'result') { console.log(data.decryptedData); es.close(); } });
import sseclient, requests, json # Extraer cookie del POST /session r_session = requests.post('http://localhost:3000/session', json={}) sse_cookie = r_session.cookies['sse_auth'] url = f'http://localhost:3000/session/{session_id}/events' with requests.get(url, cookies={'sse_auth': sse_cookie}, headers={'Accept': 'text/event-stream'}, stream=True) as r: for ev in sseclient.SSEClient(r).events(): data = json.loads(ev.data) if data['type'] == 'result': print(data['decryptedData']) break
La app externa reporta el estado de cada etapa. El Emisor lo ve en tiempo real vía SSE.
/session/:id/progress| Campo | Tipo | Descripción |
|---|---|---|
step req | string | Nombre del paso (ver tabla abajo) |
status req | string | started | in_progress | completed | failed |
timestamp | ISO string | Omitir para usar la hora del servidor |
detail | object | Información adicional libre |
| step | Descripción |
|---|---|
| qr_scanned | QR escaneado y firma RSA-PSS validada |
| idvision_health_check | Health check del servicio IDVision previo a la verificación biométrica |
| document_front_scanned | Anverso del documento leído por OCR |
| document_back_scanned | Reverso del documento leído por OCR (MRZ) |
| nfc_read | Chip NFC/RFID del documento leído |
| selfie_ready | Cámara frontal inicializada |
| selfie_captured | Selfie capturado |
| liveness_check | Verificación de vida completada |
| face_match | Comparación facial 1:1 completada |
| document_substitution | Verificación de sustitución de rostro |
| reg_civil | Consulta al Registro Civil |
| sending_result | Iniciando envío del resultado cifrado |
status: "started" para un paso que ya existe, el servidor borra el historial y emite un evento reset a todos los clientes SSE.Al completar la verificación la app cifra su resultado y hace POST al callbackUrl del QR.
/session/:id/result ← este es el callbackUrl| Campo | Tipo | Descripción |
|---|---|---|
encryptedPackage req | Base64 | JSON del EncryptedPackage codificado en Base64 |
- Decodifica Base64 → JSON
EncryptedPackage - Lee el campo
kidpara seleccionar la llave privada - Desenvuelve la clave AES con RSA-OAEP (llave privada)
- Descifra con AES-256-GCM verificando el
authTag - Emite evento SSE
{ type: "result", decryptedData, receivedAt } - Cierra todas las conexiones SSE de esa sesión
Cifrado híbrido: AES-256-GCM para los datos, RSA-OAEP para proteger la clave AES. Todo en un JSON codificado en Base64.
{
"v": 1, // versión del formato, siempre 1
"enc": "AES-256-GCM", // algoritmo de cifrado
"wrappedKey": "<Base64>", // clave AES cifrada con RSA-OAEP (512 bytes para RSA-4096)
"iv": "<Base64>", // IV de 12 bytes
"ciphertext": "<Base64>", // datos cifrados, SIN el authTag
"authTag": "<Base64>", // tag de autenticación GCM, 16 bytes
"kid": "demo-key" // ID de la llave usada para cifrar
}
authTag garantiza integridad. Si cualquier byte del paquete es modificado en tránsito, la desencriptación falla. No es posible alterar el resultado sin que el servidor lo detecte.El campo decryptedData del evento SSE result contiene este JSON (ejemplo de la app FirmaDoxID):
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"securityChecks": {
"qrValidation": { "valid": true },
"nfc": { "success": true },
"liveness": { "isBonafide": true, "score": 0.9834 },
"faceMatch": { "matched": true, "score": 98.72 },
"documentFaceSubstitution": { "passed": true, "score": 0.9901 },
"regCivil": { "vigente": true }
},
"documentData": {
"GivenNames": "JUAN PABLO",
"Surnames": "GONZALEZ RAMIREZ",
"PersonalNumber": "12345678-9",
"DocumentNumber": "512345678",
"DateOfBirth": "15 ENE 1985",
"DateOfExpiry": "20 MAR 2030",
"Country": "CHL",
"IssuingStateName": "Chile",
"Authority": "Registro Civil e Identificacion"
},
"images": {
"portrait": "<Base64 JPEG — foto chip NFC>",
"ocrPortrait": "<Base64 JPEG — foto OCR (opcional)>",
"selfie": "<Base64 JPEG — selfie capturado>",
"signature": "<Base64 JPEG — firma NFC (opcional)>"
},
"device": {
"manufacturer": "Samsung", "model": "SM-A528B",
"osVersion": "Android 14 (SDK 34)",
"appVersion": "1.0.4_secureqr_firmadox",
"locale": "es_CL", "timezone": "America/Santiago",
"network": "WIFI"
},
"deviceIntegrity": {
"isRooted": false, "isEmulator": false,
"isDebuggerAttached": false,
"hookingFrameworkDetected": false, // Frida, Xposed
"developerOptionsEnabled": false,
"usbDebuggingEnabled": false,
"bootloaderUnlocked": false,
"overallRisk": "low" // "low" | "medium" | "high"
},
"cameraIntegrity": {
"isFrontCameraReal": true,
"physicalSensorValid": true,
"virtualCameraAppDetected": false, // DroidCam, EpocCam, etc.
"screenRecordingActive": false,
"overallRisk": "low"
},
"audit": {
"qrScannedAt": "2026-04-07T12:00:00Z",
"completedAt": "2026-04-07T12:05:22Z",
"durationSeconds": 322
},
"location": { // OPCIONAL — solo si GPS disponible en 5s
"latitude": -33.4489, "longitude": -70.6693,
"accuracyMeters": 12.5,
"capturedAt": "2026-04-07T12:00:01Z"
}
}
medium: opciones de desarrollador, USB debugging, bootloader desbloqueado
low: sin indicios de compromiso
| Check | Campos | Descripción |
|---|---|---|
qrValidation | valid: boolean | Firma RSA-PSS del QR válida |
nfc | success: boolean | Chip NFC/RFID leído y autenticado |
liveness | isBonafide, score (0-1) | Anti-spoofing: persona real, no foto/video |
faceMatch | matched, score (0-100) | Comparación facial 1:1 selfie vs documento |
documentFaceSubstitution | passed, score (0-1) | Foto OCR vs foto NFC: detecta documentos alterados |
regCivil | vigente, errorDescription? | Validación Registro Civil Chile |
POST /crypto/decrypt con {"encryptedPackage":"<Base64>"}. El servidor desencripta y devuelve el JSON en claro.- Base64-decode el string
encryptedPackage→ JSON - Parsear JSON → campos
wrappedKey,iv,ciphertext,authTag - Base64-decode cada campo
- RSA-OAEP decrypt
wrappedKeycon llave privada → clave AES-256 (32 bytes) - AES-256-GCM decrypt: clave + iv + authTag → plaintext bytes
- UTF-8 decode + JSON.parse → payload original
const { createDecipheriv, privateDecrypt, constants } = require('node:crypto'); const fs = require('node:fs'); function decryptPackage(b64, keyPath) { const pkg = JSON.parse(Buffer.from(b64, 'base64').toString()); const key = Buffer.from(pkg.wrappedKey, 'base64'); const iv = Buffer.from(pkg.iv, 'base64'); const ct = Buffer.from(pkg.ciphertext, 'base64'); const tag = Buffer.from(pkg.authTag, 'base64'); const aesKey = privateDecrypt( { key: fs.readFileSync(keyPath), oaepHash: 'sha256', padding: constants.RSA_PKCS1_OAEP_PADDING }, key ); const d = createDecipheriv('aes-256-gcm', aesKey, iv); d.setAuthTag(tag); return JSON.parse(Buffer.concat([d.update(ct), d.final()])); }
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.ciphers.aead import AESGCM import base64, json def decrypt(b64, pem): pkg = json.loads(base64.b64decode(b64)) priv = serialization.load_pem_private_key(pem.encode(), None) aes = priv.decrypt(base64.b64decode(pkg['wrappedKey']), padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None)) iv = base64.b64decode(pkg['iv']) ct = base64.b64decode(pkg['ciphertext']) tag = base64.b64decode(pkg['authTag']) return json.loads(AESGCM(aes).decrypt(iv, ct+tag, None))
# El servidor desencripta usando el campo 'kid' del paquete curl -X POST http://localhost:3000/crypto/decrypt \ -H "Content-Type: application/json" \ -d '{"encryptedPackage": "<Base64>"}' # Respuesta: { "data": { /* JSON en claro */ } }
/qr/create| Campo | Default | Descripción |
|---|---|---|
payload req | — | Objeto JSON arbitrario a incluir en el QR |
issuerKeyId req | — | ID de la llave con que se firma |
format | "png" | png | svg | base64 | token |
/qr/validate// Request { "token": "<Base64URL>" } // Válido { "valid": true, "payload": { ... }, "issuerKeyId": "demo-key" } // Inválido { "valid": false, "error": "Invalid signature" }
/crypto/encrypt// Request { "data": { /* cualquier JSON */ }, "issuerKeyId": "demo-key" } // Respuesta { "encryptedPackage": "<Base64>" }
/crypto/decrypt// Request { "encryptedPackage": "<Base64>" } // Respuesta { "data": { /* JSON original */ } }
Prueba el flujo completo sin app móvil real.
/simulate| Campo | Tipo | Descripción |
|---|---|---|
sessionId req | string | ID de sesión activa |
data | object | Payload a cifrar y enviar. Si se omite, usa datos de ejemplo. |
withProgress | boolean | Si true, emite todos los eventos de progreso antes del resultado |
| Operación | Algoritmo | Parámetros |
|---|---|---|
| Firma de QR | RSA-PSS | SHA-256, saltLength=32 bytes, keySize=4096 bits |
| Cifrado de datos | AES-256-GCM | IV=12 bytes aleatorios, authTag=16 bytes, AEAD |
| Envoltura de clave | RSA-OAEP | SHA-256, MGF1-SHA256, keySize=4096 bits |
| Encoding de tokens QR | Base64URL | Sin padding, seguro para URLs y QR codes |
| Encoding de paquetes | Base64 | Estándar para transporte en JSON |
| Implementación | node:crypto (built-in) | Sin dependencias externas de criptografía |
| Método | Ruta | Descripción |
|---|---|---|
| GET | /health | Health check del servidor |
| GET | /docs | Swagger UI |
| POST | /keys/generate | Genera par de llaves RSA-4096 |
| GET | /keys/:keyId | Obtiene llave pública PEM |
| POST | /qr/create | Crea QR firmado RSA-PSS |
| POST | /qr/validate | Valida firma de un token QR |
| POST | /crypto/encrypt | Cifra datos AES-256-GCM + RSA-OAEP |
| POST | /crypto/decrypt | Desencripta paquete |
| POST | /session | Crea sesión + genera QR StartValidation |
| SSE | /session/:id/events | Stream de eventos en tiempo real |
| POST | /session/:id/progress | Reporta progreso de verificación |
| POST | /session/:id/result | Envía resultado cifrado (callbackUrl) |
| DELETE | /session/:id | Termina sesión, notifica SSE clients y borra del Map |
| POST | /simulate | Simula flujo completo de app externa |
FirmaDoxID produce dos tipos de mensajes durante un flujo de verificación: eventos de progreso (uno por cada paso, POST a /session/:id/progress) y el resultado final cifrado (POST a callbackUrl). Todos llegan al suscriptor como mensajes SSE. Esta sección documenta el schema exacto de cada uno.
| type | Origen | Cierra SSE | Campos adicionales |
|---|---|---|---|
progress | App → POST /progress | No | step, status, timestamp, detail? |
reset | App reinicia flujo (started sobre step ya reportado) | No | step (primer paso del nuevo ciclo) |
result | App → POST callbackUrl | Sí | decryptedData, encryptedPackage, receivedAt |
session_terminated | App → DELETE /session/:id | Sí | reason: "client_requested" |
session_expired | Cleanup TTL servidor (10 min sin resultado) | Sí | — |
{
"type": "progress",
"step": "liveness_check", // ver tabla de pasos abajo
"status": "completed", // "started" | "in_progress" | "completed" | "failed"
"timestamp": "2026-04-15T14:32:01.000Z",
"detail": { "isBonafide": true, "score": 0.9834 } // varía por step
}
{ "type": "session_terminated", "reason": "client_requested" }
// La app llamó DELETE /session/:id (ej: usuario presionó Reiniciar en FirmaDoxID)
{ "type": "session_expired" }
// El TTL de 10 min expiró sin que llegara resultado
detail por paso de progreso
FirmaDoxID emite un evento de progreso por cada paso del flujo. La tabla muestra el campo detail exacto según step + status. Los campos con ? pueden estar ausentes.
| step | Statuses emitidos | detail si completed | detail si failed |
|---|---|---|---|
| qr_scanned | completed failed |
{"valid": true} |
{"error": string} |
| idvision_health_check | started completed failed |
{"healthy": true} |
{"healthy": false, "error": string} |
| document_front_scanned | completed failed |
— | {"error": string} |
| document_back_scanned | completed failed |
— | {"error": string} |
| nfc_read | completed failed |
{"success": true} |
{"error": "error_code_12" | "chip_key_failure" | string} |
| selfie_ready | started |
Sin detail — indica que la cámara frontal está lista | |
| selfie_captured | completed failed |
— | {"error": string} |
| liveness_check | started completed failed |
{"isBonafide": boolean, "score": number} |
{"error": string} |
| face_match | started completed failed |
{"matched": boolean, "score": number} |
{"error": string} |
| document_substitution | started completed failed |
{"passed": boolean, "score": number} |
{"error": string} |
| reg_civil | started completed failed |
{"vigente": boolean} |
{"error": string} |
| sending_result | started completed failed |
{"httpStatus": number} |
{"error": string} |
"error_code_12" — los datos MRZ del reverso no coinciden con el chip (hay que re-escanear el reverso).
"chip_key_failure" — falla en la autenticación BAC/PACE; puede deberse a condiciones de lectura (distancia, interferencia).
decryptedData)
Al completar el flujo, FirmaDoxID cifra el resultado con AES-256-GCM + RSA-OAEP y hace POST a callbackUrl. El servidor lo desencripta y lo distribuye como decryptedData en el evento SSE type: "result". Los campos con ? son opcionales.
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"securityChecks": {
"qrValidation": { "valid": true },
"nfc": { "success": true },
"liveness"?: { "isBonafide": true, "score": 0.9834 },
"faceMatch"?: { "matched": true, "score": 98.72 },
"documentFaceSubstitution"?: { "passed": true, "score": 0.9901 },
"regCivil"?: { "vigente": true, "errorDescription"?: "string" }
},
"documentData": { // campos extraídos por OCR + chip NFC
"GivenNames": "JUAN ANDRES",
"Surnames": "PEREZ GONZALEZ",
"PersonalNumber": "12345678-9", // RUT con dígito verificador
"DocumentNumber": "532794833",
"DateOfBirth": "1985-03-22",
"DateOfExpiry": "2030-03-22",
"DateOfIssue": "2020-03-22",
"Country": "CHL",
"ICAOCode": "CL",
"IssuingStateName": "Chile",
"Authority": "Servicio de Registro Civil e Identificación",
"Description": "CÉDULA DE IDENTIDAD"
},
"images": {
"portrait"?: "<Base64 JPEG>", // foto chip NFC (preferida) o foto OCR
"ocrPortrait"?: "<Base64 JPEG>", // foto solo del OCR anverso
"selfie"?: "<Base64 JPEG>", // selfie con cámara frontal
"signature"?: "<Base64 JPEG>" // firma digitalizada del chip NFC
},
"device": {
"manufacturer": "Samsung",
"model": "SM-S926B",
"osVersion": "Android 15",
"appVersion": "1.0.4_secureqr",
"locale": "es_CL",
"timezone": "America/Santiago",
"network": "wifi"
},
"deviceIntegrity": {
"isRooted": false,
"isEmulator": false,
"isDebuggerAttached": false,
"hookingFrameworkDetected": false,
"developerOptionsEnabled": true,
"usbDebuggingEnabled": true,
"bootloaderUnlocked": false,
"overallRisk": "low" // "low" | "medium" | "high"
},
"cameraIntegrity": {
"isFrontCameraReal": true,
"physicalSensorValid": true,
"virtualCameraAppDetected": false,
"screenRecordingActive": false,
"overallRisk": "low"
},
"audit"?: { // presente cuando el flujo fue iniciado por QR
"qrScannedAt": "2026-04-15T14:31:00.000Z",
"completedAt"?: "2026-04-15T14:33:15.000Z",
"durationSeconds"?: 135
},
"location"?: { // presente cuando GPS estuvo disponible (timeout 5s)
"latitude": -33.4569,
"longitude": -70.6483,
"accuracyMeters"?: 12.5,
"capturedAt"?: "2026-04-15T14:31:05.000Z"
}
}
liveness, faceMatch y regCivil solo están presentes si el paso llegó a ejecutarse. Si el usuario abortó antes, el campo estará ausente.