From a64aa115485f2eadbb50f5c30c05a78131a5da76 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 25 Jan 2026 00:44:04 +0000 Subject: [PATCH] feat(sat): add FIEL management and encryption services (Phase 2) - Add sat-crypto.service.ts with AES-256-GCM encryption for secure credential storage using JWT_SECRET as key derivation source - Add fiel.service.ts with complete FIEL lifecycle management: - Upload and validate FIEL credentials (.cer/.key files) - Verify certificate is FIEL (not CSD) and not expired - Store encrypted credentials in database - Retrieve and decrypt credentials for SAT sync operations - Install @nodecfdi/credentials for FIEL/CSD handling Co-Authored-By: Claude Opus 4.5 --- apps/api/package.json | 3 + apps/api/src/services/fiel.service.ts | 232 ++++++++++++++++++ .../src/services/sat/sat-crypto.service.ts | 122 +++++++++ pnpm-lock.yaml | 62 +++++ 4 files changed, 419 insertions(+) create mode 100644 apps/api/src/services/fiel.service.ts create mode 100644 apps/api/src/services/sat/sat-crypto.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 68c4f0b..e657cf6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@horux/shared": "workspace:*", + "@nodecfdi/credentials": "^3.2.0", "@prisma/client": "^5.22.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", @@ -23,6 +24,7 @@ "express": "^4.21.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", + "node-forge": "^1.3.3", "zod": "^3.23.0" }, "devDependencies": { @@ -31,6 +33,7 @@ "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.0.0", + "@types/node-forge": "^1.3.14", "prisma": "^5.22.0", "tsx": "^4.19.0", "typescript": "^5.3.0" diff --git a/apps/api/src/services/fiel.service.ts b/apps/api/src/services/fiel.service.ts new file mode 100644 index 0000000..180ff51 --- /dev/null +++ b/apps/api/src/services/fiel.service.ts @@ -0,0 +1,232 @@ +import { Credential } from '@nodecfdi/credentials/node'; +import { prisma } from '../config/database.js'; +import { encrypt, decrypt } from './sat/sat-crypto.service.js'; +import type { FielStatus } from '@horux/shared'; + +/** + * Sube y valida credenciales FIEL + */ +export async function uploadFiel( + tenantId: string, + cerBase64: string, + keyBase64: string, + password: string +): Promise<{ success: boolean; message: string; status?: FielStatus }> { + try { + // Decodificar archivos de Base64 + const cerData = Buffer.from(cerBase64, 'base64'); + const keyData = Buffer.from(keyBase64, 'base64'); + + // Validar que los archivos sean válidos y coincidan + let credential: Credential; + try { + credential = Credential.create( + cerData.toString('binary'), + keyData.toString('binary'), + password + ); + } catch (error: any) { + return { + success: false, + message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta', + }; + } + + // Verificar que sea una FIEL (no CSD) + if (!credential.isFiel()) { + return { + success: false, + message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.', + }; + } + + // Obtener información del certificado + const certificate = credential.certificate(); + const rfc = certificate.rfc(); + const serialNumber = certificate.serialNumber().bytes(); + const validFrom = certificate.validFromDateTime(); + const validUntil = certificate.validToDateTime(); + + // Verificar que no esté vencida + if (new Date() > validUntil) { + return { + success: false, + message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString(), + }; + } + + // Encriptar credenciales + const { encrypted: encryptedCer, iv, tag } = encrypt(cerData); + const { encrypted: encryptedKey } = encrypt(keyData); + const { encrypted: encryptedPassword } = encrypt(Buffer.from(password, 'utf-8')); + + // Guardar o actualizar en BD + await prisma.fielCredential.upsert({ + where: { tenantId }, + create: { + tenantId, + rfc, + cerData: encryptedCer, + keyData: encryptedKey, + keyPasswordEncrypted: encryptedPassword, + encryptionIv: iv, + encryptionTag: tag, + serialNumber, + validFrom, + validUntil, + isActive: true, + }, + update: { + rfc, + cerData: encryptedCer, + keyData: encryptedKey, + keyPasswordEncrypted: encryptedPassword, + encryptionIv: iv, + encryptionTag: tag, + serialNumber, + validFrom, + validUntil, + isActive: true, + updatedAt: new Date(), + }, + }); + + const daysUntilExpiration = Math.ceil( + (validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + return { + success: true, + message: 'FIEL configurada correctamente', + status: { + configured: true, + rfc, + serialNumber, + validFrom: validFrom.toISOString(), + validUntil: validUntil.toISOString(), + isExpired: false, + daysUntilExpiration, + }, + }; + } catch (error: any) { + console.error('[FIEL Upload Error]', error); + return { + success: false, + message: error.message || 'Error al procesar la FIEL', + }; + } +} + +/** + * Obtiene el estado de la FIEL de un tenant + */ +export async function getFielStatus(tenantId: string): Promise { + const fiel = await prisma.fielCredential.findUnique({ + where: { tenantId }, + select: { + rfc: true, + serialNumber: true, + validFrom: true, + validUntil: true, + isActive: true, + }, + }); + + if (!fiel || !fiel.isActive) { + return { configured: false }; + } + + const now = new Date(); + const isExpired = now > fiel.validUntil; + const daysUntilExpiration = Math.ceil( + (fiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + + return { + configured: true, + rfc: fiel.rfc, + serialNumber: fiel.serialNumber || undefined, + validFrom: fiel.validFrom.toISOString(), + validUntil: fiel.validUntil.toISOString(), + isExpired, + daysUntilExpiration: isExpired ? 0 : daysUntilExpiration, + }; +} + +/** + * Elimina la FIEL de un tenant + */ +export async function deleteFiel(tenantId: string): Promise { + try { + await prisma.fielCredential.delete({ + where: { tenantId }, + }); + return true; + } catch { + return false; + } +} + +/** + * Obtiene las credenciales desencriptadas para usar en sincronización + * Solo debe usarse internamente por el servicio de SAT + */ +export async function getDecryptedFiel(tenantId: string): Promise<{ + credential: Credential; + rfc: string; +} | null> { + const fiel = await prisma.fielCredential.findUnique({ + where: { tenantId }, + }); + + if (!fiel || !fiel.isActive) { + return null; + } + + // Verificar que no esté vencida + if (new Date() > fiel.validUntil) { + return null; + } + + try { + // Desencriptar + const cerData = decrypt( + Buffer.from(fiel.cerData), + Buffer.from(fiel.encryptionIv), + Buffer.from(fiel.encryptionTag) + ); + const keyData = decrypt( + Buffer.from(fiel.keyData), + Buffer.from(fiel.encryptionIv), + Buffer.from(fiel.encryptionTag) + ); + const password = decrypt( + Buffer.from(fiel.keyPasswordEncrypted), + Buffer.from(fiel.encryptionIv), + Buffer.from(fiel.encryptionTag) + ).toString('utf-8'); + + // Crear credencial + const credential = Credential.create( + cerData.toString('binary'), + keyData.toString('binary'), + password + ); + + return { + credential, + rfc: fiel.rfc, + }; + } catch (error) { + console.error('[FIEL Decrypt Error]', error); + return null; + } +} + +/** + * Verifica si un tenant tiene FIEL configurada y válida + */ +export async function hasFielConfigured(tenantId: string): Promise { + const status = await getFielStatus(tenantId); + return status.configured && !status.isExpired; +} diff --git a/apps/api/src/services/sat/sat-crypto.service.ts b/apps/api/src/services/sat/sat-crypto.service.ts new file mode 100644 index 0000000..3d16868 --- /dev/null +++ b/apps/api/src/services/sat/sat-crypto.service.ts @@ -0,0 +1,122 @@ +import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto'; +import { env } from '../../config/env.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const TAG_LENGTH = 16; + +/** + * Deriva una clave de 256 bits del JWT_SECRET + */ +function deriveKey(): Buffer { + return createHash('sha256').update(env.JWT_SECRET).digest(); +} + +/** + * Encripta datos usando AES-256-GCM + */ +export function encrypt(data: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } { + const iv = randomBytes(IV_LENGTH); + const key = deriveKey(); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { encrypted, iv, tag }; +} + +/** + * Desencripta datos usando AES-256-GCM + */ +export function decrypt(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer { + const key = deriveKey(); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +} + +/** + * Encripta un string y retorna los componentes + */ +export function encryptString(text: string): { encrypted: Buffer; iv: Buffer; tag: Buffer } { + return encrypt(Buffer.from(text, 'utf-8')); +} + +/** + * Desencripta a string + */ +export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): string { + return decrypt(encrypted, iv, tag).toString('utf-8'); +} + +/** + * Encripta credenciales FIEL (cer, key, password) + */ +export function encryptFielCredentials( + cerData: Buffer, + keyData: Buffer, + password: string +): { + encryptedCer: Buffer; + encryptedKey: Buffer; + encryptedPassword: Buffer; + iv: Buffer; + tag: Buffer; +} { + // Usamos el mismo IV y tag para simplificar, concatenando los datos + const combined = Buffer.concat([ + Buffer.from(cerData.length.toString().padStart(10, '0')), + cerData, + Buffer.from(keyData.length.toString().padStart(10, '0')), + keyData, + Buffer.from(password, 'utf-8'), + ]); + + const { encrypted, iv, tag } = encrypt(combined); + + // Extraemos las partes encriptadas + const cerLength = cerData.length; + const keyLength = keyData.length; + const passwordLength = Buffer.from(password, 'utf-8').length; + + return { + encryptedCer: encrypted.subarray(0, 10 + cerLength), + encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength), + encryptedPassword: encrypted.subarray(20 + cerLength + keyLength), + iv, + tag, + }; +} + +/** + * Desencripta credenciales FIEL + */ +export function decryptFielCredentials( + encryptedCer: Buffer, + encryptedKey: Buffer, + encryptedPassword: Buffer, + iv: Buffer, + tag: Buffer +): { + cerData: Buffer; + keyData: Buffer; + password: string; +} { + const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]); + const decrypted = decrypt(combined, iv, tag); + + // Parseamos las partes + const cerLengthStr = decrypted.subarray(0, 10).toString(); + const cerLength = parseInt(cerLengthStr, 10); + const cerData = decrypted.subarray(10, 10 + cerLength); + + const keyLengthStr = decrypted.subarray(10 + cerLength, 20 + cerLength).toString(); + const keyLength = parseInt(keyLengthStr, 10); + const keyData = decrypted.subarray(20 + cerLength, 20 + cerLength + keyLength); + + const password = decrypted.subarray(20 + cerLength + keyLength).toString('utf-8'); + + return { cerData, keyData, password }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8f9732..9a359ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@horux/shared': specifier: workspace:* version: link:../../packages/shared + '@nodecfdi/credentials': + specifier: ^3.2.0 + version: 3.2.0(luxon@3.7.2) '@prisma/client': specifier: ^5.22.0 version: 5.22.0(prisma@5.22.0) @@ -44,6 +47,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 + node-forge: + specifier: ^1.3.3 + version: 1.3.3 zod: specifier: ^3.23.0 version: 3.25.76 @@ -63,6 +69,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.7 + '@types/node-forge': + specifier: ^1.3.14 + version: 1.3.14 prisma: specifier: ^5.22.0 version: 5.22.0 @@ -439,6 +448,20 @@ packages: cpu: [x64] os: [win32] + '@nodecfdi/base-converter@1.0.7': + resolution: {integrity: sha512-YoWtdhCPB86W+2TpXrZ1yXzehNC2sEFCB0vw4XtnHKdtw6pKxKyDT2qQf4TqICROp0IZNNKunFDw3EhcoR41Tw==} + engines: {node: '>=18 <=22 || ^16'} + + '@nodecfdi/credentials@3.2.0': + resolution: {integrity: sha512-knZE8kIrIib27M/tcUQRgvnObMd7oR9EKZTSdBSHXW/5Pw6UB23v0ruUAJSFY0789J3OLfKaIVRXBG2I+q9ZTA==} + engines: {node: '>=18 <=22 || ^16'} + peerDependencies: + '@types/luxon': 3.4.2 + luxon: ^3.5.0 + peerDependenciesMeta: + '@types/luxon': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -989,6 +1012,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-forge@1.3.14': + resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} + '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} @@ -1018,6 +1044,10 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@vilic/node-forge@1.3.2-5': + resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==} + engines: {node: '>= 6.13.0'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1663,6 +1693,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1749,6 +1783,10 @@ packages: sass: optional: true + node-forge@1.3.3: + resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} + engines: {node: '>= 6.13.0'} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2140,6 +2178,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2444,6 +2485,15 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@nodecfdi/base-converter@1.0.7': {} + + '@nodecfdi/credentials@3.2.0(luxon@3.7.2)': + dependencies: + '@nodecfdi/base-converter': 1.0.7 + '@vilic/node-forge': 1.3.2-5 + luxon: 3.7.2 + ts-mixer: 6.0.4 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2988,6 +3038,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-forge@1.3.14': + dependencies: + '@types/node': 22.19.7 + '@types/node@14.18.63': {} '@types/node@22.19.7': @@ -3018,6 +3072,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.19.7 + '@vilic/node-forge@1.3.2-5': {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -3717,6 +3773,8 @@ snapshots: dependencies: react: 18.3.1 + luxon@3.7.2: {} + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -3793,6 +3851,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-forge@1.3.3: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -4207,6 +4267,8 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-mixer@6.0.4: {} + tslib@2.8.1: {} tsx@4.21.0: