{
  "info": {
    "name": "QR Seguro API",
    "description": "Colección completa de la API SecureQR by CheckID — v1.0.1.\n\nFlujo típico de integración:\n1. Crear par de llaves (Keys → Generate Key Pair)\n2. Crear sesión (Session Flow → Create Session) → auto-setea {{sessionId}}\n3. Suscribirse a eventos SSE en el browser: GET {{baseUrl}}/session/{{sessionId}}/events (la cookie sse_auth se envía automáticamente en same-origin)\n4. La app externa envía progreso y resultado (Session Flow → Send Progress / Send Result)\n5. O simular el flujo completo (Session Flow → Simulate Full Flow)\n\nVariables de colección:\n- baseUrl: URL base del servidor (default: http://localhost:3000)\n- keyId: ID de la llave a usar (default: demo-key)\n- sessionId: ID de sesión activa (auto-seteado por Create Session)\n- encryptedPackage: paquete cifrado (auto-seteado por Encrypt Payload)",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
    "version": "1.0.1"
  },
  "variable": [
    { "key": "baseUrl",          "value": "http://localhost:3000", "type": "string" },
    { "key": "keyId",            "value": "demo-key",              "type": "string" },
    { "key": "sessionId",        "value": "",                      "type": "string" },
    { "key": "encryptedPackage", "value": "",                      "type": "string" }
  ],
  "item": [

    {
      "name": "System",
      "item": [
        {
          "name": "Health Check",
          "request": {
            "method": "GET",
            "header": [],
            "url": { "raw": "{{baseUrl}}/health", "host": ["{{baseUrl}}"], "path": ["health"] },
            "description": "Verifica que el servidor está activo.\n\nRespuesta esperada:\n```json\n{ \"status\": \"ok\", \"ts\": \"2026-04-07T12:00:00.000Z\" }\n```"
          },
          "response": []
        }
      ]
    },

    {
      "name": "Keys",
      "item": [
        {
          "name": "Generate Key Pair",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "var json = pm.response.json();",
                  "if (json.keyId) {",
                  "  pm.collectionVariables.set('keyId', json.keyId);",
                  "  console.log('keyId seteado:', json.keyId);",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"keyId\": \"my-integration-key\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/keys/generate", "host": ["{{baseUrl}}"], "path": ["keys", "generate"] },
            "description": "Genera un par de llaves ECDSA P-256 (firma) + RSA-4096 (cifrado) y las guarda en el servidor.\n\nEl servidor guarda 4 archivos por `keyId`:\n- `store/keys/<keyId>/private-sign.pem` — llave privada ECDSA P-256\n- `store/keys/<keyId>/public-sign.pem` — llave pública ECDSA P-256\n- `store/keys/<keyId>/private-enc.pem` — llave privada RSA-4096\n- `store/keys/<keyId>/public-enc.pem` — llave pública RSA-4096\n\nLa llave `demo-key` se genera automáticamente al iniciar el servidor si no existe.\n\n⚠️ **Migración desde v1.0.0**: la estructura anterior usaba 2 archivos (`private.pem` / `public.pem`). No hay auto-migración — si el directorio `store/keys/<keyId>/` tiene el formato viejo, elimínalo y el servidor lo regenera.\n\n**Respuesta:**\n```json\n{\n  \"keyId\": \"my-integration-key\",\n  \"signPublicKey\": \"-----BEGIN PUBLIC KEY-----\\n...ECDSA P-256...\",\n  \"encPublicKey\":  \"-----BEGIN PUBLIC KEY-----\\n...RSA-4096...\"\n}\n```\n\nEl test script guarda el `keyId` en la variable de colección."
          },
          "response": []
        },
        {
          "name": "Get Public Key",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/keys/{{keyId}}",
              "host": ["{{baseUrl}}"],
              "path": ["keys", "{{keyId}}"]
            },
            "description": "Obtiene las dos llaves públicas de un keyId existente.\n\n- `signPublicKey` (ECDSA P-256): usada para **verificar la firma** del token QR\n- `encPublicKey` (RSA-4096): usada para **cifrar el payload** de resultado (RSA-OAEP-SHA256 envolviendo la clave AES-256-GCM)\n\nEste endpoint es consumido por las apps externas al escanear el QR: descargan ambas llaves y usan `signPublicKey` para validar la firma ES256 y `encPublicKey` para cifrar el resultado al final del flujo.\n\n**Respuesta:**\n```json\n{\n  \"keyId\": \"demo-key\",\n  \"signPublicKey\": \"-----BEGIN PUBLIC KEY-----\\n...ECDSA P-256...\",\n  \"encPublicKey\":  \"-----BEGIN PUBLIC KEY-----\\n...RSA-4096...\"\n}\n```\n\n**Error 404** si el keyId no existe."
          },
          "response": []
        }
      ]
    },

    {
      "name": "QR",
      "item": [
        {
          "name": "Create Signed QR",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "var json = pm.response.json();",
                  "if (json.token) {",
                  "  pm.collectionVariables.set('qrToken', json.token);",
                  "  console.log('qrToken seteado (primeros 50 chars):', json.token.substring(0,50));",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"payload\": {\n    \"sessionId\": \"my-session-id\",\n    \"callbackUrl\": \"http://192.168.1.x:3000\"\n  },\n  \"issuerKeyId\": \"{{keyId}}\",\n  \"format\": \"png\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/qr/create", "host": ["{{baseUrl}}"], "path": ["qr", "create"] },
            "description": "Crea un QR firmado con ECDSA P-256 SHA-256 (ES256).\n\n**Campos del body:**\n- `payload`: objeto JSON a incluir en el QR (idealmente mínimo: `{ sessionId, callbackUrl }`)\n- `issuerKeyId`: ID de la llave con que se firma (usa la llave de firma ECDSA P-256)\n- `format`: `png` (data URL) | `svg` | `base64` | `token` (solo el string Base64URL)\n\n**El token QR es un JSON Base64URL** con estructura:\n```json\n{\n  \"v\": 2,\n  \"payload\": { \"sessionId\": \"...\", \"callbackUrl\": \"http://host:3000\" },\n  \"sig\": \"<Base64URL ECDSA P-256 DER signature>\",\n  \"kid\": \"demo-key\",\n  \"alg\": \"ES256\"\n}\n```\n\n⚠️ **Cambio desde v1.0.0**: la firma era RSA-PSS-SHA256 (`v:1`). Ahora es ECDSA P-256 DER (`v:2`, `alg:\"ES256\"`). El `QRValidator` rechaza tokens v1.\n\n**Respuesta:**\n```json\n{\n  \"token\": \"<Base64URL>\",\n  \"qrImage\": \"data:image/png;base64,...\",\n  \"issuerKeyId\": \"demo-key\"\n}\n```"
          },
          "response": []
        },
        {
          "name": "Validate QR Token",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"token\": \"{{qrToken}}\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/qr/validate", "host": ["{{baseUrl}}"], "path": ["qr", "validate"] },
            "description": "Valida la firma ECDSA P-256 SHA-256 (ES256) de un token QR.\n\nSólo acepta tokens con `v===2` y `alg===\"ES256\"`. Los tokens v1 (RSA-PSS) son rechazados.\n\n**Respuesta exitosa:**\n```json\n{\n  \"valid\": true,\n  \"payload\": { \"sessionId\": \"...\", \"callbackUrl\": \"http://host:3000\" },\n  \"issuerKeyId\": \"demo-key\"\n}\n```\n\n**Respuesta fallida:**\n```json\n{\n  \"valid\": false,\n  \"error\": \"Invalid signature\"\n}\n```\n\nUsa la variable `{{qrToken}}` seteada por 'Create Signed QR'."
          },
          "response": []
        }
      ]
    },

    {
      "name": "Crypto",
      "item": [
        {
          "name": "Encrypt Payload",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "var json = pm.response.json();",
                  "if (json.encryptedPackage) {",
                  "  pm.collectionVariables.set('encryptedPackage', json.encryptedPackage);",
                  "  console.log('encryptedPackage seteado (primeros 50 chars):', json.encryptedPackage.substring(0,50));",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": {\n    \"sessionId\": \"test-session-001\",\n    \"userId\": \"12345678-9\",\n    \"verified\": true,\n    \"score\": 0.97\n  },\n  \"issuerKeyId\": \"{{keyId}}\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/crypto/encrypt", "host": ["{{baseUrl}}"], "path": ["crypto", "encrypt"] },
            "description": "Cifra un objeto JSON usando cifrado híbrido AES-256-GCM + RSA-OAEP-SHA256.\n\n**La llave usada es la `encPublicKey` (RSA-4096)** del `issuerKeyId` — no la de firma.\n\n**Proceso interno:**\n1. Genera clave AES-256 aleatoria (32 bytes)\n2. Genera IV aleatorio (12 bytes)\n3. Cifra el JSON con AES-256-GCM → `ciphertext` + `authTag` (16 bytes)\n4. Envuelve la clave AES con RSA-OAEP-SHA256 usando la llave `encPublicKey` → `wrappedKey`\n5. Devuelve el `EncryptedPackage` serializado en Base64\n\n**Respuesta:**\n```json\n{ \"encryptedPackage\": \"<Base64 del JSON EncryptedPackage>\" }\n```\n\nEl test script guarda `encryptedPackage` en la variable de colección."
          },
          "response": []
        },
        {
          "name": "Decrypt Package",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"encryptedPackage\": \"{{encryptedPackage}}\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/crypto/decrypt", "host": ["{{baseUrl}}"], "path": ["crypto", "decrypt"] },
            "description": "Desencripta un paquete cifrado con AES-256-GCM + RSA-OAEP-SHA256.\n\nEl servidor usa el campo `kid` dentro del paquete para saber qué llave privada usar (`private-enc.pem` del kid).\n\n**Proceso interno:**\n1. Decodifica Base64 → JSON `EncryptedPackage`\n2. Desenvuelve la clave AES con RSA-OAEP usando la llave privada enc del `kid`\n3. Descifra con AES-256-GCM verificando el `authTag`\n4. Devuelve el JSON original\n\n**Respuesta:**\n```json\n{ \"data\": { ... } }\n```\n\nUsa `{{encryptedPackage}}` seteado por 'Encrypt Payload'."
          },
          "response": []
        }
      ]
    },

    {
      "name": "Session Flow",
      "item": [
        {
          "name": "Create Session",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "var json = pm.response.json();",
                  "if (json.sessionId) {",
                  "  pm.collectionVariables.set('sessionId', json.sessionId);",
                  "  console.log('sessionId seteado:', json.sessionId);",
                  "}",
                  "if (json.callbackUrl) {",
                  "  pm.collectionVariables.set('callbackUrl', json.callbackUrl);",
                  "  console.log('callbackUrl (base URL):', json.callbackUrl);",
                  "}"
                ],
                "type": "text/javascript"
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"issuerKeyId\": \"{{keyId}}\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/session", "host": ["{{baseUrl}}"], "path": ["session"] },
            "description": "Crea una sesión de verificación y genera el QR firmado con ECDSA P-256 (ES256).\n\n**Payload del QR generado (v2, mínimo):**\n```json\n{\n  \"sessionId\": \"<uuid>\",\n  \"callbackUrl\": \"http://<IP>:3000\"\n}\n```\n\n⚠️ `callbackUrl` es la **URL base del servidor** (sin path). La app construye los subpaths:\n- `${callbackUrl}/session/${sessionId}/result` → envío del resultado cifrado\n- `${callbackUrl}/session/${sessionId}/progress` → reporte de pasos\n- `${callbackUrl}/keys/${kid}` → descarga de llaves públicas\n\n**Respuesta (body):**\n```json\n{\n  \"sessionId\": \"<uuid>\",\n  \"token\": \"<Base64URL QR token>\",\n  \"qrImage\": \"data:image/png;base64,...\",\n  \"issuerKeyId\": \"demo-key\",\n  \"callbackUrl\": \"http://<IP>:3000\",\n  \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\n...RSA-4096 enc key...\"\n}\n```\n\n**Respuesta (header) — nuevo en v1.0.1:**\n```\nSet-Cookie: sse_auth=<uuid>; Path=/session/<sessionId>/events; HttpOnly; SameSite=Strict\n```\nEsta cookie es requerida para autenticar el canal SSE. Postman la almacena automáticamente en su cookie jar y la enviará en el `GET /events` siguiente.\n\n⚠️ `sseToken` ya **no aparece** en el body (viaja como cookie HttpOnly, no legible desde JS).\n\nEl test script setea `{{sessionId}}` y `{{callbackUrl}}` automáticamente.\n\n**Siguiente paso:** Abrir en el browser (same-origin) o via `GET /session/{{sessionId}}/events` en Postman (cookie se envía automáticamente desde el jar)."
          },
          "response": []
        },
        {
          "name": "SSE Events (abrir en browser)",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "text/event-stream" }],
            "url": {
              "raw": "{{baseUrl}}/session/{{sessionId}}/events",
              "host": ["{{baseUrl}}"],
              "path": ["session", "{{sessionId}}", "events"]
            },
            "description": "Se suscribe al stream SSE (Server-Sent Events) de una sesión.\n\n⚠️ **Postman no soporta SSE nativamente.** Para recibir eventos en tiempo real usa una de las opciones siguientes.\n\n---\n\n**Autenticación (nuevo en v1.0.1):** requiere la cookie `sse_auth` emitida por `POST /session`. La URL con `?token=` ya **no funciona** (401).\n\n**Browser same-origin (frontend servido por el backend):**\n```javascript\n// La cookie HttpOnly se envía automáticamente — no hay que hacer nada\nconst es = new EventSource(`/session/${sessionId}/events`);\nes.addEventListener('progress', e => console.log(JSON.parse(e.data)));\nes.addEventListener('result',   e => { console.log(JSON.parse(e.data)); es.close(); });\n```\n\n**curl (CLI / scripts):**\n```bash\n# 1. Crear sesión y guardar cookie\ncurl -s -c /tmp/sse_cookies.txt \\\n  -X POST http://localhost:3000/session \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"issuerKeyId\":\"demo-key\"}'\n\n# 2. Conectar al SSE enviando la cookie\ncurl -N -b /tmp/sse_cookies.txt \\\n  http://localhost:3000/session/<sessionId>/events\n```\n\n**Cross-origin (frontend en otro origen):**\n```javascript\n// Requiere configurar @fastify/cors con credentials:true en el servidor\nconst es = new EventSource(\n  `http://server:3000/session/${sessionId}/events`,\n  { withCredentials: true }\n);\n```\n\n---\n\n**Tipos de eventos recibidos:**\n- `progress`: `{ type, step, status, timestamp, detail }`\n- `reset`: `{ type, step }` — flujo reiniciado\n- `result`: `{ type, decryptedData, encryptedPackage, receivedAt }` — cierra la conexión\n\n**Keepalive:** El servidor envía `: keepalive` cada 20 segundos para mantener la conexión activa.\n\n**Reconexión:** Al reconectar, el servidor repite todos los eventos de progreso acumulados."
          },
          "response": []
        },
        {
          "name": "Send Progress — qr_scanned",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"step\": \"qr_scanned\",\n  \"status\": \"completed\",\n  \"timestamp\": \"{{$isoTimestamp}}\",\n  \"detail\": {\n    \"sessionId\": \"{{sessionId}}\"\n  }\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": {
              "raw": "{{baseUrl}}/session/{{sessionId}}/progress",
              "host": ["{{baseUrl}}"],
              "path": ["session", "{{sessionId}}", "progress"]
            },
            "description": "La app externa reporta el progreso de cada etapa de verificación.\n\nEsto se transmite en tiempo real a todos los clientes SSE conectados.\n\n**Pasos disponibles:**\n| step | Descripción |\n|------|-------------|\n| `qr_scanned` | QR escaneado y validado |\n| `document_front_scanned` | Anverso del documento leído por OCR |\n| `document_back_scanned` | Reverso del documento leído por OCR |\n| `nfc_read` | Chip NFC/RFID leído |\n| `selfie_ready` | Cámara frontal lista |\n| `selfie_captured` | Selfie capturado |\n| `liveness_check` | Verificación de vida completada |\n| `face_match` | Comparación facial completada |\n| `document_substitution` | Verificación de sustitución de rostro |\n| `reg_civil` | Consulta al Registro Civil |\n| `sending_result` | Enviando resultado cifrado |\n\n**Estados disponibles:** `started` | `in_progress` | `completed` | `failed`\n\n**Comportamiento de reset:** Si se envía `status: started` para un paso que ya existe en el log, el servidor limpia todo el historial de progreso y emite un evento `reset` a los clientes SSE."
          },
          "response": []
        },
        {
          "name": "Send Result (callback)",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"encryptedPackage\": \"{{encryptedPackage}}\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": {
              "raw": "{{baseUrl}}/session/{{sessionId}}/result",
              "host": ["{{baseUrl}}"],
              "path": ["session", "{{sessionId}}", "result"]
            },
            "description": "La app externa envía el resultado cifrado al `callbackUrl` de la sesión.\n\nEste es el endpoint que recibe la app móvil (FirmaDoxID / CheckID) después de completar el flujo de verificación. La app construye la URL como `${callbackUrl}/session/${sessionId}/result` donde `callbackUrl` es la URL base recibida en el QR.\n\n**Qué hace el servidor al recibir:**\n1. Decodifica Base64 → JSON `EncryptedPackage`\n2. Usa el campo `kid` para seleccionar la llave privada enc (`private-enc.pem`)\n3. Desenvuelve la clave AES con RSA-OAEP-SHA256 (llave privada)\n4. Descifra con AES-256-GCM verificando el `authTag`\n5. Guarda el resultado en la sesión\n6. Emite evento SSE `result` a todos los clientes conectados\n7. Cierra las conexiones SSE de esa sesión\n\n**Prerequisito:** Ejecuta primero 'Encrypt Payload' para generar `{{encryptedPackage}}`."
          },
          "response": []
        },
        {
          "name": "Simulate Full Flow",
          "request": {
            "method": "POST",
            "header": [{ "key": "Content-Type", "value": "application/json" }],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sessionId\": \"{{sessionId}}\",\n  \"withProgress\": true,\n  \"data\": {\n    \"sessionId\": \"{{sessionId}}\",\n    \"securityChecks\": {\n      \"qrValidation\": { \"valid\": true },\n      \"nfc\": { \"success\": true },\n      \"liveness\": { \"isBonafide\": true, \"score\": 0.9834 },\n      \"faceMatch\": { \"matched\": true, \"score\": 98.72 },\n      \"documentFaceSubstitution\": { \"passed\": true, \"score\": 0.9901 },\n      \"regCivil\": { \"vigente\": true }\n    },\n    \"documentData\": {\n      \"GivenNames\": \"JUAN PABLO\",\n      \"Surnames\": \"GONZALEZ RAMIREZ\",\n      \"PersonalNumber\": \"12345678-9\",\n      \"DocumentNumber\": \"512345678\",\n      \"DateOfBirth\": \"15 ENE 1985\",\n      \"DateOfExpiry\": \"20 MAR 2030\",\n      \"Country\": \"CHL\",\n      \"IssuingStateName\": \"Chile\",\n      \"Authority\": \"Registro Civil e Identificacion\"\n    },\n    \"device\": {\n      \"manufacturer\": \"Samsung\",\n      \"model\": \"SM-A528B\",\n      \"osVersion\": \"Android 14 (SDK 34)\",\n      \"appVersion\": \"1.0.5_secureqr_firmadox\",\n      \"locale\": \"es_CL\",\n      \"timezone\": \"America/Santiago\",\n      \"network\": \"WIFI\"\n    },\n    \"deviceIntegrity\": {\n      \"isRooted\": false,\n      \"isEmulator\": false,\n      \"isDebuggerAttached\": false,\n      \"hookingFrameworkDetected\": false,\n      \"developerOptionsEnabled\": false,\n      \"usbDebuggingEnabled\": false,\n      \"bootloaderUnlocked\": false,\n      \"overallRisk\": \"low\"\n    },\n    \"cameraIntegrity\": {\n      \"isFrontCameraReal\": true,\n      \"physicalSensorValid\": true,\n      \"virtualCameraAppDetected\": false,\n      \"screenRecordingActive\": false,\n      \"overallRisk\": \"low\"\n    },\n    \"audit\": {\n      \"qrScannedAt\": \"2026-04-15T12:00:00Z\",\n      \"completedAt\": \"2026-04-15T12:05:22Z\",\n      \"durationSeconds\": 322\n    }\n  }\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": { "raw": "{{baseUrl}}/simulate", "host": ["{{baseUrl}}"], "path": ["simulate"] },
            "description": "Simula el flujo completo de una app externa sin necesidad de una app móvil real.\n\n**Con `withProgress: true`** emite todos los eventos de progreso secuencialmente antes del resultado.\n\n**Qué hace internamente:**\n1. Carga la `encPublicKey` (RSA-4096) de la sesión\n2. Cifra el `data` con AES-256-GCM + RSA-OAEP-SHA256\n3. Emite eventos de progreso (si `withProgress: true`)\n4. Llama internamente al endpoint `/session/:id/result`\n5. El servidor desencripta y emite el evento `result` por SSE\n\n**Prerequisito:** Tener una sesión activa en `{{sessionId}}` y estar suscrito al SSE.\n\n**Respuesta:**\n```json\n{ \"ok\": true, \"encryptedPackage\": \"<Base64>\" }\n```"
          },
          "response": []
        }
      ]
    }

  ]
}
