Esempio n. 1
0
def update_source_key(gpg: GpgHelper, local_source: Source, remote_source: SDKSource) -> None:
    """
    Updates a source's GPG key.
    """
    if not remote_source.key.get("fingerprint"):
        logger.error("New source data lacks key fingerprint")
        return

    if not remote_source.key.get("public"):
        logger.error("New source data lacks public key")
        return

    if (
        local_source.fingerprint == remote_source.key['fingerprint'] and
        local_source.public_key == remote_source.key['public']
    ):
        logger.debug("Source key data is unchanged")
        return

    try:
        # import_key updates the source's key and fingerprint, and commits
        gpg.import_key(
            remote_source.uuid,
            remote_source.key['public'],
            remote_source.key['fingerprint']
        )
    except CryptoError:
        logger.error('Failed to update key information for source %s', remote_source.uuid)
Esempio n. 2
0
def test_import_key(homedir, config, source):
    '''
    Check the happy path that we can import a single PGP key.
    Using the `config` fixture to ensure the config is written to disk.
    '''
    helper = GpgHelper(homedir, is_qubes=False)
    helper.import_key(source['uuid'], source['public_key'])
Esempio n. 3
0
def test_import_nonexistent_key(homedir, config, session_maker):
    """
    Check failure handling when a source has no key.
    """
    source = factory.Source(public_key=None)
    helper = GpgHelper(homedir, session_maker, is_qubes=False)
    with pytest.raises(CryptoError):
        helper.import_key(source)
Esempio n. 4
0
def test_import_key(homedir, config, session_maker):
    """
    Check the happy path that we can import a single PGP key.
    Using the `config` fixture to ensure the config is written to disk.
    """
    source = factory.Source()
    helper = GpgHelper(homedir, session_maker, is_qubes=False)
    helper.import_key(source)
def test_import_key_multiple_fingerprints(homedir, source, config, mocker):
    '''
    Check that an error is raised if multiple fingerpints are found on key import.
    Using the `config` fixture to ensure the config is written to disk.
    '''
    helper = GpgHelper(homedir, is_qubes=False)
    mock_import = mocker.patch.object(helper, '_import', returnvalue={'a', 'b'})

    with pytest.raises(RuntimeError, match='Expected exactly one fingerprint\\.'):
        helper.import_key(source['uuid'], source['public_key'])

    # ensure the mock was used
    assert mock_import.called
Esempio n. 6
0
def test_import_key_gpg_call_fail(homedir, config, mocker, session_maker):
    """
    Check that a `CryptoError` is raised if calling `gpg` fails.
    Using the `config` fixture to ensure the config is written to disk.
    """
    helper = GpgHelper(homedir, session_maker, is_qubes=False)
    source = factory.Source()
    err = subprocess.CalledProcessError(cmd=["foo"], returncode=1)
    mock_call = mocker.patch("securedrop_client.crypto.subprocess.check_call", side_effect=err)

    with pytest.raises(CryptoError, match=f"Could not import key."):
        helper.import_key(source)

    # ensure the mock was used
    assert mock_call.called
Esempio n. 7
0
def test_encrypt_fail(homedir, config, mocker, session_maker, session):
    """
    Check that a `CryptoError` is raised if the call to `gpg` fails.
    Using the `config` fixture to ensure the config is written to disk.
    """
    helper = GpgHelper(homedir, session_maker, is_qubes=False)

    source = factory.Source(public_key="iwillbreakyou")
    session.add(source)

    plaintext = "bueller?"

    # skip the import in encrypt_to_source, so encryption will fail
    helper.import_key = mocker.MagicMock(return_value=None)

    with pytest.raises(CryptoError):
        helper.encrypt_to_source(source.uuid, plaintext)

    assert helper.import_key.call_count == 1
Esempio n. 8
0
class Controller(QObject):
    """
    Represents the logic for the secure drop client application. In an MVC
    application, this is the controller.
    """

    sync_events = pyqtSignal(str)
    """
    Signal that notifies that a reply was accepted by the server. Emits the reply's UUID as a
    string.
    """
    reply_succeeded = pyqtSignal(str)
    """
    Signal that notifies that a reply failed to be accepted by the server. Emits the reply's UUID
    as a string.
    """
    reply_failed = pyqtSignal(str)
    """
    A signal that emits a signal when the authentication state changes.
    - `True` when the client becomes authenticated
    - `False` when the client becomes unauthenticated
    """
    authentication_state = pyqtSignal(bool)
    """
    This signal indicates that a file has been successfully downloaded by emitting the file's
    UUID as a string.
    """
    file_ready = pyqtSignal(str)

    def __init__(self,
                 hostname,
                 gui,
                 session,
                 home: str,
                 proxy: bool = True) -> None:
        """
        The hostname, gui and session objects are used to coordinate with the
        various other layers of the application: the location of the SecureDrop
        proxy, the user interface and SqlAlchemy local storage respectively.
        """
        check_dir_permissions(home)
        super().__init__()

        # Controller is unauthenticated by default
        self.__is_authenticated = False

        # used for finding DB in sync thread
        self.home = home

        # boolean flag for whether or not the client is operating behind a proxy
        self.proxy = proxy

        # Location of the SecureDrop server.
        self.hostname = hostname

        # Reference to the UI window.
        self.gui = gui

        # Reference to the API for secure drop proxy.
        self.api = None  # type: sdclientapi.API
        # Contains active threads calling the API.
        self.api_threads = {}  # type: Dict[str, Dict]

        # Reference to the SqlAlchemy session.
        self.session = session

        # thread responsible for fetching messages
        self.message_thread = None
        self.message_sync = MessageSync(self.api, self.home, self.proxy)

        # thread responsible for fetching replies
        self.reply_thread = None
        self.reply_sync = ReplySync(self.api, self.home, self.proxy)

        self.sync_flag = os.path.join(home, 'sync_flag')

        # File data.
        self.data_dir = os.path.join(self.home, 'data')

        self.gpg = GpgHelper(home, proxy)

    @property
    def is_authenticated(self) -> bool:
        return self.__is_authenticated

    @is_authenticated.setter
    def is_authenticated(self, is_authenticated: bool) -> None:
        if self.__is_authenticated != is_authenticated:
            self.authentication_state.emit(is_authenticated)
            self.__is_authenticated = is_authenticated

    @is_authenticated.deleter
    def is_authenticated(self) -> None:
        raise AttributeError('Cannot delete is_authenticated')

    def setup(self):
        """
        Setup the application with the default state of:

        * Not logged in.
        * Show most recent state of syncronised sources.
        * Show the login screen.
        * Check the sync status every 30 seconds.
        """
        # The gui needs to reference this "controller" layer to call methods
        # triggered by UI events.
        self.gui.setup(self)

        # Create a timer to check for sync status every 30 seconds.
        self.sync_timer = QTimer()
        self.sync_timer.timeout.connect(self.update_sync)
        self.sync_timer.start(30000)

        # Automagically sync with the API every 5 minutes.
        self.sync_update = QTimer()
        self.sync_update.timeout.connect(self.sync_api)
        self.sync_update.start(1000 * 60 * 5)  # every 5 minutes.

    def call_api(self,
                 api_call_func,
                 success_callback,
                 failure_callback,
                 *args,
                 current_object=None,
                 **kwargs):
        """
        Calls the function in a non-blocking manner. Upon completion calls the
        callback with the result. Calls timeout if the timer associated with
        the call emits a timeout signal. Any further arguments are passed to
        the function to be called.
        """
        new_thread_id = str(uuid.uuid4())  # Uniquely id the new thread.

        new_api_thread = QThread(self.gui)
        new_api_runner = APICallRunner(api_call_func, current_object, *args,
                                       **kwargs)
        new_api_runner.moveToThread(new_api_thread)

        # handle completed call: copy response data, reset the
        # client, give the user-provided callback the response
        # data
        new_api_runner.call_succeeded.connect(
            lambda: self.completed_api_call(new_thread_id, success_callback))
        new_api_runner.call_failed.connect(
            lambda: self.completed_api_call(new_thread_id, failure_callback))
        new_api_runner.call_timed_out.connect(self.on_api_timeout)

        # when the thread starts, we want to run `call_api` on `api_runner`
        new_api_thread.started.connect(new_api_runner.call_api)

        # Add the thread related objects to the api_threads dictionary.
        self.api_threads[new_thread_id] = {
            'thread': new_api_thread,
            'runner': new_api_runner,
        }

        # Start the thread and related activity.
        new_api_thread.start()

    def on_api_timeout(self) -> None:
        self.gui.update_error_status(
            _('The connection to the SecureDrop server timed out. '
              'Please try again.'))

    def completed_api_call(self, thread_id, user_callback):
        """
        Manage a completed API call. The actual result *may* be an exception or
        error result from the API. It's up to the handler (user_callback) to
        handle these potential states.
        """
        logger.info("Completed API call. Cleaning up and running callback.")
        thread_info = self.api_threads.pop(thread_id)
        runner = thread_info['runner']
        result_data = runner.result

        arg_spec = inspect.getfullargspec(user_callback)
        if 'current_object' in arg_spec.args:
            user_callback(result_data, current_object=runner.current_object)
        else:
            user_callback(result_data)

    def start_message_thread(self):
        """
        Starts the message-fetching thread in the background.
        """
        if not self.message_thread:
            self.message_sync.api = self.api
            self.message_thread = QThread()
            self.message_sync.moveToThread(self.message_thread)
            self.message_thread.started.connect(self.message_sync.run)
            self.message_thread.start()
        else:  # Already running from last login
            self.message_sync.api = self.api

    def start_reply_thread(self):
        """
        Starts the reply-fetching thread in the background.
        """
        if not self.reply_thread:
            self.reply_sync.api = self.api
            self.reply_thread = QThread()
            self.reply_sync.moveToThread(self.reply_thread)
            self.reply_thread.started.connect(self.reply_sync.run)
            self.reply_thread.start()
        else:  # Already running from last login
            self.reply_sync.api = self.api

    def login(self, username, password, totp):
        """
        Given a username, password and time based one-time-passcode (TOTP),
        create a new instance representing the SecureDrop api and authenticate.
        """
        self.api = sdclientapi.API(self.hostname, username, password, totp,
                                   self.proxy)
        self.call_api(self.api.authenticate, self.on_authenticate_success,
                      self.on_authenticate_failure)

    def on_authenticate_success(self, result):
        """
        Handles a successful authentication call against the API.
        """
        self.gui.hide_login()
        self.sync_api()
        self.gui.show_main_window(self.api.username)
        self.start_message_thread()
        self.start_reply_thread()

        # Clear the sidebar error status bar if a message was shown
        # to the user indicating they should log in.
        self.gui.clear_error_status()

        self.is_authenticated = True

    def on_authenticate_failure(self, result: Exception) -> None:
        # Failed to authenticate. Reset state with failure message.
        self.api = None
        error = _('There was a problem signing in. '
                  'Please verify your credentials and try again.')
        self.gui.show_login_error(error=error)

    def login_offline_mode(self):
        """
        Allow user to view in offline mode without authentication.
        """
        self.gui.hide_login()
        self.gui.show_main_window()
        self.start_message_thread()
        self.start_reply_thread()
        self.is_authenticated = False
        self.update_sources()

    def on_action_requiring_login(self):
        """
        Indicate that a user needs to login to perform the specified action.
        """
        error = _('You must sign in to perform this action.')
        self.gui.update_error_status(error)

    def authenticated(self):
        """
        Return a boolean indication that the connection to the API is
        authenticated.
        """
        return bool(self.api and self.api.token is not None)

    def sync_api(self):
        """
        Grab data from the remote SecureDrop API in a non-blocking manner.
        """
        logger.debug("In sync_api on thread {}".format(
            self.thread().currentThreadId()))
        self.sync_events.emit('syncing')

        if self.authenticated():
            logger.debug("You are authenticated, going to make your call")
            self.call_api(storage.get_remote_data, self.on_sync_success,
                          self.on_sync_failure, self.api)
            logger.debug("In sync_api, after call to call_api, on "
                         "thread {}".format(self.thread().currentThreadId()))

    def last_sync(self):
        """
        Returns the time of last synchronisation with the remote SD server.
        """
        try:
            with open(self.sync_flag) as f:
                return arrow.get(f.read())
        except Exception:
            return None

    def on_sync_success(self, result) -> None:
        """
        Called when syncronisation of data via the API succeeds
        """
        remote_sources, remote_submissions, remote_replies = result

        storage.update_local_storage(self.session, remote_sources,
                                     remote_submissions, remote_replies,
                                     self.data_dir)

        # Set last sync flag.
        with open(self.sync_flag, 'w') as f:
            f.write(arrow.now().format())

        # import keys into keyring
        for source in remote_sources:
            if source.key and source.key.get('type', None) == 'PGP':
                pub_key = source.key.get('public', None)
                if not pub_key:
                    continue
                try:
                    self.gpg.import_key(source.uuid, pub_key)
                except CryptoError:
                    logger.warning('Failed to import key for source {}'.format(
                        source.uuid))

        self.update_sources()

    def on_sync_failure(self, result: Exception) -> None:
        """
        Called when syncronisation of data via the API fails.
        """
        pass
        self.update_sources()

    def update_sync(self):
        """
        Updates the UI to show human time of last sync.
        """
        self.gui.show_sync(self.last_sync())

    def update_sources(self):
        """
        Display the updated list of sources with those found in local storage.
        """
        sources = list(storage.get_local_sources(self.session))
        if sources:
            sources.sort(key=lambda x: x.last_updated, reverse=True)
        self.gui.show_sources(sources)
        self.update_sync()

    def on_update_star_success(self, result) -> None:
        """
        After we star a source, we should sync the API such that the local database is updated.
        """
        self.sync_api()  # Syncing the API also updates the source list UI
        self.gui.clear_error_status()

    def on_update_star_failure(self, result: Exception) -> None:
        """
        After we unstar a source, we should sync the API such that the local database is updated.
        """
        logging.info("failed to push change to server")
        error = _('Failed to update star.')
        self.gui.update_error_status(error)

    def update_star(self, source_db_object):
        """
        Star or unstar. The callback here is the API sync as we first make sure
        that we apply the change to the server, and then update locally.
        """
        if not self.api:  # Then we should tell the user they need to login.
            self.on_action_requiring_login()
            return
        else:  # Clear the error status bar
            self.gui.clear_error_status()

        source_sdk_object = sdclientapi.Source(uuid=source_db_object.uuid)

        if source_db_object.is_starred:
            self.call_api(self.api.remove_star, self.on_update_star_success,
                          self.on_update_star_failure, source_sdk_object)
        else:
            self.call_api(self.api.add_star, self.on_update_star_success,
                          self.on_update_star_failure, source_sdk_object)

    def logout(self):
        """
        Reset the API object and force the UI to update into a logged out
        state.
        """
        self.api = None
        self.message_sync.api = None
        self.reply_sync.api = None
        self.gui.logout()
        self.is_authenticated = False

    def set_status(self, message, duration=5000):
        """
        Set a textual status message to be displayed to the user for a certain
        duration.
        """
        self.gui.update_activity_status(message, duration)

    def on_file_open(self, file_db_object):
        """
        Open the already downloaded file associated with the message (which is a `File`).
        """
        # Once downloaded, submissions are stored in the data directory
        # with the same filename as the server, except with the .gz.gpg
        # stripped off.
        server_filename = file_db_object.filename
        fn_no_ext, _ = os.path.splitext(os.path.splitext(server_filename)[0])
        submission_filepath = os.path.join(self.data_dir, fn_no_ext)

        if self.proxy:
            # Running on Qubes.
            command = "qvm-open-in-vm"
            args = ['$dispvm:sd-svs-disp', submission_filepath]

            # QProcess (Qt) or Python's subprocess? Who cares? They do the
            # same thing. :-)
            process = QProcess(self)
            process.start(command, args)
        else:  # pragma: no cover
            # Non Qubes OS. Just log the event for now.
            logger.info('Opening file "{}".'.format(submission_filepath))

    def on_file_download(self, source_db_object, message):
        """
        Download the file associated with the associated message (which may
        be a Submission or Reply).
        """
        if not self.api:  # Then we should tell the user they need to login.
            self.on_action_requiring_login()
            return

        if isinstance(message, db.File) or isinstance(message, db.Message):
            # Handle submissions.
            func = self.api.download_submission
            sdk_object = sdclientapi.Submission(uuid=message.uuid)
            sdk_object.filename = message.filename
            sdk_object.source_uuid = source_db_object.uuid
        elif isinstance(message, db.Reply):
            # Handle journalist's replies.
            func = self.api.download_reply
            sdk_object = sdclientapi.Reply(uuid=message.uuid)
            sdk_object.filename = message.filename
            sdk_object.source_uuid = source_db_object.uuid

        self.set_status(_('Downloading {}'.format(sdk_object.filename)))
        self.call_api(func,
                      self.on_file_download_success,
                      self.on_file_download_failure,
                      sdk_object,
                      self.data_dir,
                      current_object=message)

    def on_file_download_success(self, result, current_object):
        """
        Called when a file has downloaded. Cause a refresh to the conversation view to display the
        contents of the new file.
        """
        file_uuid = current_object.uuid
        server_filename = current_object.filename
        _, filename = result
        # The filename contains the location where the file has been
        # stored. On non-Qubes OSes, this will be the data directory.
        # On Qubes OS, this will a ~/QubesIncoming directory. In case
        # we are on Qubes, we should move the file to the data directory
        # and name it the same as the server (e.g. spotless-tater-msg.gpg).
        filepath_in_datadir = os.path.join(self.data_dir, server_filename)
        shutil.move(filename, filepath_in_datadir)
        storage.mark_file_as_downloaded(file_uuid, self.session)

        try:
            # Attempt to decrypt the file.
            self.gpg.decrypt_submission_or_reply(filepath_in_datadir,
                                                 server_filename,
                                                 is_doc=True)
            storage.set_object_decryption_status_with_content(
                current_object, self.session, True)
        except CryptoError as e:
            logger.debug('Failed to decrypt file {}: {}'.format(
                server_filename, e))
            storage.set_object_decryption_status_with_content(
                current_object, self.session, False)
            self.set_status("Failed to decrypt file, "
                            "please try again or talk to your administrator.")
            # TODO: We should save the downloaded content, and just
            # try to decrypt again if there was a failure.
            return  # If we failed we should stop here.

        self.set_status('Finished downloading {}'.format(
            current_object.filename))
        self.file_ready.emit(file_uuid)

    def on_file_download_failure(self, result, current_object):
        """
        Called when a file fails to download.
        """
        server_filename = current_object.filename
        logger.debug('Failed to download file {}'.format(server_filename))
        # Update the UI in some way to indicate a failure state.
        self.set_status("The file download failed. Please try again.")

    def on_delete_source_success(self, result) -> None:
        """
        Handler for when a source deletion succeeds.
        """
        self.sync_api()
        self.gui.clear_error_status()

    def on_delete_source_failure(self, result: Exception) -> None:
        logging.info("failed to delete source at server")
        error = _('Failed to delete source at server')
        self.gui.update_error_status(error)

    def delete_source(self, source):
        """Performs a delete operation on source record.

        This method will first request server to delete the source record. If
        the process of deleting record at server is successful, it will sync
        the server records with the local state. On failure, it will display an
        error.
        """
        self.call_api(self.api.delete_source, self.on_delete_source_success,
                      self.on_delete_source_failure, source)

    def send_reply(self, source_uuid: str, msg_uuid: str,
                   message: str) -> None:
        sdk_source = sdclientapi.Source(uuid=source_uuid)

        try:
            encrypted_reply = self.gpg.encrypt_to_source(source_uuid, message)
        except Exception:
            tb = traceback.format_exc()
            logger.error('Failed to encrypt to source {}:\n'.format(
                source_uuid, tb))
            self.reply_failed.emit(msg_uuid)
        else:
            # Guard against calling the API if we're not logged in
            if self.api:
                self.call_api(
                    self.api.reply_source,
                    self.on_reply_success,
                    self.on_reply_failure,
                    sdk_source,
                    encrypted_reply,
                    msg_uuid,
                    current_object=(source_uuid, msg_uuid),
                )
            else:  # pragma: no cover
                logger.error('not logged in - not implemented!')
                self.reply_failed.emit(msg_uuid)

    def on_reply_success(self, result, current_object: Tuple[str,
                                                             str]) -> None:
        source_uuid, reply_uuid = current_object
        source = self.session.query(
            db.Source).filter_by(uuid=source_uuid).one()

        reply_db_object = db.Reply(
            uuid=result.uuid,
            source_id=source.id,
            journalist_id=self.api.token_journalist_uuid,
            filename=result.filename,
        )
        self.session.add(reply_db_object)
        self.session.commit()

        self.reply_succeeded.emit(reply_uuid)

    def on_reply_failure(self, result, current_object: Tuple[str,
                                                             str]) -> None:
        source_uuid, reply_uuid = current_object
        self.reply_failed.emit(reply_uuid)
Esempio n. 9
0
class Client(QObject):
    """
    Represents the logic for the secure drop client application. In an MVC
    application, this is the controller.
    """

    sync_events = pyqtSignal(str)

    def __init__(self, hostname, gui, session,
                 home: str, proxy: bool = True) -> None:
        """
        The hostname, gui and session objects are used to coordinate with the
        various other layers of the application: the location of the SecureDrop
        proxy, the user interface and SqlAlchemy local storage respectively.
        """
        check_dir_permissions(home)
        super().__init__()

        # used for finding DB in sync thread
        self.home = home

        # boolean flag for whether or not the client is operating behind a proxy
        self.proxy = proxy

        # Location of the SecureDrop server.
        self.hostname = hostname

        # Reference to the UI window.
        self.gui = gui

        # Reference to the API for secure drop proxy.
        self.api = None
        # Contains active threads calling the API.
        self.api_threads = {}

        # Reference to the SqlAlchemy session.
        self.session = session

        # thread responsible for fetching messages
        self.message_thread = None
        self.message_sync = MessageSync(self.api, self.home, self.proxy)

        # thread responsible for fetching replies
        self.reply_thread = None
        self.reply_sync = ReplySync(self.api, self.home, self.proxy)

        self.sync_flag = os.path.join(home, 'sync_flag')

        # File data.
        self.data_dir = os.path.join(self.home, 'data')

        self.gpg = GpgHelper(home, proxy)

    def setup(self):
        """
        Setup the application with the default state of:

        * Not logged in.
        * Show most recent state of syncronised sources.
        * Show the login screen.
        * Check the sync status every 30 seconds.
        """
        # The gui needs to reference this "controller" layer to call methods
        # triggered by UI events.
        self.gui.setup(self)

        # If possible, update the UI with available sources.
        self.update_sources()

        # Show the login dialog.
        self.gui.show_login()

        # Create a timer to check for sync status every 30 seconds.
        self.sync_timer = QTimer()
        self.sync_timer.timeout.connect(self.update_sync)
        self.sync_timer.start(30000)

        # Automagically sync with the API every 5 minutes.
        self.sync_update = QTimer()
        self.sync_update.timeout.connect(self.sync_api)
        self.sync_update.start(1000 * 60 * 5)  # every 5 minutes.

    def call_api(self, function, callback, timeout, *args, current_object=None,
                 **kwargs):
        """
        Calls the function in a non-blocking manner. Upon completion calls the
        callback with the result. Calls timeout if the timer associated with
        the call emits a timeout signal. Any further arguments are passed to
        the function to be called.
        """
        new_thread_id = str(uuid.uuid4())  # Uniquely id the new thread.
        new_timer = QTimer()
        new_timer.setSingleShot(True)
        new_timer.start(20000)

        new_api_thread = QThread(self.gui)
        new_api_runner = APICallRunner(function, current_object, *args,
                                       **kwargs)
        new_api_runner.moveToThread(new_api_thread)

        # handle completed call: copy response data, reset the
        # client, give the user-provided callback the response
        # data
        new_api_runner.call_finished.connect(
            lambda: self.completed_api_call(new_thread_id, callback))

        # we've started a timer. when that hits zero, call our
        # timeout function
        new_timer.timeout.connect(
            lambda: self.timeout_cleanup(new_thread_id, timeout))

        # when the thread starts, we want to run `call_api` on `api_runner`
        new_api_thread.started.connect(new_api_runner.call_api)

        # Add the thread related objects to the api_threads dictionary.
        self.api_threads[new_thread_id] = {
            'thread': new_api_thread,
            'runner': new_api_runner,
            'timer': new_timer,
        }

        # Start the thread and related activity.
        new_api_thread.start()

    def clean_thread(self, thread_id):
        """
        Clean up the identified thread's state after an API call.
        """
        if thread_id in self.api_threads:
            timer = self.api_threads[thread_id]['timer']
            timer.disconnect()
            del(self.api_threads[thread_id])

    def completed_api_call(self, thread_id, user_callback):
        """
        Manage a completed API call. The actual result *may* be an exception or
        error result from the API. It's up to the handler (user_callback) to
        handle these potential states.
        """
        logger.info("Completed API call. Cleaning up and running callback.")
        if thread_id in self.api_threads:
            thread_info = self.api_threads[thread_id]
            runner = thread_info['runner']
            timer = thread_info['timer']
            timer.stop()
            result_data = runner.result

            # The callback may or may not have an associated current_object
            if runner.current_object:
                current_object = runner.current_object
            else:
                current_object = None

            self.clean_thread(thread_id)
            if current_object:
                user_callback(result_data, current_object=current_object)
            else:
                user_callback(result_data)

    def start_message_thread(self):
        """
        Starts the message-fetching thread in the background.
        """
        if not self.message_thread:
            self.message_sync.api = self.api
            self.message_thread = QThread()
            self.message_sync.moveToThread(self.message_thread)
            self.message_thread.started.connect(self.message_sync.run)
            self.message_thread.start()
        else:  # Already running from last login
            self.message_sync.api = self.api

    def start_reply_thread(self):
        """
        Starts the reply-fetching thread in the background.
        """
        if not self.reply_thread:
            self.reply_sync.api = self.api
            self.reply_thread = QThread()
            self.reply_sync.moveToThread(self.reply_thread)
            self.reply_thread.started.connect(self.reply_sync.run)
            self.reply_thread.start()
        else:  # Already running from last login
            self.reply_sync.api = self.api

    def timeout_cleanup(self, thread_id, user_callback):
        """
        Clean up after the referenced thread has timed-out by setting some
        flags and calling the passed user_callback.
        """
        logger.info("API call timed out. Cleaning up and running "
                    "timeout callback.")
        if thread_id in self.api_threads:
            runner = self.api_threads[thread_id]['runner']
            runner.i_timed_out = True

            if runner.current_object:
                current_object = runner.current_object
            else:
                current_object = None

            self.clean_thread(thread_id)
            if current_object:
                user_callback(current_object=current_object)
            else:
                user_callback()

    def login(self, username, password, totp):
        """
        Given a username, password and time based one-time-passcode (TOTP),
        create a new instance representing the SecureDrop api and authenticate.
        """
        self.api = sdclientapi.API(self.hostname, username,
                                   password, totp, self.proxy)
        self.call_api(self.api.authenticate, self.on_authenticate,
                      self.on_login_timeout)

    def on_authenticate(self, result):
        """
        Handles the result of an authentication call against the API.
        """
        if isinstance(result, bool) and result:
            # It worked! Sync with the API and update the UI.
            self.gui.hide_login()
            self.sync_api()
            self.gui.set_logged_in_as(self.api.username)
            self.start_message_thread()
            self.start_reply_thread()

            # Clear the sidebar error status bar if a message was shown
            # to the user indicating they should log in.
            self.gui.update_error_status("")
        else:
            # Failed to authenticate. Reset state with failure message.
            self.api = None
            error = _('There was a problem signing in. '
                      'Please verify your credentials and try again.')
            self.gui.show_login_error(error=error)

    def on_login_timeout(self):
        """
        Reset the form and indicate the error.
        """

        self.api = None
        error = _('The connection to the SecureDrop server timed out. '
                  'Please try again.')
        self.gui.show_login_error(error=error)

    def on_sync_timeout(self):
        """
        Indicate that a sync failed.

        TODO: We don't really want to alert in the error bar _every time_
        this happens. Instead, we should do something like: alert if there
        have been many timeouts in a row.
        """

        error = _('The connection to the SecureDrop server timed out. '
                  'Please try again.')
        self.gui.update_error_status(error=error)

    def on_action_requiring_login(self):
        """
        Indicate that a user needs to login to perform the specified action.
        """
        error = _('You must sign in to perform this action.')
        self.gui.update_error_status(error)

    def on_sidebar_action_timeout(self):
        """
        Indicate that a timeout occurred for an action occuring in the left
        sidebar.
        """
        error = _('The connection to the SecureDrop server timed out. '
                  'Please try again.')
        self.gui.update_error_status(error)

    def authenticated(self):
        """
        Return a boolean indication that the connection to the API is
        authenticated.
        """
        return bool(self.api and self.api.token['token'])

    def sync_api(self):
        """
        Grab data from the remote SecureDrop API in a non-blocking manner.
        """
        logger.debug("In sync_api on thread {}".format(
            self.thread().currentThreadId()))
        self.sync_events.emit('syncing')

        if self.authenticated():
            logger.debug("You are authenticated, going to make your call")
            self.call_api(storage.get_remote_data, self.on_synced,
                          self.on_sync_timeout, self.api)
            logger.debug("In sync_api, after call to call_api, on "
                         "thread {}".format(self.thread().currentThreadId()))

    def last_sync(self):
        """
        Returns the time of last synchronisation with the remote SD server.
        """
        try:
            with open(self.sync_flag) as f:
                return arrow.get(f.read())
        except Exception:
            return None

    def on_synced(self, result):
        """
        Called when syncronisation of data via the API is complete.
        """
        self.sync_events.emit('synced')
        if isinstance(result, tuple):
            remote_sources, remote_submissions, remote_replies = \
                result

            storage.update_local_storage(self.session, remote_sources,
                                         remote_submissions,
                                         remote_replies, self.data_dir)

            # clean up locally cached conversation views
            remote_source_uuids = [s.uuid for s in remote_sources]
            cached_sources = list(self.gui.conversations.keys())
            for cached_source in cached_sources:
                if cached_source not in remote_source_uuids:
                    self.gui.conversations.pop(cached_source, None)

            # Set last sync flag.
            with open(self.sync_flag, 'w') as f:
                f.write(arrow.now().format())

            # import keys into keyring
            for source in remote_sources:
                if source.key and source.key.get('type', None) == 'PGP':
                    pub_key = source.key.get('public', None)
                    if not pub_key:
                        continue
                    try:
                        self.gpg.import_key(source.uuid, pub_key)
                    except CryptoError:
                        logger.warning('Failed to import key for source {}'.format(source.uuid))

            self.update_conversation_views()
        else:
            # How to handle a failure? Exceptions are already logged. Perhaps
            # a message in the UI?
            pass

        self.update_sources()

    def update_sync(self):
        """
        Updates the UI to show human time of last sync.
        """
        self.gui.show_sync(self.last_sync())

    def update_sources(self):
        """
        Display the updated list of sources with those found in local storage.
        """
        sources = list(storage.get_local_sources(self.session))
        if sources:
            sources.sort(key=lambda x: x.last_updated, reverse=True)
        self.gui.show_sources(sources)
        self.update_sync()

    def update_conversation_views(self):
        """
        Updates the conversation view to reflect progress
        of the download and decryption of messages and replies.
        """
        for conversation_wrapper in self.gui.conversations.values():
            conv = conversation_wrapper.conversation
            self.session.refresh(conv.source)
            conv.update_conversation(conv.source.collection)

    def on_update_star_complete(self, result):
        """
        After we star or unstar a source, we should sync the API
        such that the local database is updated.

        TODO: Improve the push to server sync logic.
        """
        if isinstance(result, bool) and result:  # result may be an exception.
            self.sync_api()  # Syncing the API also updates the source list UI
            self.gui.update_error_status("")
        else:
            # Here we need some kind of retry logic.
            logging.info("failed to push change to server")
            error = _('Failed to apply change.')
            self.gui.update_error_status(error)

    def update_star(self, source_db_object):
        """
        Star or unstar. The callback here is the API sync as we first make sure
        that we apply the change to the server, and then update locally.
        """
        if not self.api:  # Then we should tell the user they need to login.
            self.on_action_requiring_login()
            return
        else:  # Clear the error status bar
            self.gui.update_error_status("")

        source_sdk_object = sdclientapi.Source(uuid=source_db_object.uuid)

        if source_db_object.is_starred:
            self.call_api(self.api.remove_star, self.on_update_star_complete,
                          self.on_sidebar_action_timeout, source_sdk_object)
        else:
            self.call_api(self.api.add_star, self.on_update_star_complete,
                          self.on_sidebar_action_timeout, source_sdk_object)

    def logout(self):
        """
        Reset the API object and force the UI to update into a logged out
        state.
        """
        self.api = None
        self.message_sync.api = None
        self.reply_sync.api = None
        self.gui.logout()

    def set_status(self, message, duration=5000):
        """
        Set a textual status message to be displayed to the user for a certain
        duration.
        """
        self.gui.set_status(message, duration)

    def on_file_open(self, submission_db_object):
        """
        Open the already downloaded file associated with the message (which
        is a Submission).
        """

        # Once downloaded, submissions are stored in the data directory
        # with the same filename as the server, except with the .gz.gpg
        # stripped off.
        server_filename = submission_db_object.filename
        fn_no_ext, _ = os.path.splitext(os.path.splitext(server_filename)[0])
        submission_filepath = os.path.join(self.data_dir, fn_no_ext)

        if self.proxy:
            # Running on Qubes.
            command = "qvm-open-in-vm"
            args = ['$dispvm:sd-svs-disp', submission_filepath]

            # QProcess (Qt) or Python's subprocess? Who cares? They do the
            # same thing. :-)
            process = QProcess(self)
            process.start(command, args)
        else:  # pragma: no cover
            # Non Qubes OS. Just log the event for now.
            logger.info('Opening file "{}".'.format(submission_filepath))

    def on_file_download(self, source_db_object, message):
        """
        Download the file associated with the associated message (which may
        be a Submission or Reply).
        """
        if not self.api:  # Then we should tell the user they need to login.
            self.on_action_requiring_login()
            return

        if isinstance(message, db.Submission):
            # Handle submissions.
            func = self.api.download_submission
            sdk_object = sdclientapi.Submission(uuid=message.uuid)
            sdk_object.filename = message.filename
            sdk_object.source_uuid = source_db_object.uuid
        elif isinstance(message, db.Reply):
            # Handle journalist's replies.
            func = self.api.download_reply
            sdk_object = sdclientapi.Reply(uuid=message.uuid)
            sdk_object.filename = message.filename
            sdk_object.source_uuid = source_db_object.uuid

        self.set_status(_('Downloading {}'.format(sdk_object.filename)))
        self.call_api(func, self.on_file_downloaded,
                      self.on_download_timeout, sdk_object, self.data_dir,
                      current_object=message)

    def on_file_downloaded(self, result, current_object):
        """
        Called when a file has downloaded. Cause a refresh to the conversation
        view to display the contents of the new file.
        """
        file_uuid = current_object.uuid
        server_filename = current_object.filename
        if isinstance(result, tuple):  # The file properly downloaded.
            sha256sum, filename = result
            # The filename contains the location where the file has been
            # stored. On non-Qubes OSes, this will be the data directory.
            # On Qubes OS, this will a ~/QubesIncoming directory. In case
            # we are on Qubes, we should move the file to the data directory
            # and name it the same as the server (e.g. spotless-tater-msg.gpg).
            filepath_in_datadir = os.path.join(self.data_dir, server_filename)
            shutil.move(filename, filepath_in_datadir)

            try:
                # Attempt to decrypt the file.
                self.gpg.decrypt_submission_or_reply(
                    filepath_in_datadir, server_filename, is_doc=True)
            except CryptoError as e:
                logger.debug('Failed to decrypt file {}: {}'.format(server_filename, e))

                self.set_status("Failed to download and decrypt file, "
                                "please try again.")
                # TODO: We should save the downloaded content, and just
                # try to decrypt again if there was a failure.
                return  # If we failed we should stop here.

            # Now that download and decrypt are done, mark the file as such.
            storage.mark_file_as_downloaded(file_uuid, self.session)

            self.set_status('Finished downloading {}'.format(current_object.filename))
        else:  # The file did not download properly.
            logger.debug('Failed to download file {}'.format(server_filename))
            # Update the UI in some way to indicate a failure state.
            self.set_status("The file download failed. Please try again.")

    def on_download_timeout(self, current_object):
        """
        Called when downloading a file has timed out.
        """
        # Update the status bar to indicate a failure state.
        self.set_status("The connection to the SecureDrop server timed out. "
                        "Please try again.")

    def _on_delete_source_complete(self, result):
        """Trigger this when delete operation on source is completed."""
        if result:
            self.sync_api()
            self.gui.update_error_status("")
        else:
            logging.info("failed to delete source at server")
            error = _('Failed to delete source at server')
            self.gui.update_error_status(error)

    def _on_delete_action_timeout(self):
        """Trigger this when delete operation on source of is timeout."""
        error = _('The connection to SecureDrop timed out. Please try again.')
        self.gui.update_error_status(error)

    def delete_source(self, source):
        """Performs a delete operation on source record.

        This method will first request server to delete the source record. If
        the process of deleting record at server is successful, it will sync
        the server records with the local state. On failure, it will display an
        error.
        """
        self.call_api(
            self.api.delete_source,
            self._on_delete_source_complete,
            self._on_delete_action_timeout,
            source
        )
Esempio n. 10
0
class Controller(QObject):
    """
    Represents the logic for the secure drop client application. In an MVC
    application, this is the controller.
    """

    sync_events = pyqtSignal(str)
    """
    Signal that notifies that a reply was accepted by the server. Emits the reply's UUID as a
    string.
    """
    reply_succeeded = pyqtSignal(str)
    """
    Signal that notifies that a reply failed to be accepted by the server. Emits the reply's UUID
    as a string.
    """
    reply_failed = pyqtSignal(str)
    """
    A signal that emits a signal when the authentication state changes.
    - `True` when the client becomes authenticated
    - `False` when the client becomes unauthenticated
    """
    authentication_state = pyqtSignal(bool)
    """
    This signal indicates that a file has been successfully downloaded by emitting the file's
    UUID as a string.
    """
    file_ready = pyqtSignal(str)
    """
    This signal indicates that a message has been successfully downloaded by emitting the message's
    UUID as a string.
    """
    message_ready = pyqtSignal([str, str])
    """
    This signal indicates that a reply has been successfully downloaded by emitting the reply's
    UUID as a string.
    """
    reply_ready = pyqtSignal([str, str])

    def __init__(self,
                 hostname: str,
                 gui,
                 session_maker: sessionmaker,
                 home: str,
                 proxy: bool = True,
                 qubes: bool = True) -> None:
        """
        The hostname, gui and session objects are used to coordinate with the
        various other layers of the application: the location of the SecureDrop
        proxy, the user interface and SqlAlchemy local storage respectively.
        """
        check_dir_permissions(home)
        super().__init__()

        # Controller is unauthenticated by default
        self.__is_authenticated = False

        # used for finding DB in sync thread
        self.home = home

        # boolean flag for whether or not the client is operating behind a proxy
        self.proxy = proxy

        # boolean flag for whether the client is running within Qubes
        # (regardless of proxy state, to support local dev in an AppVM)
        self.qubes = qubes

        # Location of the SecureDrop server.
        self.hostname = hostname

        # Reference to the UI window.
        self.gui = gui

        # Reference to the API for secure drop proxy.
        self.api = None  # type: sdclientapi.API

        # Reference to the SqlAlchemy `sessionmaker` and `session`
        self.session_maker = session_maker
        self.session = session_maker()

        # Queue that handles running API job
        self.api_job_queue = ApiJobQueue(self.api, self.session_maker)
        self.api_job_queue.paused.connect(self.on_queue_paused)

        # Contains active threads calling the API.
        self.api_threads = {}  # type: Dict[str, Dict]

        self.gpg = GpgHelper(home, self.session_maker, proxy)

        self.export = Export()

        self.sync_flag = os.path.join(home, 'sync_flag')

        # File data.
        self.data_dir = os.path.join(self.home, 'data')

    @property
    def is_authenticated(self) -> bool:
        return self.__is_authenticated

    @is_authenticated.setter
    def is_authenticated(self, is_authenticated: bool) -> None:
        if self.__is_authenticated != is_authenticated:
            self.authentication_state.emit(is_authenticated)
            self.__is_authenticated = is_authenticated

    @is_authenticated.deleter
    def is_authenticated(self) -> None:
        raise AttributeError('Cannot delete is_authenticated')

    def setup(self):
        """
        Setup the application with the default state of:

        * Not logged in.
        * Show most recent state of syncronised sources.
        * Show the login screen.
        * Check the sync status every 30 seconds.
        """
        # The gui needs to reference this "controller" layer to call methods
        # triggered by UI events.
        self.gui.setup(self)

        # Create a timer to check for sync status every 30 seconds.
        self.sync_timer = QTimer()
        self.sync_timer.timeout.connect(self.update_sync)
        self.sync_timer.start(30000)

        # Automagically sync with the API every 5 minutes.
        self.sync_update = QTimer()
        self.sync_update.timeout.connect(self.sync_api)
        self.sync_update.start(1000 * 60 * 5)  # every 5 minutes.

    def call_api(self,
                 api_call_func,
                 success_callback,
                 failure_callback,
                 *args,
                 current_object=None,
                 **kwargs):
        """
        Calls the function in a non-blocking manner. Upon completion calls the
        callback with the result. Calls timeout if the timer associated with
        the call emits a timeout signal. Any further arguments are passed to
        the function to be called.
        """
        new_thread_id = str(uuid.uuid4())  # Uniquely id the new thread.

        new_api_thread = QThread(self.gui)
        new_api_runner = APICallRunner(api_call_func, current_object, *args,
                                       **kwargs)
        new_api_runner.moveToThread(new_api_thread)

        # handle completed call: copy response data, reset the
        # client, give the user-provided callback the response
        # data
        new_api_runner.call_succeeded.connect(
            lambda: self.completed_api_call(new_thread_id, success_callback))
        new_api_runner.call_failed.connect(
            lambda: self.completed_api_call(new_thread_id, failure_callback))

        # when the thread starts, we want to run `call_api` on `api_runner`
        new_api_thread.started.connect(new_api_runner.call_api)

        # Add the thread related objects to the api_threads dictionary.
        self.api_threads[new_thread_id] = {
            'thread': new_api_thread,
            'runner': new_api_runner,
        }

        # Start the thread and related activity.
        new_api_thread.start()

    def on_queue_paused(self) -> None:
        if self.api is None:
            self.gui.update_error_status(
                _('The SecureDrop server cannot be reached.'))
        else:
            self.gui.update_error_status(
                _('The SecureDrop server cannot be reached.'),
                duration=0,
                retry=True)

    def resume_queues(self) -> None:
        self.api_job_queue.resume_queues()

    def completed_api_call(self, thread_id, user_callback):
        """
        Manage a completed API call. The actual result *may* be an exception or
        error result from the API. It's up to the handler (user_callback) to
        handle these potential states.
        """
        logger.info("Completed API call. Cleaning up and running callback.")
        thread_info = self.api_threads.pop(thread_id)
        runner = thread_info['runner']
        result_data = runner.result

        arg_spec = inspect.getfullargspec(user_callback)
        if 'current_object' in arg_spec.args:
            user_callback(result_data, current_object=runner.current_object)
        else:
            user_callback(result_data)

    def login(self, username, password, totp):
        """
        Given a username, password and time based one-time-passcode (TOTP),
        create a new instance representing the SecureDrop api and authenticate.
        """
        self.api = sdclientapi.API(self.hostname, username, password, totp,
                                   self.proxy)
        self.call_api(self.api.authenticate, self.on_authenticate_success,
                      self.on_authenticate_failure)

    def on_authenticate_success(self, result):
        """
        Handles a successful authentication call against the API.
        """
        logger.info('{} successfully logged in'.format(self.api.username))
        self.gui.hide_login()
        self.sync_api()
        self.call_api(self.api.get_current_user,
                      self.on_get_current_user_success,
                      self.on_get_current_user_failure)
        self.api_job_queue.login(self.api)

        self.is_authenticated = True
        self.resume_queues()

    def on_authenticate_failure(self, result: Exception) -> None:
        # Failed to authenticate. Reset state with failure message.
        self.api = None
        error = _('There was a problem signing in. '
                  'Please verify your credentials and try again.')
        self.gui.show_login_error(error=error)

    def on_get_current_user_success(self, result) -> None:
        user = storage.update_and_get_user(result['uuid'], result['username'],
                                           result['first_name'],
                                           result['last_name'], self.session)
        self.gui.show_main_window(user)

    def on_get_current_user_failure(self, result: Exception) -> None:
        self.api = None
        self.gui.show_login_error(error=_('Could not find your account.'))

    def login_offline_mode(self):
        """
        Allow user to view in offline mode without authentication.
        """
        self.gui.hide_login()
        self.gui.show_main_window()
        self.is_authenticated = False
        self.update_sources()

    def on_action_requiring_login(self):
        """
        Indicate that a user needs to login to perform the specified action.
        """
        error = _('You must sign in to perform this action.')
        self.gui.update_error_status(error)

    def authenticated(self):
        """
        Return a boolean indication that the connection to the API is
        authenticated.
        """
        return bool(self.api and self.api.token is not None)

    def sync_api(self):
        """
        Grab data from the remote SecureDrop API in a non-blocking manner.
        """
        logger.debug("In sync_api on thread {}".format(
            self.thread().currentThreadId()))
        self.sync_events.emit('syncing')

        if self.authenticated():
            logger.debug("You are authenticated, going to make your call")
            self.call_api(storage.get_remote_data, self.on_sync_success,
                          self.on_sync_failure, self.api)
            logger.debug("In sync_api, after call to call_api, on "
                         "thread {}".format(self.thread().currentThreadId()))

    def last_sync(self):
        """
        Returns the time of last synchronisation with the remote SD server.
        """
        try:
            with open(self.sync_flag) as f:
                return arrow.get(f.read())
        except Exception:
            return None

    def on_sync_success(self, result) -> None:
        """
        Called when syncronisation of data via the API succeeds
        """
        # Update db with new metadata
        remote_sources, remote_submissions, remote_replies = result
        storage.update_local_storage(self.session, remote_sources,
                                     remote_submissions, remote_replies,
                                     self.data_dir)

        # Set last sync flag
        with open(self.sync_flag, 'w') as f:
            f.write(arrow.now().format())

        # Import keys into keyring
        for source in remote_sources:
            if source.key and source.key.get('type', None) == 'PGP':
                pub_key = source.key.get('public', None)
                fingerprint = source.key.get('fingerprint', None)
                if not pub_key or not fingerprint:
                    continue
                try:
                    self.gpg.import_key(source.uuid, pub_key, fingerprint)
                except CryptoError:
                    logger.warning('Failed to import key for source {}'.format(
                        source.uuid))

        self.update_sources()
        self.download_new_messages()
        self.download_new_replies()
        self.sync_events.emit('synced')

    def on_sync_failure(self, result: Exception) -> None:
        """
        Called when syncronisation of data via the API fails.
        """
        self.gui.update_error_status(
            _('The SecureDrop server cannot be reached.'),
            duration=0,
            retry=True)

    def update_sync(self):
        """
        Updates the UI to show human time of last sync.
        """
        self.gui.show_sync(self.last_sync())

    def update_sources(self):
        """
        Display the updated list of sources with those found in local storage.
        """
        sources = list(storage.get_local_sources(self.session))
        if sources:
            sources.sort(key=lambda x: x.last_updated, reverse=True)
        self.gui.show_sources(sources)
        self.update_sync()

    def on_update_star_success(self, result) -> None:
        """
        After we star a source, we should sync the API such that the local database is updated.
        """
        self.sync_api()  # Syncing the API also updates the source list UI

    def on_update_star_failure(self, result: UpdateStarJobException) -> None:
        """
        After we unstar a source, we should sync the API such that the local database is updated.
        """
        logging.info("failed to push change to server")
        error = _('Failed to update star.')
        self.gui.update_error_status(error)

    def update_star(self, source_db_object):
        """
        Star or unstar. The callback here is the API sync as we first make sure
        that we apply the change to the server, and then update locally.
        """
        if not self.api:  # Then we should tell the user they need to login.
            self.on_action_requiring_login()
            return

        job = UpdateStarJob(source_db_object.uuid, source_db_object.is_starred)
        job.success_signal.connect(self.on_update_star_success,
                                   type=Qt.QueuedConnection)
        job.failure_signal.connect(self.on_update_star_failure,
                                   type=Qt.QueuedConnection)

        self.api_job_queue.enqueue(job)

    def logout(self):
        """
        Call logout function in the API, reset the API object, and force the UI
        to update into a logged out state.
        """
        self.call_api(self.api.logout, self.on_logout_success,
                      self.on_logout_failure)
        self.api = None
        self.api_job_queue.logout()
        self.gui.logout()
        self.is_authenticated = False

    def set_status(self, message, duration=5000):
        """
        Set a textual status message to be displayed to the user for a certain
        duration.
        """
        self.gui.update_activity_status(message, duration)

    def _submit_download_job(self, object_type: Union[Type[db.Reply],
                                                      Type[db.Message],
                                                      Type[db.File]],
                             uuid: str) -> None:

        if object_type == db.Reply:
            job = ReplyDownloadJob(
                uuid, self.data_dir, self.gpg
            )  # type: Union[ReplyDownloadJob, MessageDownloadJob, FileDownloadJob]
            job.success_signal.connect(self.on_reply_download_success,
                                       type=Qt.QueuedConnection)
            job.failure_signal.connect(self.on_reply_download_failure,
                                       type=Qt.QueuedConnection)
        elif object_type == db.Message:
            job = MessageDownloadJob(uuid, self.data_dir, self.gpg)
            job.success_signal.connect(self.on_message_download_success,
                                       type=Qt.QueuedConnection)
            job.failure_signal.connect(self.on_message_download_failure,
                                       type=Qt.QueuedConnection)
        elif object_type == db.File:
            job = FileDownloadJob(uuid, self.data_dir, self.gpg)
            job.success_signal.connect(self.on_file_download_success,
                                       type=Qt.QueuedConnection)
            job.failure_signal.connect(self.on_file_download_failure,
                                       type=Qt.QueuedConnection)

        self.api_job_queue.enqueue(job)

    def download_new_messages(self) -> None:
        messages = storage.find_new_messages(self.session)

        if len(messages) > 0:
            self.set_status(_('Downloading new messages'))

        for message in messages:
            self._submit_download_job(type(message), message.uuid)

    def on_message_download_success(self, uuid: str) -> None:
        """
        Called when a message has downloaded.
        """
        message = storage.get_message(self.session, uuid)
        self.message_ready.emit(message.uuid, message.content)

    def on_message_download_failure(self, exception: Exception) -> None:
        """
        Called when a message fails to download.
        """
        logger.debug('Failed to download message: {}'.format(exception))

        # Keep resubmitting the job if the download is corrupted.
        if isinstance(exception, DownloadChecksumMismatchException):
            logger.debug(
                'Failure due to checksum mismatch, retrying {}'.format(
                    exception.uuid))
            self._submit_download_job(exception.object_type, exception.uuid)

    def download_new_replies(self) -> None:
        replies = storage.find_new_replies(self.session)
        for reply in replies:
            self._submit_download_job(type(reply), reply.uuid)

    def on_reply_download_success(self, uuid: str) -> None:
        """
        Called when a reply has downloaded.
        """
        reply = storage.get_reply(self.session, uuid)
        self.reply_ready.emit(reply.uuid, reply.content)

    def on_reply_download_failure(self, exception: Exception) -> None:
        """
        Called when a reply fails to download.
        """
        logger.debug('Failed to download reply: {}'.format(exception))

        # Keep resubmitting the job if the download is corrupted.
        if isinstance(exception, DownloadChecksumMismatchException):
            logger.debug(
                'Failure due to checksum mismatch, retrying {}'.format(
                    exception.uuid))
            self._submit_download_job(exception.object_type, exception.uuid)

    def on_file_open(self, file_uuid: str) -> None:
        """
        Open the already downloaded file associated with the message (which is a `File`).
        """
        # Once downloaded, submissions are stored in the data directory
        # with the same filename as the server, except with the .gz.gpg
        # stripped off.
        file = self.get_file(file_uuid)
        fn_no_ext, _ = os.path.splitext(os.path.splitext(file.filename)[0])
        submission_filepath = os.path.join(self.data_dir, fn_no_ext)
        original_filepath = os.path.join(self.data_dir, file.original_filename)

        if os.path.exists(original_filepath):
            os.remove(original_filepath)
        os.link(submission_filepath, original_filepath)
        if self.proxy or self.qubes:
            # Running on Qubes.
            command = "qvm-open-in-vm"
            args = ['$dispvm:sd-svs-disp', original_filepath]

            # QProcess (Qt) or Python's subprocess? Who cares? They do the
            # same thing. :-)
            process = QProcess(self)
            process.start(command, args)
        else:  # pragma: no cover
            # Non Qubes OS. Just log the event for now.
            logger.info('Opening file "{}".'.format(original_filepath))

    def run_export_preflight_checks(self):
        '''
        Run preflight checks to make sure the Export VM is configured correctly and
        '''
        logger.debug('Running export preflight checks')

        if not self.qubes:
            return

        self.export.run_preflight_checks()

    def export_file_to_usb_drive(self, file_uuid: str,
                                 passphrase: str) -> None:
        file = self.get_file(file_uuid)

        logger.debug('Exporting {}'.format(file.original_filename))

        if not self.qubes:
            return

        filepath = os.path.join(self.data_dir, file.original_filename)
        self.export.send_file_to_usb_device([filepath], passphrase)

        logger.debug('Export successful')

    def on_submission_download(
        self,
        submission_type: Union[Type[db.File], Type[db.Message]],
        submission_uuid: str,
    ) -> None:
        """
        Download the file associated with the Submission (which may be a File or Message).
        """
        if self.api:
            self._submit_download_job(submission_type, submission_uuid)
            self.set_status(_('Downloading file'))
        else:
            self.on_action_requiring_login()

    def on_file_download_success(self, result: Any) -> None:
        """
        Called when a file has downloaded.
        """
        self.file_ready.emit(result)

    def on_file_download_failure(self, exception: Exception) -> None:
        """
        Called when a file fails to download.
        """
        logger.debug('Failed to download file: {}'.format(exception))

        # Keep resubmitting the job if the download is corrupted.
        if isinstance(exception, DownloadChecksumMismatchException):
            logger.debug(
                'Failure due to checksum mismatch, retrying {}'.format(
                    exception.uuid))
            self._submit_download_job(exception.object_type, exception.uuid)
        else:
            self.set_status(_('The file download failed. Please try again.'))

    def on_delete_source_success(self, result) -> None:
        """
        Handler for when a source deletion succeeds.
        """
        self.sync_api()

    def on_delete_source_failure(self, result: Exception) -> None:
        logging.info("failed to delete source at server")
        error = _('Failed to delete source at server')
        self.gui.update_error_status(error)

    def delete_source(self, source):
        """Performs a delete operation on source record.

        This method will first request server to delete the source record. If
        the process of deleting record at server is successful, it will sync
        the server records with the local state. On failure, it will display an
        error.
        """
        self.call_api(self.api.delete_source, self.on_delete_source_success,
                      self.on_delete_source_failure, source)

    def send_reply(self, source_uuid: str, reply_uuid: str,
                   message: str) -> None:
        """
        Send a reply to a source.
        """
        job = SendReplyJob(
            source_uuid,
            reply_uuid,
            message,
            self.gpg,
        )
        job.success_signal.connect(self.on_reply_success,
                                   type=Qt.QueuedConnection)
        job.failure_signal.connect(self.on_reply_failure,
                                   type=Qt.QueuedConnection)

        self.api_job_queue.enqueue(job)

    def on_reply_success(self, reply_uuid: str) -> None:
        logger.debug('{} sent successfully'.format(reply_uuid))
        self.reply_succeeded.emit(reply_uuid)
        self.sync_api()

    def on_reply_failure(
            self, exception: Union[SendReplyJobError,
                                   SendReplyJobTimeoutError]) -> None:
        logger.debug('{} failed to send'.format(exception.reply_uuid))
        self.reply_failed.emit(exception.reply_uuid)

    def get_file(self, file_uuid: str) -> db.File:
        file = storage.get_file(self.session, file_uuid)
        self.session.refresh(file)
        return file

    def on_logout_success(self, result) -> None:
        logging.info('Client logout successful')

    def on_logout_failure(self, result: Exception) -> None:
        logging.info('Client logout failure')