Skip to content

Commit 5bb6e85

Browse files
authored
Merge pull request #16 from creviera/bus-id-workaround
Detect different kinds of storage devices attached to `sd-export-usb`
2 parents 78bcbfe + bb5f9a7 commit 5bb6e85

File tree

3 files changed

+117
-72
lines changed

3 files changed

+117
-72
lines changed

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@
44

55
Code for exporting and printing files from the SecureDrop Qubes Workstation.
66

7+
## Supported Printers
8+
9+
TBD
10+
11+
## Supported Export Devices
12+
13+
We support luks-encrypted drives that are either MBR/DOS partitioned or GPT partitioned. If you use `Disks` in Linux to partition your drive, you can [follow these instructions](https://docs.securedrop.org/en/stable/set_up_transfer_and_export_device.html#create-usb-transfer-device) to create a new export device. You can also use [cryptsetup](https://linux.die.net/man/8/cryptsetup) to create a luks-encrypted device with full-disk encryption, for example:
14+
15+
1. `sudo cryptsetup luksFormat --hash=sha512 --key-size=512 DEVICE` where `DEVICE` is the name of your removable drive, which you can find via `lsblk -p`.
16+
17+
Make sure `DEVICE` is correct because you will be overwriting its data irrevocably.
18+
19+
2. `sudo cryptsetup luksOpen /dev/sdb encrypted_device`
20+
21+
3. `sudo mkfs.ext4 /dev/mapper/encrypted_device`
22+
23+
4. `sudo cryptsetup luksClose /dev/mapper/encrypted_device`
24+
25+
We do not yet support drives that use full-disk encryption with VeraCrypt.
726

827
## Export Archive Format
928

securedrop_export/export.py

+61-53
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
PRINTER_NAME = "sdw-printer"
1616
PRINTER_WAIT_TIMEOUT = 60
17-
DEVICE = "/dev/sda1"
17+
DEVICE = "/dev/sda"
1818
MOUNTPOINT = "/media/usb"
1919
ENCRYPTED_DEVICE = "encrypted_volume"
2020
BRLASER_DRIVER = "/usr/share/cups/drv/brlaser.drv"
@@ -164,76 +164,84 @@ def extract_tarball(self):
164164
self.exit_gracefully(msg)
165165

166166
def check_usb_connected(self):
167-
168167
# If the USB is not attached via qvm-usb attach, lsusb will return empty string and a
169168
# return code of 1
170169
logging.info('Performing usb preflight')
171170
try:
172-
p = subprocess.check_output(["lsusb", "-s", "{}:".format(self.pci_bus_id)])
173-
logging.info("lsusb -s {} : {}".format(self.pci_bus_id, p.decode("utf-8")))
171+
subprocess.check_output(
172+
["lsblk", "-p", "-o", "KNAME", "--noheadings", "--inverse", DEVICE],
173+
stderr=subprocess.PIPE)
174+
self.exit_gracefully("USB_CONNECTED")
174175
except subprocess.CalledProcessError:
175-
msg = "ERROR_USB_CONFIGURATION"
176-
self.exit_gracefully(msg)
177-
n_usb = len(p.decode("utf-8").rstrip().split("\n"))
178-
# If there is one device, it is the root hub.
179-
if n_usb == 1:
180-
logging.info('usb preflight - no external devices connected')
181-
msg = "USB_NOT_CONNECTED"
182-
self.exit_gracefully(msg)
183-
# If there are two devices, it's the root hub and another device (presumably for export)
184-
elif n_usb == 2:
185-
logging.info('usb preflight - external device connected')
186-
msg = "USB_CONNECTED"
187-
self.exit_gracefully(msg)
188-
# Else the result is unexpected
189-
else:
190-
msg = "ERROR_USB_CHECK"
176+
self.exit_gracefully("USB_NOT_CONNECTED")
177+
178+
def set_extracted_device_name(self):
179+
try:
180+
device_and_partitions = subprocess.check_output(
181+
["lsblk", "-o", "TYPE", "--noheadings", DEVICE], stderr=subprocess.PIPE)
182+
183+
# we don't support multiple partitions
184+
partition_count = device_and_partitions.decode('utf-8').split('\n').count('part')
185+
if partition_count > 1:
186+
logging.debug("multiple partitions not supported")
187+
self.exit_gracefully("USB_NO_SUPPORTED_ENCRYPTION")
188+
189+
# set device to /dev/sda if disk is encrypted, /dev/sda1 if partition encrypted
190+
self.device = DEVICE if partition_count == 0 else DEVICE + '1'
191+
except subprocess.CalledProcessError:
192+
msg = "USB_NO_SUPPORTED_ENCRYPTION"
191193
self.exit_gracefully(msg)
192194

193195
def check_luks_volume(self):
194196
logging.info('Checking if volume is luks-encrypted')
195197
try:
196-
# cryptsetup isLuks returns 0 if the device is a luks volume
197-
# subprocess with throw if the device is not luks (rc !=0)
198-
subprocess.check_call(["sudo", "cryptsetup", "isLuks", DEVICE])
199-
msg = "USB_ENCRYPTED"
200-
self.exit_gracefully(msg)
198+
self.set_extracted_device_name()
199+
logging.debug("checking if {} is luks encrypted".format(self.device))
200+
subprocess.check_call(["sudo", "cryptsetup", "isLuks", self.device])
201+
self.exit_gracefully("USB_ENCRYPTED")
201202
except subprocess.CalledProcessError:
202203
msg = "USB_NO_SUPPORTED_ENCRYPTION"
203204
self.exit_gracefully(msg)
204205

205206
def unlock_luks_volume(self, encryption_key):
206-
# the luks device is not already unlocked
207-
logging.info('Unlocking luks volume {}'.format(self.encrypted_device))
208-
if not os.path.exists(os.path.join("/dev/mapper/", self.encrypted_device)):
209-
p = subprocess.Popen(
210-
["sudo", "cryptsetup", "luksOpen", self.device, self.encrypted_device],
211-
stdin=subprocess.PIPE,
212-
stdout=subprocess.PIPE,
213-
stderr=subprocess.PIPE
214-
)
215-
logging.info('Passing key')
216-
p.communicate(input=str.encode(encryption_key, "utf-8"))
217-
rc = p.returncode
218-
if rc != 0:
219-
logging.error('Bad phassphrase for {}'.format(self.encrypted_device))
220-
msg = "USB_BAD_PASSPHRASE"
221-
self.exit_gracefully(msg)
207+
try:
208+
# get the encrypted device name
209+
self.set_extracted_device_name()
210+
luks_header = subprocess.check_output(["sudo", "cryptsetup", "luksDump", self.device])
211+
luks_header_list = luks_header.decode('utf-8').split('\n')
212+
for line in luks_header_list:
213+
items = line.split('\t')
214+
if 'UUID' in items[0]:
215+
self.encrypted_device = 'luks-' + items[1]
216+
217+
# the luks device is not already unlocked
218+
if not os.path.exists(os.path.join("/dev/mapper/", self.encrypted_device)):
219+
logging.debug('Unlocking luks volume {}'.format(self.encrypted_device))
220+
p = subprocess.Popen(
221+
["sudo", "cryptsetup", "luksOpen", self.device, self.encrypted_device],
222+
stdin=subprocess.PIPE,
223+
stdout=subprocess.PIPE,
224+
stderr=subprocess.PIPE
225+
)
226+
logging.debug('Passing key')
227+
p.communicate(input=str.encode(encryption_key, "utf-8"))
228+
rc = p.returncode
229+
if rc != 0:
230+
logging.error('Bad phassphrase for {}'.format(self.encrypted_device))
231+
msg = "USB_BAD_PASSPHRASE"
232+
self.exit_gracefully(msg)
233+
except subprocess.CalledProcessError:
234+
self.exit_gracefully("USB_NO_SUPPORTED_ENCRYPTION")
222235

223236
def mount_volume(self):
224-
# mount target not created
225-
if not os.path.exists(self.mountpoint):
226-
subprocess.check_call(["sudo", "mkdir", self.mountpoint])
227237
try:
228-
logging.info('Mounting {} to {}'.format(self.encrypted_device, self.mountpoint))
229-
subprocess.check_call(
230-
[
231-
"sudo",
232-
"mount",
233-
os.path.join("/dev/mapper/", self.encrypted_device),
234-
self.mountpoint,
235-
]
236-
)
238+
# mount target not created
239+
if not os.path.exists(self.mountpoint):
240+
subprocess.check_call(["sudo", "mkdir", self.mountpoint])
241+
242+
mapped_device_path = os.path.join("/dev/mapper/", self.encrypted_device)
243+
logging.info('Mounting {}'.format(mapped_device_path))
244+
subprocess.check_call(["sudo", "mount", mapped_device_path, self.mountpoint])
237245
subprocess.check_call(["sudo", "chown", "-R", "user:user", self.mountpoint])
238246
except subprocess.CalledProcessError:
239247
# clean up

tests/test_export.py

+37-19
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
import pytest
55
import subprocess # noqa: F401
66
import tempfile
7+
from subprocess import CalledProcessError
78

89
from securedrop_export import export
910

1011
SAMPLE_OUTPUT_NO_PRINTER = b"network beh\nnetwork https\nnetwork ipp\nnetwork ipps\nnetwork http\nnetwork\nnetwork ipp14\nnetwork lpd" # noqa
1112
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
1213

13-
SAMPLE_OUTPUT_NO_USB = b"Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub" # noqa
14-
SAMPLE_OUTPUT_USB = b"Bus 001 Device 002: ID 0781:5575 SanDisk Corp.\nBus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub" # noqa
15-
SAMPLE_OUTPUT_USB_ERROR = b""
16-
SAMPLE_OUTPUT_USB_ERROR2 = b"h\ne\nl\nl\no"
14+
SAMPLE_OUTPUT_NO_PART = b"disk\ncrypt" # noqa
15+
SAMPLE_OUTPUT_ONE_PART = b"disk\npart\ncrypt" # noqa
16+
SAMPLE_OUTPUT_MULTI_PART = b"disk\npart\npart\npart\ncrypt" # noqa
17+
SAMPLE_OUTPUT_USB = b"/dev/sda" # noqa
1718
TEST_CONFIG = os.path.join(os.path.dirname(__file__), "sd-export-config.json")
1819
BAD_TEST_CONFIG = os.path.join(os.path.dirname(__file__), "sd-export-config-bad.json")
1920
ANOTHER_BAD_TEST_CONFIG = os.path.join(os.path.dirname(__file__), "sd-export-config-bad-2.json")
@@ -204,11 +205,13 @@ def test_is_not_open_office_file(capsys, open_office_paths):
204205
assert not submission.is_open_office_file(open_office_paths)
205206

206207

207-
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_NO_USB)
208-
def test_usb_precheck_connected(mocked_call, capsys):
208+
def test_usb_precheck_disconnected(capsys):
209209
submission = export.SDExport("testfile", TEST_CONFIG)
210210
expected_message = "USB_NOT_CONNECTED"
211211
mocked_exit = mock.patch("export.exit_gracefully", return_value=0)
212+
213+
mock.patch("subprocess.check_output", return_value=CalledProcessError(1, 'check_output'))
214+
212215
with pytest.raises(SystemExit) as sysexit:
213216
submission.check_usb_connected()
214217
mocked_exit.assert_called_once_with(expected_message)
@@ -219,7 +222,7 @@ def test_usb_precheck_connected(mocked_call, capsys):
219222

220223

221224
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_USB)
222-
def test_usb_precheck_disconnected(mocked_call, capsys):
225+
def test_usb_precheck_connected(mocked_call, capsys):
223226
submission = export.SDExport("testfile", TEST_CONFIG)
224227
expected_message = "USB_CONNECTED"
225228
mocked_exit = mock.patch("export.exit_gracefully", return_value=0)
@@ -232,38 +235,38 @@ def test_usb_precheck_disconnected(mocked_call, capsys):
232235
assert captured.err == "{}\n".format(expected_message)
233236

234237

235-
@mock.patch("subprocess.check_output", return_code=1)
236-
def test_usb_precheck_error(mocked_call, capsys):
238+
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_NO_PART)
239+
def test_luks_precheck_encrypted(mocked_call, capsys):
237240
submission = export.SDExport("testfile", TEST_CONFIG)
238-
expected_message = "ERROR_USB_CHECK"
241+
expected_message = "USB_ENCRYPTED"
239242
mocked_exit = mock.patch("export.exit_gracefully", return_value=0)
243+
240244
with pytest.raises(SystemExit) as sysexit:
241-
submission.check_usb_connected()
245+
submission.check_luks_volume()
242246
mocked_exit.assert_called_once_with(expected_message)
243-
244247
assert sysexit.value.code == 0
245248
captured = capsys.readouterr()
246249
assert captured.err == "{}\n".format(expected_message)
247250

248251

249-
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_USB_ERROR2)
250-
def test_usb_precheck_error_2(mocked_call, capsys):
252+
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_ONE_PART)
253+
def test_luks_precheck_encrypted(mocked_call, capsys):
251254
submission = export.SDExport("testfile", TEST_CONFIG)
252-
expected_message = "ERROR_USB_CHECK"
255+
expected_message = "USB_ENCRYPTED"
253256
mocked_exit = mock.patch("export.exit_gracefully", return_value=0)
257+
254258
with pytest.raises(SystemExit) as sysexit:
255-
submission.check_usb_connected()
259+
submission.check_luks_volume()
256260
mocked_exit.assert_called_once_with(expected_message)
257-
258261
assert sysexit.value.code == 0
259262
captured = capsys.readouterr()
260263
assert captured.err == "{}\n".format(expected_message)
261264

262265

263-
@mock.patch("subprocess.check_call")
266+
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_MULTI_PART)
264267
def test_luks_precheck_encrypted(mocked_call, capsys):
265268
submission = export.SDExport("testfile", TEST_CONFIG)
266-
expected_message = "USB_ENCRYPTED"
269+
expected_message = "USB_NO_SUPPORTED_ENCRYPTION"
267270
mocked_exit = mock.patch("export.exit_gracefully", return_value=0)
268271

269272
with pytest.raises(SystemExit) as sysexit:
@@ -272,3 +275,18 @@ def test_luks_precheck_encrypted(mocked_call, capsys):
272275
assert sysexit.value.code == 0
273276
captured = capsys.readouterr()
274277
assert captured.err == "{}\n".format(expected_message)
278+
279+
@mock.patch("subprocess.check_output", return_value=SAMPLE_OUTPUT_ONE_PART)
280+
def test_luks_precheck_encrypted(mocked_call, capsys):
281+
submission = export.SDExport("testfile", TEST_CONFIG)
282+
expected_message = "USB_NO_SUPPORTED_ENCRYPTION"
283+
mocked_exit = mock.patch("export.exit_gracefully", return_value=0)
284+
285+
mock.patch("subprocess.check_call", return_value=CalledProcessError(1, 'check_call'))
286+
287+
with pytest.raises(SystemExit) as sysexit:
288+
submission.check_luks_volume()
289+
mocked_exit.assert_called_once_with(expected_message)
290+
assert sysexit.value.code == 0
291+
captured = capsys.readouterr()
292+
assert captured.err == "{}\n".format(expected_message)

0 commit comments

Comments
 (0)