Skip to content

Commit

Permalink
Validate an exported secret key is decryptable by Sequoia
Browse files Browse the repository at this point in the history
When we export a secret key in EncryptionManager, validate the output by
checking that it is decryptable by Sequoia using the given passphrase
and has the expected fingerprint.

A new redwood function, is_valid_secret_key() is the sibling to the
eixsting is_valid_public_key(), except that it also takes a passphrase
and verifies the secret key can be unlocked using the passphrase.

If the key passes all the conditions, only then is it returned by
EncryptionManager to be saved in the database, and deleted out of GPG.
If, for whatever reason, GPG fails at exporting the key, or exports
something Sequoia can't handle, the key will not be stored and it'll
continue to fall back to using GPG for decryption.

While we're at it, the export function is now named
`get_source_secret_key_from_gpg`, to highlight that this specifically
just exports the key from GPG and won't work for Sequoia based sources.

Refs #7025.
  • Loading branch information
legoktm committed Oct 25, 2023
1 parent 774511e commit 1093631
Show file tree
Hide file tree
Showing 6 changed files with 75 additions and 4 deletions.
1 change: 1 addition & 0 deletions redwood/redwood/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ from typing import BinaryIO

def generate_source_key_pair(passphrase: str, email: str) -> tuple[str, str, str]: ...
def is_valid_public_key(input: str) -> str: ...
def is_valid_secret_key(input: str, passphrase: str) -> str: ...
def encrypt_message(recipients: list[str], plaintext: str, destination: Path) -> None: ...
def encrypt_stream(recipients: list[str], plaintext: BinaryIO, destination: Path) -> None: ...
def decrypt(ciphertext: bytes, secret_key: str, passphrase: str) -> bytes: ...
Expand Down
35 changes: 35 additions & 0 deletions redwood/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const KEY_CREATION_SECONDS_FROM_EPOCH: u64 = 1368507600;
fn redwood(py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(generate_source_key_pair, m)?)?;
m.add_function(wrap_pyfunction!(is_valid_public_key, m)?)?;
m.add_function(wrap_pyfunction!(is_valid_secret_key, m)?)?;
m.add_function(wrap_pyfunction!(encrypt_message, m)?)?;
m.add_function(wrap_pyfunction!(encrypt_stream, m)?)?;
m.add_function(wrap_pyfunction!(decrypt, m)?)?;
Expand Down Expand Up @@ -111,6 +112,15 @@ pub fn is_valid_public_key(input: &str) -> Result<String> {
Ok(cert.fingerprint().to_string())
}

#[pyfunction]
pub fn is_valid_secret_key(input: &str, passphrase: String) -> Result<String> {
let passphrase: Password = passphrase.into();
let cert = Cert::from_str(input)?;
let secret_key = keys::secret_key_from_cert(POLICY, &cert)?;
secret_key.decrypt_secret(&passphrase)?;
Ok(cert.fingerprint().to_string())
}

/// Encrypt a message (text) for the specified recipients. The list of
/// recipients is a set of PGP public keys. The encrypted message will
/// be written to `destination`.
Expand Down Expand Up @@ -256,6 +266,31 @@ mod tests {
);
}

#[test]
fn test_is_valid_secret_key() {
let (public_key, secret_key, fingerprint) =
generate_source_key_pair(PASSPHRASE, "foo@example.org").unwrap();
assert_eq!(
is_valid_secret_key(&secret_key, PASSPHRASE.to_string()).unwrap(),
fingerprint
);
assert_eq!(
is_valid_secret_key(
&secret_key,
"not the correct passphrase".to_string()
)
.unwrap_err()
.to_string(),
"OpenPGP error: unexpected EOF"
);
assert_eq!(
is_valid_secret_key(&public_key, PASSPHRASE.to_string())
.unwrap_err()
.to_string(),
format!("No supported keys for certificate {fingerprint}")
);
}

#[test]
fn test_source_cert_has_accepted_key() {
// Certificate with valid key
Expand Down
12 changes: 11 additions & 1 deletion securedrop/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,20 @@ def get_source_key_fingerprint(self, source_filesystem_id: str) -> str:
self._save_key_fingerprint_to_redis(source_filesystem_id, source_key_fingerprint)
return source_key_fingerprint

def get_source_secret_key(self, fingerprint: str, passphrase: str) -> str:
def get_source_secret_key_from_gpg(self, fingerprint: str, passphrase: str) -> str:
secret_key = self.gpg().export_keys(fingerprint, secret=True, passphrase=passphrase)
if not secret_key:
raise GpgKeyNotFoundError()
# Verify the secret key we got can be read and decrypted by redwood
try:
actual_fingerprint = redwood.is_valid_secret_key(secret_key, passphrase)
except redwood.RedwoodError:
# Either Sequoia can't extract the secret key or the passphrase
# is incorrect.
raise GpgKeyNotFoundError()
if fingerprint != actual_fingerprint:
# Somehow we exported the wrong key?
raise GpgKeyNotFoundError()
return secret_key

def encrypt_source_message(self, message_in: str, encrypted_message_path_out: Path) -> None:
Expand Down
2 changes: 1 addition & 1 deletion securedrop/source_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ def login() -> Union[str, werkzeug.Response]:
# Need to migrate the secret key out of GPG
encryption_mgr = EncryptionManager.get_default()
try:
secret_key = encryption_mgr.get_source_secret_key(
secret_key = encryption_mgr.get_source_secret_key_from_gpg(
source.fingerprint, source_user.gpg_secret
)
except GpgKeyNotFoundError:
Expand Down
24 changes: 24 additions & 0 deletions securedrop/tests/test_encryption.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
from unittest.mock import MagicMock

import pytest
from db import db
Expand Down Expand Up @@ -253,3 +254,26 @@ def test_gpg_encrypt_and_decrypt_journalist_reply(
# And the journalist is able to decrypt their reply
decrypted_reply_for_journalist = utils.decrypt_as_journalist(encrypted_reply).decode()
assert decrypted_reply_for_journalist == journalist_reply

def test_get_source_secret_key_from_gpg(self, test_source, tmp_path, config):
source_user = test_source["source_user"]
source = test_source["source"]

encryption_mgr = EncryptionManager(
gpg_key_dir=tmp_path,
journalist_pub_key=(config.SECUREDROP_DATA_ROOT / "journalist.pub"),
)
new_fingerprint = utils.create_legacy_gpg_key(encryption_mgr, source_user, source)
secret_key = encryption_mgr.get_source_secret_key_from_gpg(
new_fingerprint, source_user.gpg_secret
)
assert secret_key.splitlines()[0] == "-----BEGIN PGP PRIVATE KEY BLOCK-----"
# Now try an invalid passphrase
with pytest.raises(GpgKeyNotFoundError):
encryption_mgr.get_source_secret_key_from_gpg(new_fingerprint, "not correct passphrase")
# Now if we get a garbage response from GPG
mock_gpg = MagicMock()
mock_gpg.export_keys.return_value = "definitely not a gpg secret key"
encryption_mgr._gpg = mock_gpg
with pytest.raises(GpgKeyNotFoundError):
encryption_mgr.get_source_secret_key_from_gpg(new_fingerprint, source_user.gpg_secret)
5 changes: 3 additions & 2 deletions securedrop/tests/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def decrypt_as_journalist(ciphertext: bytes) -> bytes:

def create_legacy_gpg_key(
manager: EncryptionManager, source_user: SourceUser, source: models.Source
) -> None:
) -> str:
"""Create a GPG key for the source, so we can test pre-Sequoia behavior"""
# All reply keypairs will be "created" on the same day SecureDrop (then
# Strongbox) was publicly released for the first time.
Expand All @@ -95,11 +95,12 @@ def create_legacy_gpg_key(
# to set an expiration date.
expire_date="0",
)
manager.gpg().gen_key(gen_key_input)
result = manager.gpg().gen_key(gen_key_input)

# Delete the Sequoia-generated keys
source.pgp_public_key = None
source.pgp_fingerprint = None
source.pgp_secret_key = None
db.session.add(source)
db.session.commit()
return result.fingerprint

0 comments on commit 1093631

Please sign in to comment.