/* * Nayuki's Dropbox backup utility - Uploader service * * This program periodically uploads encrypted copies of local files to Dropbox, running in the background. * Usage: java DropboxBackupService ConfigFile.json * * Program notes: * * - A ConfigFile.json file has a format like this: * { * "dropbox-access-token": "r9DhIqHfwLMVYwkctcx2Vi9_UuV6O3pHcwdVYt-gCVf2APyh_Z1xVzSLC7S-TDJp", * "file-encryption-key": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", * "backup-paths": [ * {"local":"/var/www/jane-doe", "remote":"/files/www-djane/"}, * {"local":"C:\\Users\\John Smith\\My Documents", "remote":"/backup/docs-john-smith/"}, * {"local":"D:\\Sales reports", "remote":"/backup/sales-reports/"} * ] * } * Format notes: * - The key must be 64 hexadecimal characters (256 bits), case-insensitive. * - A backslash (\) in a path must be written as double-backslash (\\) due to the JSON format. * - Each remote path must start and end with slash (/). * - Each local path must be a directory, not a file. * - Trailing commas are disallowed in JSON lists. * * - This program requires Java SE 7+ and Nayuki's JSON library (https://www.nayuki.io/page/json-library-java). * No other libraries are required. In particular, the cryptography implementation is self-contained. * * - The encryption format produced by this program is secure, but also easy to understand and independently reimplement. * * First, each component of the file path (relative to the backup root) is encrypted using the "name encryption algorithm". * For example, "hello" might encrypt to "RVoJ8rD" and "world.txt" might encrypt to "ff3q_lpNLq-x", therefore the path * "/home/alice/to-backup/hello/world.txt" could map to "/dropbox/backup/RVoJ8rD/ff3q_lpNLq-x". Note that this mapping * only depends on the strings and the cipher key, not on anything else (file contents, full path, etc.). Thus a file * that stays at the same location on the local file system will always be mapped to the same location on Dropbox. * * The name encryption algorithm maps Unicode strings (not containing the NUL character) to base64 ASCII strings. * The string is converted to bytes as UTF-8. If the length is less than or equal to an AES block (16 bytes), * then it is padded with zeros and simply encrypted in ECB mode. Otherwise the length is strictly greater than * a block, then it is encrypted with AES-256-CBC with ciphertext stealing mode 3, the bytes are reversed, * then encrypted again with the same algorithm. The encrypted bytes are encoded using base64url (RFC 4648) * without padding. If only one pass of CBC mode encryption were used, then a bunch of strings with the same * long prefix would show patterns in the ciphertext due to the lack of IV. But thanks to the reversal and * second encryption, these patterns are fully obfuscated. * * Second, the contents of each file is encrypted using AES-256 with PKCS #7 padding, CBC mode, a random IV * (per encryption attempt, so the encrypted data changes on each run even if the original file stays the same), * and an HMAC-SHA-256 (covering the IV, data, and padding). Note that the MAC uses the same cipher key, * and uses "encrypt-then-MAC" mode. * * Copyright (c) 2016 Project Nayuki * All rights reserved. Contact Nayuki for licensing. * https://www.nayuki.io/page/encrypted-backup-client-for-dropbox */ package io.nayuki.dropboxbackup; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.Map; import io.nayuki.json.Json; public final class DropboxBackupService { /*---- Main application ----*/ private static byte[] cipherKey; private static String accessToken; private static Map knownFiles; public static void main(String[] args) throws IOException, InterruptedException { String errMsg = subMain(args); if (errMsg != null) { System.err.println(errMsg); System.exit(1); } } private static String subMain(String[] args) throws IOException, InterruptedException { // Check command line arguments if (args.length != 1) return "Usage: java MyDropboxBackuper ConfigFile.json"; File configFile = new File(args[0]); if (!configFile.isFile()) return "Error: Configuration file not found"; // Load configuration file data Object config = Json.parseFromFile(new File(args[0])); accessToken = Json.getString(config, "dropbox-access-token"); cipherKey = Utils.hexToBytes(Json.getString(config, "file-encryption-key")); // Check each backup path Collection backupPaths = new ArrayList<>(); for (Object bp : Json.getList(config, "backup-paths")) { File srcDir = new File(Json.getString(bp, "local")); String destPath = Json.getString(bp, "remote"); if (!srcDir.isDirectory()) throw new IllegalArgumentException("Local path must be a directory"); if (!destPath.startsWith("/") || !destPath.endsWith("/")) throw new IllegalArgumentException("Remote path must start and end with '/'"); backupPaths.add(new String[]{srcDir.getPath(), destPath}); } // Periodically check and upload files knownFiles = new HashMap<>(); while (true) { for (String[] bp : backupPaths) processBackup(new File(bp[0]), bp[1], true); Thread.sleep(3600000); // 1 hour } } // Uploads an encrypted copy of the given local file to the given Dropbox path, // or iterates over the given local directory and recurses on all its items. private static void processBackup(File srcItem, String destPath, boolean isRoot) throws IOException, OutOfMemoryError { if (srcItem.isDirectory()) { if (!isRoot) destPath += "/"; for (File subItem : srcItem.listFiles()) processBackup(subItem, destPath + encryptFileName(subItem.getName()), false); } else if (srcItem.isFile()) { // Check file info in memory cache long len = srcItem.length(); FileInfo info = knownFiles.get(srcItem); if (info != null && info.length == len && info.modTime == srcItem.lastModified()) return; // File seems unchanged, so skip it if (info == null) { info = new FileInfo(); knownFiles.put(srcItem, info); } info.length = len; info.modTime = srcItem.lastModified(); // Read entire file and encrypt if (len > 1000000000) throw new IllegalArgumentException("File too large"); byte[] b = new byte[(int)len]; try (DataInputStream in = new DataInputStream(new FileInputStream(srcItem))) { in.readFully(b); } b = encryptFileData(b); knownFiles.put(srcItem, info); // Upload file to Dropbox System.err.printf("[%s] Uploading file: %s --> %s%n", new Date(), srcItem, destPath); uploadFile(b, destPath); } } // Please ensure that the data array is already encrypted! private static void uploadFile(byte[] data, String path) throws IOException { if (!path.startsWith("/")) throw new IllegalArgumentException("Path must start with slash"); if (path.contains("&") || path.contains("?")) throw new IllegalArgumentException("Path contains forbidden characters"); URL url = new URL("https://content.dropboxapi.com/1/files_put/auto" + path + "?overwrite=true&access_token=" + accessToken); HttpURLConnection con = (HttpURLConnection)url.openConnection(); con.setRequestMethod("PUT"); con.setRequestProperty("Content-Length", Integer.toString(data.length)); con.setDoOutput(true); con.setFixedLengthStreamingMode(data.length); con.connect(); try (OutputStream out = con.getOutputStream()) { out.write(data); } try (InputStream in = con.getInputStream()) { while (in.read() != -1); } } /*---- Encryption functions ----*/ // Encrypts the given string using the cipher key and returns a base64-encoded ASCII string. // The encryption is deterministic and only depends on the string, cipher key, and cipher algorithm. // The input string must not contain the NUL character. private static String encryptFileName(String s) { if (s.contains("\0")) throw new IllegalArgumentException("File name must not contain NUL character"); byte[] b = s.getBytes(StandardCharsets.UTF_8); Aes ciph = new Aes(cipherKey); if (b.length <= Aes.BLOCK_LENGTH) { // Single block // Pad with zeros, then encrypt in ECB mode b = Arrays.copyOf(b, Aes.BLOCK_LENGTH); ciph.encryptBlock(b, 0); } else { // Multi-block encryptCbcCts3(b); Utils.reverseArray(b); encryptCbcCts3(b); } Base64.Encoder b64enc = Base64.getUrlEncoder().withoutPadding(); return b64enc.encodeToString(b); } // Encrypts the given binary data using the CBC (ciphertext block chaining) mode // with no IV (initialization vector) and using CTS (ciphertext stealing) type 3. private static void encryptCbcCts3(byte[] b) { int blockLen = Aes.BLOCK_LENGTH; if (b.length <= blockLen) throw new IllegalArgumentException("Data must be longer than one block"); Aes ciph = new Aes(cipherKey); // Encrypt head block using ECB ciph.encryptBlock(b, 0); // Encrypt subsequent blocks except the last one using CBC int i; for (i = blockLen; i + blockLen < b.length; i += blockLen) { for (int j = 0; j < blockLen; j++) b[i + j] ^= b[i - blockLen + j]; ciph.encryptBlock(b, i); } // CBC last block, swap prefixes of last two blocks, encrypt new second-last block for (int j = i; j < b.length; j++) { byte temp = b[j - blockLen]; b[j - blockLen] ^= b[j]; b[j] = temp; } ciph.encryptBlock(b, i - blockLen); } // Encrypts the file data with randomization and authentication. The algorithm pads the data // using PKCS #7 (RFC 5652), prepends a randomly generated initialization vector, encrypts the // whole data using AES-256-CBC, and appends an HMAC-SHA-256 of the encrypted data. private static byte[] encryptFileData(byte[] data) { // Calculate offsets/lengths and make final-sized array (with space for IV, message, padding, HMAC) int blockLen = Aes.BLOCK_LENGTH; int paddedLen = (data.length / blockLen + 1) * blockLen; // Without IV int hashOffset = paddedLen + blockLen; // Where the MAC starts byte[] result = new byte[hashOffset + Sha256.HASH_LENGTH]; System.arraycopy(data, 0, result, blockLen, data.length); // Apply padding for cipher int padding = paddedLen - data.length; if (padding < 1 || padding > blockLen) throw new AssertionError(); for (int i = 0; i < padding; i++) result[hashOffset - 1 - i] = (byte)padding; // Generate random initialization vector byte[] initVec = new byte[blockLen]; new SecureRandom().nextBytes(initVec); System.arraycopy(initVec, 0, result, 0, blockLen); // Encrypt data in CBC mode, processing blocks in forward order Aes ciph = new Aes(cipherKey); for (int i = blockLen; i < hashOffset; i += blockLen) { for (int j = 0; j < blockLen; j++) result[i + j] ^= result[i - blockLen + j]; ciph.encryptBlock(result, i); } // Compute and append MAC byte[] hash = Sha256.getHmac(cipherKey, Arrays.copyOf(result, hashOffset)); System.arraycopy(hash, 0, result, hashOffset, hash.length); return result; } private static final class FileInfo { public long length; public long modTime; } }