Eliminados serviciso de VHL redundantes
This commit is contained in:
parent
894e3b9f79
commit
ca4db597cc
18
.env.example
18
.env.example
@ -72,24 +72,6 @@ SIGNATURE_KEY_PATH=./certs/trust-network.key
|
|||||||
# Clave privada para firma de documentos (Document Signing Certificate)
|
# Clave privada para firma de documentos (Document Signing Certificate)
|
||||||
SSL_DCC_KEY_PATH=./certs/signature.key
|
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)
|
# HAPI FHIR (Spring Boot)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
19
README.md
19
README.md
@ -14,8 +14,7 @@ Internet / Red interna
|
|||||||
│ /fhir/IPSDocument → bus-gateway:3000 │
|
│ /fhir/IPSDocument → bus-gateway:3000 │
|
||||||
│ /fhir/DocumentReference→ bus-gateway:3000 │
|
│ /fhir/DocumentReference→ bus-gateway:3000 │
|
||||||
│ /fhir/Patient → bus-gateway:3000 │
|
│ /fhir/Patient → bus-gateway:3000 │
|
||||||
│ /vhl/* → bus-gateway:3000 │
|
│ /gdhcn/* → gdhcn-validator-service │
|
||||||
│ /gdhcn/* → gdhcn-validator │
|
|
||||||
│ /fhir/* (resto) → hapi-fhir:8080 │
|
│ /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-78** | GET | `/fhir/Patient/:id` | Patient Demographics Query (por ID) |
|
||||||
| **ITI-104** | POST | `/fhir/Patient` | Patient Identity Feed (alta) |
|
| **ITI-104** | POST | `/fhir/Patient` | Patient Identity Feed (alta) |
|
||||||
| **ITI-104** | PUT | `/fhir/Patient/:id` | Patient Identity Feed (actualización) |
|
| **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
|
## 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 |
|
| `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) |
|
| `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
|
### HAPI FHIR / PostgreSQL
|
||||||
|
|
||||||
| Variable | Default | Descripción |
|
| Variable | Default | Descripción |
|
||||||
@ -226,10 +213,10 @@ ips-nodo-dominio/
|
|||||||
├── certs/ # Certificados TLS y claves de firma
|
├── certs/ # Certificados TLS y claves de firma
|
||||||
│ └── README.md # Instrucciones para generar certificados de prueba
|
│ └── README.md # Instrucciones para generar certificados de prueba
|
||||||
├── bus-gateway/ # Gateway Node.js/Express
|
├── 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
|
│ ├── routes/ # Definición de rutas Express
|
||||||
│ ├── services/ # Clientes de servicios externos (MPI, Document Registry, FHIR)
|
│ ├── 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
|
│ ├── docs/ # Diagramas de secuencia Mermaid
|
||||||
│ └── tests/ # Suite de pruebas Jest
|
│ └── tests/ # Suite de pruebas Jest
|
||||||
├── json/ # Fixtures y schemas JSON
|
├── json/ # Fixtures y schemas JSON
|
||||||
|
|||||||
@ -21,17 +21,3 @@ LOG_LEVEL=debug
|
|||||||
# Habilita logs de requests/responses salientes al Bus (true | false)
|
# Habilita logs de requests/responses salientes al Bus (true | false)
|
||||||
BUS_DEBUG=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
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ var iti65Router = require('./routes/iti65');
|
|||||||
var iti67Router = require('./routes/iti67');
|
var iti67Router = require('./routes/iti67');
|
||||||
var iti78Router = require('./routes/iti78');
|
var iti78Router = require('./routes/iti78');
|
||||||
var iti104Router = require('./routes/iti104');
|
var iti104Router = require('./routes/iti104');
|
||||||
var vhlRouter = require('./routes/vhl');
|
|
||||||
|
|
||||||
var app = express();
|
var app = express();
|
||||||
|
|
||||||
@ -28,11 +27,6 @@ app.use('/fhir', iti78Router);
|
|||||||
// ITI-104: Patient Identity Feed → POST /fhir/Patient, PUT /fhir/Patient/:id
|
// ITI-104: Patient Identity Feed → POST /fhir/Patient, PUT /fhir/Patient/:id
|
||||||
app.use('/fhir', iti104Router);
|
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
|
// 404
|
||||||
app.use(function (req, res, next) {
|
app.use(function (req, res, next) {
|
||||||
const host = req.get('host') || 'localhost';
|
const host = req.get('host') || 'localhost';
|
||||||
|
|||||||
@ -24,19 +24,6 @@ const config = {
|
|||||||
errorKey: 'err',
|
errorKey: 'err',
|
||||||
nestedKey: null,
|
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',
|
baseURL: process.env.NODO_BASE_URL || 'http://localhost',
|
||||||
debug: process.env.BUS_DEBUG === 'true',
|
debug: process.env.BUS_DEBUG === 'true',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 };
|
|
||||||
@ -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 };
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
|
|
||||||
@ -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;
|
|
||||||
@ -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 };
|
|
||||||
@ -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 };
|
|
||||||
@ -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 };
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
@ -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 };
|
|
||||||
@ -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 };
|
|
||||||
@ -56,15 +56,6 @@ http {
|
|||||||
proxy_read_timeout 90s;
|
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
|
# Resto de /fhir/* va a hapi-fhir
|
||||||
location /fhir/ {
|
location /fhir/ {
|
||||||
proxy_pass http://hapi_fhir;
|
proxy_pass http://hapi_fhir;
|
||||||
|
|||||||
@ -72,15 +72,6 @@ http {
|
|||||||
proxy_read_timeout 90s;
|
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
|
# Resto de /fhir/* va a hapi-fhir
|
||||||
location /fhir/ {
|
location /fhir/ {
|
||||||
proxy_pass http://hapi_fhir;
|
proxy_pass http://hapi_fhir;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user