mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
128 lines
4.1 KiB
PHP
128 lines
4.1 KiB
PHP
<?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;
|
|
}
|
|
}
|