Skip to content

Commit

Permalink
Add "redwood" Sequoia Rust/Python bridge
Browse files Browse the repository at this point in the history
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
legoktm committed Jun 2, 2023
1 parent 9a08371 commit d072b8c
Show file tree
Hide file tree
Showing 9 changed files with 1,605 additions and 0 deletions.
1,250 changes: 1,250 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[workspace]

members = [
"redwood",
]
18 changes: 18 additions & 0 deletions redwood/Cargo.toml
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"
13 changes: 13 additions & 0 deletions redwood/pyproject.toml
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"
16 changes: 16 additions & 0 deletions redwood/redwood.pyi
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
67 changes: 67 additions & 0 deletions redwood/src/decryption.rs
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)
}
}
233 changes: 233 additions & 0 deletions redwood/src/lib.rs
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);
}
}
2 changes: 2 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[toolchain]
channel = "1.69.0"
1 change: 1 addition & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
max_width = 80

0 comments on commit d072b8c

Please sign in to comment.