From 5651c0f04efef324881407b3d672e433353e69c1 Mon Sep 17 00:00:00 2001 From: El RIDO Date: Sat, 29 Jun 2019 10:49:44 +0200 Subject: [PATCH] client side token creation, handle display and single password retry --- js/privatebin.js | 161 +++++++++++++++++++++++++++++------------ lib/Filter.php | 1 + lib/FormatV2.php | 3 +- lib/Model/Paste.php | 6 +- tpl/bootstrap.php | 2 +- tpl/page.php | 2 +- tst/ControllerTest.php | 4 +- tst/FormatV2Test.php | 4 +- tst/ModelTest.php | 6 +- 9 files changed, 128 insertions(+), 61 deletions(-) diff --git a/js/privatebin.js b/js/privatebin.js index 672d9322..e930845f 100644 --- a/js/privatebin.js +++ b/js/privatebin.js @@ -664,6 +664,23 @@ jQuery.PrivateBin = (function($, RawDeflate) { */ let base58 = new baseX('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'); + /** + * convert hexadecimal string to binary representation + * + * @name CryptTool.hex2bin + * @function + * @private + * @param {string} message hex string + * @return {string} binary representation as a DOMString + */ + function hex2bin(message) { + let result = []; + for (let i = 0, l = message.length; i < l; i += 2) { + result.push(parseInt(message.substr(i, 2), 16)); + } + return String.fromCharCode.apply(String, result); + } + /** * convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString * @@ -906,6 +923,33 @@ jQuery.PrivateBin = (function($, RawDeflate) { ); } + /** + * derive PBKDF2 protected credentials for server to validate password + * + * @name CryptTool.deriveCredentials + * @function + * @private + * @param {string} key + * @param {string} password + * @return {string} derived key + */ + async function deriveCredentials(key, password) + { + const spec = [ + null, // initialization vector + key.slice(0, 16), // salt + 100000, // iterations + 256, // key size + null, // tag size + null, // algorithm + 'gcm', // algorithm mode + 'none' // compression + ]; + return window.crypto.subtle.exportKey( + 'raw', await deriveKey(key.slice(16), password, spec, true) + ); + } + /** * gets crypto settings from specification and authenticated data * @@ -933,25 +977,47 @@ jQuery.PrivateBin = (function($, RawDeflate) { * @function * @param {string} key * @param {string} password - * @return {string} decrypted message, empty if decryption failed + * @return {string} derived key */ me.getCredentials = async function(key, password) { - const spec = [ - null, // initialization vector - key.slice(0, 16), // salt - 100000, // iterations - 256, // key size - null, // tag size - null, // algorithm - 'gcm', // algorithm mode - 'none' // compression - ]; - key = key.slice(16); - let derivedKey = await deriveKey(key, password, spec, true); return btoa( arraybufferToString( - await window.crypto.subtle.exportKey('raw', derivedKey) + await deriveCredentials(key, password) + ) + ); + } + + /** + * get HMAC of paste ID and PBKDF2 protected credentials for server to validate + * + * @name CryptTool.getToken + * @function + * @param {string} id + * @param {string} key + * @param {string} password + * @return {string} decrypted message, empty if decryption failed + */ + me.getToken = async function(id, key, password) + { + return btoa( + arraybufferToString( + await window.crypto.subtle.sign( + {name: 'HMAC'}, + await window.crypto.subtle.importKey( + 'raw', + await deriveCredentials(key, password), + { + name: 'HMAC', + hash: {name: 'SHA-256'} // can be "SHA-1", "SHA-256", "SHA-384" or "SHA-512" + }, + false, // may not export this + ['sign'] + ), + stringToArraybuffer( + hex2bin(id) + ) + ) ) ); } @@ -1160,7 +1226,7 @@ jQuery.PrivateBin = (function($, RawDeflate) { * force a data reload. Default: true * @return string */ - me.getPasteData = function(callback, useCache) + me.getPasteData = async function(callback, useCache) { // use cache if possible/allowed if (useCache !== false && pasteData !== null) { @@ -1173,17 +1239,31 @@ jQuery.PrivateBin = (function($, RawDeflate) { return pasteData; } - // reload data + // load data ServerInteraction.prepare(); - ServerInteraction.setUrl(Helper.baseUri() + '?pasteid=' + me.getPasteId()); + ServerInteraction.setUrl( + Helper.baseUri() + '?' + $.param({ + pasteid: me.getPasteId(), + token: await CryptTool.getToken( + me.getPasteId(), me.getPasteKey(), Prompt.getPassword() + ) + }) + ); ServerInteraction.setFailure(function (status, data) { // revert loading status… Alert.hideLoading(); TopNav.showViewButtons(); - // show error message - Alert.showError(ServerInteraction.parseUploadError(status, data, 'get paste data')); + // might be a missing password, try one more time after getting one + if (Prompt.getPassword().length === 0) { + Prompt.requestPassword(function () { + me.getPasteData(callback, useCache); + }); + } else { + // show error message + Alert.showError(ServerInteraction.parseUploadError(status, data, 'get paste data')); + } }); ServerInteraction.setSuccess(function (status, data) { pasteData = new Paste(data); @@ -1909,8 +1989,9 @@ jQuery.PrivateBin = (function($, RawDeflate) { * * @name Prompt.requestPassword * @function + * @param {function} callback */ - me.requestPassword = function() + me.requestPassword = function(callback) { // show new bootstrap method (if available) if ($passwordModal.length !== 0) { @@ -1928,9 +2009,9 @@ jQuery.PrivateBin = (function($, RawDeflate) { } if (password.length === 0) { // recurse… - return me.requestPassword(); + return me.requestPassword(callback); } - PasteDecrypter.run(); + callback(); }; /** @@ -4087,7 +4168,7 @@ jQuery.PrivateBin = (function($, RawDeflate) { // show notification const baseUri = Helper.baseUri() + '?', url = baseUri + data.id + '#' + CryptTool.base58encode(data.encryptionKey), - deleteUrl = baseUri + 'pasteid=' + data.id + '&deletetoken=' + data.deletetoken; + deleteUrl = baseUri + $.param({pasteid: data.id, deletetoken: data.deletetoken}); PasteStatus.createPasteNotification(url, deleteUrl); // show new URL in browser bar @@ -4254,7 +4335,7 @@ jQuery.PrivateBin = (function($, RawDeflate) { ]); ServerInteraction.setUnencryptedData('meta', { 'expire': TopNav.getExpiration(), - 'challenge': CryptTool.getCredentials(key, password) + 'challenge': await CryptTool.getCredentials(key, password) }); // prepare PasteViewer for later preview @@ -4318,7 +4399,7 @@ jQuery.PrivateBin = (function($, RawDeflate) { // if it fails, request password if (plaindata.length === 0 && password.length === 0) { // show prompt - Prompt.requestPassword(); + Prompt.requestPassword(me.run); // Thus, we cannot do anything yet, we need to wait for the user // input. @@ -4764,31 +4845,15 @@ jQuery.PrivateBin = (function($, RawDeflate) { const orgPosition = $(window).scrollTop(); Model.getPasteData(function (data) { - ServerInteraction.prepare(); - ServerInteraction.setUrl(Helper.baseUri() + '?pasteid=' + Model.getPasteId()); + PasteDecrypter.run(new Paste(data)); - ServerInteraction.setFailure(function (status, data) { - // revert loading status… - Alert.hideLoading(); - TopNav.showViewButtons(); + // restore position + window.scrollTo(0, orgPosition); - // show error message - Alert.showError( - ServerInteraction.parseUploadError(status, data, 'refresh display') - ); - }); - ServerInteraction.setSuccess(function (status, data) { - PasteDecrypter.run(new Paste(data)); - - // restore position - window.scrollTo(0, orgPosition); - - // NOTE: could create problems as callback may be called - // asyncronously if PasteDecrypter e.g. needs to wait for a - // password being entered - callback(); - }); - ServerInteraction.run(); + // NOTE: could create problems as callback may be called + // asyncronously if PasteDecrypter e.g. needs to wait for a + // password being entered + callback(); }, false); // this false is important as it circumvents the cache } diff --git a/lib/Filter.php b/lib/Filter.php index d7090bb4..d4840f31 100644 --- a/lib/Filter.php +++ b/lib/Filter.php @@ -72,6 +72,7 @@ class Filter /** * fixed time string comparison operation to prevent timing attacks * https://crackstation.net/hashing-security.htm?=rd#slowequals + * can be replaced with hash_equals() after we drop PHP 5.5 support * * @access public * @static diff --git a/lib/FormatV2.php b/lib/FormatV2.php index ab5ff2a7..42e0aac8 100644 --- a/lib/FormatV2.php +++ b/lib/FormatV2.php @@ -123,8 +123,7 @@ class FormatV2 // require only the key 'expire' in the metadata of pastes if (!$isComment && ( count($message['meta']) === 0 || - !array_key_exists('expire', $message['meta']) || - count($message['meta']) > 1 + !array_key_exists('expire', $message['meta']) )) { return false; } diff --git a/lib/Model/Paste.php b/lib/Model/Paste.php index e8504f2d..88ab41d9 100644 --- a/lib/Model/Paste.php +++ b/lib/Model/Paste.php @@ -116,9 +116,9 @@ class Paste extends AbstractModel $this->_data['meta']['salt'] = serversalt::generate(); // if a challenge was sent, we store the HMAC of paste ID & challenge if (array_key_exists('challenge', $this->_data['meta'])) { - $this->_data['meta']['challenge'] = hash_hmac( - 'sha256', $this->getId(), base64_decode($this->_data['meta']['challenge']) - ); + $this->_data['meta']['challenge'] = base64_encode(hash_hmac( + 'sha256', hex2bin($this->getId()), base64_decode($this->_data['meta']['challenge']), true + )); } // store paste diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index d0ef8126..d336e791 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 f4e1fde5..e70a319c 100644 --- a/tpl/page.php +++ b/tpl/page.php @@ -49,7 +49,7 @@ if ($MARKDOWN): endif; ?> - + diff --git a/tst/ControllerTest.php b/tst/ControllerTest.php index 4c9a19dc..87d0e893 100644 --- a/tst/ControllerTest.php +++ b/tst/ControllerTest.php @@ -814,7 +814,7 @@ class ControllerTest extends PHPUnit_Framework_TestCase public function testReadBurnAfterReadingWithToken() { $token = base64_encode(hash_hmac( - 'sha256', Helper::getPasteId(), random_bytes(32) + 'sha256', hex2bin(Helper::getPasteId()), random_bytes(32), true )); $burnPaste = Helper::getPaste(2, array('challenge' => $token)); $burnPaste['adata'][3] = 1; @@ -839,7 +839,7 @@ class ControllerTest extends PHPUnit_Framework_TestCase public function testReadBurnAfterReadingWithIncorrectToken() { $token = base64_encode(hash_hmac( - 'sha256', Helper::getPasteId(), random_bytes(32) + 'sha256', hex2bin(Helper::getPasteId()), random_bytes(32), true )); $burnPaste = Helper::getPaste(2, array('challenge' => base64_encode(random_bytes(32)))); $burnPaste['adata'][3] = 1; diff --git a/tst/FormatV2Test.php b/tst/FormatV2Test.php index 53473457..e6246e4c 100644 --- a/tst/FormatV2Test.php +++ b/tst/FormatV2Test.php @@ -71,6 +71,8 @@ class FormatV2Test extends PHPUnit_Framework_TestCase $paste['adata'][0][7] = '!#@'; $this->assertFalse(FormatV2::isValid($paste), 'invalid compression'); - $this->assertFalse(FormatV2::isValid(Helper::getPaste()), 'invalid meta key'); + $paste = Helper::getPastePost(); + unset($paste['meta']['expire']); + $this->assertFalse(FormatV2::isValid($paste), 'invalid missing meta key'); } } diff --git a/tst/ModelTest.php b/tst/ModelTest.php index c7d3fa9d..8a7a581f 100644 --- a/tst/ModelTest.php +++ b/tst/ModelTest.php @@ -276,9 +276,9 @@ class ModelTest extends PHPUnit_Framework_TestCase { $pasteData = Helper::getPastePost(); $pasteData['meta']['challenge'] = base64_encode(random_bytes(32)); - $token = hash_hmac( - 'sha256', Helper::getPasteId(), base64_decode($pasteData['meta']['challenge']) - ); + $token = base64_encode(hash_hmac( + 'sha256', hex2bin(Helper::getPasteId()), base64_decode($pasteData['meta']['challenge']), true + )); $this->_model->getPaste(Helper::getPasteId())->delete(); $paste = $this->_model->getPaste(Helper::getPasteId()); $this->assertFalse($paste->exists(), 'paste does not yet exist');