Skip to content

Commit

Permalink
Use Sequoia for newly created sources and all encryption
Browse files Browse the repository at this point in the history
In summary, this change implements:
* Newly created sources have a Sequoia-generated key pair stored in the
database, not in the GPG keyring.
* All message and file encryption functions are handled by Sequoia. The
journalist public key is read off disk instead of the GPG keyring.
* Decryption of journalist messages for legacy GPG sources is still done
through pretty_bad_protocol since the secret key is still in the GPG
keyring.

== EncryptionManager ==

The journalist public key is now read out of the SECUREDROP_DATA_ROOT
folder instead of from the keyring, so we no longer need to know the
specific fingerprint.

Key generation is invoked directly in source_user.create_source(), so
generate_source_key_pair() is removed, though it lives on in tests that
continue to verify GPG-based source behavior.

Since we can easily get a source's public key out of the GPG keyring,
all of the encryption functions now use Sequoia. Functionally this is
inefficient since we need to pull the key out of GPG and then pass it to
Sequoia, but the next phase of the transition will move the public keys
into the database, which will make this more efficient.

== Tests ==

We're no longer able to mock the key length of generated keys since it's
hardcoded in Rust, so the tests might overall be slower. We could
probably pregenerate a set of keys and cycle through them over the
entire test run, but that's left for a follow-up.

The journalist public key is now copied into each test's unique
data_root. It is no longer present in the GPG keyring. A number of tests
also imported the journalist secret key, decrypted a submission, and
then deleted the key from the keyring. A new
utils.decrypt_as_journalist() helper function uses Sequoia to do all of
that in a much simpler way.

Some EncryptionManager tests were duplicated to use the
create_legacy_gpg_key() helper, which generates a GPG key pair and
deletes the Sequoia-generated keys to mimic what a pre-Sequoia source
creation would be like. Tests that are explicitly testing GPG-based
functionality should have `gpg` in their name, so it's easy to run them
directly with `-k gpg`.

Fixes #6799.
  • Loading branch information
legoktm committed Aug 9, 2023
1 parent 26bb69c commit 78bea03
Show file tree
Hide file tree
Showing 18 changed files with 254 additions and 399 deletions.
124 changes: 41 additions & 83 deletions securedrop/encryption.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import os
import re
import typing
from datetime import date
from io import BytesIO, StringIO
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional
from typing import BinaryIO, Dict, Optional

import pretty_bad_protocol as gnupg
from redis import Redis
from sdconfig import SecureDropConfig

import redwood

if typing.TYPE_CHECKING:
from models import Source
from source_user import SourceUser

# To fix https://github.com/freedomofpress/securedrop/issues/78
Expand All @@ -34,27 +36,18 @@ class GpgDecryptError(Exception):

class EncryptionManager:

GPG_KEY_TYPE = "RSA"
GPG_KEY_LENGTH = 4096

# All reply keypairs will be "created" on the same day SecureDrop (then
# Strongbox) was publicly released for the first time.
# https://www.newyorker.com/news/news-desk/strongbox-and-aaron-swartz
DEFAULT_KEY_CREATION_DATE = date(2013, 5, 14)

# '0' is the magic value that tells GPG's batch key generation not
# to set an expiration date.
DEFAULT_KEY_EXPIRATION_DATE = "0"

REDIS_FINGERPRINT_HASH = "sd/crypto-util/fingerprints"
REDIS_KEY_HASH = "sd/crypto-util/keys"

SOURCE_KEY_NAME = "Source Key"
SOURCE_KEY_UID_RE = re.compile(r"(Source|Autogenerated) Key <[-A-Za-z0-9+/=_]+>")

def __init__(self, gpg_key_dir: Path, journalist_key_fingerprint: str) -> None:
def __init__(self, gpg_key_dir: Path, journalist_pub_key: Path) -> None:
self._gpg_key_dir = gpg_key_dir
self._journalist_key_fingerprint = journalist_key_fingerprint
self.journalist_pub_key = journalist_pub_key
if not self.journalist_pub_key.exists():
raise RuntimeError(
f"The journalist public key does not exist at {self.journalist_pub_key}"
)
self._redis = Redis(decode_responses=True)

# Instantiate the "main" GPG binary
Expand All @@ -71,43 +64,23 @@ def __init__(self, gpg_key_dir: Path, journalist_key_fingerprint: str) -> None:
binary="gpg2", homedir=str(self._gpg_key_dir), options=["--yes", "--trust-model direct"]
)

# Ensure that the journalist public key has been previously imported in GPG
try:
self.get_journalist_public_key()
except GpgKeyNotFoundError:
raise OSError(
f"The journalist public key with fingerprint {journalist_key_fingerprint}"
f" has not been imported into GPG."
)

@classmethod
def get_default(cls) -> "EncryptionManager":
global _default_encryption_mgr
if _default_encryption_mgr is None:
config = SecureDropConfig.get_current()
_default_encryption_mgr = cls(
gpg_key_dir=config.GPG_KEY_DIR,
journalist_key_fingerprint=config.JOURNALIST_KEY,
journalist_pub_key=(config.SECUREDROP_DATA_ROOT / "journalist.pub"),
)
return _default_encryption_mgr

def generate_source_key_pair(self, source_user: "SourceUser") -> None:
gen_key_input = self._gpg.gen_key_input(
passphrase=source_user.gpg_secret,
name_email=source_user.filesystem_id,
key_type=self.GPG_KEY_TYPE,
key_length=self.GPG_KEY_LENGTH,
name_real=self.SOURCE_KEY_NAME,
creation_date=self.DEFAULT_KEY_CREATION_DATE.isoformat(),
expire_date=self.DEFAULT_KEY_EXPIRATION_DATE,
)
new_key = self._gpg.gen_key(gen_key_input)

# Store the newly-created key's fingerprint in Redis for faster lookups
self._save_key_fingerprint_to_redis(source_user.filesystem_id, str(new_key))

def delete_source_key_pair(self, source_filesystem_id: str) -> None:
source_key_fingerprint = self.get_source_key_fingerprint(source_filesystem_id)
try:
source_key_fingerprint = self.get_source_key_fingerprint(source_filesystem_id)
except GpgKeyNotFoundError:
# If the source is entirely Sequoia-based, there is nothing to delete
return

# The subkeys keyword argument deletes both secret and public keys
self._gpg_for_key_deletion.delete_keys(source_key_fingerprint, secret=True, subkeys=True)
Expand All @@ -116,7 +89,7 @@ def delete_source_key_pair(self, source_filesystem_id: str) -> None:
self._redis.hdel(self.REDIS_FINGERPRINT_HASH, source_filesystem_id)

def get_journalist_public_key(self) -> str:
return self._get_public_key(self._journalist_key_fingerprint)
return self.journalist_pub_key.read_text()

def get_source_public_key(self, source_filesystem_id: str) -> str:
source_key_fingerprint = self.get_source_key_fingerprint(source_filesystem_id)
Expand All @@ -134,63 +107,48 @@ def get_source_key_fingerprint(self, source_filesystem_id: str) -> str:
return source_key_fingerprint

def encrypt_source_message(self, message_in: str, encrypted_message_path_out: Path) -> None:
message_as_stream = StringIO(message_in)
self._encrypt(
redwood.encrypt_message(
# A submission is only encrypted for the journalist key
using_keys_with_fingerprints=[self._journalist_key_fingerprint],
plaintext_in=message_as_stream,
ciphertext_path_out=encrypted_message_path_out,
recipients=[self.get_journalist_public_key()],
plaintext=message_in,
destination=encrypted_message_path_out,
)

def encrypt_source_file(self, file_in: typing.IO, encrypted_file_path_out: Path) -> None:
self._encrypt(
def encrypt_source_file(self, file_in: BinaryIO, encrypted_file_path_out: Path) -> None:
redwood.encrypt_stream(
# A submission is only encrypted for the journalist key
using_keys_with_fingerprints=[self._journalist_key_fingerprint],
plaintext_in=file_in,
ciphertext_path_out=encrypted_file_path_out,
recipients=[self.get_journalist_public_key()],
plaintext=file_in,
destination=encrypted_file_path_out,
)

def encrypt_journalist_reply(
self, for_source_with_filesystem_id: str, reply_in: str, encrypted_reply_path_out: Path
self, for_source: "Source", reply_in: str, encrypted_reply_path_out: Path
) -> None:
source_key_fingerprint = self.get_source_key_fingerprint(for_source_with_filesystem_id)
reply_as_stream = StringIO(reply_in)
self._encrypt(
redwood.encrypt_message(
# A reply is encrypted for both the journalist key and the source key
using_keys_with_fingerprints=[source_key_fingerprint, self._journalist_key_fingerprint],
plaintext_in=reply_as_stream,
ciphertext_path_out=encrypted_reply_path_out,
recipients=[for_source.public_key, self.get_journalist_public_key()],
plaintext=reply_in,
destination=encrypted_reply_path_out,
)

def decrypt_journalist_reply(self, for_source_user: "SourceUser", ciphertext_in: bytes) -> str:
# TODO: we shouldn't be making database queries here
for_source = for_source_user.get_db_record()
if for_source.pgp_secret_key is not None:
return redwood.decrypt(
ciphertext_in,
secret_key=for_source.pgp_secret_key,
passphrase=for_source_user.gpg_secret,
).decode()
# TODO: Migrate gpg-stored secret keys to database storage for Sequoia
ciphertext_as_stream = BytesIO(ciphertext_in)
out = self._gpg.decrypt_file(ciphertext_as_stream, passphrase=for_source_user.gpg_secret)
if not out.ok:
raise GpgDecryptError(out.stderr)

return out.data.decode("utf-8")

def _encrypt(
self,
using_keys_with_fingerprints: List[str],
plaintext_in: typing.IO,
ciphertext_path_out: Path,
) -> None:
# Remove any spaces from provided fingerprints GPG outputs fingerprints
# with spaces for readability, but requires the spaces to be removed
# when using fingerprints to specify recipients.
sanitized_key_fingerprints = [fpr.replace(" ", "") for fpr in using_keys_with_fingerprints]

out = self._gpg.encrypt(
plaintext_in,
*sanitized_key_fingerprints,
output=str(ciphertext_path_out),
always_trust=True,
armor=False,
)
if not out.ok:
raise GpgEncryptError(out.stderr)

def _get_source_key_details(self, source_filesystem_id: str) -> Dict[str, str]:
for key in self._gpg.list_keys():
for uid in key["uids"]:
Expand Down
2 changes: 1 addition & 1 deletion securedrop/journalist_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def reply() -> werkzeug.Response:
g.source.interaction_count, g.source.journalist_filename
)
EncryptionManager.get_default().encrypt_journalist_reply(
for_source_with_filesystem_id=g.filesystem_id,
for_source=g.source,
reply_in=form.message.data,
encrypted_reply_path_out=Path(Storage.get_default().path(g.filesystem_id, filename)),
)
Expand Down
5 changes: 1 addition & 4 deletions securedrop/loaddata.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def add_reply(
record_source_interaction(source)
fname = f"{source.interaction_count}-{source.journalist_filename}-reply.gpg"
EncryptionManager.get_default().encrypt_journalist_reply(
for_source_with_filesystem_id=source.filesystem_id,
for_source=source,
reply_in=next(replies),
encrypted_reply_path_out=Path(Storage.get_default().path(source.filesystem_id, fname)),
)
Expand Down Expand Up @@ -252,9 +252,6 @@ def add_source() -> Tuple[Source, str]:
source = source_user.get_db_record()
db.session.commit()

# Generate source key
EncryptionManager.get_default().generate_source_key_pair(source_user)

return source, codename


Expand Down
13 changes: 6 additions & 7 deletions securedrop/source_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import store
import werkzeug
from db import db
from encryption import EncryptionManager, GpgKeyNotFoundError
from encryption import EncryptionManager
from flask import (
Blueprint,
abort,
Expand Down Expand Up @@ -161,7 +161,8 @@ def lookup(logged_in_source: SourceUser) -> str:
with 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
for_source_user=logged_in_source,
ciphertext_in=contents,
)
reply.decrypted = decrypted_reply
except UnicodeDecodeError:
Expand All @@ -176,11 +177,9 @@ def lookup(logged_in_source: SourceUser) -> str:
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)
if logged_in_source_in_db.fingerprint is None:
# FIXME: still need to do this
raise NotImplementedError("generate a new key here")

return render_template(
"lookup.html",
Expand Down
13 changes: 12 additions & 1 deletion securedrop/source_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

import redwood

if TYPE_CHECKING:
from passphrases import DicewarePassphrase
from store import Storage
Expand Down Expand Up @@ -99,9 +101,18 @@ def create_source_user(
# Could not generate a designation that is not already used
raise SourceDesignationCollisionError()

# Generate PGP keys
public_key, secret_key, fingerprint = redwood.generate_source_key_pair(
gpg_secret, filesystem_id
)

# Store the source in the DB
source_db_record = models.Source(
filesystem_id=filesystem_id, journalist_designation=valid_designation
filesystem_id=filesystem_id,
journalist_designation=valid_designation,
public_key=public_key,
secret_key=secret_key,
fingerprint=fingerprint,
)
db_session.add(source_db_record)
try:
Expand Down
4 changes: 2 additions & 2 deletions securedrop/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from hashlib import sha256
from pathlib import Path
from tempfile import _TemporaryFileWrapper
from typing import IO, List, Optional, Type, Union
from typing import BinaryIO, List, Optional, Type, Union

import rm
from encryption import EncryptionManager
Expand Down Expand Up @@ -301,7 +301,7 @@ def save_file_submission(
count: int,
journalist_filename: str,
filename: Optional[str],
stream: "IO[bytes]",
stream: BinaryIO,
) -> str:

if filename is not None:
Expand Down
16 changes: 5 additions & 11 deletions securedrop/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@
from pathlib import Path
from typing import Any, Dict, Generator, Tuple
from unittest import mock
from unittest.mock import PropertyMock
from uuid import uuid4

import pretty_bad_protocol as gnupg
import psutil
import pytest
import sdconfig
from db import db
from encryption import EncryptionManager
from flask import Flask, url_for
from journalist_app import create_app as create_journalist_app
from passphrases import PassphraseGenerator
Expand Down Expand Up @@ -91,14 +89,10 @@ def setup_journalist_key_and_gpg_folder() -> Generator[Tuple[str, Path], None, N
gpg = gnupg.GPG("gpg2", homedir=str(tmp_gpg_dir))
journalist_public_key_path = Path(__file__).parent / "files" / "test_journalist_key.pub"
journalist_public_key = journalist_public_key_path.read_text()
journalist_key_fingerprint = gpg.import_keys(journalist_public_key).fingerprints[0]
# TODO: we don't need the journalist pub key in the keyring anymore
gpg.import_keys(journalist_public_key)

# Reduce source GPG key length to speed up tests at the expense of security
with mock.patch.object(
EncryptionManager, "GPG_KEY_LENGTH", PropertyMock(return_value=1024)
):

yield journalist_key_fingerprint, tmp_gpg_dir
yield "65A1B5FF195B56353CC63DFFCC40EF1228271441", tmp_gpg_dir

finally:
shutil.rmtree(tmp_gpg_dir, ignore_errors=True)
Expand All @@ -111,8 +105,9 @@ def config(
) -> Generator[SecureDropConfig, None, None]:
journalist_key_fingerprint, gpg_key_dir = setup_journalist_key_and_gpg_folder
worker_name, _ = setup_rqworker
data_root = Path(f"/tmp/sd-tests/conftest-{uuid4()}")
config = SecureDropConfigFactory.create(
SECUREDROP_DATA_ROOT=Path(f"/tmp/sd-tests/conftest-{uuid4()}"),
SECUREDROP_DATA_ROOT=data_root,
GPG_KEY_DIR=gpg_key_dir,
JOURNALIST_KEY=journalist_key_fingerprint,
SUPPORTED_LOCALES=i18n.get_test_locales(),
Expand Down Expand Up @@ -230,7 +225,6 @@ def test_source(journalist_app: Flask, app_storage: Storage) -> Dict[str, Any]:
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,
Expand Down
5 changes: 5 additions & 0 deletions securedrop/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,10 @@ def create(
config.TEMP_DIR.mkdir(parents=True)
config.STORE_DIR.mkdir(parents=True)

# Copy the journalist public key into DATA_ROOT
shutil.copy2(
Path(__file__).parent / "files/test_journalist_key.pub",
config.SECUREDROP_DATA_ROOT / "journalist.pub",
)
# All done
return config
Loading

0 comments on commit 78bea03

Please sign in to comment.