mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Encrypt SMTP password at rest with AES-256-GCM
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ compose.lock
|
|||||||
### Test databases ###
|
### Test databases ###
|
||||||
app/storage/test.db
|
app/storage/test.db
|
||||||
*.db
|
*.db
|
||||||
|
app/.env
|
||||||
|
|
||||||
### Logs ###
|
### Logs ###
|
||||||
error.log
|
error.log
|
||||||
|
|||||||
14
TODO.md
14
TODO.md
@@ -57,17 +57,3 @@
|
|||||||
- [x] All `$required = true` callers in `form.php`, `fieldset-tfe-info.php`, `fieldset-academic.php`, `fieldset-licence-explanation.php`, `fieldset-files.php` changed to `!$adminMode`
|
- [x] All `$required = true` callers in `form.php`, `fieldset-tfe-info.php`, `fieldset-academic.php`, `fieldset-licence-explanation.php`, `fieldset-files.php` changed to `!$adminMode`
|
||||||
- [x] Hardcoded `required` HTML attributes in `fieldset-tfe-info.php` (synopsis, objet radios), `fieldset-licence-explanation.php` (access type radios), `jury-fieldset.php` (promoteur, lecteurs interne/externe) gated on `!$adminMode`
|
- [x] Hardcoded `required` HTML attributes in `fieldset-tfe-info.php` (synopsis, objet radios), `fieldset-licence-explanation.php` (access type radios), `jury-fieldset.php` (promoteur, lecteurs interne/externe) gated on `!$adminMode`
|
||||||
- [x] Dynamic JS `ulbInput.required` in jury fieldset also gated
|
- [x] Dynamic JS `ulbInput.required` in jury fieldset also gated
|
||||||
- [x] Remove server-side validation for orientation, ap, finality, licence, jury roles in `ThesisEditController::save()` — admins can save partial records
|
|
||||||
- [x] Same for `ThesisCreateController::submit()`: added `$adminMode` param, pass `true` from `admin/actions/formulaire.php`
|
|
||||||
|
|
||||||
- [x] Encrypt SMTP password at rest (AES-256-GCM)
|
|
||||||
- [x] `app/.env` — holds `APP_KEY` (base64, 32 bytes); added to `.gitignore`
|
|
||||||
- [x] `src/Crypto.php` — `encrypt()` / `decrypt()` / `isEncrypted()` via OpenSSL AES-256-GCM
|
|
||||||
- [x] `SmtpRelay::getSettings()` — decrypts password after DB fetch
|
|
||||||
- [x] `SmtpRelay::updateSettings()` — encrypts password before DB write
|
|
||||||
- [x] `parametres.php` template — password field no longer pre-filled (ciphertext never sent to browser)
|
|
||||||
- [x] Migration `018_encrypt_smtp_password.php` — encrypted existing plaintext in DB; moved to applied/
|
|
||||||
- [x] `justfile` — `deploy` calls `deploy-env` (uploads `.env` only if remote doesn't exist yet)
|
|
||||||
- [x] `justfile` — `deploy-env` recipe: safe upload with guards
|
|
||||||
- [x] `justfile` — `reencrypt-password` recipe: rotates APP_KEY on remote DB
|
|
||||||
- [x] `scripts/reencrypt-smtp-password.php` — decrypts with old key, re-encrypts with new key, updates `.env`
|
|
||||||
|
|||||||
48
app/migrations/applied/018_encrypt_smtp_password.php
Normal file
48
app/migrations/applied/018_encrypt_smtp_password.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migration 018 — encrypt the existing plaintext SMTP password at rest.
|
||||||
|
*
|
||||||
|
* Usage: php app/migrations/pending/018_encrypt_smtp_password.php [DB_PATH]
|
||||||
|
*
|
||||||
|
* Reads APP_KEY from app/.env, encrypts the current smtp_settings.password
|
||||||
|
* using AES-256-GCM, and writes it back.
|
||||||
|
* Safe to re-run: Crypto::isEncrypted() is checked before encrypting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = dirname(__DIR__, 2); // app/
|
||||||
|
$dbPath = $argv[1] ?? ($root . '/storage/xamxam.db');
|
||||||
|
|
||||||
|
if (!file_exists($dbPath)) {
|
||||||
|
die("Database not found: $dbPath\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
define('APP_ROOT', $root);
|
||||||
|
require_once $root . '/src/Crypto.php';
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $dbPath);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$row = $pdo->query("SELECT password FROM smtp_settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
echo "No smtp_settings row found — nothing to do.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $row['password'];
|
||||||
|
|
||||||
|
if (Crypto::isEncrypted($current)) {
|
||||||
|
echo "Password already encrypted — nothing to do.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($current === '') {
|
||||||
|
echo "Password is empty — nothing to do.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted = Crypto::encrypt($current);
|
||||||
|
$pdo->prepare("UPDATE smtp_settings SET password = ? WHERE id = 1")->execute([$encrypted]);
|
||||||
|
|
||||||
|
echo "SMTP password encrypted successfully.\n";
|
||||||
127
app/src/Crypto.php
Normal file
127
app/src/Crypto.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,11 @@ class SmtpRelay
|
|||||||
);
|
);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
require_once __DIR__ . '/Crypto.php';
|
||||||
|
$row['password'] = Crypto::decrypt($row['password']);
|
||||||
|
}
|
||||||
|
|
||||||
return $row ?: [
|
return $row ?: [
|
||||||
'host' => '',
|
'host' => '',
|
||||||
'port' => 587,
|
'port' => 587,
|
||||||
@@ -124,12 +129,13 @@ class SmtpRelay
|
|||||||
WHERE id = 1'
|
WHERE id = 1'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/Crypto.php';
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
':host' => trim($merged['host']),
|
':host' => trim($merged['host']),
|
||||||
':port' => $port,
|
':port' => $port,
|
||||||
':encryption' => $encryption,
|
':encryption' => $encryption,
|
||||||
':username' => trim($merged['username']),
|
':username' => trim($merged['username']),
|
||||||
':password' => $merged['password'],
|
':password' => Crypto::encrypt($merged['password']),
|
||||||
':from_email' => trim($merged['from_email']),
|
':from_email' => trim($merged['from_email']),
|
||||||
':from_name' => trim($merged['from_name']),
|
':from_name' => trim($merged['from_name']),
|
||||||
':notify_email' => trim($merged['notify_email'] ?? ''),
|
':notify_email' => trim($merged['notify_email'] ?? ''),
|
||||||
|
|||||||
@@ -254,7 +254,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="smtp_password">Mot de passe</label>
|
<label for="smtp_password">Mot de passe</label>
|
||||||
<input type="password" id="smtp_password" name="smtp_password"
|
<input type="password" id="smtp_password" name="smtp_password"
|
||||||
value="<?= htmlspecialchars($smtpSettings['password']) ?>"
|
value=""
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="Laissez vide pour ne pas modifier"
|
placeholder="Laissez vide pour ne pas modifier"
|
||||||
<?= $smtpFieldErr('smtp_password') ?>
|
<?= $smtpFieldErr('smtp_password') ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user