171 lines
5.5 KiB
PHP
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]);
|
|
}
|
|
}
|