import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto"; const ALGORITHM = "aes-256-gcm"; const ENCRYPTED_PREFIX = "enc:"; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; const DERIVED_KEY_LENGTH = 32; const HKDF_SALT = "mosaic.crypto.v1"; const HKDF_INFO = "mosaic-db-secret-encryption"; @Injectable() export class CryptoService { private readonly key: Buffer; constructor(private readonly config: ConfigService) { const secret = this.config.get("MOSAIC_SECRET_KEY"); if (!secret) { throw new Error("MOSAIC_SECRET_KEY environment variable is required"); } if (secret.length < 32) { throw new Error("MOSAIC_SECRET_KEY must be at least 32 characters"); } this.key = Buffer.from( hkdfSync( "sha256", Buffer.from(secret, "utf8"), Buffer.from(HKDF_SALT, "utf8"), Buffer.from(HKDF_INFO, "utf8"), DERIVED_KEY_LENGTH ) ); } encrypt(plaintext: string): string { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, this.key, iv); const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); const payload = Buffer.concat([iv, ciphertext, authTag]).toString("base64"); return `${ENCRYPTED_PREFIX}${payload}`; } decrypt(encrypted: string): string { if (!this.isEncrypted(encrypted)) { throw new Error("Value is not encrypted"); } const payloadBase64 = encrypted.slice(ENCRYPTED_PREFIX.length); try { const payload = Buffer.from(payloadBase64, "base64"); if (payload.length < IV_LENGTH + AUTH_TAG_LENGTH) { throw new Error("Encrypted payload is too short"); } const iv = payload.subarray(0, IV_LENGTH); const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH); const ciphertext = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH); const decipher = createDecipheriv(ALGORITHM, this.key, iv); decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); } catch { throw new Error("Failed to decrypt value"); } } isEncrypted(value: string): boolean { return value.startsWith(ENCRYPTED_PREFIX); } generateToken(): string { return randomBytes(32).toString("hex"); } }