summaryrefslogtreecommitdiff
path: root/classes
diff options
context:
space:
mode:
authorAndrew Dolgov <fox@fakecake.org>2025-04-08 10:54:24 +0000
committerAndrew Dolgov <fox@fakecake.org>2025-04-08 10:54:24 +0000
commit008c518d5d8e60c0168cd107dbfd1f23f9c4a701 (patch)
tree8f3d8afbbf73ae7d59791a3347a557c194f691d0 /classes
parentbb2c4b380165731c3f8abf0596fffb2a0953265b (diff)
parent17b4e98249462a1feb71586d10cd5293d9487ab8 (diff)
Merge branch 'session-encryption' into 'master'
add optional encryption for stored session data using Sodium library See merge request tt-rss/tt-rss!117
Diffstat (limited to 'classes')
-rw-r--r--classes/Config.php9
-rw-r--r--classes/Crypt.php62
-rw-r--r--classes/Feeds.php24
-rw-r--r--classes/Pref_Feeds.php6
-rw-r--r--classes/RSSUtils.php24
-rw-r--r--classes/Sessions.php17
6 files changed, 135 insertions, 7 deletions
diff --git a/classes/Config.php b/classes/Config.php
index 92037ff74..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,6 +192,10 @@ 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 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 */
private const _DEFAULTS = [
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
@@ -249,7 +253,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::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..2b3a7b788
--- /dev/null
+++ b/classes/Crypt.php
@@ -0,0 +1,62 @@
+<?php
+class Crypt {
+
+ /** the only algo supported at the moment */
+ private const ENCRYPT_ALGO = 'xchacha20poly1305_ietf';
+
+ /** currently only generates keys using sodium_crypto_aead_chacha20poly1305_keygen() i.e. one supported Crypt::ENCRYPT_ALGO
+ * @return string random 256-bit (for ChaCha20-Poly1305) binary string
+ */
+ static function generate_key() : string {
+ return sodium_crypto_aead_chacha20poly1305_keygen();
+ }
+
+ /** encrypts provided ciphertext using Config::ENCRYPTION_KEY into an encrypted object
+ *
+ * @return array{'algo': string, 'nonce': string, 'payload': string} encrypted data object containing algo, nonce, and encrypted data
+ */
+ static function encrypt_string(string $ciphertext) : array {
+ $key = Config::get(Config::ENCRYPTION_KEY);
+
+ if (!$key)
+ throw new Exception("Crypt::encrypt_string() failed to encrypt - key is not available");
+
+ $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::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/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..f6a81d00f 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,26 @@ 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);
+
+ // 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;
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 +526,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/classes/Sessions.php b/classes/Sessions.php
index 5c586154b..f3c0cea42 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::ENCRYPTION_KEY)) {
+ $unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message
+
+ if ($unserialized_data !== false)
+ return Crypt::decrypt_string($unserialized_data);
+ }
+
+ // if encryption key is missing or session data is not in serialized format, assume plaintext data and 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::ENCRYPTION_KEY))
+ $data = serialize(Crypt::encrypt_string($data));
+
$data = base64_encode($data);
+
$expire = time() + $this->session_expire;
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');