From cf43eed20b1ef7e81f959a298a10d95dccfc57a8 Mon Sep 17 00:00:00 2001 From: mickael e Date: Tue, 26 May 2020 11:17:41 -0400 Subject: [PATCH] Update sys-firewall RPM key via GUI Updater If the GUI updater observes the RPM key in sys-firewall is different from the latest key, run the sys-firewall state to update the key. The reason we check is to prevent needless applications of salt state, given the requirement to boot a disp-mgmt vm each time. Here, we rely on checksums to avoid using sudo privileges to read the apt keys provisioned by both the dev logic and rpm package, which are found in /srv/salt/sd/sd-workstation Handling qvm-copy in sd-dom0-files.sls has several hidden complexities: * shell script and rc.local will need to be updated/run as well * copy to vm, copy to folder and apply permissions, for 3 files It may also lead to several edgecases: * the file will need to also be moved to the proper * issues with idempotency if the file is not deleted from ~/QubesIncoming * race conditions between dom0 rpm provisioning and sys-firewall provisioning --- launcher/sdw_updater_gui/Updater.py | 78 ++++++++++- launcher/tests/test_updater.py | 194 +++++++++++++++++++++++++++- 2 files changed, 268 insertions(+), 4 deletions(-) diff --git a/launcher/sdw_updater_gui/Updater.py b/launcher/sdw_updater_gui/Updater.py index 41f3e98c9..143e164f6 100644 --- a/launcher/sdw_updater_gui/Updater.py +++ b/launcher/sdw_updater_gui/Updater.py @@ -22,6 +22,13 @@ FLAG_FILE_LAST_UPDATED_DOM0 = os.path.join(DEFAULT_HOME, "sdw-last-updated") LOCK_FILE = "sdw-launcher.lock" LOG_FILE = "launcher.log" +SYS_FIREWALL_SIGNING_PUBKEY_LOCATION = "/rw/config/RPM-GPG-KEY-securedrop-workstation" +PROD_SIGNING_PUBKEY_SHASUM = ( + "609990581da1ed7edd91015c3e4c5f75a363ac5d4f9a653be7f27f0c7f7e9f8b" +) +TEST_SIGNING_PUBKEY_SHASUM = ( + "b12d8e46ab9547433dc6463a87e1376488ff3f9609c78c4a75f5d62e8e3cddbb" +) sdlog = logging.getLogger(__name__) @@ -376,23 +383,92 @@ def overall_update_status(results): return UpdateStatus.UPDATES_OK +def _sys_firewall_has_correct_key(): + """ + insert description here + """ + current_pubkey_shasum = "" + sdlog.info("Checking sys-firewall config") + + try: + current_pubkey_shasum = subprocess.check_output( + [ + "qvm-run", + "--pass-io", + "sys-firewall", + "sha256sum {}".format(SYS_FIREWALL_SIGNING_PUBKEY_LOCATION), + ] + ).decode("utf-8") + except subprocess.CalledProcessError as e: + sdlog.error("Failed to check sys-firewall rpm key") + sdlog.error(str(e)) + return False + + # If the key is neither test nor prod key we return false to later apply + # the sys-firewall state to ensure the pubkey updated in sys-firewall + if PROD_SIGNING_PUBKEY_SHASUM in current_pubkey_shasum: + sdlog.info("sys-firewall RPM config is using current prod key") + return True + elif TEST_SIGNING_PUBKEY_SHASUM in current_pubkey_shasum: + sdlog.info("sys-firewall RPM config is using current test key") + return True + else: + sdlog.info("sys-firewall RPM config is not using current dev or prod key") + return False + + def apply_dom0_state(): """ Applies the dom0 state to ensure dom0 and AppVMs are properly Configured. This will *not* enforce configuration inside the AppVMs. Here, we call qubectl directly (instead of through securedrop-admin) to ensure it is environment-specific. + We also check the GPG pubkey for RPM verification in sys-firewall, as it is + used by dom0 to fetch updates. If the public key is out of date, we + update it by applying sys-firewall state. """ sdlog.info("Applying dom0 state") try: subprocess.check_call(["sudo", "qubesctl", "--show-output", "state.highstate"]) sdlog.info("Dom0 state applied") - return UpdateStatus.UPDATES_OK except subprocess.CalledProcessError as e: sdlog.error("Failed to dom0 state") sdlog.error(str(e)) return UpdateStatus.UPDATES_FAILED + if not _sys_firewall_has_correct_key(): + return _apply_firewall_state() + + return UpdateStatus.UPDATES_OK + + +def _apply_firewall_state(): + """ + Applies the sys-firewall state which will copy the RPM key in place in + sys-firewall's config and update the rc.local script to ensure the key is + always populated on boot. + """ + sdlog.info("Applying sys-firewall state") + try: + subprocess.check_call( + [ + "sudo", + "qubesctl", + "--show-output", + "--skip-dom0", + "--targets", + "sys-firewall", + "state.highstate", + ] + ) + sdlog.info("sys-firewall state applied") + except subprocess.CalledProcessError as e: + sdlog.error("Failed to apply sys-firewall state") + sdlog.error(str(e)) + return UpdateStatus.UPDATES_FAILED + + return UpdateStatus.UPDATES_OK + def shutdown_and_start_vms(): """ diff --git a/launcher/tests/test_updater.py b/launcher/tests/test_updater.py index 6b6b02a78..fc9ff1983 100644 --- a/launcher/tests/test_updater.py +++ b/launcher/tests/test_updater.py @@ -2,6 +2,7 @@ import os import pytest import subprocess +import Updater from importlib.machinery import SourceFileLoader from datetime import datetime, timedelta from tempfile import TemporaryDirectory @@ -983,15 +984,202 @@ def test_should_run_updater_invalid_status_value(mocked_write): assert updater.should_launch_updater(TEST_INTERVAL) is True +@mock.patch( + "subprocess.check_output", + side_effect=[Updater.PROD_SIGNING_PUBKEY_SHASUM.encode("utf-8")], +) @mock.patch("subprocess.check_call") @mock.patch("Updater.sdlog.error") @mock.patch("Updater.sdlog.info") -def test_apply_dom0_state_success(mocked_info, mocked_error, mocked_subprocess): +def test_apply_dom0_state_success_without_key_update( + mocked_info, mocked_error, mocked_call, mocked_output +): updater.apply_dom0_state() - log_call_list = [call("Applying dom0 state"), call("Dom0 state applied")] - mocked_subprocess.assert_called_once_with( + log_call_list = [ + call("Applying dom0 state"), + call("Dom0 state applied"), + call("Checking sys-firewall config"), + call("sys-firewall RPM config is using current prod key"), + ] + mocked_call.assert_called_once_with( ["sudo", "qubesctl", "--show-output", "state.highstate"] ) + mocked_output.assert_called_once_with( + [ + "qvm-run", + "--pass-io", + "sys-firewall", + "sha256sum /rw/config/RPM-GPG-KEY-securedrop-workstation", + ] + ) + mocked_info.assert_has_calls(log_call_list) + assert not mocked_error.called + + +@mock.patch( + "subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "check_output"), +) +@mock.patch("subprocess.check_call") +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_apply_dom0_state_fails_on_checking_key_update( + mocked_info, mocked_error, mocked_call, mocked_output +): + updater.apply_dom0_state() + log_call_list = [ + call("Applying dom0 state"), + call("Dom0 state applied"), + call("Checking sys-firewall config"), + call("Applying sys-firewall state"), + call("sys-firewall state applied"), + ] + error_call_list = [ + call("Failed to check sys-firewall rpm key"), + call("Command 'check_output' returned non-zero exit status 1."), + ] + check_calls = [ + call(["sudo", "qubesctl", "--show-output", "state.highstate"]), + call( + [ + "sudo", + "qubesctl", + "--show-output", + "--skip-dom0", + "--targets", + "sys-firewall", + "state.highstate", + ] + ), + ] + mocked_call.assert_has_calls(check_calls) + mocked_output.assert_called_once_with( + [ + "qvm-run", + "--pass-io", + "sys-firewall", + "sha256sum /rw/config/RPM-GPG-KEY-securedrop-workstation", + ] + ) + mocked_info.assert_has_calls(log_call_list) + mocked_error.assert_has_calls(error_call_list) + + +@mock.patch("subprocess.check_call", side_effect="0") +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_apply_firewall_state(mocked_info, mocked_error, mocked_check): + updater._apply_firewall_state() + log_call_list = [ + call("Applying sys-firewall state"), + call("sys-firewall state applied"), + ] + mocked_check.assert_called_once_with( + [ + "sudo", + "qubesctl", + "--show-output", + "--skip-dom0", + "--targets", + "sys-firewall", + "state.highstate", + ] + ) + mocked_info.assert_has_calls(log_call_list) + assert not mocked_error.called + + +@mock.patch( + "subprocess.check_call", side_effect=subprocess.CalledProcessError(1, "check_call") +) +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_apply_firewall_state_fails(mocked_info, mocked_error, mocked_check): + updater._apply_firewall_state() + error_call_list = [ + call("Failed to apply sys-firewall state"), + call("Command 'check_call' returned non-zero exit status 1."), + ] + mocked_check.assert_called_once_with( + [ + "sudo", + "qubesctl", + "--show-output", + "--skip-dom0", + "--targets", + "sys-firewall", + "state.highstate", + ] + ) + mocked_info.assert_called_once_with("Applying sys-firewall state") + mocked_error.assert_has_calls(error_call_list) + + +@mock.patch( + "subprocess.check_output", + side_effect=[Updater.PROD_SIGNING_PUBKEY_SHASUM.encode("utf-8")], +) +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_sys_firewall_has_correct_key_prod(mocked_info, mocked_error, mocked_output): + updater._sys_firewall_has_correct_key() + log_call_list = [ + call("Checking sys-firewall config"), + call("sys-firewall RPM config is using current prod key"), + ] + mocked_output.assert_called_once_with( + [ + "qvm-run", + "--pass-io", + "sys-firewall", + "sha256sum /rw/config/RPM-GPG-KEY-securedrop-workstation", + ] + ) + mocked_info.assert_has_calls(log_call_list) + assert not mocked_error.called + + +@mock.patch( + "subprocess.check_output", + side_effect=[Updater.TEST_SIGNING_PUBKEY_SHASUM.encode("utf-8")], +) +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_sys_firewall_has_correct_key_dev(mocked_info, mocked_error, mocked_output): + updater._sys_firewall_has_correct_key() + log_call_list = [ + call("Checking sys-firewall config"), + call("sys-firewall RPM config is using current test key"), + ] + mocked_output.assert_called_once_with( + [ + "qvm-run", + "--pass-io", + "sys-firewall", + "sha256sum /rw/config/RPM-GPG-KEY-securedrop-workstation", + ] + ) + mocked_info.assert_has_calls(log_call_list) + assert not mocked_error.called + + +@mock.patch("subprocess.check_output", side_effect=["abc".encode("utf-8")]) +@mock.patch("Updater.sdlog.error") +@mock.patch("Updater.sdlog.info") +def test_sys_firewall_has_other_key(mocked_info, mocked_error, mocked_output): + updater._sys_firewall_has_correct_key() + log_call_list = [ + call("Checking sys-firewall config"), + call("sys-firewall RPM config is not using current dev or prod key"), + ] + mocked_output.assert_called_once_with( + [ + "qvm-run", + "--pass-io", + "sys-firewall", + "sha256sum /rw/config/RPM-GPG-KEY-securedrop-workstation", + ] + ) mocked_info.assert_has_calls(log_call_list) assert not mocked_error.called