CluPilotCloud/app/Services/Secrets/SecretCipher.php

92 lines
2.9 KiB
PHP

<?php
namespace App\Services\Secrets;
use Illuminate\Encryption\Encrypter;
use RuntimeException;
/**
* The SECRETS_KEY encrypter, in one place.
*
* Its own key rather than APP_KEY, because rotating APP_KEY is ordinary
* maintenance and would otherwise render every stored credential unreadable —
* discovered when Stripe stops answering, not when it happens.
*
* Extracted from SecretVault so mailbox passwords use the SAME key handling
* rather than a second copy of it. The copy is what would rot: the two
* spellings below exist because requiring the `base64:` prefix once made every
* read and write fail on exactly the setup the config comment documents.
*/
final class SecretCipher
{
public function encrypt(string $plain): string
{
return $this->encrypter()->encryptString($plain);
}
public function decrypt(string $cipher): string
{
return $this->encrypter()->decryptString($cipher);
}
/**
* Is storing credentials possible at all on this installation?
*
* Delegates to the exact same resolveKey() that encrypter() below checks
* its input against, rather than a second, shorter rule that only looks
* at emptiness. A malformed-but-nonempty key (wrong length, garbage
* base64) used to pass a bare "!== ''" check while encrypter() rejected
* it — which let EditMailbox::save() and MailboxTester::run() sail past
* the very guard they call this method for, straight into the uncaught
* RuntimeException it exists to prevent.
*/
public function isUsable(): bool
{
return $this->resolveKey() !== null;
}
/**
* The raw, exactly-32-byte key — or null if SECRETS_KEY is empty or
* malformed. The one place that decides "is this key any good", so
* isUsable() and encrypter() read it from the same rule and cannot
* disagree about a value either one is given.
*/
private function resolveKey(): ?string
{
$key = (string) config('admin_access.secrets_key');
if ($key === '') {
return null;
}
if (str_starts_with($key, 'base64:')) {
$key = substr($key, 7);
}
if (strlen($key) !== 32) {
$decoded = base64_decode($key, true);
if ($decoded !== false && strlen($decoded) === 32) {
$key = $decoded;
}
}
return strlen($key) === 32 ? $key : null;
}
private function encrypter(): Encrypter
{
$key = $this->resolveKey();
if ($key === null) {
throw new RuntimeException(
(string) config('admin_access.secrets_key') === ''
? 'SECRETS_KEY is not set — refusing to store or read credentials.'
: 'SECRETS_KEY must be 32 bytes (or base64 of 32 bytes).'
);
}
return new Encrypter($key, 'aes-256-cbc');
}
}