Initial Version
This commit is contained in:
170
shared/lib/Security/Crypto.php
Normal file
170
shared/lib/Security/Crypto.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user