def update_source_key(gpg: GpgHelper, local_source: Source, remote_source: SDKSource) -> None: """ Updates a source's GPG key. """ if not remote_source.key.get("fingerprint"): logger.error("New source data lacks key fingerprint") return if not remote_source.key.get("public"): logger.error("New source data lacks public key") return if ( local_source.fingerprint == remote_source.key['fingerprint'] and local_source.public_key == remote_source.key['public'] ): logger.debug("Source key data is unchanged") return try: # import_key updates the source's key and fingerprint, and commits gpg.import_key( remote_source.uuid, remote_source.key['public'], remote_source.key['fingerprint'] ) except CryptoError: logger.error('Failed to update key information for source %s', remote_source.uuid)
def test_import_key(homedir, config, source): ''' Check the happy path that we can import a single PGP key. Using the `config` fixture to ensure the config is written to disk. ''' helper = GpgHelper(homedir, is_qubes=False) helper.import_key(source['uuid'], source['public_key'])
def test_import_nonexistent_key(homedir, config, session_maker): """ Check failure handling when a source has no key. """ source = factory.Source(public_key=None) helper = GpgHelper(homedir, session_maker, is_qubes=False) with pytest.raises(CryptoError): helper.import_key(source)
def test_import_key(homedir, config, session_maker): """ Check the happy path that we can import a single PGP key. Using the `config` fixture to ensure the config is written to disk. """ source = factory.Source() helper = GpgHelper(homedir, session_maker, is_qubes=False) helper.import_key(source)
def __init__(self, hostname: str, gui, session_maker: sessionmaker, home: str, proxy: bool = True, qubes: bool = True) -> None: """ The hostname, gui and session objects are used to coordinate with the various other layers of the application: the location of the SecureDrop proxy, the user interface and SqlAlchemy local storage respectively. """ check_dir_permissions(home) super().__init__() # Controller is unauthenticated by default self.__is_authenticated = False # used for finding DB in sync thread self.home = home # boolean flag for whether or not the client is operating behind a proxy self.proxy = proxy # boolean flag for whether the client is running within Qubes # (regardless of proxy state, to support local dev in an AppVM) self.qubes = qubes # Location of the SecureDrop server. self.hostname = hostname # Reference to the UI window. self.gui = gui # Reference to the API for secure drop proxy. self.api = None # type: sdclientapi.API # Reference to the SqlAlchemy `sessionmaker` and `session` self.session_maker = session_maker self.session = session_maker() # Queue that handles running API job self.api_job_queue = ApiJobQueue(self.api, self.session_maker) self.api_job_queue.paused.connect(self.on_queue_paused) # Contains active threads calling the API. self.api_threads = {} # type: Dict[str, Dict] self.gpg = GpgHelper(home, self.session_maker, proxy) self.export = Export() self.sync_flag = os.path.join(home, 'sync_flag') # File data. self.data_dir = os.path.join(self.home, 'data')
def __init__(self, api, home, is_qubes): super().__init__() engine = make_engine(home) Session = sessionmaker(bind=engine) self.session = Session() # Reference to the SqlAlchemy session. self.api = api self.home = home self.is_qubes = is_qubes self.gpg = GpgHelper(home, is_qubes)
def __init__(self, api: API, home: str, is_qubes: bool) -> None: super().__init__() engine = make_engine(home) current_session = sessionmaker(bind=engine) self.session = current_session() # type: Session self.api = api self.home = home self.is_qubes = is_qubes self.gpg = GpgHelper(home, is_qubes)
def test_import_key_multiple_fingerprints(homedir, source, config, mocker): ''' Check that an error is raised if multiple fingerpints are found on key import. Using the `config` fixture to ensure the config is written to disk. ''' helper = GpgHelper(homedir, is_qubes=False) mock_import = mocker.patch.object(helper, '_import', returnvalue={'a', 'b'}) with pytest.raises(RuntimeError, match='Expected exactly one fingerprint\\.'): helper.import_key(source['uuid'], source['public_key']) # ensure the mock was used assert mock_import.called
def test_import_key_failure_in_encrypt_to_source(homedir, config, mocker, session_maker, session): """ Confirm handling of key import failure. """ helper = GpgHelper(homedir, session_maker, is_qubes=False) source = factory.Source(public_key="iwillbreakyou") session.add(source) plaintext = "bueller?" with pytest.raises(CryptoError, match=r"Could not import key before encrypting reply:"): helper.encrypt_to_source(source.uuid, plaintext)
def __init__(self, hostname, gui, session, home: str, proxy: bool = True) -> None: """ The hostname, gui and session objects are used to coordinate with the various other layers of the application: the location of the SecureDrop proxy, the user interface and SqlAlchemy local storage respectively. """ check_dir_permissions(home) super().__init__() # Controller is unauthenticated by default self.__is_authenticated = False # used for finding DB in sync thread self.home = home # boolean flag for whether or not the client is operating behind a proxy self.proxy = proxy # Location of the SecureDrop server. self.hostname = hostname # Reference to the UI window. self.gui = gui # Reference to the API for secure drop proxy. self.api = None # type: sdclientapi.API # Contains active threads calling the API. self.api_threads = {} # type: Dict[str, Dict] # Reference to the SqlAlchemy session. self.session = session # thread responsible for fetching messages self.message_thread = None self.message_sync = MessageSync(self.api, self.home, self.proxy) # thread responsible for fetching replies self.reply_thread = None self.reply_sync = ReplySync(self.api, self.home, self.proxy) self.sync_flag = os.path.join(home, 'sync_flag') # File data. self.data_dir = os.path.join(self.home, 'data') self.gpg = GpgHelper(home, proxy)
def test_import_key_gpg_call_fail(homedir, config, mocker, session_maker): ''' Check that a `CryptoError` is raised if calling `gpg` fails. Using the `config` fixture to ensure the config is written to disk. ''' helper = GpgHelper(homedir, session_maker, is_qubes=False) err = subprocess.CalledProcessError(cmd=['foo'], returncode=1) mock_call = mocker.patch('securedrop_client.crypto.subprocess.check_call', side_effect=err) with pytest.raises(CryptoError, match='Could not import key\\.'): helper._import(PUB_KEY) # ensure the mock was used assert mock_call.called
def test_encrypt_fail_if_journo_fingerprint_missing(homedir, source, config, mocker, session_maker): """ Check that a `CryptoError` is raised before making a call to `gpg` if source fingerprint is missing. """ helper = GpgHelper(homedir, session_maker, is_qubes=False) helper.journalist_key_fingerprint = None check_call_fn = mocker.patch("securedrop_client.crypto.subprocess.check_call") with pytest.raises( CryptoError, match=r"Could not encrypt reply due to missing fingerprint for journalist" ): helper.encrypt_to_source(source["uuid"], "mock") check_call_fn.assert_not_called()
def test_import_key_gpg_call_fail(homedir, config, mocker, session_maker): """ Check that a `CryptoError` is raised if calling `gpg` fails. Using the `config` fixture to ensure the config is written to disk. """ helper = GpgHelper(homedir, session_maker, is_qubes=False) source = factory.Source() err = subprocess.CalledProcessError(cmd=["foo"], returncode=1) mock_call = mocker.patch("securedrop_client.crypto.subprocess.check_call", side_effect=err) with pytest.raises(CryptoError, match=f"Could not import key."): helper.import_key(source) # ensure the mock was used assert mock_call.called
def test_send_reply_failure_timeout_error( homedir, mocker, session, session_maker, reply_status_codes, exception ): """ Check that if the SendReplyJob api call fails because of a RequestTimeoutError or ServerConnectionError that a SendReplyJobTimeoutError is raised. """ source = factory.Source() session.add(source) draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() mocker.patch.object(api_client, "reply_source", side_effect=exception) gpg = GpgHelper(homedir, session_maker, is_qubes=False) encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) with pytest.raises(SendReplyJobTimeoutError): job.call_api(api_client, session) encrypt_fn.assert_called_once_with(source.uuid, "mock_message") replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1
def test_MetadataSyncJob_success_with_missing_key(mocker, homedir, session, session_maker): """ Check that we can gracefully handle missing source keys. """ gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MetadataSyncJob(homedir, gpg) mock_source = factory.RemoteSource(key={ 'type': 'PGP', 'public': '', 'fingerprint': '', }) mock_key_import = mocker.patch.object(job.gpg, 'import_key') mock_get_remote_data = mocker.patch( 'securedrop_client.api_jobs.sync.get_remote_data', return_value=([mock_source], [], [])) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() job.call_api(api_client, session) assert mock_key_import.call_count == 0 assert mock_get_remote_data.call_count == 1
def test_MetadataSyncJob_success_with_key_import_fail(mocker, homedir, session, session_maker): """ Check that we can gracefully handle a key import failure. """ gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MetadataSyncJob(homedir, gpg) mock_source = factory.RemoteSource(key={ 'type': 'PGP', 'public': PUB_KEY, 'fingerprint': '123456ABC', }) mock_key_import = mocker.patch.object(job.gpg, 'import_key', side_effect=CryptoError) mock_get_remote_data = mocker.patch( 'securedrop_client.api_jobs.sync.get_remote_data', return_value=([mock_source], [], [])) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() job.call_api(api_client, session) assert mock_key_import.call_args[0][0] == mock_source.uuid assert mock_key_import.call_args[0][1] == mock_source.key['public'] assert mock_key_import.call_args[0][2] == mock_source.key['fingerprint'] assert mock_get_remote_data.call_count == 1
def test_send_reply_failure_when_repr_is_none(homedir, mocker, session, session_maker): ''' Check that the SendReplyJob api call results in a SendReplyJobError and nothing else, e.g. no TypeError, when an api call results in an exception that returns None for __repr__ (regression test). ''' class MockException(Exception): def __repr__(self): return None source = factory.Source(uuid='mock_reply_uuid') session.add(source) session.commit() api_client = mocker.MagicMock() mocker.patch.object(api_client, 'reply_source', side_effect=MockException('mock')) gpg = GpgHelper(homedir, session_maker, is_qubes=False) encrypt_fn = mocker.patch.object(gpg, 'encrypt_to_source') job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) with pytest.raises( SendReplyJobError, match= r'Failed to send reply for source mock_reply_uuid due to Exception: mock' ): job.call_api(api_client, session) encrypt_fn.assert_called_once_with(source.uuid, 'mock_message') replies = session.query(db.Reply).filter_by(uuid='mock_reply_uuid').all() assert len(replies) == 0
def test_ReplyDownloadJob_no_download_or_decrypt(mocker, homedir, session, session_maker): """ Test that an already-downloaded reply successfully decrypts. """ reply_is_decrypted_false = factory.Reply(source=factory.Source(), is_downloaded=True, is_decrypted=False, content=None) reply_is_decrypted_none = factory.Reply(source=factory.Source(), is_downloaded=True, is_decrypted=None, content=None) session.add(reply_is_decrypted_false) session.add(reply_is_decrypted_none) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job_1 = ReplyDownloadJob(reply_is_decrypted_false.uuid, homedir, gpg) job_2 = ReplyDownloadJob(reply_is_decrypted_none.uuid, homedir, gpg) mocker.patch.object(job_1.gpg, 'decrypt_submission_or_reply') mocker.patch.object(job_2.gpg, 'decrypt_submission_or_reply') api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() path = os.path.join(homedir, 'data') api_client.download_submission = mocker.MagicMock(return_value=('', path)) job_1.call_api(api_client, session) job_2.call_api(api_client, session) assert reply_is_decrypted_false.content is not None assert reply_is_decrypted_false.is_downloaded is True assert reply_is_decrypted_false.is_decrypted is True assert reply_is_decrypted_none.content is not None assert reply_is_decrypted_none.is_downloaded is True assert reply_is_decrypted_none.is_decrypted is True
def test_send_reply_failure_unknown_error( homedir, mocker, session, session_maker, reply_status_codes ): """ Check that if the SendReplyJob api call fails when sending a message that SendReplyJobError is raised and the reply is not added to the local database. """ source = factory.Source() session.add(source) draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() mocker.patch.object(api_client, "reply_source", side_effect=Exception) gpg = GpgHelper(homedir, session_maker, is_qubes=False) encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) with pytest.raises(Exception): job.call_api(api_client, session) encrypt_fn.assert_called_once_with(source.uuid, "mock_message") replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1
def test_MessageDownloadJob_with_crypto_error(mocker, homedir, session, session_maker): """ Test when a message successfully downloads, but does not successfully decrypt. Use the `homedir` fixture to get a GPG keyring. """ message = factory.Message(source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) session.add(message) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) mocker.patch.object(job.gpg, 'decrypt_submission_or_reply', side_effect=CryptoError) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() path = os.path.join(homedir, 'data') api_client.download_submission = mocker.MagicMock(return_value=('', path)) with pytest.raises(DownloadDecryptionException): job.call_api(api_client, session) assert message.content is None assert message.is_downloaded is True assert message.is_decrypted is False
def test_FileDownloadJob_bad_sha256_etag(mocker, homedir, session, session_maker): source = factory.Source() file_ = factory.File(source=source, is_downloaded=None, is_decrypted=None) session.add(source) session.add(file_) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: ''' :return: (etag, path_to_dl) ''' full_path = os.path.join(homedir, 'data', 'mock') with open(full_path, 'wb') as f: f.write(b'') return ('sha256:not-a-sha-sum', full_path) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download job = FileDownloadJob( file_.uuid, os.path.join(homedir, 'data'), gpg, ) with pytest.raises(DownloadChecksumMismatchException): job.call_api(api_client, session)
def test_MessageDownloadJob_happiest_path(mocker, homedir, session, session_maker): """ Test when a message successfully downloads and decrypts. Use the `homedir` fixture to get a GPG keyring. """ message = factory.Message(source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) session.add(message) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() data_dir = os.path.join(homedir, 'data') api_client.download_submission = mocker.MagicMock(return_value=('', data_dir)) job.call_api(api_client, session) assert message.content is not None assert message.is_downloaded is True assert message.is_decrypted is True
def test_MessageDownloadJob_with_base_error(mocker, homedir, session, session_maker): """ Test when a message does not successfully download. """ message = factory.Message(source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) session.add(message) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() mocker.patch.object(api_client, 'download_submission', side_effect=BaseError) decrypt_fn = mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') with pytest.raises(BaseError): job.call_api(api_client, session) assert message.content is None assert message.is_downloaded is False assert message.is_decrypted is None decrypt_fn.assert_not_called()
def test_FileDownloadJob_happy_path_unknown_etag(mocker, homedir, session, session_maker): source = factory.Source() file_ = factory.File(source=source, is_downloaded=None, is_decrypted=None) session.add(source) session.add(file_) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: """ :return: (etag, path_to_dl) """ full_path = os.path.join(homedir, "data", "mock") with open(full_path, "wb") as f: f.write(b"") return ("UNKNOWN:abc123", full_path) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg) mock_decrypt = patch_decrypt(mocker, homedir, gpg, file_.filename) mock_logger = mocker.patch("securedrop_client.api_jobs.downloads.logger") job.call_api(api_client, session) log_msg = mock_logger.debug.call_args_list[0][0][0] assert log_msg.startswith("Unknown hash algorithm") # ensure mocks aren't stale assert mock_decrypt.called
def test_MessageDownloadJob_no_download_or_decrypt(mocker, homedir, session, session_maker): """ Test that an already-downloaded message successfully decrypts. Use the `homedir` fixture to get a GPG keyring. """ message_is_decrypted_false = factory.Message(source=factory.Source(), is_downloaded=True, is_decrypted=False, content=None) message_is_decrypted_none = factory.Message(source=factory.Source(), is_downloaded=True, is_decrypted=None, content=None) session.add(message_is_decrypted_false) session.add(message_is_decrypted_none) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job_1 = MessageDownloadJob(message_is_decrypted_false.uuid, homedir, gpg) job_2 = MessageDownloadJob(message_is_decrypted_none.uuid, homedir, gpg) mocker.patch.object(job_1.gpg, 'decrypt_submission_or_reply') mocker.patch.object(job_2.gpg, 'decrypt_submission_or_reply') api_client = mocker.MagicMock() path = os.path.join(homedir, 'data') api_client.download_submission = mocker.MagicMock(return_value=('', path)) job_1.call_api(api_client, session) job_2.call_api(api_client, session) assert message_is_decrypted_false.content is not None assert message_is_decrypted_false.is_downloaded is True assert message_is_decrypted_false.is_decrypted is True assert message_is_decrypted_none.content is not None assert message_is_decrypted_none.is_downloaded is True assert message_is_decrypted_none.is_decrypted is True
class APISyncObject(QObject): def __init__(self, api, home, is_qubes): super().__init__() engine = make_engine(home) Session = sessionmaker(bind=engine) self.session = Session() # Reference to the SqlAlchemy session. self.api = api self.home = home self.is_qubes = is_qubes self.gpg = GpgHelper(home, is_qubes) def fetch_the_thing(self, item, msg, download_fn, update_fn): shasum, filepath = download_fn(item) self.gpg.decrypt_submission_or_reply(filepath, msg.filename, False) update_fn(msg.uuid, self.session) logger.info("Stored message or reply at {}".format(msg.filename))
def test_message_logic(homedir, config, mocker): """ Ensure that messages are handled. Using the `config` fixture to ensure the config is written to disk. """ gpg = GpgHelper(homedir, is_qubes=False) test_msg = 'tests/files/test-msg.gpg' expected_output_filename = 'test-msg' mock_gpg = mocker.patch('subprocess.call', return_value=0) mocker.patch('os.unlink') dest = gpg.decrypt_submission_or_reply(test_msg, expected_output_filename, is_doc=False) assert mock_gpg.call_count == 1 assert dest == os.path.join(homedir, 'data', expected_output_filename)
def test_gunzip_logic(homedir, config, mocker, session_maker): """ Ensure that gzipped documents/files are handled Using the `config` fixture to ensure the config is written to disk. """ gpg = GpgHelper(homedir, session_maker, is_qubes=False) gpg._import(PUB_KEY) gpg._import(JOURNO_KEY) test_gzip = "tests/files/test-doc.gz.gpg" expected_output_filepath = "tests/files/test-doc.txt" # mock_gpg = mocker.patch('subprocess.call', return_value=0) mock_unlink = mocker.patch("os.unlink") original_filename = gpg.decrypt_submission_or_reply( test_gzip, expected_output_filepath, is_doc=True ) assert original_filename == "test-doc.txt" # We should remove two files in the success scenario: err, filepath assert mock_unlink.call_count == 2 mock_unlink.stop() os.remove(expected_output_filepath)
def test_gzip_header_without_filename(homedir, config, mocker, session_maker): """ Test processing of a gzipped file without a filename in the header. """ gpg = GpgHelper(homedir, session_maker, is_qubes=False) gpg._import(PUB_KEY) gpg._import(JOURNO_KEY) mocker.patch("os.unlink") mocker.patch("gzip.open") mocker.patch("shutil.copy") mocker.patch("shutil.copyfileobj") # pretend the gzipped file header lacked the original filename mock_read_gzip_header_filename = mocker.patch( "securedrop_client.crypto.read_gzip_header_filename" ) mock_read_gzip_header_filename.return_value = "" test_gzip = "tests/files/test-doc.gz.gpg" output_filename = "test-doc" expected_output_filename = "tests/files/test-doc" original_filename = gpg.decrypt_submission_or_reply(test_gzip, output_filename, is_doc=True) assert original_filename == output_filename os.remove(expected_output_filename)
def test_encrypt(homedir, source, config, mocker, session_maker): ''' Check that calling `encrypt` encrypts the message. Using the `config` fixture to ensure the config is written to disk. ''' helper = GpgHelper(homedir, session_maker, is_qubes=False) # first we have to ensure the pubkeys are available helper._import(PUB_KEY) helper._import(JOURNO_KEY) plaintext = 'bueller?' cyphertext = helper.encrypt_to_source(source['uuid'], plaintext) # check that we go *any* output just for sanity assert cyphertext cyphertext_file = os.path.join(homedir, 'cyphertext.out') decrypted_file = os.path.join(homedir, 'decrypted.out') gpg_home = os.path.join(homedir, 'gpg') with open(cyphertext_file, 'w') as f: f.write(cyphertext) subprocess.check_call([ 'gpg', '--homedir', gpg_home, '--output', decrypted_file, '--decrypt', cyphertext_file ]) with open(decrypted_file) as f: decrypted = f.read() assert decrypted == plaintext