diff --git a/classes/Config.php b/classes/Config.php index c9acad93e..e906419dc 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -192,8 +192,8 @@ class Config { /** disables login form controls except HOOK_LOGINFORM_ADDITIONAL_BUTTONS (for SSO providers), also prevents logging in through auth_internal */ const DISABLE_LOGIN_FORM = "DISABLE_LOGIN_FORM"; - /** optional key to transparently encrypt stored session data using Sodium library (XChaCha20-Poly1305) - generate using bin2hex(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()) */ - const SESSION_ENCRYPTION_KEY = "SESSION_ENCRYPTION_KEY"; + /** optional key to transparently encrypt sensitive data (currently limited to sessions); key is a 32 byte hex string may be generated using update.php --gen-encryption-key */ + const ENCRYPTION_KEY = "ENCRYPTION_KEY"; /** default values for all global configuration options */ private const _DEFAULTS = [ @@ -253,7 +253,7 @@ class Config { Config::T_STRING ], Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ], Config::DISABLE_LOGIN_FORM => [ "", Config::T_BOOL ], - Config::SESSION_ENCRYPTION_KEY => [ "", Config::T_STRING ] + Config::ENCRYPTION_KEY => [ "", Config::T_STRING ] ]; private static ?Config $instance = null; diff --git a/classes/Crypt.php b/classes/Crypt.php new file mode 100644 index 000000000..d832e6530 --- /dev/null +++ b/classes/Crypt.php @@ -0,0 +1,62 @@ + self::ENCRYPT_ALGO, + 'nonce' => $nonce, + 'payload' => $payload, + ]; + + return $encrypted_data; + } + + throw new Exception("Crypt::encrypt_string() failed to encrypt ciphertext"); + } + + /** decrypts payload of a valid encrypted object using Config::ENCRYPTION_KEY + * + * @param array{'algo': string, 'nonce': string, 'payload': string} $encrypted_data + * + * @return string decrypted string payload + */ + static function decrypt_string(array $encrypted_data) : string { + $key = Config::get(Config::ENCRYPTION_KEY); + + if (!$key) + throw new Exception("Crypt::decrypt_string() failed to decrypt - key is not available"); + + // only one is supported for the time being + switch ($encrypted_data['algo']) { + case self::ENCRYPT_ALGO: + return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($encrypted_data['payload'], '', $encrypted_data['nonce'], hex2bin($key)); + } + + throw new Exception('Crypt::decrypt_string() failed to decrypt passed encrypted data object, unsupported algo: ' . $encrypted_data['algo']); + } + +} \ No newline at end of file diff --git a/classes/Sessions.php b/classes/Sessions.php index d8f14eed0..f3c0cea42 100644 --- a/classes/Sessions.php +++ b/classes/Sessions.php @@ -8,8 +8,6 @@ class Sessions implements \SessionHandlerInterface { private int $session_expire; private string $session_name; - private const SODIUM_ALGO = 'xchacha20poly1305_ietf'; - public function __construct() { $this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME)); $this->session_name = Config::get(Config::SESSION_NAME); @@ -55,48 +53,6 @@ class Sessions implements \SessionHandlerInterface { return true; } - /** encrypts provided ciphertext using Sodium symmetric encryption key if available via Config::SESSION_ENCRYPTION_KEY - * - * @return array encrypted data object containing algo, nonce, and encrypted data - * - */ - private function encrypt_string(string $ciphertext) : array { - $key = Config::get(Config::SESSION_ENCRYPTION_KEY); - $nonce = \random_bytes(\SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); - - $payload = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($ciphertext, '', $nonce, hex2bin($key)); - - if ($payload) { - $encrypted_data = [ - 'algo' => self::SODIUM_ALGO, - 'nonce' => $nonce, - 'payload' => $payload, - ]; - - return $encrypted_data; - } - - throw new Exception("Config::encrypt_string() failed to encrypt ciphertext"); - } - - /** decrypts payload of encrypted object if Config::SESSION_ENCRYPTION_KEY is available and object is in correct format - * - * @param array $encrypted_data - * - * @return string decrypted string payload - */ - private function decrypt_string(array $encrypted_data) : string { - $key = Config::get(Config::SESSION_ENCRYPTION_KEY); - - if ($encrypted_data['algo'] === self::SODIUM_ALGO) { - $payload = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($encrypted_data['payload'], '', $encrypted_data['nonce'], hex2bin($key)); - - return $payload; - } - - throw new Exception('Config::decrypt_string() failed to decrypt passed encrypted data'); - } - public function read(string $id): false|string { $sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?'); $sth->execute([$id]); @@ -104,14 +60,14 @@ class Sessions implements \SessionHandlerInterface { if ($row = $sth->fetch()) { $data = base64_decode($row['data']); - if (Config::get(Config::SESSION_ENCRYPTION_KEY)) { + if (Config::get(Config::ENCRYPTION_KEY)) { $unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message if ($unserialized_data !== false) - return $this->decrypt_string($unserialized_data); + return Crypt::decrypt_string($unserialized_data); } - // if Sodium key is missing or session data is not in serialized format, return as-is + // if encryption key is missing or session data is not in serialized format, assume plaintext data and return as-is return $data; } @@ -124,8 +80,8 @@ class Sessions implements \SessionHandlerInterface { public function write(string $id, string $data): bool { - if (Config::get(Config::SESSION_ENCRYPTION_KEY)) - $data = serialize($this->encrypt_string($data)); + if (Config::get(Config::ENCRYPTION_KEY)) + $data = serialize(Crypt::encrypt_string($data)); $data = base64_encode($data); diff --git a/update.php b/update.php index 471e323c8..dfac5ab4c 100755 --- a/update.php +++ b/update.php @@ -84,6 +84,7 @@ "update-schema::" => ["[force-yes]", "update database schema, optionally without prompting"], "force-update" => "mark all feeds as pending update", "gen-search-idx" => "generate basic PostgreSQL fulltext search index", + "gen-encryption-key" => "generate an encryption key (ChaCha20-Poly1305)", "plugins-list" => "list installed plugins", "debug-feed:" => ["N", "update specified feed with debug output enabled"], "force-refetch" => "debug update: force refetch feed data", @@ -323,6 +324,10 @@ } } + if (isset($options["gen-encryption-key"])) { + echo "Generated encryption key: " . bin2hex(Crypt::generate_key()) . "\n"; + } + if (isset($options["plugins-list"])) { $tmppluginhost = new PluginHost(); $tmppluginhost->load_all($tmppluginhost::KIND_ALL);