diff --git a/README.md b/README.md index 8090c092..d4b7eeb2 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,11 @@ qvm-copy-to-vm sd-export-usb ~/.securedrop_client/data/name-of-file The development plan is to provide functionality in the *SecureDrop Client* that automates step 3, and assists the user in taking these steps via GUI prompts. Eventually we plan to provide other methods for export, such as [OnionShare](https://onionshare.org/) (this will require the attachment of a NetVM), using a dedicated export VM template with tools such as OnionShare and Veracrypt. The next section includes instructions to approximate the OnionShare sharing flow. -##### Automated export flow (Work in progress, client integration TBD) +##### Automated export flows + +The `sd-export-usb` disposable VM handles exports to USB devices through `qvm-open-in-vm`. USB device IDs are configured in `config.json`. The automated export flows make use of the `qvm-usb --persistent` feature. This means that the persistent USB device must be available for `sd-export-usb` to start. In other words, a USB memory stick or a printer must be connected **before** the call to `qvm-open-in-vm sd-export-usb ` is made. + +###### Automated encrypted USB export flow (Work in progress, client integration TBD) The SecureDrop Workstation can automatically export to a luks-encrypted USB device provided the correct format. The file extension of the tar archive must be `.sd-export`, containing the following structure: @@ -200,11 +204,46 @@ The folder `export_data` contains all the files that will be exported to the dis ``` { + "device": "disk" "encryption-method": "luks" "encryption-key": "Your encryption passhrase goes here" } ``` +###### Automated printing flow (Work in progress, client integration TBD) + +The SecureDrop Workstation can automatically print files to a USB-connected printer provided the correct format. The file extension of the tar archive must be `.sd-export`, containing the following structure: + +Note that only Brother printers are supported now (tested with HL-L2320D) + + +``` +. +├── metadata.json +└── export_data + ├── file-to-export-1.txt + ├── file-to-export-2.pdf + ├── file-to-export-3.doc + [...] +``` + +The folder `export_data` contains all the files that will be printed, and the file `metadata.json` contains an instruction indicating that the archive will be printed: + +``` +{ + "device": "printer" +} +``` + +Optionally you can use the `printer-test` device to send a printer test page and ensure the printer is functional + +``` +{ + "device": "printer-test" +} +``` + + ###### Create the transfer device You can find instructions to create a luks-encrypted transfer device in the [SecureDrop docs](https://docs.securedrop.org/en/latest/set_up_transfer_device.html). diff --git a/dom0/fpf-apt-test-repo.sls b/dom0/fpf-apt-test-repo.sls index bc25a8b7..776d59e0 100644 --- a/dom0/fpf-apt-test-repo.sls +++ b/dom0/fpf-apt-test-repo.sls @@ -29,6 +29,7 @@ configure apt-test apt repo: - name: "deb [arch=amd64] https://apt-test-qubes.freedom.press stretch main" - file: /etc/apt/sources.list.d/securedrop_workstation.list - key_url: "salt://sd/sd-workstation/apt-test-pubkey.asc" + - clean_file: True # squash file to ensure there are no duplicates - require: - pkg: install-python-apt-for-repo-config diff --git a/dom0/sd-export-files.sls b/dom0/sd-export-files.sls index 11e72189..8d4626cc 100644 --- a/dom0/sd-export-files.sls +++ b/dom0/sd-export-files.sls @@ -11,10 +11,27 @@ include: - fpf-apt-test-repo -sd-export-template-install-cryptsetup: +sd-export-template-install-packages: pkg.installed: - pkgs: - cryptsetup + - cups + - task-print-server + - system-config-printer + - xpp + - libcups2-dev + - python3-dev + - libtool-bin + - unoconv + +# Libreoffice needs to be installed here to convert to pdf to allow printing +sd-export-install-libreoffice: + pkg.installed: + - name: libreoffice + - retry: + attempts: 3 + interval: 60 + - install_recommends: False sd-export-send-to-usb-script: file.managed: @@ -51,3 +68,12 @@ sd-export-file-format: - require: - file: sd-export-file-format - file: sd-export-desktop-file + +sd-export-securedrop-icon: + file.managed: + - name: /usr/share/securedrop/icons/sd-logo.png + - source: salt://sd/sd-proxy/logo-small.png + - user: root + - group: root + - mode: 644 + - makedirs: True diff --git a/sd-export/send-to-usb b/sd-export/send-to-usb index 172317e4..1f5969f8 100755 --- a/sd-export/send-to-usb +++ b/sd-export/send-to-usb @@ -3,10 +3,13 @@ import datetime import json import os +import shutil +import signal import subprocess import sys import tarfile import tempfile +import time def exit_gracefully(msg, e=False): @@ -20,6 +23,9 @@ def exit_gracefully(msg, e=False): sys.stderr.write("\n") if e: try: + # If the file archive was extracted, delete before returning + if os.path.isdir(SUBMISSION_TMPDIR): + shutil.rmtree(SUBMISSION_TMPDIR) e_output = e.output except Exception: e_output = "" @@ -27,36 +33,62 @@ def exit_gracefully(msg, e=False): sys.stderr.write("\n") # exit with 0 return code otherwise the os will attempt to open # the file with another application + popup_message("Export error: {}".format(msg)) sys.exit(0) -def extract_tarball(): +def popup_message(msg): try: - with tarfile.open(SUBMISSION_ARCHIVE) as tar: - tar.extractall(SUBMISSION_TMPDIR) - except Exception as e: - msg = "Error opening export bundle: " + 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:" exit_gracefully(msg, e=e) -def retrieve_metadata(): +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: + msg = "Error parsing metadata: " + exit_gracefully(msg, e=e) + + 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 + + +def extract_tarball(): try: - metadata_filepath = os.path.join(SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "metadata.json") - with open(metadata_filepath) as json_data: - data = json.load(json_data) - encryption_method = data["encryption-method"] - encryption_key = data["encryption-key"] + with tarfile.open(SUBMISSION_ARCHIVE) as tar: + tar.extractall(SUBMISSION_TMPDIR) except Exception as e: - msg = "Error parsing metadata." + msg = "Error opening export bundle: " exit_gracefully(msg, e=e) - # we only support luks for now - if encryption_method != "luks": - msg = "Unsupported export encryption." - exit_gracefully(msg) - - return (encryption_method, encryption_key) - def unlock_luks_volume(encryption_key): # the luks device is not already unlocked @@ -84,7 +116,7 @@ def mount_volume(): "sudo", "mount", os.path.join("/dev/mapper/", ENCRYPTED_DEVICE), - MOUNTPOINT + MOUNTPOINT, ] ) subprocess.check_call(["sudo", "chown", "-R", "user:user", MOUNTPOINT]) @@ -101,8 +133,11 @@ def copy_submission(): try: TARGET_DIRNAME_path = os.path.join(MOUNTPOINT, TARGET_DIRNAME) subprocess.check_call(["mkdir", TARGET_DIRNAME_path]) - export_data = os.path.join(SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "export_data/") + export_data = os.path.join( + SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "export_data/" + ) subprocess.check_call(["cp", "-r", export_data, TARGET_DIRNAME_path]) + popup_message("Files exported successfully to disk.") except (subprocess.CalledProcessError, OSError) as e: msg = "Error writing to disk:" exit_gracefully(msg, e=e) @@ -116,15 +151,177 @@ def copy_submission(): sys.exit(0) +class TimeoutException(Exception): + pass + + +def handler(s, f): + raise TimeoutException("Timeout") + + +def wait_for_print(): + # 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(PRINTER_WAIT_TIMEOUT) + printer_idle_string = "printer {} is idle".format(PRINTER_NAME) + while(True): + try: + output = subprocess.check_output(["lpstat", "-p", 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" + exit_gracefully(msg, e=e) + except TimeoutException as e: + msg = "Timeout when getting printer information" + exit_gracefully(msg, e=e) + return True + + +def get_printer_uri(): + # 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." + 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 + exit_gracefully("USB Printer not found") + elif "Brother" in printer_uri: + return printer_uri + else: + # printer url is a make that is unsupported + exit_gracefully("USB Printer not supported") + + +def install_printer_ppd(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", BRLASER_DRIVER, "-d", "/usr/share/cups/model/"] + ) + except subprocess.CalledProcessError as e: + msg = "Error installing ppd file for printer {}.".format(uri) + exit_gracefully(msg, e=e) + return BRLASER_PPD + # Here, we could support ppd drivers for other makes or models in the future + + +def setup_printer(printer_name, printer_uri, printer_ppd): + try: + # Add the printer using lpadmin + subprocess.check_call( + [ + "sudo", + "lpadmin", + "-p", + printer_name, + "-v", + printer_uri, + "-P", + printer_ppd, + ] + ) + # Activate the printer so that it can receive jobs + subprocess.check_call(["sudo", "lpadmin", "-p", printer_name, "-E"]) + # Allow user to print (without using sudo) + subprocess.check_call( + ["sudo", "lpadmin", "-p", printer_name, "-u", "allow:user"] + ) + except subprocess.CalledProcessError as e: + msg = "Error setting up printer {} at {} using {}.".format( + printer_name, printer_uri, printer_ppd + ) + exit_gracefully(msg, e=e) + + +def print_test_page(printer_name): + print_file(printer_name, "/usr/share/cups/data/testprint") + popup_message("Printing test page") + + +def print_all_files(printer_name): + files_path = os.path.join(SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "export_data/") + files = os.listdir(files_path) + print_count = 0 + for f in files: + file_path = os.path.join(files_path, f) + print_file(printer_name, file_path) + print_count += 1 + msg = "Printing document {} of {}".format(print_count, len(files)) + popup_message(msg) + + +def is_open_office_file(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(printer_name, 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 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", printer_name, file_to_print]) + except subprocess.CalledProcessError as e: + msg = "Error printing file {} with printer {}.".format( + file_to_print, printer_name + ) + exit_gracefully(msg, e=e) + + def main(): extract_tarball() - encryption_method, encryption_key = retrieve_metadata() - unlock_luks_volume(encryption_key) - mount_volume() - copy_submission() + + archive_path = os.path.join(SUBMISSION_TMPDIR, SUBMISSION_DIRNAME) + archive_metadata = Metadata(archive_path) + if archive_metadata.is_valid(): + if archive_metadata.export_method == "disk": + # exports all documents in the archive to luks-encrypted volume + unlock_luks_volume(archive_metadata.encryption_key) + mount_volume() + copy_submission() + elif archive_metadata.export_method == "printer": + # prints all documents in the archive + printer_uri = get_printer_uri() + printer_ppd = install_printer_ppd(printer_uri) + setup_printer(PRINTER_NAME, printer_uri, printer_ppd) + print_all_files(PRINTER_NAME) + elif archive_metadata.export_method == "printer-test": + # Prints a test page to ensure the printer is functional + printer_uri = get_printer_uri() + printer_ppd = install_printer_ppd(printer_uri) + setup_printer(PRINTER_NAME, printer_uri, printer_ppd) + print_test_page(PRINTER_NAME) + else: + exit_gracefully("Archive metadata is invalid") if __name__ == "__main__": + PRINTER_NAME = "sdw-printer" try: # We define globals inside the main block, rather than at the top # of the file, to catch exceptions via exit_gracefully. All var names @@ -133,17 +330,25 @@ if __name__ == "__main__": MOUNTPOINT = "/media/usb" ENCRYPTED_DEVICE = "encrypted_volume" SUBMISSION_ARCHIVE = sys.argv[1] - + BRLASER_DRIVER = "/usr/share/cups/drv/brlaser.drv" + BRLASER_PPD = "/usr/share/cups/model/br7030.ppd" + PRINTER_NAME = "sdw-printer" + PRINTER_WAIT_TIMEOUT = 60 # Halt immediately if target file is absent if not os.path.exists(SUBMISSION_ARCHIVE): msg = "File does not exist" exit_gracefully(msg) SUBMISSION_DIRNAME = os.path.basename(SUBMISSION_ARCHIVE).split(".")[0] - TARGET_DIRNAME = "sd-export-{}".format(datetime.datetime.now().strftime("%Y%m%d-%H%M%S")) + TARGET_DIRNAME = "sd-export-{}".format( + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + ) SUBMISSION_TMPDIR = tempfile.mkdtemp() main() + + # Delete extracted achive from tempfile + shutil.rmtree(SUBMISSION_TMPDIR) except Exception as e: # exit with 0 return code otherwise the os will attempt to open # the file with another application diff --git a/sd-export/test-requirements.txt b/sd-export/test-requirements.txt new file mode 100644 index 00000000..e079f8a6 --- /dev/null +++ b/sd-export/test-requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/sd-export/test_export.py b/sd-export/test_export.py new file mode 100644 index 00000000..52af5467 --- /dev/null +++ b/sd-export/test_export.py @@ -0,0 +1,158 @@ +from unittest import mock + +import imp +import os +import pytest +import tempfile + + +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 +# `securedropexport`, we could simply do `import securedropexport` +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): + test_msg = 'test' + + with pytest.raises(SystemExit) as sysexit: + securedropexport.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): + test_msg = 'test' + + with pytest.raises(SystemExit) as sysexit: + securedropexport.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): + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, securedropexport.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write("{}") + config = securedropexport.Metadata(temp_folder) + assert not config.is_valid() + + +def test_valid_printer_test_config(capsys): + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, securedropexport.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write('{"device": "printer-test"}') + config = securedropexport.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): + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, securedropexport.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write('{"device": "printer"}') + config = securedropexport.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): + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, securedropexport.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write( + '{"device": "disk", "encryption_method": "base64", "encryption_key": "hunter1"}' + ) + config = securedropexport.Metadata(temp_folder) + assert config.encryption_key == "hunter1" + assert config.encryption_method == "base64" + assert not config.is_valid() + + +def test_valid_encryption_config(capsys): + temp_folder = tempfile.mkdtemp() + metadata = os.path.join(temp_folder, securedropexport.Metadata.METADATA_FILE) + with open(metadata, "w") as f: + f.write( + '{"device": "disk", "encryption_method": "luks", "encryption_key": "hunter1"}' + ) + config = securedropexport.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): + securedropexport.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): + result = securedropexport.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): + expected_message = "USB Printer not found" + mocked_exit = mock.patch("securedropexport.exit_gracefully", return_value=0) + + with pytest.raises(SystemExit) as sysexit: + result = securedropexport.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): + assert securedropexport.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): + assert not securedropexport.is_open_office_file(open_office_paths) diff --git a/tests/test_sd_export.py b/tests/test_sd_export.py index c6dc4e47..58812568 100644 --- a/tests/test_sd_export.py +++ b/tests/test_sd_export.py @@ -19,6 +19,7 @@ def test_files_are_properly_copied(self): def test_sd_export_package_installed(self): self.assertTrue(self._package_is_installed("cryptsetup")) + self.assertTrue(self._package_is_installed("printer-driver-brlaser")) def load_tests(loader, tests, pattern):