rugk/xenforo-threema-gateway

View on GitHub
src/library/ThreemaGateway/Handler/Action/Callback.php

Summary

Maintainability
D
1 day
Test Coverage
<?php
/**
 * Allows XenForo to receive Threema messages by providing a callback.
 *
 * @package ThreemaGateway
 * @author rugk
 * @copyright Copyright (c) 2015-2016 rugk
 * @license MIT
 */

class ThreemaGateway_Handler_Action_Callback extends ThreemaGateway_Handler_Action_Abstract
{
    /**
     * @var string used by strtotime to allow messages sent in the future
     */
    const ALLOW_FUTURE_MESSAGE_TIME = '+5 sec';

    /**
     * @var XenForo_Input raw parameters
     */
    protected $input;

    /**
     * @var array filtered parameters
     */
    protected $filtered;

    /**
     * @var bool whether it has been checked that the message is not used in a
     *           replay attack
     */
    protected $messageReplayChecked = false;

    /**
     * Initializes handling for processing a request callback.
     *
     * @param Zend_Controller_Request_Http $request
     */
    public function initCallbackHandling(Zend_Controller_Request_Http $request)
    {
        $this->input = new XenForo_Input($request);

        $this->filtered = $this->input->filter([
            'accesstoken' => XenForo_Input::STRING,
            'from' => XenForo_Input::STRING,
            'to' => XenForo_Input::STRING,
            'messageId' => XenForo_Input::STRING,
            'date' => XenForo_Input::DATE_TIME,
            'nonce' => XenForo_Input::STRING,
            'box' => XenForo_Input::STRING,
            'mac' => XenForo_Input::STRING
        ]);
    }

    /**
     * Validates the callback request. In case of failure the Gateway server
     * should not retry here as it likely would not help anyway.
     *
     * This validation is only a basic validation and does not handle with any
     * potentially secret data to prevent any exposures.
     * It makes sure malwformed requests can be denied fastly without needing
     * to check the authentity/security of the message.
     *
     * @param string|array $errorString Output error string/array
     *
     * @return bool
     */
    public function validatePreConditions(&$errorString)
    {
        /** @var XenForo_Options $options */
        $options = XenForo_Application::getOptions();

        // only allow POST requests (unless GET is allowed in ACP)
        if (!$this->settings->isDebug() || !$options->threema_gateway_allow_get_receive) {
            // as an exception we access the superglobal directly here as it is
            // difficult to get the request object
            if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
                $errorString = [null, 'No POST request.', ''];
                return false;
            };
        }

        // special error message to let users know not to forget the access
        // token
        if (!$this->input->inRequest('accesstoken')) {
            $errorString = 'Access token missing';
            return false;
        };

        // check for other missing parameters
        if (!$this->input->inRequest('from') ||
            !$this->input->inRequest('to') ||
            !$this->input->inRequest('messageId') ||
            !$this->input->inRequest('date') ||
            !$this->input->inRequest('nonce') ||
            !$this->input->inRequest('box') ||
            !$this->input->inRequest('mac')
        ) {
            $errorString = [null, 'Invalid request: parameter missing', 'Invalid request'];
            return false;
        };

        if (!$this->settings->isEndtoEnd()) {
            $errorString = [null, 'Receiving messages is not supported, end to end mode is not configured (correctly)', 'Receiving messages is not supported'];
            return false;
        }

        return true;
    }

    /**
     * Validates the callback request's authenticy and integrity. In case of
     * failure the Gateway server SHOULD retry.
     *
     * This validates the integrity of the request and the authentity that the
     * calling instance actually is the Threema Gateway server.
     * Retrying is allowed as the secrets, which are used to validate the
     * request may change at any time and to avoid a loss of messages the
     * Gateway server is supposed to retry the delivery.
     *
     * @param string|array $errorString Output error string/array
     *
     * @return bool
     */
    public function validateRequest(&$errorString)
    {
        // access token validation (authentication of Gateway server)
        /** @var XenForo_Options $options */
        $options = XenForo_Application::getOptions();
        if (!$options->threema_gateway_receivecallback) {
            $errorString = [null, 'Unverified request: access token is not configured', 'Unverified request'];
            return false;
        }

        if (!$this->getCryptTool()->stringCompare(
            $options->threema_gateway_receivecallback,
            $this->filtered['accesstoken']
        )) {
            $errorString = [null, 'Unverified request: access token invalid', 'Unverified request'];
            return false;
        }

        // HMAC validation (verifies integrity of request)
        if (!$this->getE2EHelper()->checkMac(
            $this->filtered['from'],
            $this->filtered['to'],
            $this->filtered['messageId'],
            $this->filtered['date'],
            $this->filtered['nonce'],
            $this->filtered['box'],
            $this->filtered['mac'],
            $this->settings->getSecret()
        )) {
            $errorString = [null, 'Unverified request: HMAC verification failed', 'Unverified request'];
            return false;
        }

        return true;
    }

    /**
     * Validates the callback request formally. In case of failure the Gateway
     * server should NOT retry, as it likely would not help anyway.
     *
     * This validation is only a formal validation of the request data. The
     * request should already have been validated ({@see validateRequest()}).
     * In contrast to the basic validation ({@see validatePreConditions()}) this
     * validation deals with secret data and furthermore assures that the send
     * request is valid.
     * It is used to prevent malformed (but verified) bad requests to get to the
     * decryption part, which cannot decrypt them anyway.
     *
     * @param string|array $errorString Output error string/array
     *
     * @return bool
     */
    public function validateFormalities(&$errorString)
    {
        // simple, formal validation of Gateway ID
        if (!$this->getCryptTool()->stringCompare($this->filtered['to'], $this->settings->getId())) {
            $errorString = [null, 'Invalid request: formal verification failed', 'Invalid request'];
            return false;
        }

        /** @var XenForo_Options $options */
        $options   = XenForo_Application::getOptions();
        /** @var string $rejectOld the maximum age of a message; default/fallback: 14 days */
        $rejectOld = '-14 days';
        if ($options->threema_gateway_verify_receive_time && $options->threema_gateway_verify_receive_time['enabled']) {
            $rejectOld = $options->threema_gateway_verify_receive_time['time'];
        }

        // discard too old messages
        if ($this->filtered['date'] < strtotime($rejectOld, XenForo_Application::$time)) {
            $errorString = [null, 'Message cannot be processed: Message is too old (send at ' . date('Y-m-d H:i:s', $this->filtered['date']) . ', messages older than ' . $rejectOld . ' are rejected)', 'Message cannot be processed'];
            return false;
        }

        // discard messages sent in the future
        // (to handle leap seconds or so we allow some seconds difference)
        if ($this->filtered['date'] > strtotime(self::ALLOW_FUTURE_MESSAGE_TIME, XenForo_Application::$time)) {
            $errorString = [null, 'Message cannot be processed: Message is send in the future (send at ' . date('Y-m-d H:i:s', $this->filtered['date']) . ', please check your server clock)', 'Message cannot be processed'];
            return false;
        }

        return true;
    }

    /**
     * Receive the message, decrypt it and save it.
     *
     * @param string $downloadPath The directory where to store received files
     * @param bool   $debugMode    Whether debugging information should be returned
     *                             (default: false)
     *
     * @throws XenForo_Exception
     * @return string|array      the message, which should be shown
     */
    public function processMessage($downloadPath, $debugMode = false)
    {
        /** @var string $output */
        $output = '';

        if (!ThreemaGateway_Handler_Validation::checkDir($downloadPath)) {
            throw new XenForo_Exception('Download dir ' . $downloadPath . ' cannot be accessed.');
        }

        try {
            /** @var Threema\MsgApi\Helpers\ReceiveMessageResult $receiveResult */
            $receiveResult = $this->getE2EHelper()->receiveMessage(
                $this->filtered['from'],
                $this->filtered['messageId'],
                $this->getCryptTool()->hex2bin($this->filtered['box']),
                $this->getCryptTool()->hex2bin($this->filtered['nonce']),
                $downloadPath
            );
        } catch (Exception $e) {
            // as XenForo does not allow exception chaining, we better log the exception right now
            XenForo_Error::logException($e);
            throw new XenForo_Exception('Message cannot be processed: [' . get_class($e) . '] ' . $e->getMessage());
        }

        if (!$receiveResult->isSuccess()) {
            throw new XenForo_Exception('Message cannot be processed: [ResultErrors] ' . implode('|', $receiveResult->getErrors()));
        }

        /** @var Threema\MsgApi\Messages\ThreemaMessage $threemaMsg */
        $threemaMsg = $receiveResult->getThreemaMessage();

        // create detailed log when debug mode is enabled
        if ($debugMode) {
            $output = $this->getLogData($receiveResult, $threemaMsg);
        }

        /** @var bool $saveMessage whether to save the message to DB. */
        $saveMessage = true;

        XenForo_CodeEvent::fire('threemagw_message_callback_presave', [
            $this,
            $receiveResult,
            $threemaMsg,
            &$output,
            &$saveMessage,
            $debugMode
        ], $threemaMsg->getTypeCode());

        // save message in database
        try {
            if ($saveMessage) {
                $this->saveMessage($receiveResult, $threemaMsg);
            } else {
                $this->saveMessageId($receiveResult->getMessageId());
            }
        } catch (Exception $e) {
            // as XenForo does not allow Exception chaining, we better log the exception right now
            XenForo_Error::logException($e);
            throw new XenForo_Exception('Message could not be saved: [' . get_class($e) . '] ' . $e->getMessage());
        }

        XenForo_CodeEvent::fire('threemagw_message_callback_postsave', [
            $this,
            $receiveResult,
            $threemaMsg,
            &$output,
            $saveMessage,
            $debugMode
        ], $threemaMsg->getTypeCode());

        return $output;
    }

    /**
     * Adds a string to the current log string or array.
     *
     * @param array|string $log               string or array
     * @param string       $stringToAdd
     * @param string       $stringToAddDetail
     */
    public function addLog(&$log, $stringToAdd, $stringToAddDetail = null)
    {
        // convert to array if necessary or just add string
        if (is_string($log)) {
            if ($stringToAddDetail) {
                $log[1] = $log;
                $log[2] = $log;
            } else {
                $log .= PHP_EOL . $stringToAdd;
                return;
            }
        }

        // add to array
        if ($stringToAddDetail) {
            $log[1] .= PHP_EOL . $stringToAddDetail;
        } elseif ($stringToAdd) {
            $log[1] .= PHP_EOL . $stringToAdd;
        }

        if ($stringToAdd) {
            $log[2] .= PHP_EOL . $stringToAdd;
        }
    }

    /**
     * Checks whether a message is already saved. If so this may indicate a
     * replay attack.
     *
     * @param string $messageId
     *
     * @throws XenForo_Exception
     */
    public function assertNoReplayAttack($messageId)
    {
        // do not check multiple times
        if ($this->messageReplayChecked) {
            return;
        }

        // skip all internal handling of receiver as it does a simple yes/no check only
        // also skip permissions as currently no user is logged in
        /** @var ThreemaGateway_Handler_Action_Receiver $receiver */
        $receiver = new ThreemaGateway_Handler_Action_Receiver(true, true);

        // check whether message has already been saved to prevent replay attacks
        if ($receiver->messageIsReceived($messageId)) {
            throw new XenForo_Exception('Message "' . $messageId . '" has already been received and is already saved. This may indicate a replay attack.');
        }

        $this->messageReplayChecked = true;
    }

    /**
     * Get request data.
     *
     * If you obmit the $key parameter you get an array of all request parameters.
     * If not, you'll get one specific entry.
     * In case nothing could be found, this returns "null".
     *
     * @param  string            $key
     * @return string|array|null
     */
    public function getRequest($key = null)
    {
        if ($key === null) {
            return $this->filtered;
        }

        if (array_key_exists($key, $this->filtered)) {
            return $this->filtered[$key];
        }

        return null;
    }

    /**
     * Returns the original input.
     *
     * It is strongly discouraghed to use this and better use {@see getRequest()}
     * as this data is already filtered.
     * In general you should have few real reasons to get this RAW data.
     *
     * @return XenForo_Input
     */
    public function getInput()
    {
        return $this->input;
    }

    /**
     * Returns an array with a not so detailed[2] and a very detailed[1] log
     * of the received message.
     *
     * @param Threema\MsgApi\Helpers\ReceiveMessageResult $receiveResult Threema MsgApi receive result
     * @param Threema\MsgApi\Messages\ThreemaMessage      $threemaMsg    Threema MsgApi message
     *
     * @return array[null, string, string]
     */
    protected function getLogData(
        Threema\MsgApi\Helpers\ReceiveMessageResult $receiveResult,
        Threema\MsgApi\Messages\ThreemaMessage $threemaMsg
    ) {
        $eol = PHP_EOL;

        // common heading
        $publicLog  = 'New message from ' . $this->filtered['from'] . $eol . $eol;
        $publicLog .= 'ID: ' . $receiveResult->getMessageId() . $eol;
        $publicLog .= 'message.type: ' . $threemaMsg->getTypeCode() . ' (' . $threemaMsg . ')' . $eol;
        $publicLog .= 'message.date: ' . date('Y-m-d H:i:s', $this->filtered['date']) . $eol;
        $debugLog = $publicLog;
        $publicLog .= '[...]' . $eol;

        // secret part of heading
        $debugLog .= 'files: ' . implode('|', $receiveResult->getFiles()) . $eol;
        // NOTE: File type (key of array) is not logged here!

        // detailed result (is secret)
        if ($threemaMsg instanceof Threema\MsgApi\Messages\TextMessage) {
            $debugLog .= 'message.getText: ' . $threemaMsg->getText() . $eol;
        }
        if ($threemaMsg instanceof Threema\MsgApi\Messages\DeliveryReceipt) {
            $debugLog .= 'message.getReceiptType: ' . $threemaMsg->getReceiptType() . $eol;
            $debugLog .= 'message.getReceiptTypeName: ' . $threemaMsg->getReceiptTypeName() . $eol;
            $debugLog .= 'message.getAckedMessageIds: ' . implode('|', $this->bin2hexArray($threemaMsg->getAckedMessageIds())) . $eol;
        }
        if ($threemaMsg instanceof Threema\MsgApi\Messages\FileMessage) {
            $debugLog .= 'message.getBlobId: ' . $threemaMsg->getBlobId() . $eol;
            $debugLog .= 'message.getEncryptionKey: ' . $threemaMsg->getEncryptionKey() . $eol;
            $debugLog .= 'message.getFilename: ' . $threemaMsg->getFilename() . $eol;
            $debugLog .= 'message.getMimeType: ' . $threemaMsg->getMimeType() . $eol;
            $debugLog .= 'message.getSize: ' . $threemaMsg->getSize() . $eol;
            $debugLog .= 'message.getThumbnailBlobId: ' . $threemaMsg->getThumbnailBlobId() . $eol;
        }
        if ($threemaMsg instanceof Threema\MsgApi\Messages\ImageMessage) {
            $debugLog .= 'message.getBlobId: ' . $threemaMsg->getBlobId() . $eol;
            $debugLog .= 'message.getLength: ' . $threemaMsg->getLength() . $eol;
            $debugLog .= 'message.getNonce: ' . $this->getCryptTool()->bin2hex($threemaMsg->getNonce()) . $eol;
        }

        return [null, $debugLog, $publicLog];
    }

    /**
     * Saves a decrypted message in the database.
     *
     * @param Threema\MsgApi\Helpers\ReceiveMessageResult $receiveResult Threema MsgApi receive result
     * @param Threema\MsgApi\Messages\ThreemaMessage      $threemaMsg    Threema MsgApi message
     *
     * @throws XenForo_Exception
     */
    protected function saveMessage(
        Threema\MsgApi\Helpers\ReceiveMessageResult $receiveResult,
        Threema\MsgApi\Messages\ThreemaMessage $threemaMsg
    ) {
        $dataWriter = XenForo_DataWriter::create('ThreemaGateway_DataWriter_Messages');

        $dataWriter->set('message_id', $receiveResult->getMessageId()); // this is set for all tables
        $dataWriter->set('message_type_code', $threemaMsg->getTypeCode(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES);
        $dataWriter->set('sender_threema_id', $this->filtered['from'], ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES);
        $dataWriter->set('date_send', $this->filtered['date'], ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES);
        // $dataWriter->set('date_received', XenForo_Application::$time); //= default

        // files
        if (count($receiveResult->getFiles()) >= 1) {
            /** @var array $fileList the files associated to the message */
            $fileList = $receiveResult->getFiles();
            // set current (first) type/path
            $dataWriter->set('file_type', key($fileList), ThreemaGateway_Model_Messages::DB_TABLE_FILES);
            $dataWriter->set('file_path', $dataWriter->normalizeFilePath(current($fileList)), ThreemaGateway_Model_Messages::DB_TABLE_FILES);
            // remove current value from array
            unset($fileList[key($fileList)]);
            // pass as extra data for later saving
            $dataWriter->setExtraData(ThreemaGateway_DataWriter_Messages::DATA_FILES, $fileList);
        }

        // set values for each message type
        if ($threemaMsg instanceof Threema\MsgApi\Messages\TextMessage) {
            $dataWriter->set('text', $threemaMsg->getText(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES . '_text');
        } elseif ($threemaMsg instanceof Threema\MsgApi\Messages\DeliveryReceipt) {
            $dataWriter->set('receipt_type', $threemaMsg->getReceiptType(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES . '_delivery_receipt');

            /** @var array $ackedMsgIds the acknowledged message IDs */
            $ackedMsgIds = $this->bin2hexArray($threemaMsg->getAckedMessageIds());
            if (count($ackedMsgIds) >= 1) {
                // set current (first) type/path
                $dataWriter->set('ack_message_id', $ackedMsgIds[0], ThreemaGateway_Model_Messages::DB_TABLE_DELIVERY_RECEIPT);
                // remove current value from array
                unset($ackedMsgIds[0]);
                // pass as extra data for later saving
                $dataWriter->setExtraData(ThreemaGateway_DataWriter_Messages::DATA_ACKED_MSG_IDS, $ackedMsgIds);
            }
        } elseif ($threemaMsg instanceof Threema\MsgApi\Messages\FileMessage) {
            $dataWriter->set('file_size', $threemaMsg->getSize(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES . '_file');
            $dataWriter->set('file_name', $threemaMsg->getFilename(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES . '_file');
            $dataWriter->set('mime_type', $threemaMsg->getMimeType(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES . '_file');
        } elseif ($threemaMsg instanceof Threema\MsgApi\Messages\ImageMessage) {
            $dataWriter->set('file_size', $threemaMsg->getLength(), ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES . '_image');
        }

        return $dataWriter->save();
    }

    /**
     * Only saves a message ID to the database to prevent replay attacks.
     *
     * @param string $messageId
     *
     * @throws XenForo_Exception
     */
    protected function saveMessageId($messageId)
    {
        $dataWriter = XenForo_DataWriter::create('ThreemaGateway_DataWriter_Messages');

        $dataWriter->set('message_id', $messageId, ThreemaGateway_Model_Messages::DB_TABLE_MESSAGES);
        $dataWriter->roundReceiveDate(); // reduce amount of meta data stored

        return $dataWriter->save();
    }

    /**
     * Converts binary data in an array to hex.
     *
     * @param array $bin binary array
     *
     * @return array
     */
    protected function bin2hexArray($bin)
    {
        $output = [];
        foreach ($bin as $item) {
            $output[] = $this->getCryptTool()->bin2hex($item);
        }
        return  $output;
    }
}