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.ByteArrayInputStream;
021import java.io.DataOutputStream;
022import java.io.IOException;
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.List;
026import java.util.logging.Level;
027import java.util.logging.Logger;
028import java.util.regex.Matcher;
029import java.util.regex.Pattern;
030import java.util.zip.GZIPInputStream;
031import java.util.zip.GZIPOutputStream;
032
033import org.apache.commons.io.IOUtils;
034import org.apache.commons.io.output.ByteArrayOutputStream;
035import org.apache.http.Header;
036import org.apache.http.HttpHost;
037import org.apache.http.HttpResponse;
038import org.apache.http.NameValuePair;
039import org.apache.http.auth.AuthScope;
040import org.apache.http.auth.UsernamePasswordCredentials;
041import org.apache.http.client.CredentialsProvider;
042import org.apache.http.client.config.RequestConfig;
043import org.apache.http.client.entity.UrlEncodedFormEntity;
044import org.apache.http.client.methods.HttpHead;
045import org.apache.http.client.methods.HttpPost;
046import org.apache.http.impl.client.BasicCredentialsProvider;
047import org.apache.http.impl.client.CloseableHttpClient;
048import org.apache.http.impl.client.HttpClientBuilder;
049import org.apache.http.message.BasicNameValuePair;
050import org.simpleframework.xml.core.Persister;
051import org.simpleframework.xml.stream.Format;
052import org.syncany.crypto.CipherSpec;
053import org.syncany.crypto.CipherSpecs;
054import org.syncany.crypto.CipherUtil;
055import org.syncany.crypto.SaltedSecretKey;
056import org.syncany.plugins.Plugins;
057import org.syncany.plugins.transfer.StorageException;
058import org.syncany.plugins.transfer.TransferPlugin;
059import org.syncany.plugins.transfer.TransferPluginUtil;
060import org.syncany.plugins.transfer.TransferSettings;
061import org.syncany.util.Base58;
062
063import com.google.common.primitives.Ints;
064
065/**
066 * The application link class represents a <code>syncany://</code> link. It allowed creating
067 * and parsing a link. The class has two modes of operation:
068 *
069 * <p>To create a new application link from an existing repository, call the
070 * {@link #ApplicationLink(org.syncany.plugins.transfer.TransferSettings, boolean)} constructor and subsequently either
071 * call {@link #createPlaintextLink()} or {@link #createEncryptedLink(SaltedSecretKey)}.
072 * This method will typically be called during the 'init' or 'genlink' process.
073 *
074 * <p>To parse an existing application link and return the relevant {@link TransferSettings}, call the
075 * {@link #ApplicationLink(String)} constructor and subsequently call {@link #createTransferSettings()}
076 * or {@link #createTransferSettings(SaltedSecretKey)}. This method will typically be called during the 'connect' process.
077 *
078 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
079 * @author Christian Roth (christian.roth@port17.de)
080 */
081public class ApplicationLink {
082        private static final Logger logger = Logger.getLogger(ApplicationLink.class.getSimpleName());
083
084        private static final String LINK_FORMAT_NOT_ENCRYPTED = "syncany://2/not-encrypted/%s";
085        private static final String LINK_FORMAT_ENCRYPTED = "syncany://2/%s/%s";
086
087        private static final Pattern LINK_PATTERN = Pattern.compile("syncany://?2/(?:(not-encrypted/)(.+)|([^/]+)/([^/]+))$");
088        private static final int LINK_PATTERN_GROUP_NOT_ENCRYPTED_FLAG = 1;
089        private static final int LINK_PATTERN_GROUP_NOT_ENCRYPTED_PLUGIN_ENCODED = 2;
090        private static final int LINK_PATTERN_GROUP_ENCRYPTED_MASTER_KEY_SALT = 3;
091        private static final int LINK_PATTERN_GROUP_ENCRYPTED_PLUGIN_ENCODED = 4;
092
093        private static final Pattern LINK_SHORT_URL_PATTERN = Pattern.compile("syncany://?s/(.+)$");
094        private static final int LINK_SHORT_URL_PATTERN_GROUP_SHORTLINK = 1;
095        private static final String LINK_SHORT_URL_FORMAT = "syncany://s/%s";
096        private static final String LINK_SHORT_API_URL_GET_FORMAT = "https://api.syncany.org/v2/links/?l=%s";
097        private static final String LINK_SHORT_API_URL_ADD = "https://api.syncany.org/v2/links/add";
098
099        private static final Pattern LINK_HTTP_PATTERN = Pattern.compile("https?://.+");
100        private static final int LINK_HTTP_MAX_REDIRECT_COUNT = 5;
101
102        private static final int INTEGER_BYTES = 4;
103
104        private TransferSettings transferSettings;
105        private boolean shortUrl;
106
107        private boolean encrypted;
108        private byte[] masterKeySalt;
109        private byte[] encryptedSettingsBytes;
110        private byte[] plaintextSettingsBytes;
111
112        public ApplicationLink(TransferSettings transferSettings, boolean shortUrl) {
113                this.transferSettings = transferSettings;
114                this.shortUrl = shortUrl;
115        }
116
117        public ApplicationLink(String applicationLink) throws IllegalArgumentException, StorageException {
118                if (LINK_SHORT_URL_PATTERN.matcher(applicationLink).matches()) {
119                        applicationLink = expandLink(applicationLink);
120                }
121
122                if (LINK_HTTP_PATTERN.matcher(applicationLink).matches()) {
123                        applicationLink = resolveLink(applicationLink, 0);
124                }
125
126                parseLink(applicationLink);
127        }
128
129        public boolean isEncrypted() {
130                return encrypted;
131        }
132
133        public byte[] getMasterKeySalt() {
134                return masterKeySalt;
135        }
136
137        public TransferSettings createTransferSettings(SaltedSecretKey masterKey) throws Exception {
138                if (!encrypted || encryptedSettingsBytes == null) {
139                        throw new IllegalArgumentException("Link is not encrypted. Cannot call this method.");
140                }
141
142                byte[] plaintextPluginSettingsBytes = CipherUtil.decrypt(new ByteArrayInputStream(encryptedSettingsBytes), masterKey);
143                return createTransferSettings(plaintextPluginSettingsBytes);
144        }
145
146        public TransferSettings createTransferSettings() throws Exception {
147                if (encrypted || plaintextSettingsBytes == null) {
148                        throw new IllegalArgumentException("Link is encrypted. Cannot call this method.");
149                }
150
151                return createTransferSettings(plaintextSettingsBytes);
152        }
153
154        public String createEncryptedLink(SaltedSecretKey masterKey) throws Exception {
155                byte[] plaintextStorageXml = getPlaintextStorageXml();
156                List<CipherSpec> cipherSpecs = CipherSpecs.getDefaultCipherSpecs(); // TODO [low] Shouldn't this be the same as the application?!
157
158                byte[] masterKeySalt = masterKey.getSalt();
159                byte[] encryptedPluginBytes = CipherUtil.encrypt(new ByteArrayInputStream(plaintextStorageXml), cipherSpecs, masterKey);
160
161                String masterKeySaltEncodedStr = Base58.encode(masterKeySalt);
162                String encryptedEncodedPlugin = Base58.encode(encryptedPluginBytes);
163
164                String applicationLink = String.format(LINK_FORMAT_ENCRYPTED, masterKeySaltEncodedStr, encryptedEncodedPlugin);
165
166                if (shortUrl) {
167                        return shortenLink(applicationLink);
168                }
169                else {
170                        return applicationLink;
171                }
172        }
173
174        public String createPlaintextLink() throws Exception {
175                byte[] plaintextStorageXml = getPlaintextStorageXml();
176                String plaintextEncodedStorage = Base58.encode(plaintextStorageXml);
177
178                return String.format(LINK_FORMAT_NOT_ENCRYPTED, plaintextEncodedStorage);
179        }
180
181        private String expandLink(String applicationLink) {
182                Matcher shortLinkMatcher = LINK_SHORT_URL_PATTERN.matcher(applicationLink);
183
184                if (!shortLinkMatcher.matches()) {
185                        throw new IllegalArgumentException("Method may only be called with application shortlink.");
186                }
187
188                String shortLinkId = shortLinkMatcher.group(LINK_SHORT_URL_PATTERN_GROUP_SHORTLINK);
189                return String.format(LINK_SHORT_API_URL_GET_FORMAT, shortLinkId);
190        }
191
192        private String resolveLink(String httpApplicationLink, int redirectCount) throws IllegalArgumentException, StorageException {
193                if (redirectCount >= LINK_HTTP_MAX_REDIRECT_COUNT) {
194                        throw new IllegalArgumentException("Max. redirect count of " + LINK_HTTP_MAX_REDIRECT_COUNT + " for URL reached. Cannot find syncany:// link.");
195                }
196
197                try {
198                        logger.log(Level.INFO, "- Retrieving HTTP HEAD for " + httpApplicationLink + " ...");
199
200                        HttpHead headMethod = new HttpHead(httpApplicationLink);
201                        HttpResponse httpResponse = createHttpClient().execute(headMethod);
202
203                        // Find syncany:// link
204                        Header locationHeader = httpResponse.getLastHeader("Location");
205
206                        if (locationHeader == null) {
207                                throw new Exception("Link does not redirect to a syncany:// link.");
208                        }
209
210                        String locationHeaderUrl = locationHeader.getValue();
211                        Matcher locationHeaderMatcher = LINK_PATTERN.matcher(locationHeaderUrl);
212                        boolean isApplicationLink = locationHeaderMatcher.find();
213
214                        if (isApplicationLink) {
215                                String applicationLink = locationHeaderMatcher.group(0);
216                                logger.log(Level.INFO, "Resolved application link is: " + applicationLink);
217
218                                return applicationLink;
219                        }
220                        else {
221                                return resolveLink(locationHeaderUrl, ++redirectCount);
222                        }
223                }
224                catch (StorageException | IllegalArgumentException e) {
225                        throw e;
226                }
227                catch (Exception e) {
228                        throw new StorageException(e.getMessage(), e);
229                }
230        }
231
232        private String shortenLink(String applicationLink) {
233                if (!LINK_PATTERN.matcher(applicationLink).matches()) {
234                        throw new IllegalArgumentException("Invalid link provided, must start with syncany:// and match link pattern.");
235                }
236
237                try {
238                        logger.log(Level.INFO, "Shortining link " + applicationLink + " via " + LINK_SHORT_API_URL_ADD + " ...");
239
240                        List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(1);
241                        nameValuePairs.add(new BasicNameValuePair("l", applicationLink));
242
243                        HttpPost postMethod = new HttpPost(LINK_SHORT_API_URL_ADD);
244                        postMethod.setEntity(new UrlEncodedFormEntity(nameValuePairs));
245
246                        HttpResponse httpResponse = createHttpClient().execute(postMethod);
247                        ApplicationLinkShortenerResponse shortenerResponse = new Persister().read(ApplicationLinkShortenerResponse.class, httpResponse
248                                        .getEntity().getContent());
249
250                        return String.format(LINK_SHORT_URL_FORMAT, shortenerResponse.getShortLinkId());
251                }
252                catch (Exception e) {
253                        logger.log(Level.WARNING, "Cannot shorten URL. Using long URL.", e);
254                        return applicationLink;
255                }
256        }
257
258        private CloseableHttpClient createHttpClient() {
259                RequestConfig.Builder requestConfigBuilder = RequestConfig.custom()
260                                .setSocketTimeout(2000)
261                                .setConnectTimeout(2000)
262                                .setRedirectsEnabled(false);
263
264                HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
265
266                // do we use a https proxy?
267                String proxyHost = System.getProperty("https.proxyHost");
268                String proxyPortStr = System.getProperty("https.proxyPort");
269                String proxyUser = System.getProperty("https.proxyUser");
270                String proxyPassword = System.getProperty("https.proxyPassword");
271
272                if (proxyHost != null && proxyPortStr != null) {
273                        try {
274                                Integer proxyPort = Integer.parseInt(proxyPortStr);
275                                
276                                requestConfigBuilder.setProxy(new HttpHost(proxyHost, proxyPort));
277                                logger.log(Level.INFO, "Using proxy: " + proxyHost + ":" + proxyPort);
278        
279                                if (proxyUser != null && proxyPassword != null) {
280                                        logger.log(Level.INFO, "Proxy required credentials; using '" + proxyUser + "' (username) and *** (hidden password)");
281
282                                        CredentialsProvider credsProvider = new BasicCredentialsProvider();
283                                        credsProvider.setCredentials(new AuthScope(proxyHost, proxyPort), new UsernamePasswordCredentials(proxyUser, proxyPassword));
284                                        httpClientBuilder.setDefaultCredentialsProvider(credsProvider);                                 
285                                }
286                        }
287                        catch (NumberFormatException e) {
288                                logger.log(Level.WARNING, "Invalid proxy settings found. Not using proxy.", e);
289                        }
290                }
291
292                httpClientBuilder.setDefaultRequestConfig(requestConfigBuilder.build());
293
294                return httpClientBuilder.build();
295        }
296
297        private void parseLink(String applicationLink) throws IllegalArgumentException {
298                Matcher linkMatcher = LINK_PATTERN.matcher(applicationLink);
299
300                if (!linkMatcher.matches()) {
301                        throw new IllegalArgumentException("Invalid link provided, must start with syncany:// and match link pattern.");
302                }
303
304                encrypted = linkMatcher.group(LINK_PATTERN_GROUP_NOT_ENCRYPTED_FLAG) == null;
305
306                if (encrypted) {
307                        String masterKeySaltStr = linkMatcher.group(LINK_PATTERN_GROUP_ENCRYPTED_MASTER_KEY_SALT);
308                        String encryptedPluginSettingsStr = linkMatcher.group(LINK_PATTERN_GROUP_ENCRYPTED_PLUGIN_ENCODED);
309
310                        logger.log(Level.INFO, "- Master salt: " + masterKeySaltStr);
311                        logger.log(Level.INFO, "- Encrypted plugin settings: " + encryptedPluginSettingsStr);
312
313                        try {
314                                masterKeySalt = Base58.decode(masterKeySaltStr);
315                                encryptedSettingsBytes = Base58.decode(encryptedPluginSettingsStr);
316                                plaintextSettingsBytes = null;
317                        }
318                        catch (IllegalArgumentException e) {
319                                throw new IllegalArgumentException("Invalid syncany:// link provided. Parsing failed.", e);
320                        }
321                }
322                else {
323                        String plaintextEncodedSettingsStr = linkMatcher.group(LINK_PATTERN_GROUP_NOT_ENCRYPTED_PLUGIN_ENCODED);
324
325                        try {
326                                masterKeySalt = null;
327                                encryptedSettingsBytes = null;
328                                plaintextSettingsBytes = Base58.decode(plaintextEncodedSettingsStr);
329                        }
330                        catch (IllegalArgumentException e) {
331                                throw new IllegalArgumentException("Invalid syncany:// link provided. Parsing failed.", e);
332                        }
333                }
334        }
335
336        private TransferSettings createTransferSettings(byte[] plaintextPluginSettingsBytes) throws StorageException, IOException {
337                // Find plugin ID and settings XML
338                int pluginIdentifierLength = Ints.fromByteArray(Arrays.copyOfRange(plaintextPluginSettingsBytes, 0, INTEGER_BYTES));
339                String pluginId = new String(Arrays.copyOfRange(plaintextPluginSettingsBytes, INTEGER_BYTES, INTEGER_BYTES + pluginIdentifierLength));
340                byte[] gzippedPluginSettingsByteArray = Arrays.copyOfRange(plaintextPluginSettingsBytes, INTEGER_BYTES + pluginIdentifierLength,
341                                plaintextPluginSettingsBytes.length);
342                String pluginSettings = IOUtils.toString(new GZIPInputStream(new ByteArrayInputStream(gzippedPluginSettingsByteArray)));
343
344                // Create transfer settings object
345                try {
346                        TransferPlugin plugin = Plugins.get(pluginId, TransferPlugin.class);
347
348                        if (plugin == null) {
349                                throw new StorageException("Link contains unknown connection type '" + pluginId + "'. Corresponding plugin not found.");
350                        }
351
352                        Class<? extends TransferSettings> pluginTransferSettingsClass = TransferPluginUtil.getTransferSettingsClass(plugin.getClass());
353                        TransferSettings transferSettings = new Persister().read(pluginTransferSettingsClass, pluginSettings);
354
355                        logger.log(Level.INFO, "(Decrypted) link contains: " + pluginId + " -- " + pluginSettings);
356
357                        return transferSettings;
358                }
359                catch (Exception e) {
360                        throw new StorageException(e);
361                }
362        }
363
364        private byte[] getPlaintextStorageXml() throws Exception {
365                ByteArrayOutputStream plaintextByteArrayOutputStream = new ByteArrayOutputStream();
366                DataOutputStream plaintextOutputStream = new DataOutputStream(plaintextByteArrayOutputStream);
367                plaintextOutputStream.writeInt(transferSettings.getType().getBytes().length);
368                plaintextOutputStream.write(transferSettings.getType().getBytes());
369
370                GZIPOutputStream plaintextGzipOutputStream = new GZIPOutputStream(plaintextOutputStream);
371                new Persister(new Format(0)).write(transferSettings, plaintextGzipOutputStream);
372                plaintextGzipOutputStream.close();
373
374                return plaintextByteArrayOutputStream.toByteArray();
375        }
376}