2026-04-27 04:25:52 +00:00

183 lines
6.0 KiB
JavaScript

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