1Introducción y Arquitectura

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.

Emisor / Browser SecureQR API App Externa POST /session { sessionId, qrImage, callbackUrl, publicKey } GET /session/:id/events (SSE) Escanea QR → valida firma RSA-PSS GET /keys/:kid → publicKey PEM POST /session/:id/progress (×N) SSE: { type:"progress", step, status } POST callbackUrl { encryptedPackage } SSE: { type:"result", decryptedData }

Ver diagrama completo interactivo →

2Requisitos Previos
ParámetroValorNotas
Base URLhttp://localhost:3000Configurable con PUBLIC_HOST
Content-Typeapplication/jsonRequerido en todos los POST
AutenticaciónNingunaLa seguridad viene de la criptografía
Body limit10 MBPara paquetes con imágenes Base64
SSE Accepttext/event-streamPara GET /session/:id/events
Swagger UI/docsDocumentación interactiva completa
3Gestión de Llaves RSA

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.

POST/keys/generate
CampoTipoDescripción
keyId reqstringIdentificador único del par de llaves
curl
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 }
GET/keys/:keyId

Devuelve la llave pública en PEM. Las apps externas usan este endpoint para verificar la firma del QR.

curl
curl http://localhost:3000/keys/demo-key
# → { "keyId": "demo-key", "publicKey": "-----BEGIN PUBLIC KEY-----\n..." }
4Crear una Sesión

Al crear una sesión el servidor genera automáticamente un QR firmado con el payload StartValidation.

POST/session
Campo bodyDefaultDescripción
issuerKeyId"demo-key"ID de la llave con que se firma el QR
Respuesta
CampoTipoDescripción
sessionIdUUIDÚsalo en todos los endpoints /session/:id/*
tokenBase64URLEl token QR firmado
qrImagedata URLQR listo para un <img src="...">
callbackUrlURLLa app externa debe hacer POST aquí con el resultado
publicKeyPEMLlave pública RSA-4096 para cifrar el resultado
Payload del QR generado
json
{
  "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
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']
5Conectar a Eventos SSE

Suscríbete al stream SSE para recibir actualizaciones en tiempo real. El servidor mantiene la conexión abierta hasta que llega el resultado final.

SSEGET /session/:id/events
ℹ️
Autenticación: el endpoint requiere la cookie sse_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 incluir Access-Control-Allow-Origin + Access-Control-Allow-Credentials: true.
  • Clientes no-browser (curl, Python): extraer el valor de Set-Cookie: sse_auth=<valor> del response de POST /session y enviarlo en el header Cookie: sse_auth=<valor>.
Keepalive cada 20 s. Al reconectar, el servidor repite todos los eventos de progreso acumulados.
typeCuándo se emiteCierra conexión
progressLa app reporta avance en un pasoNo
resetLa app reinicia el flujo desde un paso ya reportadoNo
resultResultado final recibido y desencriptado
session_terminatedLa app llamó DELETE /session/:id (ej: usuario presionó Reiniciar)
session_expiredTTL de 10 min expirado sin resultado
curl
# 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
6Reportar Progreso

La app externa reporta el estado de cada etapa. El Emisor lo ve en tiempo real vía SSE.

POST/session/:id/progress
CampoTipoDescripción
step reqstringNombre del paso (ver tabla abajo)
status reqstringstarted | in_progress | completed | failed
timestampISO stringOmitir para usar la hora del servidor
detailobjectInformación adicional libre
stepDescripción
qr_scannedQR escaneado y firma RSA-PSS validada
idvision_health_checkHealth check del servicio IDVision previo a la verificación biométrica
document_front_scannedAnverso del documento leído por OCR
document_back_scannedReverso del documento leído por OCR (MRZ)
nfc_readChip NFC/RFID del documento leído
selfie_readyCámara frontal inicializada
selfie_capturedSelfie capturado
liveness_checkVerificación de vida completada
face_matchComparación facial 1:1 completada
document_substitutionVerificación de sustitución de rostro
reg_civilConsulta al Registro Civil
sending_resultIniciando envío del resultado cifrado
⚠️
Reset: Si se envía status: "started" para un paso que ya existe, el servidor borra el historial y emite un evento reset a todos los clientes SSE.
7Enviar Resultado Cifrado

Al completar la verificación la app cifra su resultado y hace POST al callbackUrl del QR.

POST/session/:id/result ← este es el callbackUrl
CampoTipoDescripción
encryptedPackage reqBase64JSON del EncryptedPackage codificado en Base64
Qué hace el servidor al recibir
  1. Decodifica Base64 → JSON EncryptedPackage
  2. Lee el campo kid para seleccionar la llave privada
  3. Desenvuelve la clave AES con RSA-OAEP (llave privada)
  4. Descifra con AES-256-GCM verificando el authTag
  5. Emite evento SSE { type: "result", decryptedData, receivedAt }
  6. Cierra todas las conexiones SSE de esa sesión
8Estructura del Paquete Cifrado

Cifrado híbrido: AES-256-GCM para los datos, RSA-OAEP para proteger la clave AES. Todo en un JSON codificado en Base64.

Proceso de Cifrado
JSON Payload
+
Clave AES-256 (random 32 bytes)
+
IV (random 12 bytes)
AES-256-GCM
ciphertext + authTag (16 bytes)
Clave AES-256
+
Public Key RSA-4096
RSA-OAEP SHA-256
wrappedKey (512 bytes)
Todo se serializa como JSON → Base64 → body HTTP
json — EncryptedPackage
{
  "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
}
El 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.
9Payload Desencriptado — Estructura Completa

El campo decryptedData del evento SSE result contiene este JSON (ejemplo de la app FirmaDoxID):

json — payload completo
{
  "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"
  }
}
deviceIntegrity.overallRiskstring
high: root/Magisk, emulador, debugger, Frida/Xposed detectado
medium: opciones de desarrollador, USB debugging, bootloader desbloqueado
low: sin indicios de compromiso
securityChecksobject
CheckCamposDescripción
qrValidationvalid: booleanFirma RSA-PSS del QR válida
nfcsuccess: booleanChip NFC/RFID leído y autenticado
livenessisBonafide, score (0-1)Anti-spoofing: persona real, no foto/video
faceMatchmatched, score (0-100)Comparación facial 1:1 selfie vs documento
documentFaceSubstitutionpassed, score (0-1)Foto OCR vs foto NFC: detecta documentos alterados
regCivilvigente, errorDescription?Validación Registro Civil Chile
10Cómo Desencriptar el Paquete
Opción simple: POST /crypto/decrypt con {"encryptedPackage":"<Base64>"}. El servidor desencripta y devuelve el JSON en claro.
Algoritmo manual paso a paso
  1. Base64-decode el string encryptedPackage → JSON
  2. Parsear JSON → campos wrappedKey, iv, ciphertext, authTag
  3. Base64-decode cada campo
  4. RSA-OAEP decrypt wrappedKey con llave privada → clave AES-256 (32 bytes)
  5. AES-256-GCM decrypt: clave + iv + authTag → plaintext bytes
  6. UTF-8 decode + JSON.parse → payload original
node.js
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 */ } }
11Endpoints QR
POST/qr/create
CampoDefaultDescripción
payload reqObjeto JSON arbitrario a incluir en el QR
issuerKeyId reqID de la llave con que se firma
format"png"png | svg | base64 | token
POST/qr/validate
json
// Request
{ "token": "<Base64URL>" }

// Válido
{ "valid": true, "payload": { ... }, "issuerKeyId": "demo-key" }

// Inválido
{ "valid": false, "error": "Invalid signature" }
12Endpoints de Criptografía
POST/crypto/encrypt
json
// Request
{ "data": { /* cualquier JSON */ }, "issuerKeyId": "demo-key" }
// Respuesta
{ "encryptedPackage": "<Base64>" }
POST/crypto/decrypt
json
// Request
{ "encryptedPackage": "<Base64>" }
// Respuesta
{ "data": { /* JSON original */ } }
13Simulador

Prueba el flujo completo sin app móvil real.

POST/simulate
CampoTipoDescripción
sessionId reqstringID de sesión activa
dataobjectPayload a cifrar y enviar. Si se omite, usa datos de ejemplo.
withProgressbooleanSi true, emite todos los eventos de progreso antes del resultado
ℹ️
También puedes usar el botón "Simular app externa" del Demo Site para editar el payload JSON en el browser.
14Especificaciones Criptográficas
OperaciónAlgoritmoParámetros
Firma de QRRSA-PSSSHA-256, saltLength=32 bytes, keySize=4096 bits
Cifrado de datosAES-256-GCMIV=12 bytes aleatorios, authTag=16 bytes, AEAD
Envoltura de claveRSA-OAEPSHA-256, MGF1-SHA256, keySize=4096 bits
Encoding de tokens QRBase64URLSin padding, seguro para URLs y QR codes
Encoding de paquetesBase64Estándar para transporte en JSON
Implementaciónnode:crypto (built-in)Sin dependencias externas de criptografía
15Referencia Rápida de Endpoints
MétodoRutaDescripción
GET/healthHealth check del servidor
GET/docsSwagger UI
POST/keys/generateGenera par de llaves RSA-4096
GET/keys/:keyIdObtiene llave pública PEM
POST/qr/createCrea QR firmado RSA-PSS
POST/qr/validateValida firma de un token QR
POST/crypto/encryptCifra datos AES-256-GCM + RSA-OAEP
POST/crypto/decryptDesencripta paquete
POST/sessionCrea sesión + genera QR StartValidation
SSE/session/:id/eventsStream de eventos en tiempo real
POST/session/:id/progressReporta progreso de verificación
POST/session/:id/resultEnvía resultado cifrado (callbackUrl)
DELETE/session/:idTermina sesión, notifica SSE clients y borra del Map
POST/simulateSimula flujo completo de app externa
16Eventos FirmaDoxID — Schema de Retorno

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.

Tipos de mensajes SSE
typeOrigenCierra SSECampos adicionales
progressApp → POST /progressNostep, status, timestamp, detail?
resetApp reinicia flujo (started sobre step ya reportado)Nostep (primer paso del nuevo ciclo)
resultApp → POST callbackUrldecryptedData, encryptedPackage, receivedAt
session_terminatedApp → DELETE /session/:idreason: "client_requested"
session_expiredCleanup TTL servidor (10 min sin resultado)
json — type: "progress"
{
  "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
}
json — type: "session_terminated"
{ "type": "session_terminated", "reason": "client_requested" }
// La app llamó DELETE /session/:id (ej: usuario presionó Reiniciar en FirmaDoxID)
json — type: "session_expired"
{ "type": "session_expired" }
// El TTL de 10 min expiró sin que llegara resultado
Schema de 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.

stepStatuses emitidosdetail si completeddetail 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}
💡
Errores conocidos en nfc_read: "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).
Schema del payload desencriptado (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.

json — decryptedData
{
  "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"
  }
}
ℹ️
documentFaceSubstitution solo está presente cuando el chip NFC y el OCR del anverso devolvieron ambos una foto — compara las dos imágenes para detectar cédulas con fotos suplantadas.

liveness, faceMatch y regCivil solo están presentes si el paso llegó a ejecutarse. Si el usuario abortó antes, el campo estará ausente.