def test_encrypt_source_message(self, setup_journalist_key_and_gpg_folder, tmp_path): # Given an encryption manager journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder encryption_mgr = EncryptionManager( gpg_key_dir=gpg_key_dir, journalist_key_fingerprint=journalist_key_fingerprint) # And a message to be submitted by a source message = "s3cr3t message" # When the source tries to encrypt the message # It succeeds encrypted_message_path = tmp_path / "message.gpg" encryption_mgr.encrypt_source_message( message_in=message, encrypted_message_path_out=encrypted_message_path) # And the output file contains the encrypted data encrypted_message = encrypted_message_path.read_bytes() assert encrypted_message # And the journalist is able to decrypt the message with import_journalist_private_key(encryption_mgr): decrypted_message = encryption_mgr._gpg.decrypt( encrypted_message).data assert decrypted_message.decode() == message # And the source or anyone else is NOT able to decrypt the message # For GPG 2.1+, a non-null passphrase _must_ be passed to decrypt() assert not encryption_mgr._gpg.decrypt(encrypted_message, passphrase="test 123").ok
def test_encrypt_source_file(self, setup_journalist_key_and_gpg_folder, tmp_path): # Given an encryption manager journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder encryption_mgr = EncryptionManager( gpg_key_dir=gpg_key_dir, journalist_key_fingerprint=journalist_key_fingerprint) # And a file to be submitted by a source - we use this python file file_to_encrypt_path = Path(__file__) with file_to_encrypt_path.open() as file_to_encrypt: # When the source tries to encrypt the file # It succeeds encrypted_file_path = tmp_path / "file.gpg" encryption_mgr.encrypt_source_file( file_in=file_to_encrypt, encrypted_file_path_out=encrypted_file_path, ) # And the output file contains the encrypted data encrypted_file = encrypted_file_path.read_bytes() assert encrypted_file # And the journalist is able to decrypt the file with import_journalist_private_key(encryption_mgr): decrypted_file = encryption_mgr._gpg.decrypt(encrypted_file).data assert decrypted_file.decode() == file_to_encrypt_path.read_text() # And the source or anyone else is NOT able to decrypt the file # For GPG 2.1+, a non-null passphrase _must_ be passed to decrypt() assert not encryption_mgr._gpg.decrypt(encrypted_file, passphrase="test 123").ok
def reply(storage, journalist, source, num_replies): """Generates and submits *num_replies* replies to *source* from *journalist*. Returns reply objects as a list. :param Journalist journalist: The journalist to write the reply from. :param Source source: The source to send the reply to. :param int num_replies: Number of random-data replies to make. :returns: A list of the :class:`Reply`s submitted. """ assert num_replies >= 1 replies = [] for _ in range(num_replies): source.interaction_count += 1 fname = "{}-{}-reply.gpg".format(source.interaction_count, source.journalist_filename) EncryptionManager.get_default().encrypt_journalist_reply( for_source_with_filesystem_id=source.filesystem_id, reply_in=str(os.urandom(1)), encrypted_reply_path_out=storage.path(source.filesystem_id, fname), ) reply = Reply(journalist, source, fname, storage) replies.append(reply) db.session.add(reply) seen_reply = SeenReply(reply=reply, journalist=journalist) db.session.add(seen_reply) db.session.commit() return replies
def add_reply(source: Source, journalist: Journalist, journalist_who_saw: Optional[Journalist]) -> None: """ Adds a single reply to a source. """ record_source_interaction(source) fname = "{}-{}-reply.gpg".format(source.interaction_count, source.journalist_filename) EncryptionManager.get_default().encrypt_journalist_reply( for_source_with_filesystem_id=source.filesystem_id, reply_in=next(replies), encrypted_reply_path_out=Path(Storage.get_default().path( source.filesystem_id, fname)), ) reply = Reply(journalist, source, fname, Storage.get_default()) db.session.add(reply) # Journalist who replied has seen the reply author_seen_reply = SeenReply(reply=reply, journalist=journalist) db.session.add(author_seen_reply) if journalist_who_saw: other_seen_reply = SeenReply(reply=reply, journalist=journalist_who_saw) db.session.add(other_seen_reply) db.session.commit()
def _create_source_and_submission(config_in_use: SecureDropConfig) -> Path: """Directly create a source and a submission within the app. Some tests for the journalist app require a submission to already be present, and this function is used to create the source user and submission when the journalist app starts. This implementation is much faster than using Selenium to navigate the source app in order to create a submission: it takes 0.2s to run, while the Selenium implementation takes 7s. """ # This function will be called in a separate Process that runs the app # Hence the late imports from encryption import EncryptionManager from models import Submission from passphrases import PassphraseGenerator from source_user import create_source_user from store import Storage, add_checksum_for_file from tests.functional.db_session import get_database_session # Create a source passphrase = PassphraseGenerator.get_default().generate_passphrase() with get_database_session( database_uri=config_in_use.DATABASE_URI) as db_session: source_user = create_source_user( db_session=db_session, source_passphrase=passphrase, source_app_storage=Storage.get_default(), ) source_db_record = source_user.get_db_record() EncryptionManager.get_default().generate_source_key_pair(source_user) # Create a file submission from this source source_db_record.interaction_count += 1 app_storage = Storage.get_default() encrypted_file_name = app_storage.save_file_submission( filesystem_id=source_user.filesystem_id, count=source_db_record.interaction_count, journalist_filename=source_db_record.journalist_filename, filename="filename.txt", stream=BytesIO(b"File with S3cr3t content"), ) submission = Submission(source_db_record, encrypted_file_name, app_storage) db_session.add(submission) source_db_record.pending = False source_db_record.last_updated = datetime.now(timezone.utc) db_session.commit() submission_file_path = app_storage.path(source_user.filesystem_id, submission.filename) add_checksum_for_file( session=db_session, db_obj=submission, file_path=submission_file_path, ) return Path(submission_file_path)
def save_message_submission( self, filesystem_id: str, count: int, journalist_filename: str, message: str ) -> str: filename = "{0}-{1}-msg.gpg".format(count, journalist_filename) msg_loc = self.path(filesystem_id, filename) EncryptionManager.get_default().encrypt_source_message( message_in=message, encrypted_message_path_out=Path(msg_loc), ) return filename
def test_delete_source_key_pair_on_journalist_key( self, setup_journalist_key_and_gpg_folder): # Given an encryption manager journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder encryption_mgr = EncryptionManager( gpg_key_dir=gpg_key_dir, journalist_key_fingerprint=journalist_key_fingerprint) # When trying to delete the journalist key via the encryption manager # It fails with pytest.raises(GpgKeyNotFoundError): encryption_mgr.delete_source_key_pair(journalist_key_fingerprint)
def test_get_source_public_key_wrong_id( self, setup_journalist_key_and_gpg_folder): # Given an encryption manager journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder encryption_mgr = EncryptionManager( gpg_key_dir=gpg_key_dir, journalist_key_fingerprint=journalist_key_fingerprint) # When using the encryption manager to fetch a key for an invalid filesystem id # It fails with pytest.raises(GpgKeyNotFoundError): encryption_mgr.get_source_public_key("1234test")
def lookup(logged_in_source: SourceUser) -> str: replies = [] logged_in_source_in_db = logged_in_source.get_db_record() source_inbox = Reply.query.filter_by( source_id=logged_in_source_in_db.id, deleted_by_source=False ).all() first_submission = logged_in_source_in_db.interaction_count == 0 if first_submission: min_message_length = InstanceConfig.get_default().initial_message_min_len else: min_message_length = 0 for reply in source_inbox: reply_path = Storage.get_default().path( logged_in_source.filesystem_id, reply.filename, ) try: with io.open(reply_path, "rb") as f: contents = f.read() decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( for_source_user=logged_in_source, ciphertext_in=contents ) reply.decrypted = decrypted_reply except UnicodeDecodeError: current_app.logger.error("Could not decode reply %s" % reply.filename) except FileNotFoundError: current_app.logger.error("Reply file missing: %s" % reply.filename) else: reply.date = datetime.utcfromtimestamp(os.stat(reply_path).st_mtime) replies.append(reply) # Sort the replies by date replies.sort(key=operator.attrgetter("date"), reverse=True) # If not done yet, generate a keypair to encrypt replies from the journalist encryption_mgr = EncryptionManager.get_default() try: encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) except GpgKeyNotFoundError: encryption_mgr.generate_source_key_pair(logged_in_source) return render_template( "lookup.html", is_user_logged_in=True, allow_document_uploads=InstanceConfig.get_default().allow_document_uploads, replies=replies, min_len=min_message_length, new_user_codename=session.get("new_user_codename", None), form=SubmissionForm(), )
async def get_saved_notes(message): data = db.get_notes(message["from"]["id"]) if len(data) > 0: msg = '''Your saved notes 📖 are:''' enc = EncryptionManager(message["from"]["id"]) for i in data: msg += "\n\n" + enc.decrypt_data(i[0]) await message.reply(msg) del enc else: await message.reply("YOU HAVE NOT SAVED ANYTHING YET! 😕")
def test_get_journalist_public_key(self, setup_journalist_key_and_gpg_folder): # Given an encryption manager journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder encryption_mgr = EncryptionManager( gpg_key_dir=gpg_key_dir, journalist_key_fingerprint=journalist_key_fingerprint) # When using the encryption manager to fetch the journalist public key # It succeeds journalist_pub_key = encryption_mgr.get_journalist_public_key() assert journalist_pub_key assert journalist_pub_key.startswith( "-----BEGIN PGP PUBLIC KEY BLOCK----")
def delete_collection(filesystem_id: str) -> None: """deletes source account including files and reply key""" # Delete the source's collection of submissions path = Storage.get_default().path(filesystem_id) if os.path.exists(path): Storage.get_default().move_to_shredder(path) # Delete the source's reply keypair EncryptionManager.get_default().delete_source_key_pair(filesystem_id) # Delete their entry in the db source = get_source(filesystem_id, include_deleted=True) db.session.delete(source) db.session.commit()
def col(filesystem_id: str) -> str: form = ReplyForm() source = get_source(filesystem_id) try: EncryptionManager.get_default().get_source_public_key( filesystem_id) source.has_key = True except GpgKeyNotFoundError: source.has_key = False return render_template("col.html", filesystem_id=filesystem_id, source=source, form=form)
def test_delete_source_key_pair_pinentry_status_is_handled( self, source_app, test_source, mocker, capsys): """ Regression test for https://github.com/freedomofpress/securedrop/issues/4294 """ # Given a source user with a key pair in the default encryption manager source_user = test_source["source_user"] encryption_mgr = EncryptionManager.get_default() # And a gpg binary that will trigger the issue described in #4294 mocker.patch( "pretty_bad_protocol._util._separate_keyword", return_value=("PINENTRY_LAUNCHED", "does not matter"), ) # When using the encryption manager to delete this source user's key pair # It succeeds encryption_mgr.delete_source_key_pair(source_user.filesystem_id) # And the user's key information can no longer be retrieved with pytest.raises(GpgKeyNotFoundError): encryption_mgr.get_source_key_fingerprint( source_user.filesystem_id) # And the bug fix was properly triggered captured = capsys.readouterr() assert "ValueError: Unknown status message: 'PINENTRY_LAUNCHED'" not in captured.err
def _journalist_downloads_message(self): self._journalist_selects_the_first_source() self.wait_for(lambda: self.driver.find_element_by_css_selector( "table#submissions")) submissions = self.driver.find_elements_by_css_selector( "#submissions a") assert 1 == len(submissions) file_url = submissions[0].get_attribute("href") # Downloading files with Selenium is tricky because it cannot automate # the browser's file download dialog. We can directly request the file # using requests, but we need to pass the cookies for logged in user # for Flask to allow this. def cookie_string_from_selenium_cookies(cookies): result = {} for cookie in cookies: result[cookie["name"]] = cookie["value"] return result cks = cookie_string_from_selenium_cookies(self.driver.get_cookies()) raw_content = self.return_downloaded_content(file_url, cks) encryption_mgr = EncryptionManager.get_default() with import_journalist_private_key(encryption_mgr): decrypted_submission = encryption_mgr._gpg.decrypt(raw_content) submission = self._get_submission_content(file_url, decrypted_submission) if type(submission) == bytes: submission = submission.decode("utf-8") assert self.secret_message == submission
def test_encrypt_fails(self, setup_journalist_key_and_gpg_folder, tmp_path): # Given an encryption manager journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder encryption_mgr = EncryptionManager( gpg_key_dir=gpg_key_dir, journalist_key_fingerprint=journalist_key_fingerprint) # When trying to encrypt some data without providing any recipient # It fails and the right exception is raised with pytest.raises(GpgEncryptError) as exc: encryption_mgr._encrypt( using_keys_with_fingerprints=[], plaintext_in="test", ciphertext_path_out=tmp_path / "encrypt_fails", ) assert "no terminal at all requested" in str(exc)
def test_reply_normal(journalist_app, source_app, test_journo, config): """Test for regression on #1360 (failure to encode bytes before calling gpg functions). """ encryption_mgr = EncryptionManager.get_default() with mock.patch.object(encryption_mgr._gpg, "_encoding", "ansi_x3.4_1968"): _helper_test_reply(journalist_app, source_app, config, test_journo, "This is a test reply.", True)
def init_source(storage): """Initialize a source: create their database record, the filesystem directory that stores their submissions & replies, and their GPG key encrypted with their codename. Return a source object and their codename string. :returns: A 2-tuple. The first entry, the :class:`Source` initialized. The second, their codename string. """ passphrase = PassphraseGenerator.get_default().generate_passphrase() source_user = create_source_user( db_session=db.session, source_passphrase=passphrase, source_app_storage=storage, ) EncryptionManager.get_default().generate_source_key_pair(source_user) return source_user.get_db_record(), passphrase
def _can_decrypt_with_source_secret_key(msg: bytes, source_gpg_secret: str) -> None: encryption_mgr = EncryptionManager.get_default() decryption_result = encryption_mgr._gpg.decrypt( msg, passphrase=source_gpg_secret) assert decryption_result.ok, "Could not decrypt msg with key, gpg says: {}".format( decryption_result.stderr)
def test_encrypt_and_decrypt_journalist_reply(self, source_app, test_source, tmp_path, app_storage): # Given a source user with a key pair in the default encryption manager source_user1 = test_source["source_user"] encryption_mgr = EncryptionManager.get_default() # And another source with a key pair in the default encryption manager with source_app.app_context(): source_user2 = create_source_user( db_session=db.session, source_passphrase=PassphraseGenerator.get_default(). generate_passphrase(), source_app_storage=app_storage, ) encryption_mgr.generate_source_key_pair(source_user2) # When the journalist tries to encrypt a reply to source1 # It succeeds journalist_reply = "s3cr3t message" encrypted_reply_path = tmp_path / "reply.gpg" encryption_mgr.encrypt_journalist_reply( for_source_with_filesystem_id=source_user1.filesystem_id, reply_in=journalist_reply, encrypted_reply_path_out=encrypted_reply_path, ) # And the output file contains the encrypted data encrypted_reply = encrypted_reply_path.read_bytes() assert encrypted_reply # And source1 is able to decrypt the reply decrypted_reply = encryption_mgr.decrypt_journalist_reply( for_source_user=source_user1, ciphertext_in=encrypted_reply) assert decrypted_reply assert decrypted_reply == journalist_reply # And source2 is NOT able to decrypt the reply with pytest.raises(GpgDecryptError): encryption_mgr.decrypt_journalist_reply( for_source_user=source_user2, ciphertext_in=encrypted_reply) # Amd the reply can't be decrypted without providing the source1's gpg secret result = encryption_mgr._gpg.decrypt( # For GPG 2.1+, a non-null passphrase _must_ be passed to decrypt() encrypted_reply, passphrase="test 123", ) assert not result.ok # And the journalist is able to decrypt their reply with import_journalist_private_key(encryption_mgr): decrypted_reply_for_journalist = encryption_mgr._gpg.decrypt( # For GPG 2.1+, a non-null passphrase _must_ be passed to decrypt() encrypted_reply, passphrase="test 123", ).data assert decrypted_reply_for_journalist.decode() == journalist_reply
def save_file_submission( self, filesystem_id: str, count: int, journalist_filename: str, filename: typing.Optional[str], stream: "IO[bytes]", ) -> str: if filename is not None: sanitized_filename = secure_filename(filename) else: sanitized_filename = secure_filename("unknown.file") # We store file submissions in a .gz file for two reasons: # # 1. Downloading large files over Tor is very slow. If we can # compress the file, we can speed up future downloads. # # 2. We want to record the original filename because it might be # useful, either for context about the content of the submission # or for figuring out which application should be used to open # it. However, we'd like to encrypt that info and have the # decrypted file automatically have the name of the original # file. Given various usability constraints in GPG and Tails, this # is the most user-friendly way we have found to do this. encrypted_file_name = "{0}-{1}-doc.gz.gpg".format(count, journalist_filename) encrypted_file_path = self.path(filesystem_id, encrypted_file_name) with SecureTemporaryFile("/tmp") as stf: # nosec with gzip.GzipFile(filename=sanitized_filename, mode="wb", fileobj=stf, mtime=0) as gzf: # Buffer the stream into the gzip file to avoid excessive # memory consumption while True: buf = stream.read(1024 * 8) if not buf: break gzf.write(buf) EncryptionManager.get_default().encrypt_source_file( file_in=stf, encrypted_file_path_out=Path(encrypted_file_path), ) return encrypted_file_name
def prime_keycache() -> None: """Pre-load the source public keys into Redis.""" with app.app_context(): encryption_mgr = EncryptionManager.get_default() for source in Source.query.filter_by(pending=False, deleted_at=None).all(): try: encryption_mgr.get_source_public_key(source.filesystem_id) except GpgKeyNotFoundError: pass
def add_source() -> Tuple[Source, str]: """ Adds a single source. """ codename = PassphraseGenerator.get_default().generate_passphrase() source_user = create_source_user( db_session=db.session, source_passphrase=codename, source_app_storage=Storage.get_default(), ) source = source_user.get_db_record() source.pending = False db.session.commit() # Generate source key EncryptionManager.get_default().generate_source_key_pair(source_user) return source, codename
def _can_decrypt_with_journalist_secret_key(msg: bytes) -> None: encryption_mgr = EncryptionManager.get_default() with import_journalist_private_key(encryption_mgr): # For GPG 2.1+, a non null passphrase _must_ be passed to decrypt() decryption_result = encryption_mgr._gpg.decrypt( msg, passphrase="dummy passphrase") assert decryption_result.ok, "Could not decrypt msg with key, gpg says: {}".format( decryption_result.stderr)
def test_source(journalist_app: Flask, app_storage: Storage) -> Dict[str, Any]: with journalist_app.app_context(): passphrase = PassphraseGenerator.get_default().generate_passphrase() source_user = create_source_user( db_session=db.session, source_passphrase=passphrase, source_app_storage=app_storage, ) EncryptionManager.get_default().generate_source_key_pair(source_user) source = source_user.get_db_record() return { "source_user": source_user, # TODO(AD): Eventually the next keys could be removed as they are in source_user "source": source, "codename": passphrase, "filesystem_id": source_user.filesystem_id, "uuid": source.uuid, "id": source.id, }
def download_public_key() -> flask.Response: journalist_pubkey = EncryptionManager.get_default( ).get_journalist_public_key() data = BytesIO(journalist_pubkey.encode("utf-8")) return send_file( data, mimetype="application/pgp-keys", attachment_filename=config.JOURNALIST_KEY + ".asc", as_attachment=True, )
async def save_notes(message): msg = message["text"].replace('/save', '', 1).strip() if len(msg) > 0: msg = EncryptionManager(message["from"]["id"]).encrypt_data(msg) db.save_notes(message["from"]["id"], msg, datetime.now().strftime("%y-%m-%d %H:%M:%S")) await message.reply("Saved 👍") else: await message.reply("SEND SOMETHING TO SAVE! 😕")
def test_unicode_reply_with_ansi_env(journalist_app, source_app, test_journo, config): # This makes python-gnupg handle encoding equivalent to if we were # running SD in an environment where os.getenv("LANG") == "C". # Unfortunately, with the way our test suite is set up simply setting # that env var here will not have the desired effect. Instead we # monkey-patch the GPG object that is called crypto_util to imitate the # _encoding attribute it would have had it been initialized in a "C" # environment. See # https://github.com/freedomofpress/securedrop/issues/1360 for context. encryption_mgr = EncryptionManager.get_default() with mock.patch.object(encryption_mgr._gpg, "_encoding", "ansi_x3.4_1968"): _helper_test_reply(journalist_app, source_app, config, test_journo, "ᚠᛇᚻ᛫ᛒᛦᚦ᛫ᚠᚱᚩᚠᚢᚱ᛫ᚠᛁᚱᚪ᛫ᚷᛖᚻᚹᛦᛚᚳᚢᛗ", True)
def test_authorized_user_can_add_reply( journalist_app, journalist_api_token, test_source, test_journo, app_storage ): with journalist_app.test_client() as app: source_id = test_source["source"].id uuid = test_source["source"].uuid # First we must encrypt the reply, or it will get rejected # by the server. encryption_mgr = EncryptionManager.get_default() source_key = encryption_mgr.get_source_key_fingerprint(test_source["source"].filesystem_id) reply_content = encryption_mgr._gpg.encrypt("This is a plaintext reply", source_key).data response = app.post( url_for("api.all_source_replies", source_uuid=uuid), data=json.dumps({"reply": reply_content.decode("utf-8")}), headers=get_api_headers(journalist_api_token), ) assert response.status_code == 201 # ensure the uuid is present and valid reply_uuid = UUID(response.json["uuid"]) # check that the uuid has a matching db object reply = Reply.query.filter_by(uuid=str(reply_uuid)).one_or_none() assert reply is not None # check that the filename is present and correct (#4047) assert response.json["filename"] == reply.filename with journalist_app.app_context(): # Now verify everything was saved. assert reply.journalist_id == test_journo["id"] assert reply.source_id == source_id # regression test for #3918 assert "/" not in reply.filename source = Source.query.get(source_id) expected_filename = "{}-{}-reply.gpg".format( source.interaction_count, source.journalist_filename ) expected_filepath = app_storage.path(source.filesystem_id, expected_filename) with open(expected_filepath, "rb") as fh: saved_content = fh.read() assert reply_content == saved_content
def test_submit_and_retrieve_happy_path(self, sd_servers_v2_with_clean_state, tor_browser_web_driver, firefox_web_driver): # Given a source user accessing the app from their browser source_app_nav = SourceAppNagivator( source_app_base_url=sd_servers_v2_with_clean_state. source_app_base_url, web_driver=tor_browser_web_driver, ) # And they created an account source_app_nav.source_visits_source_homepage() source_app_nav.source_clicks_submit_documents_on_homepage() source_app_nav.source_continues_to_submit_page() # And the source user submitted a message submitted_message = "Confidential message with some international characters: éèö" source_app_nav.source_submits_a_message(message=submitted_message) source_app_nav.source_logs_out() # When a journalist logs in journ_app_nav = JournalistAppNavigator( journalist_app_base_url=sd_servers_v2_with_clean_state. journalist_app_base_url, web_driver=firefox_web_driver, ) journ_app_nav.journalist_logs_in( username=sd_servers_v2_with_clean_state.journalist_username, password=sd_servers_v2_with_clean_state.journalist_password, otp_secret=sd_servers_v2_with_clean_state.journalist_otp_secret, ) journ_app_nav.journalist_checks_messages() # And they try to download the message # Then it succeeds and the journalist sees correct message servers_sd_config = sd_servers_v2_with_clean_state.config_in_use retrieved_message = journ_app_nav.journalist_downloads_first_message( encryption_mgr_to_use_for_decryption=EncryptionManager( gpg_key_dir=Path(servers_sd_config.GPG_KEY_DIR), journalist_key_fingerprint=servers_sd_config.JOURNALIST_KEY, )) assert retrieved_message == submitted_message