Skip to content

Commit f1cdf68

Browse files
committed
feat: add occ command to scan and selete orphaned keys
Signed-off-by: Hamza <hamzamahjoubi221@gmail.com>
1 parent 9581230 commit f1cdf68

File tree

4 files changed

+205
-0
lines changed

4 files changed

+205
-0
lines changed

apps/encryption/appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
<command>OCA\Encryption\Command\FixEncryptedVersion</command>
6868
<command>OCA\Encryption\Command\FixKeyLocation</command>
6969
<command>OCA\Encryption\Command\DropLegacyFileKey</command>
70+
<command>OCA\Encryption\Command\CleanOrphanedKeys</command>
7071
</commands>
7172

7273
<settings>

apps/encryption/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
return array(
99
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
1010
'OCA\\Encryption\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
11+
'OCA\\Encryption\\Command\\CleanOrphanedKeys' => $baseDir . '/../lib/Command/CleanOrphanedKeys.php',
1112
'OCA\\Encryption\\Command\\DisableMasterKey' => $baseDir . '/../lib/Command/DisableMasterKey.php',
1213
'OCA\\Encryption\\Command\\DropLegacyFileKey' => $baseDir . '/../lib/Command/DropLegacyFileKey.php',
1314
'OCA\\Encryption\\Command\\EnableMasterKey' => $baseDir . '/../lib/Command/EnableMasterKey.php',

apps/encryption/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class ComposerStaticInitEncryption
2323
public static $classMap = array (
2424
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
2525
'OCA\\Encryption\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
26+
'OCA\\Encryption\\Command\\CleanOrphanedKeys' => __DIR__ . '/..' . '/../lib/Command/CleanOrphanedKeys.php',
2627
'OCA\\Encryption\\Command\\DisableMasterKey' => __DIR__ . '/..' . '/../lib/Command/DisableMasterKey.php',
2728
'OCA\\Encryption\\Command\\DropLegacyFileKey' => __DIR__ . '/..' . '/../lib/Command/DropLegacyFileKey.php',
2829
'OCA\\Encryption\\Command\\EnableMasterKey' => __DIR__ . '/..' . '/../lib/Command/EnableMasterKey.php',
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\Encryption\Command;
10+
11+
use OC\Encryption\Util;
12+
use OC\Files\SetupManager;
13+
use OCA\Encryption\Crypto\Encryption;
14+
use OCP\Files\File;
15+
use OCP\Files\Folder;
16+
use OCP\Files\IRootFolder;
17+
use OCP\Files\NotFoundException;
18+
use OCP\IConfig;
19+
use OCP\IUser;
20+
use OCP\IUserManager;
21+
use Psr\Log\LoggerInterface;
22+
use Symfony\Component\Console\Command\Command;
23+
use Symfony\Component\Console\Helper\ProgressBar;
24+
use Symfony\Component\Console\Helper\QuestionHelper;
25+
use Symfony\Component\Console\Input\InputInterface;
26+
use Symfony\Component\Console\Output\OutputInterface;
27+
use Symfony\Component\Console\Question\ConfirmationQuestion;
28+
use Symfony\Component\Console\Question\Question;
29+
30+
class CleanOrphanedKeys extends Command {
31+
32+
public function __construct(
33+
protected IConfig $config,
34+
protected QuestionHelper $questionHelper,
35+
private IUserManager $userManager,
36+
private Util $encryptionUtil,
37+
private SetupManager $setupManager,
38+
private IRootFolder $rootFolder,
39+
private LoggerInterface $logger,
40+
) {
41+
parent::__construct();
42+
43+
}
44+
45+
protected function configure(): void {
46+
$this
47+
->setName('encryption:clean-orphaned-keys')
48+
->setDescription('Scan the keys storage for orphaned keys and remove them');
49+
}
50+
51+
protected function execute(InputInterface $input, OutputInterface $output): int {
52+
$orphanedKeys = [];
53+
$headline = 'Scanning all keys for file parity';
54+
$output->writeln($headline);
55+
$output->writeln(str_pad('', strlen($headline), '='));
56+
$output->writeln("\n");
57+
$progress = new ProgressBar($output);
58+
$progress->setFormat(" %message% \n [%bar%]");
59+
60+
foreach ($this->userManager->getSeenUsers() as $user) {
61+
$uid = $user->getUID();
62+
$progress->setMessage('Scanning all keys for: ' . $uid);
63+
$progress->advance();
64+
$this->setupUserFileSystem($user);
65+
$root = $this->encryptionUtil->getKeyStorageRoot() . '/' . $uid . '/files_encryption/keys';
66+
$userOrphanedKeys = $this->scanFolder($output, $root, $uid);
67+
$orphanedKeys = array_merge($orphanedKeys, $userOrphanedKeys);
68+
}
69+
$progress->setMessage('Scanned orphaned keys for all users');
70+
$progress->finish();
71+
$output->writeln("\n");
72+
foreach ($orphanedKeys as $keyPath) {
73+
$output->writeln('Orphaned key found: ' . $keyPath);
74+
}
75+
if (count($orphanedKeys) == 0) {
76+
return self::SUCCESS;
77+
}
78+
$question = new ConfirmationQuestion('Do you want to delete all orphaned keys? (y/n) ', false);
79+
if ($this->questionHelper->ask($input, $output, $question)) {
80+
$this->deleteAll($orphanedKeys, $output);
81+
} else {
82+
83+
$question = new ConfirmationQuestion('Do you want to delete specific keys? (y/n) ', false);
84+
if ($this->questionHelper->ask($input, $output, $question)) {
85+
$this->deleteSpecific($input, $output, $orphanedKeys);
86+
}
87+
}
88+
89+
return self::SUCCESS;
90+
}
91+
92+
private function scanFolder(OutputInterface $output, string $folderPath, string $user) : array {
93+
$orphanedKeys = [];
94+
try {
95+
$folder = $this->rootFolder->get($folderPath);
96+
} catch (NotFoundException $e) {
97+
// Happens when user doesn't have encrypted files
98+
$this->logger->error('Error when accessing folder ' . $folderPath . ' for user ' . $user, ['exception' => $e]);
99+
return [];
100+
}
101+
102+
if (!($folder instanceof Folder)) {
103+
$this->logger->error('Invalid folder');
104+
return [];
105+
}
106+
107+
foreach ($folder->getDirectoryListing() as $item) {
108+
$path = $folderPath . '/' . $item->getName();
109+
$stopValue = $this->stopCondition($path);
110+
if ($stopValue === null) {
111+
$this->logger->error('Reached unexpected state when scanning user\'s filesystem for orphaned encryption keys' . $path);
112+
} elseif ($stopValue) {
113+
$filePath = str_replace('files_encryption/keys/', '', $path);
114+
try {
115+
$this->rootFolder->get($filePath);
116+
} catch (NotFoundException $e) {
117+
// We found an orphaned key
118+
$orphanedKeys[] = $path;
119+
continue;
120+
}
121+
} else {
122+
$orphanedKeys = array_merge($orphanedKeys, $this->scanFolder($output, $path, $user));
123+
}
124+
}
125+
return $orphanedKeys;
126+
}
127+
/**
128+
* Checks the stop considition for the recursion
129+
* following the logic that keys are stored in files_encryption/keys/<user>/<path>/<fileName>/OC_DEFAULT_MODULE/<key>.sharekey
130+
* @param string $path path of the current folder
131+
* @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
132+
*/
133+
private function stopCondition(string $path) : ?bool {
134+
$folder = $this->rootFolder->get($path);
135+
if ($folder instanceof Folder) {
136+
$content = $folder->getDirectoryListing();
137+
$subfolder = $content[0];
138+
if (count($content) === 1 && $subfolder->getName() === Encryption::ID) {
139+
if ($subfolder instanceof Folder) {
140+
$content = $subfolder->getDirectoryListing();
141+
if (count($content) === 1 && $content[0] instanceof File) {
142+
return strtolower($content[0]->getExtension()) === 'sharekey' ;
143+
}
144+
}
145+
}
146+
return false;
147+
}
148+
// We shouldn't end up here, because we return true when reaching the folder named after the file containing OC_DEFAULT_MODULE
149+
return null;
150+
}
151+
private function deleteAll(array $keys, OutputInterface $output) {
152+
foreach ($keys as $key) {
153+
$file = $this->rootFolder->get($key);
154+
try {
155+
$file->delete();
156+
$output->writeln('Key deleted: ' . $key);
157+
} catch (\Exception $e) {
158+
$output->writeln('Failed to delete ' . $key);
159+
$this->logger->error('Error when deleting orphaned key ' . $key . '. ' . $e->getMessage());
160+
}
161+
}
162+
}
163+
164+
private function deleteSpecific(InputInterface $input, OutputInterface $output, array $orphanedKeys) {
165+
$question = new Question('Please enter path for key to delete: ');
166+
$path = $this->questionHelper->ask($input, $output, $question);
167+
if (!in_array(trim($path), $orphanedKeys)) {
168+
$output->writeln('Wrong key path');
169+
} else {
170+
try {
171+
$this->rootFolder->get(trim($path))->delete();
172+
$output->writeln('Key deleted: ' . $path);
173+
} catch (\Exception $e) {
174+
$output->writeln('Failed to delete ' . $path);
175+
$this->logger->error('Error when deleting orphaned key ' . $path . '. ' . $e->getMessage());
176+
}
177+
$orphanedKeys = array_filter($orphanedKeys, function ($k) use ($path) {
178+
return $k !== trim($path);
179+
});
180+
}
181+
if (count($orphanedKeys) == 0) {
182+
return;
183+
}
184+
$output->writeln('Remaining orphaned keys: ');
185+
foreach ($orphanedKeys as $keyPath) {
186+
$output->writeln($keyPath);
187+
}
188+
$question = new ConfirmationQuestion('Do you want to delete more orphaned keys? (y/n) ', false);
189+
if ($this->questionHelper->ask($input, $output, $question)) {
190+
$this->deleteSpecific($input, $output, $orphanedKeys);
191+
}
192+
193+
}
194+
195+
/**
196+
* setup user file system
197+
*/
198+
protected function setupUserFileSystem(IUser $user): void {
199+
$this->setupManager->tearDown();
200+
$this->setupManager->setupForUser($user);
201+
}
202+
}

0 commit comments

Comments
 (0)