moodle/lib/tests/encryption_test.php
Paul Holden c1c000aa15
MDL-71421 core: deprecate openssl fallbacks in encryption library.
Since c66dc591 the PHP Sodium library is required, negating the need
for the OpenSSL equivalent. Remove fallbacks where possible, leaving
only the ability to decrypt legacy OpenSSL-encrypted content (with
debugging).
2023-08-01 12:10:36 +01:00

272 lines
9.4 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
use advanced_testcase;
/**
* Test encryption.
*
* @package core
* @copyright 2020 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core\encryption
*/
class encryption_test extends advanced_testcase {
/**
* Clear junk created by tests.
*/
protected function tearDown(): void {
global $CFG;
$keyfile = encryption::get_key_file(encryption::METHOD_OPENSSL);
if (file_exists($keyfile)) {
chmod($keyfile, 0700);
}
$keyfile = encryption::get_key_file(encryption::METHOD_SODIUM);
if (file_exists($keyfile)) {
chmod($keyfile, 0700);
}
remove_dir($CFG->dataroot . '/secret');
unset($CFG->nokeygeneration);
}
protected function setUp(): void {
$this->tearDown();
require_once(__DIR__ . '/fixtures/testable_encryption.php');
}
/**
* Many of the tests work with both encryption methods.
*
* @return array[] Array of method options for test
*/
public function encryption_method_provider(): array {
return [
'Sodium' => [encryption::METHOD_SODIUM],
];
}
/**
* Tests the create_keys and get_key functions.
*
* @param string $method Encryption method
* @dataProvider encryption_method_provider
*/
public function test_create_key(string $method): void {
encryption::create_key($method);
$key = testable_encryption::get_key($method);
$this->assertEquals(32, strlen($key));
$this->expectExceptionMessage('Key already exists');
encryption::create_key($method);
}
/**
* Test that we can create keys for legacy {@see encryption::METHOD_OPENSSL} content
*/
public function test_create_key_openssl(): void {
encryption::create_key(encryption::METHOD_OPENSSL);
$key = testable_encryption::get_key(encryption::METHOD_OPENSSL);
$this->assertEquals(32, strlen($key));
$this->expectExceptionMessage('Key already exists');
encryption::create_key(encryption::METHOD_OPENSSL);
}
/**
* Tests encryption and decryption with empty strings.
*/
public function test_encrypt_and_decrypt_empty(): void {
$this->assertEquals('', encryption::encrypt(''));
$this->assertEquals('', encryption::decrypt(''));
}
/**
* Tests encryption when the keys weren't created yet.
*
* @param string $method Encryption method
* @dataProvider encryption_method_provider
*/
public function test_encrypt_nokeys(string $method): void {
global $CFG;
// Prevent automatic generation of keys.
$CFG->nokeygeneration = true;
$this->expectExceptionMessage('Key not found');
encryption::encrypt('frogs', $method);
}
/**
* Test that attempting to encrypt with legacy {@see encryption::METHOD_OPENSSL} method falls back to Sodium
*/
public function test_encrypt_openssl(): void {
$encrypted = encryption::encrypt('Frogs', encryption::METHOD_OPENSSL);
$this->assertStringStartsWith(encryption::METHOD_SODIUM . ':', $encrypted);
$this->assertDebuggingCalledCount(1, ['Encryption using legacy OpenSSL is deprecated, reverting to Sodium']);
}
/**
* Tests decryption when the data has a different encryption method
*/
public function test_decrypt_wrongmethod(): void {
$this->expectExceptionMessage('Data does not match a supported encryption method');
encryption::decrypt('FAKE-CIPHER-METHOD:xx');
}
/**
* Tests decryption when not enough data is supplied to get the IV and some data.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_decrypt_tooshort(string $method): void {
$this->expectExceptionMessage('Insufficient data');
switch ($method) {
case encryption::METHOD_OPENSSL:
// It needs min 49 bytes (16 bytes IV + 32 bytes HMAC + 1 byte data).
$justtooshort = '0123456789abcdef0123456789abcdef0123456789abcdef';
break;
case encryption::METHOD_SODIUM:
// Sodium needs 25 bytes at least as far as our code is concerned (24 bytes IV + 1
// byte data); it splits out any authentication hashes itself.
$justtooshort = '0123456789abcdef01234567';
break;
}
encryption::decrypt($method . ':' .base64_encode($justtooshort));
}
/**
* Tests decryption when data is not valid base64.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_decrypt_notbase64(string $method): void {
$this->expectExceptionMessage('Invalid base64 data');
encryption::decrypt($method . ':' . chr(160));
}
/**
* Tests decryption when the keys weren't created yet.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_decrypt_nokeys(string $method): void {
global $CFG;
// Prevent automatic generation of keys.
$CFG->nokeygeneration = true;
$this->expectExceptionMessage('Key not found');
encryption::decrypt($method . ':' . base64_encode(
'0123456789abcdef0123456789abcdef0123456789abcdef0'));
}
/**
* Test that we can decrypt legacy {@see encryption::METHOD_OPENSSL} content
*/
public function test_decrypt_openssl(): void {
$key = testable_encryption::get_key(encryption::METHOD_OPENSSL);
// Construct encrypted string using openssl method/cipher.
$iv = random_bytes(openssl_cipher_iv_length(encryption::OPENSSL_CIPHER));
$encrypted = @openssl_encrypt('Frogs', encryption::OPENSSL_CIPHER, $key, OPENSSL_RAW_DATA, $iv);
$hmac = hash_hmac('sha256', $iv . $encrypted, $key, true);
$decrypted = encryption::decrypt(encryption::METHOD_OPENSSL . ':' . base64_encode($iv . $encrypted . $hmac));
$this->assertEquals('Frogs', $decrypted);
$this->assertDebuggingCalledCount(1, ['Decryption using legacy OpenSSL is deprecated, please upgrade to Sodium']);
}
/**
* Test automatic generation of keys when needed.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_auto_key_generation(string $method): void {
// Allow automatic generation (default).
$encrypted = encryption::encrypt('frogs', $method);
$this->assertEquals('frogs', encryption::decrypt($encrypted));
}
/**
* Checks that invalid key causes failures.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_invalid_key(string $method): void {
global $CFG;
// Set the key to something bogus.
$folder = $CFG->dataroot . '/secret/key';
check_dir_exists($folder);
file_put_contents(encryption::get_key_file($method), 'silly');
switch ($method) {
case encryption::METHOD_SODIUM:
$this->expectExceptionMessageMatches('/(should|must) be SODIUM_CRYPTO_SECRETBOX_KEYBYTES bytes/');
break;
case encryption::METHOD_OPENSSL:
$this->expectExceptionMessage('Invalid key');
break;
}
encryption::encrypt('frogs', $method);
}
/**
* Checks that modified data causes failures.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_modified_data(string $method): void {
$encrypted = encryption::encrypt('frogs', $method);
$mainbit = base64_decode(substr($encrypted, strlen($method) + 1));
$mainbit = substr($mainbit, 0, 16) . 'X' . substr($mainbit, 16);
$encrypted = $method . ':' . base64_encode($mainbit);
$this->expectExceptionMessage('Integrity check failed');
encryption::decrypt($encrypted);
}
/**
* Tests encryption and decryption for real.
*
* @dataProvider encryption_method_provider
* @param string $method Encryption method
*/
public function test_encrypt_and_decrypt_realdata(string $method): void {
// Encrypt short string.
$encrypted = encryption::encrypt('frogs', $method);
$this->assertNotEquals('frogs', $encrypted);
$this->assertEquals('frogs', encryption::decrypt($encrypted));
// Encrypt really long string (1 MB).
$long = str_repeat('X', 1024 * 1024);
$this->assertEquals($long, encryption::decrypt(encryption::encrypt($long, $method)));
}
}