Eliminados serviciso de VHL redundantes

This commit is contained in:
Alejandro Gomez Auad 2026-04-30 20:48:40 +00:00
parent 894e3b9f79
commit ca4db597cc
18 changed files with 3 additions and 927 deletions

View File

@ -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)
# =============================================================================

View File

@ -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

View File

@ -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

View File

@ -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';

View File

@ -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',
};

View File

@ -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": "<base64url COSE_Sign1>" }
*
* 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 };

View File

@ -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 };

View File

@ -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

View File

@ -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.

View File

@ -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 23)
router.post('/:patientId', issueVHLController);
module.exports = router;

View File

@ -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<object>} 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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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;

View File

@ -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;