mirror of
https://git.tt-rss.org/fox/tt-rss.git
synced 2025-09-21 21:41:00 +02:00
Merge branch 'session-encryption' into 'master'
add optional encryption for stored session data using Sodium library See merge request tt-rss/tt-rss!117
This commit is contained in:
commit
008c518d5d
@ -16,7 +16,7 @@ RUN [ ! -z ${ALPINE_MIRROR} ] && \
|
|||||||
apk add --no-cache ca-certificates dcron git postgresql-client rsync sudo tzdata \
|
apk add --no-cache ca-certificates dcron git postgresql-client rsync sudo tzdata \
|
||||||
php${PHP_SUFFIX} \
|
php${PHP_SUFFIX} \
|
||||||
$(for p in ctype curl dom exif fileinfo fpm gd iconv intl json mbstring opcache \
|
$(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"; \
|
php_pkgs="$php_pkgs php${PHP_SUFFIX}-$p"; \
|
||||||
done; \
|
done; \
|
||||||
echo $php_pkgs) && \
|
echo $php_pkgs) && \
|
||||||
|
@ -6,7 +6,7 @@ class Config {
|
|||||||
const T_STRING = 2;
|
const T_STRING = 2;
|
||||||
const T_INT = 3;
|
const T_INT = 3;
|
||||||
|
|
||||||
const SCHEMA_VERSION = 147;
|
const SCHEMA_VERSION = 148;
|
||||||
|
|
||||||
/** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
|
/** 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 */
|
/** 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";
|
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 */
|
/** default values for all global configuration options */
|
||||||
private const _DEFAULTS = [
|
private const _DEFAULTS = [
|
||||||
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
|
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::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)',
|
||||||
Config::T_STRING ],
|
Config::T_STRING ],
|
||||||
Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ],
|
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;
|
private static ?Config $instance = null;
|
||||||
|
62
classes/Crypt.php
Normal file
62
classes/Crypt.php
Normal file
@ -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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -2376,5 +2376,29 @@ class Feeds extends Handler_Protected {
|
|||||||
return [$query, $skip_first_id];
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -560,6 +560,7 @@ class Pref_Feeds extends Handler_Protected {
|
|||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
$row["icon"] = Feeds::_get_icon($feed_id);
|
$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 = $update_intervals;
|
||||||
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[Prefs::get(Prefs::DEFAULT_UPDATE_INTERVAL, $_SESSION['uid'])]);
|
$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"] ?? "");
|
$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) {
|
if (!$batch) {
|
||||||
|
|
||||||
/* $sth = $this->pdo->prepare("SELECT feed_url FROM ttrss_feeds WHERE id = ?");
|
/* $sth = $this->pdo->prepare("SELECT feed_url FROM ttrss_feeds WHERE id = ?");
|
||||||
|
@ -331,6 +331,8 @@ class RSSUtils {
|
|||||||
$pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed->owner_uid);
|
$pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed->owner_uid);
|
||||||
//$pluginhost->load_data();
|
//$pluginhost->load_data();
|
||||||
|
|
||||||
|
$feed_auth_pass_plaintext = Feeds::decrypt_feed_pass($feed->auth_pass);
|
||||||
|
|
||||||
$basic_info = [];
|
$basic_info = [];
|
||||||
|
|
||||||
$pluginhost->run_hooks_callback(PluginHost::HOOK_FEED_BASIC_INFO, function ($result) use (&$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;
|
$basic_info = $result;
|
||||||
return true;
|
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) {
|
if (!$basic_info) {
|
||||||
$feed_data = UrlHelper::fetch([
|
$feed_data = UrlHelper::fetch([
|
||||||
'url' => $feed->feed_url,
|
'url' => $feed->feed_url,
|
||||||
'login' => $feed->auth_login,
|
'login' => $feed->auth_login,
|
||||||
'pass' => $feed->auth_pass,
|
'pass' => $feed_auth_pass_plaintext,
|
||||||
'timeout' => Config::get(Config::FEED_FETCH_TIMEOUT),
|
'timeout' => Config::get(Config::FEED_FETCH_TIMEOUT),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -458,12 +460,26 @@ class RSSUtils {
|
|||||||
$hff_owner_uid = $feed_obj->owner_uid;
|
$hff_owner_uid = $feed_obj->owner_uid;
|
||||||
$hff_feed_url = $feed_obj->feed_url;
|
$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,
|
$pluginhost->chain_hooks_callback(PluginHost::HOOK_FETCH_FEED,
|
||||||
function ($result, $plugin) use (&$feed_data, $start_ts) {
|
function ($result, $plugin) use (&$feed_data, $start_ts) {
|
||||||
$feed_data = $result;
|
$feed_data = $result;
|
||||||
Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE);
|
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) {
|
if ($feed_data) {
|
||||||
Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE);
|
Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE);
|
||||||
@ -510,7 +526,7 @@ class RSSUtils {
|
|||||||
$feed_data = UrlHelper::fetch([
|
$feed_data = UrlHelper::fetch([
|
||||||
"url" => $feed_obj->feed_url,
|
"url" => $feed_obj->feed_url,
|
||||||
"login" => $feed_obj->auth_login,
|
"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),
|
"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
|
"last_modified" => $force_refetch ? "" : $feed_obj->last_modified
|
||||||
]);
|
]);
|
||||||
|
@ -58,7 +58,17 @@ class Sessions implements \SessionHandlerInterface {
|
|||||||
$sth->execute([$id]);
|
$sth->execute([$id]);
|
||||||
|
|
||||||
if ($row = $sth->fetch()) {
|
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;
|
$expire = time() + $this->session_expire;
|
||||||
@ -69,7 +79,12 @@ class Sessions implements \SessionHandlerInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function write(string $id, string $data): bool {
|
public function write(string $id, string $data): bool {
|
||||||
|
|
||||||
|
if (Config::get(Config::ENCRYPTION_KEY))
|
||||||
|
$data = serialize(Crypt::encrypt_string($data));
|
||||||
|
|
||||||
$data = base64_encode($data);
|
$data = base64_encode($data);
|
||||||
|
|
||||||
$expire = time() + $this->session_expire;
|
$expire = time() + $this->session_expire;
|
||||||
|
|
||||||
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');
|
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');
|
||||||
|
2
sql/mysql/migrations/148.sql
Normal file
2
sql/mysql/migrations/148.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
alter table ttrss_feeds change auth_pass auth_pass text not null;
|
||||||
|
|
@ -121,7 +121,7 @@ create table ttrss_feeds (id integer not null auto_increment primary key,
|
|||||||
favicon_is_custom boolean default null,
|
favicon_is_custom boolean default null,
|
||||||
site_url varchar(250) not null default '',
|
site_url varchar(250) not null default '',
|
||||||
auth_login 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,
|
parent_feed integer default null,
|
||||||
private bool not null default false,
|
private bool not null default false,
|
||||||
rtl_content bool not null default false,
|
rtl_content bool not null default false,
|
||||||
|
5
sql/pgsql/migrations/148.sql
Normal file
5
sql/pgsql/migrations/148.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
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;
|
@ -92,7 +92,7 @@ create table ttrss_feeds (id serial not null primary key,
|
|||||||
auth_login varchar(250) not null default '',
|
auth_login varchar(250) not null default '',
|
||||||
parent_feed integer default null references ttrss_feeds(id) on delete set null,
|
parent_feed integer default null references ttrss_feeds(id) on delete set null,
|
||||||
private boolean not null default false,
|
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,
|
hidden boolean not null default false,
|
||||||
include_in_digest boolean not null default true,
|
include_in_digest boolean not null default true,
|
||||||
rtl_content boolean not null default false,
|
rtl_content boolean not null default false,
|
||||||
|
@ -84,6 +84,7 @@
|
|||||||
"update-schema::" => ["[force-yes]", "update database schema, optionally without prompting"],
|
"update-schema::" => ["[force-yes]", "update database schema, optionally without prompting"],
|
||||||
"force-update" => "mark all feeds as pending update",
|
"force-update" => "mark all feeds as pending update",
|
||||||
"gen-search-idx" => "generate basic PostgreSQL fulltext search index",
|
"gen-search-idx" => "generate basic PostgreSQL fulltext search index",
|
||||||
|
"gen-encryption-key" => "generate an encryption key (ChaCha20-Poly1305)",
|
||||||
"plugins-list" => "list installed plugins",
|
"plugins-list" => "list installed plugins",
|
||||||
"debug-feed:" => ["N", "update specified feed with debug output enabled"],
|
"debug-feed:" => ["N", "update specified feed with debug output enabled"],
|
||||||
"force-refetch" => "debug update: force refetch feed data",
|
"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"])) {
|
if (isset($options["plugins-list"])) {
|
||||||
$tmppluginhost = new PluginHost();
|
$tmppluginhost = new PluginHost();
|
||||||
$tmppluginhost->load_all($tmppluginhost::KIND_ALL);
|
$tmppluginhost->load_all($tmppluginhost::KIND_ALL);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user