Merge pull request #54316 from nextcloud/feat/add-encryption-integration-tests

feat(encryption): Add integration tests for occ commands and fix them
pull/55015/head
Ferdinand Thiessen 2025-09-10 18:08:37 +07:00 committed by GitHub
commit 6cdf098378
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 250 additions and 286 deletions

@ -1,11 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\Tests\unit\Connector\Sabre\RequestTest;
use OCP\AppFramework\Http;

@ -85,7 +85,7 @@ class DropLegacyFileKey extends Command {
$output->writeln('<error>' . $path . ' does not have a proper header</error>');
} else {
try {
$legacyFileKey = $this->keyManager->getFileKey($path, null, true);
$legacyFileKey = $this->keyManager->getFileKey($path, true);
if ($legacyFileKey === '') {
$output->writeln('Got an empty legacy filekey for ' . $path . ', continuing', OutputInterface::VERBOSITY_VERBOSE);
continue;

@ -342,9 +342,8 @@ class Crypt {
* @param string $privateKey
* @param string $password
* @param string $uid for regular users, empty for system keys
* @return false|string
*/
public function decryptPrivateKey($privateKey, $password = '', $uid = '') {
public function decryptPrivateKey($privateKey, $password = '', $uid = '') : string|false {
$header = $this->parseHeader($privateKey);
if (isset($header['cipher'])) {

@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Encryption\Crypto;
use OCA\Encryption\Exceptions\PrivateKeyMissingException;
@ -18,14 +21,6 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
class DecryptAll {
/**
* @param Util $util
* @param KeyManager $keyManager
* @param Crypt $crypt
* @param Session $session
* @param QuestionHelper $questionHelper
*/
public function __construct(
protected Util $util,
protected KeyManager $keyManager,
@ -37,13 +32,8 @@ class DecryptAll {
/**
* prepare encryption module to decrypt all files
*
* @param InputInterface $input
* @param OutputInterface $output
* @param $user
* @return bool
*/
public function prepare(InputInterface $input, OutputInterface $output, $user) {
public function prepare(InputInterface $input, OutputInterface $output, ?string $user): bool {
$question = new Question('Please enter the recovery key password: ');
if ($this->util->isMasterKeyEnabled()) {
@ -52,7 +42,7 @@ class DecryptAll {
$password = $this->keyManager->getMasterKeyPassword();
} else {
$recoveryKeyId = $this->keyManager->getRecoveryKeyId();
if (!empty($user)) {
if ($user !== null && $user !== '') {
$output->writeln('You can only decrypt the users files if you know');
$output->writeln('the users password or if they activated the recovery key.');
$output->writeln('');
@ -96,12 +86,9 @@ class DecryptAll {
/**
* get the private key which will be used to decrypt all files
*
* @param string $user
* @param string $password
* @return bool|string
* @throws PrivateKeyMissingException
*/
protected function getPrivateKey($user, $password) {
protected function getPrivateKey(string $user, string $password): string|false {
$recoveryKeyId = $this->keyManager->getRecoveryKeyId();
$masterKeyId = $this->keyManager->getMasterKeyId();
if ($user === $recoveryKeyId) {
@ -118,7 +105,7 @@ class DecryptAll {
return $privateKey;
}
protected function updateSession($user, $privateKey) {
protected function updateSession(string $user, string $privateKey): void {
$this->session->prepareDecryptAll($user, $privateKey);
}
}

@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Encryption\Crypto;
use OC\Encryption\Exceptions\DecryptionFailedException;
@ -60,11 +63,8 @@ class EncryptAll {
/**
* start to encrypt all files
*
* @param InputInterface $input
* @param OutputInterface $output
*/
public function encryptAll(InputInterface $input, OutputInterface $output) {
public function encryptAll(InputInterface $input, OutputInterface $output): void {
$this->input = $input;
$this->output = $output;
@ -111,7 +111,7 @@ class EncryptAll {
/**
* create key-pair for every user
*/
protected function createKeyPairs() {
protected function createKeyPairs(): void {
$this->output->writeln("\n");
$progress = new ProgressBar($this->output);
$progress->setFormat(" %message% \n [%bar%]");
@ -146,7 +146,7 @@ class EncryptAll {
/**
* iterate over all user and encrypt their files
*/
protected function encryptAllUsersFiles() {
protected function encryptAllUsersFiles(): void {
$this->output->writeln("\n");
$progress = new ProgressBar($this->output);
$progress->setFormat(" %message% \n [%bar%]");
@ -168,10 +168,8 @@ class EncryptAll {
/**
* encrypt all user files with the master key
*
* @param ProgressBar $progress
*/
protected function encryptAllUserFilesWithMasterKey(ProgressBar $progress) {
protected function encryptAllUserFilesWithMasterKey(ProgressBar $progress): void {
$userNo = 1;
foreach ($this->userManager->getBackends() as $backend) {
$limit = 500;
@ -190,12 +188,8 @@ class EncryptAll {
/**
* encrypt files from the given user
*
* @param string $uid
* @param ProgressBar $progress
* @param string $userCount
*/
protected function encryptUsersFiles($uid, ProgressBar $progress, $userCount) {
protected function encryptUsersFiles(string $uid, ProgressBar $progress, string $userCount): void {
$this->setupUserFS($uid);
$directories = [];
$directories[] = '/' . $uid . '/files';
@ -268,7 +262,7 @@ class EncryptAll {
/**
* output one-time encryption passwords
*/
protected function outputPasswords() {
protected function outputPasswords(): void {
$table = new Table($this->output);
$table->setHeaders(['Username', 'Private key password']);
@ -309,10 +303,8 @@ class EncryptAll {
/**
* write one-time encryption passwords to a csv file
*
* @param array $passwords
*/
protected function writePasswordsToFile(array $passwords) {
protected function writePasswordsToFile(array $passwords): void {
$fp = $this->rootView->fopen('oneTimeEncryptionPasswords.csv', 'w');
foreach ($passwords as $pwd) {
fputcsv($fp, $pwd);
@ -330,10 +322,8 @@ class EncryptAll {
/**
* setup user file system
*
* @param string $uid
*/
protected function setupUserFS($uid) {
protected function setupUserFS(string $uid): void {
\OC_Util::tearDownFS();
\OC_Util::setupFS($uid);
}
@ -341,10 +331,9 @@ class EncryptAll {
/**
* generate one time password for the user and store it in a array
*
* @param string $uid
* @return string password
*/
protected function generateOneTimePassword($uid) {
protected function generateOneTimePassword(string $uid): string {
$password = $this->secureRandom->generate(16, ISecureRandom::CHAR_HUMAN_READABLE);
$this->userPasswords[$uid] = $password;
return $password;
@ -353,7 +342,7 @@ class EncryptAll {
/**
* send encryption key passwords to the users by mail
*/
protected function sendPasswordsByMail() {
protected function sendPasswordsByMail(): void {
$noMail = [];
$this->output->writeln('');

@ -127,7 +127,7 @@ class Encryption implements IEncryptionModule {
/* If useLegacyFileKey is not specified in header, auto-detect, to be safe */
$useLegacyFileKey = (($header['useLegacyFileKey'] ?? '') == 'false' ? false : null);
$this->fileKey = $this->keyManager->getFileKey($this->path, $this->user, $useLegacyFileKey, $this->session->decryptAllModeActivated());
$this->fileKey = $this->keyManager->getFileKey($this->path, $useLegacyFileKey, $this->session->decryptAllModeActivated());
// always use the version from the original file, also part files
// need to have a correct version number if they get moved over to the
@ -322,7 +322,7 @@ class Encryption implements IEncryptionModule {
* update encrypted file, e.g. give additional users access to the file
*
* @param string $path path to the file which should be updated
* @param string $uid of the user who performs the operation
* @param string $uid ignored
* @param array $accessList who has access to the file contains the key 'users' and 'public'
* @return bool
*/
@ -335,7 +335,7 @@ class Encryption implements IEncryptionModule {
return false;
}
$fileKey = $this->keyManager->getFileKey($path, $uid, null);
$fileKey = $this->keyManager->getFileKey($path, null);
if (!empty($fileKey)) {
$publicKeys = [];
@ -438,7 +438,7 @@ class Encryption implements IEncryptionModule {
* @throws DecryptionFailedException
*/
public function isReadable($path, $uid) {
$fileKey = $this->keyManager->getFileKey($path, $uid, null);
$fileKey = $this->keyManager->getFileKey($path, null);
if (empty($fileKey)) {
$owner = $this->util->getOwner($path);
if ($owner !== $uid) {

@ -23,7 +23,7 @@ class KeyManager {
private string $recoveryKeyId;
private string $publicShareKeyId;
private string $masterKeyId;
private string $keyId;
private ?string $keyUid;
private string $publicKeyId = 'publicKey';
private string $privateKeyId = 'privateKey';
private string $shareKeyId = 'shareKey';
@ -62,7 +62,7 @@ class KeyManager {
$this->config->setAppValue('encryption', 'masterKeyId', $this->masterKeyId);
}
$this->keyId = $userSession->isLoggedIn() ? $userSession->getUser()->getUID() : false;
$this->keyUid = $userSession->isLoggedIn() ? $userSession->getUser()?->getUID() : null;
}
/**
@ -136,7 +136,11 @@ class KeyManager {
if (!$this->session->isPrivateKeySet()) {
$masterKey = $this->getSystemPrivateKey($this->masterKeyId);
$decryptedMasterKey = $this->crypt->decryptPrivateKey($masterKey, $this->getMasterKeyPassword(), $this->masterKeyId);
$this->session->setPrivateKey($decryptedMasterKey);
if ($decryptedMasterKey === false) {
$this->logger->error('A public master key is available but decrypting it failed. This should never happen.');
} else {
$this->session->setPrivateKey($decryptedMasterKey);
}
}
// after the encryption key is available we are ready to go
@ -347,11 +351,8 @@ class KeyManager {
/**
* @param ?bool $useLegacyFileKey null means try both
*/
public function getFileKey(string $path, ?string $uid, ?bool $useLegacyFileKey, bool $useDecryptAll = false): string {
if ($uid === '') {
$uid = null;
}
$publicAccess = is_null($uid);
public function getFileKey(string $path, ?bool $useLegacyFileKey, bool $useDecryptAll = false): string {
$publicAccess = ($this->keyUid === null);
$encryptedFileKey = '';
if ($useLegacyFileKey ?? true) {
$encryptedFileKey = $this->keyStorage->getFileKey($path, $this->fileKeyId, Encryption::ID);
@ -380,6 +381,7 @@ class KeyManager {
$privateKey = $this->keyStorage->getSystemUserKey($this->publicShareKeyId . '.' . $this->privateKeyId, Encryption::ID);
$privateKey = $this->crypt->decryptPrivateKey($privateKey);
} else {
$uid = $this->keyUid;
$shareKey = $this->getShareKey($path, $uid);
$privateKey = $this->session->getPrivateKey();
}

@ -67,12 +67,8 @@ class Recovery {
/**
* change recovery key id
*
* @param string $newPassword
* @param string $oldPassword
* @return bool
*/
public function changeRecoveryKeyPassword($newPassword, $oldPassword) {
public function changeRecoveryKeyPassword(string $newPassword, string $oldPassword): bool {
$recoveryKey = $this->keyManager->getSystemPrivateKey($this->keyManager->getRecoveryKeyId());
$decryptedRecoveryKey = $this->crypt->decryptPrivateKey($recoveryKey, $oldPassword);
if ($decryptedRecoveryKey === false) {
@ -80,7 +76,7 @@ class Recovery {
}
$encryptedRecoveryKey = $this->crypt->encryptPrivateKey($decryptedRecoveryKey, $newPassword);
$header = $this->crypt->generateHeader();
if ($encryptedRecoveryKey) {
if ($encryptedRecoveryKey !== false) {
$this->keyManager->setSystemPrivateKey($this->keyManager->getRecoveryKeyId(), $header . $encryptedRecoveryKey);
return true;
}
@ -163,7 +159,7 @@ class Recovery {
if ($item['type'] === 'dir') {
$this->addRecoveryKeys($filePath . '/');
} else {
$fileKey = $this->keyManager->getFileKey($filePath, $this->user->getUID(), null);
$fileKey = $this->keyManager->getFileKey($filePath, null);
if (!empty($fileKey)) {
$accessList = $this->file->getAccessList($filePath);
$publicKeys = [];

@ -1,24 +1,23 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Encryption;
use OCA\Encryption\Exceptions\PrivateKeyMissingException;
use OCP\ISession;
class Session {
public const NOT_INITIALIZED = '0';
public const INIT_EXECUTED = '1';
public const INIT_SUCCESSFUL = '2';
/**
* @param ISession $session
*/
public function __construct(
protected ISession $session,
) {
@ -29,7 +28,7 @@ class Session {
*
* @param string $status INIT_SUCCESSFUL, INIT_EXECUTED, NOT_INITIALIZED
*/
public function setStatus($status) {
public function setStatus(string $status): void {
$this->session->set('encryptionInitialized', $status);
}
@ -38,7 +37,7 @@ class Session {
*
* @return string init status INIT_SUCCESSFUL, INIT_EXECUTED, NOT_INITIALIZED
*/
public function getStatus() {
public function getStatus(): string {
$status = $this->session->get('encryptionInitialized');
if (is_null($status)) {
$status = self::NOT_INITIALIZED;
@ -49,10 +48,8 @@ class Session {
/**
* check if encryption was initialized successfully
*
* @return bool
*/
public function isReady() {
public function isReady(): bool {
$status = $this->getStatus();
return $status === self::INIT_SUCCESSFUL;
}
@ -63,7 +60,7 @@ class Session {
* @return string $privateKey The user's plaintext private key
* @throws Exceptions\PrivateKeyMissingException
*/
public function getPrivateKey() {
public function getPrivateKey(): string {
$key = $this->session->get('privateKey');
if (is_null($key)) {
throw new PrivateKeyMissingException('please try to log-out and log-in again');
@ -73,10 +70,8 @@ class Session {
/**
* check if private key is set
*
* @return boolean
*/
public function isPrivateKeySet() {
public function isPrivateKeySet(): bool {
$key = $this->session->get('privateKey');
if (is_null($key)) {
return false;
@ -92,17 +87,14 @@ class Session {
*
* @note this should only be set on login
*/
public function setPrivateKey($key) {
public function setPrivateKey(string $key): void {
$this->session->set('privateKey', $key);
}
/**
* store data needed for the decrypt all operation in the session
*
* @param string $user
* @param string $key
*/
public function prepareDecryptAll($user, $key) {
public function prepareDecryptAll(string $user, string $key): void {
$this->session->set('decryptAll', true);
$this->session->set('decryptAllKey', $key);
$this->session->set('decryptAllUid', $user);
@ -110,10 +102,8 @@ class Session {
/**
* check if we are in decrypt all mode
*
* @return bool
*/
public function decryptAllModeActivated() {
public function decryptAllModeActivated(): bool {
$decryptAll = $this->session->get('decryptAll');
return ($decryptAll === true);
}
@ -121,10 +111,9 @@ class Session {
/**
* get uid used for decrypt all operation
*
* @return string
* @throws \Exception
*/
public function getDecryptAllUid() {
public function getDecryptAllUid(): string {
$uid = $this->session->get('decryptAllUid');
if (is_null($uid) && $this->decryptAllModeActivated()) {
throw new \Exception('No uid found while in decrypt all mode');
@ -138,10 +127,9 @@ class Session {
/**
* get private key for decrypt all operation
*
* @return string
* @throws PrivateKeyMissingException
*/
public function getDecryptAllKey() {
public function getDecryptAllKey(): string {
$privateKey = $this->session->get('decryptAllKey');
if (is_null($privateKey) && $this->decryptAllModeActivated()) {
throw new PrivateKeyMissingException('No private key found while in decrypt all mode');
@ -155,7 +143,7 @@ class Session {
/**
* remove keys from session
*/
public function clear() {
public function clear(): void {
$this->session->remove('publicSharePrivateKey');
$this->session->remove('privateKey');
$this->session->remove('encryptionInitialized');

@ -85,7 +85,7 @@ class RecoveryControllerTest extends TestCase {
->method('changeRecoveryKeyPassword')
->with($password, $oldPassword)
->willReturnMap([
['test', 'oldTestFail', false],
['test', 'oldtestFail', false],
['test', 'oldtest', true]
]);

@ -232,7 +232,7 @@ class EncryptionTest extends TestCase {
->willReturn(true);
$this->keyManagerMock->expects($this->once())
->method('getFileKey')
->with($path, 'user', null, true)
->with($path, null, true)
->willReturn($fileKey);
$this->instance->begin($path, 'user', 'r', [], []);

@ -335,32 +335,25 @@ class KeyManagerTest extends TestCase {
return [
['user1', false, 'privateKey', 'legacyKey', 'multiKeyDecryptResult'],
['user1', false, 'privateKey', '', 'multiKeyDecryptResult'],
['user1', false, false, 'legacyKey', ''],
['user1', false, false, '', ''],
['user1', false, '', 'legacyKey', ''],
['user1', false, '', '', ''],
['user1', true, 'privateKey', 'legacyKey', 'multiKeyDecryptResult'],
['user1', true, 'privateKey', '', 'multiKeyDecryptResult'],
['user1', true, false, 'legacyKey', ''],
['user1', true, false, '', ''],
['user1', true, '', 'legacyKey', ''],
['user1', true, '', '', ''],
[null, false, 'privateKey', 'legacyKey', 'multiKeyDecryptResult'],
[null, false, 'privateKey', '', 'multiKeyDecryptResult'],
[null, false, false, 'legacyKey', ''],
[null, false, false, '', ''],
[null, false, '', 'legacyKey', ''],
[null, false, '', '', ''],
[null, true, 'privateKey', 'legacyKey', 'multiKeyDecryptResult'],
[null, true, 'privateKey', '', 'multiKeyDecryptResult'],
[null, true, false, 'legacyKey', ''],
[null, true, false, '', ''],
[null, true, '', 'legacyKey', ''],
[null, true, '', '', ''],
];
}
/**
*
* @param $uid
* @param $isMasterKeyEnabled
* @param $privateKey
* @param $expected
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataTestGetFileKey')]
public function testGetFileKey($uid, $isMasterKeyEnabled, $privateKey, $encryptedFileKey, $expected): void {
public function testGetFileKey(?string $uid, bool $isMasterKeyEnabled, string $privateKey, string $encryptedFileKey, string $expected): void {
$path = '/foo.txt';
if ($isMasterKeyEnabled) {
@ -374,6 +367,7 @@ class KeyManagerTest extends TestCase {
}
$this->invokePrivate($this->instance, 'masterKeyId', ['masterKeyId']);
$this->invokePrivate($this->instance, 'keyUid', [$uid]);
$this->keyStorageMock->expects($this->exactly(2))
->method('getFileKey')
@ -423,7 +417,7 @@ class KeyManagerTest extends TestCase {
}
$this->assertSame($expected,
$this->instance->getFileKey($path, $uid, null)
$this->instance->getFileKey($path, null)
);
}

@ -7,6 +7,7 @@ declare(strict_types=1);
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Encryption\Tests;
use OC\Files\View;
@ -22,38 +23,16 @@ use Test\TestCase;
class RecoveryTest extends TestCase {
private static $tempStorage = [];
/**
* @var IFile|\PHPUnit\Framework\MockObject\MockObject
*/
private $fileMock;
/**
* @var View|\PHPUnit\Framework\MockObject\MockObject
*/
private $viewMock;
/**
* @var IUserSession|\PHPUnit\Framework\MockObject\MockObject
*/
private $userSessionMock;
/**
* @var MockObject|IUser
*/
private $user;
/**
* @var KeyManager|\PHPUnit\Framework\MockObject\MockObject
*/
private $keyManagerMock;
/**
* @var IConfig|\PHPUnit\Framework\MockObject\MockObject
*/
private $configMock;
/**
* @var Crypt|\PHPUnit\Framework\MockObject\MockObject
*/
private $cryptMock;
/**
* @var Recovery
*/
private $instance;
private IFile&MockObject $fileMock;
private View&MockObject $viewMock;
private IUserSession&MockObject $userSessionMock;
private IUser&MockObject $user;
private KeyManager&MockObject $keyManagerMock;
private IConfig&MockObject $configMock;
private Crypt&MockObject $cryptMock;
private Recovery $instance;
public function testEnableAdminRecoverySuccessful(): void {
$this->keyManagerMock->expects($this->exactly(2))
@ -122,21 +101,23 @@ class RecoveryTest extends TestCase {
}
public function testChangeRecoveryKeyPasswordSuccessful(): void {
$this->assertFalse($this->instance->changeRecoveryKeyPassword('password',
'passwordOld'));
$this->assertFalse($this->instance->changeRecoveryKeyPassword('password', 'passwordOld'));
$this->keyManagerMock->expects($this->once())
->method('getSystemPrivateKey');
->method('getSystemPrivateKey')
->willReturn('privateKey');
$this->cryptMock->expects($this->once())
->method('decryptPrivateKey');
->method('decryptPrivateKey')
->with('privateKey', 'passwordOld')
->willReturn('decryptedPrivateKey');
$this->cryptMock->expects($this->once())
->method('encryptPrivateKey')
->willReturn(true);
->with('decryptedPrivateKey', 'password')
->willReturn('privateKey');
$this->assertTrue($this->instance->changeRecoveryKeyPassword('password',
'passwordOld'));
$this->assertTrue($this->instance->changeRecoveryKeyPassword('password', 'passwordOld'));
}
public function testChangeRecoveryKeyPasswordCouldNotDecryptPrivateRecoveryKey(): void {

@ -76,7 +76,7 @@ class SessionTest extends TestCase {
public function testGetDecryptAllUidException2(): void {
$this->expectException(\Exception::class);
$this->instance->prepareDecryptAll(null, 'key');
$this->instance->prepareDecryptAll('', 'key');
$this->instance->getDecryptAllUid();
}
@ -95,7 +95,7 @@ class SessionTest extends TestCase {
public function testGetDecryptAllKeyException2(): void {
$this->expectException(PrivateKeyMissingException::class);
$this->instance->prepareDecryptAll('user', null);
$this->instance->prepareDecryptAll('user', '');
$this->instance->getDecryptAllKey();
}

@ -26,7 +26,7 @@ trait CommandLine {
* @param []string $args OCC command, the part behind "occ". For example: "files:transfer-ownership"
* @return int exit code
*/
public function runOcc($args = []) {
public function runOcc($args = [], string $inputString = '') {
$args = array_map(function ($arg) {
return escapeshellarg($arg);
}, $args);
@ -39,6 +39,10 @@ trait CommandLine {
2 => ['pipe', 'w'],
];
$process = proc_open('php console.php ' . $args, $descriptor, $pipes, $this->ocPath);
if ($inputString !== '') {
fwrite($pipes[0], $inputString . "\n");
fclose($pipes[0]);
}
$this->lastStdOut = stream_get_contents($pipes[1]);
$this->lastStdErr = stream_get_contents($pipes[2]);
$this->lastCode = proc_close($process);
@ -58,6 +62,14 @@ trait CommandLine {
$this->runOcc($args);
}
/**
* @Given /^invoking occ with "([^"]*)" with input "([^"]+)"$/
*/
public function invokingTheCommandWith($cmd, $inputString) {
$args = explode(' ', $cmd);
$this->runOcc($args, $inputString);
}
/**
* Find exception texts in stderr
*/
@ -126,6 +138,13 @@ trait CommandLine {
Assert::assertStringContainsString($text, $this->lastStdOut, 'The command did not output the expected text on stdout');
}
/**
* @Then /^the command output does not contain the text "([^"]*)"$/
*/
public function theCommandOutputDoesNotContainTheText($text) {
Assert::assertStringNotContainsString($text, $this->lastStdOut, 'The command did output the not expected text on stdout');
}
/**
* @Then /^the command error output contains the text "([^"]*)"$/
*/

@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
Feature: encryption
Scenario: encryption tests
# Setup encryption
Given using new dav path
And user "user0" exists
And User "user0" uploads file with content "BLABLABLA" to "/non-encrypted.txt"
And invoking occ with "app:enable encryption"
And the command was successful
And invoking occ with "encryption:enable"
And the command was successful
And As an "user0"
And User "user0" uploads file with content "BLABLABLA" to "/encrypted.txt"
# Check both encrypted and non-encrypted files can be read
When Downloading file "/encrypted.txt" with range "bytes=0-8"
Then Downloaded content should be "BLABLABLA"
When Downloading file "/non-encrypted.txt" with range "bytes=0-8"
Then Downloaded content should be "BLABLABLA"
When invoking occ with "info:file user0/files/encrypted.txt"
And the command was successful
Then the command output contains the text "server-side encrypted: yes"
When invoking occ with "info:file user0/files/non-encrypted.txt"
And the command was successful
Then the command output does not contain the text "server-side encrypted: yes"
# Run encryption:encrypt-all and checks that non-encrypted file gets encrypted
When invoking occ with "encryption:encrypt-all" with input "y"
And the command was successful
And invoking occ with "info:file user0/files/non-encrypted.txt"
And the command was successful
Then the command output contains the text "server-side encrypted: yes"
And Downloading file "/non-encrypted.txt" with range "bytes=0-8"
And Downloaded content should be "BLABLABLA"
# Run encryption:decrypt-all and checks that files gets decrypted
When invoking occ with "encryption:decrypt-all" with input "y"
And the command was successful
And invoking occ with "info:file user0/files/non-encrypted.txt"
And the command was successful
Then the command output does not contain the text "server-side encrypted: yes"
And Downloading file "/non-encrypted.txt" with range "bytes=0-8"
And Downloaded content should be "BLABLABLA"

@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OC\Encryption;
use OC\Encryption\Exceptions\DecryptionFailedException;
@ -17,61 +20,50 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class DecryptAll {
/** @var OutputInterface */
protected $output;
/** @var InputInterface */
protected $input;
/** @var array files which couldn't be decrypted */
protected $failed;
/** @var array<string,list<string>> files which couldn't be decrypted */
protected array $failed = [];
public function __construct(
protected IManager $encryptionManager,
protected IUserManager $userManager,
protected View $rootView,
) {
$this->failed = [];
}
/**
* start to decrypt all files
*
* @param InputInterface $input
* @param OutputInterface $output
* @param string $user which users data folder should be decrypted, default = all users
* @return bool
* @throws \Exception
*/
public function decryptAll(InputInterface $input, OutputInterface $output, $user = '') {
$this->input = $input;
$this->output = $output;
public function decryptAll(InputInterface $input, OutputInterface $output, string $user = ''): bool {
if ($user !== '' && $this->userManager->userExists($user) === false) {
$this->output->writeln('User "' . $user . '" does not exist. Please check the username and try again');
$output->writeln('User "' . $user . '" does not exist. Please check the username and try again');
return false;
}
$this->output->writeln('prepare encryption modules...');
if ($this->prepareEncryptionModules($user) === false) {
$output->writeln('prepare encryption modules...');
if ($this->prepareEncryptionModules($input, $output, $user) === false) {
return false;
}
$this->output->writeln(' done.');
$output->writeln(' done.');
$this->decryptAllUsersFiles($user);
$this->failed = [];
$this->decryptAllUsersFiles($output, $user);
/** @psalm-suppress RedundantCondition $this->failed is modified by decryptAllUsersFiles, not clear why psalm fails to see it */
if (empty($this->failed)) {
$this->output->writeln('all files could be decrypted successfully!');
$output->writeln('all files could be decrypted successfully!');
} else {
$this->output->writeln('Files for following users couldn\'t be decrypted, ');
$this->output->writeln('maybe the user is not set up in a way that supports this operation: ');
$output->writeln('Files for following users couldn\'t be decrypted, ');
$output->writeln('maybe the user is not set up in a way that supports this operation: ');
foreach ($this->failed as $uid => $paths) {
$this->output->writeln(' ' . $uid);
$output->writeln(' ' . $uid);
foreach ($paths as $path) {
$this->output->writeln(' ' . $path);
$output->writeln(' ' . $path);
}
}
$this->output->writeln('');
$output->writeln('');
}
return true;
@ -79,21 +71,18 @@ class DecryptAll {
/**
* prepare encryption modules to perform the decrypt all function
*
* @param $user
* @return bool
*/
protected function prepareEncryptionModules($user) {
protected function prepareEncryptionModules(InputInterface $input, OutputInterface $output, string $user): bool {
// prepare all encryption modules for decrypt all
$encryptionModules = $this->encryptionManager->getEncryptionModules();
foreach ($encryptionModules as $moduleDesc) {
/** @var IEncryptionModule $module */
$module = call_user_func($moduleDesc['callback']);
$this->output->writeln('');
$this->output->writeln('Prepare "' . $module->getDisplayName() . '"');
$this->output->writeln('');
if ($module->prepareDecryptAll($this->input, $this->output, $user) === false) {
$this->output->writeln('Module "' . $moduleDesc['displayName'] . '" does not support the functionality to decrypt all files again or the initialization of the module failed!');
$output->writeln('');
$output->writeln('Prepare "' . $module->getDisplayName() . '"');
$output->writeln('');
if ($module->prepareDecryptAll($input, $output, $user) === false) {
$output->writeln('Module "' . $moduleDesc['displayName'] . '" does not support the functionality to decrypt all files again or the initialization of the module failed!');
return false;
}
}
@ -106,12 +95,12 @@ class DecryptAll {
*
* @param string $user which users files should be decrypted, default = all users
*/
protected function decryptAllUsersFiles($user = '') {
$this->output->writeln("\n");
protected function decryptAllUsersFiles(OutputInterface $output, string $user = ''): void {
$output->writeln("\n");
$userList = [];
if ($user === '') {
$fetchUsersProgress = new ProgressBar($this->output);
$fetchUsersProgress = new ProgressBar($output);
$fetchUsersProgress->setFormat(" %message% \n [%bar%]");
$fetchUsersProgress->start();
$fetchUsersProgress->setMessage('Fetch list of users...');
@ -135,9 +124,9 @@ class DecryptAll {
$userList[] = $user;
}
$this->output->writeln("\n\n");
$output->writeln("\n\n");
$progress = new ProgressBar($this->output);
$progress = new ProgressBar($output);
$progress->setFormat(" %message% \n [%bar%]");
$progress->start();
$progress->setMessage('starting to decrypt files...');
@ -154,17 +143,13 @@ class DecryptAll {
$progress->setMessage('starting to decrypt files... finished');
$progress->finish();
$this->output->writeln("\n\n");
$output->writeln("\n\n");
}
/**
* encrypt files from the given user
*
* @param string $uid
* @param ProgressBar $progress
* @param string $userCount
*/
protected function decryptUsersFiles($uid, ProgressBar $progress, $userCount) {
protected function decryptUsersFiles(string $uid, ProgressBar $progress, string $userCount): void {
$this->setupUserFS($uid);
$directories = [];
$directories[] = '/' . $uid . '/files';
@ -207,11 +192,8 @@ class DecryptAll {
/**
* encrypt file
*
* @param string $path
* @return bool
*/
protected function decryptFile($path) {
protected function decryptFile(string $path): bool {
// skip already decrypted files
$fileInfo = $this->rootView->getFileInfo($path);
if ($fileInfo !== false && !$fileInfo->isEncrypted()) {
@ -237,20 +219,15 @@ class DecryptAll {
/**
* get current timestamp
*
* @return int
*/
protected function getTimestamp() {
protected function getTimestamp(): int {
return time();
}
/**
* setup user file system
*
* @param string $uid
*/
protected function setupUserFS($uid) {
protected function setupUserFS(string $uid): void {
\OC_Util::tearDownFS();
\OC_Util::setupFS($uid);
}

@ -67,21 +67,16 @@ class EncryptionEventListener implements IEventListener {
}
private function getUpdate(?IUser $owner = null): Update {
if (is_null($this->updater)) {
$user = $this->userSession->getUser();
if (!$user && ($owner !== null)) {
$user = $owner;
}
if (!$user) {
throw new \Exception('Inconsistent data, File unshared, but owner not found. Should not happen');
}
$uid = $user->getUID();
$user = $this->userSession->getUser();
if (!$user && ($owner !== null)) {
$user = $owner;
}
if ($user) {
if (!$this->setupManager->isSetupComplete($user)) {
$this->setupManager->setupForUser($user);
}
}
if (is_null($this->updater)) {
$this->updater = new Update(
new Util(
new View(),
@ -91,7 +86,6 @@ class EncryptionEventListener implements IEventListener {
\OC::$server->getEncryptionManager(),
\OC::$server->get(IFile::class),
\OC::$server->get(LoggerInterface::class),
$uid
);
}

@ -27,7 +27,6 @@ class Update {
protected Manager $encryptionManager,
protected File $file,
protected LoggerInterface $logger,
protected string $uid,
) {
}
@ -108,10 +107,10 @@ class Update {
foreach ($allFiles as $file) {
$usersSharing = $this->file->getAccessList($file);
try {
$encryptionModule->update($file, $this->uid, $usersSharing);
$encryptionModule->update($file, '', $usersSharing);
} catch (GenericEncryptionException $e) {
// If the update of an individual file fails e.g. due to a corrupt key we should continue the operation and just log the failure
$this->logger->error('Failed to update encryption module for ' . $this->uid . ' ' . $file, [ 'exception' => $e ]);
$this->logger->error('Failed to update encryption module for ' . $file, [ 'exception' => $e ]);
}
}
}

@ -655,6 +655,13 @@ class Cache implements ICache {
return $this->storage->instanceOfStorage(Encryption::class);
}
protected function shouldEncrypt(string $targetPath): bool {
if (!$this->storage->instanceOfStorage(Encryption::class)) {
return false;
}
return $this->storage->shouldEncrypt($targetPath);
}
/**
* Move a file or folder in the cache
*
@ -1173,7 +1180,9 @@ class Cache implements ICache {
$data = $this->cacheEntryToArray($sourceEntry);
// when moving from an encrypted storage to a non-encrypted storage remove the `encrypted` mark
if ($sourceCache instanceof Cache && $sourceCache->hasEncryptionWrapper() && !$this->hasEncryptionWrapper()) {
if ($sourceCache instanceof Cache
&& $sourceCache->hasEncryptionWrapper()
&& !$this->shouldEncrypt($targetPath)) {
$data['encrypted'] = 0;
}

@ -337,7 +337,7 @@ class Encryption extends Wrapper {
}
// encryption disabled on write of new file and write to existing unencrypted file -> don't encrypt
if (!$encryptionEnabled || !$this->shouldEncrypt($path)) {
if (!$this->shouldEncrypt($path)) {
if (!$targetExists || !$targetIsEncrypted) {
$shouldEncrypt = false;
}
@ -585,7 +585,7 @@ class Encryption extends Wrapper {
bool $isRename,
bool $keepEncryptionVersion,
): void {
$isEncrypted = $this->encryptionManager->isEnabled() && $this->shouldEncrypt($targetInternalPath);
$isEncrypted = $this->shouldEncrypt($targetInternalPath);
$cacheInformation = [
'encrypted' => $isEncrypted,
];
@ -884,7 +884,10 @@ class Encryption extends Wrapper {
/**
* check if the given storage should be encrypted or not
*/
protected function shouldEncrypt(string $path): bool {
public function shouldEncrypt(string $path): bool {
if (!$this->encryptionManager->isEnabled()) {
return false;
}
$fullPath = $this->getFullPath($path);
$mountPointConfig = $this->mount->getOption('encrypt', true);
if ($mountPointConfig === false) {

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
@ -16,6 +18,7 @@ use OC\Files\View;
use OCP\Files\Storage\IStorage;
use OCP\IUserManager;
use OCP\UserInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
@ -30,26 +33,14 @@ use Test\TestCase;
* @package Test\Encryption
*/
class DecryptAllTest extends TestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject | IUserManager */
protected $userManager;
/** @var \PHPUnit\Framework\MockObject\MockObject | Manager */
protected $encryptionManager;
/** @var \PHPUnit\Framework\MockObject\MockObject | View */
protected $view;
/** @var \PHPUnit\Framework\MockObject\MockObject | \Symfony\Component\Console\Input\InputInterface */
protected $inputInterface;
private IUserManager&MockObject $userManager;
private Manager&MockObject $encryptionManager;
private View&MockObject $view;
private InputInterface&MockObject $inputInterface;
private OutputInterface&MockObject $outputInterface;
private UserInterface&MockObject $userInterface;
/** @var \PHPUnit\Framework\MockObject\MockObject | \Symfony\Component\Console\Output\OutputInterface */
protected $outputInterface;
/** @var \PHPUnit\Framework\MockObject\MockObject|UserInterface */
protected $userInterface;
/** @var DecryptAll */
protected $instance;
private DecryptAll $instance;
protected function setUp(): void {
parent::setUp();
@ -93,19 +84,14 @@ class DecryptAllTest extends TestCase {
];
}
/**
* @param bool $prepareResult
* @param string $user
* @param bool $userExistsChecked
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataDecryptAll')]
public function testDecryptAll($prepareResult, $user, $userExistsChecked): void {
public function testDecryptAll(bool $prepareResult, string $user, bool $userExistsChecked): void {
if ($userExistsChecked) {
$this->userManager->expects($this->once())->method('userExists')->willReturn(true);
} else {
$this->userManager->expects($this->never())->method('userExists');
}
/** @var DecryptAll | \PHPUnit\Framework\MockObject\MockObject | $instance */
/** @var DecryptAll&MockObject $instance */
$instance = $this->getMockBuilder('OC\Encryption\DecryptAll')
->setConstructorArgs(
[
@ -119,13 +105,13 @@ class DecryptAllTest extends TestCase {
$instance->expects($this->once())
->method('prepareEncryptionModules')
->with($user)
->with($this->inputInterface, $this->outputInterface, $user)
->willReturn($prepareResult);
if ($prepareResult) {
$instance->expects($this->once())
->method('decryptAllUsersFiles')
->with($user);
->with($this->outputInterface, $user);
} else {
$instance->expects($this->never())->method('decryptAllUsersFiles');
}
@ -182,13 +168,13 @@ class DecryptAllTest extends TestCase {
->willReturn([$moduleDescription]);
$this->assertSame($success,
$this->invokePrivate($this->instance, 'prepareEncryptionModules', [$user])
$this->invokePrivate($this->instance, 'prepareEncryptionModules', [$this->inputInterface, $this->outputInterface, $user])
);
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataTestDecryptAllUsersFiles')]
public function testDecryptAllUsersFiles($user): void {
/** @var DecryptAll | \PHPUnit\Framework\MockObject\MockObject | $instance */
/** @var DecryptAll&MockObject $instance */
$instance = $this->getMockBuilder('OC\Encryption\DecryptAll')
->setConstructorArgs(
[
@ -200,9 +186,6 @@ class DecryptAllTest extends TestCase {
->onlyMethods(['decryptUsersFiles'])
->getMock();
$this->invokePrivate($instance, 'input', [$this->inputInterface]);
$this->invokePrivate($instance, 'output', [$this->outputInterface]);
if (empty($user)) {
$this->userManager->expects($this->once())
->method('getBackends')
@ -226,7 +209,7 @@ class DecryptAllTest extends TestCase {
->with($user);
}
$this->invokePrivate($instance, 'decryptAllUsersFiles', [$user]);
$this->invokePrivate($instance, 'decryptAllUsersFiles', [$this->outputInterface, $user]);
}
public static function dataTestDecryptAllUsersFiles(): array {
@ -296,9 +279,10 @@ class DecryptAllTest extends TestCase {
];
$instance->expects($this->exactly(2))
->method('decryptFile')
->willReturnCallback(function ($path) use (&$calls): void {
->willReturnCallback(function ($path) use (&$calls): bool {
$expected = array_shift($calls);
$this->assertEquals($expected, $path);
return true;
});
@ -320,7 +304,7 @@ class DecryptAllTest extends TestCase {
public function testDecryptFile($isEncrypted): void {
$path = 'test.txt';
/** @var DecryptAll | \PHPUnit\Framework\MockObject\MockObject $instance */
/** @var DecryptAll&MockObject $instance */
$instance = $this->getMockBuilder('OC\Encryption\DecryptAll')
->setConstructorArgs(
[
@ -360,7 +344,7 @@ class DecryptAllTest extends TestCase {
public function testDecryptFileFailure(): void {
$path = 'test.txt';
/** @var DecryptAll | \PHPUnit\Framework\MockObject\MockObject $instance */
/** @var DecryptAll&MockObject $instance */
$instance = $this->getMockBuilder('OC\Encryption\DecryptAll')
->setConstructorArgs(
[

@ -936,19 +936,13 @@ class EncryptionTest extends Storage {
];
}
/**
*
* @param bool $encryptMountPoint
* @param mixed $encryptionModule
* @param bool $encryptionModuleShouldEncrypt
* @param bool $expected
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataTestShouldEncrypt')]
public function testShouldEncrypt(
$encryptMountPoint,
$encryptionModule,
$encryptionModuleShouldEncrypt,
$expected,
bool $encryptionEnabled,
bool $encryptMountPoint,
?bool $encryptionModule,
bool $encryptionModuleShouldEncrypt,
bool $expected,
): void {
$encryptionManager = $this->createMock(\OC\Encryption\Manager::class);
$util = $this->createMock(Util::class);
@ -978,13 +972,15 @@ class EncryptionTest extends Storage {
->onlyMethods(['getFullPath', 'getEncryptionModule'])
->getMock();
$encryptionManager->method('isEnabled')->willReturn($encryptionEnabled);
if ($encryptionModule === true) {
/** @var IEncryptionModule|MockObject $encryptionModule */
$encryptionModule = $this->createMock(IEncryptionModule::class);
}
$wrapper->method('getFullPath')->with($path)->willReturn($fullPath);
$wrapper->expects($encryptMountPoint ? $this->once() : $this->never())
$wrapper->expects(($encryptionEnabled && $encryptMountPoint) ? $this->once() : $this->never())
->method('getEncryptionModule')
->with($fullPath)
->willReturnCallback(
@ -995,7 +991,8 @@ class EncryptionTest extends Storage {
return $encryptionModule;
}
);
$mount->expects($this->once())->method('getOption')->with('encrypt', true)
$mount->expects($encryptionEnabled ? $this->once() : $this->never())
->method('getOption')->with('encrypt', true)
->willReturn($encryptMountPoint);
if ($encryptionModule !== null && $encryptionModule !== false) {
@ -1019,11 +1016,12 @@ class EncryptionTest extends Storage {
public static function dataTestShouldEncrypt(): array {
return [
[false, false, false, false],
[true, false, false, false],
[true, true, false, false],
[true, true, true, true],
[true, null, false, true],
[true, false, false, false, false],
[true, true, false, false, false],
[true, true, true, false, false],
[true, true, true, true, true],
[true, true, null, false, true],
[false, true, true, true, false],
];
}
}

@ -82,6 +82,7 @@ trait EncryptionTrait {
$encryptionManager = $container->query(IManager::class);
$this->encryptionApp->setUp($encryptionManager);
$keyManager->init($name, $password);
$this->invokePrivate($keyManager, 'keyUid', [$name]);
}
protected function postLogin() {