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:
232
apps/api/src/services/fiel.service.ts
Normal file
232
apps/api/src/services/fiel.service.ts
Normal 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;
|
||||
}
|
||||
122
apps/api/src/services/sat/sat-crypto.service.ts
Normal file
122
apps/api/src/services/sat/sat-crypto.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user