diff --git a/js/pastemeta.jsonld b/js/pastemeta.jsonld
index 2ebeca07..3294998a 100644
--- a/js/pastemeta.jsonld
+++ b/js/pastemeta.jsonld
@@ -26,6 +26,9 @@
},
"time_to_live": {
"@type": "pb:RemainingSeconds"
+ },
+ "challenge": {
+ "@type": "pb:Challenge"
}
}
}
\ No newline at end of file
diff --git a/js/privatebin.js b/js/privatebin.js
index 57fbeda8..672d9322 100644
--- a/js/privatebin.js
+++ b/js/privatebin.js
@@ -4229,7 +4229,9 @@ jQuery.PrivateBin = (function($, RawDeflate) {
// prepare server interaction
ServerInteraction.prepare();
- ServerInteraction.setCryptParameters(TopNav.getPassword());
+ const key = CryptTool.getSymmetricKey(),
+ password = TopNav.getPassword();
+ ServerInteraction.setCryptParameters(password, key);
// set success/fail functions
ServerInteraction.setSuccess(showCreatedPaste);
@@ -4250,7 +4252,10 @@ jQuery.PrivateBin = (function($, RawDeflate) {
TopNav.getOpenDiscussion() ? 1 : 0,
TopNav.getBurnAfterReading() ? 1 : 0
]);
- ServerInteraction.setUnencryptedData('meta', {'expire': TopNav.getExpiration()});
+ ServerInteraction.setUnencryptedData('meta', {
+ 'expire': TopNav.getExpiration(),
+ 'challenge': CryptTool.getCredentials(key, password)
+ });
// prepare PasteViewer for later preview
PasteViewer.setText(plainText);
diff --git a/js/types.jsonld b/js/types.jsonld
index 005b68b1..d0b1ed0b 100644
--- a/js/types.jsonld
+++ b/js/types.jsonld
@@ -92,6 +92,9 @@
"@type": "dp:Second",
"@minimum": 1
},
+ "Challenge": {
+ "@type": "pb:Base64"
+ },
"CipherParameters": {
"@container": "@list",
"@value": [
diff --git a/lib/Controller.php b/lib/Controller.php
index 2e0588b7..07b96de3 100644
--- a/lib/Controller.php
+++ b/lib/Controller.php
@@ -276,9 +276,7 @@ class Controller
// accessing this method ensures that the paste would be
// deleted if it has already expired
$paste->get();
- if (
- Filter::slowEquals($deletetoken, $paste->getDeleteToken())
- ) {
+ if ($paste->isDeleteTokenCorrect($deletetoken)) {
// Paste exists and deletion token is valid: Delete the paste.
$paste->delete();
$this->_status = 'Paste was properly deleted.';
@@ -315,9 +313,20 @@ class Controller
try {
$paste = $this->_model->getPaste($dataid);
if ($paste->exists()) {
+ // handle challenge response
+ if (!$paste->isTokenCorrect($this->_request->getParam('token'))) {
+ // we send a generic error to avoid leaking information
+ // about the existance of a burn after reading pastes
+ // this avoids an attacker being able to poll, if it has
+ // been read by the intended recipient or not
+ $this->_return_message(1, self::GENERIC_ERROR);
+ return;
+ }
$data = $paste->get();
- if (array_key_exists('salt', $data['meta'])) {
- unset($data['meta']['salt']);
+ foreach (array('salt', 'challenge') as $key) {
+ if (array_key_exists($key, $data['meta'])) {
+ unset($data['meta'][$key]);
+ }
}
$this->_return_message(0, $dataid, (array) $data);
} else {
diff --git a/lib/FormatV2.php b/lib/FormatV2.php
index 358d834e..ab5ff2a7 100644
--- a/lib/FormatV2.php
+++ b/lib/FormatV2.php
@@ -67,6 +67,13 @@ class FormatV2
if (!($ct = base64_decode($message['ct'], true))) {
return false;
}
+ // - (optional) challenge
+ if (
+ !$isComment && array_key_exists('challenge', $message['meta']) &&
+ !base64_decode($message['meta']['challenge'], true)
+ ) {
+ return false;
+ }
// Make sure some fields have a reasonable size:
// - initialization vector
diff --git a/lib/Model/Paste.php b/lib/Model/Paste.php
index 27890840..e8504f2d 100644
--- a/lib/Model/Paste.php
+++ b/lib/Model/Paste.php
@@ -14,6 +14,7 @@ namespace PrivateBin\Model;
use Exception;
use PrivateBin\Controller;
+use PrivateBin\Filter;
use PrivateBin\Persistence\ServerSalt;
/**
@@ -23,6 +24,14 @@ use PrivateBin\Persistence\ServerSalt;
*/
class Paste extends AbstractModel
{
+ /**
+ * Token for challenge/response.
+ *
+ * @access protected
+ * @var string
+ */
+ protected $_token = '';
+
/**
* Get paste data.
*
@@ -32,6 +41,11 @@ class Paste extends AbstractModel
*/
public function get()
{
+ // return cached result if one is found
+ if (array_key_exists('adata', $this->_data) || array_key_exists('data', $this->_data)) {
+ return $this->_data;
+ }
+
$data = $this->_store->read($this->getId());
if ($data === false) {
throw new Exception(Controller::GENERIC_ERROR, 64);
@@ -48,10 +62,16 @@ class Paste extends AbstractModel
unset($data['meta']['expire_date']);
}
- // check if non-expired burn after reading paste needs to be deleted
+ // check if non-expired burn after reading paste needs to be deleted,
+ // but don't delete it if an incorrect token was sent
if (
- (array_key_exists('adata', $data) && $data['adata'][3] === 1) ||
- (array_key_exists('burnafterreading', $data['meta']) && $data['meta']['burnafterreading'])
+ (
+ (array_key_exists('adata', $data) && $data['adata'][3] === 1) ||
+ (array_key_exists('burnafterreading', $data['meta']) && $data['meta']['burnafterreading'])
+ ) && (
+ !array_key_exists('challenge', $data['meta']) ||
+ $this->_token === $data['meta']['challenge']
+ )
) {
$this->delete();
}
@@ -94,6 +114,12 @@ class Paste extends AbstractModel
$this->_data['meta']['created'] = time();
$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'])
+ );
+ }
// store paste
if (
@@ -201,6 +227,40 @@ class Paste extends AbstractModel
(array_key_exists('opendiscussion', $this->_data['meta']) && $this->_data['meta']['opendiscussion']);
}
+ /**
+ * Check if paste challenge matches provided token.
+ *
+ * @access public
+ * @param string $token
+ * @throws Exception
+ * @return bool
+ */
+ public function isTokenCorrect($token)
+ {
+ $this->_token = $token;
+ if (!array_key_exists('challenge', $this->_data['meta'])) {
+ $this->get();
+ }
+ if (array_key_exists('challenge', $this->_data['meta'])) {
+ return Filter::slowEquals($token, $this->_data['meta']['challenge']);
+ }
+ // paste created without challenge, accept every token sent
+ return true;
+ }
+
+ /**
+ * Check if paste salt based HMAC matches provided delete token.
+ *
+ * @access public
+ * @param string $deletetoken
+ * @throws Exception
+ * @return bool
+ */
+ public function isDeleteTokenCorrect($deletetoken)
+ {
+ return Filter::slowEquals($deletetoken, $this->getDeleteToken());
+ }
+
/**
* Sanitizes data to conform with current configuration.
*
diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php
index c73b6738..d0ef8126 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 bbf55845..f4e1fde5 100644
--- a/tpl/page.php
+++ b/tpl/page.php
@@ -49,7 +49,7 @@ if ($MARKDOWN):
endif;
?>
-
+
diff --git a/tst/Bootstrap.php b/tst/Bootstrap.php
index b5393510..540dc231 100644
--- a/tst/Bootstrap.php
+++ b/tst/Bootstrap.php
@@ -155,7 +155,7 @@ class Helper
public static function getPastePost($version = 2, array $meta = array())
{
$example = self::getPaste($version, $meta);
- $example['meta'] = array('expire' => $example['meta']['expire']);
+ $example['meta'] = array_merge(array('expire' => $example['meta']['expire']), $meta);
return $example;
}
diff --git a/tst/ControllerTest.php b/tst/ControllerTest.php
index b00f2ce6..4c9a19dc 100644
--- a/tst/ControllerTest.php
+++ b/tst/ControllerTest.php
@@ -366,6 +366,30 @@ class ControllerTest extends PHPUnit_Framework_TestCase
$this->assertEquals(1, $paste['adata'][2], 'discussion is enabled');
}
+ /**
+ * @runInSeparateProcess
+ */
+ public function testCreateInvalidFormat()
+ {
+ $options = parse_ini_file(CONF, true);
+ $options['traffic']['limit'] = 0;
+ Helper::createIniFile(CONF, $options);
+ $paste = Helper::getPasteJson(2, array('challenge' => '$'));
+ $file = tempnam(sys_get_temp_dir(), 'FOO');
+ file_put_contents($file, $paste);
+ Request::setInputStream($file);
+ $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_SERVER['REMOTE_ADDR'] = '::1';
+ ob_start();
+ new Controller;
+ $content = ob_get_contents();
+ ob_end_clean();
+ $response = json_decode($content, true);
+ $this->assertEquals(1, $response['status'], 'outputs error status');
+ $this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste exists after posting data');
+ }
+
/**
* @runInSeparateProcess
*/
@@ -784,6 +808,56 @@ class ControllerTest extends PHPUnit_Framework_TestCase
$this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste successfully deleted');
}
+ /**
+ * @runInSeparateProcess
+ */
+ public function testReadBurnAfterReadingWithToken()
+ {
+ $token = base64_encode(hash_hmac(
+ 'sha256', Helper::getPasteId(), random_bytes(32)
+ ));
+ $burnPaste = Helper::getPaste(2, array('challenge' => $token));
+ $burnPaste['adata'][3] = 1;
+ $this->_data->create(Helper::getPasteId(), $burnPaste);
+ $this->assertTrue($this->_data->exists(Helper::getPasteId()), 'paste exists before deleting data');
+ $_SERVER['QUERY_STRING'] = Helper::getPasteId() . '&token=' . $token;
+ $_GET[Helper::getPasteId()] = '';
+ $_GET['token'] = $token;
+ $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+ ob_start();
+ new Controller;
+ $content = ob_get_contents();
+ ob_end_clean();
+ $response = json_decode($content, true);
+ $this->assertEquals(0, $response['status'], 'outputs status');
+ $this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste successfully deleted');
+ }
+
+ /**
+ * @runInSeparateProcess
+ */
+ public function testReadBurnAfterReadingWithIncorrectToken()
+ {
+ $token = base64_encode(hash_hmac(
+ 'sha256', Helper::getPasteId(), random_bytes(32)
+ ));
+ $burnPaste = Helper::getPaste(2, array('challenge' => base64_encode(random_bytes(32))));
+ $burnPaste['adata'][3] = 1;
+ $this->_data->create(Helper::getPasteId(), $burnPaste);
+ $this->assertTrue($this->_data->exists(Helper::getPasteId()), 'paste exists before deleting data');
+ $_SERVER['QUERY_STRING'] = Helper::getPasteId() . '&token=' . $token;
+ $_GET[Helper::getPasteId()] = '';
+ $_GET['token'] = $token;
+ $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+ ob_start();
+ new Controller;
+ $content = ob_get_contents();
+ ob_end_clean();
+ $response = json_decode($content, true);
+ $this->assertEquals(1, $response['status'], 'outputs status');
+ $this->assertTrue($this->_data->exists(Helper::getPasteId()), 'paste not deleted');
+ }
+
/**
* @runInSeparateProcess
*/
diff --git a/tst/FormatV2Test.php b/tst/FormatV2Test.php
index 8aa4d3ca..53473457 100644
--- a/tst/FormatV2Test.php
+++ b/tst/FormatV2Test.php
@@ -21,6 +21,10 @@ class FormatV2Test extends PHPUnit_Framework_TestCase
$paste['ct'] = '$';
$this->assertFalse(FormatV2::isValid($paste), 'invalid base64 encoding of ct');
+ $paste = Helper::getPastePost();
+ $paste['meta']['challenge'] = '$';
+ $this->assertFalse(FormatV2::isValid($paste), 'invalid base64 encoding of ct');
+
$paste = Helper::getPastePost();
$paste['ct'] = 'bm9kYXRhbm9kYXRhbm9kYXRhbm9kYXRhbm9kYXRhCg==';
$this->assertFalse(FormatV2::isValid($paste), 'low ct entropy');
diff --git a/tst/ModelTest.php b/tst/ModelTest.php
index d5c4074f..c7d3fa9d 100644
--- a/tst/ModelTest.php
+++ b/tst/ModelTest.php
@@ -268,10 +268,48 @@ class ModelTest extends PHPUnit_Framework_TestCase
$paste->setData($pasteData);
$paste->store();
- $paste = $paste->get();
+ $paste = $this->_model->getPaste(Helper::getPasteId())->get();
$this->assertEquals((float) 300, (float) $paste['meta']['time_to_live'], 'remaining time is set correctly', 1.0);
}
+ public function testToken()
+ {
+ $pasteData = Helper::getPastePost();
+ $pasteData['meta']['challenge'] = base64_encode(random_bytes(32));
+ $token = hash_hmac(
+ 'sha256', Helper::getPasteId(), base64_decode($pasteData['meta']['challenge'])
+ );
+ $this->_model->getPaste(Helper::getPasteId())->delete();
+ $paste = $this->_model->getPaste(Helper::getPasteId());
+ $this->assertFalse($paste->exists(), 'paste does not yet exist');
+
+ $paste = $this->_model->getPaste();
+ $paste->setData($pasteData);
+ $paste->store();
+
+ $paste = $this->_model->getPaste(Helper::getPasteId());
+ $this->assertTrue(
+ $paste->isTokenCorrect($token),
+ 'token is accepted after store and retrieval'
+ );
+ }
+
+ public function testDeleteToken()
+ {
+ $pasteData = Helper::getPastePost();
+ $this->_model->getPaste(Helper::getPasteId())->delete();
+ $paste = $this->_model->getPaste(Helper::getPasteId());
+ $this->assertFalse($paste->exists(), 'paste does not yet exist');
+
+ $paste = $this->_model->getPaste();
+ $paste->setData($pasteData);
+ $paste->store();
+ $deletetoken = $paste->getDeleteToken();
+
+ $paste = $this->_model->getPaste(Helper::getPasteId());
+ $this->assertTrue($paste->isDeleteTokenCorrect($deletetoken), 'delete token is accepted after store and retrieval');
+ }
+
/**
* @expectedException Exception
* @expectedExceptionCode 64
@@ -287,6 +325,20 @@ class ModelTest extends PHPUnit_Framework_TestCase
$paste->getComment(Helper::getPasteId())->delete();
}
+ /**
+ * @expectedException Exception
+ * @expectedExceptionCode 75
+ */
+ public function testInvalidFormat()
+ {
+ $pasteData = Helper::getPastePost();
+ $pasteData['adata'][1] = 'foo';
+ $this->_model->getPaste(Helper::getPasteId())->delete();
+
+ $paste = $this->_model->getPaste();
+ $paste->setData($pasteData);
+ }
+
public function testPurge()
{
$conf = new Configuration;
diff --git a/tst/RequestTest.php b/tst/RequestTest.php
index 9b440be0..b92d903d 100644
--- a/tst/RequestTest.php
+++ b/tst/RequestTest.php
@@ -130,6 +130,22 @@ class RequestTest extends PHPUnit_Framework_TestCase
$this->assertEquals('read', $request->getOperation());
}
+ public function testApiReadWithToken()
+ {
+ $this->reset();
+ $id = $this->getRandomId();
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['HTTP_ACCEPT'] = 'application/json, text/javascript, */*; q=0.01';
+ $_SERVER['QUERY_STRING'] = $id . '&token=foo';
+ $_GET[$id] = '';
+ $_GET['token'] = 'foo';
+ $request = new Request;
+ $this->assertTrue($request->isJsonApiCall(), 'is JSON Api call');
+ $this->assertEquals($id, $request->getParam('pasteid'));
+ $this->assertEquals('foo', $request->getParam('token'));
+ $this->assertEquals('read', $request->getOperation());
+ }
+
public function testApiDelete()
{
$this->reset();