-
Notifications
You must be signed in to change notification settings - Fork 687
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add "redwood" Sequoia Rust/Python bridge
Sequoia is a modern PGP library written in Rust that we're going to switch SecureDrop over to using instead of gpg/pretty_bad_protocol for our encryption/decryption needs. The overall transition has been explored and discussed in #6399 and <https://github.com/freedomofpress/securedrop-engineering/blob/main/proposals/approved/sequoia-server.md>. This adds the Rust code we will compile into a Python wheel, named "redwood", to call into the Sequoia library. Four functions are exposed: * generate_source_key_pair * encrypt_message * encrypt_file * decrypt The functions are rather self-explanatory and Python type stubs are provided as well. The `rust-toolchain.toml` file instructs rustup to use Rust 1.69.0 (current latest version), we'll figure out a toolchain upgrade cadence later on. It should now be possible to build a redwood wheel: $ maturin build -m redwood/Cargo.toml
- Loading branch information
Showing
9 changed files
with
1,605 additions
and
0 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
[workspace] | ||
|
||
members = [ | ||
"redwood", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
[package] | ||
name = "redwood" | ||
version = "0.1.0" | ||
edition = "2021" | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
[lib] | ||
name = "redwood" | ||
crate-type = ["cdylib"] | ||
|
||
[dependencies] | ||
anyhow = "1.0" | ||
pyo3 = { version = "0.18.0", features = ["extension-module"] } | ||
sequoia-openpgp = { version = "1.13.0", default-features = false, features = ["crypto-openssl", "compression"]} | ||
thiserror = "1.0.31" | ||
|
||
[dev-dependencies] | ||
tempfile = "3.3.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
[build-system] | ||
requires = ["maturin>=1.0,<2.0"] | ||
build-backend = "maturin" | ||
|
||
[project] | ||
name = "redwood" | ||
requires-python = ">=3.8" | ||
classifiers = [ | ||
"Programming Language :: Rust", | ||
] | ||
|
||
[tool.maturin] | ||
compatibility = "linux" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
# type stub for redwood module | ||
# see https://pyo3.rs/v0.16.4/python_typing_hints.html | ||
from pathlib import Path | ||
from typing import List | ||
|
||
def generate_source_key_pair(passphrase: str, email: str) -> (str, str, str): | ||
pass | ||
|
||
def encrypt_message(recipients: List[str], plaintext: str, destination: Path) -> None: | ||
pass | ||
|
||
def encrypt_file(recipients: List[str], plaintext: Path, destination: Path) -> None: | ||
pass | ||
|
||
def decrypt(ciphertext: bytes, secret_key: str, passphrase: str) -> str: | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
//! Decryption is much more complicated than encryption, | ||
//! This code is mostly lifted from https://docs.sequoia-pgp.org/sequoia_guide/chapter_02/index.html | ||
use sequoia_openpgp::crypto::{Password, SessionKey}; | ||
use sequoia_openpgp::parse::stream::*; | ||
use sequoia_openpgp::policy::Policy; | ||
use sequoia_openpgp::types::SymmetricAlgorithm; | ||
|
||
pub(crate) struct Helper<'a> { | ||
pub(crate) policy: &'a dyn Policy, | ||
pub(crate) secret: &'a sequoia_openpgp::Cert, | ||
pub(crate) passphrase: &'a Password, | ||
} | ||
|
||
impl<'a> VerificationHelper for Helper<'a> { | ||
fn get_certs( | ||
&mut self, | ||
_ids: &[sequoia_openpgp::KeyHandle], | ||
) -> sequoia_openpgp::Result<Vec<sequoia_openpgp::Cert>> { | ||
// Return public keys for signature verification here. | ||
Ok(Vec::new()) | ||
} | ||
|
||
fn check( | ||
&mut self, | ||
_structure: MessageStructure, | ||
) -> sequoia_openpgp::Result<()> { | ||
// Implement your signature verification policy here. | ||
Ok(()) | ||
} | ||
} | ||
|
||
impl<'a> DecryptionHelper for Helper<'a> { | ||
fn decrypt<D>( | ||
&mut self, | ||
pkesks: &[sequoia_openpgp::packet::PKESK], | ||
_skesks: &[sequoia_openpgp::packet::SKESK], | ||
sym_algo: Option<SymmetricAlgorithm>, | ||
mut decrypt: D, | ||
) -> sequoia_openpgp::Result<Option<sequoia_openpgp::Fingerprint>> | ||
where | ||
D: FnMut(SymmetricAlgorithm, &SessionKey) -> bool, | ||
{ | ||
// The encryption key is the first and only subkey. | ||
let key = self | ||
.secret | ||
.keys() | ||
.secret() | ||
.with_policy(self.policy, None) | ||
.next() | ||
// FIXME: unwrap() | ||
.unwrap() | ||
.key() | ||
.clone(); | ||
|
||
// Decrypt the secret key with the specified passphrase. | ||
let mut pair = key.decrypt_secret(self.passphrase)?.into_keypair()?; | ||
|
||
pkesks[0] | ||
.decrypt(&mut pair, sym_algo) | ||
.map(|(algo, session_key)| decrypt(algo, &session_key)); | ||
|
||
// XXX: In production code, return the Fingerprint of the | ||
// recipient's Cert here | ||
Ok(None) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
#![deny(clippy::all)] | ||
|
||
use pyo3::create_exception; | ||
use pyo3::exceptions::PyException; | ||
use pyo3::prelude::*; | ||
use sequoia_openpgp::cert::{CertBuilder, CipherSuite}; | ||
use sequoia_openpgp::crypto::Password; | ||
use sequoia_openpgp::parse::{stream::DecryptorBuilder, Parse}; | ||
use sequoia_openpgp::policy::StandardPolicy; | ||
use sequoia_openpgp::serialize::{ | ||
stream::{Armorer, Encryptor, LiteralWriter, Message}, | ||
SerializeInto, | ||
}; | ||
use sequoia_openpgp::Cert; | ||
use std::fs::File; | ||
use std::io::{self, Read}; | ||
use std::path::{Path, PathBuf}; | ||
use std::str::FromStr; | ||
use std::string::FromUtf8Error; | ||
use std::time::{Duration, SystemTime}; | ||
|
||
/// Alias to make it easier for Python readers | ||
type Bytes = Vec<u8>; | ||
|
||
mod decryption; | ||
|
||
#[derive(thiserror::Error, Debug)] | ||
pub enum Error { | ||
#[error("OpenPGP error: {0}")] | ||
OpenPgp(#[from] anyhow::Error), | ||
#[error("IO error: {0}")] | ||
Io(#[from] io::Error), | ||
#[error("Unexpected non-UTF-8 text: {0}")] | ||
NotUnicode(#[from] FromUtf8Error), | ||
} | ||
|
||
create_exception!(redwood, RedwoodError, PyException); | ||
|
||
impl From<Error> for PyErr { | ||
fn from(original: Error) -> Self { | ||
// TODO: make sure we're not losing the stacktrace here | ||
RedwoodError::new_err(original.to_string()) | ||
} | ||
} | ||
|
||
type Result<T> = std::result::Result<T, Error>; | ||
|
||
/// A Python module implemented in Rust. | ||
#[pymodule] | ||
fn redwood(py: Python, m: &PyModule) -> PyResult<()> { | ||
m.add_function(wrap_pyfunction!(generate_source_key_pair, m)?)?; | ||
m.add_function(wrap_pyfunction!(encrypt_message, m)?)?; | ||
m.add_function(wrap_pyfunction!(encrypt_file, m)?)?; | ||
m.add_function(wrap_pyfunction!(decrypt, m)?)?; | ||
m.add("RedwoodError", py.get_type::<RedwoodError>())?; | ||
Ok(()) | ||
} | ||
|
||
/// Generate a new PGP key pair using the given email (user ID) and protected | ||
/// with the specified passphrase. | ||
/// Returns the public key, private key, and 40-character fingerprint | ||
#[pyfunction] | ||
pub fn generate_source_key_pair( | ||
passphrase: &str, | ||
email: &str, | ||
) -> Result<(String, String, String)> { | ||
let (cert, _revocation) = CertBuilder::new() | ||
.set_cipher_suite(CipherSuite::RSA4k) | ||
.add_userid(format!("Source Key <{}>", email)) | ||
.set_creation_time( | ||
// 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 | ||
SystemTime::UNIX_EPOCH | ||
// Equivalent to 2013-05-14 | ||
.checked_add(Duration::from_secs(1368507600)) | ||
// unwrap: Safe because the value is fixed and we know it won't overflow | ||
.unwrap(), | ||
) | ||
.add_storage_encryption_subkey() | ||
.set_password(Some(passphrase.into())) | ||
.generate()?; | ||
let public_key = String::from_utf8(cert.armored().to_vec()?)?; | ||
let secret_key = String::from_utf8(cert.as_tsk().armored().to_vec()?)?; | ||
Ok((public_key, secret_key, format!("{}", cert.fingerprint()))) | ||
} | ||
|
||
/// 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`. | ||
#[pyfunction] | ||
pub fn encrypt_message( | ||
recipients: Vec<String>, | ||
plaintext: String, | ||
destination: PathBuf, | ||
) -> Result<()> { | ||
let plaintext = plaintext.as_bytes(); | ||
encrypt(&recipients, plaintext, &destination) | ||
} | ||
|
||
/// Encrypt a file that's already on disk for the specified recipients. | ||
/// The list of recipients is a set of PGP public keys. The encrypted file | ||
/// will be written to `destination`. | ||
#[pyfunction] | ||
pub fn encrypt_file( | ||
recipients: Vec<String>, | ||
plaintext: PathBuf, | ||
destination: PathBuf, | ||
) -> Result<()> { | ||
let plaintext = File::open(plaintext)?; | ||
encrypt(&recipients, plaintext, &destination) | ||
} | ||
|
||
/// Helper function to encrypt readable things. | ||
fn encrypt( | ||
recipients: &[String], | ||
mut plaintext: impl Read, | ||
destination: &Path, | ||
) -> Result<()> { | ||
let p = &StandardPolicy::new(); | ||
let mut certs = vec![]; | ||
let mut recipient_keys = vec![]; | ||
for recipient in recipients { | ||
certs.push(Cert::from_str(recipient)?); | ||
} | ||
for cert in certs.iter() { | ||
for key in cert | ||
.keys() | ||
.with_policy(p, None) | ||
.supported() | ||
.alive() | ||
.revoked(false) | ||
{ | ||
recipient_keys.push(key); | ||
} | ||
} | ||
|
||
let mut sink = File::create(destination)?; | ||
let message = Message::new(&mut sink); | ||
let message = Armorer::new(message).build()?; | ||
let message = Encryptor::for_recipients(message, recipient_keys).build()?; | ||
let mut message = LiteralWriter::new(message).build()?; | ||
|
||
io::copy(&mut plaintext, &mut message)?; | ||
|
||
message.finalize()?; | ||
|
||
Ok(()) | ||
} | ||
|
||
/// Decrypt the given ciphertext using the specified private key that is locked | ||
/// using the provided passphrase. It is assumed that the plaintext is UTF-8. | ||
#[pyfunction] | ||
pub fn decrypt( | ||
ciphertext: Bytes, | ||
secret_key: String, | ||
passphrase: String, | ||
) -> Result<String> { | ||
let recipient = Cert::from_str(&secret_key)?; | ||
let policy = &StandardPolicy::new(); | ||
let passphrase: Password = passphrase.into(); | ||
let helper = decryption::Helper { | ||
policy, | ||
secret: &recipient, | ||
passphrase: &passphrase, | ||
}; | ||
|
||
// Now, create a decryptor with a helper using the given Certs. | ||
let mut decryptor = DecryptorBuilder::from_bytes(&ciphertext)? | ||
.with_policy(policy, None, helper)?; | ||
|
||
// Decrypt the data. | ||
let mut buffer: Bytes = vec![]; | ||
io::copy(&mut decryptor, &mut buffer)?; | ||
let plaintext = String::from_utf8(buffer)?; | ||
Ok(plaintext) | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use tempfile::NamedTempFile; | ||
|
||
#[test] | ||
fn test_generate_source_key_pair() { | ||
let (public_key, secret_key, fingerprint) = generate_source_key_pair( | ||
"correcthorsebatterystaple", | ||
"foo@example.org", | ||
) | ||
.unwrap(); | ||
assert_eq!(fingerprint.len(), 40); | ||
println!("{}", public_key); | ||
assert!(public_key.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----")); | ||
assert!(public_key.contains("Comment: Source Key <foo@example.org>")); | ||
let cert = Cert::from_str(&public_key).unwrap(); | ||
assert_eq!(format!("{}", cert.fingerprint()), fingerprint); | ||
println!("{}", secret_key); | ||
assert!(secret_key.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----")); | ||
assert!(secret_key.contains("Comment: Source Key <foo@example.org>")); | ||
let cert = Cert::from_str(&secret_key).unwrap(); | ||
assert_eq!(format!("{}", cert.fingerprint()), fingerprint); | ||
} | ||
|
||
#[test] | ||
fn test_encryption_decryption() { | ||
// Generate a new key | ||
let (public_key, secret_key, _fingerprint) = generate_source_key_pair( | ||
"correcthorsebatterystaple", | ||
"foo@example.org", | ||
) | ||
.unwrap(); | ||
let tmp = NamedTempFile::new().unwrap(); | ||
// Encrypt a message | ||
encrypt_message( | ||
vec![public_key], | ||
"Rust is great 🦀".to_string(), | ||
tmp.path().to_path_buf(), | ||
) | ||
.unwrap(); | ||
let ciphertext = std::fs::read_to_string(tmp.path()).unwrap(); | ||
// Verify ciphertext looks like an encrypted message | ||
assert!(ciphertext.starts_with("-----BEGIN PGP MESSAGE-----\n")); | ||
// Try to decrypt the message | ||
let plaintext = decrypt( | ||
ciphertext.into_bytes(), | ||
secret_key, | ||
"correcthorsebatterystaple".to_string(), | ||
) | ||
.unwrap(); | ||
// Verify message is what we put in originally | ||
assert_eq!("Rust is great 🦀", &plaintext); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
[toolchain] | ||
channel = "1.69.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
max_width = 80 |