FixKeyLocation.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Encryption\Command;
  8. use OC\Encryption\Manager;
  9. use OC\Encryption\Util;
  10. use OC\Files\Storage\Wrapper\Encryption;
  11. use OC\Files\View;
  12. use OCP\Encryption\IManager;
  13. use OCP\Files\Config\ICachedMountInfo;
  14. use OCP\Files\Config\IUserMountCache;
  15. use OCP\Files\File;
  16. use OCP\Files\Folder;
  17. use OCP\Files\IRootFolder;
  18. use OCP\Files\Node;
  19. use OCP\IUser;
  20. use OCP\IUserManager;
  21. use Symfony\Component\Console\Command\Command;
  22. use Symfony\Component\Console\Input\InputArgument;
  23. use Symfony\Component\Console\Input\InputInterface;
  24. use Symfony\Component\Console\Input\InputOption;
  25. use Symfony\Component\Console\Output\OutputInterface;
  26. class FixKeyLocation extends Command {
  27. private string $keyRootDirectory;
  28. private View $rootView;
  29. private Manager $encryptionManager;
  30. public function __construct(
  31. private IUserManager $userManager,
  32. private IUserMountCache $userMountCache,
  33. private Util $encryptionUtil,
  34. private IRootFolder $rootFolder,
  35. IManager $encryptionManager,
  36. ) {
  37. $this->keyRootDirectory = rtrim($this->encryptionUtil->getKeyStorageRoot(), '/');
  38. $this->rootView = new View();
  39. if (!$encryptionManager instanceof Manager) {
  40. throw new \Exception('Wrong encryption manager');
  41. }
  42. $this->encryptionManager = $encryptionManager;
  43. parent::__construct();
  44. }
  45. protected function configure(): void {
  46. parent::configure();
  47. $this
  48. ->setName('encryption:fix-key-location')
  49. ->setDescription('Fix the location of encryption keys for external storage')
  50. ->addOption('dry-run', null, InputOption::VALUE_NONE, "Only list files that require key migration, don't try to perform any migration")
  51. ->addArgument('user', InputArgument::REQUIRED, 'User id to fix the key locations for');
  52. }
  53. protected function execute(InputInterface $input, OutputInterface $output): int {
  54. $dryRun = $input->getOption('dry-run');
  55. $userId = $input->getArgument('user');
  56. $user = $this->userManager->get($userId);
  57. if (!$user) {
  58. $output->writeln("<error>User $userId not found</error>");
  59. return self::FAILURE;
  60. }
  61. \OC_Util::setupFS($user->getUID());
  62. $mounts = $this->getSystemMountsForUser($user);
  63. foreach ($mounts as $mount) {
  64. $mountRootFolder = $this->rootFolder->get($mount->getMountPoint());
  65. if (!$mountRootFolder instanceof Folder) {
  66. $output->writeln('<error>System wide mount point is not a directory, skipping: ' . $mount->getMountPoint() . '</error>');
  67. continue;
  68. }
  69. $files = $this->getAllEncryptedFiles($mountRootFolder);
  70. foreach ($files as $file) {
  71. /** @var File $file */
  72. $hasSystemKey = $this->hasSystemKey($file);
  73. $hasUserKey = $this->hasUserKey($user, $file);
  74. if (!$hasSystemKey) {
  75. if ($hasUserKey) {
  76. // key was stored incorrectly as user key, migrate
  77. if ($dryRun) {
  78. $output->writeln('<info>' . $file->getPath() . '</info> needs migration');
  79. } else {
  80. $output->write('Migrating key for <info>' . $file->getPath() . '</info> ');
  81. if ($this->copyUserKeyToSystemAndValidate($user, $file)) {
  82. $output->writeln('<info>✓</info>');
  83. } else {
  84. $output->writeln('<fg=red>❌</>');
  85. $output->writeln(' Failed to validate key for <error>' . $file->getPath() . '</error>, key will not be migrated');
  86. }
  87. }
  88. } else {
  89. // no matching key, probably from a broken cross-storage move
  90. $shouldBeEncrypted = $file->getStorage()->instanceOfStorage(Encryption::class);
  91. $isActuallyEncrypted = $this->isDataEncrypted($file);
  92. if ($isActuallyEncrypted) {
  93. if ($dryRun) {
  94. if ($shouldBeEncrypted) {
  95. $output->write('<info>' . $file->getPath() . '</info> needs migration');
  96. } else {
  97. $output->write('<info>' . $file->getPath() . '</info> needs decryption');
  98. }
  99. $foundKey = $this->findUserKeyForSystemFile($user, $file);
  100. if ($foundKey) {
  101. $output->writeln(', valid key found at <info>' . $foundKey . '</info>');
  102. } else {
  103. $output->writeln(' <error>❌ No key found</error>');
  104. }
  105. } else {
  106. if ($shouldBeEncrypted) {
  107. $output->write('<info>Migrating key for ' . $file->getPath() . '</info>');
  108. } else {
  109. $output->write('<info>Decrypting ' . $file->getPath() . '</info>');
  110. }
  111. $foundKey = $this->findUserKeyForSystemFile($user, $file);
  112. if ($foundKey) {
  113. if ($shouldBeEncrypted) {
  114. $systemKeyPath = $this->getSystemKeyPath($file);
  115. $this->rootView->copy($foundKey, $systemKeyPath);
  116. $output->writeln(' Migrated key from <info>' . $foundKey . '</info>');
  117. } else {
  118. $this->decryptWithSystemKey($file, $foundKey);
  119. $output->writeln(' Decrypted with key from <info>' . $foundKey . '</info>');
  120. }
  121. } else {
  122. $output->writeln(' <error>❌ No key found</error>');
  123. }
  124. }
  125. } else {
  126. if ($dryRun) {
  127. $output->writeln('<info>' . $file->getPath() . ' needs to be marked as not encrypted</info>');
  128. } else {
  129. $this->markAsUnEncrypted($file);
  130. $output->writeln('<info>' . $file->getPath() . ' marked as not encrypted</info>');
  131. }
  132. }
  133. }
  134. }
  135. }
  136. }
  137. return self::SUCCESS;
  138. }
  139. private function getUserRelativePath(string $path): string {
  140. $parts = explode('/', $path, 3);
  141. if (count($parts) >= 3) {
  142. return '/' . $parts[2];
  143. } else {
  144. return '';
  145. }
  146. }
  147. /**
  148. * @return ICachedMountInfo[]
  149. */
  150. private function getSystemMountsForUser(IUser $user): array {
  151. return array_filter($this->userMountCache->getMountsForUser($user), function (ICachedMountInfo $mount) use (
  152. $user
  153. ) {
  154. $mountPoint = substr($mount->getMountPoint(), strlen($user->getUID() . '/'));
  155. return $this->encryptionUtil->isSystemWideMountPoint($mountPoint, $user->getUID());
  156. });
  157. }
  158. /**
  159. * Get all files in a folder which are marked as encrypted
  160. *
  161. * @return \Generator<File>
  162. */
  163. private function getAllEncryptedFiles(Folder $folder) {
  164. foreach ($folder->getDirectoryListing() as $child) {
  165. if ($child instanceof Folder) {
  166. yield from $this->getAllEncryptedFiles($child);
  167. } else {
  168. if (substr($child->getName(), -4) !== '.bak' && $child->isEncrypted()) {
  169. yield $child;
  170. }
  171. }
  172. }
  173. }
  174. private function getSystemKeyPath(Node $node): string {
  175. $path = $this->getUserRelativePath($node->getPath());
  176. return $this->keyRootDirectory . '/files_encryption/keys/' . $path . '/';
  177. }
  178. private function getUserBaseKeyPath(IUser $user): string {
  179. return $this->keyRootDirectory . '/' . $user->getUID() . '/files_encryption/keys';
  180. }
  181. private function getUserKeyPath(IUser $user, Node $node): string {
  182. $path = $this->getUserRelativePath($node->getPath());
  183. return $this->getUserBaseKeyPath($user) . '/' . $path . '/';
  184. }
  185. private function hasSystemKey(Node $node): bool {
  186. // this uses View instead of the RootFolder because the keys might not be in the cache
  187. return $this->rootView->file_exists($this->getSystemKeyPath($node));
  188. }
  189. private function hasUserKey(IUser $user, Node $node): bool {
  190. // this uses View instead of the RootFolder because the keys might not be in the cache
  191. return $this->rootView->file_exists($this->getUserKeyPath($user, $node));
  192. }
  193. /**
  194. * Check that the user key stored for a file can decrypt the file
  195. */
  196. private function copyUserKeyToSystemAndValidate(IUser $user, File $node): bool {
  197. $path = trim(substr($node->getPath(), strlen($user->getUID()) + 1), '/');
  198. $systemKeyPath = $this->keyRootDirectory . '/files_encryption/keys/' . $path . '/';
  199. $userKeyPath = $this->keyRootDirectory . '/' . $user->getUID() . '/files_encryption/keys/' . $path . '/';
  200. $this->rootView->copy($userKeyPath, $systemKeyPath);
  201. if ($this->tryReadFile($node)) {
  202. // cleanup wrong key location
  203. $this->rootView->rmdir($userKeyPath);
  204. return true;
  205. } else {
  206. // remove the copied key if we know it's invalid
  207. $this->rootView->rmdir($systemKeyPath);
  208. return false;
  209. }
  210. }
  211. private function tryReadFile(File $node): bool {
  212. try {
  213. $fh = $node->fopen('r');
  214. // read a single chunk
  215. $data = fread($fh, 8192);
  216. if ($data === false) {
  217. return false;
  218. } else {
  219. return true;
  220. }
  221. } catch (\Exception $e) {
  222. return false;
  223. }
  224. }
  225. /**
  226. * Get the contents of a file without decrypting it
  227. *
  228. * @return resource
  229. */
  230. private function openWithoutDecryption(File $node, string $mode) {
  231. $storage = $node->getStorage();
  232. $internalPath = $node->getInternalPath();
  233. if ($storage->instanceOfStorage(Encryption::class)) {
  234. /** @var Encryption $storage */
  235. try {
  236. $storage->setEnabled(false);
  237. $handle = $storage->fopen($internalPath, 'r');
  238. $storage->setEnabled(true);
  239. } catch (\Exception $e) {
  240. $storage->setEnabled(true);
  241. throw $e;
  242. }
  243. } else {
  244. $handle = $storage->fopen($internalPath, $mode);
  245. }
  246. /** @var resource|false $handle */
  247. if ($handle === false) {
  248. throw new \Exception('Failed to open ' . $node->getPath());
  249. }
  250. return $handle;
  251. }
  252. /**
  253. * Check if the data stored for a file is encrypted, regardless of it's metadata
  254. */
  255. private function isDataEncrypted(File $node): bool {
  256. $handle = $this->openWithoutDecryption($node, 'r');
  257. $firstBlock = fread($handle, $this->encryptionUtil->getHeaderSize());
  258. fclose($handle);
  259. $header = $this->encryptionUtil->parseRawHeader($firstBlock);
  260. return isset($header['oc_encryption_module']);
  261. }
  262. /**
  263. * Attempt to find a key (stored for user) for a file (that needs a system key) even when it's not stored in the expected location
  264. */
  265. private function findUserKeyForSystemFile(IUser $user, File $node): ?string {
  266. $userKeyPath = $this->getUserBaseKeyPath($user);
  267. $possibleKeys = $this->findKeysByFileName($userKeyPath, $node->getName());
  268. foreach ($possibleKeys as $possibleKey) {
  269. if ($this->testSystemKey($user, $possibleKey, $node)) {
  270. return $possibleKey;
  271. }
  272. }
  273. return null;
  274. }
  275. /**
  276. * Attempt to find a key for a file even when it's not stored in the expected location
  277. *
  278. * @return \Generator<string>
  279. */
  280. private function findKeysByFileName(string $basePath, string $name) {
  281. if ($this->rootView->is_dir($basePath . '/' . $name . '/OC_DEFAULT_MODULE')) {
  282. yield $basePath . '/' . $name;
  283. } else {
  284. /** @var false|resource $dh */
  285. $dh = $this->rootView->opendir($basePath);
  286. if (!$dh) {
  287. throw new \Exception('Invalid base path ' . $basePath);
  288. }
  289. while ($child = readdir($dh)) {
  290. if ($child != '..' && $child != '.') {
  291. $childPath = $basePath . '/' . $child;
  292. // recurse if the child is not a key folder
  293. if ($this->rootView->is_dir($childPath) && !is_dir($childPath . '/OC_DEFAULT_MODULE')) {
  294. yield from $this->findKeysByFileName($childPath, $name);
  295. }
  296. }
  297. }
  298. }
  299. }
  300. /**
  301. * Test if the provided key is valid as a system key for the file
  302. */
  303. private function testSystemKey(IUser $user, string $key, File $node): bool {
  304. $systemKeyPath = $this->getSystemKeyPath($node);
  305. if ($this->rootView->file_exists($systemKeyPath)) {
  306. // already has a key, reject new key
  307. return false;
  308. }
  309. $this->rootView->copy($key, $systemKeyPath);
  310. $isValid = $this->tryReadFile($node);
  311. $this->rootView->rmdir($systemKeyPath);
  312. return $isValid;
  313. }
  314. /**
  315. * Decrypt a file with the specified system key and mark the key as not-encrypted
  316. */
  317. private function decryptWithSystemKey(File $node, string $key): void {
  318. $storage = $node->getStorage();
  319. $name = $node->getName();
  320. $node->move($node->getPath() . '.bak');
  321. $systemKeyPath = $this->getSystemKeyPath($node);
  322. $this->rootView->copy($key, $systemKeyPath);
  323. try {
  324. if (!$storage->instanceOfStorage(Encryption::class)) {
  325. $storage = $this->encryptionManager->forceWrapStorage($node->getMountPoint(), $storage);
  326. }
  327. /** @var false|resource $source */
  328. $source = $storage->fopen($node->getInternalPath(), 'r');
  329. if (!$source) {
  330. throw new \Exception('Failed to open ' . $node->getPath() . ' with ' . $key);
  331. }
  332. $decryptedNode = $node->getParent()->newFile($name);
  333. $target = $this->openWithoutDecryption($decryptedNode, 'w');
  334. stream_copy_to_stream($source, $target);
  335. fclose($target);
  336. fclose($source);
  337. $decryptedNode->getStorage()->getScanner()->scan($decryptedNode->getInternalPath());
  338. } catch (\Exception $e) {
  339. $this->rootView->rmdir($systemKeyPath);
  340. // remove the .bak
  341. $node->move(substr($node->getPath(), 0, -4));
  342. throw $e;
  343. }
  344. if ($this->isDataEncrypted($decryptedNode)) {
  345. throw new \Exception($node->getPath() . ' still encrypted after attempting to decrypt with ' . $key);
  346. }
  347. $this->markAsUnEncrypted($decryptedNode);
  348. $this->rootView->rmdir($systemKeyPath);
  349. }
  350. private function markAsUnEncrypted(Node $node): void {
  351. $node->getStorage()->getCache()->update($node->getId(), ['encrypted' => 0]);
  352. }
  353. }