]> source.dussan.org Git - nextcloud-server.git/commitdiff
refactor(encryption): Migrate away from Hooks to typed events
authorFerdinand Thiessen <opensource@fthiessen.de>
Tue, 24 Sep 2024 21:37:18 +0000 (23:37 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Tue, 15 Oct 2024 16:33:06 +0000 (18:33 +0200)
Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Co-authored-by: Louis <louis@chmn.me>
Co-authored-by: Côme Chilliet <91878298+come-nc@users.noreply.github.com>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
17 files changed:
apps/encryption/composer/composer/autoload_classmap.php
apps/encryption/composer/composer/autoload_static.php
apps/encryption/lib/AppInfo/Application.php
apps/encryption/lib/Crypto/Crypt.php
apps/encryption/lib/HookManager.php [deleted file]
apps/encryption/lib/Hooks/Contracts/IHook.php [deleted file]
apps/encryption/lib/Hooks/UserHooks.php [deleted file]
apps/encryption/lib/KeyManager.php
apps/encryption/lib/Listeners/UserEventsListener.php [new file with mode: 0644]
apps/encryption/lib/Services/PassphraseService.php [new file with mode: 0644]
apps/encryption/lib/Users/Setup.php
apps/encryption/lib/Util.php
apps/encryption/tests/HookManagerTest.php [deleted file]
apps/encryption/tests/Hooks/UserHooksTest.php [deleted file]
apps/encryption/tests/Listeners/UserEventsListenersTest.php [new file with mode: 0644]
apps/encryption/tests/PassphraseServiceTest.php [new file with mode: 0644]
lib/private/Log/ExceptionSerializer.php

index 059296338b49a4c10da1ab8aac4d4910213e438e..814f39653e99093297bb4478808f46a12c97229c 100644 (file)
@@ -26,12 +26,11 @@ return array(
     'OCA\\Encryption\\Exceptions\\MultiKeyEncryptException' => $baseDir . '/../lib/Exceptions/MultiKeyEncryptException.php',
     'OCA\\Encryption\\Exceptions\\PrivateKeyMissingException' => $baseDir . '/../lib/Exceptions/PrivateKeyMissingException.php',
     'OCA\\Encryption\\Exceptions\\PublicKeyMissingException' => $baseDir . '/../lib/Exceptions/PublicKeyMissingException.php',
-    'OCA\\Encryption\\HookManager' => $baseDir . '/../lib/HookManager.php',
-    'OCA\\Encryption\\Hooks\\Contracts\\IHook' => $baseDir . '/../lib/Hooks/Contracts/IHook.php',
-    'OCA\\Encryption\\Hooks\\UserHooks' => $baseDir . '/../lib/Hooks/UserHooks.php',
     'OCA\\Encryption\\KeyManager' => $baseDir . '/../lib/KeyManager.php',
+    'OCA\\Encryption\\Listeners\\UserEventsListener' => $baseDir . '/../lib/Listeners/UserEventsListener.php',
     'OCA\\Encryption\\Migration\\SetMasterKeyStatus' => $baseDir . '/../lib/Migration/SetMasterKeyStatus.php',
     'OCA\\Encryption\\Recovery' => $baseDir . '/../lib/Recovery.php',
+    'OCA\\Encryption\\Services\\PassphraseService' => $baseDir . '/../lib/Services/PassphraseService.php',
     'OCA\\Encryption\\Session' => $baseDir . '/../lib/Session.php',
     'OCA\\Encryption\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
     'OCA\\Encryption\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php',
index 6c458eabddd1a93a906eea583092fee4f4a4b8a3..af5e51925205f2ca742917312adf2d3808771eaa 100644 (file)
@@ -41,12 +41,11 @@ class ComposerStaticInitEncryption
         'OCA\\Encryption\\Exceptions\\MultiKeyEncryptException' => __DIR__ . '/..' . '/../lib/Exceptions/MultiKeyEncryptException.php',
         'OCA\\Encryption\\Exceptions\\PrivateKeyMissingException' => __DIR__ . '/..' . '/../lib/Exceptions/PrivateKeyMissingException.php',
         'OCA\\Encryption\\Exceptions\\PublicKeyMissingException' => __DIR__ . '/..' . '/../lib/Exceptions/PublicKeyMissingException.php',
-        'OCA\\Encryption\\HookManager' => __DIR__ . '/..' . '/../lib/HookManager.php',
-        'OCA\\Encryption\\Hooks\\Contracts\\IHook' => __DIR__ . '/..' . '/../lib/Hooks/Contracts/IHook.php',
-        'OCA\\Encryption\\Hooks\\UserHooks' => __DIR__ . '/..' . '/../lib/Hooks/UserHooks.php',
         'OCA\\Encryption\\KeyManager' => __DIR__ . '/..' . '/../lib/KeyManager.php',
+        'OCA\\Encryption\\Listeners\\UserEventsListener' => __DIR__ . '/..' . '/../lib/Listeners/UserEventsListener.php',
         'OCA\\Encryption\\Migration\\SetMasterKeyStatus' => __DIR__ . '/..' . '/../lib/Migration/SetMasterKeyStatus.php',
         'OCA\\Encryption\\Recovery' => __DIR__ . '/..' . '/../lib/Recovery.php',
+        'OCA\\Encryption\\Services\\PassphraseService' => __DIR__ . '/..' . '/../lib/Services/PassphraseService.php',
         'OCA\\Encryption\\Session' => __DIR__ . '/..' . '/../lib/Session.php',
         'OCA\\Encryption\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
         'OCA\\Encryption\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php',
index d683c82286ac7c7a8a86793ed6e4adb4c0d9540c..600bc30079714f4b516eb85ac6f1b0a378fc7965 100644 (file)
@@ -7,14 +7,14 @@
  */
 namespace OCA\Encryption\AppInfo;
 
+use OC\Core\Events\BeforePasswordResetEvent;
+use OC\Core\Events\PasswordResetEvent;
 use OCA\Encryption\Crypto\Crypt;
 use OCA\Encryption\Crypto\DecryptAll;
 use OCA\Encryption\Crypto\EncryptAll;
 use OCA\Encryption\Crypto\Encryption;
-use OCA\Encryption\HookManager;
-use OCA\Encryption\Hooks\UserHooks;
 use OCA\Encryption\KeyManager;
-use OCA\Encryption\Recovery;
+use OCA\Encryption\Listeners\UserEventsListener;
 use OCA\Encryption\Session;
 use OCA\Encryption\Users\Setup;
 use OCA\Encryption\Util;
@@ -23,7 +23,14 @@ use OCP\AppFramework\Bootstrap\IBootContext;
 use OCP\AppFramework\Bootstrap\IBootstrap;
 use OCP\AppFramework\Bootstrap\IRegistrationContext;
 use OCP\Encryption\IManager;
+use OCP\EventDispatcher\IEventDispatcher;
 use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IUserSession;
+use OCP\User\Events\BeforePasswordUpdatedEvent;
+use OCP\User\Events\PasswordUpdatedEvent;
+use OCP\User\Events\UserCreatedEvent;
+use OCP\User\Events\UserDeletedEvent;
 use Psr\Log\LoggerInterface;
 
 class Application extends App implements IBootstrap {
@@ -49,7 +56,7 @@ class Application extends App implements IBootstrap {
                        }
 
                        $context->injectFn($this->registerEncryptionModule(...));
-                       $context->injectFn($this->registerHooks(...));
+                       $context->injectFn($this->registerEventListeners(...));
                        $context->injectFn($this->setUp(...));
                });
        }
@@ -57,38 +64,29 @@ class Application extends App implements IBootstrap {
        public function setUp(IManager $encryptionManager) {
                if ($encryptionManager->isEnabled()) {
                        /** @var Setup $setup */
-                       $setup = $this->getContainer()->query(Setup::class);
+                       $setup = $this->getContainer()->get(Setup::class);
                        $setup->setupSystem();
                }
        }
 
-       /**
-        * register hooks
-        */
-       public function registerHooks(IConfig $config) {
-               if (!$config->getSystemValueBool('maintenance')) {
-                       $container = $this->getContainer();
-                       $server = $container->getServer();
-                       // Register our hooks and fire them.
-                       $hookManager = new HookManager();
-
-                       $hookManager->registerHook([
-                               new UserHooks($container->query(KeyManager::class),
-                                       $server->getUserManager(),
-                                       $server->get(LoggerInterface::class),
-                                       $container->query(Setup::class),
-                                       $server->getUserSession(),
-                                       $container->query(Util::class),
-                                       $container->query(Session::class),
-                                       $container->query(Crypt::class),
-                                       $container->query(Recovery::class))
-                       ]);
+       public function registerEventListeners(IConfig $config, IEventDispatcher $eventDispatcher, IManager $encryptionManager): void {
+               if (!$encryptionManager->isEnabled()) {
+                       return;
+               }
 
-                       $hookManager->fireHooks();
-               } else {
+               if ($config->getSystemValueBool('maintenance')) {
                        // Logout user if we are in maintenance to force re-login
-                       $this->getContainer()->getServer()->getUserSession()->logout();
+                       $this->getContainer()->get(IUserSession::class)->logout();
+                       return;
                }
+
+               // No maintenance so register all events
+               $eventDispatcher->addServiceListener(UserCreatedEvent::class, UserEventsListener::class);
+               $eventDispatcher->addServiceListener(UserDeletedEvent::class, UserEventsListener::class);
+               $eventDispatcher->addServiceListener(BeforePasswordUpdatedEvent::class, UserEventsListener::class);
+               $eventDispatcher->addServiceListener(PasswordUpdatedEvent::class, UserEventsListener::class);
+               $eventDispatcher->addServiceListener(BeforePasswordResetEvent::class, UserEventsListener::class);
+               $eventDispatcher->addServiceListener(PasswordResetEvent::class, UserEventsListener::class);
        }
 
        public function registerEncryptionModule(IManager $encryptionManager) {
@@ -99,14 +97,14 @@ class Application extends App implements IBootstrap {
                        Encryption::DISPLAY_NAME,
                        function () use ($container) {
                                return new Encryption(
-                                       $container->query(Crypt::class),
-                                       $container->query(KeyManager::class),
-                                       $container->query(Util::class),
-                                       $container->query(Session::class),
-                                       $container->query(EncryptAll::class),
-                                       $container->query(DecryptAll::class),
-                                       $container->getServer()->get(LoggerInterface::class),
-                                       $container->getServer()->getL10N($container->getAppName())
+                                       $container->get(Crypt::class),
+                                       $container->get(KeyManager::class),
+                                       $container->get(Util::class),
+                                       $container->get(Session::class),
+                                       $container->get(EncryptAll::class),
+                                       $container->get(DecryptAll::class),
+                                       $container->get(LoggerInterface::class),
+                                       $container->get(IL10N::class),
                                );
                        });
        }
index b38734dd06139937641d5fafd773d0bd774f6d27..463ca4e22bb21263cc988e4db62984a1de18b4c8 100644 (file)
@@ -83,7 +83,7 @@ class Crypt {
        /**
         * create new private/public key-pair for user
         *
-        * @return array|bool
+        * @return array{publicKey: string, privateKey: string}|false
         */
        public function createKeyPair() {
                $res = $this->getOpenSSLPKey();
diff --git a/apps/encryption/lib/HookManager.php b/apps/encryption/lib/HookManager.php
deleted file mode 100644 (file)
index 6ad56eb..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php
-
-/**
- * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-namespace OCA\Encryption;
-
-use OCA\Encryption\Hooks\Contracts\IHook;
-
-class HookManager {
-       /** @var IHook[] */
-       private $hookInstances = [];
-
-       /**
-        * @param array|IHook $instances
-        *                               - This accepts either a single instance of IHook or an array of instances of IHook
-        * @return bool
-        */
-       public function registerHook($instances) {
-               if (is_array($instances)) {
-                       foreach ($instances as $instance) {
-                               if (!$instance instanceof IHook) {
-                                       return false;
-                               }
-                               $this->hookInstances[] = $instance;
-                       }
-               } elseif ($instances instanceof IHook) {
-                       $this->hookInstances[] = $instances;
-               }
-               return true;
-       }
-
-       public function fireHooks() {
-               foreach ($this->hookInstances as $instance) {
-                       /**
-                        * Fire off the add hooks method of each instance stored in cache
-                        */
-                       $instance->addHooks();
-               }
-       }
-}
diff --git a/apps/encryption/lib/Hooks/Contracts/IHook.php b/apps/encryption/lib/Hooks/Contracts/IHook.php
deleted file mode 100644 (file)
index 5bb8046..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-/**
- * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-namespace OCA\Encryption\Hooks\Contracts;
-
-interface IHook {
-       /**
-        * Connects Hooks
-        *
-        * @return null
-        */
-       public function addHooks();
-}
diff --git a/apps/encryption/lib/Hooks/UserHooks.php b/apps/encryption/lib/Hooks/UserHooks.php
deleted file mode 100644 (file)
index e75a1e5..0000000
+++ /dev/null
@@ -1,266 +0,0 @@
-<?php
-
-/**
- * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-namespace OCA\Encryption\Hooks;
-
-use OC\Files\Filesystem;
-use OCA\Encryption\Crypto\Crypt;
-use OCA\Encryption\Hooks\Contracts\IHook;
-use OCA\Encryption\KeyManager;
-use OCA\Encryption\Recovery;
-use OCA\Encryption\Session;
-use OCA\Encryption\Users\Setup;
-use OCA\Encryption\Util;
-use OCP\Encryption\Exceptions\GenericEncryptionException;
-use OCP\IUserManager;
-use OCP\IUserSession;
-use OCP\Util as OCUtil;
-use Psr\Log\LoggerInterface;
-
-class UserHooks implements IHook {
-       /**
-        * list of user for which we perform a password reset
-        * @var array<string, true>
-        */
-       protected static array $passwordResetUsers = [];
-
-       public function __construct(
-               private KeyManager $keyManager,
-               private IUserManager $userManager,
-               private LoggerInterface $logger,
-               private Setup $userSetup,
-               private IUserSession $userSession,
-               private Util $util,
-               private Session $session,
-               private Crypt $crypt,
-               private Recovery $recovery,
-       ) {
-       }
-
-       /**
-        * Connects Hooks
-        *
-        * @return null
-        */
-       public function addHooks() {
-               OCUtil::connectHook('OC_User', 'post_login', $this, 'login');
-               OCUtil::connectHook('OC_User', 'logout', $this, 'logout');
-
-               // this hooks only make sense if no master key is used
-               if ($this->util->isMasterKeyEnabled() === false) {
-                       OCUtil::connectHook('OC_User',
-                               'post_setPassword',
-                               $this,
-                               'setPassphrase');
-
-                       OCUtil::connectHook('OC_User',
-                               'pre_setPassword',
-                               $this,
-                               'preSetPassphrase');
-
-                       OCUtil::connectHook('\OC\Core\LostPassword\Controller\LostController',
-                               'post_passwordReset',
-                               $this,
-                               'postPasswordReset');
-
-                       OCUtil::connectHook('\OC\Core\LostPassword\Controller\LostController',
-                               'pre_passwordReset',
-                               $this,
-                               'prePasswordReset');
-
-                       OCUtil::connectHook('OC_User',
-                               'post_createUser',
-                               $this,
-                               'postCreateUser');
-
-                       OCUtil::connectHook('OC_User',
-                               'post_deleteUser',
-                               $this,
-                               'postDeleteUser');
-               }
-       }
-
-
-       /**
-        * Startup encryption backend upon user login
-        *
-        * @note This method should never be called for users using client side encryption
-        * @param array $params
-        * @return boolean|null
-        */
-       public function login($params) {
-               // ensure filesystem is loaded
-               if (!Filesystem::$loaded) {
-                       $this->setupFS($params['uid']);
-               }
-               if ($this->util->isMasterKeyEnabled() === false) {
-                       $this->userSetup->setupUser($params['uid'], $params['password']);
-               }
-
-               $this->keyManager->init($params['uid'], $params['password']);
-       }
-
-       /**
-        * remove keys from session during logout
-        */
-       public function logout() {
-               $this->session->clear();
-       }
-
-       /**
-        * setup encryption backend upon user created
-        *
-        * @note This method should never be called for users using client side encryption
-        * @param array $params
-        */
-       public function postCreateUser($params) {
-               $this->userSetup->setupUser($params['uid'], $params['password']);
-       }
-
-       /**
-        * cleanup encryption backend upon user deleted
-        *
-        * @param array $params : uid, password
-        * @note This method should never be called for users using client side encryption
-        */
-       public function postDeleteUser($params) {
-               $this->keyManager->deletePublicKey($params['uid']);
-       }
-
-       public function prePasswordReset($params) {
-               $user = $params['uid'];
-               self::$passwordResetUsers[$user] = true;
-       }
-
-       public function postPasswordReset($params) {
-               $uid = $params['uid'];
-               $password = $params['password'];
-               $this->keyManager->backupUserKeys('passwordReset', $uid);
-               $this->keyManager->deleteUserKeys($uid);
-               $this->userSetup->setupUser($uid, $password);
-               unset(self::$passwordResetUsers[$uid]);
-       }
-
-       /**
-        * If the password can't be changed within Nextcloud, than update the key password in advance.
-        *
-        * @param array $params : uid, password
-        * @return boolean|null
-        */
-       public function preSetPassphrase($params) {
-               $user = $this->userManager->get($params['uid']);
-
-               if ($user && !$user->canChangePassword()) {
-                       $this->setPassphrase($params);
-               }
-       }
-
-       /**
-        * Change a user's encryption passphrase
-        *
-        * @param array $params keys: uid, password
-        * @return boolean|null
-        */
-       public function setPassphrase($params) {
-               // if we are in the process to resetting a user password, we have nothing
-               // to do here
-               if (isset(self::$passwordResetUsers[$params['uid']])) {
-                       return true;
-               }
-
-               // Get existing decrypted private key
-               $user = $this->userSession->getUser();
-
-               // current logged in user changes their own password
-               if ($user && $params['uid'] === $user->getUID()) {
-                       $privateKey = $this->session->getPrivateKey();
-
-                       // Encrypt private key with new user pwd as passphrase
-                       $encryptedPrivateKey = $this->crypt->encryptPrivateKey($privateKey, $params['password'], $params['uid']);
-
-                       // Save private key
-                       if ($encryptedPrivateKey) {
-                               $this->keyManager->setPrivateKey($user->getUID(),
-                                       $this->crypt->generateHeader() . $encryptedPrivateKey);
-                       } else {
-                               $this->logger->error('Encryption could not update users encryption password');
-                       }
-
-                       // NOTE: Session does not need to be updated as the
-                       // private key has not changed, only the passphrase
-                       // used to decrypt it has changed
-               } else { // admin changed the password for a different user, create new keys and re-encrypt file keys
-                       $userId = $params['uid'];
-                       $this->initMountPoints($userId);
-                       $recoveryPassword = $params['recoveryPassword'] ?? null;
-
-                       $recoveryKeyId = $this->keyManager->getRecoveryKeyId();
-                       $recoveryKey = $this->keyManager->getSystemPrivateKey($recoveryKeyId);
-                       try {
-                               $decryptedRecoveryKey = $this->crypt->decryptPrivateKey($recoveryKey, $recoveryPassword);
-                       } catch (\Exception $e) {
-                               $decryptedRecoveryKey = false;
-                       }
-                       if ($decryptedRecoveryKey === false) {
-                               $message = 'Can not decrypt the recovery key. Maybe you provided the wrong password. Try again.';
-                               throw new GenericEncryptionException($message, $message);
-                       }
-
-                       // we generate new keys if...
-                       // ...we have a recovery password and the user enabled the recovery key
-                       // ...encryption was activated for the first time (no keys exists)
-                       // ...the user doesn't have any files
-                       if (
-                               ($this->recovery->isRecoveryEnabledForUser($userId) && $recoveryPassword)
-                               || !$this->keyManager->userHasKeys($userId)
-                               || !$this->util->userHasFiles($userId)
-                       ) {
-                               // backup old keys
-                               //$this->backupAllKeys('recovery');
-
-                               $newUserPassword = $params['password'];
-
-                               $keyPair = $this->crypt->createKeyPair();
-
-                               // Save public key
-                               $this->keyManager->setPublicKey($userId, $keyPair['publicKey']);
-
-                               // Encrypt private key with new password
-                               $encryptedKey = $this->crypt->encryptPrivateKey($keyPair['privateKey'], $newUserPassword, $userId);
-
-                               if ($encryptedKey) {
-                                       $this->keyManager->setPrivateKey($userId, $this->crypt->generateHeader() . $encryptedKey);
-
-                                       if ($recoveryPassword) { // if recovery key is set we can re-encrypt the key files
-                                               $this->recovery->recoverUsersFiles($recoveryPassword, $userId);
-                                       }
-                               } else {
-                                       $this->logger->error('Encryption Could not update users encryption password');
-                               }
-                       }
-               }
-       }
-
-       /**
-        * init mount points for given user
-        *
-        * @param string $user
-        * @throws \OC\User\NoUserException
-        */
-       protected function initMountPoints($user) {
-               Filesystem::initMountPoints($user);
-       }
-
-       /**
-        * setup file system for user
-        *
-        * @param string $uid user id
-        */
-       protected function setupFS($uid) {
-               \OC_Util::setupFS($uid);
-       }
-}
index 0c9c02760a897a86421a07e0e4d55bf92e4921e0..f694e6550f1f76e17adb574ade886cd222b15486 100644 (file)
@@ -287,11 +287,9 @@ class KeyManager {
        /**
         * Decrypt private key and store it
         *
-        * @param string $uid user id
-        * @param string $passPhrase users password
         * @return boolean
         */
-       public function init($uid, $passPhrase) {
+       public function init(string $uid, ?string $passPhrase) {
                $this->session->setStatus(Session::INIT_EXECUTED);
 
                try {
@@ -300,6 +298,10 @@ class KeyManager {
                                $passPhrase = $this->getMasterKeyPassword();
                                $privateKey = $this->getSystemPrivateKey($uid);
                        } else {
+                               if ($passPhrase === null) {
+                                       $this->logger->warning('Master key is disabled but not passphrase provided.');
+                                       return false;
+                               }
                                $privateKey = $this->getPrivateKey($uid);
                        }
                        $privateKey = $this->crypt->decryptPrivateKey($privateKey, $passPhrase, $uid);
diff --git a/apps/encryption/lib/Listeners/UserEventsListener.php b/apps/encryption/lib/Listeners/UserEventsListener.php
new file mode 100644 (file)
index 0000000..694640a
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Encryption\Listeners;
+
+use OC\Core\Events\BeforePasswordResetEvent;
+use OC\Core\Events\PasswordResetEvent;
+use OC\Files\SetupManager;
+use OCA\Encryption\KeyManager;
+use OCA\Encryption\Services\PassphraseService;
+use OCA\Encryption\Session;
+use OCA\Encryption\Users\Setup;
+use OCA\Encryption\Util;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\User\Events\BeforePasswordUpdatedEvent;
+use OCP\User\Events\PasswordUpdatedEvent;
+use OCP\User\Events\UserCreatedEvent;
+use OCP\User\Events\UserDeletedEvent;
+use OCP\User\Events\UserLoggedInEvent;
+use OCP\User\Events\UserLoggedOutEvent;
+
+/**
+ * @template-implements IEventListener<UserCreatedEvent|UserDeletedEvent|UserLoggedInEvent|UserLoggedOutEvent|BeforePasswordUpdatedEvent|PasswordUpdatedEvent|BeforePasswordResetEvent|PasswordResetEvent>
+ */
+class UserEventsListener implements IEventListener {
+
+       public function __construct(
+               private Util $util,
+               private Setup $userSetup,
+               private Session $session,
+               private KeyManager $keyManager,
+               private IUserManager $userManager,
+               private IUserSession $userSession,
+               private SetupManager $setupManager,
+               private PassphraseService $passphraseService,
+       ) {
+       }
+
+       public function handle(Event $event): void {
+               if ($event instanceof UserCreatedEvent) {
+                       $this->onUserCreated($event->getUid(), $event->getPassword());
+               } elseif ($event instanceof UserDeletedEvent) {
+                       $this->onUserDeleted($event->getUid());
+               } elseif ($event instanceof UserLoggedInEvent) {
+                       $this->onUserLogin($event->getUser(), $event->getPassword());
+               } elseif ($event instanceof UserLoggedOutEvent) {
+                       $this->onUserLogout();
+               } elseif ($event instanceof BeforePasswordUpdatedEvent) {
+                       $this->onBeforePasswordUpdated($event->getUser(), $event->getPassword(), $event->getRecoveryPassword());
+               } elseif ($event instanceof PasswordUpdatedEvent) {
+                       $this->onPasswordUpdated($event->getUid(), $event->getPassword(), $event->getRecoveryPassword());
+               } elseif ($event instanceof BeforePasswordResetEvent) {
+                       $this->onBeforePasswordReset($event->getUid());
+               } elseif ($event instanceof PasswordResetEvent) {
+                       $this->onPasswordReset($event->getUid(), $event->getPassword());
+               }
+       }
+
+       /**
+        * Startup encryption backend upon user login
+        */
+       private function onUserLogin(IUser $user, ?string $password): void {
+               // ensure filesystem is loaded
+               $this->setupManager->setupForUser($user);
+               if ($this->util->isMasterKeyEnabled() === false) {
+                       // Skip if no master key and the password is not provided
+                       if ($password === null) {
+                               return;
+                       }
+
+                       $this->userSetup->setupUser($user->getUID(), $password);
+               }
+
+               $this->keyManager->init($user->getUID(), $password);
+       }
+
+       /**
+        * Remove keys from session during logout
+        */
+       private function onUserLogout(): void {
+               $this->session->clear();
+       }
+
+       /**
+        * Setup encryption backend upon user created
+        *
+        * This method should never be called for users using client side encryption
+        */
+       protected function onUserCreated(string $userId, string $password): void {
+               $this->userSetup->setupUser($userId, $password);
+       }
+
+       /**
+        * Cleanup encryption backend upon user deleted
+        *
+        * This method should never be called for users using client side encryption
+        */
+       protected function onUserDeleted(string $userId): void {
+               $this->keyManager->deletePublicKey($userId);
+       }
+
+       /**
+        * If the password can't be changed within Nextcloud, than update the key password in advance.
+        */
+       public function onBeforePasswordUpdated(IUser $user, string $password, ?string $recoveryPassword = null): void {
+               if (!$user->canChangePassword()) {
+                       $this->passphraseService->setPassphraseForUser($user->getUID(), $password, $recoveryPassword);
+               }
+       }
+
+       /**
+        * Change a user's encryption passphrase
+        */
+       public function onPasswordUpdated(string $userId, string $password, ?string $recoveryPassword): void {
+               $this->passphraseService->setPassphraseForUser($userId, $password, $recoveryPassword);
+       }
+
+       /**
+        * Set user password resetting state to allow ignoring "reset"-requests on password update
+        */
+       public function onBeforePasswordReset(string $userId): void {
+               $this->passphraseService->setProcessingReset($userId);
+       }
+
+       /**
+        * Create new encryption keys on password reset and backup the old one
+        */
+       public function onPasswordReset(string $userId, string $password): void {
+               $this->keyManager->backupUserKeys('passwordReset', $userId);
+               $this->keyManager->deleteUserKeys($userId);
+               $this->userSetup->setupUser($userId, $password);
+               $this->passphraseService->setProcessingReset($userId, false);
+       }
+}
diff --git a/apps/encryption/lib/Services/PassphraseService.php b/apps/encryption/lib/Services/PassphraseService.php
new file mode 100644 (file)
index 0000000..4b3e833
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Encryption\Services;
+
+use OCA\Encryption\Crypto\Crypt;
+use OCA\Encryption\KeyManager;
+use OCA\Encryption\Recovery;
+use OCA\Encryption\Session;
+use OCA\Encryption\Util;
+use OCP\Encryption\Exceptions\GenericEncryptionException;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+
+class PassphraseService {
+
+       /** @var array<string, bool> */
+       private static array $passwordResetUsers = [];
+
+       public function __construct(
+               private Util $util,
+               private Crypt $crypt,
+               private Session $session,
+               private Recovery $recovery,
+               private KeyManager $keyManager,
+               private LoggerInterface $logger,
+               private IUserManager $userManager,
+               private IUserSession $userSession,
+       ) {
+       }
+
+       public function setProcessingReset(string $uid, bool $processing = true): void {
+               if ($processing) {
+                       self::$passwordResetUsers[$uid] = true;
+               } else {
+                       unset(self::$passwordResetUsers[$uid]);
+               }
+       }
+
+       /**
+        * Change a user's encryption passphrase
+        */
+       public function setPassphraseForUser(string $userId, string $password, ?string $recoveryPassword = null): bool {
+               // if we are in the process to resetting a user password, we have nothing
+               // to do here
+               if (isset(self::$passwordResetUsers[$userId])) {
+                       return true;
+               }
+
+               // Check user exists on backend
+               $user = $this->userManager->get($userId);
+               if ($user === null) {
+                       return false;
+               }
+
+               // Get existing decrypted private key
+               $currentUser = $this->userSession->getUser();
+
+               // current logged in user changes his own password
+               if ($currentUser !== null && $userId === $currentUser->getUID()) {
+                       $privateKey = $this->session->getPrivateKey();
+
+                       // Encrypt private key with new user pwd as passphrase
+                       $encryptedPrivateKey = $this->crypt->encryptPrivateKey($privateKey, $password, $userId);
+
+                       // Save private key
+                       if ($encryptedPrivateKey !== false) {
+                               $key = $this->crypt->generateHeader() . $encryptedPrivateKey;
+                               $this->keyManager->setPrivateKey($userId, $key);
+                               return true;
+                       }
+
+                       $this->logger->error('Encryption could not update users encryption password');
+                       
+                       // NOTE: Session does not need to be updated as the
+                       // private key has not changed, only the passphrase
+                       // used to decrypt it has changed
+               } else {
+                       // admin changed the password for a different user, create new keys and re-encrypt file keys
+                       $recoveryPassword = $recoveryPassword ?? '';
+                       $this->initMountPoints($user);
+
+                       $recoveryKeyId = $this->keyManager->getRecoveryKeyId();
+                       $recoveryKey = $this->keyManager->getSystemPrivateKey($recoveryKeyId);
+                       try {
+                               $this->crypt->decryptPrivateKey($recoveryKey, $recoveryPassword);
+                       } catch (\Exception) {
+                               $message = 'Can not decrypt the recovery key. Maybe you provided the wrong password. Try again.';
+                               throw new GenericEncryptionException($message, $message);
+                       }
+
+                       // we generate new keys if...
+                       // ...we have a recovery password and the user enabled the recovery key
+                       // ...encryption was activated for the first time (no keys exists)
+                       // ...the user doesn't have any files
+                       if (
+                               ($this->recovery->isRecoveryEnabledForUser($userId) && $recoveryPassword !== '')
+                               || !$this->keyManager->userHasKeys($userId)
+                               || !$this->util->userHasFiles($userId)
+                       ) {
+                               $keyPair = $this->crypt->createKeyPair();
+                               if ($keyPair === false) {
+                                       $this->logger->error('Could not create new private key-pair for user.');
+                                       return false;
+                               }
+
+                               // Save public key
+                               $this->keyManager->setPublicKey($userId, $keyPair['publicKey']);
+
+                               // Encrypt private key with new password
+                               $encryptedKey = $this->crypt->encryptPrivateKey($keyPair['privateKey'], $password, $userId);
+                               if ($encryptedKey === false) {
+                                       $this->logger->error('Encryption could not update users encryption password');
+                                       return false;
+                               }
+
+                               $this->keyManager->setPrivateKey($userId, $this->crypt->generateHeader() . $encryptedKey);
+
+                               if ($recoveryPassword !== '') {
+                                       // if recovery key is set we can re-encrypt the key files
+                                       $this->recovery->recoverUsersFiles($recoveryPassword, $userId);
+                               }
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Init mount points for given user
+        */
+       private function initMountPoints(IUser $user): void {
+               \OC\Files\Filesystem::initMountPoints($user);
+       }
+}
index 30e7c5461ccb6bac30c23fc1282eb5a89b89050a..f2189d6dab25ab5fbf4273619bd41d4cf8ba219f 100644 (file)
@@ -11,15 +11,11 @@ use OCA\Encryption\Crypto\Crypt;
 use OCA\Encryption\KeyManager;
 
 class Setup {
-       /** @var Crypt */
-       private $crypt;
-       /** @var KeyManager */
-       private $keyManager;
 
-       public function __construct(Crypt $crypt,
-               KeyManager $keyManager) {
-               $this->crypt = $crypt;
-               $this->keyManager = $keyManager;
+       public function __construct(
+               private Crypt $crypt,
+               private KeyManager $keyManager,
+       ) {
        }
 
        /**
index 6ca4d2c1e1e8d565f6b567ad6e4b617708f20777..61656ddcda3528b33a5a305bbbd87d0f6b2c8cd9 100644 (file)
@@ -25,11 +25,7 @@ class Util {
                private IConfig $config,
                private IUserManager $userManager,
        ) {
-               $this->files = $files;
-               $this->crypt = $crypt;
                $this->user = $userSession->isLoggedIn() ? $userSession->getUser() : false;
-               $this->config = $config;
-               $this->userManager = $userManager;
        }
 
        /**
@@ -140,4 +136,5 @@ class Util {
        public function getStorage($path) {
                return $this->files->getMount($path)->getStorage();
        }
+
 }
diff --git a/apps/encryption/tests/HookManagerTest.php b/apps/encryption/tests/HookManagerTest.php
deleted file mode 100644 (file)
index f831690..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-/**
- * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-namespace OCA\Encryption\Tests;
-
-use OCA\Encryption\HookManager;
-use OCA\Encryption\Hooks\Contracts\IHook;
-use OCP\IConfig;
-use Test\TestCase;
-
-class HookManagerTest extends TestCase {
-
-       /**
-        * @var HookManager
-        */
-       private static $instance;
-
-       
-       public function testRegisterHookWithArray(): void {
-               self::$instance->registerHook([
-                       $this->getMockBuilder(IHook::class)->disableOriginalConstructor()->getMock(),
-                       $this->getMockBuilder(IHook::class)->disableOriginalConstructor()->getMock(),
-                       $this->createMock(IConfig::class)
-               ]);
-
-               $hookInstances = self::invokePrivate(self::$instance, 'hookInstances');
-               // Make sure our type checking works
-               $this->assertCount(2, $hookInstances);
-       }
-
-
-       
-       public static function setUpBeforeClass(): void {
-               parent::setUpBeforeClass();
-               // have to make instance static to preserve data between tests
-               self::$instance = new HookManager();
-       }
-
-       
-       public function testRegisterHooksWithInstance(): void {
-               $mock = $this->getMockBuilder(IHook::class)->disableOriginalConstructor()->getMock();
-               /** @var IHook $mock */
-               self::$instance->registerHook($mock);
-
-               $hookInstances = self::invokePrivate(self::$instance, 'hookInstances');
-               $this->assertCount(3, $hookInstances);
-       }
-}
diff --git a/apps/encryption/tests/Hooks/UserHooksTest.php b/apps/encryption/tests/Hooks/UserHooksTest.php
deleted file mode 100644 (file)
index 072a20d..0000000
+++ /dev/null
@@ -1,370 +0,0 @@
-<?php
-
-/**
- * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-namespace OCA\Encryption\Tests\Hooks;
-
-use OCA\Encryption\Crypto\Crypt;
-use OCA\Encryption\Hooks\UserHooks;
-use OCA\Encryption\KeyManager;
-use OCA\Encryption\Recovery;
-use OCA\Encryption\Session;
-use OCA\Encryption\Users\Setup;
-use OCA\Encryption\Util;
-use OCP\IUser;
-use OCP\IUserManager;
-use OCP\IUserSession;
-use PHPUnit\Framework\MockObject\MockObject;
-use Psr\Log\LoggerInterface;
-use Test\TestCase;
-
-/**
- * Class UserHooksTest
- *
- * @group DB
- * @package OCA\Encryption\Tests\Hooks
- */
-class UserHooksTest extends TestCase {
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $utilMock;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $recoveryMock;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $sessionMock;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $keyManagerMock;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $userManagerMock;
-
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $userSetupMock;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $userSessionMock;
-       /**
-        * @var MockObject|IUser
-        */
-       private $user;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $cryptMock;
-       /**
-        * @var \PHPUnit\Framework\MockObject\MockObject
-        */
-       private $loggerMock;
-       /**
-        * @var UserHooks
-        */
-       private $instance;
-
-       private $params = ['uid' => 'testUser', 'password' => 'password'];
-
-       public function testLogin(): void {
-               $this->userSetupMock->expects($this->once())
-                       ->method('setupUser')
-                       ->willReturnOnConsecutiveCalls(true, false);
-
-               $this->keyManagerMock->expects($this->once())
-                       ->method('init')
-                       ->with('testUser', 'password');
-
-               $this->assertNull($this->instance->login($this->params));
-       }
-
-       public function testLogout(): void {
-               $this->sessionMock->expects($this->once())
-                       ->method('clear');
-               $this->instance->logout();
-               $this->addToAssertionCount(1);
-       }
-
-       public function testPostCreateUser(): void {
-               $this->userSetupMock->expects($this->once())
-                       ->method('setupUser');
-
-               $this->instance->postCreateUser($this->params);
-               $this->addToAssertionCount(1);
-       }
-
-       public function testPostDeleteUser(): void {
-               $this->keyManagerMock->expects($this->once())
-                       ->method('deletePublicKey')
-                       ->with('testUser');
-
-               $this->instance->postDeleteUser($this->params);
-               $this->addToAssertionCount(1);
-       }
-
-       public function testPrePasswordReset(): void {
-               $params = ['uid' => 'user1'];
-               $expected = ['user1' => true];
-               $this->instance->prePasswordReset($params);
-               $passwordResetUsers = $this->invokePrivate($this->instance, 'passwordResetUsers');
-
-               $this->assertSame($expected, $passwordResetUsers);
-       }
-
-       public function testPostPasswordReset(): void {
-               $params = ['uid' => 'user1', 'password' => 'password'];
-               $this->invokePrivate($this->instance, 'passwordResetUsers', [['user1' => true]]);
-               $this->keyManagerMock->expects($this->once())->method('backupUserKeys')
-                       ->with('passwordReset', 'user1');
-               $this->keyManagerMock->expects($this->once())->method('deleteUserKeys')
-                       ->with('user1');
-               $this->userSetupMock->expects($this->once())->method('setupUser')
-                       ->with('user1', 'password');
-
-               $this->instance->postPasswordReset($params);
-               $passwordResetUsers = $this->invokePrivate($this->instance, 'passwordResetUsers');
-               $this->assertEmpty($passwordResetUsers);
-       }
-
-       /**
-        * @dataProvider dataTestPreSetPassphrase
-        */
-       public function testPreSetPassphrase($canChange): void {
-               /** @var UserHooks | \PHPUnit\Framework\MockObject\MockObject  $instance */
-               $instance = $this->getMockBuilder(UserHooks::class)
-                       ->setConstructorArgs(
-                               [
-                                       $this->keyManagerMock,
-                                       $this->userManagerMock,
-                                       $this->loggerMock,
-                                       $this->userSetupMock,
-                                       $this->userSessionMock,
-                                       $this->utilMock,
-                                       $this->sessionMock,
-                                       $this->cryptMock,
-                                       $this->recoveryMock
-                               ]
-                       )
-                       ->setMethods(['setPassphrase'])
-                       ->getMock();
-
-               $userMock = $this->createMock(IUser::class);
-
-               $this->userManagerMock->expects($this->once())
-                       ->method('get')
-                       ->with($this->params['uid'])
-                       ->willReturn($userMock);
-               $userMock->expects($this->once())
-                       ->method('canChangePassword')
-                       ->willReturn($canChange);
-
-               if ($canChange) {
-                       // in this case the password will be changed in the post hook
-                       $instance->expects($this->never())->method('setPassphrase');
-               } else {
-                       // if user can't change the password we update the encryption
-                       // key password already in the pre hook
-                       $instance->expects($this->once())
-                               ->method('setPassphrase')
-                               ->with($this->params);
-               }
-
-               $instance->preSetPassphrase($this->params);
-       }
-
-       public function dataTestPreSetPassphrase() {
-               return [
-                       [true],
-                       [false]
-               ];
-       }
-
-       public function XtestSetPassphrase() {
-               $this->sessionMock->expects($this->once())
-                       ->method('getPrivateKey')
-                       ->willReturn(true);
-
-               $this->cryptMock->expects($this->exactly(4))
-                       ->method('encryptPrivateKey')
-                       ->willReturn(true);
-
-               $this->cryptMock->expects($this->any())
-                       ->method('generateHeader')
-                       ->willReturn(Crypt::HEADER_START . ':Cipher:test:' . Crypt::HEADER_END);
-
-               $this->keyManagerMock->expects($this->exactly(4))
-                       ->method('setPrivateKey')
-                       ->willReturnCallback(function ($user, $key): void {
-                               $header = substr($key, 0, strlen(Crypt::HEADER_START));
-                               $this->assertSame(
-                                       Crypt::HEADER_START,
-                                       $header, 'every encrypted file should start with a header');
-                       });
-
-               $this->assertNull($this->instance->setPassphrase($this->params));
-               $this->params['recoveryPassword'] = 'password';
-
-               $this->recoveryMock->expects($this->exactly(3))
-                       ->method('isRecoveryEnabledForUser')
-                       ->with('testUser1')
-                       ->willReturnOnConsecutiveCalls(true, false);
-
-
-               $this->instance = $this->getMockBuilder(UserHooks::class)
-                       ->setConstructorArgs(
-                               [
-                                       $this->keyManagerMock,
-                                       $this->userManagerMock,
-                                       $this->loggerMock,
-                                       $this->userSetupMock,
-                                       $this->userSessionMock,
-                                       $this->utilMock,
-                                       $this->sessionMock,
-                                       $this->cryptMock,
-                                       $this->recoveryMock
-                               ]
-                       )->setMethods(['initMountPoints'])->getMock();
-
-               $this->instance->expects($this->exactly(3))->method('initMountPoints');
-
-               $this->params['uid'] = 'testUser1';
-
-               // Test first if statement
-               $this->assertNull($this->instance->setPassphrase($this->params));
-
-               // Test Second if conditional
-               $this->keyManagerMock->expects($this->exactly(2))
-                       ->method('userHasKeys')
-                       ->with('testUser1')
-                       ->willReturn(true);
-
-               $this->assertNull($this->instance->setPassphrase($this->params));
-
-               // Test third and final if condition
-               $this->utilMock->expects($this->once())
-                       ->method('userHasFiles')
-                       ->with('testUser1')
-                       ->willReturn(false);
-
-               $this->cryptMock->expects($this->once())
-                       ->method('createKeyPair');
-
-               $this->keyManagerMock->expects($this->once())
-                       ->method('setPrivateKey');
-
-               $this->recoveryMock->expects($this->once())
-                       ->method('recoverUsersFiles')
-                       ->with('password', 'testUser1');
-
-               $this->assertNull($this->instance->setPassphrase($this->params));
-       }
-
-       public function testSetPassphraseResetUserMode(): void {
-               $params = ['uid' => 'user1', 'password' => 'password'];
-               $this->invokePrivate($this->instance, 'passwordResetUsers', [[$params['uid'] => true]]);
-               $this->sessionMock->expects($this->never())->method('getPrivateKey');
-               $this->keyManagerMock->expects($this->never())->method('setPrivateKey');
-               $this->assertTrue($this->instance->setPassphrase($params));
-               $this->invokePrivate($this->instance, 'passwordResetUsers', [[]]);
-       }
-
-       public function XtestSetPasswordNoUser() {
-               $userSessionMock = $this->getMockBuilder(IUserSession::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-
-               $userSessionMock->expects($this->any())->method('getUser')->willReturn(null);
-
-               $this->recoveryMock->expects($this->once())
-                       ->method('isRecoveryEnabledForUser')
-                       ->with('testUser')
-                       ->willReturn(false);
-
-               $userHooks = $this->getMockBuilder(UserHooks::class)
-                       ->setConstructorArgs(
-                               [
-                                       $this->keyManagerMock,
-                                       $this->userManagerMock,
-                                       $this->loggerMock,
-                                       $this->userSetupMock,
-                                       $userSessionMock,
-                                       $this->utilMock,
-                                       $this->sessionMock,
-                                       $this->cryptMock,
-                                       $this->recoveryMock
-                               ]
-                       )->setMethods(['initMountPoints'])->getMock();
-
-               /** @var UserHooks $userHooks */
-               $this->assertNull($userHooks->setPassphrase($this->params));
-       }
-
-       protected function setUp(): void {
-               parent::setUp();
-               $this->loggerMock = $this->createMock(LoggerInterface::class);
-               $this->keyManagerMock = $this->getMockBuilder(KeyManager::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-               $this->userManagerMock = $this->getMockBuilder(IUserManager::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-               $this->userSetupMock = $this->getMockBuilder(Setup::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-
-               $this->user = $this->createMock(IUser::class);
-               $this->user->expects($this->any())
-                       ->method('getUID')
-                       ->willReturn('testUser');
-
-               $this->userSessionMock = $this->createMock(IUserSession::class);
-               $this->userSessionMock->expects($this->any())
-                       ->method('getUser')
-                       ->willReturn($this->user);
-
-               $utilMock = $this->getMockBuilder(Util::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-
-               $sessionMock = $this->getMockBuilder(Session::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-
-               $this->cryptMock = $this->getMockBuilder(Crypt::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-               $recoveryMock = $this->getMockBuilder(Recovery::class)
-                       ->disableOriginalConstructor()
-                       ->getMock();
-
-               $this->sessionMock = $sessionMock;
-               $this->recoveryMock = $recoveryMock;
-               $this->utilMock = $utilMock;
-               $this->utilMock->expects($this->any())->method('isMasterKeyEnabled')->willReturn(false);
-
-               $this->instance = $this->getMockBuilder(UserHooks::class)
-                       ->setConstructorArgs(
-                               [
-                                       $this->keyManagerMock,
-                                       $this->userManagerMock,
-                                       $this->loggerMock,
-                                       $this->userSetupMock,
-                                       $this->userSessionMock,
-                                       $this->utilMock,
-                                       $this->sessionMock,
-                                       $this->cryptMock,
-                                       $this->recoveryMock
-                               ]
-                       )->setMethods(['setupFS'])->getMock();
-       }
-}
diff --git a/apps/encryption/tests/Listeners/UserEventsListenersTest.php b/apps/encryption/tests/Listeners/UserEventsListenersTest.php
new file mode 100644 (file)
index 0000000..cb31523
--- /dev/null
@@ -0,0 +1,258 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Encryption\Tests\Listeners;
+
+use OC\Core\Events\BeforePasswordResetEvent;
+use OC\Core\Events\PasswordResetEvent;
+use OC\Files\SetupManager;
+use OCA\Encryption\KeyManager;
+use OCA\Encryption\Listeners\UserEventsListener;
+use OCA\Encryption\Services\PassphraseService;
+use OCA\Encryption\Session;
+use OCA\Encryption\Users\Setup;
+use OCA\Encryption\Util;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\User\Events\BeforePasswordUpdatedEvent;
+use OCP\User\Events\PasswordUpdatedEvent;
+use OCP\User\Events\UserCreatedEvent;
+use OCP\User\Events\UserDeletedEvent;
+use OCP\User\Events\UserLoggedInEvent;
+use OCP\User\Events\UserLoggedOutEvent;
+use PHPUnit\Framework\MockObject\MockObject;
+use Test\TestCase;
+
+/**
+ * @group DB
+ */
+class UserEventsListenersTest extends TestCase {
+
+       protected Util&MockObject $util;
+       protected Setup&MockObject $userSetup;
+       protected Session&MockObject $session;
+       protected KeyManager&MockObject $keyManager;
+       protected IUserManager&MockObject $userManager;
+       protected IUserSession&MockObject $userSession;
+       protected SetupManager&MockObject $setupManager;
+       protected PassphraseService&MockObject $passphraseService;
+
+       protected UserEventsListener $instance;
+
+       public function setUp(): void {
+               parent::setUp();
+
+               $this->util = $this->createMock(Util::class);
+               $this->userSetup = $this->createMock(Setup::class);
+               $this->session = $this->createMock(Session::class);
+               $this->keyManager = $this->createMock(KeyManager::class);
+               $this->userManager = $this->createMock(IUserManager::class);
+               $this->userSession = $this->createMock(IUserSession::class);
+               $this->setupManager = $this->createMock(SetupManager::class);
+               $this->passphraseService = $this->createMock(PassphraseService::class);
+
+               $this->instance = new UserEventsListener(
+                       $this->util,
+                       $this->userSetup,
+                       $this->session,
+                       $this->keyManager,
+                       $this->userManager,
+                       $this->userSession,
+                       $this->setupManager,
+                       $this->passphraseService,
+               );
+       }
+
+       public function testLogin(): void {
+               $this->userSetup->expects(self::once())
+                       ->method('setupUser')
+                       ->willReturn(true);
+
+               $this->keyManager->expects(self::once())
+                       ->method('init')
+                       ->with('testUser', 'password');
+
+               $this->util->method('isMasterKeyEnabled')->willReturn(false);
+
+               $user = $this->createMock(IUser::class);
+               $user->expects(self::any())
+                       ->method('getUID')
+                       ->willReturn('testUser');
+               $event = $this->createMock(UserLoggedInEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUser')
+                       ->willReturn($user);
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+
+               $this->instance->handle($event);
+       }
+
+       public function testLoginMasterKey(): void {
+               $this->util->method('isMasterKeyEnabled')->willReturn(true);
+
+               $this->userSetup->expects(self::never())
+                       ->method('setupUser');
+
+               $this->keyManager->expects(self::once())
+                       ->method('init')
+                       ->with('testUser', 'password');
+
+               $user = $this->createMock(IUser::class);
+               $user->expects(self::any())
+                       ->method('getUID')
+                       ->willReturn('testUser');
+
+               $event = $this->createMock(UserLoggedInEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUser')
+                       ->willReturn($user);
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+
+               $this->instance->handle($event);
+       }
+
+       public function testLogout(): void {
+               $this->session->expects(self::once())
+                       ->method('clear');
+
+               $event = $this->createMock(UserLoggedOutEvent::class);
+               $this->instance->handle($event);
+       }
+
+       public function testUserCreated(): void {
+               $this->userSetup->expects(self::once())
+                       ->method('setupUser')
+                       ->with('testUser', 'password');
+
+               $event = $this->createMock(UserCreatedEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUid')
+                       ->willReturn('testUser');
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+
+               $this->instance->handle($event);
+       }
+
+       public function testUserDeleted(): void {
+               $this->keyManager->expects(self::once())
+                       ->method('deletePublicKey')
+                       ->with('testUser');
+
+               $event = $this->createMock(UserDeletedEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUid')
+                       ->willReturn('testUser');
+               $this->instance->handle($event);
+       }
+
+       public function testBeforePasswordUpdated(): void {
+               $this->passphraseService->expects(self::never())
+                       ->method('setPassphraseForUser');
+
+               $user = $this->createMock(IUser::class);
+               $user->expects(self::atLeastOnce())
+                       ->method('canChangePassword')
+                       ->willReturn(true);
+
+               $event = $this->createMock(BeforePasswordUpdatedEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUser')
+                       ->willReturn($user);
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+               $this->instance->handle($event);
+       }
+
+       public function testBeforePasswordUpdated_CannotChangePassword(): void {
+               $this->passphraseService->expects(self::once())
+                       ->method('setPassphraseForUser')
+                       ->with('testUser', 'password');
+
+               $user = $this->createMock(IUser::class);
+               $user->expects(self::atLeastOnce())
+                       ->method('getUID')
+                       ->willReturn('testUser');
+               $user->expects(self::atLeastOnce())
+                       ->method('canChangePassword')
+                       ->willReturn(false);
+
+               $event = $this->createMock(BeforePasswordUpdatedEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUser')
+                       ->willReturn($user);
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+               $this->instance->handle($event);
+       }
+
+       public function testPasswordUpdated(): void {
+               $this->passphraseService->expects(self::once())
+                       ->method('setPassphraseForUser')
+                       ->with('testUser', 'password');
+
+               $event = $this->createMock(PasswordUpdatedEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUid')
+                       ->willReturn('testUser');
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+
+               $this->instance->handle($event);
+       }
+
+       public function testBeforePasswordReset(): void {
+               $this->passphraseService->expects(self::once())
+                       ->method('setProcessingReset')
+                       ->with('testUser');
+
+               $event = $this->createMock(BeforePasswordResetEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUid')
+                       ->willReturn('testUser');
+               $this->instance->handle($event);
+       }
+
+       public function testPasswordReset(): void {
+               // backup required
+               $this->keyManager->expects(self::once())
+                       ->method('backupUserKeys')
+                       ->with('passwordReset', 'testUser');
+               // delete old keys
+               $this->keyManager->expects(self::once())
+                       ->method('deleteUserKeys')
+                       ->with('testUser');
+               // create new keys
+               $this->userSetup->expects(self::once())
+                       ->method('setupUser')
+                       ->with('testUser', 'password');
+               // reset ends
+               $this->passphraseService->expects(self::once())
+                       ->method('setProcessingReset')
+                       ->with('testUser', false);
+
+               $event = $this->createMock(PasswordResetEvent::class);
+               $event->expects(self::atLeastOnce())
+                       ->method('getUid')
+                       ->willReturn('testUser');
+               $event->expects(self::atLeastOnce())
+                       ->method('getPassword')
+                       ->willReturn('password');
+               $this->instance->handle($event);
+       }
+
+}
diff --git a/apps/encryption/tests/PassphraseServiceTest.php b/apps/encryption/tests/PassphraseServiceTest.php
new file mode 100644 (file)
index 0000000..c2dc9d8
--- /dev/null
@@ -0,0 +1,196 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Encryption\Tests;
+
+use OCA\Encryption\Crypto\Crypt;
+use OCA\Encryption\KeyManager;
+use OCA\Encryption\Recovery;
+use OCA\Encryption\Services\PassphraseService;
+use OCA\Encryption\Session;
+use OCA\Encryption\Util;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use Psr\Log\LoggerInterface;
+use Test\TestCase;
+
+/**
+ * @group DB
+ */
+class PassphraseServiceTest extends TestCase {
+
+       protected Util&MockObject $util;
+       protected Crypt&MockObject $crypt;
+       protected Session&MockObject $session;
+       protected Recovery&MockObject $recovery;
+       protected KeyManager&MockObject $keyManager;
+       protected IUserManager&MockObject $userManager;
+       protected IUserSession&MockObject $userSession;
+
+       protected PassphraseService $instance;
+
+       public function setUp(): void {
+               parent::setUp();
+
+               $this->util = $this->createMock(Util::class);
+               $this->crypt = $this->createMock(Crypt::class);
+               $this->session = $this->createMock(Session::class);
+               $this->recovery = $this->createMock(Recovery::class);
+               $this->keyManager = $this->createMock(KeyManager::class);
+               $this->userManager = $this->createMock(IUserManager::class);
+               $this->userSession = $this->createMock(IUserSession::class);
+
+               $this->instance = new PassphraseService(
+                       $this->util,
+                       $this->crypt,
+                       $this->session,
+                       $this->recovery,
+                       $this->keyManager,
+                       $this->createMock(LoggerInterface::class),
+                       $this->userManager,
+                       $this->userSession,
+               );
+       }
+
+       public function testSetProcessingReset(): void {
+               $this->instance->setProcessingReset('userId');
+               $this->assertEquals(['userId' => true], $this->invokePrivate($this->instance, 'passwordResetUsers'));
+       }
+
+       public function testUnsetProcessingReset(): void {
+               $this->instance->setProcessingReset('userId');
+               $this->assertEquals(['userId' => true], $this->invokePrivate($this->instance, 'passwordResetUsers'));
+               $this->instance->setProcessingReset('userId', false);
+               $this->assertEquals([], $this->invokePrivate($this->instance, 'passwordResetUsers'));
+       }
+
+       /**
+        * Check that the passphrase setting skips if a reset is processed
+        */
+       public function testSetPassphraseResetUserMode(): void {
+               $this->session->expects(self::never())
+                       ->method('getPrivateKey');
+               $this->keyManager->expects(self::never())
+                       ->method('setPrivateKey');
+
+               $this->instance->setProcessingReset('userId');
+               $this->assertTrue($this->instance->setPassphraseForUser('userId', 'password'));
+       }
+
+       public function testSetPassphrase_currentUser() {
+               $instance = $this->getMockBuilder(PassphraseService::class)
+                       ->onlyMethods(['initMountPoints'])
+                       ->setConstructorArgs([
+                               $this->util,
+                               $this->crypt,
+                               $this->session,
+                               $this->recovery,
+                               $this->keyManager,
+                               $this->createMock(LoggerInterface::class),
+                               $this->userManager,
+                               $this->userSession,
+                       ])
+                       ->getMock();
+
+               $user = $this->createMock(IUser::class);
+               $user->method('getUID')->willReturn('testUser');
+               $this->userSession->expects(self::atLeastOnce())
+                       ->method('getUser')
+                       ->willReturn($user);
+               $this->userManager->expects(self::atLeastOnce())
+                       ->method('get')
+                       ->with('testUser')
+                       ->willReturn($user);
+               $this->session->expects(self::any())
+                       ->method('getPrivateKey')
+                       ->willReturn('private-key');
+               $this->crypt->expects(self::any())
+                       ->method('encryptPrivateKey')
+                       ->with('private-key')
+                       ->willReturn('encrypted-key');
+               $this->crypt->expects(self::any())
+                       ->method('generateHeader')
+                       ->willReturn('crypt-header: ');
+
+               $this->keyManager->expects(self::atLeastOnce())
+                       ->method('setPrivateKey')
+                       ->with('testUser', 'crypt-header: encrypted-key');
+
+               $this->assertTrue($instance->setPassphraseForUser('testUser', 'password'));
+       }
+
+       public function testSetPassphrase_currentUserFails() {
+               $instance = $this->getMockBuilder(PassphraseService::class)
+                       ->onlyMethods(['initMountPoints'])
+                       ->setConstructorArgs([
+                               $this->util,
+                               $this->crypt,
+                               $this->session,
+                               $this->recovery,
+                               $this->keyManager,
+                               $this->createMock(LoggerInterface::class),
+                               $this->userManager,
+                               $this->userSession,
+                       ])
+                       ->getMock();
+
+               $user = $this->createMock(IUser::class);
+               $user->method('getUID')->willReturn('testUser');
+               $this->userManager->expects(self::atLeastOnce())
+                       ->method('get')
+                       ->with('testUser')
+                       ->willReturn($user);
+               $this->userSession->expects(self::atLeastOnce())
+                       ->method('getUser')
+                       ->willReturn($user);
+               $this->session->expects(self::any())
+                       ->method('getPrivateKey')
+                       ->willReturn('private-key');
+               $this->crypt->expects(self::any())
+                       ->method('encryptPrivateKey')
+                       ->with('private-key')
+                       ->willReturn(false);
+
+               $this->keyManager->expects(self::never())
+                       ->method('setPrivateKey');
+
+               $this->assertFalse($instance->setPassphraseForUser('testUser', 'password'));
+       }
+
+       public function testSetPassphrase_currentUserNotExists() {
+               $instance = $this->getMockBuilder(PassphraseService::class)
+                       ->onlyMethods(['initMountPoints'])
+                       ->setConstructorArgs([
+                               $this->util,
+                               $this->crypt,
+                               $this->session,
+                               $this->recovery,
+                               $this->keyManager,
+                               $this->createMock(LoggerInterface::class),
+                               $this->userManager,
+                               $this->userSession,
+                       ])
+                       ->getMock();
+
+               $user = $this->createMock(IUser::class);
+               $user->method('getUID')->willReturn('testUser');
+               $this->userManager->expects(self::atLeastOnce())
+                       ->method('get')
+                       ->with('testUser')
+                       ->willReturn(null);
+               $this->userSession->expects(self::never())
+                       ->method('getUser');
+               $this->keyManager->expects(self::never())
+                       ->method('setPrivateKey');
+
+               $this->assertFalse($instance->setPassphraseForUser('testUser', 'password'));
+       }
+
+}
index 25e52e66f39e98f69910ec30b077dc55b3c79f23..904107f6998fb4d26023064951ce3cea31b0136c 100644 (file)
@@ -14,8 +14,9 @@ use OCA\Encryption\Controller\RecoveryController;
 use OCA\Encryption\Controller\SettingsController;
 use OCA\Encryption\Crypto\Crypt;
 use OCA\Encryption\Crypto\Encryption;
-use OCA\Encryption\Hooks\UserHooks;
 use OCA\Encryption\KeyManager;
+use OCA\Encryption\Listeners\UserEventsListener;
+use OCA\Encryption\Services\PassphraseService;
 use OCA\Encryption\Session;
 use OCP\HintException;
 
@@ -169,14 +170,16 @@ class ExceptionSerializer {
                \OCA\Encryption\Users\Setup::class => [
                        'setupUser',
                ],
-               UserHooks::class => [
-                       'login',
-                       'postCreateUser',
-                       'postDeleteUser',
-                       'prePasswordReset',
-                       'postPasswordReset',
-                       'preSetPassphrase',
-                       'setPassphrase',
+               UserEventsListener::class => [
+                       'handle',
+                       'onUserCreated',
+                       'onUserLogin',
+                       'onBeforePasswordUpdated',
+                       'onPasswordUpdated',
+                       'onPasswordReset',
+               ],
+               PassphraseService::class => [
+                       'setPassphraseForUser',
                ],
        ];