80 lines
2.7 KiB
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;
|
|
}
|
|
}
|