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);
+ }
}