FixKeyLocation.php 13 KB

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