001/*
002 * Syncany, www.syncany.org
003 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation, either version 3 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018package org.syncany.operations.init;
019
020import java.io.File;
021import java.io.FileInputStream;
022import java.util.logging.Level;
023import java.util.logging.Logger;
024
025import org.apache.commons.io.FileUtils;
026import org.simpleframework.xml.Serializer;
027import org.simpleframework.xml.core.Persister;
028import org.syncany.config.Config;
029import org.syncany.config.DaemonConfigHelper;
030import org.syncany.config.to.ConfigTO;
031import org.syncany.config.to.MasterTO;
032import org.syncany.config.to.RepoTO;
033import org.syncany.crypto.CipherException;
034import org.syncany.crypto.CipherUtil;
035import org.syncany.crypto.SaltedSecretKey;
036import org.syncany.operations.daemon.messages.ShowMessageExternalEvent;
037import org.syncany.operations.init.ConnectOperationOptions.ConnectOptionsStrategy;
038import org.syncany.operations.init.ConnectOperationResult.ConnectResultCode;
039import org.syncany.plugins.UserInteractionListener;
040import org.syncany.plugins.transfer.StorageException;
041import org.syncany.plugins.transfer.StorageTestResult;
042import org.syncany.plugins.transfer.TransferManager;
043import org.syncany.plugins.transfer.TransferSettings;
044import org.syncany.plugins.transfer.files.MasterRemoteFile;
045import org.syncany.plugins.transfer.files.RemoteFile;
046import org.syncany.plugins.transfer.files.SyncanyRemoteFile;
047
048/**
049 * The connect operation connects to an existing repository at a given remote storage
050 * location. Its responsibilities include:
051 *
052 * <ul>
053 *   <li>Downloading of the repo file. If it is encrypted, also downloading the master
054 *       file to allow decrypting the repo file.</li>
055 *   <li>If encrypted: Querying the user for the password and creating the master key using
056 *       the password and the master salt.</li>
057 *   <li>If encrypted: Decrypting and verifying the repo file.</li>
058 *   <li>Creating the local Syncany folder structure in the local directory (.syncany
059 *       folder and the sub-structure) and copying the repo/master file to it.</li>
060 * </ul>
061 *
062 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
063 */
064public class ConnectOperation extends AbstractInitOperation {
065        private static final Logger logger = Logger.getLogger(ConnectOperation.class.getSimpleName());
066
067        private static final int MAX_RETRY_PASSWORD_COUNT = 3;
068        private int retryPasswordCount = 0;
069
070        private final ConnectOperationOptions options;
071        private final ConnectOperationResult result;
072
073        private TransferManager transferManager;
074
075        public ConnectOperation(ConnectOperationOptions options, UserInteractionListener listener) {
076                super(null, listener);
077
078                this.options = options;
079                this.result = new ConnectOperationResult();
080        }
081
082        @Override
083        public ConnectOperationResult execute() throws Exception {
084                logger.log(Level.INFO, "");
085                logger.log(Level.INFO, "Running 'Connect'");
086                logger.log(Level.INFO, "--------------------------------------------");
087
088                // Decrypt and init configTO
089                ConfigTO configTO = null;
090
091                try {
092                        configTO = createConfigTO();
093                }
094                catch (CipherException e) {
095                        logger.log(Level.FINE, "Could not create config", e);
096                        return new ConnectOperationResult(ConnectResultCode.NOK_DECRYPT_ERROR);
097                }
098
099                // Init plugin and transfer manager
100                transferManager = createTransferManagerFromNullConfig(options.getConfigTO());
101
102                // Test the repo
103                if (!performRepoTest(transferManager)) {
104                        logger.log(Level.INFO, "- Connecting to the repo failed, repo already exists or cannot be created: " + result.getResultCode());
105                        return result;
106                }
107
108                logger.log(Level.INFO, "- Connecting to the repo was successful; now downloading repo file ...");
109
110                // Create local .syncany directory
111                File tmpRepoFile = downloadFile(transferManager, new SyncanyRemoteFile());
112
113                if (CipherUtil.isEncrypted(tmpRepoFile)) {
114                        logger.log(Level.INFO, "- Repo is ENCRYPTED. Decryption necessary.");
115
116                        if (configTO.getMasterKey() == null) {
117                                logger.log(Level.INFO, "- No master key present; Asking for password ...");
118
119                                boolean retryPassword = true;
120
121                                while (retryPassword) {
122                                        SaltedSecretKey possibleMasterKey = askPasswordAndCreateMasterKey();
123                                        logger.log(Level.INFO, "- Master key created. Now verifying by decrypting repo file...");
124
125                                        if (decryptAndVerifyRepoFile(tmpRepoFile, possibleMasterKey)) {
126                                                logger.log(Level.INFO, "- SUCCESS: Repo file decrypted successfully.");
127
128                                                configTO.setMasterKey(possibleMasterKey);
129                                                retryPassword = false;
130                                        }
131                                        else {
132                                                logger.log(Level.INFO, "- FAILURE: Repo file decryption failed. Asking for retry.");
133                                                retryPassword = askRetryPassword();
134
135                                                if (!retryPassword) {
136                                                        logger.log(Level.INFO, "- No retry possible/desired. Returning NOK_DECRYPT_ERROR.");
137                                                        return new ConnectOperationResult(ConnectResultCode.NOK_DECRYPT_ERROR);
138                                                }
139                                        }
140                                }
141                        }
142                        else {
143                                logger.log(Level.INFO, "- Master key present; Now verifying by decrypting repo file...");
144
145                                if (!decryptAndVerifyRepoFile(tmpRepoFile, configTO.getMasterKey())) {
146                                        logger.log(Level.INFO, "- FAILURE: Repo file decryption failed. Returning NOK_DECRYPT_ERROR.");
147                                        return new ConnectOperationResult(ConnectResultCode.NOK_DECRYPT_ERROR);
148                                }
149                        }
150                }
151                else {
152                        String repoFileStr = FileUtils.readFileToString(tmpRepoFile);
153                        verifyRepoFile(repoFileStr);
154                }
155
156                // Success, now do the work!
157                File appDir = createAppDirs(options.getLocalDir());
158
159                // Write file 'config.xml'
160                File configFile = new File(appDir, Config.FILE_CONFIG);
161                configTO.save(configFile);
162
163                // Write file 'syncany'
164                File repoFile = new File(appDir, Config.FILE_REPO);
165                FileUtils.copyFile(tmpRepoFile, repoFile);
166                tmpRepoFile.delete();
167
168                // Write file 'master'
169                if (configTO.getMasterKey() != null) {
170                        File masterFile = new File(appDir, Config.FILE_MASTER);
171                        new MasterTO(configTO.getMasterKey().getSalt()).save(masterFile);
172                }
173
174                // Shutdown plugin
175                transferManager.disconnect();
176
177                // Add to daemon (if requested)
178                if (options.isDaemon()) {
179                        try {
180                                boolean addedToDaemonConfig = DaemonConfigHelper.addFolder(options.getLocalDir());
181                                result.setAddedToDaemon(addedToDaemonConfig);
182                        }
183                        catch (Exception e) {
184                                logger.log(Level.WARNING, "Cannot add folder to daemon config.", e);
185                                result.setAddedToDaemon(false);
186                        }
187                }
188
189                result.setResultCode(ConnectResultCode.OK);
190                return result;
191        }
192
193        private boolean decryptAndVerifyRepoFile(File tmpRepoFile, SaltedSecretKey masterKey) throws StorageException {
194                try {
195                        String repoFileStr = decryptRepoFile(tmpRepoFile, masterKey);
196                        verifyRepoFile(repoFileStr);
197
198                        return true;
199                }
200                catch (CipherException e) {
201                        logger.log(Level.FINE, "Could not decrypt the repository file", e);
202                        return false;
203                }
204        }
205
206        private SaltedSecretKey askPasswordAndCreateMasterKey() throws CipherException, StorageException {
207                File tmpMasterFile = downloadFile(transferManager, new MasterRemoteFile());
208                MasterTO masterTO = readMasterFile(tmpMasterFile);
209
210                tmpMasterFile.delete();
211
212                String masterKeyPassword = getOrAskPassword();
213                byte[] masterKeySalt = masterTO.getSalt();
214
215                return createMasterKeyFromPassword(masterKeyPassword, masterKeySalt); // This takes looong!
216        }
217
218        private ConfigTO createConfigTO() throws StorageException, CipherException {
219                ConfigTO configTO = options.getConfigTO();
220
221                if (options.getStrategy() == ConnectOptionsStrategy.CONNECTION_TO) {
222                        return configTO;
223                }
224                else if (options.getStrategy() == ConnectOptionsStrategy.CONNECTION_LINK) {
225                        return createConfigTOFromLink(configTO, options.getConnectLink(), options.getPassword());
226                }
227                else {
228                        throw new RuntimeException("Unhandled connect strategy: " + options.getStrategy());
229                }
230        }
231
232        private ConfigTO createConfigTOFromLink(ConfigTO configTO, String link, String masterPassword) throws StorageException, CipherException {
233                logger.log(Level.INFO, "Creating config TO from link: " + link + " ...");
234                ApplicationLink applicationLink = new ApplicationLink(link);
235
236                try {
237                        if (applicationLink.isEncrypted()) {
238                                // Non-interactive mode
239                                if (masterPassword != null) {
240                                        logger.log(Level.INFO, " - Link is encrypted. Password available.");
241
242                                        SaltedSecretKey masterKey = createMasterKeyFromPassword(masterPassword, applicationLink.getMasterKeySalt());
243                                        TransferSettings transferSettings = applicationLink.createTransferSettings(masterKey);
244
245                                        configTO.setMasterKey(masterKey);
246                                        configTO.setTransferSettings(transferSettings);
247                                }
248                                else {
249                                        logger.log(Level.INFO, " - Link is encrypted. Asking for password.");
250
251                                        boolean retryPassword = true;
252
253                                        while (retryPassword) {
254                                                // Ask password
255                                                masterPassword = getOrAskPassword();
256
257                                                // Generate master key
258                                                SaltedSecretKey masterKey = createMasterKeyFromPassword(masterPassword, applicationLink.getMasterKeySalt());
259
260                                                // Decrypt config
261                                                try {
262                                                        TransferSettings transferSettings = applicationLink.createTransferSettings(masterKey);
263
264                                                        configTO.setMasterKey(masterKey);
265                                                        configTO.setTransferSettings(transferSettings);
266
267                                                        retryPassword = false;
268                                                }
269                                                catch (CipherException e) {
270                                                        retryPassword = askRetryPassword();
271                                                }
272                                        }
273                                }
274
275                                if (configTO.getTransferSettings() == null) {
276                                        throw new CipherException("Unable to decrypt link.");
277                                }
278                        }
279                        else {
280                                logger.log(Level.INFO, " - Link is NOT encrypted. No password needed.");
281
282                                TransferSettings transferSettings = applicationLink.createTransferSettings();
283                                configTO.setTransferSettings(transferSettings);
284                        }
285                }
286                catch (Exception e) {
287                        throw new StorageException("Unable to extract connection settings: " + e.getMessage(), e);
288                }
289
290                return configTO;
291        }
292
293        private boolean performRepoTest(TransferManager transferManager) {
294                StorageTestResult testResult = transferManager.test(false);
295
296                logger.log(Level.INFO, "Storage test result ist " + testResult);
297
298                if (testResult.isRepoFileExists()) {
299                        logger.log(Level.INFO, "--> OKAY: Repo file exists. We're good to go!");
300                        return true;
301                }
302                else {
303                        logger.log(Level.INFO, "--> NOT OKAY: Invalid target/repo state. Operation cannot be continued.");
304
305                        result.setResultCode(ConnectResultCode.NOK_TEST_FAILED);
306                        result.setTestResult(testResult);
307
308                        return false;
309                }
310        }
311
312        private String getOrAskPassword() {
313                if (options.getPassword() == null) {
314                        if (listener == null) {
315                                throw new RuntimeException("Repository file is encrypted, but password cannot be queried (no listener).");
316                        }
317
318                        return listener.onUserPassword(null, "Master Password: ");
319                }
320                else {
321                        return options.getPassword();
322                }
323        }
324
325        private boolean askRetryPassword() {
326                retryPasswordCount++;
327
328                if (retryPasswordCount < MAX_RETRY_PASSWORD_COUNT) {
329                        int triesLeft = MAX_RETRY_PASSWORD_COUNT - retryPasswordCount;
330                        String triesLeftStr = triesLeft != 1 ? triesLeft + " tries left." : "Last chance.";
331
332                        eventBus.post(new ShowMessageExternalEvent("ERROR: Invalid password or corrupt ciphertext. " + triesLeftStr));
333                        return true;
334                }
335                else {
336                        return false;
337                }
338        }
339
340        protected File downloadFile(TransferManager transferManager, RemoteFile remoteFile) throws StorageException {
341                try {
342                        File tmpRepoFile = File.createTempFile("syncanyfile", "tmp");
343
344                        transferManager.download(remoteFile, tmpRepoFile);
345                        return tmpRepoFile;
346                }
347                catch (Exception e) {
348                        throw new StorageException("Unable to connect to repository.", e);
349                }
350        }
351
352        private SaltedSecretKey createMasterKeyFromPassword(String masterPassword, byte[] masterKeySalt) throws CipherException {
353                fireNotifyCreateMaster();
354
355                SaltedSecretKey masterKey = CipherUtil.createMasterKey(masterPassword, masterKeySalt);
356                return masterKey;
357        }
358
359        private String decryptRepoFile(File file, SaltedSecretKey masterKey) throws CipherException {
360                try {
361                        logger.log(Level.INFO, "Decrypting repo file ...");
362
363                        FileInputStream encryptedRepoConfig = new FileInputStream(file);
364                        String repoFileStr = new String(CipherUtil.decrypt(encryptedRepoConfig, masterKey));
365
366                        logger.log(Level.INFO, "Repo file decrypted:");
367                        logger.log(Level.INFO, repoFileStr);
368
369                        return repoFileStr;
370                }
371                catch (Exception e) {
372                        logger.log(Level.INFO, "Invalid password given, or repo file corrupt.", e);
373                        throw new CipherException("Invalid password given, or repo file corrupt.", e);
374                }
375        }
376
377        private void verifyRepoFile(String repoFileStr) throws StorageException {
378                try {
379                        Serializer serializer = new Persister();
380                        serializer.read(RepoTO.class, repoFileStr);
381                }
382                catch (Exception e) {
383                        throw new StorageException("Repo file corrupt.", e);
384                }
385        }
386
387        private MasterTO readMasterFile(File tmpMasterFile) throws StorageException {
388                try {
389                        Serializer serializer = new Persister();
390                        return serializer.read(MasterTO.class, tmpMasterFile);
391                }
392                catch (Exception e) {
393                        throw new StorageException("Master file corrupt.", e);
394                }
395        }
396}