- 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)
91 lines
2.8 KiB
PHP
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;
|
|
}
|
|
}
|