La encriptación se implementa a nivel de base de datos usando la extensión pgcrypto de PostgreSQL. Los datos sensibles se almacenan encriptados en columnas de tipo bytea y se desencriptan automáticamente al consultarlos.
Características Principales
Encriptación transparente: Los datos se encriptan automáticamente antes de guardarse y se desencriptan al consultarlos
A nivel de base de datos: La encriptación/desencriptación ocurre en PostgreSQL, no en la aplicación
Detección automática: El sistema detecta automáticamente qué campos deben encriptarse
Sin cambios en servicios: Los servicios trabajan con datos planos, la encriptación es transparente
Propósito: Convierte entre bytea (Buffer) y string para TypeORM.
Code
export const pgcryptoTransformer: ValueTransformer = { to(value: string | null): string | null { // Retorna el valor tal cual - la encriptación se hace en el subscriber if (value == null) return value return value }, from(value: Buffer | string | null): string | null { // Convierte Buffer a string cuando TypeORM lee de la BD if (value == null) return value if (Buffer.isBuffer(value)) { return value.toString('utf8') } return value.toString() },}
Uso: Se aplica en el decorador @Column de las entidades.
Siempre usar type: 'bytea' para columnas encriptadas
El transformer es necesario para la conversión Buffer ↔ string
El subscriber detectará automáticamente estos campos
Paso 2: Crear la Migración
Crear una migración para:
Habilitar la extensión pgcrypto (si no existe)
Cambiar el tipo de columna a bytea
Encriptar los datos existentes
Ejemplo:
Code
import 'dotenv/config'import { MigrationInterface, QueryRunner } from 'typeorm'export class EncryptMyEntityFields1234567890000 implements MigrationInterface { name = 'EncryptMyEntityFields1234567890000' public async up(queryRunner: QueryRunner): Promise<void> { const key = process.env.DB_ENCRYPTION_KEY if (!key) { throw new Error('DB_ENCRYPTION_KEY is not set') } const escapedKey = key.replace(/'/g, "''") await queryRunner.query(` DO $$ BEGIN -- Habilitar pgcrypto si no existe CREATE EXTENSION IF NOT EXISTS pgcrypto; -- Encriptar campo obligatorio IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'my_entity' AND column_name = 'sensitive_field' AND data_type != 'bytea' ) THEN ALTER TABLE my_entity ALTER COLUMN sensitive_field TYPE bytea USING pgp_sym_encrypt(sensitive_field::text, '${escapedKey}'); END IF; -- Encriptar campo opcional (maneja NULL) IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'my_entity' AND column_name = 'optional_sensitive_field' AND data_type != 'bytea' ) THEN ALTER TABLE my_entity ALTER COLUMN optional_sensitive_field TYPE bytea USING ( CASE WHEN optional_sensitive_field IS NOT NULL THEN pgp_sym_encrypt(optional_sensitive_field::text, '${escapedKey}') ELSE NULL END ); END IF; END $$; `) } public async down(queryRunner: QueryRunner): Promise<void> { const key = process.env.DB_ENCRYPTION_KEY if (!key) { throw new Error('DB_ENCRYPTION_KEY is not set') } const escapedKey = key.replace(/'/g, "''") await queryRunner.query(` DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'my_entity' AND column_name = 'sensitive_field' AND data_type = 'bytea' ) THEN ALTER TABLE my_entity ALTER COLUMN sensitive_field TYPE text USING pgp_sym_decrypt(sensitive_field, '${escapedKey}')::text; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'my_entity' AND column_name = 'optional_sensitive_field' AND data_type = 'bytea' ) THEN ALTER TABLE my_entity ALTER COLUMN optional_sensitive_field TYPE text USING ( CASE WHEN optional_sensitive_field IS NOT NULL THEN pgp_sym_decrypt(optional_sensitive_field, '${escapedKey}')::text ELSE NULL END ); END IF; END $$; `) }}
Notas importantes:
Verificar que DB_ENCRYPTION_KEY esté configurado
Usar IF EXISTS para evitar errores si la migración se ejecuta múltiples veces
Manejar campos NULL correctamente
Si la columna tiene DEFAULT, usar DROP DEFAULT antes de cambiar el tipo
Paso 3: Actualizar el Repository
3.1 Agregar método helper para desencriptar campos
// ✅ Correcto para campos opcionales@Column({ type: 'bytea', nullable: true, transformer: pgcryptoTransformer })optionalField?: string// En la migraciónCASE WHEN optional_field IS NOT NULLTHEN pgp_sym_encrypt(optional_field::text, 'key')ELSE NULL END
3. Desencriptar campos en todos los SELECT
Code
// ✅ Correcto - siempre desencriptar campos en queriesfindAll() { const query = this.createQueryBuilder('user') this.addDecryptedUserFields(query) // ← Importante return query.getMany()}
// ✅ Correctoconst key = process.env.DB_ENCRYPTION_KEYif (!key) { throw new Error('DB_ENCRYPTION_KEY is not set')}
7. No modificar datos encriptados directamente
Code
// ✅ Correcto - trabajar con datos planosuser.email = 'new@example.com'await this.userRepository.save(user)// ❌ Incorrecto - no encriptar manualmenteuser.email = encrypt('new@example.com') // El subscriber lo hace automáticamente
Troubleshooting
Error: "Wrong key or corrupt data"
Causa: Se intenta desencriptar un campo que no está encriptado o está corrupto.
Solución:
Verificar que la migración se ejecutó correctamente
Verificar que DB_ENCRYPTION_KEY es el mismo usado para encriptar
Verificar que no haya datos sin encriptar
Error: "default for column cannot be cast automatically to type bytea"
Causa: La columna tiene un DEFAULT que no puede convertirse a bytea.
Solución: En la migración, usar DROP DEFAULT antes de cambiar el tipo:
Code
ALTER TABLE user ALTER COLUMN email DROP DEFAULT;ALTER TABLE user ALTER COLUMN email TYPE bytea USING ...;
TypeORM genera migraciones incorrectas después de encriptar
Causa: Falta type: 'bytea' en la definición de la entidad.
Solución: Agregar type: 'bytea' al decorador @Column: