diff --git a/apps/files_external/appinfo/info.xml b/apps/files_external/appinfo/info.xml index b99a20b0c3fee..5ba8baaa61077 100644 --- a/apps/files_external/appinfo/info.xml +++ b/apps/files_external/appinfo/info.xml @@ -47,6 +47,7 @@ External storage can be configured using the GUI or at the command line. This se OCA\Files_External\Command\Backends OCA\Files_External\Command\Verify OCA\Files_External\Command\Notify + OCA\Files_External\Command\MigrateOc diff --git a/apps/files_external/composer/composer/autoload_classmap.php b/apps/files_external/composer/composer/autoload_classmap.php index b10fc32e10059..569a86fae52c5 100644 --- a/apps/files_external/composer/composer/autoload_classmap.php +++ b/apps/files_external/composer/composer/autoload_classmap.php @@ -17,6 +17,7 @@ 'OCA\\Files_External\\Command\\Export' => $baseDir . '/../lib/Command/Export.php', 'OCA\\Files_External\\Command\\Import' => $baseDir . '/../lib/Command/Import.php', 'OCA\\Files_External\\Command\\ListCommand' => $baseDir . '/../lib/Command/ListCommand.php', + 'OCA\\Files_External\\Command\\MigrateOc' => $baseDir . '/../lib/Command/MigrateOc.php', 'OCA\\Files_External\\Command\\Notify' => $baseDir . '/../lib/Command/Notify.php', 'OCA\\Files_External\\Command\\Option' => $baseDir . '/../lib/Command/Option.php', 'OCA\\Files_External\\Command\\Verify' => $baseDir . '/../lib/Command/Verify.php', diff --git a/apps/files_external/composer/composer/autoload_static.php b/apps/files_external/composer/composer/autoload_static.php index c5406fe3cf861..c40bff471d36c 100644 --- a/apps/files_external/composer/composer/autoload_static.php +++ b/apps/files_external/composer/composer/autoload_static.php @@ -32,6 +32,7 @@ class ComposerStaticInitFiles_External 'OCA\\Files_External\\Command\\Export' => __DIR__ . '/..' . '/../lib/Command/Export.php', 'OCA\\Files_External\\Command\\Import' => __DIR__ . '/..' . '/../lib/Command/Import.php', 'OCA\\Files_External\\Command\\ListCommand' => __DIR__ . '/..' . '/../lib/Command/ListCommand.php', + 'OCA\\Files_External\\Command\\MigrateOc' => __DIR__ . '/..' . '/../lib/Command/MigrateOc.php', 'OCA\\Files_External\\Command\\Notify' => __DIR__ . '/..' . '/../lib/Command/Notify.php', 'OCA\\Files_External\\Command\\Option' => __DIR__ . '/..' . '/../lib/Command/Option.php', 'OCA\\Files_External\\Command\\Verify' => __DIR__ . '/..' . '/../lib/Command/Verify.php', diff --git a/apps/files_external/lib/Command/MigrateOc.php b/apps/files_external/lib/Command/MigrateOc.php new file mode 100644 index 0000000000000..5b9a3cc12986d --- /dev/null +++ b/apps/files_external/lib/Command/MigrateOc.php @@ -0,0 +1,277 @@ + + * + * @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\Files_External\Command; + +use OC\Core\Command\Base; +use OCA\Files_External\Lib\Storage\SMB; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Security\ICrypto; +use phpseclib\Crypt\AES; +use phpseclib\Crypt\Hash; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class MigrateOc extends Base { + private IDBConnection $connection; + private IConfig $config; + private ICrypto $crypto; + + public const ALL = -1; + + public function __construct( + IDBConnection $connection, + IConfig $config, + ICrypto $crypto, + ) { + parent::__construct(); + $this->connection = $connection; + $this->config = $config; + $this->crypto = $crypto; + } + + protected function configure(): void { + $this + ->setName('files_external:migrate-oc') + ->setDescription('Migrate external storages when moving from ownCloud') + ->addOption("dry-run", null, InputOption::VALUE_NONE, "Don't save any modifications, only try the migration"); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $configs = $this->getWndConfigs(); + $dryRun = $input->getOption('dry-run'); + + $output->writeln("Found " . count($configs) . " wnd storages"); + + foreach ($configs as $config) { + if (!isset($config['user'])) { + $output->writeln("Only basic username password authentication is currently supported"); + return 1; + } + + if (isset($config['root']) && $config['root'] !== '' && $config['root'] !== '/') { + $root = '/' . trim($config['root'], '/') . '/'; + } else { + $root = '/'; + } + + if (isset($config['domain']) && $config['domain'] !== "" + && \strpos($config['user'], "\\") === false && \strpos($config['user'], "/") === false + ) { + $usernameWithDomain = $config['domain'] . "\\" . $config['user']; + } else { + $usernameWithDomain = $config['user']; + } + $wndStorageId = "wnd::{$usernameWithDomain}@{$config['host']}/{$config['share']}/{$root}"; + + $storage = new SMB($config); + $storageId = $storage->getId(); + if (!$dryRun) { + if (!$this->setStorageId($wndStorageId, $storageId)) { + $output->writeln("No WMD storage with id $wndStorageId found"); + return 1; + } + } + } + + if (count($configs) && !$dryRun) { + $this->migrateWndBackend(); + + $output->writeln("Successfully migrated"); + } + + $this->migrateV2StoragePasswords($dryRun, $output); + $this->migrateUserCredentials($dryRun, $output); + + return 0; + } + + private function migrateWndBackend(): int { + $query = $this->connection->getQueryBuilder(); + $query->update('external_mounts') + ->set('storage_backend', $query->createNamedParameter('smb')) + ->where($query->expr()->eq('storage_backend', $query->createNamedParameter('windows_network_drive'))); + return $query->executeStatement(); + } + + /** + * @return array> + */ + private function getWndConfigs(): array { + $query = $this->connection->getQueryBuilder(); + $query->select('c.mount_id', 'key', 'value') + ->from('external_config', 'c') + ->innerJoin('c', 'external_mounts', 'm', $query->expr()->eq('c.mount_id', 'm.mount_id')) + ->where($query->expr()->eq('storage_backend', $query->createNamedParameter('windows_network_drive'))); + + $rows = $query->executeQuery()->fetchAll(); + $configs = []; + foreach ($rows as $row) { + $mountId = (int)$row['mount_id']; + if (!isset($configs[$mountId])) { + $configs[$mountId] = []; + } + $configs[$mountId][$row['key']] = $row['value']; + } + return $configs; + } + + /** + * @return array + */ + private function getV2StoragePasswords(): array { + $query = $this->connection->getQueryBuilder(); + $query->select('config_id', 'value') + ->from('external_config') + ->where($query->expr()->eq('key', $query->createNamedParameter('password'))) + ->andWhere($query->expr()->like('value', $query->createNamedParameter('v2|%'))); + + $rows = $query->executeQuery()->fetchAll(); + $configs = []; + foreach ($rows as $row) { + $configs[(int)$row['config_id']] = $row['value']; + } + return $configs; + } + + private function migrateV2StoragePasswords(bool $dryRun, OutputInterface $output): void { + $passwords = $this->getV2StoragePasswords(); + + if (count($passwords)) { + $output->writeln("Found " . count($passwords) . " stored passwords that need re-encoding"); + foreach ($passwords as $id => $password) { + $decoded = $this->decodePassword($password); + if (!$dryRun) { + $this->setStorageConfig($id, $this->encryptPassword($decoded)); + } + } + } + } + + private function setStorageConfig(int $id, string $value): void { + $query = $this->connection->getQueryBuilder(); + $query->update('external_config') + ->set('value', $query->createNamedParameter($value)) + ->where($query->expr()->eq('config_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + /** + * @return array> + */ + private function getUserCredentials(): array { + $query = $this->connection->getQueryBuilder(); + $query->select('user', 'identifier', 'credentials') + ->from('credentials'); + + return $query->executeQuery()->fetchAll(); + } + + private function migrateUserCredentials(bool $dryRun, OutputInterface $output): void { + $passwords = $this->getUserCredentials(); + + if (count($passwords)) { + $output->writeln("Found " . count($passwords) . " stored user credentials that need re-encoding"); + foreach ($passwords as $passwordRow) { + $decoded = $this->decodePassword($passwordRow["credentials"]); + if (!$dryRun) { + $this->setStorageCredentials($passwordRow, $this->encryptPassword($decoded)); + } + } + } + } + + private function setStorageCredentials(array $row, string $encryptedPassword): void { + $query = $this->connection->getQueryBuilder(); + + $query->insert('storages_credentials') + ->values([ + 'user' => $query->createNamedParameter($row['user']), + 'identifier' => $query->createNamedParameter($row['identifier']), + 'credentials' => $query->createNamedParameter($encryptedPassword), + ]) + ->executeStatement(); + } + + private function setStorageId(string $old, string $new): bool { + $query = $this->connection->getQueryBuilder(); + $query->update('storages') + ->set('id', $query->createNamedParameter($new)) + ->where($query->expr()->eq('id', $query->createNamedParameter($old))); + return $query->executeStatement() > 0; + } + + /** + * Decrypt a password from the ownCloud scheme + * + * @param string $encoded + * @return string + * @throws \Exception + * @psalm-suppress InternalMethod + */ + private function decodePassword(string $encoded): string { + if (str_starts_with($encoded, 'v2')) { + // see https://github.com/owncloud/core/blob/89c5c364b8fa39b011c89fbfad779b547a333a92/lib/private/Security/Crypto.php#L129 + $parts = \explode('|', $encoded); + $cipher = new AES(); + $password = $this->config->getSystemValue('secret'); + $derived = \hash_hkdf('sha512', $password, 0); + [$password, $hmacKey] = \str_split($derived, 32); + $cipher->setPassword($password); + + $ciphertext = \hex2bin($parts[1]); + $iv = \hex2bin($parts[2]); + $hmac = \hex2bin($parts[3]); + + $cipher->setIV($iv); + + if (!\hash_equals($this->calculateHMAC($parts[1] . $iv, $hmacKey), $hmac)) { + throw new \Exception('HMAC does not match while attempting to re-encode password.'); + } + + return $cipher->decrypt($ciphertext); + } else { + return $this->crypto->decrypt($encoded); + } + } + + private function calculateHMAC(string $message, string $password): string { + // Append an "a" behind the password and hash it to prevent reusing the same password as for encryption + $password = \hash('sha512', $password . 'a'); + + $hash = new Hash('sha512'); + $hash->setKey($password); + return $hash->hash($message); + } + + /** + * Encrypt a password in the Nextcloud scheme + */ + private function encryptPassword(string $password): string { + return $this->crypto->encrypt($password); + } +}