Files
server/shared/lib/Security/Crypto.php
2026-01-03 13:18:04 -05:00

171 lines
5.5 KiB
PHP

<?php
namespace KTXF\Security;
use KTXC\SessionTenant;
use phpseclib3\Crypt\AES;
/**
* Symmetric encryption helper
*/
class Crypto
{
private const NONCE_LEN = 16; // 128-bit nonce for GCM (phpseclib allows 12-16)
private const KEY_LEN = 32; // 256-bit key
private const TAG_LEN = 16; // 128-bit auth tag
// Binary envelope constants
// Layout:
// 0-3: magic 'KXCG' (4 bytes)
// 4: version (1 byte)
// 5: flags (1 byte)
// 6-7: nonce length (uint16 big-endian, 2 bytes)
// 8-9: tag length (uint16 big-endian, 2 bytes)
// 10-13: data length (uint32 big-endian, 4 bytes)
// 14...: nonce || tag || encryptedData
private const ENCODING_HEADER_TAG = "KXCG";
private const ENCODING_HEADER_VERSION = 1;
private const ENCODING_HEADER_LEN = 14;
public function __construct(protected SessionTenant $sessionTenant)
{ }
/**
* Encrypt arbitrary string data.
* Returns hex string of length-prefixed binary envelope.
*/
public function encrypt(string $data, ?string $password = null): string
{
if ($password === null) {
$password = $this->tenantSecret();
if ($password === null) {
throw new \RuntimeException('Tenant secret unavailable for encryption');
}
}
$nonce = random_bytes(self::NONCE_LEN);
$key = hash_hkdf('sha256', $password);
if ($key === false || strlen($key) !== self::KEY_LEN) {
throw new \RuntimeException('Key derivation failed');
}
$aes = new AES('gcm');
$aes->setKey($key);
$aes->setNonce($nonce);
$encryptedData = $aes->encrypt($data);
if ($encryptedData === false) {
throw new \RuntimeException('Encryption failed');
}
$tag = $aes->getTag(self::TAG_LEN);
if ($tag === false || strlen($tag) !== self::TAG_LEN) {
throw new \RuntimeException('Authentication tag retrieval failed');
}
$nonceLen = strlen($nonce);
$tagLen = strlen($tag);
$dataLen = strlen($encryptedData);
$header = self::ENCODING_HEADER_TAG
. chr(self::ENCODING_HEADER_VERSION)
. chr(0x00) // flags
. pack('n', $nonceLen) // uint16 BE
. pack('n', $tagLen) // uint16 BE
. pack('N', $dataLen); // uint32 BE
$binary = $header . $nonce . $tag . $encryptedData;
return bin2hex($binary);
}
/**
* Decrypt hex-encoded length-prefixed binary envelope.
*/
public function decrypt(string $data, ?string $password = null): string
{
if ($password === null) {
$password = $this->tenantSecret();
if ($password === null) {
throw new \RuntimeException('Tenant secret unavailable for decryption');
}
}
if (!ctype_xdigit($data) || strlen($data) % 2 !== 0) {
throw new \InvalidArgumentException('Invalid data format');
}
$binary = hex2bin($data);
if ($binary === false || strlen($binary) < self::ENCODING_HEADER_LEN) {
throw new \InvalidArgumentException('Invalid data format');
}
if (substr($binary, 0, 4) !== self::ENCODING_HEADER_TAG) {
throw new \InvalidArgumentException('Invalid data format');
}
if (ord($binary[4]) !== self::ENCODING_HEADER_VERSION) {
throw new \InvalidArgumentException('Unsupported version');
}
$flags = ord($binary[5]); // currently unused; reserved for future
$nonceLen = unpack('n', substr($binary, 6, 2))[1];
$tagLen = unpack('n', substr($binary, 8, 2))[1];
$dataLen = unpack('N', substr($binary, 10, 4))[1];
if (strlen($binary) !== (self::ENCODING_HEADER_LEN + $nonceLen + $tagLen + $dataLen)) {
throw new \InvalidArgumentException('Invalid data format');
}
$nonce = substr($binary, 14, $nonceLen);
$tag = substr($binary, 14 + $nonceLen, $tagLen);
$encryptedData = substr($binary, 14 + $nonceLen + $tagLen, $dataLen);
$key = hash_hkdf('sha256', $password);
if ($key === false || strlen($key) !== self::KEY_LEN) {
throw new \RuntimeException('Key derivation failed');
}
$aes = new AES('gcm');
$aes->setKey($key);
$aes->setNonce($nonce);
$aes->setTag($tag);
$plainData = $aes->decrypt($encryptedData);
if ($plainData === false) {
throw new \RuntimeException('Decryption failed (auth)');
}
return $plainData;
}
private function tenantSecret(): ?string
{
$config = $this->sessionTenant->configuration();
return $config->security()->code();
}
// =========================================================================
// Password Hashing
// =========================================================================
/**
* Hash a password using bcrypt
*/
public function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
}
/**
* Verify a password against a hash
*/
public function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
/**
* Check if a password hash needs to be rehashed
*/
public function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12]);
}
}