feat: complete jobcard client management foundation
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Framework-free credential encryption using XChaCha20-Poly1305 AEAD.
|
||||
* Ciphertext is versioned and URL-safe; plaintext is never part of display output.
|
||||
*/
|
||||
final class CredentialVault
|
||||
{
|
||||
private const VERSION = 'v1';
|
||||
private const ASSOCIATED_DATA = 'jobcard-system:credential:v1';
|
||||
private string $key;
|
||||
private TechnicalInformation $information;
|
||||
|
||||
public function __construct(?string $appKey = null)
|
||||
{
|
||||
$material = $appKey ?? getenv('APP_KEY');
|
||||
if ($material === false || $material === null || trim($material) === '') {
|
||||
throw new RuntimeException('APP_KEY is required for credential encryption.');
|
||||
}
|
||||
$material = trim($material);
|
||||
if (in_array($material, ['generate-a-long-random-secret', 'replace-with-a-long-random-secret'], true)) {
|
||||
throw new RuntimeException('APP_KEY contains an invalid placeholder value.');
|
||||
}
|
||||
$this->key = $this->keyFromMaterial($material);
|
||||
$this->information = new TechnicalInformation();
|
||||
}
|
||||
|
||||
public function encrypt(string $plaintext): string
|
||||
{
|
||||
if ($plaintext === '') throw new InvalidArgumentException('Credential secret must not be empty.');
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, self::ASSOCIATED_DATA, $nonce, $this->key);
|
||||
return self::VERSION . '.' . $this->base64UrlEncode($nonce . $ciphertext);
|
||||
}
|
||||
|
||||
public function decrypt(string $encoded): string
|
||||
{
|
||||
try {
|
||||
if (!str_starts_with($encoded, self::VERSION . '.')) throw new RuntimeException();
|
||||
$binary = $this->base64UrlDecode(substr($encoded, strlen(self::VERSION) + 1));
|
||||
$nonceLength = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
|
||||
if (strlen($binary) <= $nonceLength + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) throw new RuntimeException();
|
||||
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(substr($binary, $nonceLength), self::ASSOCIATED_DATA, substr($binary, 0, $nonceLength), $this->key);
|
||||
if ($plaintext === false) throw new RuntimeException();
|
||||
return $plaintext;
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Unable to decrypt credential.');
|
||||
}
|
||||
}
|
||||
|
||||
public function mask(string $secret): string
|
||||
{
|
||||
return '••••••••••••••••••••';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function display(array $credential): array
|
||||
{
|
||||
$safe = $this->projectMetadata($credential);
|
||||
if (array_key_exists('secret', $credential) || array_key_exists('secret_ciphertext', $credential)) $safe['secret'] = $this->mask('');
|
||||
return $safe;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function toDisplay(array $credential): array
|
||||
{
|
||||
return $this->display($credential);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function encryptCredential(array $credential): array
|
||||
{
|
||||
$validation = $this->information->validate($credential);
|
||||
if (!$validation['valid']) throw new InvalidArgumentException('Invalid credential metadata: ' . implode(' ', $validation['errors']));
|
||||
$secret = $credential['secret'] ?? null;
|
||||
if (!is_string($secret) || $secret === '') throw new InvalidArgumentException('Credential secret must be a non-empty string.');
|
||||
$stored = $this->projectMetadata(array_key_exists('id', $credential) ? [...$validation, 'id' => $credential['id']] : $validation);
|
||||
$stored['secret_ciphertext'] = $this->encrypt($secret);
|
||||
return $stored;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function decryptCredential(array $credential): array
|
||||
{
|
||||
if (!isset($credential['secret_ciphertext']) || !is_string($credential['secret_ciphertext'])) {
|
||||
throw new InvalidArgumentException('Encrypted credential secret is missing.');
|
||||
}
|
||||
return [...$this->projectMetadata($credential), 'secret' => $this->decrypt($credential['secret_ciphertext'])];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function projectMetadata(array $credential): array
|
||||
{
|
||||
$normalized = $this->information->normalize($credential);
|
||||
$safe = $this->information->display($normalized);
|
||||
if (array_key_exists('id', $credential)) $safe = ['id' => $credential['id'], ...$safe];
|
||||
return $safe;
|
||||
}
|
||||
|
||||
private function keyFromMaterial(string $material): string
|
||||
{
|
||||
if (str_starts_with($material, 'base64:')) {
|
||||
$decoded = base64_decode(substr($material, 7), true);
|
||||
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
|
||||
throw new RuntimeException('APP_KEY base64 material must decode to exactly 32 bytes.');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
$unprefixedBase64 = base64_decode($material, true);
|
||||
if ($unprefixedBase64 !== false && strlen($unprefixedBase64) === SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES && preg_match('/^[A-Za-z0-9+\/=]+$/', $material) === 1) {
|
||||
return $unprefixedBase64;
|
||||
}
|
||||
if (preg_match('/^[a-f0-9]{64}$/i', $material) === 1) return hex2bin($material);
|
||||
if (strlen($material) < 32) throw new RuntimeException('APP_KEY material must be at least 32 bytes.');
|
||||
return hash('sha256', $material, true);
|
||||
}
|
||||
|
||||
private function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $value): string
|
||||
{
|
||||
if ($value === '' || preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) throw new RuntimeException();
|
||||
$decoded = base64_decode(strtr($value, '-_', '+/') . str_repeat('=', (4 - strlen($value) % 4) % 4), true);
|
||||
if ($decoded === false) throw new RuntimeException();
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
/** Normalizes and validates non-secret technical-information metadata. */
|
||||
final class TechnicalInformation
|
||||
{
|
||||
public const CATEGORY_HOSTING = 'hosting';
|
||||
public const CATEGORY_VPN = 'vpn';
|
||||
public const CATEGORY_EMAIL = 'email';
|
||||
public const CATEGORY_DOMAIN = 'domain';
|
||||
public const CATEGORY_DATABASE = 'database';
|
||||
public const CATEGORY_SSH = 'ssh';
|
||||
public const CATEGORY_API = 'api';
|
||||
public const CATEGORY_OTHER = 'other';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function categories(): array
|
||||
{
|
||||
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER];
|
||||
}
|
||||
|
||||
/** @return array{category:string, label:string, username:string|null, notes:string|null} */
|
||||
public function normalize(array $information): array
|
||||
{
|
||||
return [
|
||||
'category' => strtolower($this->text($information['category'] ?? null) ?? ''),
|
||||
'label' => $this->text($information['label'] ?? null) ?? '',
|
||||
'username' => $this->text($information['username'] ?? null),
|
||||
'notes' => $this->text($information['notes'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{valid:bool,errors:array<string,string>,category:string,label:string,username:string|null,notes:string|null} */
|
||||
public function validate(array $information): array
|
||||
{
|
||||
$normalized = $this->normalize($information);
|
||||
$errors = [];
|
||||
if (!in_array($normalized['category'], self::categories(), true)) {
|
||||
$errors['category'] = 'Credential category is invalid.';
|
||||
}
|
||||
if ($normalized['label'] === '') {
|
||||
$errors['label'] = 'Credential label is required.';
|
||||
} elseif ($normalized['label'] !== '' && mb_strlen($normalized['label']) > 120 || ($normalized['label'] !== '' && $this->hasControlCharacter($normalized['label']))) {
|
||||
$errors['label'] = 'Credential label must be 120 characters or fewer and contain no control characters.';
|
||||
}
|
||||
if ($normalized['username'] !== null && (mb_strlen($normalized['username']) > 190 || $this->hasControlCharacter($normalized['username']))) {
|
||||
$errors['username'] = 'Credential username must be 190 characters or fewer and contain no control characters.';
|
||||
}
|
||||
if ($normalized['notes'] !== null && mb_strlen($normalized['notes']) > 2000) {
|
||||
$errors['notes'] = 'Credential notes must be 2000 characters or fewer.';
|
||||
}
|
||||
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function display(array $information): array
|
||||
{
|
||||
$safe = [];
|
||||
foreach (['id', 'category', 'label', 'username', 'notes'] as $field) {
|
||||
if (array_key_exists($field, $information)) $safe[$field] = $information[$field];
|
||||
}
|
||||
return $safe;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function toDisplay(array $information): array
|
||||
{
|
||||
return $this->display($information);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) return null;
|
||||
$text = trim(is_scalar($value) ? (string) $value : '');
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function hasControlCharacter(string $value): bool
|
||||
{
|
||||
return preg_match('/[\x00-\x1F\x7F]/', $value) === 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user