From 026d68fc2d0f24e4f2d46c5743a22f42053caa67 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Mon, 7 Apr 2025 20:08:17 +0300 Subject: [PATCH 1/7] add optional encryption for stored session data using Sodium library --- .docker/app/Dockerfile | 2 +- classes/Config.php | 53 +++++++++++++++++++++++++++++++++++++++++- classes/Sessions.php | 17 +++++++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index acfc1a9e5..7a87f7ea3 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -16,7 +16,7 @@ RUN [ ! -z ${ALPINE_MIRROR} ] && \ apk add --no-cache ca-certificates dcron git postgresql-client rsync sudo tzdata \ php${PHP_SUFFIX} \ $(for p in ctype curl dom exif fileinfo fpm gd iconv intl json mbstring opcache \ - openssl pcntl pdo pdo_pgsql pecl-apcu pecl-xdebug phar posix session simplexml sockets tokenizer xml xmlwriter zip; do \ + openssl pcntl pdo pdo_pgsql pecl-apcu pecl-xdebug phar posix session simplexml sockets sodium tokenizer xml xmlwriter zip; do \ php_pkgs="$php_pkgs php${PHP_SUFFIX}-$p"; \ done; \ echo $php_pkgs) && \ diff --git a/classes/Config.php b/classes/Config.php index 92037ff74..11ce5ccfa 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -8,6 +8,8 @@ class Config { const SCHEMA_VERSION = 147; + const SODIUM_ALGO = 'xchacha20poly1305_ietf'; + /** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: * * DB_TYPE becomes: @@ -192,6 +194,12 @@ 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 symmetric encryption key for Sodium library (XChaCha20-Poly1305) - generate using bin2hex(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()) + * + * if set, used to transparently encrypt stored session data in the database + */ + const SODIUM_ENCRYPTION_KEY = "SODIUM_ENCRYPTION_KEY"; + /** default values for all global configuration options */ private const _DEFAULTS = [ Config::DB_TYPE => [ "pgsql", Config::T_STRING ], @@ -249,7 +257,8 @@ class Config { Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)', Config::T_STRING ], Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ], - Config::DISABLE_LOGIN_FORM => [ "", Config::T_BOOL ] + Config::DISABLE_LOGIN_FORM => [ "", Config::T_BOOL ], + Config::SODIUM_ENCRYPTION_KEY => [ "", Config::T_STRING ] ]; private static ?Config $instance = null; @@ -298,6 +307,48 @@ class Config { return self::get_instance()->_get_version($as_string); } + /** encrypts provided ciphertext using Sodium symmetric encryption key if available via Config::SODIUM_ENCRYPTION_KEY + * + * @return array|false encrypted data object containing algo, nonce, and encrypted data or false if encryption failed + * + */ + static function encrypt_string(string $ciphertext) : array|false { + $key = Config::get(Config::SODIUM_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::SODIUM_ENCRYPTION_KEY is available and object is in correct format + * + * @param array $encrypted_data + * + * @return string|false decrypted string payload or false if decryption failed + */ + static function decrypt_string(array $encrypted_data) : string|false { + $key = Config::get(Config::SODIUM_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'); + } + // returns version showing (if possible) full timestamp of commit id static function get_version_html() : string { $version = self::get_version(false); diff --git a/classes/Sessions.php b/classes/Sessions.php index 5c586154b..e8cba1765 100644 --- a/classes/Sessions.php +++ b/classes/Sessions.php @@ -58,7 +58,17 @@ class Sessions implements \SessionHandlerInterface { $sth->execute([$id]); if ($row = $sth->fetch()) { - return base64_decode($row['data']); + $data = base64_decode($row['data']); + + if (Config::get(Config::SODIUM_ENCRYPTION_KEY)) { + $unserialized_data = unserialize($data); + + if ($unserialized_data !== false) + return Config::decrypt_string($unserialized_data); + } + + // if Sodium key is missing or session data is not in serialized format, return as-is + return $data; } $expire = time() + $this->session_expire; @@ -69,7 +79,12 @@ class Sessions implements \SessionHandlerInterface { } public function write(string $id, string $data): bool { + + if (Config::get(Config::SODIUM_ENCRYPTION_KEY)) + $data = serialize(Config::encrypt_string($data)); + $data = base64_encode($data); + $expire = time() + $this->session_expire; $sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?'); From 58677fc791604bd891fb1ef4f4cc5e040ce8e39f Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Mon, 7 Apr 2025 20:23:19 +0300 Subject: [PATCH 2/7] rename SODIUM_ENCRYPTION_KEY to SESSION_ENCRYPTION_KEY and move related stuff to Sessions class --- classes/Config.php | 53 +++---------------------------------------- classes/Sessions.php | 54 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/classes/Config.php b/classes/Config.php index 11ce5ccfa..c9acad93e 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -8,8 +8,6 @@ class Config { const SCHEMA_VERSION = 147; - const SODIUM_ALGO = 'xchacha20poly1305_ietf'; - /** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: * * DB_TYPE becomes: @@ -194,11 +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 symmetric encryption key for Sodium library (XChaCha20-Poly1305) - generate using bin2hex(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()) - * - * if set, used to transparently encrypt stored session data in the database - */ - const SODIUM_ENCRYPTION_KEY = "SODIUM_ENCRYPTION_KEY"; + /** 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"; /** default values for all global configuration options */ private const _DEFAULTS = [ @@ -258,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::SODIUM_ENCRYPTION_KEY => [ "", Config::T_STRING ] + Config::SESSION_ENCRYPTION_KEY => [ "", Config::T_STRING ] ]; private static ?Config $instance = null; @@ -307,48 +302,6 @@ class Config { return self::get_instance()->_get_version($as_string); } - /** encrypts provided ciphertext using Sodium symmetric encryption key if available via Config::SODIUM_ENCRYPTION_KEY - * - * @return array|false encrypted data object containing algo, nonce, and encrypted data or false if encryption failed - * - */ - static function encrypt_string(string $ciphertext) : array|false { - $key = Config::get(Config::SODIUM_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::SODIUM_ENCRYPTION_KEY is available and object is in correct format - * - * @param array $encrypted_data - * - * @return string|false decrypted string payload or false if decryption failed - */ - static function decrypt_string(array $encrypted_data) : string|false { - $key = Config::get(Config::SODIUM_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'); - } - // returns version showing (if possible) full timestamp of commit id static function get_version_html() : string { $version = self::get_version(false); diff --git a/classes/Sessions.php b/classes/Sessions.php index e8cba1765..d8f14eed0 100644 --- a/classes/Sessions.php +++ b/classes/Sessions.php @@ -8,6 +8,8 @@ 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); @@ -53,6 +55,48 @@ 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]); @@ -60,11 +104,11 @@ class Sessions implements \SessionHandlerInterface { if ($row = $sth->fetch()) { $data = base64_decode($row['data']); - if (Config::get(Config::SODIUM_ENCRYPTION_KEY)) { - $unserialized_data = unserialize($data); + if (Config::get(Config::SESSION_ENCRYPTION_KEY)) { + $unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message if ($unserialized_data !== false) - return Config::decrypt_string($unserialized_data); + return $this->decrypt_string($unserialized_data); } // if Sodium key is missing or session data is not in serialized format, return as-is @@ -80,8 +124,8 @@ class Sessions implements \SessionHandlerInterface { public function write(string $id, string $data): bool { - if (Config::get(Config::SODIUM_ENCRYPTION_KEY)) - $data = serialize(Config::encrypt_string($data)); + if (Config::get(Config::SESSION_ENCRYPTION_KEY)) + $data = serialize($this->encrypt_string($data)); $data = base64_encode($data); From 25d3ce4ee8f411a19c3a0e69ebb5c575c16243a8 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Tue, 8 Apr 2025 08:55:44 +0300 Subject: [PATCH 3/7] drop SESSION-specific stuff and move encrypt/decrypt helpers to a separate class; add a command line flag to generate encryption keys --- classes/Config.php | 6 ++--- classes/Crypt.php | 62 ++++++++++++++++++++++++++++++++++++++++++++ classes/Sessions.php | 54 ++++---------------------------------- update.php | 5 ++++ 4 files changed, 75 insertions(+), 52 deletions(-) create mode 100644 classes/Crypt.php 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); From eedc1460e5dadb00a731c1974642a4db7ab30868 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Tue, 8 Apr 2025 09:36:04 +0300 Subject: [PATCH 4/7] support transparent encryption for feed passwords, bump schema to drop length limit of ttrss_feeds.auth_pass --- classes/Config.php | 5 +++-- classes/Feeds.php | 24 ++++++++++++++++++++++++ classes/Pref_Feeds.php | 6 ++++++ classes/RSSUtils.php | 12 ++++++++---- sql/mysql/migrations/148.sql | 3 +++ sql/mysql/schema.sql | 2 +- sql/pgsql/migrations/148.sql | 7 +++++++ sql/pgsql/schema.sql | 2 +- 8 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 sql/mysql/migrations/148.sql create mode 100644 sql/pgsql/migrations/148.sql diff --git a/classes/Config.php b/classes/Config.php index e906419dc..5098bfe68 100644 --- a/classes/Config.php +++ b/classes/Config.php @@ -6,7 +6,7 @@ class Config { const T_STRING = 2; const T_INT = 3; - const SCHEMA_VERSION = 147; + const SCHEMA_VERSION = 148; /** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: * @@ -192,7 +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 sensitive data (currently limited to sessions); key is a 32 byte hex string may be generated using update.php --gen-encryption-key */ + /** optional key to transparently encrypt sensitive data (currently limited to sessions and feed passwords), + * key is a 32 byte hex string which may be generated using `update.php --gen-encryption-key` */ const ENCRYPTION_KEY = "ENCRYPTION_KEY"; /** default values for all global configuration options */ diff --git a/classes/Feeds.php b/classes/Feeds.php index 7a81ec6c0..7d818598d 100644 --- a/classes/Feeds.php +++ b/classes/Feeds.php @@ -2376,5 +2376,29 @@ class Feeds extends Handler_Protected { return [$query, $skip_first_id]; } + + /** decrypts encrypted feed password if possible (key is available and data is a base64-encoded serialized object) + * + * @param $auth_pass possibly encrypted feed password + * + * @return string plaintext representation of an encrypted feed password if encrypted or plaintext password otherwise + * */ + static function decrypt_feed_pass(string $auth_pass) : string { + $key = Config::get(Config::ENCRYPTION_KEY); + + if ($auth_pass && $key) { + $auth_pass_serialized = @base64_decode($auth_pass); + + if ($auth_pass_serialized) { + $unserialized_data = @unserialize($auth_pass_serialized); + + if ($unserialized_data !== false) + return Crypt::decrypt_string($unserialized_data); + } + } + + return $auth_pass; + } + } diff --git a/classes/Pref_Feeds.php b/classes/Pref_Feeds.php index 537cc3c86..bc059b99f 100644 --- a/classes/Pref_Feeds.php +++ b/classes/Pref_Feeds.php @@ -560,6 +560,7 @@ class Pref_Feeds extends Handler_Protected { ob_end_clean(); $row["icon"] = Feeds::_get_icon($feed_id); + $row["auth_pass"] = Feeds::decrypt_feed_pass($row["auth_pass"]); $local_update_intervals = $update_intervals; $local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[Prefs::get(Prefs::DEFAULT_UPDATE_INTERVAL, $_SESSION['uid'])]); @@ -746,6 +747,11 @@ class Pref_Feeds extends Handler_Protected { $feed_language = clean($_POST["feed_language"] ?? ""); + $key = Config::get(Config::ENCRYPTION_KEY); + + if ($key && $auth_pass) + $auth_pass = base64_encode(serialize(Crypt::encrypt_string($auth_pass))); + if (!$batch) { /* $sth = $this->pdo->prepare("SELECT feed_url FROM ttrss_feeds WHERE id = ?"); diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index ee58416e3..8e3e56ee7 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -331,6 +331,8 @@ class RSSUtils { $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed->owner_uid); //$pluginhost->load_data(); + $feed_auth_pass_plaintext = Feeds::decrypt_feed_pass($feed->auth_pass); + $basic_info = []; $pluginhost->run_hooks_callback(PluginHost::HOOK_FEED_BASIC_INFO, function ($result) use (&$basic_info) { @@ -338,13 +340,13 @@ class RSSUtils { $basic_info = $result; return true; } - }, $basic_info, $feed->feed_url, $feed->owner_uid, $feed_id, $feed->auth_login, $feed->auth_pass); + }, $basic_info, $feed->feed_url, $feed->owner_uid, $feed_id, $feed->auth_login, $feed_auth_pass_plaintext); if (!$basic_info) { $feed_data = UrlHelper::fetch([ 'url' => $feed->feed_url, 'login' => $feed->auth_login, - 'pass' => $feed->auth_pass, + 'pass' => $feed_auth_pass_plaintext, 'timeout' => Config::get(Config::FEED_FETCH_TIMEOUT), ]); @@ -458,12 +460,14 @@ class RSSUtils { $hff_owner_uid = $feed_obj->owner_uid; $hff_feed_url = $feed_obj->feed_url; + $feed_auth_pass_plaintext = Feeds::decrypt_feed_pass($feed_obj->auth_pass); + $pluginhost->chain_hooks_callback(PluginHost::HOOK_FETCH_FEED, function ($result, $plugin) use (&$feed_data, $start_ts) { $feed_data = $result; Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); }, - $feed_data, $hff_feed_url, $hff_owner_uid, $feed, $last_article_timestamp, $feed_obj->auth_login, $feed_obj->auth_pass); + $feed_data, $hff_feed_url, $hff_owner_uid, $feed, $last_article_timestamp, $feed_obj->auth_login, $feed_auth_pass_plaintext); if ($feed_data) { Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE); @@ -510,7 +514,7 @@ class RSSUtils { $feed_data = UrlHelper::fetch([ "url" => $feed_obj->feed_url, "login" => $feed_obj->auth_login, - "pass" => $feed_obj->auth_pass, + "pass" => $feed_auth_pass_plaintext, "timeout" => $no_cache ? Config::get(Config::FEED_FETCH_NO_CACHE_TIMEOUT) : Config::get(Config::FEED_FETCH_TIMEOUT), "last_modified" => $force_refetch ? "" : $feed_obj->last_modified ]); diff --git a/sql/mysql/migrations/148.sql b/sql/mysql/migrations/148.sql new file mode 100644 index 000000000..64eca0972 --- /dev/null +++ b/sql/mysql/migrations/148.sql @@ -0,0 +1,3 @@ +alter table ttrss_feeds change auth_pass auth_pass text not null; + +update ttrss_version set schema_version = 148; diff --git a/sql/mysql/schema.sql b/sql/mysql/schema.sql index ffabab669..43be5b479 100644 --- a/sql/mysql/schema.sql +++ b/sql/mysql/schema.sql @@ -121,7 +121,7 @@ create table ttrss_feeds (id integer not null auto_increment primary key, favicon_is_custom boolean default null, site_url varchar(250) not null default '', auth_login varchar(250) not null default '', - auth_pass varchar(250) not null default '', + auth_pass text not null default '', parent_feed integer default null, private bool not null default false, rtl_content bool not null default false, diff --git a/sql/pgsql/migrations/148.sql b/sql/pgsql/migrations/148.sql new file mode 100644 index 000000000..98a04de3e --- /dev/null +++ b/sql/pgsql/migrations/148.sql @@ -0,0 +1,7 @@ +alter table ttrss_feeds rename column auth_pass to auth_pass_old; +alter table ttrss_feeds add column auth_pass text; +update ttrss_feeds set auth_pass = auth_pass_old; +alter table ttrss_feeds alter column auth_pass set not null; +alter table ttrss_feeds drop column auth_pass_old; + +update ttrss_version set schema_version = 148; diff --git a/sql/pgsql/schema.sql b/sql/pgsql/schema.sql index 938ccc905..acfa619c9 100644 --- a/sql/pgsql/schema.sql +++ b/sql/pgsql/schema.sql @@ -92,7 +92,7 @@ create table ttrss_feeds (id serial not null primary key, auth_login varchar(250) not null default '', parent_feed integer default null references ttrss_feeds(id) on delete set null, private boolean not null default false, - auth_pass varchar(250) not null default '', + auth_pass text not null default '', hidden boolean not null default false, include_in_digest boolean not null default true, rtl_content boolean not null default false, From f00d9a18f8d8778b8305f5d2110039fc1f61fb24 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Tue, 8 Apr 2025 09:43:03 +0300 Subject: [PATCH 5/7] if possible, automatically encrypt stored plaintext password for feed on update --- classes/RSSUtils.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/classes/RSSUtils.php b/classes/RSSUtils.php index 8e3e56ee7..f6a81d00f 100644 --- a/classes/RSSUtils.php +++ b/classes/RSSUtils.php @@ -462,6 +462,18 @@ class RSSUtils { $feed_auth_pass_plaintext = Feeds::decrypt_feed_pass($feed_obj->auth_pass); + // transparently encrypt plaintext password if possible + if ($feed_obj->auth_pass && $feed_auth_pass_plaintext === $feed_obj->auth_pass) { + $key = Config::get(Config::ENCRYPTION_KEY); + + if ($key) { + Debug::log("encrypting stored plaintext feed password...", Debug::LOG_VERBOSE); + + $feed_obj->auth_pass = base64_encode(serialize(Crypt::encrypt_string($feed_auth_pass_plaintext))); + $feed_obj->save(); + } + } + $pluginhost->chain_hooks_callback(PluginHost::HOOK_FETCH_FEED, function ($result, $plugin) use (&$feed_data, $start_ts) { $feed_data = $result; From 597971f2382c273106b6205b35f7810a4ed78476 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Tue, 8 Apr 2025 09:48:44 +0300 Subject: [PATCH 6/7] we no longer directly modify schema_version in migrations --- sql/mysql/migrations/148.sql | 1 - sql/pgsql/migrations/148.sql | 2 -- 2 files changed, 3 deletions(-) diff --git a/sql/mysql/migrations/148.sql b/sql/mysql/migrations/148.sql index 64eca0972..b981cadeb 100644 --- a/sql/mysql/migrations/148.sql +++ b/sql/mysql/migrations/148.sql @@ -1,3 +1,2 @@ alter table ttrss_feeds change auth_pass auth_pass text not null; -update ttrss_version set schema_version = 148; diff --git a/sql/pgsql/migrations/148.sql b/sql/pgsql/migrations/148.sql index 98a04de3e..68678e4f5 100644 --- a/sql/pgsql/migrations/148.sql +++ b/sql/pgsql/migrations/148.sql @@ -3,5 +3,3 @@ alter table ttrss_feeds add column auth_pass text; update ttrss_feeds set auth_pass = auth_pass_old; alter table ttrss_feeds alter column auth_pass set not null; alter table ttrss_feeds drop column auth_pass_old; - -update ttrss_version set schema_version = 148; From 17b4e98249462a1feb71586d10cd5293d9487ab8 Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Tue, 8 Apr 2025 13:52:00 +0300 Subject: [PATCH 7/7] spaces to tabs --- classes/Crypt.php | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/classes/Crypt.php b/classes/Crypt.php index d832e6530..2b3a7b788 100644 --- a/classes/Crypt.php +++ b/classes/Crypt.php @@ -1,15 +1,15 @@