diff --git a/.projektstructure.txt b/.projektstructure.txt index 41570cc..d9b97f1 100755 --- a/.projektstructure.txt +++ b/.projektstructure.txt @@ -16,9 +16,11 @@ Aktueller Projektstand - Kinder koennen im Mitgliederbereich zusaetzlich nachtraeglich bearbeitet und geloescht werden. - Wenn bei einem Kind ein Geburtsdatum hinterlegt ist, wird das Alter automatisch daraus berechnet und spaeter bei Bedarf jaehrlich aktualisiert; ohne Geburtsdatum bleibt das manuell gepflegte Alter massgeblich. - Profilbezogene sensible Angaben wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; vorhandene Klartextwerte werden bei der Nutzung schrittweise nachmigriert. +- Auch die Konto-E-Mail wird jetzt app-seitig verschluesselt gespeichert; fuer Login, Verifizierung, Passwort-Reset und Dublettenpruefung dient nur ein separater HMAC-Lookup-Hash. - Der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist auf der Datenschutz-Seite jetzt zusaetzlich deutlich hervorgehoben. - Das Profil besitzt jetzt zusaetzlich eine vollstaendige Adresse mit verschluesselter Strasse/Hausnummer sowie eine optionale Adress-Lokalisierung ueber die vorhandenen externen Karten-/Geocoding-Dienste. - Die Profiladresse unterstuetzt jetzt ausserdem eine Auswahl moeglicher Adress-Treffer, die Uebernahme aus dem Browser-Standort und einen kurzen Hinweisdialog zur lokalen Relevanz. +- Fuer Umkreis- und Distanzfunktionen bleiben nur `lat`/`lng` technisch im Klartext; die eigentliche Strasse/Hausnummer ist verschluesselt. - Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt. - Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden. diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 974b654..18b4f99 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -36,6 +36,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - Kinder koennen im Mitgliederbereich angelegt, nachtraeglich bearbeitet und wieder geloescht werden. - Wenn ein Geburtsdatum gesetzt ist, wird das Alter im Mitgliederbereich automatisch berechnet und spaeter bei Bedarf anhand des Datums aktualisiert; ohne Geburtsdatum gilt der manuelle Alterswert. - Sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden bei Nutzung schrittweise in den verschluesselten Zustand ueberfuehrt. +- Die Konto-E-Mail wird ebenfalls app-seitig verschluesselt gespeichert; fuer Login, Verifizierung, Passwort-Reset und Dublettenpruefung existiert nur ein separater HMAC-Lookup-Hash. - Der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist auf der Datenschutz-Seite jetzt zusaetzlich deutlich hervorgehoben. - Im Profil koennen jetzt auch Strasse/Hausnummer, PLZ, Ort und Region gepflegt werden; fuer die Adress-Lokalisierung werden die bereits vorhandenen externen Karten-/Geocoding-Dienste wiederverwendet. - Die Profiladresse bietet jetzt zusaetzlich eine Trefferauswahl fuer die Suche, eine Uebernahme aus dem Browser-Standort und einen kurzen Hinweisdialog zur Relevanz fuer lokale Termin-Hinweise. @@ -65,6 +66,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - `enabled` - Im Einstellungsbereich werden außerdem Browser-Grenzen erklärt: Die Browser-Freigabe selbst kann nicht von der Website gesetzt oder aufgehoben werden. - Es gibt eine eigene Seite `/datenschutz` für Datenschutz- und Cookie-Hinweise sowie einen Footer-Link auf die Consent-Einstellungen. +- Fuer Distanz- und Umkreissuchen bleiben nur die erforderlichen Standortkoordinaten im Klartext gespeichert; die eigentliche Strasse/Hausnummer ist verschluesselt. ## Doku-Regel Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden: diff --git a/README.md b/README.md index cc5c2b9..c27b185 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale - im Bereich `Kinder` koennen vorhandene Eintraege jetzt auch nachtraeglich bearbeitet und geloescht werden - bei Kindern mit Geburtsdatum wird das Alter automatisch berechnet und bei Aufruf des Mitgliederbereichs bei Bedarf jaehrlich nachgezogen; ohne Geburtsdatum gilt das manuell gepflegte Alter - sensible Profildaten werden im Mitgliederbereich jetzt konsequent app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden beim Lesen schrittweise in den verschluesselten Zustand ueberfuehrt +- auch Konto-E-Mails werden jetzt app-seitig verschluesselt gespeichert; fuer Login, Verifizierung, Passwort-Reset und Dublettenpruefung wird nur ein separater HMAC-Lookup-Hash verwendet - der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist jetzt auch auf der Datenschutz-Seite deutlich hervorgehoben - das Profil unterstuetzt jetzt eine vollstaendige Adresse mit verschluesselter Strasse/Hausnummer sowie optionaler Adress-Lokalisierung ueber die bereits vorhandenen Karten-/Geocoding-Dienste - bei der Profiladresse gibt es jetzt eine Trefferauswahl fuer Adresssuche, eine Uebernahme aus dem Browser-Standort sowie einen mobilen Hinweisdialog, warum die Adresse fuer lokale Termine relevant ist @@ -66,6 +67,12 @@ Der aktuelle Code nutzt oder kann nutzen: - OpenStreetMap Nominatim (`external_services`) - DiceBear fuer serverseitig erzeugte Profilbilder; das Ergebnis wird lokal als Benutzer-SVG gespeichert +Zur Datensicherheit: +- Passwort-Hashes laufen ueber Argon2id. +- Konto-E-Mails werden verschluesselt gespeichert und nur ueber einen separaten HMAC-Lookup-Hash adressiert. +- Profilfelder wie Vorname, Nachname, Strasse/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert. +- Standortkoordinaten (`lat`, `lng`) bleiben fuer Distanz- und Umkreissuchen technisch im Klartext gespeichert. + Wichtig: - Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden. - Änderungen an Cookies, Tracking oder Drittanbietern müssen immer auch in Consent und rechtlichen Hinweisen nachgezogen werden. diff --git a/public/page/datenschutz.php b/public/page/datenschutz.php index 52bf233..967b33c 100644 --- a/public/page/datenschutz.php +++ b/public/page/datenschutz.php @@ -18,8 +18,9 @@ $clientCookie = $config->cookiePrefix() . 'client';
Wichtiger Hinweis zum Datenschutz: Personenbezogene und sensible Profilangaben werden bei Papa-Kind-Treff app-seitig verschluesselt gespeichert. - Das betrifft insbesondere Profilangaben wie Vorname, Nachname, Strasse/Hausnummer, Telefonnummer, Beruf, - Sprachen, Kurzvorstellung sowie einzelne Kinderdaten. + Das betrifft insbesondere auch die Konto-E-Mail sowie Profilangaben wie Vorname, Nachname, Strasse/Hausnummer, + Telefonnummer, Beruf, Sprachen, Kurzvorstellung und einzelne Kinderdaten. Fuer Login, Verifizierung und + Passwort-Reset wird die E-Mail technisch nur ueber einen separaten HMAC-Lookup-Hash adressiert.
diff --git a/public/page/staging-users.php b/public/page/staging-users.php index cf3dfef..3c0eaaa 100644 --- a/public/page/staging-users.php +++ b/public/page/staging-users.php @@ -10,12 +10,16 @@ $app = app(); $pdo = $app->pdo(); $users = []; $error = ''; +$emailStore = null; try { if (!$pdo) { throw new RuntimeException('Keine Datenbankverbindung verfügbar.'); } + $emailStore = new App\UserEmailStore($pdo); + $emailStore->ensureSchema(); + $hasRolesTable = false; try { $stmt = $pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'user_roles' LIMIT 1"); @@ -36,6 +40,7 @@ try { '; $stmt = $pdo->query($sql); $users = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + $users = $emailStore->decryptRowEmails($users); if ($hasRolesTable && $users) { $roleStmt = $pdo->query('SELECT user_id, role FROM user_roles ORDER BY user_id ASC, role ASC'); diff --git a/schema.sql b/schema.sql index c6576b7..459e653 100755 --- a/schema.sql +++ b/schema.sql @@ -3,18 +3,22 @@ -- - Passwörter mit Argon2id hashen. -- - Sensible Felder werden app-seitig mit libsodium (XChaCha20-Poly1305) verschlüsselt -- und als base64 in VARBINARY-Spalten abgelegt (nonce + cipher). +-- - Konto-E-Mails werden app-seitig verschlüsselt gespeichert; für die technische Suche +-- und Dublettenprüfung wird zusätzlich ein HMAC-SHA-256-Lookup-Hash abgelegt. -- - share_level steuert Papa-Infos (basic, papa, papa_contact). -- - children_visibility steuert Kinder-Infos separat (hidden, age_only, details). CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, + email VARBINARY(512) NOT NULL, + email_lookup_hash CHAR(64) NOT NULL, password_hash VARCHAR(255) NOT NULL, status ENUM('active','pending','blocked') DEFAULT 'pending', email_verified_at DATETIME NULL, last_login_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_email_lookup_hash (email_lookup_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE user_profiles ( diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 9950ac2..a2ef66d 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -104,7 +104,6 @@ final class AccountPages public static function verify(App $app): array { - $pdo = $app->pdo(); $flash = $app->flash()->get(); $error = ''; $info = ''; @@ -119,20 +118,19 @@ final class AccountPages if ($action === 'resend') { try { - $stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1'); - $stmt?->execute(['email' => $email]); - $row = $stmt?->fetch(\PDO::FETCH_ASSOC); + $row = $auth->findUserMetaByEmail($email); if (!$row) { throw new \RuntimeException('E-Mail nicht gefunden.'); } $userId = (int)$row['id']; - $codeNew = $auth->createVerifyCode($userId, $email); - $mailer->sendTemplate('registration_resend_code', $email, [ + $storedEmail = $auth->getEmailByUserId($userId); + $codeNew = $auth->createVerifyCode($userId, $storedEmail); + $mailer->sendTemplate('registration_resend_code', $storedEmail, [ 'code' => $codeNew, 'display_name' => $row['display_name'] ?? '', ]); $info = 'Neuer Code wurde versendet.'; - $_SESSION['verify_email'] = $email; + $_SESSION['verify_email'] = $storedEmail; } catch (\Throwable $e) { $error = $e->getMessage(); } @@ -141,7 +139,11 @@ final class AccountPages $userId = $auth->verifyCode($email, $code); $_SESSION['user_id'] = $userId; unset($_SESSION['verify_email']); - $mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]); + $storedEmail = $auth->getEmailByUserId($userId); + $meta = $auth->findUserMetaByEmail($storedEmail); + $mailer->sendTemplate('registration_welcome', $storedEmail, [ + 'display_name' => $meta['display_name'] ?? $storedEmail, + ]); $app->flash()->set('success', 'Registrierung bestätigt. Willkommen!'); redirect('/dashboard'); } catch (\Throwable $e) { @@ -471,7 +473,7 @@ final class AccountPages static fn(string $column): string => 'p.' . $column, AvatarManager::allProfileColumns() )); - $stmt = $pdo?->prepare("SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.street, p.zip, p.city, p.region, p.lat, p.lng, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1"); + $stmt = $pdo?->prepare("SELECT u.status, p.display_name, p.first_name, p.last_name, p.street, p.zip, p.city, p.region, p.lat, p.lng, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1"); $stmt?->execute(['id' => $userId]); $row = $stmt?->fetch(\PDO::FETCH_ASSOC); if ($row) { @@ -488,6 +490,7 @@ final class AccountPages ); } } + $profile['email'] = (new Auth($app))->getEmailByUserId($userId); $profile = AvatarManager::normalizeProfile($profile, $userId); $editChildId = isset($_GET['edit_child']) ? (int)$_GET['edit_child'] : 0; diff --git a/src/App/Auth.php b/src/App/Auth.php index 5af7d63..20909e6 100755 --- a/src/App/Auth.php +++ b/src/App/Auth.php @@ -5,6 +5,8 @@ namespace App; final class Auth { + private ?UserEmailStore $emailStore = null; + public function __construct(private App $app) {} private function pdo(): \PDO @@ -16,6 +18,16 @@ final class Auth return $pdo; } + private function emailStore(): UserEmailStore + { + if ($this->emailStore === null) { + $this->emailStore = new UserEmailStore($this->pdo()); + $this->emailStore->ensureSchema(); + } + + return $this->emailStore; + } + public function register(string $displayName, string $email, string $password): int { $pdo = $this->pdo(); @@ -26,18 +38,18 @@ final class Auth throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.'); } + $emailStore = $this->emailStore(); $pdo->beginTransaction(); try { - $stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); - $stmt->execute(['email' => $email]); - if ($stmt->fetchColumn()) { + if ($emailStore->findUserByEmail($email, 'id')) { throw new \RuntimeException('E-Mail ist bereits registriert.'); } $hash = password_hash($password, PASSWORD_ARGON2ID); - $stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())'); + $stmt = $pdo->prepare('INSERT INTO users (email, email_lookup_hash, password_hash, status, created_at, updated_at) VALUES (:email, :lookup, :pw, :status, NOW(), NOW())'); $stmt->execute([ - 'email' => $email, + 'email' => $emailStore->encrypt($email), + 'lookup' => $emailStore->lookupHash($email), 'pw' => $hash, 'status' => 'pending', ]); @@ -80,11 +92,12 @@ final class Auth public function verifyCode(string $email, string $code): int { $pdo = $this->pdo(); - $email = strtolower(trim($email)); + $email = $this->emailStore()->normalize($email); $hash = hash('sha256', $code); - $stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1'); - $stmt->execute(['type' => 'verify', 'email' => $email]); + $lookupHash = $this->emailStore()->lookupHash($email); + $stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email_lookup_hash = :lookup AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1'); + $stmt->execute(['type' => 'verify', 'lookup' => $lookupHash]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$row || !hash_equals((string)$row['token_hash'], $hash)) { throw new \RuntimeException('Code ist ungültig oder abgelaufen.'); @@ -109,17 +122,17 @@ final class Auth public function createResetCode(string $email): array { $pdo = $this->pdo(); - $email = strtolower(trim($email)); + $email = $this->emailStore()->normalize($email); - $stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1'); - $stmt->execute(['email' => $email]); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $row = $this->emailStore()->findUserByEmail($email, 'id'); if (!$row) { throw new \RuntimeException('E-Mail ist nicht registriert.'); } $userId = (int)$row['id']; - $displayName = (string)($row['display_name'] ?? $email); + $stmt = $pdo->prepare('SELECT display_name FROM user_profiles WHERE user_id = :id LIMIT 1'); + $stmt->execute(['id' => $userId]); + $displayName = (string)($stmt->fetchColumn() ?: $email); $code = $this->generateCode(6); $hash = hash('sha256', $code); @@ -138,11 +151,12 @@ final class Auth public function verifyResetCode(string $email, string $code): int { $pdo = $this->pdo(); - $email = strtolower(trim($email)); + $email = $this->emailStore()->normalize($email); $hash = hash('sha256', $code); - $stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1'); - $stmt->execute(['type' => 'reset', 'email' => $email]); + $lookupHash = $this->emailStore()->lookupHash($email); + $stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email_lookup_hash = :lookup AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1'); + $stmt->execute(['type' => 'reset', 'lookup' => $lookupHash]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$row || !hash_equals((string)$row['token_hash'], $hash)) { throw new \RuntimeException('Code ist ungültig oder abgelaufen.'); @@ -191,11 +205,9 @@ final class Auth public function login(string $email, string $password): array { $pdo = $this->pdo(); - $email = strtolower(trim($email)); + $email = $this->emailStore()->normalize($email); - $stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1'); - $stmt->execute(['email' => $email]); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $row = $this->emailStore()->findUserByEmail($email, 'id, password_hash, status'); if (!$row) { throw new \RuntimeException('E-Mail oder Passwort ist falsch.'); @@ -214,4 +226,23 @@ final class Auth return ['id' => $userId, 'status' => $status]; } + + public function findUserMetaByEmail(string $email): ?array + { + $row = $this->emailStore()->findUserByEmail($email, 'id, status'); + if (!$row) { + return null; + } + + $stmt = $this->pdo()->prepare('SELECT display_name FROM user_profiles WHERE user_id = :id LIMIT 1'); + $stmt->execute(['id' => (int)$row['id']]); + $row['display_name'] = (string)($stmt->fetchColumn() ?: ''); + + return $row; + } + + public function getEmailByUserId(int $userId): string + { + return $this->emailStore()->getEmailByUserId($userId); + } } diff --git a/src/App/CommunityAccess.php b/src/App/CommunityAccess.php index 700c8bd..81fd1d4 100644 --- a/src/App/CommunityAccess.php +++ b/src/App/CommunityAccess.php @@ -7,11 +7,22 @@ final class CommunityAccess { private array $tableCache = []; private array $columnCache = []; + private ?UserEmailStore $emailStore = null; public function __construct(private \PDO $pdo, private array $communityConfig) { } + private function emailStore(): UserEmailStore + { + if ($this->emailStore === null) { + $this->emailStore = new UserEmailStore($this->pdo); + $this->emailStore->ensureSchema(); + } + + return $this->emailStore; + } + public function getUserRoles(int $userId): array { if ($userId <= 0) { @@ -182,7 +193,7 @@ final class CommunityAccess $stmt = $this->pdo->prepare($sql); $stmt->execute($params); - return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + return $this->emailStore()->decryptRowEmails($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []); } public function decideApplication(int $adminUserId, int $applicationId, string $decision, ?string $reason = null): void @@ -273,7 +284,7 @@ final class CommunityAccess LEFT JOIN user_profiles up ON up.user_id = ur.user_id ORDER BY FIELD(ur.role, "owner", "site_admin", "forum_admin"), ur.assigned_at ASC '); - return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + return $this->emailStore()->decryptRowEmails($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [], 'user_id'); } public function setRestriction(int $actingUserId, int $targetUserId, string $type, string $reason): void diff --git a/src/App/UserEmailStore.php b/src/App/UserEmailStore.php new file mode 100644 index 0000000..00bd2ac --- /dev/null +++ b/src/App/UserEmailStore.php @@ -0,0 +1,247 @@ +crypto = new Crypto(Config::fromPhpConstants($configDir)); + $this->lookupKey = $this->buildLookupKey(); + } + + public function ensureSchema(): void + { + if ($this->schemaEnsured) { + return; + } + + if (!$this->hasColumn('users', 'email_lookup_hash')) { + $this->pdo->exec('ALTER TABLE users ADD COLUMN email_lookup_hash CHAR(64) NULL AFTER email'); + } + + if ($this->getColumnDataType('users', 'email') !== 'varbinary') { + $this->pdo->exec('ALTER TABLE users MODIFY COLUMN email VARBINARY(512) NOT NULL'); + } + + $this->migrateEmails(); + $this->dropUniqueIndexOnEmailColumn(); + $this->ensureLookupHashIndex(); + $this->schemaEnsured = true; + } + + public function normalize(string $email): string + { + return strtolower(trim($email)); + } + + public function lookupHash(string $email): string + { + return hash_hmac('sha256', $this->normalize($email), $this->lookupKey); + } + + public function encrypt(string $email): string + { + return $this->crypto->encrypt($this->normalize($email)); + } + + public function decrypt(?string $value): string + { + $raw = (string)($value ?? ''); + if ($raw === '') { + return ''; + } + if (Crypto::looksEncrypted($raw)) { + return $this->crypto->decrypt($raw) ?: ''; + } + return $this->normalize($raw); + } + + public function decodeAndMigrateValue(?string $value, int $userId): string + { + $decoded = $this->decrypt($value); + $raw = (string)($value ?? ''); + if ($decoded !== '' && !Crypto::looksEncrypted($raw)) { + $this->updateUserEmail($userId, $decoded); + } + return $decoded; + } + + public function updateUserEmail(int $userId, string $email): void + { + $normalized = $this->normalize($email); + $stmt = $this->pdo->prepare('UPDATE users SET email = :email, email_lookup_hash = :lookup, updated_at = NOW() WHERE id = :id'); + $stmt->execute([ + 'email' => $this->encrypt($normalized), + 'lookup' => $this->lookupHash($normalized), + 'id' => $userId, + ]); + } + + public function findUserByEmail(string $email, string $select = 'id, email, password_hash, status'): array|false + { + $this->ensureSchema(); + $stmt = $this->pdo->prepare(sprintf('SELECT %s FROM users WHERE email_lookup_hash = :lookup LIMIT 1', $select)); + $stmt->execute([ + 'lookup' => $this->lookupHash($email), + ]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + public function getEmailByUserId(int $userId): string + { + $this->ensureSchema(); + $stmt = $this->pdo->prepare('SELECT email FROM users WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $userId]); + $value = $stmt->fetchColumn(); + return $this->decodeAndMigrateValue($value === false ? null : (string)$value, $userId); + } + + public function decryptRowEmails(array $rows, string $idKey = 'id', string $emailKey = 'email'): array + { + foreach ($rows as &$row) { + if (!isset($row[$emailKey])) { + continue; + } + $row[$emailKey] = $this->decodeAndMigrateValue((string)$row[$emailKey], (int)($row[$idKey] ?? 0)); + } + unset($row); + + return $rows; + } + + private function migrateEmails(): void + { + $stmt = $this->pdo->query('SELECT id, email, email_lookup_hash FROM users'); + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $emailValue = (string)($row['email'] ?? ''); + if ($emailValue === '') { + continue; + } + + $decoded = $this->decrypt($emailValue); + if ($decoded === '') { + continue; + } + + $needsEmailUpdate = !Crypto::looksEncrypted($emailValue); + $lookupHash = (string)($row['email_lookup_hash'] ?? ''); + $needsLookupUpdate = $lookupHash === '' || !hash_equals($lookupHash, $this->lookupHash($decoded)); + + if ($needsEmailUpdate || $needsLookupUpdate) { + $this->updateUserEmail((int)$row['id'], $decoded); + } + } + } + + private function ensureLookupHashIndex(): void + { + $stmt = $this->pdo->prepare(" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'users' + AND index_name = 'uq_users_email_lookup_hash' + "); + $stmt->execute(); + if ((int)$stmt->fetchColumn() === 0) { + $this->pdo->exec('ALTER TABLE users ADD UNIQUE INDEX uq_users_email_lookup_hash (email_lookup_hash)'); + } + } + + private function dropUniqueIndexOnEmailColumn(): void + { + $stmt = $this->pdo->query(" + SELECT index_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'users' + AND column_name = 'email' + AND non_unique = 0 + AND index_name <> 'PRIMARY' + "); + foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) ?: [] as $indexName) { + $this->pdo->exec(sprintf('ALTER TABLE users DROP INDEX %s', $indexName)); + } + } + + private function hasColumn(string $table, string $column): bool + { + $stmt = $this->pdo->prepare(" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :tableName + AND COLUMN_NAME = :columnName + "); + $stmt->execute([ + 'tableName' => $table, + 'columnName' => $column, + ]); + + return (int)$stmt->fetchColumn() > 0; + } + + private function getColumnDataType(string $table, string $column): ?string + { + $stmt = $this->pdo->prepare(" + SELECT DATA_TYPE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :tableName + AND COLUMN_NAME = :columnName + LIMIT 1 + "); + $stmt->execute([ + 'tableName' => $table, + 'columnName' => $column, + ]); + + $value = $stmt->fetchColumn(); + return $value !== false ? strtolower((string)$value) : null; + } + + private function buildLookupKey(): string + { + $raw = trim((string)(getenv('EMAIL_LOOKUP_KEY') ?: '')); + if ($raw !== '') { + $decoded = base64_decode(str_starts_with($raw, 'base64:') ? substr($raw, 7) : $raw, true); + if ($decoded !== false && $decoded !== '') { + return $decoded; + } + if (ctype_xdigit($raw) && strlen($raw) % 2 === 0) { + $hex = hex2bin($raw); + if ($hex !== false) { + return $hex; + } + } + return $raw; + } + + $dataKey = trim((string)(getenv('DATA_KEY') ?: '')); + if ($dataKey === '') { + throw new \RuntimeException('EMAIL_LOOKUP_KEY oder DATA_KEY wird fuer sichere E-Mail-Speicherung benoetigt.'); + } + + if (str_starts_with($dataKey, 'base64:')) { + $dataKey = substr($dataKey, 7); + } + $decoded = base64_decode($dataKey, true); + if ($decoded !== false && $decoded !== '') { + $dataKey = $decoded; + } elseif (ctype_xdigit($dataKey) && strlen($dataKey) % 2 === 0) { + $hex = hex2bin($dataKey); + if ($hex !== false) { + $dataKey = $hex; + } + } + + return hash_hkdf('sha256', (string)$dataKey, 32, 'pkt-user-email-lookup'); + } +}