Browse Source

Add some security utilities

This adds some security utilities to core including:
- A library for basic crypto operations (e.g. to encrypt passwords)
- A better library for cryptographic actions which allows you to specify the charset
- A library for secure string comparisions

Remove .htaccess

Remove .htaccess

Fix typo

Add public API

Use timing constant comparision

Remove CBC constant

Adjust code

Remove confusing $this
tags/v8.0.0alpha1
Lukas Reschke 9 years ago
parent
commit
d26a9c3c58

+ 3
- 0
config/config.sample.php View File

@@ -35,6 +35,9 @@ $CONFIG = array(
/* Define the salt used to hash the user passwords. All your user passwords are lost if you lose this string. */
"passwordsalt" => "",

/* Secret used by ownCloud for various purposes, e.g. to encrypt data. If you lose this string there will be data corruption. */
"secret" => "",

/* Force use of HTTPS connection (true = use HTTPS) */
"forcessl" => false,


+ 0
- 1
core/setup/controller.php View File

@@ -153,7 +153,6 @@ class Controller {
'hasMSSQL' => $hasMSSQL,
'databases' => $databases,
'directory' => $datadir,
'secureRNG' => \OC_Util::secureRNGAvailable(),
'htaccessWorking' => $htaccessWorking,
'vulnerableToNullByte' => $vulnerableToNullByte,
'errors' => $errors,

+ 0
- 7
core/templates/installation.php View File

@@ -27,13 +27,6 @@
<?php p($l->t('Please update your PHP installation to use %s securely.', $theme->getName() )); ?></p>
</fieldset>
<?php endif; ?>
<?php if(!$_['secureRNG']): ?>
<fieldset class="warning">
<legend><strong><?php p($l->t('Security Warning'));?></strong></legend>
<p><?php p($l->t('No secure random number generator is available, please enable the PHP OpenSSL extension.'));?><br/>
<?php p($l->t('Without a secure random number generator an attacker may be able to predict password reset tokens and take over your account.'));?></p>
</fieldset>
<?php endif; ?>
<?php if(!$_['htaccessWorking']): ?>
<fieldset class="warning">
<legend><strong><?php p($l->t('Security Warning'));?></strong></legend>

+ 1
- 0
lib/private/repair.php View File

@@ -71,6 +71,7 @@ class Repair extends BasicEmitter {
return array(
new \OC\Repair\RepairMimeTypes(),
new \OC\Repair\RepairLegacyStorages(\OC::$server->getConfig(), \OC_DB::getConnection()),
new \OC\Repair\RepairConfig(),
);
}


+ 104
- 0
lib/private/security/crypto.php View File

@@ -0,0 +1,104 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/


namespace OC\Security;

use Crypt_AES;
use Crypt_Hash;
use OCP\Security\ICrypto;
use OCP\Security\StringUtils;

/**
* Class Crypto provides a high-level encryption layer using AES-CBC. If no key has been provided
* it will use the secret defined in config.php as key. Additionally the message will be HMAC'd.
*
* Usage:
* $encryptWithDefaultPassword = \OC::$server->getCrypto()->encrypt('EncryptedText');
* $encryptWithCustompassword = \OC::$server->getCrypto()->encrypt('EncryptedText', 'password');
*
* @package OC\Security
*/
class Crypto implements ICrypto {
/** @var Crypt_AES $cipher */
private $cipher;
/** @var int */
private $ivLength = 16;

function __construct() {
$this->cipher = new Crypt_AES();
}

/**
* @param string $message The message to authenticate
* @param string $password Password to use (defaults to `secret` in config.php)
* @return string Calculated HMAC
*/
public function calculateHMAC($message, $password = '') {
if($password === '') {
$password = \OC::$server->getConfig()->getSystemValue('secret');
}

$hash = new Crypt_Hash('sha512');
$hash->setKey($password);
return $hash->hash($message);
}

/**
* Encrypts a value and adds an HMAC (Encrypt-Then-MAC)
* @param string $plaintext
* @param string $password Password to encrypt, if not specified the secret from config.php will be taken
* @return string Authenticated ciphertext
*/
public function encrypt($plaintext, $password = '') {
if($password === '') {
$password = \OC::$server->getConfig()->getSystemValue('secret');
}
$this->cipher->setPassword($password);

$iv = \OC::$server->getSecureRandom()->getLowStrengthGenerator()->generate($this->ivLength);
$this->cipher->setIV($iv);

$ciphertext = bin2hex($this->cipher->encrypt($plaintext));
$hmac = bin2hex($this->calculateHMAC($ciphertext.$iv, $password));

return $ciphertext.'|'.$iv.'|'.$hmac;
}

/**
* Decrypts a value and verifies the HMAC (Encrypt-Then-Mac)
* @param string $authenticatedCiphertext
* @param string $password Password to encrypt, if not specified the secret from config.php will be taken
* @return string plaintext
* @throws \Exception If the HMAC does not match
*/
public function decrypt($authenticatedCiphertext, $password = '') {
if($password === '') {
$password = \OC::$server->getConfig()->getSystemValue('secret');
}
$this->cipher->setPassword($password);

$parts = explode('|', $authenticatedCiphertext);
if(sizeof($parts) !== 3) {
throw new \Exception('Authenticated ciphertext could not be decoded.');
}

$ciphertext = hex2bin($parts[0]);
$iv = $parts[1];
$hmac = hex2bin($parts[2]);

$this->cipher->setIV($iv);

if(!StringUtils::equals($this->calculateHMAC($parts[0].$parts[1], $password), $hmac)) {
throw new \Exception('HMAC does not match.');
}

return $this->cipher->decrypt($ciphertext);
}

}

+ 79
- 0
lib/private/security/securerandom.php View File

@@ -0,0 +1,79 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

namespace OC\Security;

use RandomLib;
use Sabre\DAV\Exception;
use OCP\Security\ISecureRandom;

/**
* Class SecureRandom provides a layer around RandomLib to generate
* secure random strings.
*
* Usage:
* \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate(10);
*
* @package OC\Security
*/
class SecureRandom implements ISecureRandom {

/** @var \RandomLib\Factory */
var $factory;
/** @var \RandomLib\Generator */
var $generator;

function __construct() {
$this->factory = new RandomLib\Factory;
}

/**
* Convenience method to get a low strength random number generator.
*
* Low Strength should be used anywhere that random strings are needed
* in a non-cryptographical setting. They are not strong enough to be
* used as keys or salts. They are however useful for one-time use tokens.
*
* @return $this
*/
public function getLowStrengthGenerator() {
$this->generator = $this->factory->getLowStrengthGenerator();
return $this;
}

/**
* Convenience method to get a medium strength random number generator.
*
* Medium Strength should be used for most needs of a cryptographic nature.
* They are strong enough to be used as keys and salts. However, they do
* take some time and resources to generate, so they should not be over-used
*
* @return $this
*/
public function getMediumStrengthGenerator() {
$this->generator = $this->factory->getMediumStrengthGenerator();
return $this;
}

/**
* Generate a random string of specified length.
* @param string $length The length of the generated string
* @param string $characters An optional list of characters to use if no characterlist is
* specified 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ./
* is used.
* @return string
* @throws \Exception If the generator is not initialized.
*/
public function generate($length, $characters = '') {
if(is_null($this->generator)) {
throw new \Exception('Generator is not initialized.');
}

return $this->generator->generateString($length, $characters);
}
}

+ 38
- 0
lib/private/security/stringutils.php View File

@@ -0,0 +1,38 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

namespace OC\Security;

class StringUtils {

/**
* Compares whether two strings are equal. To prevent guessing of the string
* length this is done by comparing two hashes against each other and afterwards
* a comparison of the real string to prevent against the unlikely chance of
* collisions.
* @param string $expected The expected value
* @param string $input The input to compare against
* @return bool True if the two strings are equal, otherwise false.
*/
public static function equals($expected, $input) {

if(function_exists('hash_equals')) {
return hash_equals($expected, $input);
}

$randomString = \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate(10);

if(hash('sha512', $expected.$randomString) === hash('sha512', $input.$randomString)) {
if($expected === $input) {
return true;
}
}

return false;
}
}

+ 26
- 0
lib/private/server.php View File

@@ -9,6 +9,8 @@ use OC\Cache\UserCache;
use OC\DB\ConnectionWrapper;
use OC\Files\Node\Root;
use OC\Files\View;
use OC\Security\Crypto;
use OC\Security\SecureRandom;
use OCP\IServerContainer;

/**
@@ -199,6 +201,12 @@ class Server extends SimpleContainer implements IServerContainer {
$this->registerService('Search', function ($c) {
return new Search();
});
$this->registerService('SecureRandom', function($c) {
return new SecureRandom();
});
$this->registerService('Crypto', function($c) {
return new Crypto();
});
$this->registerService('Db', function ($c) {
return new Db();
});
@@ -455,6 +463,24 @@ class Server extends SimpleContainer implements IServerContainer {
return $this->query('Search');
}

/**
* Returns a SecureRandom instance
*
* @return \OCP\Security\ISecureRandom
*/
function getSecureRandom() {
return $this->query('SecureRandom');
}

/**
* Returns a Crypto instance
*
* @return \OCP\Security\ICrypto
*/
function getCrypto() {
return $this->query('Crypto');
}

/**
* Returns an instance of the db facade
*

+ 11
- 6
lib/private/setup.php View File

@@ -67,14 +67,19 @@ class OC_Setup {
}

//generate a random salt that is used to salt the local user passwords
$salt = OC_Util::generateRandomBytes(30);
OC_Config::setValue('passwordsalt', $salt);
$salt = \OC::$server->getSecureRandom()->getLowStrengthGenerator()->generate(30);
\OC::$server->getConfig()->setSystemValue('passwordsalt', $salt);

// generate a secret
$secret = \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate(48);
\OC::$server->getConfig()->setSystemValue('secret', $secret);

//write the config file
OC_Config::setValue('trusted_domains', $trustedDomains);
OC_Config::setValue('datadirectory', $datadir);
OC_Config::setValue('dbtype', $dbtype);
OC_Config::setValue('version', implode('.', OC_Util::getVersion()));
\OC::$server->getConfig()->setSystemValue('trusted_domains', $trustedDomains);
\OC::$server->getConfig()->setSystemValue('datadirectory', $datadir);
\OC::$server->getConfig()->setSystemValue('dbtype', $dbtype);
\OC::$server->getConfig()->setSystemValue('version', implode('.', OC_Util::getVersion()));

try {
$dbSetup->initialize($options);
$dbSetup->setupDatabase($username);

+ 6
- 48
lib/private/util.php View File

@@ -905,7 +905,7 @@ class OC_Util {
$id = OC_Config::getValue('instanceid', null);
if (is_null($id)) {
// We need to guarantee at least one letter in instanceid so it can be used as the session_name
$id = 'oc' . self::generateRandomBytes(10);
$id = 'oc' . \OC::$server->getSecureRandom()->getLowStrengthGenerator()->generate(10);
OC_Config::$object->setValue('instanceid', $id);
}
return $id;
@@ -1208,62 +1208,20 @@ class OC_Util {
*
* @param int $length of the random string
* @return string
* Please also update secureRNGAvailable if you change something here
* @deprecated Use \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate($length); instead
*/
public static function generateRandomBytes($length = 30) {
// Try to use openssl_random_pseudo_bytes
if (function_exists('openssl_random_pseudo_bytes')) {
$pseudoByte = bin2hex(openssl_random_pseudo_bytes($length, $strong));
if ($strong == true) {
return substr($pseudoByte, 0, $length); // Truncate it to match the length
}
}

// Try to use /dev/urandom
if (!self::runningOnWindows()) {
$fp = @file_get_contents('/dev/urandom', false, null, 0, $length);
if ($fp !== false) {
$string = substr(bin2hex($fp), 0, $length);
return $string;
}
}

// Fallback to mt_rand()
$characters = '0123456789';
$characters .= 'abcdefghijklmnopqrstuvwxyz';
$charactersLength = strlen($characters) - 1;
$pseudoByte = "";

// Select some random characters
for ($i = 0; $i < $length; $i++) {
$pseudoByte .= $characters[mt_rand(0, $charactersLength)];
}
return $pseudoByte;
return \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate($length);
}

/**
* Checks if a secure random number generator is available
*
* @return bool
* @return true
* @deprecated Function will be removed in the future and does only return true.
*/
public static function secureRNGAvailable() {
// Check openssl_random_pseudo_bytes
if (function_exists('openssl_random_pseudo_bytes')) {
openssl_random_pseudo_bytes(1, $strong);
if ($strong == true) {
return true;
}
}

// Check /dev/urandom
if (!self::runningOnWindows()) {
$fp = @file_get_contents('/dev/urandom', false, null, 0, 1);
if ($fp !== false) {
return true;
}
}

return false;
return true;
}

/**

+ 46
- 0
lib/public/security/icrypto.php View File

@@ -0,0 +1,46 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

namespace OCP\Security;

/**
* Class Crypto provides a high-level encryption layer using AES-CBC. If no key has been provided
* it will use the secret defined in config.php as key. Additionally the message will be HMAC'd.
*
* Usage:
* $encryptWithDefaultPassword = \OC::$server->getCrypto()->encrypt('EncryptedText');
* $encryptWithCustomPassword = \OC::$server->getCrypto()->encrypt('EncryptedText', 'password');
*
* @package OCP\Security
*/
interface ICrypto {

/**
* @param string $message The message to authenticate
* @param string $password Password to use (defaults to `secret` in config.php)
* @return string Calculated HMAC
*/
public function calculateHMAC($message, $password = '');

/**
* Encrypts a value and adds an HMAC (Encrypt-Then-MAC)
* @param string $plaintext
* @param string $password Password to encrypt, if not specified the secret from config.php will be taken
* @return string Authenticated ciphertext
*/
public function encrypt($plaintext, $password = '');

/**
* Decrypts a value and verifies the HMAC (Encrypt-Then-Mac)
* @param string $authenticatedCiphertext
* @param string $password Password to encrypt, if not specified the secret from config.php will be taken
* @return string plaintext
* @throws \Exception If the HMAC does not match
*/
public function decrypt($authenticatedCiphertext, $password = '');
}

+ 53
- 0
lib/public/security/isecurerandom.php View File

@@ -0,0 +1,53 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

namespace OCP\Security;

/**
* Class SecureRandom provides a layer around RandomLib to generate
* secure random numbers.
*
* Usage:
* $rng = new \OC\Security\SecureRandom();
* $randomString = $rng->getMediumStrengthGenerator()->generateString(30);
*
* @package OCP\Security
*/
interface ISecureRandom {

/**
* Convenience method to get a low strength random number generator.
*
* Low Strength should be used anywhere that random strings are needed
* in a non-cryptographical setting. They are not strong enough to be
* used as keys or salts. They are however useful for one-time use tokens.
*
* @return $this
*/
public function getLowStrengthGenerator();

/**
* Convenience method to get a medium strength random number generator.
*
* Medium Strength should be used for most needs of a cryptographic nature.
* They are strong enough to be used as keys and salts. However, they do
* take some time and resources to generate, so they should not be over-used
*
* @return $this
*/
public function getMediumStrengthGenerator();

/**
* Generate a random string of specified length.
* @param string $length The length of the generated string
* @param string $characters An optional list of characters to use
* @return string
* @throws \Exception
*/
public function generate($length, $characters = '');
}

+ 25
- 0
lib/public/security/stringutils.php View File

@@ -0,0 +1,25 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/


namespace OCP\Security;

class StringUtils {
/**
* Compares whether two strings are equal. To prevent guessing of the string
* length this is done by comparing two hashes against each other and afterwards
* a comparison of the real string to prevent against the unlikely chance of
* collisions.
* @param string $expected The expected value
* @param string $input The input to compare against
* @return bool True if the two strings are equal, otherwise false.
*/
public static function equals($expected, $input) {
return \OC\Security\StringUtils::equals($expected, $input);
}
}

+ 1
- 0
lib/public/util.php View File

@@ -505,6 +505,7 @@ class Util {
* Generates a cryptographic secure pseudo-random string
* @param int $length of the random string
* @return string
* @deprecated Use \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate($length); instead
*/
public static function generateRandomBytes($length = 30) {
return \OC_Util::generateRandomBytes($length);

+ 37
- 0
lib/repair/repairconfig.php View File

@@ -0,0 +1,37 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

namespace OC\Repair;

use OC\Hooks\BasicEmitter;
use OC\RepairStep;
use Sabre\DAV\Exception;

class RepairConfig extends BasicEmitter implements RepairStep {

public function getName() {
return 'Repair config';
}

/**
* Updates the configuration after running an update
*/
public function run() {
$this->addSecret();
}

/**
* Adds a secret to config.php
*/
private function addSecret() {
if(\OC::$server->getConfig()->getSystemValue('secret', null) === null) {
$secret = \OC::$server->getSecureRandom()->getMediumStrengthGenerator()->generate(48);
\OC::$server->getConfig()->setSystemValue('secret', $secret);
}
}
}

+ 63
- 0
tests/lib/security/crypto.php View File

@@ -0,0 +1,63 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

use \OC\Security\Crypto;

class CryptoTest extends \PHPUnit_Framework_TestCase {

function testDefaultEncrypt() {
$stringToEncrypt = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt.';
$crypto = new Crypto();
$ciphertext = $crypto->encrypt($stringToEncrypt);
$this->assertEquals($stringToEncrypt, $crypto->decrypt($ciphertext));

$stringToEncrypt = '';
$ciphertext = $crypto->encrypt($stringToEncrypt);
$this->assertEquals($stringToEncrypt, $crypto->decrypt($ciphertext));
}

/**
* @expectedException \Exception
* @expectedExceptionMessage HMAC does not match.
*/
function testWrongPassword() {
$stringToEncrypt = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt.';
$encryptCrypto = new Crypto();
$ciphertext = $encryptCrypto->encrypt($stringToEncrypt);

$decryptCrypto = new Crypto();
$this->assertFalse($decryptCrypto->decrypt($ciphertext, 'A wrong password!'));
}

function testLaterDecryption() {
$stringToEncrypt = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt.';
$encryptedString = '560f5436ba864b9f12f7f7ca6d41c327554a6f2c0a160a03316b202af07c65163274993f3a46e7547c07ba89304f00594a2f3bd99f83859097c58049c39d0d4ade10e0de914ff0604961e7c849d0271ed6c0b23f984ba16e7d033e3305fb0910e7b6a2a65c988d17dbee71d8f953684d|d2kdFUspVjC0Y0sr|1a5feacf87eaa6869a6abdfba9a296e7bbad45b6ad89f7dce67cdc98e2da5dc4379cc672cc655e52bbf19599bf59482fbea13a73937697fa656bf10f3fc4f1aa';
$crypto = new Crypto();
$this->assertEquals($stringToEncrypt, $crypto->decrypt($encryptedString, 'ThisIsAVeryS3cur3P4ssw0rd'));
}

/**
* @expectedException \Exception
* @expectedExceptionMessage HMAC does not match.
*/
function testWrongIV() {
$encryptedString = '560f5436ba864b9f12f7f7ca6d41c327554a6f2c0a160a03316b202af07c65163274993f3a46e7547c07ba89304f00594a2f3bd99f83859097c58049c39d0d4ade10e0de914ff0604961e7c849d0271ed6c0b23f984ba16e7d033e3305fb0910e7b6a2a65c988d17dbee71d8f953684d|d2kdFUspVjC0o0sr|1a5feacf87eaa6869a6abdfba9a296e7bbad45b6ad89f7dce67cdc98e2da5dc4379cc672cc655e52bbf19599bf59482fbea13a73937697fa656bf10f3fc4f1aa';
$crypto = new Crypto();
$crypto->decrypt($encryptedString, 'ThisIsAVeryS3cur3P4ssw0rd');
}

/**
* @expectedException \Exception
* @expectedExceptionMessage Authenticated ciphertext could not be decoded.
*/
function testWrongParameters() {
$encryptedString = '1|2';
$crypto = new Crypto();
$crypto->decrypt($encryptedString, 'ThisIsAVeryS3cur3P4ssw0rd');
}
}

+ 51
- 0
tests/lib/security/securerandom.php View File

@@ -0,0 +1,51 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

class SecureRandomTest extends \PHPUnit_Framework_TestCase {

public function stringGenerationProvider() {
return array(
array(0, 0),
array(1, 1),
array(128, 128),
array(256, 256),
array(1024, 1024),
array(2048, 2048),
array(64000, 64000),
);
}

/**
* @dataProvider stringGenerationProvider
*/
function testGetLowStrengthGeneratorLength($length, $expectedLength) {
$rng = new \OC\Security\SecureRandom();
$generator = $rng->getLowStrengthGenerator();

$this->assertEquals($expectedLength, strlen($generator->generate($length)));
}

/**
* @dataProvider stringGenerationProvider
*/
function testMediumLowStrengthGeneratorLength($length, $expectedLength) {
$rng = new \OC\Security\SecureRandom();
$generator = $rng->getMediumStrengthGenerator();

$this->assertEquals($expectedLength, strlen($generator->generate($length)));
}

/**
* @expectedException \Exception
* @expectedExceptionMessage Generator is not initialized
*/
function testUninitializedGenerate() {
$rng = new \OC\Security\SecureRandom();
$rng->generate(30);
}
}

+ 21
- 0
tests/lib/security/stringutils.php View File

@@ -0,0 +1,21 @@
<?php
/**
* Copyright (c) 2014 Lukas Reschke <lukas@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

use \OC\Security\StringUtils;

class StringUtilsTest extends \PHPUnit_Framework_TestCase {

function testEquals() {
$this->assertTrue(StringUtils::equals('GpKY9fSnWRaeFNJbES99zVGvA', 'GpKY9fSnWRaeFNJbES99zVGvA'));
$this->assertFalse(StringUtils::equals('GpKY9fSnWNJbES99zVGvA', 'GpKY9fSnWRaeFNJbES99zVGvA'));
$this->assertFalse(StringUtils::equals('', 'GpKY9fSnWRaeFNJbES99zVGvA'));
$this->assertFalse(StringUtils::equals('GpKY9fSnWRaeFNJbES99zVGvA', ''));
$this->assertTrue(StringUtils::equals('', ''));
}

}

Loading…
Cancel
Save