drop SESSION-specific stuff and move encrypt/decrypt helpers to a separate class; add a command line flag to generate encryption keys

This commit is contained in:
Andrew Dolgov 2025-04-08 08:55:44 +03:00
parent 58677fc791
commit 25d3ce4ee8
No known key found for this signature in database
GPG Key ID: 1A56B4FA25D4AF2A
4 changed files with 75 additions and 52 deletions

View File

@ -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 */ /** 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 stored session data using Sodium library (XChaCha20-Poly1305) - generate using bin2hex(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()) */ /** 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 SESSION_ENCRYPTION_KEY = "SESSION_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 = [
@ -253,7 +253,7 @@ class Config {
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::SESSION_ENCRYPTION_KEY => [ "", Config::T_STRING ] Config::ENCRYPTION_KEY => [ "", Config::T_STRING ]
]; ];
private static ?Config $instance = null; private static ?Config $instance = null;

62
classes/Crypt.php Normal file
View 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']);
}
}

View File

@ -8,8 +8,6 @@ class Sessions implements \SessionHandlerInterface {
private int $session_expire; private int $session_expire;
private string $session_name; private string $session_name;
private const SODIUM_ALGO = 'xchacha20poly1305_ietf';
public function __construct() { public function __construct() {
$this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME)); $this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME));
$this->session_name = Config::get(Config::SESSION_NAME); $this->session_name = Config::get(Config::SESSION_NAME);
@ -55,48 +53,6 @@ class Sessions implements \SessionHandlerInterface {
return true; return true;
} }
/** encrypts provided ciphertext using Sodium symmetric encryption key if available via Config::SESSION_ENCRYPTION_KEY
*
* @return array<string,mixed> 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<string,mixed> $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 { public function read(string $id): false|string {
$sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?'); $sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?');
$sth->execute([$id]); $sth->execute([$id]);
@ -104,14 +60,14 @@ class Sessions implements \SessionHandlerInterface {
if ($row = $sth->fetch()) { if ($row = $sth->fetch()) {
$data = base64_decode($row['data']); $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 $unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message
if ($unserialized_data !== false) 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; return $data;
} }
@ -124,8 +80,8 @@ class Sessions implements \SessionHandlerInterface {
public function write(string $id, string $data): bool { public function write(string $id, string $data): bool {
if (Config::get(Config::SESSION_ENCRYPTION_KEY)) if (Config::get(Config::ENCRYPTION_KEY))
$data = serialize($this->encrypt_string($data)); $data = serialize(Crypt::encrypt_string($data));
$data = base64_encode($data); $data = base64_encode($data);

View File

@ -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);