Пример #1
0
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
Пример #2
0
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()
Пример #3
0
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)
Пример #4
0
 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
Пример #5
0
    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(),
        )
Пример #6
0
    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)
Пример #7
0
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 _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
Пример #9
0
    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
Пример #10
0
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)
Пример #11
0
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
Пример #12
0
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)
Пример #13
0
    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
Пример #14
0
    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
Пример #15
0
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
Пример #16
0
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)
Пример #17
0
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
Пример #18
0
 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,
     )
Пример #19
0
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,
        }
Пример #20
0
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
Пример #22
0
    def test_delete_source_key_pair(self, source_app, test_source):
        # Given a source user with a key pair in the default encryption manager
        source_user = test_source["source_user"]
        encryption_mgr = EncryptionManager.get_default()

        # 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_public_key(source_user.filesystem_id)

        with pytest.raises(GpgKeyNotFoundError):
            encryption_mgr.get_source_key_fingerprint(
                source_user.filesystem_id)

        with pytest.raises(GpgKeyNotFoundError):
            encryption_mgr._get_source_key_details(source_user.filesystem_id)
Пример #23
0
    def test_get_source_public_key(self, test_source):
        # Given a source user with a key pair in the default encryption manager
        source_user = test_source["source_user"]
        encryption_mgr = EncryptionManager.get_default()

        # When using the encryption manager to fetch the source user's public key
        # It succeeds
        source_pub_key = encryption_mgr.get_source_public_key(
            source_user.filesystem_id)
        assert source_pub_key
        assert source_pub_key.startswith("-----BEGIN PGP PUBLIC KEY BLOCK----")

        # And the key's fingerprint was saved to Redis
        source_key_fingerprint = encryption_mgr._redis.hget(
            encryption_mgr.REDIS_FINGERPRINT_HASH, source_user.filesystem_id)
        assert source_key_fingerprint

        # And the public key was saved to Redis
        assert encryption_mgr._redis.hget(encryption_mgr.REDIS_KEY_HASH,
                                          source_key_fingerprint)
Пример #24
0
def test_submit_message(journalist_app, source_app, test_journo, app_storage):
    """When a source creates an account, test that a new entry appears
    in the journalist interface"""
    test_msg = "This is a test message."

    with source_app.test_client() as app:
        app.post("/generate", data=GENERATE_DATA)
        tab_id = next(iter(session["codenames"].keys()))
        app.post("/create", data={"tab_id": tab_id}, follow_redirects=True)
        source_user = SessionManager.get_logged_in_user(db_session=db.session)
        filesystem_id = source_user.filesystem_id

        # redirected to submission form
        resp = app.post(
            "/submit",
            data=dict(
                msg=test_msg,
                fh=(BytesIO(b""), ""),
            ),
            follow_redirects=True,
        )
        assert resp.status_code == 200
        app.get("/logout")

    # Request the Journalist Interface index
    with journalist_app.test_client() as app:
        _login_user(app, test_journo)
        resp = app.get("/")
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")
        assert "Sources" in text
        soup = BeautifulSoup(text, "html.parser")

        # The source should have a "download unread" link that
        # says "1 unread"
        col = soup.select("table#collections tr.source")[0]
        unread_span = col.select("td.unread a")[0]
        assert "1 unread" in unread_span.get_text()

        col_url = soup.select("table#collections th.designation a")[0]["href"]
        resp = app.get(col_url)
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")
        soup = BeautifulSoup(text, "html.parser")
        submission_url = soup.select(
            "table#submissions th.filename a")[0]["href"]
        assert "-msg" in submission_url
        size = soup.select("table#submissions td.info")[0]
        assert re.compile(r"\d+ bytes").match(size["title"])

        resp = app.get(submission_url)
        assert resp.status_code == 200

        encryption_mgr = EncryptionManager.get_default()
        with import_journalist_private_key(encryption_mgr):
            decryption_result = encryption_mgr._gpg.decrypt(resp.data)
        assert decryption_result.ok
        assert decryption_result.data.decode("utf-8") == test_msg

        # delete submission
        resp = app.get(col_url)
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")
        soup = BeautifulSoup(text, "html.parser")
        doc_name = soup.select(
            'table#submissions > tr.submission > td.status input[name="doc_names_selected"]'
        )[0]["value"]
        resp = app.post(
            "/bulk",
            data=dict(
                action="delete",
                filesystem_id=filesystem_id,
                doc_names_selected=doc_name,
            ),
            follow_redirects=True,
        )
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")
        soup = BeautifulSoup(text, "html.parser")
        assert "The item has been deleted." in text

        # confirm that submission deleted and absent in list of submissions
        resp = app.get(col_url)
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")
        assert "No submissions to display." in text

        # the file should be deleted from the filesystem
        # since file deletion is handled by a polling worker, this test
        # needs to wait for the worker to get the job and execute it
        def assertion():
            assert not (os.path.exists(
                app_storage.path(filesystem_id, doc_name)))

        utils.asynchronous.wait_for_assertion(assertion)
 def _source_delete_key(self):
     filesystem_id = _SourceScryptManager.get_default(
     ).derive_source_filesystem_id(self.source_name)
     EncryptionManager.get_default().delete_source_key_pair(filesystem_id)
Пример #26
0
def _helper_test_reply(journalist_app,
                       source_app,
                       config,
                       test_journo,
                       test_reply,
                       expected_success=True):
    test_msg = "This is a test message."

    with source_app.test_client() as app:
        app.post("/generate", data=GENERATE_DATA)
        tab_id, codename = next(iter(session["codenames"].items()))
        app.post("/create", data={"tab_id": tab_id}, follow_redirects=True)
        # redirected to submission form
        resp = app.post(
            "/submit",
            data=dict(
                msg=test_msg,
                fh=(BytesIO(b""), ""),
            ),
            follow_redirects=True,
        )
        assert resp.status_code == 200
        source_user = SessionManager.get_logged_in_user(db_session=db.session)
        filesystem_id = source_user.filesystem_id
        app.get("/logout")

    with journalist_app.test_client() as app:
        _login_user(app, test_journo)
        resp = app.get("/")
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")
        assert "Sources" in text
        soup = BeautifulSoup(resp.data, "html.parser")
        col_url = soup.select(
            "table#collections tr.source > th.designation a")[0]["href"]

        resp = app.get(col_url)
        assert resp.status_code == 200

    assert EncryptionManager.get_default().get_source_key_fingerprint(
        filesystem_id)

    # Create 2 replies to test deleting on journalist and source interface
    with journalist_app.test_client() as app:
        _login_user(app, test_journo)
        for i in range(2):
            resp = app.post(
                "/reply",
                data=dict(filesystem_id=filesystem_id, message=test_reply),
                follow_redirects=True,
            )
            assert resp.status_code == 200

        if not expected_success:
            pass
        else:
            text = resp.data.decode("utf-8")
            assert "The source will receive your reply" in text

        resp = app.get(col_url)
        text = resp.data.decode("utf-8")
        assert "reply-" in text

    soup = BeautifulSoup(text, "html.parser")

    # Download the reply and verify that it can be decrypted with the
    # journalist's key as well as the source's reply key
    filesystem_id = soup.select('input[name="filesystem_id"]')[0]["value"]
    checkbox_values = [
        soup.select('input[name="doc_names_selected"]')[1]["value"]
    ]
    resp = app.post(
        "/bulk",
        data=dict(filesystem_id=filesystem_id,
                  action="download",
                  doc_names_selected=checkbox_values),
        follow_redirects=True,
    )
    assert resp.status_code == 200

    zf = zipfile.ZipFile(BytesIO(resp.data), "r")
    data = zf.read(zf.namelist()[0])
    _can_decrypt_with_journalist_secret_key(data)
    _can_decrypt_with_source_secret_key(data, source_user.gpg_secret)

    # Test deleting reply on the journalist interface
    last_reply_number = len(
        soup.select('input[name="doc_names_selected"]')) - 1
    _helper_filenames_delete(app, soup, last_reply_number)

    with source_app.test_client() as app:
        resp = app.post("/login",
                        data=dict(codename=codename),
                        follow_redirects=True)
        assert resp.status_code == 200
        resp = app.get("/lookup")
        assert resp.status_code == 200
        text = resp.data.decode("utf-8")

        if not expected_success:
            # there should be no reply
            assert "You have received a reply." not in text
        else:
            assert "You have received a reply. To protect your identity" in text
            assert test_reply in text, text
            soup = BeautifulSoup(text, "html.parser")
            msgid = soup.select(
                'form > input[name="reply_filename"]')[0]["value"]
            resp = app.post(
                "/delete",
                data=dict(filesystem_id=filesystem_id, reply_filename=msgid),
                follow_redirects=True,
            )
            assert resp.status_code == 200
            text = resp.data.decode("utf-8")
            assert "Reply deleted" in text

        app.get("/logout")
Пример #27
0
    def reply() -> werkzeug.Response:
        """Attempt to send a Reply from a Journalist to a Source. Empty
        messages are rejected, and an informative error message is flashed
        on the client. In the case of unexpected errors involving database
        transactions (potentially caused by racing request threads that
        modify the same the database object) logging is done in such a way
        so as not to write potentially sensitive information to disk, and a
        generic error message is flashed on the client.

        Returns:
           flask.Response: The user is redirected to the same Source
               collection view, regardless if the Reply is created
               successfully.
        """
        form = ReplyForm()
        if not form.validate_on_submit():
            for error in form.message.errors:
                flash(error, "error")
            return redirect(url_for("col.col", filesystem_id=g.filesystem_id))

        g.source.interaction_count += 1
        filename = "{0}-{1}-reply.gpg".format(g.source.interaction_count,
                                              g.source.journalist_filename)
        EncryptionManager.get_default().encrypt_journalist_reply(
            for_source_with_filesystem_id=g.filesystem_id,
            reply_in=form.message.data,
            encrypted_reply_path_out=Path(Storage.get_default().path(
                g.filesystem_id, filename)),
        )

        try:
            reply = Reply(g.user, g.source, filename, Storage.get_default())
            db.session.add(reply)
            seen_reply = SeenReply(reply=reply, journalist=g.user)
            db.session.add(seen_reply)
            db.session.commit()
            store.async_add_checksum_for_file(reply, Storage.get_default())
        except Exception as exc:
            flash(
                gettext("An unexpected error occurred! Please "
                        "inform your admin."), "error")
            # We take a cautious approach to logging here because we're dealing
            # with responses to sources. It's possible the exception message
            # could contain information we don't want to write to disk.
            current_app.logger.error(
                "Reply from '{}' (ID {}) failed: {}!".format(
                    g.user.username, g.user.id, exc.__class__))
        else:

            flash(
                Markup("<b>{}</b> {}".format(
                    # Translators: Precedes a message confirming the success of an operation.
                    escape(gettext("Success!")),
                    escape(
                        gettext("The source will receive your reply "
                                "next time they log in.")),
                )),
                "success",
            )
        finally:
            return redirect(url_for("col.col", filesystem_id=g.filesystem_id))
Пример #28
0
 def public_key(self) -> "Optional[str]":
     try:
         return EncryptionManager.get_default().get_source_public_key(
             self.filesystem_id)
     except GpgKeyNotFoundError:
         return None
Пример #29
0
 def key_available(filesystem_id):
     assert EncryptionManager.get_default().get_source_key_fingerprint(
         filesystem_id)
Пример #30
0
 def test_get_default(self, config):
     encryption_mgr = EncryptionManager.get_default()
     assert encryption_mgr
     assert encryption_mgr.get_journalist_public_key()