183 lines
6.0 KiB
JavaScript
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,
|
|
};
|