├── .gitignore ├── .travis.yml ├── README.md ├── package-lock.json ├── package.json ├── src ├── __snapshots__ │ ├── index.test.js.snap │ └── rclone.test.js.snap ├── ciphers │ ├── FileCipher.js │ ├── FileCipher.test.js │ ├── PathCipher.js │ ├── PathCipher.test.js │ ├── PushStream.js │ ├── __fixtures__ │ │ └── FileCipher │ │ │ ├── nonceTest │ │ │ ├── nonceTest.decrypted │ │ │ ├── test │ │ │ └── test.png │ ├── __snapshots__ │ │ ├── FileCipher.test.js.snap │ │ └── PathCipher.test.js.snap │ ├── eme │ │ └── index.js │ └── text-encoding.js ├── constants.js ├── index.js ├── index.test.js ├── rclone.js ├── rclone.test.js ├── reveal.js └── reveal.test.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | dist 4 | .vscode -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | notifications: 6 | email: false 7 | node_js: 8 | - 'node' 9 | - 'lts/*' 10 | script: 11 | - npm run test 12 | - npm run build 13 | after_success: 14 | - codecov 15 | branches: 16 | except: 17 | - /^v\d+\.\d+\.\d+$/ 18 | jobs: 19 | include: 20 | - stage: release 21 | node_js: lts/* 22 | deploy: 23 | provider: script 24 | skip_cleanup: true 25 | script: 26 | - npx semantic-release 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rclone-js 2 | [![Travis Build](https://img.shields.io/travis/FWeinb/rclone-js.svg?style=flat-square)](https://travis-ci.org/FWeinb/rclone-js) 3 | [![Codecov](https://img.shields.io/codecov/c/github/FWeinb/rclone-js.svg?style=flat-square)](https://codecov.io/gh/FWeinb/rclone-js) 4 | ==== 5 | 6 | Pure Javascript implementation of the cipher used in rclone (crypt-mount). 7 | 8 | # Installation 9 | 10 | ## Node 11 | 12 | ``` 13 | npm install rclone 14 | ``` 15 | 16 | ## Browser 17 | 18 | You can find a browser bundle here: 19 | [https://unpkg.com/rclone/dist/rclone.umd.min.js](https://unpkg.com/rclone/dist/rclone.umd.min.js) 20 | Use `window.rclone.Rclone` to access the constructor function. 21 | 22 | # Getting Started 23 | 24 | ## Examples 25 | 26 | ### Encrypt/Decrypt Paths 27 | 28 | ```js 29 | import { Rclone } from 'rclone'; 30 | 31 | // Create Rclone instance 32 | Rclone({ 33 | password: 'UmyLSdRHfew6aual28-ggx78qHqSfQ', 34 | salt: 'Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw' 35 | }) 36 | .then(rclone => { 37 | 38 | // Decryption 39 | console.log( 40 | rclone.Path.decrypt("dk7voi2247uqbgbuh439j13eo0/p0q5lhi767fsplsdjla7j7uv60") // Hello World 41 | ); 42 | 43 | // Encryption 44 | console.log( 45 | rclone.Path.encrypt("Hello/World") // dk7voi2247uqbgbuh439j13eo0/p0q5lhi767fsplsdjla7j7uv60 46 | ); 47 | 48 | }) 49 | .catch(error => { 50 | // Catch error creating rclone instance 51 | }) 52 | ``` 53 | 54 | ### Decrypt Files 55 | 56 | #### Concept 57 | 58 | To decrypt files in the browser rclone-js is using a node concept called [Streams](https://nodejs.org/api/stream.html) in the browser using [readable-stream](https://github.com/nodejs/readable-stream). You can create a decrypting [ReadableStream](https://nodejs.org/api/stream.html#stream_readable_streams) by using the function `rclone.File.createReadStream` which will take a function that needs to return a ReadableStream representing the decrypted file. To provide random access rclone will pass an options object to the function like used by the node [`fs`](https://nodejs.org/api/fs.html#fs_fs_createreadstream_path_options) module. An important additon to these options is the `chunkSize` propertie, it is needed because rclone uses a block cipher and can only operate on a integer multiple of this size. These options need to be taken into account for the creation of the underyling ReadableStream returned from the function. 59 | 60 | #### Example 61 | 62 | ##### [Fetch Stream](https://codesandbox.io/s/w0l01oopl5) 63 | > Using [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [range headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) to decrypt files from amazon s3 64 | 65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rclone", 3 | "version": "0.0.0-development", 4 | "description": "Decrypt filenames in the browser", 5 | "main": "dist/index.js", 6 | "module": "src/index.js", 7 | "author": "Fabrice Weinberg ", 8 | "license": "MIT", 9 | "files": [ 10 | "dist", 11 | "src", 12 | "README.md" 13 | ], 14 | "devDependencies": { 15 | "@babel/cli": "^7.5.5", 16 | "@babel/core": "^7.5.5", 17 | "@babel/preset-env": "^7.5.5", 18 | "babel-jest": "^24.8.0", 19 | "babel-loader": "^8.0.6", 20 | "codecov": "^3.5.0", 21 | "commitizen": "^4.0.3", 22 | "cz-conventional-changelog": "^3.0.2", 23 | "ghooks": "^2.0.4", 24 | "jest": "^24.8.0", 25 | "npm-run-all": "^4.1.5", 26 | "rimraf": "^2.6.3", 27 | "semantic-release": "^15.13.19", 28 | "stream-chunker": "^1.2.8", 29 | "stream-equal": "^1.1.1", 30 | "uglifyjs-webpack-plugin": "^2.2.0", 31 | "webpack": "^4.39.1", 32 | "webpack-bundle-analyzer": "^3.4.1", 33 | "webpack-cli": "^3.3.6", 34 | "webpack-dev-server": "^3.8.0" 35 | }, 36 | "dependencies": { 37 | "aes-js": "^3.1.2", 38 | "base32-decode": "^1.0.0", 39 | "base32-encode": "^1.1.1", 40 | "scrypt-js": "^2.0.4", 41 | "through2": "^3.0.1", 42 | "tweetnacl": "^1.0.1" 43 | }, 44 | "scripts": { 45 | "commit": "git-cz", 46 | "prebuild": "rimraf dist", 47 | "build": "npm-run-all --parallel build:*", 48 | "build:main": "NODE_ENV=production babel --out-dir dist --ignore *.test.js src", 49 | "build:umd": "webpack-cli --output-filename rclone.umd.js", 50 | "build:umd.min": "webpack-cli -p --output-filename rclone.umd.min.js", 51 | "build:path.umd.min": "webpack-cli -p --entry ./src/ciphers/PathCipher.js --output-library PathCipher --output-filename rclone.pathcipher.min.js", 52 | "test": "jest", 53 | "dev": "webpack-dev-server", 54 | "semantic-release": "semantic-release" 55 | }, 56 | "jest": { 57 | "rootDir": "src", 58 | "collectCoverage": true, 59 | "coverageReporters": [ 60 | "lcov", 61 | "html" 62 | ] 63 | }, 64 | "babel": { 65 | "presets": [ 66 | "@babel/preset-env" 67 | ] 68 | }, 69 | "config": { 70 | "commitizen": { 71 | "path": "node_modules/cz-conventional-changelog" 72 | }, 73 | "ghooks": { 74 | "pre-commit": "npm run test" 75 | } 76 | }, 77 | "repository": { 78 | "type": "git", 79 | "url": "https://github.com/FWeinb/rclone-js.git" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`provide access to file and path ciphers 1`] = ` 4 | Object { 5 | "File": Object { 6 | "calculateDecryptedSize": [Function], 7 | "createReadStream": [Function], 8 | "createReadStreamFactory": [Function], 9 | }, 10 | "Path": Object { 11 | "decrypt": [Function], 12 | "decryptName": [Function], 13 | "encrypt": [Function], 14 | "encryptName": [Function], 15 | }, 16 | "getKeys": [Function], 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /src/__snapshots__/rclone.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`both password and salt must be passed: password and salt 1`] = `[Error: Both password and salt must be specified]`; 4 | 5 | exports[`derive keys from password: empty key rclone 1`] = ` 6 | Object { 7 | "dataKey": Uint8Array [ 8 | 141, 9 | 221, 10 | 255, 11 | 96, 12 | 210, 13 | 69, 14 | 65, 15 | 244, 16 | 71, 17 | 146, 18 | 103, 19 | 45, 20 | 248, 21 | 199, 22 | 117, 23 | 104, 24 | 63, 25 | 158, 26 | 208, 27 | 37, 28 | 247, 29 | 132, 30 | 60, 31 | 19, 32 | 21, 33 | 123, 34 | 90, 35 | 106, 36 | 38, 37 | 191, 38 | 116, 39 | 151, 40 | ], 41 | "nameKey": Uint8Array [ 42 | 7, 43 | 70, 44 | 145, 45 | 143, 46 | 237, 47 | 130, 48 | 150, 49 | 239, 50 | 150, 51 | 203, 52 | 18, 53 | 140, 54 | 247, 55 | 244, 56 | 186, 57 | 113, 58 | 243, 59 | 124, 60 | 105, 61 | 76, 62 | 228, 63 | 36, 64 | 165, 65 | 200, 66 | 252, 67 | 250, 68 | 206, 69 | 192, 70 | 108, 71 | 133, 72 | 242, 73 | 224, 74 | ], 75 | "nameTweak": Uint8Array [ 76 | 175, 77 | 72, 78 | 94, 79 | 105, 80 | 195, 81 | 20, 82 | 108, 83 | 175, 84 | 135, 85 | 45, 86 | 240, 87 | 211, 88 | 154, 89 | 221, 90 | 78, 91 | 93, 92 | ], 93 | "password": "UmyLSdRHfew6aual28-ggx78qHqSfQ", 94 | "salt": "Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw", 95 | } 96 | `; 97 | -------------------------------------------------------------------------------- /src/ciphers/FileCipher.js: -------------------------------------------------------------------------------- 1 | import tweetncal from 'tweetnacl'; 2 | import through2 from 'through2'; 3 | 4 | import PushStream from './PushStream'; 5 | 6 | const { secretbox } = tweetncal; 7 | 8 | import { 9 | keySize, 10 | fileMagic, 11 | fileMagicSize, 12 | fileNonceSize, 13 | fileHeaderSize, 14 | blockHeaderSize, 15 | blockDataSize, 16 | blockSize 17 | } from '../constants'; 18 | 19 | function FileCipher({ dataKey } = {}) { 20 | if (dataKey === undefined) { 21 | throw new Error('dataKey must be specified'); 22 | } 23 | 24 | function createReadStreamFactory(createReadStream) { 25 | return createReadStreamFactoryInternal(createReadStream, dataKey); 26 | } 27 | function createReadStream(createReadStream, opts) { 28 | return createReadStreamFactory(createReadStream)(opts); 29 | } 30 | 31 | return { 32 | createReadStream, 33 | createReadStreamFactory, 34 | calculateDecryptedSize 35 | }; 36 | } 37 | 38 | FileCipher.blockSize = blockSize; 39 | 40 | export default FileCipher; 41 | 42 | function createReadStreamFactoryInternal(getEncryptedStream, key, factOpts) { 43 | const blockMulitples = (factOpts && factOpts.blockMulitples) || 16; 44 | const noncePromise = loadNonce(getEncryptedStream); 45 | // Decrypted Stream 46 | return opts => { 47 | const start = (opts && opts.start) || 0; 48 | 49 | let blockOffset = Math.floor(start / blockDataSize); 50 | let offsetInBlock = start % blockDataSize; 51 | 52 | const offset = fileHeaderSize + blockOffset * blockSize; 53 | 54 | // The Encrytped stream must be read 55 | const encrytpedStream = getEncryptedStream({ 56 | start: offset, 57 | chunkSize: blockSize * blockMulitples // default 16; 58 | }); 59 | 60 | return new PushStream(next => { 61 | noncePromise 62 | .then(initalNonce => { 63 | // Ensure that initalNonce will not be modfied 64 | const nonce = initalNonce.slice(); 65 | 66 | // Advance the nonce to the blockOffset 67 | incrementNonceBy(nonce, blockOffset); 68 | 69 | encrytpedStream 70 | // Create a decryptor for this key and nonce 71 | .pipe(createCipher(key, nonce)) 72 | .on('data', data => { 73 | if (offsetInBlock !== 0) { 74 | data = data.subarray(offsetInBlock); 75 | offsetInBlock = 0; 76 | } 77 | next(null, data); 78 | }) 79 | .on('error', err => { 80 | next(err, null); 81 | }) 82 | .on('end', () => { 83 | next(null, null); 84 | }); 85 | }) 86 | .catch(err => { 87 | next(err, null); 88 | }); 89 | }); 90 | }; 91 | } 92 | 93 | function loadNonce(getEncryptedStream, key) { 94 | return new Promise((resolve, reject) => { 95 | const stream = getEncryptedStream({ 96 | start: 0, 97 | end: fileHeaderSize, 98 | chunkSize: fileHeaderSize 99 | }).once('data', data => { 100 | const magic = data 101 | .subarray(0, fileMagicSize) 102 | .reduce((acc, i) => acc + String.fromCharCode(i), ''); 103 | 104 | // Test if this is a valid rClone file 105 | if (magic !== fileMagic) { 106 | reject(new Error('Magic is wrong')); 107 | } 108 | 109 | const initalNonce = data.subarray(fileMagicSize); 110 | 111 | stream.destroy(); 112 | 113 | // Resolve 114 | resolve(initalNonce); 115 | }); 116 | }); 117 | } 118 | 119 | // Operation on multiples of blockSize chunks 120 | function createCipher(key, nonce) { 121 | return through2((data, enc, next) => { 122 | // If there is no content we can stop reading 123 | if (data.length === 0) { 124 | return next(null, null); 125 | } 126 | // Size of the decrypted data 127 | const decryptedSize = calculateDecryptedSize(data.length) + fileHeaderSize; 128 | 129 | let encryptedOffset = 0; 130 | let decryptedOffset = 0; 131 | do { 132 | // Read a encrypted block from the data array 133 | const end = encryptedOffset + blockSize; 134 | const part = data.subarray(encryptedOffset, end); 135 | // Decrypt it 136 | let decrypted = secretbox.open(part, nonce, key); 137 | if (decrypted == null) { 138 | return next(new Error('Could not decrypt data'), null); 139 | } 140 | // Advance Nonce 141 | incrementNonce(nonce); 142 | 143 | // Align the decrypted data in the data array 144 | data.set(decrypted, decryptedOffset); 145 | 146 | // Advance both offsets 147 | decryptedOffset += blockDataSize; 148 | encryptedOffset = end; 149 | 150 | // Do we need to decrypt more 151 | } while (decryptedOffset < decryptedSize); 152 | 153 | next(null, data.subarray(0, decryptedSize)); 154 | }); 155 | } 156 | 157 | function calculateDecryptedSize(size) { 158 | size = size - fileHeaderSize; 159 | const blocks = Math.floor(size / blockSize); 160 | const decryptedSize = blocks * blockDataSize; 161 | let residue = size % blockSize; 162 | if (residue !== 0) { 163 | residue -= blockHeaderSize; 164 | } 165 | return decryptedSize + residue; 166 | } 167 | 168 | // This will break for x > 2^53 because Javascript can't 169 | // represent these numbers... 170 | function incrementNonceBy(nonce, x) { 171 | if (x <= 0) return; 172 | let carry = 0; 173 | for (let i = 0; i < 8; i++) { 174 | const digit = nonce[i]; 175 | const xDigit = x & 0xff; 176 | x = x >> 8; 177 | carry = carry + (digit & 0xffff) + (xDigit & 0xffff); 178 | nonce[i] = carry & 0xff; 179 | carry = carry >> 8; 180 | } 181 | if (carry != 0) { 182 | incrementNonce(nounce, 8); 183 | } 184 | } 185 | 186 | function incrementNonce(nonce, i = 0) { 187 | for (; i < nonce.length; i++) { 188 | const digit = nonce[i]; 189 | nonce[i] = digit + 1; 190 | if (nonce[i] >= digit) { 191 | break; 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/ciphers/FileCipher.test.js: -------------------------------------------------------------------------------- 1 | import FileCipher from './FileCipher'; 2 | import { Rclone } from '../rclone'; 3 | 4 | import fs from 'fs'; 5 | import chunker from 'stream-chunker'; 6 | import streamEqual from 'stream-equal'; 7 | 8 | const key = { 9 | // password: 'UmyLSdRHfew6aual28-ggx78qHqSfQ', 10 | // salt: 'Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw' 11 | // prettier-ignore 12 | dataKey: new Uint8Array([ 13 | 141, 221, 255, 96, 210, 69, 65, 244, 71, 146, 103, 14 | 45, 248, 199, 117, 104, 63, 158, 208, 37, 247, 132, 15 | 60, 19, 21, 123, 90, 106, 38, 191, 116, 151 16 | ]) 17 | }; 18 | 19 | const cipher = FileCipher(key); 20 | 21 | test('dataKey is needed', () => { 22 | expect(() => { 23 | FileCipher(); 24 | }).toThrowErrorMatchingSnapshot(); 25 | }); 26 | 27 | test('decrypt full stream', done => { 28 | const decryptedStream = cipher.createReadStream( 29 | createFielStreamFactory(getPath('test')) 30 | ); 31 | 32 | const compareStream = fs.createReadStream(getPath('test.png')); 33 | 34 | streamEqual(decryptedStream, compareStream, (err, equal) => { 35 | if (err) done.fail(err); 36 | expect(equal).toBeTruthy(); 37 | done(); 38 | }); 39 | }); 40 | 41 | test('provide random access in stream', done => { 42 | const randomAccess = { 43 | start: 200 44 | }; 45 | 46 | const decryptedStream = cipher.createReadStream( 47 | createFielStreamFactory(getPath('test')), 48 | randomAccess 49 | ); 50 | 51 | const compareStream = fs.createReadStream(getPath('test.png'), randomAccess); 52 | 53 | streamEqual(decryptedStream, compareStream, (err, equal) => { 54 | if (err) done.fail(err); 55 | expect(equal).toBeTruthy(); 56 | done(); 57 | }); 58 | }); 59 | 60 | test('random access over block boundary', done => { 61 | const randomAccess = { 62 | start: FileCipher.blockSize + 20 63 | }; 64 | const decryptedStream = cipher.createReadStream( 65 | createFielStreamFactory(getPath('nonceTest')), 66 | randomAccess 67 | ); 68 | 69 | const compareStream = fs.createReadStream( 70 | getPath('nonceTest.decrypted'), 71 | randomAccess 72 | ); 73 | 74 | streamEqual(decryptedStream, compareStream, (err, equal) => { 75 | if (err) done.fail(err); 76 | expect(equal).toBeTruthy(); 77 | done(); 78 | }); 79 | }); 80 | 81 | test('fail on wrong magic word', done => { 82 | cipher 83 | .createReadStream(createFielStreamFactory(getPath('test.png'))) 84 | .once('data', data => { 85 | done.fail("We don't want data to be read here"); 86 | }) 87 | .once('error', err => { 88 | expect(err).toMatchSnapshot(); 89 | done(); 90 | }); 91 | }); 92 | 93 | test('fail if decryption is not possible', done => { 94 | const wrongCipher = FileCipher({ 95 | dataKey: new Uint8Array(32) 96 | }); 97 | wrongCipher 98 | .createReadStream(createFielStreamFactory(getPath('test'))) 99 | .once('data', data => { 100 | done.fail("We don't want data to be read here"); 101 | }) 102 | .once('error', err => { 103 | expect(err).toMatchSnapshot(); 104 | done(); 105 | }); 106 | }); 107 | 108 | test('calculate decrpyted size', () => { 109 | // prettier-ignore 110 | const cases = [ 111 | [0, 32], 112 | [1, 32 + 16 + 1], 113 | [65536, 32 + 16 + 65536], 114 | [65537, 32 + 16 + 65536 + 16 + 1], 115 | [1 << 30, 32 + 16384 * (16 + 65536)], 116 | [1 << 20, 32 + 16 * (16 + 65536)], 117 | [(1 << 20) + 65535, 32 + 16*(16+65536) + 16 + 65535], 118 | // This is to big for JS 119 | // [(1 << 40) + 1, 32 + 16777216 * (16 + 65536) + 16 + 1] 120 | ]; 121 | 122 | cases.forEach(item => { 123 | expect(cipher.calculateDecryptedSize(item[1])).toEqual(item[0]); 124 | }); 125 | }); 126 | 127 | function createFielStreamFactory(url) { 128 | return opts => 129 | fs 130 | .createReadStream(url, opts) 131 | .pipe(chunker(opts.chunkSize, { flush: true })); 132 | } 133 | 134 | function getPath(name) { 135 | return __dirname + '/__fixtures__/FileCipher/' + name; 136 | } 137 | -------------------------------------------------------------------------------- /src/ciphers/PathCipher.js: -------------------------------------------------------------------------------- 1 | import { AES, padding } from 'aes-js'; 2 | import { default as decodeBase32 } from 'base32-decode'; 3 | import { default as encodeBase32 } from 'base32-encode'; 4 | import { Decrypt, Encrypt } from './eme'; 5 | import { TextDecoder, TextEncoder } from './text-encoding'; 6 | 7 | const { pkcs7 } = padding; 8 | 9 | const decodeUTF8 = (() => { 10 | const decoder = new TextDecoder('utf-8'); 11 | return data => decoder.decode(new Uint8Array(data)); 12 | })(); 13 | 14 | const encodeUTF8 = (() => { 15 | const encoder = new TextEncoder('utf-8'); 16 | return data => encoder.encode(data); 17 | })(); 18 | 19 | export default function PathCipher({ nameKey, nameTweak } = {}) { 20 | if (nameKey === undefined || nameTweak === undefined) { 21 | throw new Error('nameKey and nameTweak must be specified'); 22 | } 23 | // Name Cipher Fuctions 24 | const nameCipher = new AES(nameKey); 25 | 26 | function encryptName(name) { 27 | const ciphertext = encodeUTF8(name); 28 | const paddedCipherText = pkcs7.pad(ciphertext); 29 | const rawCipherText = Encrypt(nameCipher, nameTweak, paddedCipherText); 30 | 31 | let encodedCipher = encodeBase32(rawCipherText, 'RFC4648-HEX'); 32 | return encodedCipher.replace(/=+$/, '').toLowerCase(); 33 | } 34 | 35 | function encrypt(path) { 36 | return path 37 | .split('/') 38 | .map(encryptName) 39 | .join('/'); 40 | } 41 | function decryptName(name) { 42 | const rawCipherText = new Uint8Array( 43 | decodeBase32(name.toUpperCase(), 'RFC4648-HEX') 44 | ); 45 | const paddedPlaintext = Decrypt(nameCipher, nameTweak, rawCipherText); 46 | return decodeUTF8(pkcs7.strip(paddedPlaintext)); 47 | } 48 | 49 | function decrypt(path) { 50 | return path 51 | .split('/') 52 | .map(decryptName) 53 | .join('/'); 54 | } 55 | 56 | return { 57 | encryptName, 58 | decryptName, 59 | encrypt, 60 | decrypt 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/ciphers/PathCipher.test.js: -------------------------------------------------------------------------------- 1 | import PathCipher from './PathCipher'; 2 | 3 | test('both nameKey and nameTweak are need', () => { 4 | expect(() => { 5 | PathCipher(); 6 | }).toThrowErrorMatchingSnapshot(); 7 | }); 8 | 9 | test('shoud encrypt/decrypt file name', () => { 10 | const cases = [ 11 | ['es785ret6k0hje8fkrqmu9fdus', 'Hallo World'], 12 | ['p0e52nreeaj0a5ea7s64m4j72s', '1'], 13 | ['p0e52nreeaj0a5ea7s64m4j72s/l42g6771hnv3an9cgc8cr2n1ng', '1/12'], 14 | [ 15 | 'p0e52nreeaj0a5ea7s64m4j72s/l42g6771hnv3an9cgc8cr2n1ng/qgm4avr35m5loi1th53ato71v0', 16 | '1/12/123' 17 | ] 18 | ]; 19 | 20 | const cipher = PathCipher({ 21 | nameKey: new Uint8Array(32), 22 | nameTweak: new Uint8Array(16) 23 | }); 24 | 25 | cases.forEach(item => { 26 | expect(cipher.decrypt(item[0])).toEqual(item[1]); 27 | }); 28 | 29 | cases.forEach(item => { 30 | expect(cipher.encrypt(item[1])).toEqual(item[0]); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/ciphers/PushStream.js: -------------------------------------------------------------------------------- 1 | import Readable from 'readable-stream'; 2 | 3 | export class PushStream extends Readable { 4 | constructor(init) { 5 | super(); 6 | this._next = this._next.bind(this); 7 | init(this._next); 8 | } 9 | 10 | _next(err, data) { 11 | if (this._destroyed) return; 12 | if (err) return this.destroy(err); 13 | if (data === null) return this.push(null); 14 | this._reading = false; 15 | this.push(data); 16 | } 17 | _read(size) {} 18 | 19 | destroy(err) { 20 | if (this._destroyed) return; 21 | this._destroyed = true; 22 | setTimeout(() => { 23 | if (err) this.emit('error', err); 24 | this.emit('close'); 25 | }); 26 | } 27 | } 28 | 29 | export default PushStream; 30 | -------------------------------------------------------------------------------- /src/ciphers/__fixtures__/FileCipher/nonceTest: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FWeinb/rclone-js/370a8e255d7ce033f9aa1ad47ae26fc6d12abfae/src/ciphers/__fixtures__/FileCipher/nonceTest -------------------------------------------------------------------------------- /src/ciphers/__fixtures__/FileCipher/nonceTest.decrypted: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ciphers/__fixtures__/FileCipher/test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FWeinb/rclone-js/370a8e255d7ce033f9aa1ad47ae26fc6d12abfae/src/ciphers/__fixtures__/FileCipher/test -------------------------------------------------------------------------------- /src/ciphers/__fixtures__/FileCipher/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FWeinb/rclone-js/370a8e255d7ce033f9aa1ad47ae26fc6d12abfae/src/ciphers/__fixtures__/FileCipher/test.png -------------------------------------------------------------------------------- /src/ciphers/__snapshots__/FileCipher.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`dataKey is needed 1`] = `"dataKey must be specified"`; 4 | 5 | exports[`fail if decryption is not possible 1`] = `[Error: Could not decrypt data]`; 6 | 7 | exports[`fail on wrong magic word 1`] = `[Error: Magic is wrong]`; 8 | -------------------------------------------------------------------------------- /src/ciphers/__snapshots__/PathCipher.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`both nameKey and nameTweak are need 1`] = `"nameKey and nameTweak must be specified"`; 4 | -------------------------------------------------------------------------------- /src/ciphers/eme/index.js: -------------------------------------------------------------------------------- 1 | // Javascript port of https://github.com/rfjakob/eme/blob/master/eme.go by rfjakob 2 | 3 | // EME is a wide-block encryption mode developed by Halevi and Rogaway. 4 | const DirectionEncrypt = 0; 5 | const DirectionDecrypt = 1; 6 | 7 | function multByTwo(out, input) { 8 | if (input.length !== 16) { 9 | throw new Error('Invalid length'); 10 | } 11 | let last = input[0]; 12 | 13 | out[0] = (2 * input[0]) & 0xff; 14 | if (input[15] >= 128) { 15 | out[0] ^= 135; 16 | } 17 | for (let j = 1; j < 16; j++) { 18 | const tmp = input[j]; 19 | out[j] = (2 * input[j]) & 0xff; 20 | if (last >= 128) { 21 | out[j] = (out[j] + 1) & 0xff; 22 | } 23 | last = tmp; 24 | } 25 | } 26 | 27 | function xorBlocks(out, in1, in2) { 28 | if (in1.length !== in2.length && in2.length !== out.length) { 29 | throw new Error('Length must all match'); 30 | } 31 | 32 | for (let i = 0; i < in1.length; i++) { 33 | out[i] = in1[i] ^ in2[i]; 34 | } 35 | } 36 | 37 | function tabulateL(bc, m) { 38 | let Li = bc.encrypt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); 39 | const LTable = new Array(m); 40 | for (let i = 0; i < m; i++) { 41 | multByTwo(Li, Li); 42 | LTable[i] = Li.slice(); 43 | } 44 | return LTable; 45 | } 46 | 47 | function aesTransform(src, direction, bc) { 48 | if (direction === DirectionEncrypt) { 49 | return bc.encrypt(src); 50 | } else if (direction === DirectionDecrypt) { 51 | const rs = bc.decrypt(src); 52 | return rs; 53 | } 54 | } 55 | 56 | function Transform(bc, tweak, inputData, direction) { 57 | // In the paper, the tweak is just called "T". Call it the same here to 58 | // make following the paper easy. 59 | const T = tweak; // in bytes 60 | 61 | // In the paper, the plaintext data is called "P" and the ciphertext is 62 | // called "C". Because encryption and decryption are virtually indentical, 63 | // we share the code and always call the input data "P" and the output data 64 | // "C", regardless of the direction. 65 | const P = inputData; // in bytes 66 | 67 | if (T.length !== 16) { 68 | throw new Error('Tweak must be 16 bytes long'); 69 | } 70 | if (P.length % 16 !== 0) { 71 | throw new Error('Input Data must be a multiple of 16 long'); 72 | } 73 | 74 | const m = P.length / 16; 75 | if (m === 0 || m > 128) { 76 | throw new Error( 77 | 'EME operates on 1 to 128 block-cipher blocks, you passed ' + m 78 | ); 79 | } 80 | 81 | // Result 82 | const C = new Uint8Array(P.length); // in bytes 83 | const LTable = tabulateL(bc, m); // in bytes 84 | 85 | const PPj = new Uint8Array(16); // in bytes 86 | for (let j = 0; j < m; j++) { 87 | let Pj = P.slice(j * 16, (j + 1) * 16); 88 | /* PPj = 2**(j-1)*L xor Pj */ 89 | xorBlocks(PPj, Pj, LTable[j]); 90 | 91 | /* PPPj = AESenc(K; PPj) */ 92 | C.set(aesTransform(PPj, direction, bc), j * 16); 93 | } 94 | 95 | /* MP =(xorSum PPPj) xor T */ 96 | const MP = new Uint8Array(16); 97 | xorBlocks(MP, C.subarray(0, 16), T); 98 | for (let j = 1; j < m; j++) { 99 | xorBlocks(MP, MP, C.subarray(j * 16, (j + 1) * 16)); 100 | } 101 | /* MC = AESenc(K; MP) */ 102 | const MC = aesTransform(MP, direction, bc); 103 | 104 | /* M = MP xor MC */ 105 | const M = new Uint8Array(16); 106 | xorBlocks(M, MP, MC); 107 | const CCCj = new Uint8Array(16); 108 | for (let j = 1; j < m; j++) { 109 | multByTwo(M, M); 110 | /* CCCj = 2**(j-1)*M xor PPPj */ 111 | xorBlocks(CCCj, C.subarray(j * 16, (j + 1) * 16), M); 112 | C.set(CCCj, j * 16); 113 | } 114 | 115 | /* CCC1 = (xorSum CCCj) xor T xor MC */ 116 | const CCC1 = new Uint8Array(16); 117 | xorBlocks(CCC1, MC, T); 118 | for (let j = 1; j < m; j++) { 119 | xorBlocks(CCC1, CCC1, C.subarray(j * 16, (j + 1) * 16)); 120 | } 121 | C.set(CCC1, 0); 122 | 123 | for (let j = 0; j < m; j++) { 124 | /* CCj = AES-enc(K; CCCj) */ 125 | C.set( 126 | aesTransform(C.subarray(j * 16, (j + 1) * 16), direction, bc), 127 | j * 16 128 | ); 129 | 130 | const tmp = C.subarray(j * 16, (j + 1) * 16); 131 | /* Cj = 2**(j-1)*L xor CCj */ 132 | xorBlocks(tmp, tmp, LTable[j]); 133 | C.set(tmp, j * 16); 134 | } 135 | return C; 136 | } 137 | 138 | export function Encrypt(bc, tweak, data) { 139 | return Transform(bc, tweak, data, DirectionEncrypt); 140 | } 141 | export function Decrypt(bc, tweak, data) { 142 | return Transform(bc, tweak, data, DirectionDecrypt); 143 | } 144 | -------------------------------------------------------------------------------- /src/ciphers/text-encoding.js: -------------------------------------------------------------------------------- 1 | let encoder, decoder; 2 | if (typeof module === 'object' && module.exports) { 3 | encoder = require('ut' + 'il').TextEncoder; 4 | decoder = require('ut' + 'il').TextDecoder; 5 | } 6 | if (typeof TextDecoder === 'function') { 7 | decoder = TextDecoder; 8 | encoder = TextEncoder; 9 | } 10 | 11 | module.exports = { 12 | ['TextEncoder']: encoder, 13 | ['TextDecoder']: decoder 14 | }; 15 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // Constants 2 | export const keySize = 32 + 32 + 16; 3 | 4 | // File Constants 5 | export const fileMagic = 'RCLONE\x00\x00'; 6 | export const fileMagicSize = fileMagic.length; 7 | export const fileNonceSize = 24; 8 | export const fileHeaderSize = fileMagicSize + fileNonceSize; 9 | export const blockHeaderSize = 16; // crypto_secretbox_BOXZEROBYTES 10 | export const blockDataSize = 64 * 1024; 11 | export const blockSize = blockHeaderSize + blockDataSize; 12 | export const defaultSalt = [0xa8, 0x0d, 0xf4, 0x3a, 0x8f, 0xbd, 0x03, 0x08, 0xa7, 0xca, 0xb8, 0x3e, 0x58, 0x1f, 0x86, 0xb1]; 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Simple Interface to rclone 2 | import { Rclone as RcloneInternal } from './rclone'; 3 | import FileCipher from './ciphers/FileCipher'; 4 | import PathCipher from './ciphers/PathCipher'; 5 | 6 | export function Rclone(opts) { 7 | return RcloneInternal(opts).then(keys => ({ 8 | getKeys: () => keys, 9 | File: FileCipher(keys), 10 | Path: PathCipher(keys) 11 | })); 12 | } 13 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import { Rclone } from './index'; 2 | 3 | test('provide access to file and path ciphers', done => { 4 | Rclone({ 5 | password: '', 6 | salt: '' 7 | }).then(rclone => { 8 | expect(rclone).toMatchSnapshot(); 9 | done(); 10 | }); 11 | }); 12 | 13 | test('should return keys using getKeys()', done => { 14 | Rclone({ password: '', salt: '' }).then(rclone => { 15 | const keys = rclone.getKeys(); 16 | expect(keys.dataKey).toBeInstanceOf(Uint8Array); 17 | expect(keys.nameKey).toBeInstanceOf(Uint8Array); 18 | expect(keys.nameTweak).toBeInstanceOf(Uint8Array); 19 | expect(keys.password).toEqual(''); 20 | expect(keys.salt).toEqual(''); 21 | done(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/rclone.js: -------------------------------------------------------------------------------- 1 | import scrypt from 'scrypt-js'; 2 | import { reveal } from './reveal.js'; 3 | 4 | import { 5 | keySize, 6 | fileMagic, 7 | fileMagicSize, 8 | fileNonceSize, 9 | fileHeaderSize, 10 | blockHeaderSize, 11 | blockDataSize, 12 | blockSize, 13 | defaultSalt 14 | } from './constants'; 15 | 16 | function Rclone({ password, salt } = {}) { 17 | return new Promise((resolve, reject) => { 18 | if (password === undefined || salt === undefined) { 19 | reject(new Error('Both password and salt must be specified')); 20 | } 21 | try { 22 | generateKeys(password, salt, (error, keys) => { 23 | resolve(fromKeys(keys)); 24 | }); 25 | } catch (e) { 26 | reject(e); 27 | } 28 | }); 29 | } 30 | 31 | function fromKeys(keys) { 32 | // Streaming file decryptor 33 | // Takes a function createReadStream function which represents the 34 | // encrypted file. 35 | // 36 | // createReadStream(options) 37 | // Options: 38 | // start: Start offset where the stream needs to beginn 39 | // chunkSize: needs to be a multiple of createReadStreamFactory.blockSize 40 | function createReadStreamFactory(createReadStream) { 41 | return createReadStreamFactoryInternal(createReadStream, keys.dataKey); 42 | } 43 | 44 | createReadStreamFactory.chunkSize = blockSize; 45 | 46 | return keys; 47 | } 48 | 49 | export { Rclone }; 50 | 51 | // pass and salt are still encrypted with the rclone config encryption 52 | function generateKeys(encPass, encSalt, callback) { 53 | const password = reveal(encPass); 54 | const decryptedSalt = reveal(encSalt); 55 | const salt = decryptedSalt.length ? decryptedSalt : defaultSalt; 56 | 57 | if (password.length === 0) { 58 | // Empty key for testing 59 | callback( 60 | null, 61 | createKeysFromKey(encPass, encSalt, new Array(keySize).fill(0)) 62 | ); 63 | } else { 64 | scrypt(password, salt, 16384, 8, 1, keySize, (error, progress, key) => { 65 | if (error) callback(error, null); 66 | if (key) { 67 | callback(null, createKeysFromKey(encPass, encSalt, key)); 68 | } 69 | }); 70 | } 71 | } 72 | 73 | function createKeysFromKey(encPass, encSalt, key) { 74 | return { 75 | password: encPass, 76 | salt: encSalt, 77 | dataKey: new Uint8Array(key.slice(0, 32)), 78 | nameKey: new Uint8Array(key.slice(32, 64)), 79 | nameTweak: new Uint8Array(key.slice(64)) 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/rclone.test.js: -------------------------------------------------------------------------------- 1 | import { Rclone } from './rclone'; 2 | import {defaultSalt} from './constants'; 3 | import PathCipher from './ciphers/PathCipher'; 4 | 5 | test('both password and salt must be passed', done => { 6 | Rclone({ 7 | password: 'hello' 8 | }).catch(err => expect(err).toMatchSnapshot('password and salt'), done()); 9 | }); 10 | 11 | test('derive keys from password', done => { 12 | Rclone({ 13 | password: 'UmyLSdRHfew6aual28-ggx78qHqSfQ', 14 | salt: 'Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw' 15 | }) 16 | .then(rclone => { 17 | expect(rclone).toMatchSnapshot('empty key rclone'); 18 | done(); 19 | }) 20 | .catch(err => done.fail(err)); 21 | }); 22 | 23 | test('use default salt when empty', done => { 24 | // Generated encrypted string with the use of rclone and empty salt 25 | const rcloneEncryptedFileName = 'ecrk8fu3e0pk86td3r634nan08'; 26 | const rcloneDecryptedFileName = 'encrypted_file' 27 | 28 | Rclone({ 29 | password: 'UmyLSdRHfew6aual28-ggx78qHqSfQ', 30 | salt: '' 31 | }) 32 | .then(rclone => { 33 | const pathCipher = PathCipher(rclone); 34 | const decryptedString = pathCipher.decrypt(rcloneEncryptedFileName); 35 | 36 | expect(decryptedString).toEqual(rcloneDecryptedFileName); 37 | done(); 38 | }) 39 | .catch(err => done.fail(err)); 40 | }); 41 | -------------------------------------------------------------------------------- /src/reveal.js: -------------------------------------------------------------------------------- 1 | import AES from 'aes-js'; 2 | const { ctr } = AES.ModeOfOperation; 3 | 4 | // prettier-ignore 5 | // https://github.com/ncw/rclone/blob/a3759921863f5b1c7169464170de03f47c34e0a7/fs/config/obscure/obscure.go#L17-L22 6 | const key = [ 7 | 0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d, 8 | 0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xa9, 0x2b, 9 | 0xd3, 0x90, 0x19, 0x8e, 0xb8, 0x12, 0x8a, 0xfb, 10 | 0xf4, 0xde, 0x16, 0x2b, 0x8b, 0x95, 0xf6, 0x38, 11 | ] 12 | 13 | let ctrCipher; // Singelton 14 | function createCipher() { 15 | if (ctrCipher) return ctrCipher; 16 | // Key extracted from rclone 17 | return (ctrCipher = new ctr(key, 0)); 18 | } 19 | 20 | function reveal(cipherText) { 21 | const blockCipher = createCipher(); 22 | 23 | cipherText = cipherText.replace(/-/g, '+').replace(/_/g, '/'); 24 | const bytes = base64(cipherText); 25 | 26 | const iv = bytes.subarray(0, 16); 27 | const buf = bytes.subarray(16); 28 | 29 | // I don't always want to create a new instance of 30 | // AES so I am reusing the ctr cipher and reseting it 31 | ctrCipher._counter._counter = iv; 32 | ctrCipher._remainingCounter = null; 33 | ctrCipher._remainingCounterIndex = 16; 34 | 35 | return ctrCipher.decrypt(buf); 36 | } 37 | 38 | export { reveal }; 39 | 40 | function base64(s) { 41 | if (s.length % 4 != 0) { 42 | s += '===='.substr(0, 4 - s.length % 4); 43 | } 44 | return new Uint8Array( 45 | atob2(s) 46 | .split('') 47 | .map(charCodeAt) 48 | ); 49 | } 50 | 51 | function atob2(data) { 52 | return typeof atob === 'function' 53 | ? atob(data) 54 | : Buffer.from(data, 'base64').toString('binary'); 55 | } 56 | 57 | function charCodeAt(c) { 58 | return c.charCodeAt(0); 59 | } 60 | -------------------------------------------------------------------------------- /src/reveal.test.js: -------------------------------------------------------------------------------- 1 | import { reveal } from './reveal'; 2 | 3 | test('should decrypt', () => { 4 | expect(reveal('UmyLSdRHfew6aual28-ggx78qHqSfQ')).toEqual( 5 | new Uint8Array([49, 50, 51, 52, 53, 54]) // 123456 6 | ); 7 | expect(reveal('Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw')).toEqual( 8 | new Uint8Array([54, 53, 52, 51, 50, 49]) // 654321 9 | ); 10 | }); 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const { join } = require('path'); 3 | 4 | const config = env => { 5 | return { 6 | mode: 'production', 7 | entry: './src/index.js', 8 | output: { 9 | path: join(__dirname, 'dist'), 10 | libraryTarget: 'umd', 11 | library: 'rclone' 12 | }, 13 | devtool: 'source-map', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | include: [join(__dirname, 'src')], 19 | use: { 20 | loader: 'babel-loader', 21 | options: { 22 | presets: ['@babel/preset-env'] 23 | } 24 | } 25 | } 26 | ] 27 | } 28 | }; 29 | }; 30 | 31 | module.exports = config; 32 | --------------------------------------------------------------------------------