From ca4db597ccccc084982e8a3c49c5f117d1913bef Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Auad Date: Thu, 30 Apr 2026 20:48:40 +0000 Subject: [PATCH] Eliminados serviciso de VHL redundantes --- .env.example | 18 --- README.md | 19 +-- bus-gateway/.env.example | 14 --- bus-gateway/app.js | 6 - bus-gateway/config/index.js | 13 -- bus-gateway/controllers/vhlIssue.js | 46 ------- bus-gateway/controllers/vhlVerify.js | 67 ---------- bus-gateway/docs/obtener_qr_vhl.mmd | 11 -- bus-gateway/docs/validar_qr_vhl.mmd | 27 ---- bus-gateway/routes/vhl.js | 21 ---- bus-gateway/services/vhlIssue.js | 109 ---------------- bus-gateway/services/vhlVerify.js | 50 -------- bus-gateway/utils/cbor.js | 109 ---------------- bus-gateway/utils/vhlCrypto.js | 182 --------------------------- bus-gateway/utils/vhlKeys.js | 79 ------------ bus-gateway/utils/vhlStorage.js | 141 --------------------- nginx/http.conf | 9 -- nginx/https.conf | 9 -- 18 files changed, 3 insertions(+), 927 deletions(-) delete mode 100644 bus-gateway/controllers/vhlIssue.js delete mode 100644 bus-gateway/controllers/vhlVerify.js delete mode 100644 bus-gateway/docs/obtener_qr_vhl.mmd delete mode 100644 bus-gateway/docs/validar_qr_vhl.mmd delete mode 100644 bus-gateway/routes/vhl.js delete mode 100644 bus-gateway/services/vhlIssue.js delete mode 100644 bus-gateway/services/vhlVerify.js delete mode 100644 bus-gateway/utils/cbor.js delete mode 100644 bus-gateway/utils/vhlCrypto.js delete mode 100644 bus-gateway/utils/vhlKeys.js delete mode 100644 bus-gateway/utils/vhlStorage.js diff --git a/.env.example b/.env.example index 4ea6d9c..c8d1479 100644 --- a/.env.example +++ b/.env.example @@ -72,24 +72,6 @@ SIGNATURE_KEY_PATH=./certs/trust-network.key # Clave privada para firma de documentos (Document Signing Certificate) SSL_DCC_KEY_PATH=./certs/signature.key -# ============================================================================= -# VERIFIABLE HEALTH LINKS (VHL) -# ============================================================================= - -# Ruta a una clave EC P-256 en formato PEM para firmar CWTs. -# Si se omite, se genera una clave efímera en memoria (se pierde al reiniciar). -# VHL_PRIVATE_KEY_FILE=./certs/vhl.key - -# URI del emisor VHL (iss del CWT). Por defecto usa BUS_ISSUER. -# VHL_ISSUER=https://your-repositorio-url - -# URL pública del gateway para construir las URLs de manifiesto y documento. -# Requerida si se usan los endpoints /vhl/*. -# VHL_BASE_URL=https://your-nodo-url - -# TTL del token VHL en segundos (default: 604800 = 7 días) -# VHL_TOKEN_TTL=604800 - # ============================================================================= # HAPI FHIR (Spring Boot) # ============================================================================= diff --git a/README.md b/README.md index 916b959..f02f0da 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,7 @@ Internet / Red interna │ /fhir/IPSDocument → bus-gateway:3000 │ │ /fhir/DocumentReference→ bus-gateway:3000 │ │ /fhir/Patient → bus-gateway:3000 │ - │ /vhl/* → bus-gateway:3000 │ - │ /gdhcn/* → gdhcn-validator │ + │ /gdhcn/* → gdhcn-validator-service │ │ /fhir/* (resto) → hapi-fhir:8080 │ └──────────────────────────────────────────────┘ ``` @@ -48,9 +47,6 @@ El `bus-gateway` implementa los siguientes perfiles de interoperabilidad: | **ITI-78** | GET | `/fhir/Patient/:id` | Patient Demographics Query (por ID) | | **ITI-104** | POST | `/fhir/Patient` | Patient Identity Feed (alta) | | **ITI-104** | PUT | `/fhir/Patient/:id` | Patient Identity Feed (actualización) | -| **VHL** | POST | `/vhl/:patientId` | Emitir Verifiable Health Link (token QR) | -| **VHL** | GET | `/vhl/manifest/:id` | Servir manifiesto VHL | -| **VHL** | GET | `/vhl/document/:id` | Servir documento VHL cifrado | ## Requisitos @@ -189,15 +185,6 @@ Todas las variables se definen en el archivo `.env` de la raíz del proyecto. | `SIGNATURE_KEY_PATH` | `./certs/trust-network.key` | Clave privada de la red de confianza | | `SSL_DCC_KEY_PATH` | `./certs/signature.key` | Clave privada para firma de documentos (DSC) | -### Verifiable Health Links (VHL) - -| Variable | Default | Descripción | -|---|---|---| -| `VHL_PRIVATE_KEY_FILE` | *(efímera)* | Ruta a clave EC P-256 en PEM para firmar CWTs. Si se omite, se genera una clave en memoria que se pierde al reiniciar | -| `VHL_ISSUER` | `BUS_ISSUER` | URI del emisor VHL (iss del CWT) | -| `VHL_BASE_URL` | — | URL pública del gateway para construir URLs de manifiesto y documento. Requerida para usar endpoints VHL | -| `VHL_TOKEN_TTL` | `604800` | TTL del token VHL en segundos (default: 7 días) | - ### HAPI FHIR / PostgreSQL | Variable | Default | Descripción | @@ -226,10 +213,10 @@ ips-nodo-dominio/ ├── certs/ # Certificados TLS y claves de firma │ └── README.md # Instrucciones para generar certificados de prueba ├── bus-gateway/ # Gateway Node.js/Express -│ ├── controllers/ # Manejadores de transacciones IHE (ITI-65/67/78/104, VHL) +│ ├── controllers/ # Manejadores de transacciones IHE (ITI-65/67/78/104) │ ├── routes/ # Definición de rutas Express │ ├── services/ # Clientes de servicios externos (MPI, Document Registry, FHIR) -│ ├── utils/ # Utilidades (auth, logging, criptografía VHL) +│ ├── utils/ # Utilidades (auth, logging) │ ├── docs/ # Diagramas de secuencia Mermaid │ └── tests/ # Suite de pruebas Jest ├── json/ # Fixtures y schemas JSON diff --git a/bus-gateway/.env.example b/bus-gateway/.env.example index 8a36577..9b212cc 100644 --- a/bus-gateway/.env.example +++ b/bus-gateway/.env.example @@ -21,17 +21,3 @@ LOG_LEVEL=debug # Habilita logs de requests/responses salientes al Bus (true | false) BUS_DEBUG=false -# --- VHL (Verifiable Health Link) --- -# Ruta al archivo PEM con la clave privada EC P-256 para firmar los CWT. -# Si no se configura, se genera una clave efímera en cada inicio (no apta para producción). -VHL_PRIVATE_KEY_FILE=/etc/bus-gateway/vhl-private-key.pem - -# URI del emisor del CWT (claim "iss"). Por defecto usa BUS_ISSUER. -# VHL_ISSUER=https://gateway.salud.gob.ar - -# URL pública base de este gateway, usada para construir las URLs de manifest y documento. -# Requerida para usar los endpoints VHL. -VHL_BASE_URL=https://gateway.salud.gob.ar - -# TTL de los tokens VHL y documentos cifrados, en segundos (por defecto: 604800 = 7 días). -# VHL_TOKEN_TTL=604800 diff --git a/bus-gateway/app.js b/bus-gateway/app.js index 6a77ba8..4079147 100644 --- a/bus-gateway/app.js +++ b/bus-gateway/app.js @@ -8,7 +8,6 @@ var iti65Router = require('./routes/iti65'); var iti67Router = require('./routes/iti67'); var iti78Router = require('./routes/iti78'); var iti104Router = require('./routes/iti104'); -var vhlRouter = require('./routes/vhl'); var app = express(); @@ -28,11 +27,6 @@ app.use('/fhir', iti78Router); // ITI-104: Patient Identity Feed → POST /fhir/Patient, PUT /fhir/Patient/:id app.use('/fhir', iti104Router); -// VHL: Issue VHL → POST /vhl/:patientId -// Serve manifest → POST /vhl/manifest/:id -// Serve encrypted document → GET /vhl/document/:id -app.use('/vhl', vhlRouter); - // 404 app.use(function (req, res, next) { const host = req.get('host') || 'localhost'; diff --git a/bus-gateway/config/index.js b/bus-gateway/config/index.js index 2174148..d65bf23 100644 --- a/bus-gateway/config/index.js +++ b/bus-gateway/config/index.js @@ -24,19 +24,6 @@ const config = { errorKey: 'err', nestedKey: null, }, - vhl: { - // Path to an EC P-256 private key in PEM format for signing CWTs. - // If omitted, an ephemeral key is generated at startup (lost on restart). - privateKeyFile: process.env.VHL_PRIVATE_KEY_FILE, - // URI identifying this gateway as the CWT issuer (iss claim). - // Defaults to BUS_ISSUER so no extra variable is needed in most deployments. - issuer: process.env.VHL_ISSUER || process.env.BUS_ISSUER, - // Public base URL of this gateway, used to build manifest and document URLs. - // Required when the VHL endpoints are used. - baseUrl: process.env.VHL_BASE_URL, - // VHL token and document TTL in seconds (default: 7 days). - ttl: parseInt(process.env.VHL_TOKEN_TTL || '604800', 10), - }, baseURL: process.env.NODO_BASE_URL || 'http://localhost', debug: process.env.BUS_DEBUG === 'true', }; diff --git a/bus-gateway/controllers/vhlIssue.js b/bus-gateway/controllers/vhlIssue.js deleted file mode 100644 index d86c22f..0000000 --- a/bus-gateway/controllers/vhlIssue.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -const createError = require('http-errors'); -const { issueVHL } = require('../services/vhlIssue'); - -/** - * POST /vhl/:patientId - * - * Issues a Verifiable Health Link (VHL) for a patient. - * Corresponds to Fase 1 (Emisión) in obtener_qr_vhl.mmd: - * MiArgentina → BusNación: solicita generación de VHL para [PacienteID] - * BusNación → MiArgentina: retorna VHL firmado (CWT) - * - * Path parameter: - * :patientId — FHIR Patient logical ID in the local HAPI FHIR server. - * - * Body (optional, application/json): - * { "pin": "1234" } ← citizen-configured PIN; protects the manifest. - * - * Response 201: - * { "cwt": "" } - * - * MiArgentina then: - * - Decodes the base64url to get the CWT bytes. - * - Compresses with ZLIB and encodes as Base45 to produce the QR payload. - * - Displays the QR code to the citizen. - */ -async function issueVHLController(req, res, next) { - try { - const { patientId } = req.params; - if (!patientId) { - throw createError(400, 'Missing path parameter: patientId'); - } - - const pin = req.body && typeof req.body.pin === 'string' - ? req.body.pin.trim() || undefined - : undefined; - - const result = await issueVHL({ patientId, pin }); - res.status(201).json(result); - } catch (err) { - next(err); - } -} - -module.exports = { issueVHLController }; diff --git a/bus-gateway/controllers/vhlVerify.js b/bus-gateway/controllers/vhlVerify.js deleted file mode 100644 index ee032c3..0000000 --- a/bus-gateway/controllers/vhlVerify.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict'; - -const { getManifest, getDocument } = require('../services/vhlVerify'); - -/** - * POST /vhl/manifest/:manifestId - * - * Returns the VHL manifest for a given manifest ID. - * Corresponds to Fase 4 step 6 in validar_qr_vhl.mmd: - * NodoDominio → BusNación: POST [Manifest_URL] (envía PIN si el ciudadano lo configuró) - * BusNación → NodoDominio: 200 OK (retorna Manifest con link al NodoDominio_B) - * - * Path parameter: - * :manifestId — ID portion of the manifest URL embedded in the CWT. - * - * Body (optional, application/json): - * { "pin": "1234" } ← required only if the VHL was issued with a PIN. - * - * Response 200: - * { - * "files": [ - * { "contentType": "application/fhir+json", "location": "https://..." } - * ] - * } - */ -async function getManifestController(req, res, next) { - try { - const { manifestId } = req.params; - const pin = req.body && typeof req.body.pin === 'string' - ? req.body.pin.trim() || undefined - : undefined; - - const manifest = getManifest(manifestId, pin); - res.status(200).json(manifest); - } catch (err) { - next(err); - } -} - -/** - * GET /vhl/document/:documentId - * - * Returns the AES-256-GCM encrypted IPS document. - * Corresponds to Fase 4 step 8 in validar_qr_vhl.mmd: - * NodoDominio → NodoDominio_B: GET [Document_URL] (descarga P2P directa del archivo) - * NodoDominio_B → NodoDominio: 200 OK (retorna IPS encriptado) - * - * The caller decrypts with the symmetric key extracted from the CWT: - * Wire format: [ 12 bytes IV ][ N bytes ciphertext ][ 16 bytes GCM tag ] - * Algorithm: AES-256-GCM - * - * Response 200: application/octet-stream (binary encrypted payload) - */ -async function getDocumentController(req, res, next) { - try { - const { documentId } = req.params; - const payload = getDocument(documentId); - res - .status(200) - .set('Content-Type', 'application/octet-stream') - .send(payload); - } catch (err) { - next(err); - } -} - -module.exports = { getManifestController, getDocumentController }; diff --git a/bus-gateway/docs/obtener_qr_vhl.mmd b/bus-gateway/docs/obtener_qr_vhl.mmd deleted file mode 100644 index c365b82..0000000 --- a/bus-gateway/docs/obtener_qr_vhl.mmd +++ /dev/null @@ -1,11 +0,0 @@ -sequenceDiagram - autonumber - actor Ciudadano as Ciudadano (VHL Holder) - participant MiArgentina as MiArgentina (Wallet) - participant BusNacion as Bus Nacion (VHL Sharer / Emisor) - Note over Ciudadano,BusNacion: Fase 1. Emisión del Verifiable Health Link (Issue VHL) - Ciudadano->>MiArgentina: Solicita compartir IPS (configurar PIN opcional) - MiArgentina->>BusNacion: Solicita generación de VHL para [PacienteID], - BusNacion-->>MiArgentina: Retorna VHL Firmado - MiArgentina->>MiArgentina: Comprime y codifica en Base45 (genera QR) - MiArgentina-->>Ciudadano: Despliega QR en pantalla \ No newline at end of file diff --git a/bus-gateway/docs/validar_qr_vhl.mmd b/bus-gateway/docs/validar_qr_vhl.mmd deleted file mode 100644 index 93e4d97..0000000 --- a/bus-gateway/docs/validar_qr_vhl.mmd +++ /dev/null @@ -1,27 +0,0 @@ -sequenceDiagram - autonumber - actor Ciudadano as Ciudadano (VHL Holder) - participant HIS_A as HIS_A (Médico / Consumidor) - participant NodoDominio as NodoDominio (Gateway origen) - participant RedDeConfianza (PKI / GDHCN) - participant BusNacion as BusNación - participant NodoDominio_B as NodoDominio_B (Repositorio destino) - Note over Ciudadano,HIS_A: Fase 2: Presentación (Provide VHL) - Ciudadano->>HIS_A: Muestra el código QR - HIS_A->>HIS_A: Escanea QR (decodifica Base45 y extgrae el CWT) - HIS_A->>NodoDominio: Delega validación y descarga (Envía el CWT) - Note over NodoDominio,RedDeConfianza: Fase 3. Verificación de confianza (Trust & Verify) - Note over NodoDominio,RedDeConfianza: Extrae el "kid" (Key ID) del encabezado del CWT - NodoDominio->>RedDeConfianza: Consulta clave pública del emisor - RedDeConfianza-->>NodoDominio: Retorna clave pública - NodoDominio->>NodoDominio: Verifica la firma digital del WCT (valida Autenticidad) - Note over NodoDominio,NodoDominio_B: Fase 4. Descarga segura P2P (Retrive VHL Manifest & Docs) - Note over NodoDominio: Extrae URL del Man ifest y Clave simétrica del payload del VHL - NodoDominio->>BusNacion: POST [Manifest_URL](Envía PIN si el ciudadano lo configuró) - BusNacion-->>NodoDominio: 200 OK (Retorna Manifest con link al NodoDominio_B) - NodoDominio->>NodoDominio_B: GET [Document_URL] (Descarga P2P directa del archivo) - NodoDominio_B-->>NodoDominio: 200 Ok (Retorna IPS encriptado) - NodoDominio->>NodoDominio: Desencripta el IPS usando la clave del QR - NodoDominio-->>HIS_A: Retorna documento clinico en texto claro - HIS_A-->>Ciudadano: Renderiza el documento para el médico. - diff --git a/bus-gateway/routes/vhl.js b/bus-gateway/routes/vhl.js deleted file mode 100644 index 45c3380..0000000 --- a/bus-gateway/routes/vhl.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const express = require('express'); -const router = express.Router(); -const { issueVHLController } = require('../controllers/vhlIssue'); -const { getManifestController, getDocumentController } = require('../controllers/vhlVerify'); - -// Specific sub-paths are registered before /:patientId to avoid ambiguity. -// Express matches routes in registration order; 'manifest' and 'document' -// are literal path segments, so they won't be captured by /:patientId. - -// Fase 4 — Retrieve VHL Manifest (validar_qr_vhl.mmd, step 6) -router.post('/manifest/:manifestId', getManifestController); - -// Fase 4 — Serve encrypted IPS document (validar_qr_vhl.mmd, step 8) -router.get('/document/:documentId', getDocumentController); - -// Fase 1 — Issue VHL (obtener_qr_vhl.mmd, steps 2–3) -router.post('/:patientId', issueVHLController); - -module.exports = router; diff --git a/bus-gateway/services/vhlIssue.js b/bus-gateway/services/vhlIssue.js deleted file mode 100644 index affa604..0000000 --- a/bus-gateway/services/vhlIssue.js +++ /dev/null @@ -1,109 +0,0 @@ -'use strict'; - -const axios = require('axios'); -const createError = require('http-errors'); -const config = require('../config'); -const { getPrivateKey, getKid } = require('../utils/vhlKeys'); -const { buildVHLCWT, encryptBundle, serializeEncrypted } = require('../utils/vhlCrypto'); -const { storeDocument, storeManifest } = require('../utils/vhlStorage'); - -/** - * Fetches the most recent IPS document-type Bundle for a patient from HAPI FHIR. - * - * Searches using the FHIR chained parameter: - * GET /Bundle?composition.subject=Patient/{patientId}&type=document&_sort=-_lastUpdated&_count=1 - * - * @param {string} patientId - FHIR Patient logical ID. - * @returns {Promise} FHIR Bundle resource. - * @throws {HttpError} 404 if no Bundle is found; 502 on FHIR server error. - */ -async function fetchIPSBundle(patientId) { - let response; - try { - response = await axios.get(`${config.fhir.url}/Bundle`, { - params: { - 'composition.subject': `Patient/${patientId}`, - type: 'document', - _sort: '-_lastUpdated', - _count: 1, - }, - headers: { Accept: 'application/fhir+json' }, - }); - } catch (err) { - throw createError(502, `HAPI FHIR error fetching Bundle: ${err.message}`); - } - - const searchSet = response.data; - const entries = Array.isArray(searchSet.entry) ? searchSet.entry : []; - if (entries.length === 0) { - throw createError(404, `No se encontró un IPS Bundle para el paciente ${patientId}`); - } - - const bundle = entries[0].resource; - if (!bundle) { - throw createError(502, 'HAPI FHIR returned an entry without a resource'); - } - return bundle; -} - -/** - * Issues a Verifiable Health Link (VHL) for a patient. - * - * Flow (matching obtener_qr_vhl.mmd — Fase 1. Emisión del VHL): - * 1. Fetch the patient's IPS Bundle from HAPI FHIR. - * 2. Encrypt the Bundle with AES-256-GCM (random key per issuance). - * 3. Store the encrypted document; derive its public URL. - * 4. Build a manifest { files: [{ contentType, location }] } and store it. - * 5. Sign a CWT (COSE_Sign1 / tag-18) containing the manifest URL and the - * symmetric key so the holder can share both atomically via a QR code. - * 6. Return the raw CWT bytes as base64url (MiArgentina encodes as Base45 + QR). - * - * @param {object} opts - * @param {string} opts.patientId - FHIR Patient logical ID. - * @param {string} [opts.pin] - Optional PIN; the manifest will require it. - * @returns {Promise<{ cwt: string }>} Base64url-encoded COSE_Sign1 CWT. - */ -async function issueVHL({ patientId, pin }) { - if (!config.vhl.baseUrl) { - throw createError(500, 'VHL_NODO_BASEURL is not configured'); - } - - const bundle = await fetchIPSBundle(patientId); - - const encrypted = encryptBundle(bundle); - const serialized = serializeEncrypted(encrypted); - - const ttl = config.vhl.ttl; - const baseUrl = config.vhl.baseUrl.replace(/\/$/, ''); - - const documentId = storeDocument(serialized, ttl); - const documentUrl = `${baseUrl}/vhl/document/${documentId}`; - - const manifest = { - files: [ - { - contentType: 'application/fhir+json', - location: documentUrl, - }, - ], - }; - const manifestId = storeManifest(manifest, ttl, pin); - const manifestUrl = `${baseUrl}/vhl/manifest/${manifestId}`; - - const cwtBytes = buildVHLCWT( - { - issuer: config.vhl.issuer, - patientId, - manifestUrl, - symmetricKey: encrypted.key, - ttl, - pin, - }, - getPrivateKey(), - getKid(), - ); - - return { cwt: cwtBytes.toString('base64url') }; -} - -module.exports = { issueVHL, fetchIPSBundle }; diff --git a/bus-gateway/services/vhlVerify.js b/bus-gateway/services/vhlVerify.js deleted file mode 100644 index 87fe064..0000000 --- a/bus-gateway/services/vhlVerify.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -const createError = require('http-errors'); -const { - getManifest: getManifestFromStore, - getDocument: getDocumentFromStore, -} = require('../utils/vhlStorage'); - -/** - * Retrieves a VHL manifest by ID, enforcing PIN validation when required. - * - * Called by NodoDominio during Fase 4 (Retrieve VHL Manifest & Docs) of - * validar_qr_vhl.mmd: NodoDominio extracts the manifest URL from the CWT - * and POSTs to it (with optional PIN) to get the document locations. - * - * @param {string} manifestId - * @param {string} [pin] - * @returns {object} Manifest { files: [{ contentType, location }] } - * @throws {HttpError} 404 if not found/expired; 401/403 on PIN failure. - */ -function getManifest(manifestId, pin) { - // Delegate to storage; it throws with .status on PIN failures - const manifest = getManifestFromStore(manifestId, pin); - if (!manifest) { - throw createError(404, 'Manifest no encontrado o expirado'); - } - return manifest; -} - -/** - * Retrieves a serialized AES-256-GCM encrypted document by ID. - * - * Called by NodoDominio via a direct P2P download (step 8 in validar_qr_vhl.mmd). - * The caller decrypts using the symmetric key extracted from the CWT. - * - * Wire format: [ 12 bytes IV ][ N bytes ciphertext ][ 16 bytes GCM tag ] - * - * @param {string} documentId - * @returns {Buffer} Serialized encrypted document. - * @throws {HttpError} 404 if not found or expired. - */ -function getDocument(documentId) { - const doc = getDocumentFromStore(documentId); - if (!doc) { - throw createError(404, 'Documento no encontrado o expirado'); - } - return doc; -} - -module.exports = { getManifest, getDocument }; diff --git a/bus-gateway/utils/cbor.js b/bus-gateway/utils/cbor.js deleted file mode 100644 index 1a88632..0000000 --- a/bus-gateway/utils/cbor.js +++ /dev/null @@ -1,109 +0,0 @@ -'use strict'; - -/** - * Minimal CBOR encoder for CWT/COSE structures (RFC 7049). - * - * Supports: - * - null / undefined - * - booleans - * - integers (positive and negative) - * - byte strings (Buffer) - * - text strings - * - fixed-length arrays - * - maps (ES6 Map — preserves key order; plain objects — string keys only) - * - tagged values - * - * Not supported: floats, indefinite-length items. - */ - -/** - * Encodes a CBOR "head": major type (3 bits) + additional info (variable length). - * @param {number} majorType - 0..7 - * @param {number} n - Argument value (uint). - * @returns {Buffer} - */ -function encodeHead(majorType, n) { - const mt = majorType << 5; - if (n <= 23) return Buffer.from([mt | n]); - if (n <= 0xff) return Buffer.from([mt | 24, n]); - if (n <= 0xffff) return Buffer.from([mt | 25, (n >> 8) & 0xff, n & 0xff]); - return Buffer.from([ - mt | 26, - (n >>> 24) & 0xff, - (n >>> 16) & 0xff, - (n >>> 8) & 0xff, - n & 0xff, - ]); -} - -/** - * CBOR-encodes a JavaScript value. - * - * - null / undefined → 0xf6 (null) - * - boolean → 0xf4 / 0xf5 - * - integer >= 0 → major type 0 - * - integer < 0 → major type 1 - * - Buffer → major type 2 (byte string) - * - string → major type 3 (text string, UTF-8) - * - Array → major type 4 - * - Map → major type 5 (keys encoded in insertion order) - * - plain object → major type 5 (string keys, own enumerable) - * - * @param {*} value - * @returns {Buffer} - */ -function encode(value) { - if (value === null || value === undefined) { - return Buffer.from([0xf6]); - } - if (typeof value === 'boolean') { - return Buffer.from([value ? 0xf5 : 0xf4]); - } - if (typeof value === 'number') { - if (!Number.isInteger(value)) { - throw new Error('cbor: float encoding not supported'); - } - if (value >= 0) return encodeHead(0, value); // unsigned int - return encodeHead(1, -1 - value); // negative int - } - if (typeof value === 'string') { - const buf = Buffer.from(value, 'utf8'); - return Buffer.concat([encodeHead(3, buf.length), buf]); - } - if (Buffer.isBuffer(value)) { - return Buffer.concat([encodeHead(2, value.length), value]); - } - if (Array.isArray(value)) { - const parts = [encodeHead(4, value.length)]; - for (const item of value) parts.push(encode(item)); - return Buffer.concat(parts); - } - if (value instanceof Map) { - const parts = [encodeHead(5, value.size)]; - for (const [k, v] of value.entries()) { - parts.push(encode(k), encode(v)); - } - return Buffer.concat(parts); - } - if (typeof value === 'object') { - const keys = Object.keys(value); - const parts = [encodeHead(5, keys.length)]; - for (const k of keys) { - parts.push(encode(k), encode(value[k])); - } - return Buffer.concat(parts); - } - throw new Error(`cbor: cannot encode type "${typeof value}"`); -} - -/** - * Wraps an already-encoded CBOR Buffer with a CBOR tag (major type 6). - * @param {number} tagNum - Tag number (e.g. 18 for COSE_Sign1). - * @param {Buffer} encoded - Pre-encoded CBOR value to tag. - * @returns {Buffer} - */ -function tagged(tagNum, encoded) { - return Buffer.concat([encodeHead(6, tagNum), encoded]); -} - -module.exports = { encode, tagged }; diff --git a/bus-gateway/utils/vhlCrypto.js b/bus-gateway/utils/vhlCrypto.js deleted file mode 100644 index 86d34e0..0000000 --- a/bus-gateway/utils/vhlCrypto.js +++ /dev/null @@ -1,182 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); -const cbor = require('./cbor'); - -// COSE algorithm identifier for ES256 (ECDSA w/ SHA-256 over P-256), per RFC 8152 -const COSE_ALG_ES256 = -7; -// CBOR tag 18 = COSE_Sign1 -const COSE_SIGN1_TAG = 18; - -/** - * Creates a signed COSE_Sign1 structure (tag 18) over a CBOR-encoded payload. - * - * Structure per RFC 8152 §4.2: - * COSE_Sign1 = [ - * protected: bstr .cbor header_map, // { 1: -7, 4: kid } - * unprotected: {}, - * payload: bstr, - * signature: bstr // ECDSA P-256, IEEE P1363 (r || s) - * ] - * Tagged with #6.18 - * - * @param {Buffer} payloadBstr - CBOR-encoded CWT claims map. - * @param {crypto.KeyObject} privateKey - EC P-256 private key. - * @param {string} kid - Key ID included in the protected header. - * @returns {Buffer} COSE_Sign1 tagged with #6.18. - */ -function createCOSESign1(payloadBstr, privateKey, kid) { - // Protected header: { 1: -7 (ES256), 4: kid_as_bstr } - const protectedHeader = new Map([ - [1, COSE_ALG_ES256], - [4, Buffer.from(kid, 'utf8')], - ]); - const protectedBstr = cbor.encode(protectedHeader); - - // Sig_Structure = ["Signature1", protected_bstr, external_aad, payload] - // external_aad is empty bstr per RFC 8152 §4.4 - const sigStructure = cbor.encode([ - 'Signature1', - protectedBstr, - Buffer.alloc(0), - payloadBstr, - ]); - - // Sign with ECDSA P-256 + SHA-256. - // dsaEncoding: 'ieee-p1363' produces the raw r||s format required by COSE - // (as opposed to DER, which Node.js uses by default). - const signature = crypto.sign('SHA256', sigStructure, { - key: privateKey, - dsaEncoding: 'ieee-p1363', - }); - - const coseSign1 = cbor.encode([ - protectedBstr, - new Map(), // unprotected header: empty map - payloadBstr, - signature, - ]); - - return cbor.tagged(COSE_SIGN1_TAG, coseSign1); -} - -/** - * Builds and signs a CWT (CBOR Web Token) containing VHL claims. - * - * CWT claims (RFC 8392 integer keys): - * 1 = iss (issuer URI) - * 2 = sub (patient ID) - * 4 = exp (expiration, Unix timestamp) - * 6 = iat (issued at, Unix timestamp) - * -1 = vhl (private claim: manifest URL + symmetric key) - * - * VHL claim map (text keys): - * "url" → manifest URL - * "key" → base64url-encoded AES-256-GCM symmetric key - * "label" → human-readable label - * "flag" → "P" if PIN-protected (optional) - * - * @param {object} opts - * @param {string} opts.issuer - VHL_ISSUER URI. - * @param {string} opts.patientId - Patient FHIR logical ID (becomes CWT "sub"). - * @param {string} opts.manifestUrl - URL where the VHL manifest is served. - * @param {Buffer} opts.symmetricKey - 32-byte AES-256-GCM key for the encrypted document. - * @param {number} opts.ttl - Token TTL in seconds. - * @param {string} [opts.pin] - Optional PIN (signals flag="P" in the VHL claim). - * @param {crypto.KeyObject} privateKey - EC P-256 private key. - * @param {string} kid - Key ID for the COSE protected header. - * @returns {Buffer} Signed CWT bytes (COSE_Sign1 tagged). - */ -function buildVHLCWT({ issuer, patientId, manifestUrl, symmetricKey, ttl, pin }, privateKey, kid) { - const now = Math.floor(Date.now() / 1000); - - const vhlClaim = new Map([ - ['url', manifestUrl], - ['key', symmetricKey.toString('base64url')], - ['label', 'Resumen de Salud del Paciente'], - ]); - if (pin) { - vhlClaim.set('flag', 'P'); // "P" = PIN required, per SMART Health Links convention - } - - const claims = new Map([ - [1, issuer], - [2, patientId], - [6, now], - [4, now + ttl], - [-1, vhlClaim], - ]); - - const payloadBstr = cbor.encode(claims); - return createCOSESign1(payloadBstr, privateKey, kid); -} - -/** - * Encrypts a FHIR Bundle (JSON) with AES-256-GCM. - * - * @param {object} bundle - FHIR Bundle resource. - * @returns {{ ciphertext: Buffer, key: Buffer, iv: Buffer, tag: Buffer }} - */ -function encryptBundle(bundle) { - const key = crypto.randomBytes(32); // 256-bit key - const iv = crypto.randomBytes(12); // 96-bit GCM nonce (recommended size) - - const plaintext = Buffer.from(JSON.stringify(bundle), 'utf8'); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); - const tag = cipher.getAuthTag(); // 128-bit authentication tag - - return { ciphertext, key, iv, tag }; -} - -/** - * Decrypts an AES-256-GCM encrypted FHIR Bundle. - * - * @param {{ ciphertext: Buffer, key: Buffer, iv: Buffer, tag: Buffer }} enc - * @returns {object} Parsed FHIR Bundle. - */ -function decryptBundle({ ciphertext, key, iv, tag }) { - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - return JSON.parse(plaintext.toString('utf8')); -} - -/** - * Serializes an encrypted document to a single Buffer for storage and transport. - * - * Wire format: [ 12 bytes IV ][ N bytes ciphertext ][ 16 bytes GCM tag ] - * - * The decrypting side (NodoDominio) reconstructs iv/ciphertext/tag from - * this layout using the symmetric key extracted from the CWT. - * - * @param {{ ciphertext: Buffer, iv: Buffer, tag: Buffer }} enc - * @returns {Buffer} - */ -function serializeEncrypted({ ciphertext, iv, tag }) { - return Buffer.concat([iv, ciphertext, tag]); -} - -/** - * Deserializes a Buffer produced by serializeEncrypted. - * - * @param {Buffer} buf - * @returns {{ iv: Buffer, ciphertext: Buffer, tag: Buffer }} - */ -function deserializeEncrypted(buf) { - if (buf.length < 28) { - throw new Error('vhlCrypto: encrypted payload too short'); - } - const iv = buf.slice(0, 12); - const tag = buf.slice(buf.length - 16); - const ciphertext = buf.slice(12, buf.length - 16); - return { iv, ciphertext, tag }; -} - -module.exports = { - buildVHLCWT, - encryptBundle, - decryptBundle, - serializeEncrypted, - deserializeEncrypted, -}; diff --git a/bus-gateway/utils/vhlKeys.js b/bus-gateway/utils/vhlKeys.js deleted file mode 100644 index 80e249e..0000000 --- a/bus-gateway/utils/vhlKeys.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const crypto = require('crypto'); - -let _privateKey = null; -let _publicKey = null; -let _kid = null; - -/** - * Lazy-loads or generates the EC P-256 key pair used for signing VHL CWTs. - * - * Key resolution order: - * 1. PEM file at VHL_PRIVATE_KEY_FILE (if set and exists). - * 2. Ephemeral key pair generated at startup (logged as warning). - * - * The kid (Key ID) is derived as the first 8 bytes of SHA-256 over the - * DER-encoded public key, encoded as base64url. This matches the GDHCN - * convention for referencing public keys in the trust registry. - */ -function loadKeys() { - if (_privateKey) return; - - const keyFile = process.env.VHL_PRIVATE_KEY_FILE; - if (keyFile) { - if (fs.existsSync(keyFile)) { - const pem = fs.readFileSync(keyFile); - _privateKey = crypto.createPrivateKey({ key: pem, format: 'pem' }); - _publicKey = crypto.createPublicKey(_privateKey); - } else { - console.warn(`[VHL] VHL_PRIVATE_KEY_FILE not found: ${keyFile}. Generating ephemeral key pair.`); - } - } else { - console.warn('[VHL] VHL_PRIVATE_KEY_FILE not set. Generating ephemeral key pair (lost on restart).'); - } - - if (!_privateKey) { - const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); - _privateKey = privateKey; - _publicKey = publicKey; - } - - // kid = base64url(SHA-256(DER public key)[0..7]) - const pubDer = _publicKey.export({ type: 'spki', format: 'der' }); - _kid = crypto.createHash('sha256').update(pubDer).digest().slice(0, 8).toString('base64url'); -} - -/** Returns the EC P-256 private KeyObject for signing. */ -function getPrivateKey() { - loadKeys(); - return _privateKey; -} - -/** Returns the EC P-256 public KeyObject for verification. */ -function getPublicKey() { - loadKeys(); - return _publicKey; -} - -/** - * Returns the Key ID (kid) for use in CWT protected headers. - * Derived from the public key fingerprint so verifiers can look up the key - * in the GDHCN trust registry. - */ -function getKid() { - loadKeys(); - return _kid; -} - -/** - * Returns the public key in JWK format for publishing to a trust registry. - * @returns {object} JWK representation of the public key. - */ -function getPublicJWK() { - loadKeys(); - return _publicKey.export({ format: 'jwk' }); -} - -module.exports = { getPrivateKey, getPublicKey, getKid, getPublicJWK }; diff --git a/bus-gateway/utils/vhlStorage.js b/bus-gateway/utils/vhlStorage.js deleted file mode 100644 index 492d1c1..0000000 --- a/bus-gateway/utils/vhlStorage.js +++ /dev/null @@ -1,141 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); - -/** - * In-memory store for VHL manifests and encrypted documents. - * - * Each entry has a TTL; expired entries are cleaned up lazily. - * - * WARNING: state is lost on process restart. Replace with Redis or a - * persistent store for production deployments. - */ - -const manifests = new Map(); // manifestId → { manifest, pinHash: string|null, expiresAt: number } -const documents = new Map(); // documentId → { payload: Buffer, expiresAt: number } - -/** Generates a cryptographically random URL-safe ID (22 chars). */ -function generateId() { - return crypto.randomBytes(16).toString('base64url'); -} - -/** - * Derives a SHA-256 hash of a PIN. - * Stored instead of the plaintext PIN. - * @param {string} pin - * @returns {string} base64url hash - */ -function hashPin(pin) { - return crypto.createHash('sha256').update(String(pin)).digest('base64url'); -} - -// --- Documents --- - -/** - * Stores a serialized encrypted document and returns its ID. - * @param {Buffer} payload - Output of vhlCrypto.serializeEncrypted(). - * @param {number} ttlSeconds - * @returns {string} documentId - */ -function storeDocument(payload, ttlSeconds) { - const id = generateId(); - documents.set(id, { - payload, - expiresAt: Date.now() + ttlSeconds * 1000, - }); - scheduleCleanup(); - return id; -} - -/** - * Retrieves an encrypted document by ID. - * Returns null if not found or expired. - * @param {string} id - * @returns {Buffer|null} - */ -function getDocument(id) { - const entry = documents.get(id); - if (!entry) return null; - if (Date.now() > entry.expiresAt) { - documents.delete(id); - return null; - } - return entry.payload; -} - -// --- Manifests --- - -/** - * Stores a manifest and returns its ID. - * @param {object} manifest - Plain manifest object (e.g. { files: [...] }). - * @param {number} ttlSeconds - * @param {string} [pin] - If provided, access requires this PIN. - * @returns {string} manifestId - */ -function storeManifest(manifest, ttlSeconds, pin) { - const id = generateId(); - manifests.set(id, { - manifest, - pinHash: pin ? hashPin(pin) : null, - expiresAt: Date.now() + ttlSeconds * 1000, - }); - scheduleCleanup(); - return id; -} - -/** - * Retrieves a manifest by ID, optionally validating a PIN. - * - * @param {string} id - * @param {string} [pin] - Required when the manifest was stored with a PIN. - * @returns {object|null} The manifest, or null if not found / expired. - * @throws {Error} err.status = 401 if PIN is required but not provided. - * @throws {Error} err.status = 403 if PIN is incorrect. - */ -function getManifest(id, pin) { - const entry = manifests.get(id); - if (!entry) return null; - if (Date.now() > entry.expiresAt) { - manifests.delete(id); - return null; - } - if (entry.pinHash) { - if (!pin) { - const err = new Error('PIN requerido para acceder a este manifest'); - err.status = 401; - throw err; - } - if (hashPin(pin) !== entry.pinHash) { - const err = new Error('PIN incorrecto'); - err.status = 403; - throw err; - } - } - return entry.manifest; -} - -// --- Cleanup --- - -let _cleanupTimer = null; - -/** - * Schedules a one-shot cleanup run 60 seconds from now (if not already scheduled). - * Removes all entries whose TTL has expired. - */ -function scheduleCleanup() { - if (_cleanupTimer) return; - _cleanupTimer = setTimeout(() => { - _cleanupTimer = null; - const now = Date.now(); - for (const [id, entry] of manifests) { - if (now > entry.expiresAt) manifests.delete(id); - } - for (const [id, entry] of documents) { - if (now > entry.expiresAt) documents.delete(id); - } - }, 60_000); - // Allow the process to exit even if cleanup hasn't fired - if (_cleanupTimer.unref) _cleanupTimer.unref(); -} - -module.exports = { storeDocument, getDocument, storeManifest, getManifest }; diff --git a/nginx/http.conf b/nginx/http.conf index b142c47..58c46ff 100644 --- a/nginx/http.conf +++ b/nginx/http.conf @@ -56,15 +56,6 @@ http { proxy_read_timeout 90s; } - location /vhl/ { - proxy_pass http://bus_gateway; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 90s; - } - # Resto de /fhir/* va a hapi-fhir location /fhir/ { proxy_pass http://hapi_fhir; diff --git a/nginx/https.conf b/nginx/https.conf index d1d137e..4b1f661 100644 --- a/nginx/https.conf +++ b/nginx/https.conf @@ -72,15 +72,6 @@ http { proxy_read_timeout 90s; } - location /vhl/ { - proxy_pass http://bus_gateway; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 90s; - } - # Resto de /fhir/* va a hapi-fhir location /fhir/ { proxy_pass http://hapi_fhir;