You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

727 lines
23 KiB

<?php
/**
* @package tikiwiki
*/
// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
//
// All Rights Reserved. See copyright.txt for details and a complete list of authors.
// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
// $Id$
/*
* CryptLib (aims to) safely store encrypted data, e.g. passwords for external systems, in Tiki.
* The encrypted data can only be decrypted by the owner/user.
*
* CryptLib will use openssl if the PHP extension is available.
* Otherwise it reverts to (near-plaintext) Base64 encoding.
*
* In order to use openssl
* 1. The openssl PHP extension must be available
* 2. Call the init method before using cryptlib
*
* Before Tiki 18, CryptLib used the mcrypt library, which is being deprecated in PHP 7.1 and removed from the standard installation starting with PHP 7.2.
* See http://php.net/manual/en/migration71.deprecated.php for details.
* In order to convert existing, encrypted data, the mcrypt must be used.
* Thus CryptLib still may attempt to use mcrypt if such data is found.
*
* The method setUserData encrypts the value and stores a user preference
* getUserData reads it back into cleartext
*
* The secret key phrase is the MD5 sum of the username + Tiki password.
* The secret key is thus 1) personal 2) not stored anywhere in Tiki.
*
* Each encryption uses its own initialization vector (seed).
* Rehashing the same value should thus yield a different result every time.
*
* When a user logs in, Tiki calls onUserLogin, which registers the current secret key in a session variable.
* This session variable is used to decrypt the stored user passwords when needed.
*
* When a user changes the password, Tiki will call onChangeUserPassword. There the value must be rehashed.
* Changing a user's Tiki password directly in the database will not fire onChangeUserPassword,
* making the stored passwords unreadable.
*
* The system needs to have the username + both the old and new passwords in cleartext,
* in order order to be able to rehash the encrypted data. This may not always be possible.
* When an admin "hard" sets a user password, without having to know the previous password,
* the old password is unknown. The encrypted data can then no longer be decrypted when the user logs in,
* since the "secret key" has changed. The user will have to re-enter the lost data.
* A recovery is possible. The recovery mechanism should call onChangeUserPassword.
*/
class CryptLib extends TikiLib
{
// MCrypt attributes (Old, phased out encryption) . Kept for conversion of existing data
private $mcrypt_key; // mcrypt key
private $mcrypt; // mcrypt object
private $mcrypt_prefprefix = 'dp'; // prefix for user pref keys: 'test' => 'dp.test'
// OpenSSL attributes
private $hasOpenSSL = false;
private $cryptMethod = 'aes-256-ctr';
private $key; // crypt key
private $prefprefix = 'ds'; // prefix for user pref keys: 'test' => 'ds.test'
// Sodium attributes
private $hasSodium = false;
//
// Init and release
////////////////////////////////
public function __construct()
{
parent::__construct();
}
public function __destruct()
{
$this->release();
}
public function init()
{
if (! isset($_SESSION['cryptphrase'])) {
throw new Exception(tra('Unable to locate cryptphrase'));
}
$phraseMD5 = $_SESSION['cryptphrase'];
$this->initSeed($phraseMD5);
}
public function initSeed($phraseMD5)
{
if (extension_loaded('sodium')) {
$this->hasSodium = true;
$this->prefprefix = 'du';
$this->key = $phraseMD5;
}
if (extension_loaded('openssl')) {
$this->hasOpenSSL = true;
$this->key = $phraseMD5;
}
if (extension_loaded('mcrypt') && $this->mcrypt == null) {
$this->mcrypt_key = $phraseMD5;
// Using Rijndael 256 in CBC mode.
$this->mcrypt = mcrypt_module_open(MCRYPT_RIJNDAEL_256, '', 'cbc', '');
}
}
public function makeCryptPhrase($username, $cleartextPwd)
{
return md5($username . $cleartextPwd);
}
public function release()
{
if ($this->hasOpenSSL) {
$this->key = null;
}
if ($this->mcrypt != null) {
mcrypt_module_close($this->mcrypt);
$this->mcrypt_key = null;
$this->mcrypt = null;
}
}
public function generateKey($algo)
{
if (extension_loaded('sodium')) {
return random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
}
if (extension_loaded('openssl')) {
$keylen = openssl_cipher_iv_length($algo);
return openssl_random_pseudo_bytes($keylen);
}
throw new Exception(tra('No encryption extension found.'));
}
public function isSupported()
{
return extension_loaded('sodium') || extension_loaded('openssl');
}
public function algorithms()
{
if (extension_loaded('sodium')) {
return [];
}
if (extension_loaded('openssl')) {
return openssl_get_cipher_methods();
}
return [];
}
//
// Test/Check utilities
////////////////////////////////
/**
* Check if Sodium encryption is used
*
* @return bool
*/
public function hasSodiumCrypt()
{
return $this->hasSodium;
}
// Check if encryption is used (and not Base64)
public function hasCrypt()
{
return $this->hasOpenSSL;
}
// Check if MCrypt module is available in case a conversion is needed
public function hasMCrypt()
{
return $this->mcrypt != null;
}
// Check if any data exists the user preference.
// Return true if data exit (not necessarily readable). false, if no stored data is found
public function hasUserData($userprefKey, $paramName = '')
{
global $user;
if (! empty($paramName)) {
$paramName = '.' . $paramName;
}
$storedPwd64 = $this->get_user_preference($user, $this->prefprefix . '.' . $userprefKey . $paramName);
if (empty($storedPwd64)) {
// Check if old, mcrypt encrypted data exist
// Decryption is done when getting data
$storedPwdMCrypt = $this->get_user_preference($user, $this->mcrypt_prefprefix . '.' . $userprefKey . $paramName);
if (empty($storedPwdMCrypt)) {
return false;
}
}
return true;
}
/**
* Get the number of rows associated with the specified cryptographic method
* @param string $method "mcrypt" for MCrypt, or "openssl" for OpenSSL, or "sodium" for Sodium
* @return int Number of rows
*/
public function getUserCryptDataStats($method)
{
if ($method == 'mcrypt') {
$pattern = 'dp.%';
} elseif ($method == 'openssl') {
$pattern = 'ds.%';
} elseif ($method == 'sodium') {
$pattern = 'du.%';
} else {
throw new DomainException('Invalid method');
}
return $this->getOne('SELECT count(*) as Nr FROM `tiki_user_preferences` WHERE `prefName` like \'' . $pattern . "'");
}
//
// User data utilities
////////////////////////////////
/*
* Encrypt and save the data in the user preferences.
* The class specified prefix will be applied to the pref key.
* So, is the paramName, if specified. Given...
* $prefprefix = 'pwddom';
* $userprefKey = 'test'
* $paramName = ''
* => pwddom.test
* if $paramName = 'user', then
* * => pwddom.test.user
*/
//
// Return false on failure otherwise the generated crypt text
public function setUserData($userprefKey, $cleartext, $paramName = '')
{
global $user;
if (empty($cleartext)) {
return false;
}
$storedPwd64 = $this->encryptData($cleartext);
if (! empty($paramName)) {
$paramName = '.' . $paramName;
}
$this->set_user_preference($user, $this->prefprefix . '.' . $userprefKey . $paramName, $storedPwd64);
return $storedPwd64;
}
/*
* Encrypt and save the data in the user preferences for the specified user.
* The class specified prefix will be applied to the pref key.
* So, is the paramName, if specified. Given...
* $username = 'myuser'
* $prefprefix = 'pwddom';
* $userprefKey = 'test'
* $paramName = ''
* => pwddom.test
* if $paramName = 'user', then
* * => pwddom.test.user
*/
//
// Return false on failure otherwise the generated crypt text
public function putUserData($username, $userprefKey, $cleartext, $paramName = '')
{
if (empty($cleartext)) {
return false;
}
$storedPwd64 = $this->encryptData($cleartext);
if (! empty($paramName)) {
$paramName = '.' . $paramName;
}
$this->set_user_preference($username, $this->prefprefix . '.' . $userprefKey . $paramName, $storedPwd64);
return $storedPwd64;
}
// Get the data from the user preferences.
// Decrypt and return cleartext
// Return false, if no stored data is found
public function getUserData($userprefKey, $paramName = '')
{
global $user;
if (! empty($paramName)) {
$paramName = '.' . $paramName;
}
$storedPwd64 = $this->get_user_preference($user, $this->prefprefix . '.' . $userprefKey . $paramName);
if (empty($storedPwd64)) {
return false;
} else {
$cleartext = $this->decryptData($storedPwd64);
}
// Check if the cleartext contain any illigal password character.
// If found, it indicates that the decryption has failed.
if (! ctype_print($cleartext)) {
return false;
}
return $cleartext;
}
// Recover the stored cleartext data from the user preferences.
// Return stored data in cleartext or false on error
/*
* WARNING: Not converted to OpenSSL
function recoverUserData($username, $cleartextPwd, $userprefKey, $paramName = '')
{
if (empty($cleartextPwd)) {
return false;
}
// Initialize using the input params
$cryptlib = new CryptLib();
$phraseMD5 = md5($username.$cleartextPwd);
$cryptlib->initSeed($phraseMD5);
// Build the pref key
if (!empty($paramName)) {
$paramName = '.'.$paramName;
}
$prefKey = $cryptlib->prefprefix.'.'.$userprefKey.$paramName;
// Get the stored data
$storedPwd64 = $cryptlib->get_user_preference($username, $prefKey);
if (empty($storedPwd64)) {
return false;
}
// Decrypt
$cleartext = $cryptlib->decryptData($storedPwd64);
// Check if the cleartext contain any illigal password character.
// If found, it indicates that the decryption has failed.
if (!ctype_print ($cleartext)) {
return false;
}
return $cleartext;
}
*/
public function getPasswordDomains($use_prefix = false)
{
global $prefs;
// Load the domain ddefinitions
$domainsText = $prefs['feature_password_domains'];
$domains = explode(',', $domainsText);
// Trim whitespace from names
foreach ($domains as &$dom) {
$dom = trim($dom);
}
// Add prefix
if ($use_prefix) {
foreach ($domains as &$dom) {
$dom = $this->prefprefix . '.' . $dom;
}
}
return $domains;
}
//
// Data encryption
////////////////////////////////
// Encrypt data
// Return encrypted data, or false on error
public function encryptData($cleartextData)
{
if (empty($cleartextData)) {
return false;
}
// Due to appending spaces to short input data, short cleartext data cannot end with space
$pwdLen = mb_strlen($cleartextData);
if ($pwdLen < 20 && $cleartextData[$pwdLen] == ' ') {
throw new Exception('Data to encrypt cannot end with a space');
}
// Make sure the data is at least 20 characters long
// The spaces are trimmed when decrypting
while (mb_strlen($cleartextData) < 20) {
$cleartextData .= ' ';
}
// Encrypt the data
$cryptData = $this->encrypt($cleartextData);
if (empty($cryptData)) {
return false;
}
// Save iv in the stored data
$cryptData64 = base64_encode($cryptData);
return $cryptData64;
}
// Decrypt data
// Return cleartext data, or false on error
public function decryptData($cryptData64)
{
if (empty($cryptData64)) {
return false;
}
// Extract the iv and crypttext
$cryptData = base64_decode($cryptData64);
// Decrypt
$cleartext = $this->decrypt($cryptData);
return rtrim($cleartext);
}
//
// Tiki events
////////////////////////////////
// User has logged in
public function onUserLogin($cleartextPwd)
{
global $user;
// Encode the phrase
$phraseMD5 = $this->makeCryptPhrase($user, $cleartextPwd);
// Store the pass phrase in a session variable
$_SESSION['cryptphrase'] = $phraseMD5;
$this->convertMCryptDataToOpenSSL($user);
$this->convertOpenSSLDataToSodium($user);
$this->convertPendingEncryptionData($user);
}
// User has changed the password
// Change/Rehash the password, given the old and the new key phrases
public function onChangeUserPassword($oldCleartextPwd, $newCleartextPwd)
{
global $user;
// Lookup pref key that are encrypted data
$domains = $this->getPasswordDomains();
// Rehash encrypted preferences
foreach ($domains as $userprefKey) {
$rc = $this->changeUserPassword($userprefKey, md5($user . $oldCleartextPwd), md5($user . $newCleartextPwd));
// Also update the username, if defined
if ($rc && $this->hasUserData($userprefKey, 'usr')) {
$this->changeUserPassword($userprefKey . '.usr', md5($user . $oldCleartextPwd), md5($user . $newCleartextPwd));
}
}
// Save the new cryptphrase, so the new hash is readable without logging out
$this->onUserLogin($newCleartextPwd);
}
// Change/Rehash the password, given the old and the new key phrases
// Return true on success; otherwise false, e.g. if no stored password is found, or a decryption failure
public function changeUserPassword($userprefKey, $oldPhraseMD5, $newPhraseMD5)
{
global $user;
// Retrieve the old password
$cryptOld = new CryptLib();
$cryptOld->initSeed($oldPhraseMD5);
if (! $cryptOld->hasCrypt()) {
// Crypt is not available.
// Only Base64 encoding. No conversion needed
return false;
}
$cleartextPwd = $cryptOld->getUserData($userprefKey);
$cryptOld->release();
if ($cleartextPwd == false) {
return false;
}
// Check if the cleartext contain any illigal password character.
// If found, it indicates that the decryption has failed. The $oldPhraseMD5 may be incorrect?
// Then, do not proceed to rehash the password
if (! ctype_print($cleartextPwd)) {
return false;
}
// Rehash and save
$cryptNew = new CryptLib();
$cryptNew->initSeed($newPhraseMD5);
$cryptPwd = $cryptNew->setUserData($userprefKey, $cleartextPwd);
$cryptNew->release();
if ($cryptPwd == false) {
return false;
}
// Rehashed OK
return true;
}
//
// Crypt
////////////////////////////////
// Use OpenSSL if available. Otherwise Base64 encode only
// Return base64 encoded string, containing either the crypttext with the iv prepended, or the cleartext if on base64 encoding is used
private function encrypt($cleartext)
{
if ($this->hasSodiumCrypt()) {
$key = str_pad(substr($this->key, 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES), SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$crypttext = $nonce . sodium_crypto_secretbox($cleartext, $nonce, $key);
} elseif ($this->hasCrypt()) {
$ivSize = openssl_cipher_iv_length($this->cryptMethod);
$iv = openssl_random_pseudo_bytes($ivSize);
$crypttext = openssl_encrypt(
$cleartext,
$this->cryptMethod,
$this->key,
OPENSSL_RAW_DATA,
$iv
);
// Prepend the iv
$crypttext = $iv . $crypttext;
} else {
$crypttext = base64_encode($cleartext);
}
return $crypttext;
}
// Use OpenSSL if available. Otherwise Base64 decode
// Return cleartext
private function decrypt($crypttext)
{
if ($this->hasSodiumCrypt()) {
$key = str_pad(substr($this->key, 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES), SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$nonce = substr($crypttext, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertextLength = strlen($crypttext) - SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
$crypttext = substr($crypttext, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, $ciphertextLength);
$rawcleartext = sodium_crypto_secretbox_open($crypttext, $nonce, $key);
} elseif ($this->hasCrypt()) {
$ivSize = openssl_cipher_iv_length($this->cryptMethod);
$iv = substr($crypttext, 0, $ivSize);
$ciphertext = substr($crypttext, $ivSize);
$rawcleartext = openssl_decrypt(
$ciphertext,
$this->cryptMethod,
$this->key,
OPENSSL_RAW_DATA,
$iv
);
} else {
// Use Base64 encoding
$rawcleartext = base64_decode($crypttext);
}
// Clear trailing null-characters
$cleartext = rtrim($rawcleartext);
return $cleartext;
}
//
// Old MCrypt coded data conversion
////////////////////////////////
/**
* Decrypt OpenSSL data
*
* @param $crypttext
* @return Mixed
*/
private function decryptOpenSll($crypttext)
{
$cleartext = null;
if ($this->hasCrypt()) {
$crypttext = base64_decode($crypttext);
$ivSize = openssl_cipher_iv_length($this->cryptMethod);
$iv = substr($crypttext, 0, $ivSize);
$ciphertext = substr($crypttext, $ivSize);
$cleartext = openssl_decrypt(
$ciphertext,
$this->cryptMethod,
$this->key,
OPENSSL_RAW_DATA,
$iv
);
// Clear trailing null-characters
$cleartext = rtrim($cleartext);
}
return $cleartext;
}
// Use MCrypt if available. Otherwise Base64 decode
// Return cleartext
private function decryptMcrypt($cryptData64)
{
if ($this->hasMCrypt()) {
$cryptData = base64_decode($cryptData64);
$ivSize = mcrypt_enc_get_iv_size($this->mcrypt);
$iv = substr($cryptData, 0, $ivSize);
$crypttext = substr($cryptData, $ivSize);
$rawcleartext = mcrypt_decrypt(MCRYPT_RIJNDAEL_256, $this->mcrypt_key, $crypttext, MCRYPT_MODE_CBC, $iv);
// Clear trailing null-characters
$cleartext = rtrim($rawcleartext);
} else {
// Use Base64 encoding
$cleartext = base64_decode($cryptData64);
}
return $cleartext;
}
private function convertMCryptDataToOpenSSL($login)
{
$this->init();
// Convert encrypted data, if OpenSSL is installed
if ($this->hasCrypt()) {
$query = 'SELECT `prefName` , `value` FROM `tiki_user_preferences` WHERE `prefName` like \'dp.%\' and `user` = ?';
$result = $this->query($query, [$login]);
while ($row = $result->fetchRow()) {
$orgPrefName = $row['prefName'];
$storedPwdMCrypt64 = $row['value'];
if ($this->hasMCrypt()) {
$cleartext = $this->decryptMcrypt($storedPwdMCrypt64);
// Strip dp. from prefName
$prefName = str_replace('dp.', '', $orgPrefName);
// Add new OpenSSL coded user data
$this->setUserData($prefName, $cleartext);
}
// Delete old Mcrypt coded user data
$userPreferences = $this->table('tiki_user_preferences', false);
$userPreferences->delete(['user' => $login, 'prefName' => $orgPrefName]);
}
}
}
/**
* Convert OpenSSL encrypted data to Sodium
*
* @param $login
* @return null
*/
private function convertOpenSSLDataToSodium($login)
{
$this->init();
// Convert encrypted OpenSSL data, if Sodium is installed
if ($this->hasSodiumCrypt()) {
$query = 'SELECT `prefName` , `value` FROM `tiki_user_preferences` WHERE `prefName` like \'ds.%\' and `user` = ?';
$result = $this->query($query, [$login]);
while ($row = $result->fetchRow()) {
$orgPrefName = $row['prefName'];
$storedPwdMCrypt64 = $row['value'];
if ($this->hasCrypt()) {
$cleartext = $this->decryptOpenSll($storedPwdMCrypt64, $orgPrefName);
if (empty($cleartext)) {
continue;
}
// Strip ds. from prefName
$prefName = str_replace('ds.', '', $orgPrefName);
// Add new Sodium coded user data
$this->setUserData($prefName, $cleartext);
}
// Delete old OpenSSL coded user data
$userPreferences = $this->table('tiki_user_preferences', false);
$userPreferences->delete(['user' => $login, 'prefName' => $orgPrefName]);
}
}
}
/**
* Convert cleartext data scheduled for encryption
*
* @param $login
* @return null
*/
private function convertPendingEncryptionData($login)
{
$this->init();
$query = 'SELECT `prefName` , `value` FROM `tiki_user_preferences` WHERE `prefName` like \'pe.%\' and `user` = ?';
$result = $this->query($query, [$login]);
while ($row = $result->fetchRow()) {
$orgPrefName = $row['prefName'];
$cleartext = $row['value'];
// Strip ds. from prefName
$prefName = str_replace('pe.', '', $orgPrefName);
// Add new encrypted user data
$this->setUserData($prefName, $cleartext);
// Delete old cleartext user data
$userPreferences = $this->table('tiki_user_preferences', false);
$userPreferences->delete(['user' => $login, 'prefName' => $orgPrefName]);
}
}
}