diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..8104f05fc --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,12 @@ +include LICENSE +include README.md +include securedrop_export/VERSION +include changelog.md +include build-requirements.txt +include requirements.txt +include securedrop_export/*.py +include setup.py +include files/send-to-usb.desktop +include files/application-x-sd-export.xml +include files/sd-logo.png + diff --git a/build-requirements.txt b/build-requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.md b/changelog.md new file mode 100644 index 000000000..02c74ef5a --- /dev/null +++ b/changelog.md @@ -0,0 +1,6 @@ +securedrop-export (0.1.1-1) unstable; urgency=medium + + [ Freedom Of The Press Foundation ] + * Initial release + + -- SecureDrop Team Thu, 18 Jul 2019 10:47:38 -0700 diff --git a/files/application-x-sd-export.xml b/files/application-x-sd-export.xml new file mode 100644 index 000000000..9e36ef08b --- /dev/null +++ b/files/application-x-sd-export.xml @@ -0,0 +1,7 @@ + + + + Archive for transfering files from the SecureDrop workstation to an external USB device. + + + diff --git a/files/sd-logo.png b/files/sd-logo.png new file mode 100644 index 000000000..531cbf26c Binary files /dev/null and b/files/sd-logo.png differ diff --git a/files/send-to-usb.desktop b/files/send-to-usb.desktop new file mode 100644 index 000000000..6521a92c9 --- /dev/null +++ b/files/send-to-usb.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Type=Application +MimeType=application/x-sd-export +Name="Export SD submission to USB" +Exec=/usr/bin/send-to-usb diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..e69de29bb diff --git a/securedrop_export/VERSION b/securedrop_export/VERSION new file mode 100644 index 000000000..17e51c385 --- /dev/null +++ b/securedrop_export/VERSION @@ -0,0 +1 @@ +0.1.1 diff --git a/securedrop_export/__init__.py b/securedrop_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/securedrop_export/entrypoint.py b/securedrop_export/entrypoint.py new file mode 100755 index 000000000..9958d5d05 --- /dev/null +++ b/securedrop_export/entrypoint.py @@ -0,0 +1,23 @@ +import os +import shutil +import sys + +from securedrop_export import export +from securedrop_export import main + + +def start(): + my_sub = export.SDExport(sys.argv[1]) + try: + # Halt immediately if target file is absent + if not os.path.exists(my_sub.archive): + msg = "File does not exist" + my_sub.exit_gracefully(msg) + main.__main__(my_sub) + # Delete extracted achive from tempfile + shutil.rmtree(my_sub.tmpdir) + except Exception as e: + # exit with 0 return code otherwise the os will attempt to open + # the file with another application + msg = "Unhandled exception:" + my_sub.exit_gracefully(msg, e=e) diff --git a/securedrop_export/export.py b/securedrop_export/export.py new file mode 100755 index 000000000..f5ce56a95 --- /dev/null +++ b/securedrop_export/export.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 + +import datetime +import json +import os +import shutil +import signal +import subprocess +import sys +import tarfile +import tempfile +import time + +PRINTER_NAME = "sdw-printer" +PRINTER_WAIT_TIMEOUT = 60 +DEVICE = "/dev/sda1" +MOUNTPOINT = "/media/usb" +ENCRYPTED_DEVICE = "encrypted_volume" +BRLASER_DRIVER = "/usr/share/cups/drv/brlaser.drv" +BRLASER_PPD = "/usr/share/cups/model/br7030.ppd" + + +class Metadata(object): + """ + Object to parse, validate and store json metadata from the sd-export archive. + """ + + METADATA_FILE = "metadata.json" + SUPPORTED_EXPORT_METHODS = ["disk", "printer", "printer-test"] + SUPPORTED_ENCRYPTION_METHODS = ["luks"] + + def __init__(self, archive_path): + self.metadata_path = os.path.join(archive_path, self.METADATA_FILE) + try: + with open(self.metadata_path) as f: + json_config = json.loads(f.read()) + self.export_method = json_config.get("device", None) + self.encryption_method = json_config.get("encryption_method", None) + self.encryption_key = json_config.get("encryption_key", None) + except Exception as e: + raise + + def is_valid(self): + if self.export_method not in self.SUPPORTED_EXPORT_METHODS: + return False + + if self.export_method == "disk": + if self.encryption_method not in self.SUPPORTED_ENCRYPTION_METHODS: + return False + return True + + +class SDExport(object): + + def __init__(self, archive): + self.device = DEVICE + self.mountpoint = MOUNTPOINT + self.encrypted_device = ENCRYPTED_DEVICE + + self.printer_name = PRINTER_NAME + self.printer_wait_timeout = PRINTER_WAIT_TIMEOUT + + self.brlaser_driver = BRLASER_DRIVER + self.brlaser_ppd = BRLASER_PPD + + self.archive = archive + self.submission_dirname = os.path.basename(self.archive).split(".")[0] + self.target_dirname = "sd-export-{}".format( + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + ) + self.tmpdir = tempfile.mkdtemp() + + + def exit_gracefully(self, msg, e=False): + """ + Utility to print error messages, mostly used during debugging, + then exits successfully despite the error. Always exits 0, + since non-zero exit values will cause system to try alternative + solutions for mimetype handling, which we want to avoid. + """ + sys.stderr.write(msg) + sys.stderr.write("\n") + if e: + try: + # If the file archive was extracted, delete before returning + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + e_output = e.output + except Exception: + e_output = "" + sys.stderr.write(e_output) + sys.stderr.write("\n") + # exit with 0 return code otherwise the os will attempt to open + # the file with another application + self.popup_message("Export error: {}".format(msg)) + sys.exit(0) + + + def popup_message(self, msg): + try: + subprocess.check_call([ + "notify-send", + "--expire-time", "3000", + "--icon", "/usr/share/securedrop/icons/sd-logo.png", + "SecureDrop: {}".format(msg) + ]) + except subprocess.CalledProcessError as e: + msg = "Error sending notification:" + self.exit_gracefully(msg, e=e) + + + def extract_tarball(self): + try: + with tarfile.open(self.archive) as tar: + tar.extractall(self.tmpdir) + except Exception as e: + print (e) + msg = "Error opening export bundle: " + self.exit_gracefully(msg, e=e) + + + def unlock_luks_volume(self, encryption_key): + # the luks device is not already unlocked + if not os.path.exists(os.path.join("/dev/mapper/", self.encrypted_device)): + p = subprocess.Popen( + ["sudo", "cryptsetup", "luksOpen", self.device, self.encrypted_device], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + p.communicate(input=str.encode(encryption_key, "utf-8")) + rc = p.returncode + if rc != 0: + msg = "Bad passphrase or luks error." + self.exit_gracefully(msg) + + + def mount_volume(self): + # mount target not created + if not os.path.exists(self.mountpoint): + subprocess.check_call(["sudo", "mkdir", self.mountpoint]) + try: + subprocess.check_call( + [ + "sudo", + "mount", + os.path.join("/dev/mapper/", self.encrypted_device), + self.mountpoint, + ] + ) + subprocess.check_call( + [ + "sudo", + "chown", + "-R", "user:user", self.mountpoint, + ] + ) + except subprocess.CalledProcessError as e: + # clean up + subprocess.check_call(["sudo", "cryptsetup", "luksClose", self.encrypted_device]) + msg = "An error occurred while mounting disk: " + self.exit_gracefully(msg, e=e) + + + def copy_submission(self): + # move files to drive (overwrites files with same filename) and unmount drive + try: + target_path = os.path.join(self.mountpoint, self.target_dirname) + subprocess.check_call(["mkdir", target_path]) + export_data = os.path.join( + self.tmpdir, "export_data/" + ) + subprocess.check_call(["cp", "-r", export_data, target_path]) + self.popup_message("Files exported successfully to disk.") + except (subprocess.CalledProcessError, OSError) as e: + msg = "Error writing to disk:" + self.exit_gracefully(msg, e=e) + finally: + # Finally, we sync the filesystem, unmount the drive and lock the + # luks volume, and exit 0 + subprocess.check_call(["sync"]) + subprocess.check_call(["sudo", "umount", self.mountpoint]) + subprocess.check_call(["sudo", "cryptsetup", "luksClose", self.encrypted_device]) + subprocess.check_call(["rm", "-rf", self.tmpdir]) + sys.exit(0) + + + def wait_for_print(self): + # use lpstat to ensure the job was fully transfered to the printer + # returns True if print was successful, otherwise will throw exceptions + signal.signal(signal.SIGALRM, handler) + signal.alarm(self.printer_wait_timeout) + printer_idle_string = "printer {} is idle".format(self.printer_name) + while(True): + try: + output = subprocess.check_output(["lpstat", "-p", self.printer_name]) + if(printer_idle_string in output.decode("utf-8")): + return True + else: + time.sleep(5) + except subprocess.CalledProcessError as e: + msg = "Error while retrieving print status" + self.exit_gracefully(msg, e=e) + except TimeoutException as e: + msg = "Timeout when getting printer information" + self.exit_gracefully(msg, e=e) + return True + + + def get_printer_uri(self): + # Get the URI via lpinfo and only accept URIs of supported printers + printer_uri = "" + try: + output = subprocess.check_output(["sudo", "lpinfo", "-v"]) + except subprocess.CalledProcessError as e: + msg = "Error retrieving printer uri." + self.exit_gracefully(msg, e=e) + + # fetch the usb printer uri + for line in output.split(): + if "usb://" in line.decode("utf-8"): + printer_uri = line.decode("utf-8") + + # verify that the printer is supported, else exit + if printer_uri == "": + # No usb printer is connected + self.exit_gracefully("USB Printer not found") + elif "Brother" in printer_uri: + return printer_uri + else: + # printer url is a make that is unsupported + self.exit_gracefully("USB Printer not supported") + + + def install_printer_ppd(self, uri): + # Some drivers don't come with ppd files pre-compiled, we must compile them + if "Brother" in uri: + try: + subprocess.check_call( + ["sudo", "ppdc", self.brlaser_driver, "-d", "/usr/share/cups/model/"] + ) + except subprocess.CalledProcessError as e: + msg = "Error installing ppd file for printer {}.".format(uri) + self.exit_gracefully(msg, e=e) + return self.brlaser_ppd + # Here, we could support ppd drivers for other makes or models in the future + + + def setup_printer(self, printer_uri, printer_ppd): + try: + # Add the printer using lpadmin + subprocess.check_call( + [ + "sudo", + "lpadmin", + "-p", + self.printer_name, + "-v", + printer_uri, + "-P", + printer_ppd, + ] + ) + # Activate the printer so that it can receive jobs + subprocess.check_call(["sudo", "lpadmin", "-p", self.printer_name, "-E"]) + # Allow user to print (without using sudo) + subprocess.check_call( + ["sudo", "lpadmin", "-p", self.printer_name, "-u", "allow:user"] + ) + except subprocess.CalledProcessError as e: + msg = "Error setting up printer {} at {} using {}.".format( + self.printer_name, printer_uri, printer_ppd + ) + self.exit_gracefully(msg, e=e) + + + def print_test_page(self): + self.print_file("/usr/share/cups/data/testprint") + self.popup_message("Printing test page") + + + def print_all_files(self): + files_path = os.path.join(self.tmpdir, "export_data/") + files = os.listdir(files_path) + print_count = 0 + for f in files: + file_path = os.path.join(files_path, f) + self.print_file(file_path) + print_count += 1 + msg = "Printing document {} of {}".format(print_count, len(files)) + self.popup_message(msg) + + + def is_open_office_file(self, filename): + OPEN_OFFICE_FORMATS = [".doc", ".docx", ".xls", ".xlsx", + ".ppt", ".pptx", ".odt", ".ods", ".odp"] + for extension in OPEN_OFFICE_FORMATS: + if os.path.basename(filename).endswith(extension): + return True + return False + + + def print_file(self, file_to_print): + try: + # if the file to print is an (open)office document, we need to call unoconf to convert + # the file to pdf as printer drivers do not immediately support this format out of the box + if self.is_open_office_file(file_to_print): + folder = os.path.dirname(file_to_print) + converted_filename = file_to_print + ".pdf" + converted_path = os.path.join(folder, converted_filename) + subprocess.check_call(["unoconv", "-o", converted_path, file_to_print]) + file_to_print = converted_path + + subprocess.check_call(["xpp", "-P", self.printer_name, file_to_print]) + except subprocess.CalledProcessError as e: + msg = "Error printing file {} with printer {}.".format( + file_to_print, self.printer_name + ) + self.exit_gracefully(msg, e=e) + +## class ends here +class TimeoutException(Exception): + pass + + +def handler(s, f): + raise TimeoutException("Timeout") diff --git a/securedrop_export/main.py b/securedrop_export/main.py new file mode 100755 index 000000000..79b5d721d --- /dev/null +++ b/securedrop_export/main.py @@ -0,0 +1,37 @@ +import os +import shutil +import sys + +from securedrop_export import export + +def __main__(submission): + submission.extract_tarball() + + try: + submission.archive_metadata = export.Metadata(submission.tmpdir) + except Exception as e: + msg = "Error parsing metadata: " + submission.exit_gracefully(msg, e=e) + + if submission.archive_metadata.is_valid(): + if submission.archive_metadata.export_method == "disk": + # exports all documents in the archive to luks-encrypted volume + submission.unlock_luks_volume(submission.archive_metadata.encryption_key) + submission.mount_volume() + submission.copy_submission() + elif submission.archive_metadata.export_method == "printer": + # prints all documents in the archive + printer_uri = submission.get_printer_uri() + printer_ppd = submission.install_printer_ppd(printer_uri) + submission.setup_printer(printer_uri, printer_ppd) + submission.print_all_files() + elif submission.archive_metadata.export_method == "printer-test": + # Prints a test page to ensure the printer is functional + printer_uri = submission.get_printer_uri() + printer_ppd = submission.install_printer_ppd(printer_uri) + submission.setup_printer(printer_uri, printer_ppd) + submission.print_test_page() + else: + submission.exit_gracefully("Archive metadata is invalid") + + diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..c13b851e9 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +import pkgutil +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +version = pkgutil.get_data("securedrop_export", "VERSION").decode("utf-8") +version = version.strip() + +setuptools.setup( + name="securedrop-export", + version=version, + author="Freedom of the Press Foundation", + author_email="securedrop@freedom.press", + description="SecureDrop Qubes export scripts", + long_description=long_description, + long_description_content_type="text/markdown", + license="GPLv3+", + install_requires=[], + python_requires=">=3.5", + url="https://github.com/freedomofpress/securedrop-export", + packages=setuptools.find_packages(exclude=["docs", "tests"]), + package_data={ + 'securedrop_export': ['VERSION'], + }, + classifiers=( + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + ), + entry_points={"console_scripts": ["send-to-usb = securedrop_export.entrypoint:start"]}, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..e079f8a60 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 000000000..85dbcd258 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,171 @@ +from unittest import mock + +import os +import pytest +import tempfile + +from securedrop_export import export + +SAMPLE_OUTPUT_NO_PRINTER = b"network beh\nnetwork https\nnetwork ipp\nnetwork ipps\nnetwork http\nnetwork\nnetwork ipp14\nnetwork lpd" # noqa +SAMPLE_OUTPUT_BOTHER_PRINTER = b"network beh\nnetwork https\nnetwork ipp\nnetwork ipps\nnetwork http\nnetwork\nnetwork ipp14\ndirect usb://Brother/HL-L2320D%20series?serial=A00000A000000\nnetwork lpd" # noqa + + +# This below stanza is only necessary because the export code is not +# structured as a module. If a Python module were created called +# `export`, we could simply do `import export` +# path_to_script = os.path.join( +# os.path.dirname(os.path.abspath(__file__)), "send-to-usb" +# ) +# securedropexport = imp.load_source("send-to-usb", path_to_script) + + +def test_exit_gracefully_no_exception(capsys): + submission = export.SDExport("testfile") + test_msg = 'test' + + with pytest.raises(SystemExit) as sysexit: + submission.exit_gracefully(test_msg) + + # A graceful exit means a return code of 0 + assert sysexit.value.code == 0 + + captured = capsys.readouterr() + assert captured.err == "{}\n".format(test_msg) + assert captured.out == "" + + +def test_exit_gracefully_exception(capsys): + submission = export.SDExport("testfile") + test_msg = 'test' + + with pytest.raises(SystemExit) as sysexit: + submission.exit_gracefully(test_msg, + e=Exception('BANG!')) + + # A graceful exit means a return code of 0 + assert sysexit.value.code == 0 + + captured = capsys.readouterr() + assert captured.err == "{}\n\n".format(test_msg) + assert captured.out == "" + + +def test_empty_config(capsys): + submission = export.SDExport("testfile") + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, export.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write("{}") + config = export.Metadata(temp_folder) + assert not config.is_valid() + + +def test_valid_printer_test_config(capsys): + submission = export.SDExport("testfile") + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, export.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write('{"device": "printer-test"}') + config = export.Metadata(temp_folder) + assert config.is_valid() + assert config.encryption_key is None + assert config.encryption_method is None + + +def test_valid_printer_config(capsys): + submission = export.SDExport("") + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, export.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write('{"device": "printer"}') + config = export.Metadata(temp_folder) + assert config.is_valid() + assert config.encryption_key is None + assert config.encryption_method is None + + +def test_invalid_encryption_config(capsys): + submission = export.SDExport("testfile") + + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, export.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write( + '{"device": "disk", "encryption_method": "base64", "encryption_key": "hunter1"}' + ) + config = export.Metadata(temp_folder) + assert config.encryption_key == "hunter1" + assert config.encryption_method == "base64" + assert not config.is_valid() + + +def test_valid_encryption_config(capsys): + submission = export.SDExport("testfile") + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, export.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write( + '{"device": "disk", "encryption_method": "luks", "encryption_key": "hunter1"}' + ) + config = export.Metadata(temp_folder) + assert config.encryption_key == "hunter1" + assert config.encryption_method == "luks" + assert config.is_valid() + + +@mock.patch("subprocess.check_call") +def test_popup_message(mocked_call): + submission = export.SDExport("testfile") + submission.popup_message("hello!") + mocked_call.assert_called_once_with([ + "notify-send", + "--expire-time", "3000", + "--icon", "/usr/share/securedrop/icons/sd-logo.png", + "SecureDrop: hello!" + ]) + + +@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_BOTHER_PRINTER) +def test_get_good_printer_uri(mocked_call): + submission = export.SDExport("testfile") + result = submission.get_printer_uri() + assert result == "usb://Brother/HL-L2320D%20series?serial=A00000A000000" + + +@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_NO_PRINTER) +def test_get_bad_printer_uri(mocked_call, capsys): + submission = export.SDExport("testfile") + expected_message = "USB Printer not found" + mocked_exit = mock.patch("export.exit_gracefully", return_value=0) + + with pytest.raises(SystemExit) as sysexit: + result = submission.get_printer_uri() + assert result == "" + mocked_exit.assert_called_once_with(expected_message) + + assert sysexit.value.code == 0 + captured = capsys.readouterr() + assert captured.err == "{}\n".format(expected_message) + assert captured.out == "" + + +@pytest.mark.parametrize('open_office_paths', [ + "/tmp/whatver/thisisadoc.doc" + "/home/user/Downloads/thisisadoc.xlsx" + "/home/user/Downloads/file.odt" + "/tmp/tmpJf83j9/secret.pptx" +]) +def test_is_open_office_file(capsys, open_office_paths): + submission = export.SDExport("") + assert submission.is_open_office_file(open_office_paths) + + +@pytest.mark.parametrize('open_office_paths', [ + "/tmp/whatver/thisisadoc.doccc" + "/home/user/Downloads/thisisa.xlsx.zip" + "/home/user/Downloads/file.odz" + "/tmp/tmpJf83j9/secret.gpg" +]) +def test_is_not_open_office_file(capsys, open_office_paths): + submission = export.SDExport("") + assert not submission.is_open_office_file(open_office_paths)