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 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-25 00:44:04 +00:00
parent 787aac9a4c
commit a64aa11548
4 changed files with 419 additions and 0 deletions

View File

@@ -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"

View File

@@ -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<FielStatus> {
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<boolean> {
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<boolean> {
const status = await getFielStatus(tenantId);
return status.configured && !status.isExpired;
}

View File

@@ -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 };
}