Skip to content

Commit

Permalink
Merge pull request freedomofpress#113 from freedomofpress/rest-of-mvp
Browse files Browse the repository at this point in the history
file decryption and open
  • Loading branch information
redshiftzero authored Nov 7, 2018
2 parents 970168e + 599f4fd commit 5fcf0cd
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 55 deletions.
77 changes: 77 additions & 0 deletions securedrop_client/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Copyright (C) 2018 The Freedom of the Press Foundation.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""

import gzip
import logging
import os
import shutil
import subprocess
import tempfile

from securedrop_client.models import make_engine


logger = logging.getLogger(__name__)


def decrypt_submission(filepath, target_filename, home_dir, is_qubes=True,
is_doc=False):
out = tempfile.NamedTemporaryFile(suffix=".message")
err = tempfile.NamedTemporaryFile(suffix=".message-error", delete=False)
if is_qubes:
gpg_binary = "qubes-gpg-client"
else:
gpg_binary = "gpg"
cmd = [gpg_binary, "--decrypt", filepath]
res = subprocess.call(cmd, stdout=out, stderr=err)

os.unlink(filepath) # original file

if res != 0:
out.close()
err.close()

with open(err.name) as e:
msg = e.read()
logger.error("GPG error: {}".format(msg))

os.unlink(err.name)
dest = ""
else:
if is_doc:
# Docs are gzipped, so gunzip the file
with gzip.open(out.name, 'rb') as infile:
unzipped_decrypted_data = infile.read()

# Need to split twice as filename is e.g.
# 1-impractical_thing-doc.gz.gpg
fn_no_ext, _ = os.path.splitext(
os.path.splitext(os.path.basename(filepath))[0])
dest = os.path.join(home_dir, "data", fn_no_ext)

with open(dest, 'wb') as outfile:
outfile.write(unzipped_decrypted_data)
else:
fn_no_ext, _ = os.path.splitext(target_filename)
dest = os.path.join(home_dir, "data", fn_no_ext)
shutil.copy(out.name, dest)

out.close()
err.close()
logger.info("Downloaded and decrypted: {}".format(dest))

return res, dest
10 changes: 8 additions & 2 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,15 @@ def __init__(self, source_db_object, submission_db_object,

def mouseReleaseEvent(self, e):
"""
Handle a completed click via the program logic.
Handle a completed click via the program logic. The download state
of the file distinguishes which function in the logic layer to call.
"""
self.controller.on_file_click(self.source, self.submission)
if self.submission.is_downloaded:
# Open the already downloaded file.
self.controller.on_file_open(self.submission)
else:
# Download the file.
self.controller.on_file_download(self.source, self.submission)


class ConversationView(QWidget):
Expand Down
57 changes: 49 additions & 8 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@
import copy
import uuid
from sqlalchemy import event
from securedrop_client import crypto
from securedrop_client import storage
from securedrop_client import models
from securedrop_client.utils import check_dir_permissions
from securedrop_client.data import Data
from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer
from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer, QProcess
from securedrop_client.message_sync import MessageSync


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -439,7 +439,33 @@ def set_status(self, message, duration=5000):
"""
self.gui.set_status(message, duration)

def on_file_click(self, source_db_object, message):
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).
Expand All @@ -457,33 +483,48 @@ def on_file_click(self, source_db_object, message):
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,
self.call_api(func, self.on_file_downloaded,
self.on_download_timeout, sdk_object, self.data_dir,
current_object=message)

def on_file_download(self, result, current_object):
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):
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).
shutil.move(filename, os.path.join(self.data_dir, server_filename))
filepath_in_datadir = os.path.join(self.data_dir, server_filename)
shutil.move(filename, filepath_in_datadir)

# Attempt to decrypt the file.
res, filepath = crypto.decrypt_submission(
filepath_in_datadir, server_filename, self.home,
self.proxy, is_doc=True)

if res != 0: # Then the file did not decrypt properly.
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)

# Refresh the current source conversation, bearing in mind
# that the user may have navigated to another source.
self.gui.show_conversation_for(self.gui.current_source)
self.set_status(
'Finished downloading {}'.format(current_object.filename))
else:
else: # The file did not download properly.
# Update the UI in some way to indicate a failure state.
self.set_status("The file download failed. Please try again.")

Expand Down
41 changes: 9 additions & 32 deletions securedrop_client/message_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from PyQt5.QtCore import QObject
from securedrop_client import storage
from securedrop_client import crypto
from securedrop_client.models import make_engine

from sqlalchemy.orm import sessionmaker
Expand Down Expand Up @@ -57,48 +58,24 @@ def run(self, loop=True):

for m in submissions:
try:

# api.download_submission wants an _api_ submission
# object, which is different from own submission
# object. so we coerce that here.
sdk_submission = sdkobjects.Submission(
uuid=m.uuid
)
sdk_submission.source_uuid = m.source.uuid
# Need to set filename on non-Qubes platforms
# Needed for non-Qubes platforms
sdk_submission.filename = m.filename
shasum, filepath = self.api.download_submission(
sdk_submission
)
out = tempfile.NamedTemporaryFile(suffix=".message")
err = tempfile.NamedTemporaryFile(suffix=".message-error",
delete=False)
if self.is_qubes:
gpg_binary = "qubes-gpg-client"
else:
gpg_binary = "gpg"
cmd = [gpg_binary, "--decrypt", filepath]
res = subprocess.call(cmd, stdout=out, stderr=err)

os.unlink(filepath) # original file

if res != 0:
out.close()
err.close()

with open(err.name) as e:
msg = e.read()
logger.error("GPG error: {}".format(msg))

os.unlink(err.name)
else:
fn_no_ext, _ = os.path.splitext(m.filename)
dest = os.path.join(self.home, "data", fn_no_ext)
shutil.copy(out.name, dest)
err.close()
sdk_submission)
res, stored_filename = crypto.decrypt_submission(
filepath, m.filename, self.home,
is_qubes=self.is_qubes)
if res == 0:
storage.mark_file_as_downloaded(m.uuid, self.session)

logger.info("Stored message at {}".format(out.name))
logger.info("Stored message at {}".format(
stored_filename))
except Exception as e:
logger.critical(
"Exception while downloading submission! {}".format(e)
Expand Down
Binary file added tests/files/test-doc.gz.gpg
Binary file not shown.
24 changes: 21 additions & 3 deletions tests/gui/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,9 +413,27 @@ def test_FileWidget_init_right():
assert fw.controller == mock_controller


def test_FileWidget_mousePressEvent():
def test_FileWidget_mousePressEvent_download():
"""
Should fire the expected event handler in the logic layer.
Should fire the expected download event handler in the logic layer.
"""
mock_message = mock.MagicMock()
mock_controller = mock.MagicMock()
source = models.Source('source-uuid', 'testy-mctestface', False,
'mah pub key', 1, False, datetime.now())
submission = models.Submission(source, 'submission-uuid', 123,
'mah-reply.gpg',
'http://mah-server/mah-reply-url')
submission.is_downloaded = False

fw = FileWidget(source, submission, mock_controller)
fw.mouseReleaseEvent(None)
fw.controller.on_file_download.assert_called_once_with(source, submission)


def test_FileWidget_mousePressEvent_open():
"""
Should fire the expected open event handler in the logic layer.
"""
mock_message = mock.MagicMock()
mock_controller = mock.MagicMock()
Expand All @@ -428,7 +446,7 @@ def test_FileWidget_mousePressEvent():

fw = FileWidget(source, submission, mock_controller)
fw.mouseReleaseEvent(None)
fw.controller.on_file_click.assert_called_once_with(source, submission)
fw.controller.on_file_open.assert_called_once_with(submission)


def test_ConversationView_init():
Expand Down
31 changes: 31 additions & 0 deletions tests/test_crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pytest
import os
from unittest import mock

from securedrop_client.crypto import decrypt_submission


def test_gunzip_logic(safe_tmpdir):
"""
Ensure that gzipped documents/files are handled
"""
# Make data dir since we do need it for this test
data_dir = os.path.join(str(safe_tmpdir), 'data')
if not os.path.exists(data_dir):
os.makedirs(data_dir)

test_gzip = 'tests/files/test-doc.gz.gpg'
expected_output_filename = 'test-doc'

with mock.patch('subprocess.call',
return_value=0) as mock_gpg, \
mock.patch('os.unlink') as mock_unlink:
res, dest = decrypt_submission(
test_gzip, expected_output_filename,
str(safe_tmpdir), is_qubes=False,
is_doc=True)

assert mock_gpg.call_count == 1
assert res == 0
assert dest == '{}/data/{}'.format(
str(safe_tmpdir), expected_output_filename)
Loading

0 comments on commit 5fcf0cd

Please sign in to comment.