/*
* Syncany, www.syncany.org
* Copyright (C) 2011-2015 Philipp C. Heckel <philipp.heckel@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.syncany.operations.init;
import java.io.File;
import java.io.FileInputStream;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.io.FileUtils;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import org.syncany.config.Config;
import org.syncany.config.DaemonConfigHelper;
import org.syncany.config.to.ConfigTO;
import org.syncany.config.to.MasterTO;
import org.syncany.config.to.RepoTO;
import org.syncany.crypto.CipherException;
import org.syncany.crypto.CipherUtil;
import org.syncany.crypto.SaltedSecretKey;
import org.syncany.operations.daemon.messages.ShowMessageExternalEvent;
import org.syncany.operations.init.ConnectOperationOptions.ConnectOptionsStrategy;
import org.syncany.operations.init.ConnectOperationResult.ConnectResultCode;
import org.syncany.plugins.Plugins;
import org.syncany.plugins.UserInteractionListener;
import org.syncany.plugins.transfer.StorageException;
import org.syncany.plugins.transfer.StorageTestResult;
import org.syncany.plugins.transfer.TransferManager;
import org.syncany.plugins.transfer.TransferPlugin;
import org.syncany.plugins.transfer.TransferSettings;
import org.syncany.plugins.transfer.files.MasterRemoteFile;
import org.syncany.plugins.transfer.files.RemoteFile;
import org.syncany.plugins.transfer.files.SyncanyRemoteFile;
/**
* The connect operation connects to an existing repository at a given remote storage
* location. Its responsibilities include:
*
* <ul>
* <li>Downloading of the repo file. If it is encrypted, also downloading the master
* file to allow decrypting the repo file.</li>
* <li>If encrypted: Querying the user for the password and creating the master key using
* the password and the master salt.</li>
* <li>If encrypted: Decrypting and verifying the repo file.</li>
* <li>Creating the local Syncany folder structure in the local directory (.syncany
* folder and the sub-structure) and copying the repo/master file to it.</li>
* </ul>
*
* @author Philipp C. Heckel <philipp.heckel@gmail.com>
*/
public class ConnectOperation extends AbstractInitOperation {
private static final Logger logger = Logger.getLogger(ConnectOperation.class.getSimpleName());
private static final int MAX_RETRY_PASSWORD_COUNT = 3;
private int retryPasswordCount = 0;
private ConnectOperationOptions options;
private ConnectOperationResult result;
private TransferPlugin plugin;
private TransferManager transferManager;
public ConnectOperation(ConnectOperationOptions options, UserInteractionListener listener) {
super(null, listener);
this.options = options;
this.result = new ConnectOperationResult();
}
@Override
public ConnectOperationResult execute() throws Exception {
logger.log(Level.INFO, "");
logger.log(Level.INFO, "Running 'Connect'");
logger.log(Level.INFO, "--------------------------------------------");
// Decrypt and init configTO
ConfigTO configTO = null;
try {
configTO = createConfigTO();
}
catch (CipherException e) {
logger.log(Level.FINE, "Could not create config", e);
return new ConnectOperationResult(ConnectResultCode.NOK_DECRYPT_ERROR);
}
// Init plugin and transfer manager
String pluginId = options.getConfigTO().getTransferSettings().getType();
plugin = Plugins.get(pluginId, TransferPlugin.class);
TransferSettings transferSettings = options.getConfigTO().getTransferSettings();
transferSettings.setUserInteractionListener(listener);
transferManager = plugin.createTransferManager(transferSettings, null); // "null" because no config exists yet!
// Test the repo
if (!performRepoTest(transferManager)) {
logger.log(Level.INFO, "- Connecting to the repo failed, repo already exists or cannot be created: " + result.getResultCode());
return result;
}
logger.log(Level.INFO, "- Connecting to the repo was successful; now downloading repo file ...");
// Create local .syncany directory
File tmpRepoFile = downloadFile(transferManager, new SyncanyRemoteFile());
if (CipherUtil.isEncrypted(tmpRepoFile)) {
logger.log(Level.INFO, "- Repo is ENCRYPTED. Decryption necessary.");
if (configTO.getMasterKey() == null) {
logger.log(Level.INFO, "- No master key present; Asking for password ...");
boolean retryPassword = true;
while (retryPassword) {
SaltedSecretKey possibleMasterKey = askPasswordAndCreateMasterKey();
logger.log(Level.INFO, "- Master key created. Now verifying by decrypting repo file...");
if (decryptAndVerifyRepoFile(tmpRepoFile, possibleMasterKey)) {
logger.log(Level.INFO, "- SUCCESS: Repo file decrypted successfully.");
configTO.setMasterKey(possibleMasterKey);
retryPassword = false;
}
else {
logger.log(Level.INFO, "- FAILURE: Repo file decryption failed. Asking for retry.");
retryPassword = askRetryPassword();
if (!retryPassword) {
logger.log(Level.INFO, "- No retry possible/desired. Returning NOK_DECRYPT_ERROR.");
return new ConnectOperationResult(ConnectResultCode.NOK_DECRYPT_ERROR);
}
}
}
}
else {
logger.log(Level.INFO, "- Master key present; Now verifying by decrypting repo file...");
if (!decryptAndVerifyRepoFile(tmpRepoFile, configTO.getMasterKey())) {
logger.log(Level.INFO, "- FAILURE: Repo file decryption failed. Returning NOK_DECRYPT_ERROR.");
return new ConnectOperationResult(ConnectResultCode.NOK_DECRYPT_ERROR);
}
}
}
else {
String repoFileStr = FileUtils.readFileToString(tmpRepoFile);
verifyRepoFile(repoFileStr);
}
// Success, now do the work!
File appDir = createAppDirs(options.getLocalDir());
// Write file 'config.xml'
File configFile = new File(appDir, Config.FILE_CONFIG);
configTO.save(configFile);
// Write file 'syncany'
File repoFile = new File(appDir, Config.FILE_REPO);
FileUtils.copyFile(tmpRepoFile, repoFile);
tmpRepoFile.delete();
// Write file 'master'
if (configTO.getMasterKey() != null) {
File masterFile = new File(appDir, Config.FILE_MASTER);
new MasterTO(configTO.getMasterKey().getSalt()).save(masterFile);
}
// Shutdown plugin
transferManager.disconnect();
// Add to daemon (if requested)
if (options.isDaemon()) {
try {
boolean addedToDaemonConfig = DaemonConfigHelper.addFolder(options.getLocalDir());
result.setAddedToDaemon(addedToDaemonConfig);
}
catch (Exception e) {
logger.log(Level.WARNING, "Cannot add folder to daemon config.", e);
result.setAddedToDaemon(false);
}
}
result.setResultCode(ConnectResultCode.OK);
return result;
}
private boolean decryptAndVerifyRepoFile(File tmpRepoFile, SaltedSecretKey masterKey) throws StorageException {
try {
String repoFileStr = decryptRepoFile(tmpRepoFile, masterKey);
verifyRepoFile(repoFileStr);
return true;
}
catch (CipherException e) {
logger.log(Level.FINE, "Could not decrypt the repository file", e);
return false;
}
}
private SaltedSecretKey askPasswordAndCreateMasterKey() throws CipherException, StorageException {
File tmpMasterFile = downloadFile(transferManager, new MasterRemoteFile());
MasterTO masterTO = readMasterFile(tmpMasterFile);
tmpMasterFile.delete();
String masterKeyPassword = getOrAskPassword();
byte[] masterKeySalt = masterTO.getSalt();
return createMasterKeyFromPassword(masterKeyPassword, masterKeySalt); // This takes looong!
}
private ConfigTO createConfigTO() throws StorageException, CipherException {
ConfigTO configTO = options.getConfigTO();
if (options.getStrategy() == ConnectOptionsStrategy.CONNECTION_TO) {
return configTO;
}
else if (options.getStrategy() == ConnectOptionsStrategy.CONNECTION_LINK) {
return createConfigTOFromLink(configTO, options.getConnectLink(), options.getPassword());
}
else {
throw new RuntimeException("Unhandled connect strategy: " + options.getStrategy());
}
}
private ConfigTO createConfigTOFromLink(ConfigTO configTO, String link, String masterPassword) throws StorageException, CipherException {
logger.log(Level.INFO, "Creating config TO from link: " + link + " ...");
ApplicationLink applicationLink = new ApplicationLink(link);
try {
if (applicationLink.isEncrypted()) {
// Non-interactive mode
if (masterPassword != null) {
logger.log(Level.INFO, " - Link is encrypted. Password available.");
SaltedSecretKey masterKey = createMasterKeyFromPassword(masterPassword, applicationLink.getMasterKeySalt());
TransferSettings transferSettings = applicationLink.createTransferSettings(masterKey);
configTO.setMasterKey(masterKey);
configTO.setTransferSettings(transferSettings);
}
else {
logger.log(Level.INFO, " - Link is encrypted. Asking for password.");
boolean retryPassword = true;
while (retryPassword) {
// Ask password
masterPassword = getOrAskPassword();
// Generate master key
SaltedSecretKey masterKey = createMasterKeyFromPassword(masterPassword, applicationLink.getMasterKeySalt());
// Decrypt config
try {
TransferSettings transferSettings = applicationLink.createTransferSettings(masterKey);
configTO.setMasterKey(masterKey);
configTO.setTransferSettings(transferSettings);
retryPassword = false;
}
catch (CipherException e) {
retryPassword = askRetryPassword();
}
}
}
if (configTO.getTransferSettings() == null) {
throw new CipherException("Unable to decrypt link.");
}
}
else {
logger.log(Level.INFO, " - Link is NOT encrypted. No password needed.");
TransferSettings transferSettings = applicationLink.createTransferSettings();
configTO.setTransferSettings(transferSettings);
}
}
catch (Exception e) {
throw new StorageException("Unable to extract connection settings: " + e.getMessage(), e);
}
return configTO;
}
private boolean performRepoTest(TransferManager transferManager) {
StorageTestResult testResult = transferManager.test(false);
logger.log(Level.INFO, "Storage test result ist " + testResult);
if (testResult.isRepoFileExists()) {
logger.log(Level.INFO, "--> OKAY: Repo file exists. We're good to go!");
return true;
}
else {
logger.log(Level.INFO, "--> NOT OKAY: Invalid target/repo state. Operation cannot be continued.");
result.setResultCode(ConnectResultCode.NOK_TEST_FAILED);
result.setTestResult(testResult);
return false;
}
}
private String getOrAskPassword() {
if (options.getPassword() == null) {
if (listener == null) {
throw new RuntimeException("Repository file is encrypted, but password cannot be queried (no listener).");
}
return listener.onUserPassword(null, "Password: ");
}
else {
return options.getPassword();
}
}
private boolean askRetryPassword() {
retryPasswordCount++;
if (retryPasswordCount < MAX_RETRY_PASSWORD_COUNT) {
int triesLeft = MAX_RETRY_PASSWORD_COUNT - retryPasswordCount;
String triesLeftStr = triesLeft != 1 ? triesLeft + " tries left." : "Last chance.";
eventBus.post(new ShowMessageExternalEvent("ERROR: Invalid password or corrupt ciphertext. " + triesLeftStr));
return true;
}
else {
return false;
}
}
protected File downloadFile(TransferManager transferManager, RemoteFile remoteFile) throws StorageException {
try {
File tmpRepoFile = File.createTempFile("syncanyfile", "tmp");
transferManager.download(remoteFile, tmpRepoFile);
return tmpRepoFile;
}
catch (Exception e) {
throw new StorageException("Unable to connect to repository.", e);
}
}
private SaltedSecretKey createMasterKeyFromPassword(String masterPassword, byte[] masterKeySalt) throws CipherException {
fireNotifyCreateMaster();
SaltedSecretKey masterKey = CipherUtil.createMasterKey(masterPassword, masterKeySalt);
return masterKey;
}
private String decryptRepoFile(File file, SaltedSecretKey masterKey) throws CipherException {
try {
logger.log(Level.INFO, "Decrypting repo file ...");
FileInputStream encryptedRepoConfig = new FileInputStream(file);
String repoFileStr = new String(CipherUtil.decrypt(encryptedRepoConfig, masterKey));
logger.log(Level.INFO, "Repo file decrypted:");
logger.log(Level.INFO, repoFileStr);
return repoFileStr;
}
catch (Exception e) {
logger.log(Level.INFO, "Invalid password given, or repo file corrupt.", e);
throw new CipherException("Invalid password given, or repo file corrupt.", e);
}
}
private void verifyRepoFile(String repoFileStr) throws StorageException {
try {
Serializer serializer = new Persister();
serializer.read(RepoTO.class, repoFileStr);
}
catch (Exception e) {
throw new StorageException("Repo file corrupt.", e);
}
}
private MasterTO readMasterFile(File tmpMasterFile) throws StorageException {
try {
Serializer serializer = new Persister();
return serializer.read(MasterTO.class, tmpMasterFile);
}
catch (Exception e) {
throw new StorageException("Master file corrupt.", e);
}
}
}