diff --git a/apps/files_external/lib/Command/MigrateOc.php b/apps/files_external/lib/Command/MigrateOc.php index 7f6f36bd10ce8..40aba9bd4a8da 100644 --- a/apps/files_external/lib/Command/MigrateOc.php +++ b/apps/files_external/lib/Command/MigrateOc.php @@ -25,31 +25,45 @@ 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 + 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'); + ->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"); @@ -76,18 +90,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int $storage = new SMB($config); $storageId = $storage->getId(); - if (!$this->setStorageId($wndStorageId, $storageId)) { - $output->writeln("No WMD storage with id $wndStorageId found"); - return 1; + if (!$dryRun) { + if (!$this->setStorageId($wndStorageId, $storageId)) { + $output->writeln("No WMD storage with id $wndStorageId found"); + return 1; + } } } - if (count($configs)) { + if (count($configs) && !$dryRun) { $this->migrateWndBackend(); $output->writeln("Successfully migrated"); } + $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)); + } + } + } + return 0; } @@ -121,6 +149,32 @@ private function getWndConfigs(): array { 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 setStorageConfig(int $id, string $value) { + $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(); + } + private function setStorageId(string $old, string $new): bool { $query = $this->connection->getQueryBuilder(); $query->update('storages') @@ -128,4 +182,59 @@ private function setStorageId(string $old, string $new): bool { ->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 + */ + 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); + /** @psalm-suppress InternalMethod */ + $cipher->setPassword($password); + + $ciphertext = \hex2bin($parts[1]); + $iv = \hex2bin($parts[2]); + $hmac = \hex2bin($parts[3]); + + /** @psalm-suppress InternalMethod */ + $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.'); + } + + /** @psalm-suppress InternalMethod */ + return $cipher->decrypt($ciphertext); + } else { + return $this->crypto->decrypt($encoded); + } + } + + private function calculateHMAC($message, $password) { + // 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 + * + * @param $password + * @return string + */ + private function encryptPassword($password) { + return $this->crypto->encrypt($password); + } }