import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' // AES-256-GCM at-rest encryption for Stalwart host credentials (app passwords). // The key comes from SCHEDULING_CREDENTIAL_KEY (64 hex chars = 32 bytes); in // production this is sourced from KMS/sealed-secrets. We store ciphertext + iv + // authTag separately (all base64) so the GCM auth tag is verified on every open — // a tampered ciphertext throws rather than returning garbage. Secrets are NEVER // logged: this module deals only in opaque buffers. const ALGO = 'aes-256-gcm' const IV_BYTES = 12 // GCM standard nonce length export interface SealedSecret { encryptedSecret: string // base64 ciphertext iv: string // base64 nonce authTag: string // base64 GCM tag } @Injectable() export class CredentialCipher { private readonly key: Buffer constructor(config: ConfigService) { const hex = config.get('SCHEDULING_CREDENTIAL_KEY') ?? '' const key = Buffer.from(hex, 'hex') if (key.length !== 32) { throw new Error( 'SCHEDULING_CREDENTIAL_KEY must be 32 bytes (64 hex chars). Generate with: openssl rand -hex 32', ) } this.key = key } seal(plaintext: string): SealedSecret { const iv = randomBytes(IV_BYTES) const cipher = createCipheriv(ALGO, this.key, iv) const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) return { encryptedSecret: enc.toString('base64'), iv: iv.toString('base64'), authTag: cipher.getAuthTag().toString('base64'), } } open(sealed: SealedSecret): string { const decipher = createDecipheriv(ALGO, this.key, Buffer.from(sealed.iv, 'base64')) decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64')) const dec = Buffer.concat([ decipher.update(Buffer.from(sealed.encryptedSecret, 'base64')), decipher.final(), ]) return dec.toString('utf8') } }