From 8212f733ab292d50720bb342671261d83c77acf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Wed, 6 May 2026 20:10:20 +0200 Subject: [PATCH] feat: implement AES-256-GCM encryption for SMTP passwords with environment-based key support --- docker-compose.yml | 1 + .../database/entities/user-settings.entity.ts | 2 +- .../user-settings/user-settings.service.ts | 48 +++++++++++++++++-- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5762f79..a5ddcb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - SMTP_USER=${SMTP_USER:-} - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-paperless@localhost} + - SMTP_ENCRYPTION_KEY=${SMTP_ENCRYPTION_KEY:-} - POSTPROCESSING_ERROR_TAG=${POSTPROCESSING_ERROR_TAG:-0} - MANUELL_BEARBEITEN_TAG=${MANUELL_BEARBEITEN_TAG:-6} - IMAP_HOST=${IMAP_HOST:-} diff --git a/paperless-backend/src/database/entities/user-settings.entity.ts b/paperless-backend/src/database/entities/user-settings.entity.ts index 2359758..06c4987 100644 --- a/paperless-backend/src/database/entities/user-settings.entity.ts +++ b/paperless-backend/src/database/entities/user-settings.entity.ts @@ -17,7 +17,7 @@ export class UserSettings { @Column({ type: 'varchar', length: 255, nullable: true }) SmtpUser!: string | null; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: 'varchar', length: 512, nullable: true }) SmtpPass!: string | null; @Column({ type: 'varchar', length: 255, nullable: true }) diff --git a/paperless-backend/src/user-settings/user-settings.service.ts b/paperless-backend/src/user-settings/user-settings.service.ts index 3f5800e..21a8cf0 100644 --- a/paperless-backend/src/user-settings/user-settings.service.ts +++ b/paperless-backend/src/user-settings/user-settings.service.ts @@ -1,9 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; import { Repository } from 'typeorm'; import * as nodemailer from 'nodemailer'; +import * as crypto from 'crypto'; import { UserSettings } from '../database/entities/user-settings.entity'; +const ALGORITHM = 'aes-256-gcm'; + export interface UserSettingsDto { smtpHost: string | null; smtpPort: number | null; @@ -16,10 +20,46 @@ export interface UserSettingsDto { @Injectable() export class UserSettingsService { + private readonly logger = new Logger(UserSettingsService.name); + private readonly encKey: Buffer | null; + constructor( @InjectRepository(UserSettings) private readonly repo: Repository, - ) {} + private readonly configService: ConfigService, + ) { + const raw = this.configService.get('SMTP_ENCRYPTION_KEY', ''); + if (raw && raw.length === 64) { + this.encKey = Buffer.from(raw, 'hex'); + } else { + this.encKey = null; + this.logger.warn( + 'SMTP_ENCRYPTION_KEY fehlt oder hat falsche Länge (64 Hex-Zeichen = 32 Bytes erforderlich). Passwörter werden im Klartext gespeichert.', + ); + } + } + + private encrypt(plaintext: string): string { + if (!this.encKey) return plaintext; + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, this.encKey, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; + } + + private decrypt(stored: string): string { + if (!this.encKey || !stored.startsWith('enc:')) return stored; + const parts = stored.split(':'); + if (parts.length !== 4) return stored; + const [, ivHex, authTagHex, encryptedHex] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const encrypted = Buffer.from(encryptedHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, this.encKey, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } async getSettings(userId: string): Promise { const entity = await this.repo.findOne({ where: { UserId: userId } }); @@ -48,7 +88,7 @@ export class UserSettingsService { if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure; if (data.smtpUser !== undefined) entity.SmtpUser = data.smtpUser; if (data.smtpPass !== undefined && data.smtpPass !== null && data.smtpPass !== '') { - entity.SmtpPass = data.smtpPass; + entity.SmtpPass = this.encrypt(data.smtpPass); } if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom; if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml; @@ -88,7 +128,7 @@ export class UserSettingsService { port: entity.SmtpPort ?? 587, secure: entity.SmtpSecure, user: entity.SmtpUser ?? '', - pass: entity.SmtpPass, + pass: this.decrypt(entity.SmtpPass), from: entity.SmtpFrom ?? entity.SmtpUser ?? '', }; }