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