Encrypt SMTP password at rest with AES-256-GCM

This commit is contained in:
Pontoporeia
2026-05-08 12:48:27 +02:00
parent 95fcbc919a
commit 7e35bba530
6 changed files with 184 additions and 16 deletions

127
app/src/Crypto.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
/**
* Symmetric encryption helper using AES-256-GCM (OpenSSL).
*
* Key is read from APP_KEY in app/.env (base64-encoded 32 bytes).
* Ciphertext format stored in the DB: base64( iv [12 bytes] | tag [16 bytes] | ciphertext )
*/
class Crypto
{
private const CIPHER = 'aes-256-gcm';
private const IV_LEN = 12;
private const TAG_LEN = 16;
private static ?string $key = null;
// ── Key loading ────────────────────────────────────────────────────────────
/**
* Returns the raw 32-byte key, loading it from .env on first call.
*/
private static function key(): string
{
if (self::$key !== null) {
return self::$key;
}
$envFile = defined('APP_ROOT') ? APP_ROOT . '/.env' : __DIR__ . '/../.env';
if (!file_exists($envFile)) {
throw new RuntimeException('APP_KEY not found: .env file missing at ' . $envFile);
}
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), 'APP_KEY=')) {
$b64 = trim(substr($line, strlen('APP_KEY=')));
$raw = base64_decode($b64, strict: true);
if ($raw === false || strlen($raw) !== 32) {
throw new RuntimeException('APP_KEY must be a base64-encoded 32-byte value.');
}
self::$key = $raw;
return self::$key;
}
}
throw new RuntimeException('APP_KEY not found in .env');
}
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Encrypt a plaintext string. Returns a base64-encoded blob safe to store in the DB.
*/
public static function encrypt(string $plaintext): string
{
$iv = random_bytes(self::IV_LEN);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
self::key(),
OPENSSL_RAW_DATA,
$iv,
$tag,
'',
self::TAG_LEN,
);
if ($ciphertext === false) {
throw new RuntimeException('Encryption failed: ' . openssl_error_string());
}
return base64_encode($iv . $tag . $ciphertext);
}
/**
* Decrypt a blob produced by encrypt(). Returns the original plaintext.
* Returns '' and logs a warning if the blob is invalid or authentication fails.
*/
public static function decrypt(string $blob): string
{
if ($blob === '') {
return '';
}
$raw = base64_decode($blob, strict: true);
if ($raw === false || strlen($raw) < self::IV_LEN + self::TAG_LEN + 1) {
// Likely a legacy plaintext value — return as-is so existing installs
// don't hard-break before the migration has run.
return $blob;
}
$iv = substr($raw, 0, self::IV_LEN);
$tag = substr($raw, self::IV_LEN, self::TAG_LEN);
$ciphertext = substr($raw, self::IV_LEN + self::TAG_LEN);
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER,
self::key(),
OPENSSL_RAW_DATA,
$iv,
$tag,
);
if ($plaintext === false) {
error_log('Crypto::decrypt — authentication tag mismatch; returning empty string.');
return '';
}
return $plaintext;
}
/**
* Returns true if a value looks like it was produced by encrypt()
* (as opposed to a legacy plaintext password).
*/
public static function isEncrypted(string $value): bool
{
if ($value === '') {
return false;
}
$raw = base64_decode($value, strict: true);
return $raw !== false && strlen($raw) >= self::IV_LEN + self::TAG_LEN + 1;
}
}

View File

@@ -72,6 +72,11 @@ class SmtpRelay
);
$row = $stmt->fetch();
if ($row) {
require_once __DIR__ . '/Crypto.php';
$row['password'] = Crypto::decrypt($row['password']);
}
return $row ?: [
'host' => '',
'port' => 587,
@@ -124,12 +129,13 @@ class SmtpRelay
WHERE id = 1'
);
require_once __DIR__ . '/Crypto.php';
$stmt->execute([
':host' => trim($merged['host']),
':port' => $port,
':encryption' => $encryption,
':username' => trim($merged['username']),
':password' => $merged['password'],
':password' => Crypto::encrypt($merged['password']),
':from_email' => trim($merged['from_email']),
':from_name' => trim($merged['from_name']),
':notify_email' => trim($merged['notify_email'] ?? ''),