Steuerkram/src/Services/Crypto.php
fds2610-bot 995b52fbd3 Feature: FinTS-Direktverbindung und vereinheitlichte Importquellen
- FinTS-Abruf für Comdirect/DKB/Sparkassen via nemiah/php-fints (4.1)
  inkl. photoTAN-Eingabe-Modal und Multi-Konto-Auswahl
- Neue Tabelle bank_sources (CSV/API/FinTS) ersetzt bank_import_formats
  und bank_connections, Backfill in Migration 068
- Symmetrische Verschlüsselung sensibler Felder (PayPal-Signature,
  FinTS-PIN) mit AES-256-GCM, APP_KEY in .env
- Routes /bank/fints* → /bank/direkt*; "+ Neue Bank…"-Dialog erlaubt
  freie API/FinTS-Anbieter
- Scripts/encrypt_bank_secrets.php als idempotenter Migrations-Helfer
- Neue Tests 36 (Crypto) und 37 (BankSource)
2026-05-22 07:17:00 +02:00

91 lines
2.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Config;
use RuntimeException;
/**
* Symmetrische Verschlüsselung sensibler Felder (PayPal-Signature, FinTS-PIN).
*
* Format: "enc:v1:" + base64( iv(12) || tag(16) || ciphertext )
* Algorithmus: AES-256-GCM, Schlüssel als 64-stellige Hex-Zeichenkette aus Config::get('app_key').
*
* Migrationsmodus: decrypt() reicht Werte ohne Prefix unverändert durch (Klartext),
* sodass Klartext-Werte aus dem SQL-Backfill funktionieren, bis das Verschlüsselungsskript
* sie idempotent in den enc-Form überführt hat.
*/
class Crypto
{
private const PREFIX = 'enc:v1:';
private const CIPHER = 'aes-256-gcm';
private const IV_LEN = 12;
private const TAG_LEN = 16;
public static function encrypt(string $plain): string
{
if ($plain === '') {
return '';
}
if (self::isEncrypted($plain)) {
return $plain;
}
$key = self::key();
$iv = random_bytes(self::IV_LEN);
$tag = '';
$cipher = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LEN);
if ($cipher === false) {
throw new RuntimeException('Crypto::encrypt fehlgeschlagen.');
}
return self::PREFIX . base64_encode($iv . $tag . $cipher);
}
public static function decrypt(string $value): string
{
if ($value === '') {
return '';
}
if (!self::isEncrypted($value)) {
return $value;
}
$raw = base64_decode(substr($value, strlen(self::PREFIX)), true);
if ($raw === false || strlen($raw) < self::IV_LEN + self::TAG_LEN) {
throw new RuntimeException('Crypto::decrypt: ungültiges Format.');
}
$iv = substr($raw, 0, self::IV_LEN);
$tag = substr($raw, self::IV_LEN, self::TAG_LEN);
$cipher = substr($raw, self::IV_LEN + self::TAG_LEN);
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false) {
throw new RuntimeException('Crypto::decrypt fehlgeschlagen (falscher Schlüssel oder Datenkorruption).');
}
return $plain;
}
public static function isEncrypted(string $value): bool
{
return str_starts_with($value, self::PREFIX);
}
private static function key(): string
{
$hex = (string)Config::get('app_key', '');
if ($hex === '') {
throw new RuntimeException('APP_KEY ist nicht gesetzt. Bitte in .env hinterlegen.');
}
$key = @hex2bin($hex);
if ($key === false || strlen($key) !== 32) {
throw new RuntimeException('APP_KEY muss eine 64-stellige Hex-Zeichenkette (32 Byte) sein.');
}
return $key;
}
}