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]); } }