Skip to content

Commit

Permalink
Merge pull request #39935 from owncloud/sftp_key_handling
Browse files Browse the repository at this point in the history
Sftp key handling
  • Loading branch information
jvillafanez authored Apr 27, 2022
2 parents 1e3adb2 + 80bff00 commit 8cffbdd
Show file tree
Hide file tree
Showing 12 changed files with 364 additions and 61 deletions.
106 changes: 106 additions & 0 deletions apps/files_external/appinfo/Migrations/Version20220329110116.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php
namespace OCA\files_external\Migrations;

use OC\NeedsUpdateException;
use OCA\Files_External\Lib\Backend\SFTP;
use OCA\Files_External\Lib\Auth\PublicKey\RSA;
use OCA\Files_External\Lib\RSAStore;
use OCP\Migration\ISimpleMigration;
use OCP\Migration\IOutput;
use OCP\Files\External\Service\IGlobalStoragesService;
use OCP\Files\External\IStorageConfig;
use OCP\ILogger;
use OCP\IConfig;
use phpseclib3\Crypt\RSA as RSACrypt;
use phpseclib3\Crypt\RSA\PrivateKey;

class Version20220329110116 implements ISimpleMigration {
/** @var IGlobalStoragesService */
private $storageService;
/** @var ILogger */
private $logger;
/** @var IConfig */
private $config;

public function __construct(IGlobalStoragesService $storageService, ILogger $logger, IConfig $config) {
$this->storageService = $storageService;
$this->logger = $logger;
$this->config = $config;
}
/**
* @param IOutput $out
*/
public function run(IOutput $out) {
if (!$this->config->getSystemValue('installed', false)) {
// Skip the migration for new installations -> nothing to migrate
return;
}

$this->loadFSApps();
\OC_Util::setupFS(); // this should load additional backends and auth mechanisms
$storageConfigs = $this->storageService->getStorageForAllUsers();
$pass = $this->config->getSystemValue('secret', '');

$rsaStore = RSAStore::getGlobalInstance();
foreach ($storageConfigs as $storageConfig) {
if ($storageConfig->getBackend() instanceof SFTP && $storageConfig->getAuthMechanism() instanceof RSA) {
$encPubKey = $storageConfig->getBackendOption('public_key');
$encPrivKey = $storageConfig->getBackendOption('private_key');

$pubKey = \base64_decode($encPubKey, true);
$privKey = \base64_decode($encPrivKey, true);

$configId = $storageConfig->getId();
if ($pubKey === false || $privKey === false) {
$out->warning("Storage configuration with id = {$configId}: Cannot decode either public or private key, skipping");
continue;
}

try {
$rsaKey = RSACrypt::load($privKey, $pass)->withHash('sha1');
} catch (\phpseclib3\Exception\NoKeyLoadedException $e) {
$out->warning("Storage configuration with id = {$configId}: Cannot load private key, skipping");
continue;
}

$targetUserId = '';
if ($storageConfig->getType() === IStorageConfig::MOUNT_TYPE_PERSONAl) {
$applicableUsers = $storageConfig->getApplicableUsers();
$targetUserId = $applicableUsers[0]; // it must have one user.
}

$token = $rsaStore->storeData($rsaKey, $targetUserId);
$storageConfig->setBackendOption('public_key', $pubKey);
$storageConfig->setBackendOption('private_key', $token);

$this->storageService->updateStorage($storageConfig);
$out->info("Storage configuration with id = {$configId}: keys migrated successfully");
}
}
}

/**
* Load the FS apps. This is required because the FS apps might not be loaded during the
* migration.
*/
private function loadFSApps() {
$enabledApps = \OC_App::getEnabledApps();
foreach ($enabledApps as $enabledApp) {
if ($enabledApp !== 'files_external' && \OC_App::isType($enabledApp, ['filesystem'])) {
try {
\OC_App::loadApp($enabledApp);
} catch (NeedsUpdateException $ex) {
if (\OC_App::updateApp($enabledApp)) {
// update successful.
// We can load the app without checking if the should upgrade or not.
\OC_App::loadApp($enabledApp, false);
} else {
$this->logger->error("Error during files_external migration. $enabledApp couldn't be loaded nor updated.", ['app' => 'files_external']);
$this->logger->logException($ex, ['app' => 'files_external']);
$this->logger->error("Mount points using $enabledApp might not be migrated properly. You might need to re-enter the passwords for those mount points", ['app' => 'files_external']);
}
}
}
}
}
}
2 changes: 1 addition & 1 deletion apps/files_external/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<admin>admin-external-storage</admin>
</documentation>
<rememberlogin>false</rememberlogin>
<version>0.8.0</version>
<version>0.9.0</version>
<types>
<filesystem/>
</types>
Expand Down
18 changes: 15 additions & 3 deletions apps/files_external/js/public_key.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ $(document).ready(function() {
OCA.External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme, onCompletion) {
if (scheme === 'publickey') {
var config = $tr.find('.configuration');
if ($(config).find('[name="public_key_generate"]').length === 0) {
if (config.find('[name="public_key_generate"]').length === 0) {
setupTableRow($tr, config);
onCompletion.then(function() {
// If there's no private key, build one
if (0 === $(config).find('[data-parameter="private_key"]').val().length) {
var $privateKeyElem = config.find('[data-parameter="private_key"]');
if ($privateKeyElem.length !== 0 && $privateKeyElem.val().length === 0) {
// the private_key element might be removed in some scenarios such as
// global mount showing in the personal mounts
generateKeys($tr);
}
});
Expand All @@ -33,7 +36,16 @@ $(document).ready(function() {
function generateKeys(tr) {
var config = $(tr).find('.configuration');

$.post(OC.filePath('files_external', 'ajax', 'public_key.php'), {}, function(result) {
var $table = $(tr).parentsUntil('#files_external', '#externalStorage');
var isAdmin = $table.data('admin');

var postData = {};
if (!isAdmin) {
postData = {
'userId': OC.currentUser
};
}
$.post(OC.filePath('files_external', 'ajax', 'public_key.php'), postData, function(result) {
if (result && result.status === 'success') {
$(config).find('[data-parameter="public_key"]').val(result.data.public_key).keyup();
$(config).find('[data-parameter="private_key"]').val(result.data.private_key);
Expand Down
10 changes: 5 additions & 5 deletions apps/files_external/lib/Controller/AjaxController.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public function __construct($appName, IRequest $request, RSA $rsaMechanism) {
$this->rsaMechanism = $rsaMechanism;
}

private function generateSshKeys() {
$key = $this->rsaMechanism->createKey();
private function generateSshKeys($userId) {
$key = $this->rsaMechanism->createKey($userId);
// Replace the placeholder label with a more meaningful one
$key['publickey'] = \str_replace('phpseclib-generated-key', \gethostname(), $key['publickey']);

Expand All @@ -49,11 +49,11 @@ private function generateSshKeys() {

/**
* Generates an SSH public/private key pair.
*
* @param string $userId
* @NoAdminRequired
*/
public function getSshKeys() {
$key = $this->generateSshKeys();
public function getSshKeys($userId = '') {
$key = $this->generateSshKeys($userId);
return new JSONResponse(
['data' => [
'private_key' => $key['privatekey'],
Expand Down
42 changes: 14 additions & 28 deletions apps/files_external/lib/Lib/Auth/PublicKey/RSA.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@

use OCP\Files\External\Auth\AuthMechanism;
use OCP\Files\External\DefinitionParameter;
use OCP\Files\External\IStorageConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IUser;
use OCA\Files_External\Lib\RSAStore;
use phpseclib3\Crypt\RSA as RSACrypt;

/**
Expand All @@ -36,12 +34,7 @@
class RSA extends AuthMechanism {
public const CREATE_KEY_BITS = 1024;

/** @var IConfig */
private $config;

public function __construct(IL10N $l, IConfig $config) {
$this->config = $config;

public function __construct(IL10N $l) {
$this
->setIdentifier('publickey::rsa')
->setScheme(self::SCHEME_PUBLICKEY)
Expand All @@ -56,33 +49,26 @@ public function __construct(IL10N $l, IConfig $config) {
;
}

public function manipulateStorageConfig(IStorageConfig &$storage, IUser $user = null) {
$privateKey = $storage->getBackendOption('private_key');
$password = $this->config->getSystemValue('secret', '');

try {
$rsaKey = RSACrypt::load($privateKey, $password)->withHash('sha1');
} catch (\phpseclib3\Exception\NoKeyLoadedException $e) {
throw new \RuntimeException('unable to load private key');
}

$storage->setBackendOption('private_key', \base64_encode($privateKey));
$storage->setBackendOption('public_key_auth', $rsaKey);
}

/**
* Generate a keypair
*
* Generate a keypair.
* The public key will be returned without any modification.
* The private key will be stored using the RSAStore, and a token will
* be returned instead. The token can be used to retrieve the private key
* from the RSAStore later.
* @params string $userId the userId holding the keys, or empty string if the keys are global
* (for system-wide mount points, for example)
* @return array ['privatekey' => $privateKey, 'publickey' => $publicKey]
*/
public function createKey() {
public function createKey($userId = '') {
/** @var RSACrypt\PrivateKey $rsaKey */
$rsaKey = RSACrypt::createKey(self::CREATE_KEY_BITS)
->withHash('sha1')
->withMGFHash('sha1');
$password = $this->config->getSystemValue('secret', '');

$rsaStore = RSAStore::getGlobalInstance();
$token = $rsaStore->storeData($rsaKey, $userId);
return [
'privatekey' => $rsaKey->withPassword($password)->toString('PKCS1'),
'privatekey' => $token,
'publickey' => $rsaKey->getPublicKey()->toString('OpenSSH')
];
}
Expand Down
112 changes: 112 additions & 0 deletions apps/files_external/lib/Lib/RSAStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
/**
* @author Juan Pablo Villafáñez Ramos <[email protected]>
*
* @copyright Copyright (c) 2022, ownCloud GmbH
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/

namespace OCA\Files_External\Lib;

use OCP\Security\ICredentialsManager;
use OCP\IConfig;
use phpseclib3\Crypt\RSA;
use phpseclib3\Crypt\RSA\PrivateKey;

/**
* Store and retrieve phpseclib3 RSA private keys
*/
class RSAStore {
private static $rsaStore = null;

/** @var ICredentialsManager */
private $credentialsManager;
/** @var IConfig */
private $config;

/**
* Get the global instance of the RSAStore. If no one is set yet, a new
* one will be created using real server components.
* @return RSAStore
*/
public static function getGlobalInstance(): RSAStore {
if (self::$rsaStore === null) {
self::$rsaStore = new RSAStore(
\OC::$server->getCredentialsManager(),
\OC::$server->getConfig()
);
}
return self::$rsaStore;
}

/**
* Set a new RSAStore instance as a global instance overwriting whatever
* instance was there.
* This shouldn't be needed outside of unit tests
* @param RSAStore|null The RSAStore to be set as global instance, or null
* to destroy the global instance (destroying the global instance will allow
* getting the default one again)
*/
public static function setGlobalInstance(?RSAStore $rsaStore) {
self::$rsaStore = $rsaStore;
}

/**
* @param ICredentialsManager $credentialsManager
* @param IConfig $config
*/
public function __construct(ICredentialsManager $credentialsManager, IConfig $config) {
$this->credentialsManager = $credentialsManager;
$this->config = $config;
}

/**
* Store the $rsaKey inside the $userId's space. A token will be returned
* in order to retrieve the stored key
* @param PrivateKey $rsaKey the private key to be stored
* @param string $userId the user under which the token will be stored
* @return string an opaque token to be used to retrieve the stored key later
*/
public function storeData(PrivateKey $rsaKey, string $userId): string {
$password = $this->config->getSystemValue('secret', '');
$privatekey = $rsaKey->withPassword($password)->toString('PKCS1');

$keyId = \uniqid('rsaid:', true);

$this->credentialsManager->store($userId, $keyId, $privatekey);

$keyData = [
'rsaId' => $keyId,
'userId' => $userId,
];
return \base64_encode(\json_encode($keyData));
}

/**
* Retrieve a previously stored private key using the token that was returned
* when the key was stored
* @param string $token the token returned previously by the "storeData"
* method when the key was stored.
* @return PrivateKey the stored private key
*/
public function retrieveData(string $token): PrivateKey {
$keyData = \json_decode(\base64_decode($token), true);
$privateKey = $this->credentialsManager->retrieve($keyData['userId'], $keyData['rsaId']);
$password = $this->config->getSystemValue('secret', '');

return RSA::load($privateKey, $password)->withHash('sha1');
}
}
8 changes: 6 additions & 2 deletions apps/files_external/lib/Lib/Storage/SFTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use Icewind\Streams\IteratorDirectory;
use Icewind\Streams\RetryWrapper;
use phpseclib3\Net\SFTP\Stream;
use OCA\Files_External\Lib\RSAStore;

/**
* Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to
Expand Down Expand Up @@ -92,8 +93,11 @@ public function __construct($params) {
}
$this->user = $params['user'];

if (isset($params['public_key_auth'])) {
$this->auth = $params['public_key_auth'];
if (isset($params['private_key'])) {
// The $params['private_key'] contains the token to get the private key, not the key.
// The actual private key is fetched from the RSAStore using that token.
$rsaStore = RSAStore::getGlobalInstance();
$this->auth = $rsaStore->retrieveData($params['private_key']);
} elseif (isset($params['password'])) {
$this->auth = $params['password'];
} else {
Expand Down
Loading

0 comments on commit 8cffbdd

Please sign in to comment.