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}