diff --git a/apps/encryption/appinfo/info.xml b/apps/encryption/appinfo/info.xml
index 3e2469631c888..5b579915ce426 100644
--- a/apps/encryption/appinfo/info.xml
+++ b/apps/encryption/appinfo/info.xml
@@ -46,6 +46,7 @@
OCA\Encryption\Command\RecoverUser
OCA\Encryption\Command\ScanLegacyFormat
OCA\Encryption\Command\FixEncryptedVersion
+ OCA\Encryption\Command\FixKeyLocation
diff --git a/apps/encryption/composer/composer/autoload_classmap.php b/apps/encryption/composer/composer/autoload_classmap.php
index 0ce1e86f8a6b2..9f9ab4e406f3d 100644
--- a/apps/encryption/composer/composer/autoload_classmap.php
+++ b/apps/encryption/composer/composer/autoload_classmap.php
@@ -11,6 +11,7 @@
'OCA\\Encryption\\Command\\DisableMasterKey' => $baseDir . '/../lib/Command/DisableMasterKey.php',
'OCA\\Encryption\\Command\\EnableMasterKey' => $baseDir . '/../lib/Command/EnableMasterKey.php',
'OCA\\Encryption\\Command\\FixEncryptedVersion' => $baseDir . '/../lib/Command/FixEncryptedVersion.php',
+ 'OCA\\Encryption\\Command\\FixKeyLocation' => $baseDir . '/../lib/Command/FixKeyLocation.php',
'OCA\\Encryption\\Command\\RecoverUser' => $baseDir . '/../lib/Command/RecoverUser.php',
'OCA\\Encryption\\Command\\ScanLegacyFormat' => $baseDir . '/../lib/Command/ScanLegacyFormat.php',
'OCA\\Encryption\\Controller\\RecoveryController' => $baseDir . '/../lib/Controller/RecoveryController.php',
diff --git a/apps/encryption/composer/composer/autoload_static.php b/apps/encryption/composer/composer/autoload_static.php
index fc1fcbcf63b4e..8f50f0649970f 100644
--- a/apps/encryption/composer/composer/autoload_static.php
+++ b/apps/encryption/composer/composer/autoload_static.php
@@ -26,6 +26,7 @@ class ComposerStaticInitEncryption
'OCA\\Encryption\\Command\\DisableMasterKey' => __DIR__ . '/..' . '/../lib/Command/DisableMasterKey.php',
'OCA\\Encryption\\Command\\EnableMasterKey' => __DIR__ . '/..' . '/../lib/Command/EnableMasterKey.php',
'OCA\\Encryption\\Command\\FixEncryptedVersion' => __DIR__ . '/..' . '/../lib/Command/FixEncryptedVersion.php',
+ 'OCA\\Encryption\\Command\\FixKeyLocation' => __DIR__ . '/..' . '/../lib/Command/FixKeyLocation.php',
'OCA\\Encryption\\Command\\RecoverUser' => __DIR__ . '/..' . '/../lib/Command/RecoverUser.php',
'OCA\\Encryption\\Command\\ScanLegacyFormat' => __DIR__ . '/..' . '/../lib/Command/ScanLegacyFormat.php',
'OCA\\Encryption\\Controller\\RecoveryController' => __DIR__ . '/..' . '/../lib/Controller/RecoveryController.php',
diff --git a/apps/encryption/lib/Command/FixKeyLocation.php b/apps/encryption/lib/Command/FixKeyLocation.php
new file mode 100644
index 0000000000000..5339247ae1974
--- /dev/null
+++ b/apps/encryption/lib/Command/FixKeyLocation.php
@@ -0,0 +1,186 @@
+
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+namespace OCA\Encryption\Command;
+
+use OC\Encryption\Util;
+use OC\Files\View;
+use OCP\Files\Config\ICachedMountInfo;
+use OCP\Files\Config\IUserMountCache;
+use OCP\Files\Folder;
+use OCP\Files\File;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class FixKeyLocation extends Command {
+ private IUserManager $userManager;
+ private IUserMountCache $userMountCache;
+ private Util $encryptionUtil;
+ private IRootFolder $rootFolder;
+ private string $keyRootDirectory;
+ private View $rootView;
+
+ public function __construct(IUserManager $userManager, IUserMountCache $userMountCache, Util $encryptionUtil, IRootFolder $rootFolder) {
+ $this->userManager = $userManager;
+ $this->userMountCache = $userMountCache;
+ $this->encryptionUtil = $encryptionUtil;
+ $this->rootFolder = $rootFolder;
+ $this->keyRootDirectory = rtrim($this->encryptionUtil->getKeyStorageRoot(), '/');
+ $this->rootView = new View();
+
+ parent::__construct();
+ }
+
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('encryption:fix-key-location')
+ ->setDescription('Fix the location of encryption keys for external storage')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, "Only list files that require key migration, don't try to perform any migration")
+ ->addArgument('user', InputArgument::REQUIRED, "User id to fix the key locations for");
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $dryRun = $input->getOption('dry-run');
+ $userId = $input->getArgument('user');
+ $user = $this->userManager->get($userId);
+ if (!$user) {
+ $output->writeln("User $userId not found");
+ return 1;
+ }
+
+ \OC_Util::setupFS($user->getUID());
+
+ $mounts = $this->getSystemMountsForUser($user);
+ foreach ($mounts as $mount) {
+ $mountRootFolder = $this->rootFolder->get($mount->getMountPoint());
+ if (!$mountRootFolder instanceof Folder) {
+ $output->writeln("System wide mount point is not a directory, skipping: " . $mount->getMountPoint() . "");
+ continue;
+ }
+
+ $files = $this->getAllFiles($mountRootFolder);
+ foreach ($files as $file) {
+ if ($this->isKeyStoredForUser($user, $file)) {
+ if ($dryRun) {
+ $output->writeln("" . $file->getPath() . " needs migration");
+ } else {
+ $output->write("Migrating key for " . $file->getPath() . " ");
+ if ($this->copyKeyAndValidate($user, $file)) {
+ $output->writeln("✓");
+ } else {
+ $output->writeln("❌>");
+ $output->writeln(" Failed to validate key for " . $file->getPath() . ", key will not be migrated");
+ }
+ }
+ }
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * @param IUser $user
+ * @return ICachedMountInfo[]
+ */
+ private function getSystemMountsForUser(IUser $user): array {
+ return array_filter($this->userMountCache->getMountsForUser($user), function(ICachedMountInfo $mount) use ($user) {
+ $mountPoint = substr($mount->getMountPoint(), strlen($user->getUID() . '/'));
+ return $this->encryptionUtil->isSystemWideMountPoint($mountPoint, $user->getUID());
+ });
+ }
+
+ /**
+ * @param Folder $folder
+ * @return \Generator
+ */
+ private function getAllFiles(Folder $folder) {
+ foreach ($folder->getDirectoryListing() as $child) {
+ if ($child instanceof Folder) {
+ yield from $this->getAllFiles($child);
+ } else {
+ yield $child;
+ }
+ }
+ }
+
+ /**
+ * Check if the key for a file is stored in the user's keystore and not the system one
+ *
+ * @param IUser $user
+ * @param Node $node
+ * @return bool
+ */
+ private function isKeyStoredForUser(IUser $user, Node $node): bool {
+ $path = trim(substr($node->getPath(), strlen($user->getUID()) + 1), '/');
+ $systemKeyPath = $this->keyRootDirectory . '/files_encryption/keys/' . $path . '/';
+ $userKeyPath = $this->keyRootDirectory . '/' . $user->getUID() . '/files_encryption/keys/' . $path . '/';
+
+ // this uses View instead of the RootFolder because the keys might not be in the cache
+ $systemKeyExists = $this->rootView->file_exists($systemKeyPath);
+ $userKeyExists = $this->rootView->file_exists($userKeyPath);
+ return $userKeyExists && !$systemKeyExists;
+ }
+
+ /**
+ * Check that the user key stored for a file can decrypt the file
+ *
+ * @param IUser $user
+ * @param File $node
+ * @return bool
+ */
+ private function copyKeyAndValidate(IUser $user, File $node): bool {
+ $path = trim(substr($node->getPath(), strlen($user->getUID()) + 1), '/');
+ $systemKeyPath = $this->keyRootDirectory . '/files_encryption/keys/' . $path . '/';
+ $userKeyPath = $this->keyRootDirectory . '/' . $user->getUID() . '/files_encryption/keys/' . $path . '/';
+
+ $this->rootView->copy($userKeyPath, $systemKeyPath);
+ try {
+ // check that the copied key is valid
+ $fh = $node->fopen('r');
+ // read a single chunk
+ $data = fread($fh, 8192);
+ if ($data === false) {
+ throw new \Exception("Read failed");
+ }
+
+ // cleanup wrong key location
+ $this->rootView->rmdir($userKeyPath);
+ return true;
+ } catch (\Exception $e) {
+ // remove the copied key if we know it's invalid
+ $this->rootView->rmdir($systemKeyPath);
+ return false;
+ }
+ }
+}