From ee4bc2762dbe6f281fc14feddfc15d412aa91d71 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 21 Jun 2019 17:06:36 -0700 Subject: [PATCH 1/6] test: example of how to structure a unit test suite for export logic --- sd-export/test-requirements.txt | 1 + sd-export/test_export.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 sd-export/test-requirements.txt create mode 100644 sd-export/test_export.py 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..ec4f8c8e --- /dev/null +++ b/sd-export/test_export.py @@ -0,0 +1,41 @@ +import imp +import os +import pytest + + +# 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 == "" \ No newline at end of file From ff323282d9a11a858b81d04216a24c5f995e524f Mon Sep 17 00:00:00 2001 From: mickael e Date: Wed, 26 Jun 2019 14:41:09 -0400 Subject: [PATCH 2/6] Provide initial printer support for sd-export-usb Provides inital support for printing files inside an `sd-export` archive sent to sd-export-usb. The format supports two actions: * `"device": "printer-test"` will print the default printer test page. * `"device": "printer" will print the documents in the archive. --- README.md | 41 ++++++++++- dom0/sd-export-files.sls | 4 +- sd-export/send-to-usb | 153 ++++++++++++++++++++++++++++++++++++--- tests/test_sd_export.py | 1 + 4 files changed, 188 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8090c092..6f915d7d 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 **prior** to the the `qvm-open-in-vm sd-export-usb` call 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/sd-export-files.sls b/dom0/sd-export-files.sls index 11e72189..533d9dc8 100644 --- a/dom0/sd-export-files.sls +++ b/dom0/sd-export-files.sls @@ -11,10 +11,12 @@ include: - fpf-apt-test-repo -sd-export-template-install-cryptsetup: +sd-export-template-install-packages: pkg.installed: - pkgs: - cryptsetup + - cups + - task-print-server sd-export-send-to-usb-script: file.managed: diff --git a/sd-export/send-to-usb b/sd-export/send-to-usb index 172317e4..79e450bf 100755 --- a/sd-export/send-to-usb +++ b/sd-export/send-to-usb @@ -3,6 +3,7 @@ import datetime import json import os +import re import subprocess import sys import tarfile @@ -39,9 +40,32 @@ def extract_tarball(): exit_gracefully(msg, e=e) -def retrieve_metadata(): +def retrieve_export_method(): try: - metadata_filepath = os.path.join(SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "metadata.json") + metadata_filepath = os.path.join( + SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "metadata.json" + ) + with open(metadata_filepath) as json_data: + data = json.load(json_data) + export_method = data["device"] + except Exception as e: + msg = "Error parsing metadata." + exit_gracefully(msg, e=e) + + # we only support printers and encrypted disks as well as their test methods + # for now + if export_method not in ["disk", "disk-test", "printer", "printer-test"]: + msg = "Unsupported export device." + exit_gracefully(msg) + + return export_method + + +def retrieve_encryption_metadata(): + 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"] @@ -84,7 +108,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,7 +125,9 @@ 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]) except (subprocess.CalledProcessError, OSError) as e: msg = "Error writing to disk:" @@ -116,12 +142,116 @@ def copy_submission(): sys.exit(0) +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") + + +def print_all_files(printer_name): + files_path = os.path.join(SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "export_data/") + files = os.listdir(files_path) + for f in files: + file_path = os.path.join(files_path, f) + print_file(printer_name, file_path) + + +def print_file(printer_name, file_to_print): + try: + subprocess.check_call(["lpr", "-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() + export_method = retrieve_export_method() + if export_method == "disk": + # exports all documents in the archive to luks-encrypted volume + encryption_method, encryption_key = retrieve_encryption_metadata() + unlock_luks_volume(encryption_key) + mount_volume() + copy_submission() + elif 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 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) if __name__ == "__main__": @@ -133,6 +263,9 @@ 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" # Halt immediately if target file is absent if not os.path.exists(SUBMISSION_ARCHIVE): @@ -140,7 +273,9 @@ if __name__ == "__main__": 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() 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): From 43b242b1f766bf54208e096caa33ab1babdf7801 Mon Sep 17 00:00:00 2001 From: mickael e Date: Thu, 27 Jun 2019 17:08:54 -0400 Subject: [PATCH 3/6] Add metadata objects and tests * Ensure more reliable and DRY parsing of sd-export metadata.json * Use notify-send to provide user feedback on export status --- dom0/sd-export-files.sls | 9 +++ sd-export/send-to-usb | 128 +++++++++++++++++++++------------------ sd-export/test_export.py | 101 +++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 60 deletions(-) diff --git a/dom0/sd-export-files.sls b/dom0/sd-export-files.sls index 533d9dc8..cc895975 100644 --- a/dom0/sd-export-files.sls +++ b/dom0/sd-export-files.sls @@ -53,3 +53,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 79e450bf..9cc19e96 100755 --- a/sd-export/send-to-usb +++ b/sd-export/send-to-usb @@ -3,7 +3,6 @@ import datetime import json import os -import re import subprocess import sys import tarfile @@ -28,59 +27,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_export_method(): - try: - metadata_filepath = os.path.join( - SUBMISSION_TMPDIR, SUBMISSION_DIRNAME, "metadata.json" - ) - with open(metadata_filepath) as json_data: - data = json.load(json_data) - export_method = data["device"] - except Exception as e: - msg = "Error parsing metadata." - exit_gracefully(msg, e=e) +class Metadata(object): + """ + Object to parse, validate and store json metadata from the sd-export archive. + """ - # we only support printers and encrypted disks as well as their test methods - # for now - if export_method not in ["disk", "disk-test", "printer", "printer-test"]: - msg = "Unsupported export device." - exit_gracefully(msg) + METADATA_FILE = "metadata.json" + SUPPORTED_EXPORT_METHODS = ["disk", "printer", "printer-test"] + SUPPORTED_ENCRYPTION_METHODS = ["luks"] - return export_method + 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 retrieve_encryption_metadata(): +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 @@ -129,6 +131,7 @@ def copy_submission(): 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) @@ -211,14 +214,19 @@ def setup_printer(printer_name, printer_uri, printer_ppd): 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 print_file(printer_name, file_to_print): @@ -233,25 +241,29 @@ def print_file(printer_name, file_to_print): def main(): extract_tarball() - export_method = retrieve_export_method() - if export_method == "disk": - # exports all documents in the archive to luks-encrypted volume - encryption_method, encryption_key = retrieve_encryption_metadata() - unlock_luks_volume(encryption_key) - mount_volume() - copy_submission() - elif 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 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) + + 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__": diff --git a/sd-export/test_export.py b/sd-export/test_export.py index ec4f8c8e..eb8a1d01 100644 --- a/sd-export/test_export.py +++ b/sd-export/test_export.py @@ -1,6 +1,13 @@ +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 @@ -17,7 +24,7 @@ def test_exit_gracefully_no_exception(capsys): 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 @@ -38,4 +45,94 @@ def test_exit_gracefully_exception(capsys): captured = capsys.readouterr() assert captured.err == "{}\n\n".format(test_msg) - assert captured.out == "" \ No newline at end of file + 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 == "" From d428680de8ec741d0722297d26f36bb7d61f14b9 Mon Sep 17 00:00:00 2001 From: mickael e Date: Thu, 4 Jul 2019 17:31:01 -0400 Subject: [PATCH 4/6] Improve export/print flow * Use xpp for printer settings menu prior to sending to printer, add applet for printing * Poll lpinfo to wait for job to be sent before returning: lpinfo considers the job complete once the job was sent to the printer, and *not* when the print is completed. * Convert openoffice files to pdf prior to printing, which requires openoffice to be installed. This will convert the openoffice document to pdf. Out of the box, lp/xpp/lpr cannot print openoffice/word files * Delete extracted files from /tmp/ after printing. --- dom0/sd-export-files.sls | 15 ++++++++++ sd-export/send-to-usb | 62 ++++++++++++++++++++++++++++++++++++++-- sd-export/test_export.py | 20 +++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/dom0/sd-export-files.sls b/dom0/sd-export-files.sls index cc895975..8d4626cc 100644 --- a/dom0/sd-export-files.sls +++ b/dom0/sd-export-files.sls @@ -17,6 +17,21 @@ sd-export-template-install-packages: - 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: diff --git a/sd-export/send-to-usb b/sd-export/send-to-usb index 9cc19e96..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 = "" @@ -145,6 +151,36 @@ 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 = "" @@ -229,9 +265,27 @@ def print_all_files(printer_name): 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: - subprocess.check_call(["lpr", "-P", printer_name, file_to_print]) + # 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 @@ -267,6 +321,7 @@ def main(): 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 @@ -278,7 +333,7 @@ if __name__ == "__main__": 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" @@ -291,6 +346,9 @@ if __name__ == "__main__": 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_export.py b/sd-export/test_export.py index eb8a1d01..52af5467 100644 --- a/sd-export/test_export.py +++ b/sd-export/test_export.py @@ -136,3 +136,23 @@ def test_get_bad_printer_uri(mocked_call, capsys): 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) From 01ef8d29c418663e488cbdd49020c824169679fb Mon Sep 17 00:00:00 2001 From: mickael e Date: Mon, 8 Jul 2019 16:33:52 -0400 Subject: [PATCH 5/6] Use clean_file to prevent duplicates entries in securedrop_workstation.list fpf-apt-test-repo.sls would append the repo URL to /etc/apt/sources.list.d/securedrop_workstation.list each time make * is called. This is because templates persist accross installs, and the source is added to the template. Using `clean_file: True` will ensure `/etc/apt/sources.list.d/securedrop_workstation.list` is squashed each time fpf-apt-test-repo.sls is invoked. --- dom0/fpf-apt-test-repo.sls | 1 + 1 file changed, 1 insertion(+) 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 From b44f952b4407a886347d9bba3b1ddab58bfd2050 Mon Sep 17 00:00:00 2001 From: mickael e Date: Mon, 8 Jul 2019 16:58:28 -0400 Subject: [PATCH 6/6] Address doc review comments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f915d7d..d4b7eeb2 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The development plan is to provide functionality in the *SecureDrop Client* that ##### 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 **prior** to the the `qvm-open-in-vm sd-export-usb` call is made. +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)