CluPilotCloud/app/Services/Wireguard/ConfigVault.php

80 lines
2.7 KiB
PHP

<?php
namespace App\Services\Wireguard;
use RuntimeException;
use SensitiveParameter;
/**
* Encrypts a stored WireGuard config at rest.
*
* Deliberately NOT Laravel's Crypt: that uses APP_KEY, which every part of the
* app already holds and which ends up in more places than it should. VPN
* configs are credentials for the management network, so they get their own
* key (VPN_CONFIG_KEY) that can be rotated without invalidating sessions,
* signed URLs and everything else APP_KEY protects.
*
* The ciphertext carries a version prefix so a future key rotation can tell
* old records apart instead of guessing.
*
* What this does NOT protect against: someone who has both the database and
* the .env. There is no way around that on a single box — it buys protection
* against a database-only leak (a dump, a backup, a stray replica).
*/
final class ConfigVault
{
private const VERSION = 'v1';
public static function available(): bool
{
return self::rawKey() !== null;
}
public static function encrypt(#[SensitiveParameter] string $plaintext): string
{
$key = self::rawKey() ?? throw new RuntimeException(
'VPN_CONFIG_KEY is not set — refusing to store a VPN config in plaintext.'
);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
return self::VERSION.':'.base64_encode($nonce.sodium_crypto_secretbox($plaintext, $nonce, $key));
}
public static function decrypt(string $ciphertext): ?string
{
$key = self::rawKey();
if ($key === null || ! str_starts_with($ciphertext, self::VERSION.':')) {
return null;
}
$raw = base64_decode(substr($ciphertext, strlen(self::VERSION) + 1), true);
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
return null;
}
$plaintext = sodium_crypto_secretbox_open(
substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),
substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),
$key,
);
// False means the key is wrong or the record was tampered with. Both are
// "you cannot have this", not an exception to swallow somewhere upstream.
return $plaintext === false ? null : $plaintext;
}
/** @return non-empty-string|null */
private static function rawKey(): ?string
{
$configured = (string) config('admin_access.vpn_config_key', '');
if ($configured === '') {
return null;
}
$key = base64_decode(str_starts_with($configured, 'base64:') ? substr($configured, 7) : $configured, true);
return $key !== false && strlen($key) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES ? $key : null;
}
}