From 0dbbb61d1175aab80b664400e1b3a258fad4588f Mon Sep 17 00:00:00 2001 From: El RIDO Date: Sat, 1 Sep 2018 19:42:22 +0200 Subject: [PATCH] implementing web crypto API for encryption --- .travis.yml | 6 +- composer.json | 4 +- js/common.js | 1 + js/package.json | 43 +++++++ js/privatebin.js | 266 +++++++++++++++++++++++++++++++++---------- js/test/CryptTool.js | 21 ++-- js/test/Helper.js | 9 +- tpl/bootstrap.php | 2 +- tpl/page.php | 2 +- tst/README.md | 6 + 10 files changed, 280 insertions(+), 80 deletions(-) create mode 100644 js/package.json diff --git a/.travis.yml b/.travis.yml index d368a0c..0584c72 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,13 +10,13 @@ php: # as this is a php project, node.js v4 (for JS unit testing) isn't installed install: - - if [ ! -d "$HOME/.nvm" ]; then mkdir -p $HOME/.nvm && curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | NVM_METHOD=script bash; fi - - source ~/.nvm/nvm.sh && nvm install 4 + - if [ ! -d "$HOME/.nvm" ]; then mkdir -p $HOME/.nvm && curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | NVM_METHOD=script bash; fi + - source ~/.nvm/nvm.sh && nvm install --lts before_script: - composer install -n - npm install -g mocha - - cd js && npm install jsverify jsdom@9 jsdom-global@2 mime-types + - cd js && npm install script: - mocha diff --git a/composer.json b/composer.json index 7f0bd45..2beddab 100644 --- a/composer.json +++ b/composer.json @@ -3,13 +3,13 @@ "description": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted in the browser using 256 bit AES in Galois Counter mode (GCM).", "type": "project", "keywords": ["private", "secure", "end-to-end-encrypted", "e2e", "paste", "pastebin", "zero", "zero-knowledge", "encryption", "encrypted", "AES"], - "homepage": "https://github.com/PrivateBin", + "homepage": "https://privatebin.info/", "license":"zlib-acknowledgement", "support": { "issues": "https://github.com/PrivateBin/PrivateBin/issues", "wiki": "https://github.com/PrivateBin/PrivateBin/wiki", "source": "https://github.com/PrivateBin/PrivateBin", - "docs": "https://zerobin.dssr.ch/documentation/" + "docs": "https://privatebin.info/codedoc/" }, "require": { "php": "^5.4.0 || ^7.0", diff --git a/js/common.js b/js/common.js index 6fc92d4..6aa43ba 100644 --- a/js/common.js +++ b/js/common.js @@ -6,6 +6,7 @@ global.jsc = require('jsverify'); global.jsdom = require('jsdom-global'); global.cleanup = global.jsdom(); global.fs = require('fs'); +global.WebCrypto = require('node-webcrypto-ossl'); // application libraries to test global.$ = global.jQuery = require('./jquery-3.3.1'); diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..7993fbc --- /dev/null +++ b/js/package.json @@ -0,0 +1,43 @@ +{ + "name": "privatebin", + "version": "1.2.1", + "description": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted in the browser using 256 bit AES in Galois Counter mode (GCM).", + "main": "privatebin.js", + "directories": { + "test": "test" + }, + "dependencies": {}, + "devDependencies": { + "jsdom": "^9.12.0", + "jsdom-global": "^2.1.1", + "jsverify": "^0.8.3", + "mime-types": "^2.1.20", + "node-webcrypto-ossl": "^1.0.37" + }, + "scripts": { + "test": "mocha" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/PrivateBin/PrivateBin.git" + }, + "keywords": [ + "private", + "secure", + "end-to-end-encrypted", + "e2e", + "paste", + "pastebin", + "zero", + "zero-knowledge", + "encryption", + "encrypted", + "AES" + ], + "author": "", + "license": "zlib-acknowledgement", + "bugs": { + "url": "https://github.com/PrivateBin/PrivateBin/issues" + }, + "homepage": "https://privatebin.info/" +} diff --git a/js/privatebin.js b/js/privatebin.js index 5e03dd9..05f8ec0 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -526,6 +526,30 @@ jQuery.PrivateBin = (function($, sjcl, RawDeflate) { var CryptTool = (function () { var me = {}; + /** + * convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString + * + * Iterates over the bytes of the message, converting them all hexadecimal + * percent encoded representations, then URI decodes them all + * + * @name CryptTool.btou + * @function + * @private + * @param {string} message UTF-8 string + * @return {string} UTF-16 string + */ + function btou(message) + { + return decodeURIComponent( + message.split('').map( + function(character) + { + return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2); + } + ).join('') + ); + } + /** * convert DOMString (UTF-16) to a UTF-8 string stored in a DOMString * @@ -550,27 +574,49 @@ jQuery.PrivateBin = (function($, sjcl, RawDeflate) { } /** - * convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString + * convert ArrayBuffer into a UTF-8 string * - * Iterates over the bytes of the message, converting them all hexadecimal - * percent encoded representations, then URI decodes them all + * Iterates over the bytes of the array, catenating them into a string * - * @name CryptTool.btou + * @name CryptTool.ArrToStr + * @function + * @private + * @param {ArrayBuffer} messageArray + * @return {string} message + */ + function ArrToStr(messageArray) + { + var array = new Uint8Array(messageArray), + len = array.length, + message = '', + i = 0; + while(i < len) { + var c = array[i++]; + message += String.fromCharCode(c); + } + return message; + } + + /** + * convert UTF-8 string into a Uint8Array + * + * Iterates over the bytes of the message, writing them to the array + * + * @name CryptTool.StrToArr * @function * @private * @param {string} message UTF-8 string - * @return {string} UTF-16 string + * @return {Uint8Array} array */ - function btou(message) + function StrToArr(message) { - return decodeURIComponent( - message.split('').map( - function(character) - { - return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2); - } - ).join('') - ); + var messageUtf8 = message, + messageLen = messageUtf8.length, + messageArray = new Uint8Array(messageLen); + for (var i = 0; i < messageLen; ++i) { + messageArray[i] = messageUtf8.charCodeAt(i); + } + return messageArray; } /** @@ -611,6 +657,42 @@ jQuery.PrivateBin = (function($, sjcl, RawDeflate) { } } + /** + * returns specified number of random bytes + * + * @name CryptTool.getRandomBytes + * @function + * @private + * @param {int} length number of random bytes to fetch + * @throws {string} + * @return {string} random bytes + */ + function getRandomBytes(length) + { + if ( + typeof window !== 'undefined' && + typeof Uint8Array !== 'undefined' && + String.fromCodePoint && + ( + typeof window.crypto !== 'undefined' || + typeof window.msCrypto !== 'undefined' + ) + ) { + // modern browser environment + var bytes = '', + byteArray = new Uint8Array(length), + crypto = window.crypto || window.msCrypto; + crypto.getRandomValues(byteArray); + for (var i = 0; i < length; ++i) { + bytes += String.fromCharCode(byteArray[i]); + } + return bytes; + } else { + // legacy browser or unsupported environment + throw 'No supported crypto API detected, you may read pastes and comments, but can\'t create pastes or add new comments.'; + } + }; + /** * compress, then encrypt message with given key and password * @@ -621,19 +703,68 @@ jQuery.PrivateBin = (function($, sjcl, RawDeflate) { * @param {string} message * @return {string} data - JSON with encrypted data */ - me.cipher = function(key, password, message) + me.cipher = async function(key, password, message) { - // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit - var options = { - mode: 'gcm', - ks: 256, - ts: 128 - }; + // AES in Galois Counter Mode, keysize 256 bit, authentication tag 128 bit, 10000 iterations in key derivation + var iv = getRandomBytes(16), + salt = getRandomBytes(8), + object = { + iv: btoa(iv), + v: 1, + iter: 10000, + ks: 256, + ts: 128, + mode: 'gcm', + adata: '', // if used, base64 encode it with btoa() + cipher: 'aes', + salt: btoa(salt) + }, + algo = 'AES-' + object.mode.toUpperCase(); - if ((password || '').trim().length === 0) { - return sjcl.encrypt(key, compress(message), options); + if ((password || '').trim().length > 0) { + key += sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)); } - return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), compress(message), options); + + // import raw key + var importedKey = await window.crypto.subtle.importKey( + 'raw', // only 'raw' is allowed + StrToArr(key), + {name: 'PBKDF2'}, // we use PBKDF2 for key derivation + false, // the key may not be exported + ["deriveKey"] // we may only use it for key derivation + ) + + // derive a stronger key for use with AES + var derivedKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', // we use PBKDF2 for key derivation + salt: StrToArr(atob(object.salt)), // salt used in HMAC + iterations: object.iter, // amount of iterations to apply + hash: {name: "SHA-256"}, // can be "SHA-1", "SHA-256", "SHA-384" or "SHA-512" + }, + importedKey, + { + // can be any supported AES algorithm ("AES-CTR", "AES-CBC", "AES-CMAC", "AES-GCM", "AES-CFB", "AES-KW", "ECDH", "DH" or "HMAC") + name: algo, + length: object.ks, // can be 128, 192 or 256 + }, + false, // the key may not be exported + ["encrypt"] // we may only use it for decryption + ) + + // finally, encrypt message + var encrypted = await window.crypto.subtle.encrypt( + { + // can be any supported AES algorithm ("AES-CTR", "AES-CBC", "AES-CMAC", "AES-GCM", "AES-CFB", "AES-KW", "ECDH", "DH" or "HMAC") + name: algo, + iv: StrToArr(atob(object.iv)), // the initialization vector you used to encrypt + additionalData: StrToArr(atob(object.adata)), // the addtional data you used during encryption (if any) + tagLength: object.ts, // the length of the tag you used to encrypt (if any) + }, + derivedKey, + StrToArr(compress(message)) // compressed plain text to encrypt + ) + return btoa(ArrToStr(encrypted)); }; /** @@ -646,18 +777,57 @@ jQuery.PrivateBin = (function($, sjcl, RawDeflate) { * @param {string} data - JSON with encrypted data * @return {string} decrypted message, empty if decryption failed */ - me.decipher = function(key, password, data) + me.decipher = async function(key, password, data) { - if (data !== undefined) { - try { - return decompress(sjcl.decrypt(key, data)); - } catch(err) { - try { - return decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data)); - } catch(e) { - return ''; - } + try { + if (password.length > 0) { + key += sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)); } + var object = JSON.parse(data), + algo = 'AES-' + object.mode.toUpperCase(); + + // import raw key + var importedKey = await window.crypto.subtle.importKey( + 'raw', // only 'raw' is allowed + StrToArr(key), + {name: 'PBKDF2'}, // we use PBKDF2 for key derivation + false, // the key may not be exported + ["deriveKey"] // we may only use it for key derivation + ) + + // derive a stronger key for use with AES + var derivedKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', // we use PBKDF2 for key derivation + salt: StrToArr(atob(object.salt)), // salt used in HMAC + iterations: object.iter, // amount of iterations to apply + hash: {name: "SHA-256"}, // can be "SHA-1", "SHA-256", "SHA-384" or "SHA-512" + }, + importedKey, + { + // can be any supported AES algorithm ("AES-CTR", "AES-CBC", "AES-CMAC", "AES-GCM", "AES-CFB", "AES-KW", "ECDH", "DH" or "HMAC") + name: algo, + length: object.ks, // can be 128, 192 or 256 + }, + false, // the key may not be exported + ["decrypt"] // we may only use it for decryption + ) + + // finally, decrypt message + var decrypted = await window.crypto.subtle.decrypt( + { + // can be any supported AES algorithm ("AES-CTR", "AES-CBC", "AES-CMAC", "AES-GCM", "AES-CFB", "AES-KW", "ECDH", "DH" or "HMAC") + name: algo, + iv: StrToArr(atob(object.iv)), // the initialization vector you used to encrypt + additionalData: StrToArr(atob(object.adata)), // the addtional data you used during encryption (if any) + tagLength: object.ts, // the length of the tag you used to encrypt (if any) + }, + derivedKey, + StrToArr(atob(object.ct)) // cipher text to decrypt + ) + return decompress(ArrToStr(decrypted)); + } catch(err) { + return ''; } }; @@ -673,33 +843,7 @@ jQuery.PrivateBin = (function($, sjcl, RawDeflate) { */ me.getSymmetricKey = function() { - var crypto, key; - if (typeof module !== 'undefined' && module.exports) { - // node environment - key = require('crypto').randomBytes(32).toString('base64'); - } else if ( - typeof window !== 'undefined' && - typeof Uint8Array !== 'undefined' && - String.fromCodePoint && - ( - typeof window.crypto !== 'undefined' || - typeof window.msCrypto !== 'undefined' - ) - ) { - // modern browser environment - var bytes = '', - byteArray = new Uint8Array(32), - crypto = window.crypto || window.msCrypto; - crypto.getRandomValues(byteArray); - for (var i = 0; i < 32; ++i) { - bytes += String.fromCharCode(byteArray[i]); - } - key = btoa(bytes); - } else { - // legacy browser or unsupported environment - throw 'No supported crypto API detected, you may read pastes and post comments, but can\'t create pastes.'; - } - return key; + return btoa(getRandomBytes(32)); }; return me; diff --git a/js/test/CryptTool.js b/js/test/CryptTool.js index 6666921..756c0e9 100644 --- a/js/test/CryptTool.js +++ b/js/test/CryptTool.js @@ -10,10 +10,12 @@ describe('CryptTool', function () { 'string', 'string', function (key, password, message) { + jsdom(); + window.crypto = new WebCrypto(); return message === $.PrivateBin.CryptTool.decipher( key, password, - $.PrivateBin.CryptTool.cipher(key, password, message) + $.PrivateBin.CryptTool.cipher(key, password, message.trim()) ); } ), @@ -25,15 +27,16 @@ describe('CryptTool', function () { // SJCL based pastes still works it( 'supports PrivateBin v1 ciphertext (SJCL & browser atob)', - function () { + async function () { delete global.Base64; // make btoa available jsdom(); global.btoa = window.btoa; + window.crypto = new WebCrypto(); // Of course you can easily decipher the following texts, if you like. // Bonus points for finding their sources and hidden meanings. - var paste1 = $.PrivateBin.CryptTool.decipher( + var paste1 = await $.PrivateBin.CryptTool.decipher( '6t2qsmLyfXIokNCL+3/yl15rfTUBQvm5SOnFPvNE7Q8=', // -- "That's amazing. I've got the same combination on my luggage." Array.apply(0, Array(6)).map(function(_,b) { return b + 1; }).join(''), @@ -65,7 +68,7 @@ describe('CryptTool', function () { 'C5fARPJ4F2BIWgzgzkNj+dVjusft2XnziamWdbS5u3kuRlVuz5LQj+R5' + 'imnqQAincdZTkTT1nYx+DatlOLllCYIHffpI="}' ), - paste2 = $.PrivateBin.CryptTool.decipher( + paste2 = await $.PrivateBin.CryptTool.decipher( 's9pmKZKOBN7EVvHpTA8jjLFH3Xlz/0l8lB4+ONPACrM=', '', // no password '{"iv":"WA42mdxIVXUwBqZu7JYNiw==","v":1,"iter":10000,"ks"' + @@ -101,12 +104,14 @@ describe('CryptTool', function () { it( 'supports ZeroBin ciphertext (SJCL & Base64 1.7)', - function () { + async function () { global.Base64 = require('../base64-1.7').Base64; + jsdom(); + window.crypto = new WebCrypto(); // Of course you can easily decipher the following texts, if you like. // Bonus points for finding their sources and hidden meanings. - var paste1 = $.PrivateBin.CryptTool.decipher( + var paste1 = await $.PrivateBin.CryptTool.decipher( '6t2qsmLyfXIokNCL+3/yl15rfTUBQvm5SOnFPvNE7Q8=', // -- "That's amazing. I've got the same combination on my luggage." Array.apply(0, Array(6)).map(function(_,b) { return b + 1; }).join(''), @@ -130,7 +135,7 @@ describe('CryptTool', function () { 'V37AeiNoD2PcI6ZcHbRdPa+XRrRcJhSPPW7UQ0z4OvBfjdu/w390QxAx' + 'SxvZewoh49fKKB6hTsRnZb4tpHkjlww=="}' ), - paste2 = $.PrivateBin.CryptTool.decipher( + paste2 = await $.PrivateBin.CryptTool.decipher( 's9pmKZKOBN7EVvHpTA8jjLFH3Xlz/0l8lB4+ONPACrM=', '', // no password '{"iv":"Z7lAZQbkrqGMvruxoSm6Pw==","v":1,"iter":10000,"ks"' + @@ -167,6 +172,8 @@ describe('CryptTool', function () { 'returns random, non-empty keys', 'integer', function(counter) { + jsdom(); + window.crypto = new WebCrypto(); var key = $.PrivateBin.CryptTool.getSymmetricKey(), result = (key !== '' && keys.indexOf(key) === -1); keys.push(key); diff --git a/js/test/Helper.js b/js/test/Helper.js index f76bf4f..056268b 100644 --- a/js/test/Helper.js +++ b/js/test/Helper.js @@ -213,15 +213,14 @@ describe('Helper', function () { this.timeout(30000); jsc.property( 'returns the requested cookie', - 'nearray asciinestring', - 'nearray asciistring', + jsc.nearray(jsc.nearray(common.jscAlnumString())), + jsc.nearray(jsc.nearray(common.jscAlnumString())), function (labels, values) { var selectedKey = '', selectedValue = '', cookieArray = []; labels.forEach(function(item, i) { - // deliberatly using a non-ascii key for replacing invalid characters - var key = item.replace(/[\s;,=]/g, Array(i+2).join('£')), - value = (values[i] || values[0]).replace(/[\s;,=]/g, ''); + var key = item.join(''), + value = (values[i] || values[0]).join(''); cookieArray.push(key + '=' + value); if (Math.random() < 1 / i || selectedKey === key) { diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index cab961e..4c14115 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -71,7 +71,7 @@ if ($MARKDOWN): endif; ?> - + diff --git a/tpl/page.php b/tpl/page.php index 411de9d..fdce1ec 100644 --- a/tpl/page.php +++ b/tpl/page.php @@ -49,7 +49,7 @@ if ($MARKDOWN): endif; ?> - + diff --git a/tst/README.md b/tst/README.md index d7f4ed0..ec42433 100644 --- a/tst/README.md +++ b/tst/README.md @@ -69,6 +69,12 @@ $ npm install jsverify jsdom@9 jsdom-global@2 mime-types Note: If you use a distribution that provides nodeJS >= 6, then you can install the latest jsdom and jsdom-global packages and don't need to use @9 and @2. +Note: When running Ubuntu 18.04, there is [a bug](https://bugs.launchpad.net/ubuntu/+source/nodejs/+bug/1779863) +due to the mismatch of nodejs 8 and OpenSSL 1.1 library it was compiled against. +Until this is solved, you may have to use [a PPA of nodejs, compiled against +OpenSSL 1.0](https://launchpad.net/~ddstreet/+archive/ubuntu/lp1779863) or use +nodejs 10 or later from a different source. + To run the tests, just change into the `js` directory and run istanbul: ```console $ cd PrivateBin/js