Files
RustBox/src/pgp/mod.rs
T
2026-10-31 14:36:07 +01:00

1148 lines
42 KiB
Rust

pub mod discovery;
use std::fs;
use std::io::{BufRead, BufReader, Cursor, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use pgp::composed::{
ArmorOptions, CleartextSignedMessage, Deserializable, DetachedSignature, EncryptionCaps,
KeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey,
SubkeyParamsBuilder,
};
use pgp::crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm};
use pgp::types::{KeyDetails, Password};
use serde::Serialize as SerdeSerialize;
const MAX_DECRYPTED_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
use smallvec::smallvec;
#[derive(Debug, Clone)]
pub struct ImportedKey {
pub fingerprint: String,
pub key_id: String,
pub user_id: String,
pub email: String,
pub public_armor: String,
pub secret_armor: Option<String>,
}
#[derive(Debug, Clone, SerdeSerialize)]
#[serde(rename_all = "camelCase")]
pub struct DecryptResult {
pub plaintext: String,
pub signed: bool,
pub verified: bool,
pub signer_fingerprint: Option<String>,
}
pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result<ImportedKey, String> {
let user_id = if name.trim().is_empty() {
email.trim().to_string()
} else {
format!("{} <{}>", name.trim(), email.trim())
};
let passphrase = (!passphrase.is_empty()).then(|| passphrase.to_string());
let subkey = SubkeyParamsBuilder::default()
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
.can_encrypt(EncryptionCaps::All)
.passphrase(passphrase.clone())
.build()
.map_err(|e| format!("Failed to configure PGP encryption subkey: {e}"))?;
let params = SecretKeyParamsBuilder::default()
.key_type(KeyType::Ed25519Legacy)
.can_certify(true)
.can_sign(true)
.primary_user_id(user_id)
.passphrase(passphrase)
.preferred_symmetric_algorithms(smallvec![
SymmetricKeyAlgorithm::AES256,
SymmetricKeyAlgorithm::AES128,
])
.preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256])
.preferred_compression_algorithms(smallvec![])
.subkeys(vec![subkey])
.build()
.map_err(|e| format!("Failed to configure OpenPGP key: {e}"))?;
let mut rng = rand08::thread_rng();
let secret = params
.generate(&mut rng)
.map_err(|e| format!("Failed to generate OpenPGP key: {e}"))?;
secret
.verify_bindings()
.map_err(|e| format!("Generated OpenPGP key failed self-check: {e}"))?;
material_from_secret(secret)
}
pub fn import_key(data: &[u8]) -> Result<ImportedKey, String> {
let armored = data.starts_with(b"-----BEGIN PGP");
if armored {
let text = std::str::from_utf8(data)
.map_err(|_| "Armored OpenPGP key is not valid UTF-8".to_string())?;
if let Ok((secret, _)) = SignedSecretKey::from_string(text) {
secret
.verify_bindings()
.map_err(|e| format!("Invalid OpenPGP secret-key bindings: {e}"))?;
return material_from_secret(secret);
}
let (public, _) = SignedPublicKey::from_string(text)
.map_err(|e| format!("Input is not a supported OpenPGP public or secret key: {e}"))?;
public
.verify_bindings()
.map_err(|e| format!("Invalid OpenPGP public-key bindings: {e}"))?;
return material_from_public(public);
}
if let Ok((secret, _)) = SignedSecretKey::from_reader_single(Cursor::new(data)) {
secret
.verify_bindings()
.map_err(|e| format!("Invalid OpenPGP secret-key bindings: {e}"))?;
return material_from_secret(secret);
}
let (public, _) = SignedPublicKey::from_reader_single(Cursor::new(data))
.map_err(|e| format!("Input is not a supported OpenPGP public or secret key: {e}"))?;
public
.verify_bindings()
.map_err(|e| format!("Invalid OpenPGP public-key bindings: {e}"))?;
material_from_public(public)
}
fn material_from_secret(secret: SignedSecretKey) -> Result<ImportedKey, String> {
let public = secret.to_public_key();
let mut material = material_from_public(public)?;
material.secret_armor = Some(
secret
.to_armored_string(ArmorOptions::default())
.map_err(|e| format!("Failed to armor secret key: {e}"))?,
);
Ok(material)
}
fn material_from_public(public: SignedPublicKey) -> Result<ImportedKey, String> {
let fingerprint = normalize_fingerprint(&format!("{}", public.primary_key.fingerprint()));
let key_id = fingerprint
.chars()
.rev()
.take(16)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
let user_id = public
.details
.users
.first()
.map(|user| String::from_utf8_lossy(user.id.id()).trim().to_string())
.unwrap_or_default();
let email = extract_email(&user_id);
let public_armor = public
.to_armored_string(ArmorOptions::default())
.map_err(|e| format!("Failed to armor public key: {e}"))?;
Ok(ImportedKey {
fingerprint,
key_id,
user_id,
email,
public_armor,
secret_armor: None,
})
}
pub fn sign_detached(data: &[u8], secret_armor: &str, passphrase: &str) -> Result<String, String> {
let (secret, _) = SignedSecretKey::from_string(secret_armor)
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
let password: Password = passphrase.into();
let signature = DetachedSignature::sign_binary_data(
rand08::thread_rng(),
&secret.primary_key,
&password,
HashAlgorithm::Sha256,
data,
)
.map_err(|e| format!("OpenPGP signing failed: {e}"))?;
signature
.to_armored_string(ArmorOptions::default())
.map_err(|e| format!("Failed to armor OpenPGP signature: {e}"))
}
pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> {
sign_detached(
b"RustBox OpenPGP unlock self-test",
secret_armor,
passphrase,
)
.map(|_| ())
}
pub fn verify_detached(
data: &[u8],
signature_armor: &str,
public_armor: &str,
) -> Result<(), String> {
let (signature, _) = DetachedSignature::from_string(signature_armor)
.map_err(|e| format!("Failed to parse OpenPGP signature: {e}"))?;
let (public, _) = SignedPublicKey::from_string(public_armor)
.map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))?;
signature
.verify(&public, data)
.map_err(|e| format!("OpenPGP signature verification failed: {e}"))
}
pub fn encrypt_bytes(data: &[u8], public_armors: &[String]) -> Result<String, String> {
if public_armors.is_empty() {
return Err("At least one OpenPGP recipient key is required".to_string());
}
let keys = parse_public_keys(public_armors)?;
let mut rng = rand08::thread_rng();
let mut builder = MessageBuilder::from_bytes("message.eml", data.to_vec())
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
for key in &keys {
if let Some(subkey) = key.public_subkeys.first() {
builder
.encrypt_to_key(&mut rng, subkey)
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
} else {
builder
.encrypt_to_key(&mut rng, &key.primary_key)
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
}
}
builder
.to_armored_string(&mut rng, ArmorOptions::default())
.map_err(|e| format!("OpenPGP encryption failed: {e}"))
}
pub fn inline_sign(text: &str, secret_armor: &str, passphrase: &str) -> Result<String, String> {
let (secret, _) = SignedSecretKey::from_string(secret_armor)
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
let password: Password = passphrase.into();
CleartextSignedMessage::sign(rand08::thread_rng(), text, &secret.primary_key, &password)
.and_then(|signed| signed.to_armored_string(ArmorOptions::default()))
.map_err(|e| format!("OpenPGP inline signing failed: {e}"))
}
pub fn inline_encrypt(
text: &str,
public_armors: &[String],
signer: Option<(&str, &str)>,
) -> Result<String, String> {
if public_armors.is_empty() {
return Err("At least one OpenPGP recipient key is required".to_string());
}
let keys = parse_public_keys(public_armors)?;
let signing_secret = if let Some((secret_armor, _)) = signer {
let (secret, _) = SignedSecretKey::from_string(secret_armor)
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
Some(secret)
} else {
None
};
let mut rng = rand08::thread_rng();
let mut builder = MessageBuilder::from_bytes("message.txt", text.as_bytes().to_vec())
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
if let (Some(secret), Some((_, passphrase))) = (signing_secret.as_ref(), signer) {
let password: Password = passphrase.into();
builder.sign(&secret.primary_key, password, HashAlgorithm::Sha256);
}
for key in &keys {
if let Some(subkey) = key.public_subkeys.first() {
builder
.encrypt_to_key(&mut rng, subkey)
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
} else {
builder
.encrypt_to_key(&mut rng, &key.primary_key)
.map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?;
}
}
builder
.to_armored_string(&mut rng, ArmorOptions::default())
.map_err(|e| format!("OpenPGP inline encryption failed: {e}"))
}
pub fn decrypt_inline(
armored: &str,
secret_armor: &str,
passphrase: &str,
public_armors: &[String],
) -> Result<DecryptResult, String> {
let (secret, _) = SignedSecretKey::from_string(secret_armor)
.map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?;
let (message, _) = Message::from_string(armored)
.map_err(|e| format!("Failed to parse OpenPGP message: {e}"))?;
let password: Password = passphrase.into();
let mut decrypted = message
.decrypt(&password, &secret)
.map_err(|e| format!("OpenPGP decryption failed: {e}"))?;
let signed = decrypted.is_signed() || decrypted.is_one_pass_signed();
let plaintext_bytes = read_message_limited(&mut decrypted)?;
let plaintext = String::from_utf8(plaintext_bytes)
.map_err(|_| "Decrypted OpenPGP payload is not valid UTF-8/MIME text".to_string())?;
let mut verified = false;
let mut signer_fingerprint = None;
if signed {
for armor in public_armors {
if let Ok((public, _)) = SignedPublicKey::from_string(armor)
&& decrypted.verify(&public).is_ok()
{
verified = true;
signer_fingerprint = Some(normalize_fingerprint(&format!(
"{}",
public.primary_key.fingerprint()
)));
break;
}
}
}
Ok(DecryptResult {
plaintext,
signed,
verified,
signer_fingerprint,
})
}
pub fn verify_inline(armored: &str, public_armor: &str) -> Result<String, String> {
let (public, _) = SignedPublicKey::from_string(public_armor)
.map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))?;
if armored.contains("-----BEGIN PGP SIGNED MESSAGE-----") {
let (message, _) = CleartextSignedMessage::from_string(armored)
.map_err(|e| format!("Failed to parse OpenPGP cleartext signature: {e}"))?;
message
.verify(&public)
.map_err(|e| format!("OpenPGP signature verification failed: {e}"))?;
return Ok(message.signed_text());
}
let (mut message, _) = Message::from_string(armored)
.map_err(|e| format!("Failed to parse OpenPGP message: {e}"))?;
let text = String::from_utf8(read_message_limited(&mut message)?)
.map_err(|_| "OpenPGP signed payload is not valid UTF-8 text".to_string())?;
message
.verify(&public)
.map_err(|e| format!("OpenPGP signature verification failed: {e}"))?;
Ok(text)
}
fn read_message_limited(message: &mut Message<'_>) -> Result<Vec<u8>, String> {
let mut output = Vec::new();
let limit = MAX_DECRYPTED_MESSAGE_BYTES.saturating_add(1) as u64;
(&mut *message)
.take(limit)
.read_to_end(&mut output)
.map_err(|e| format!("Failed to read OpenPGP payload: {e}"))?;
if output.len() > MAX_DECRYPTED_MESSAGE_BYTES {
return Err("OpenPGP payload exceeds the safe processing limit".to_string());
}
Ok(output)
}
fn parse_public_keys(armors: &[String]) -> Result<Vec<SignedPublicKey>, String> {
armors
.iter()
.map(|armor| {
SignedPublicKey::from_string(armor)
.map(|(key, _)| key)
.map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))
})
.collect()
}
pub fn private_key_dir(data_dir: &str, user_hash: &str) -> PathBuf {
Path::new(data_dir)
.join(user_hash)
.join("pgp")
.join("private")
}
pub fn store_secret_key(
data_dir: &str,
user_hash: &str,
fingerprint: &str,
armor: &str,
) -> Result<String, String> {
let dir = private_key_dir(data_dir, user_hash);
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?;
set_dir_permissions(&dir)?;
if fingerprint.is_empty()
|| !fingerprint
.chars()
.all(|c| c.is_ascii_hexdigit() || c.is_ascii_whitespace() || c == ':')
{
return Err("Invalid OpenPGP fingerprint".to_string());
}
let safe = normalize_fingerprint(fingerprint);
if safe.is_empty() {
return Err("Invalid OpenPGP fingerprint".to_string());
}
let final_path = dir.join(format!("{safe}.asc"));
let temp_path = dir.join(format!(".{safe}.{}.part", uuid::Uuid::new_v4()));
write_private_file(&temp_path, armor.as_bytes())?;
fs::rename(&temp_path, &final_path).map_err(|e| {
let _ = fs::remove_file(&temp_path);
format!("Failed to install OpenPGP private key: {e}")
})?;
Ok(final_path.to_string_lossy().into_owned())
}
pub fn read_secret_key(path: &str) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read OpenPGP private key: {e}"))
}
pub fn delete_secret_key(path: &str) -> Result<(), String> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to delete OpenPGP private key: {e}")),
}
}
pub fn change_secret_key_passphrase(
data_dir: &str,
user_hash: &str,
fingerprint: &str,
secret_path: &str,
old_passphrase: &str,
new_passphrase: &str,
gpg_binary: &str,
) -> Result<(), String> {
if old_passphrase.len() > 1024 || new_passphrase.len() > 1024 {
return Err("OpenPGP passphrase is too long".to_string());
}
let safe = normalize_fingerprint(fingerprint);
if safe.is_empty() || !safe.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Invalid OpenPGP fingerprint".to_string());
}
let current = read_secret_key(secret_path)?;
test_secret_key_passphrase(&current, old_passphrase)
.map_err(|_| "Current OpenPGP passphrase is incorrect".to_string())?;
let private_dir = private_key_dir(data_dir, user_hash);
fs::create_dir_all(&private_dir)
.map_err(|e| format!("Failed to create OpenPGP key directory: {e}"))?;
set_dir_permissions(&private_dir)?;
let work_dir = private_dir.join(format!(".rewrap-{}", uuid::Uuid::new_v4()));
fs::create_dir(&work_dir)
.map_err(|e| format!("Failed to create OpenPGP rewrap directory: {e}"))?;
set_dir_permissions(&work_dir)?;
let result = (|| {
let import = Command::new(gpg_binary)
.arg("--homedir")
.arg(&work_dir)
.args(["--no-options", "--batch", "--yes", "--import"])
.arg(secret_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("OpenPGP passphrase change requires GnuPG ({gpg_binary}): {e}"))?;
if !import.status.success() {
return Err("GnuPG could not import the private key for passphrase change".to_string());
}
let mut child = Command::new(gpg_binary)
.arg("--homedir")
.arg(&work_dir)
.args([
"--no-options",
"--batch",
"--yes",
"--pinentry-mode",
"loopback",
"--status-fd",
"2",
"--command-fd",
"0",
"--passphrase-repeat",
"0",
"--passwd",
])
.arg(&safe)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start GnuPG passphrase change: {e}"))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Failed to open GnuPG command input".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Failed to open GnuPG status output".to_string())?;
let mut hidden_prompts = 0usize;
let mut bad_passphrase = false;
for line in BufReader::new(stderr).lines() {
let line = line.map_err(|e| format!("Failed to read GnuPG status: {e}"))?;
if line.contains("[GNUPG:] BAD_PASSPHRASE") {
bad_passphrase = true;
} else if line.contains("[GNUPG:] GET_HIDDEN") {
let response = if hidden_prompts == 0 && !old_passphrase.is_empty() {
old_passphrase
} else {
new_passphrase
};
stdin
.write_all(response.as_bytes())
.map_err(|e| format!("Failed to answer GnuPG passphrase prompt: {e}"))?;
stdin
.write_all(b"\n")
.map_err(|e| format!("Failed to answer GnuPG passphrase prompt: {e}"))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush GnuPG passphrase prompt: {e}"))?;
hidden_prompts += 1;
} else if line.contains("[GNUPG:] GET_BOOL") {
stdin
.write_all(b"y\n")
.map_err(|e| format!("Failed to answer GnuPG confirmation: {e}"))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush GnuPG confirmation: {e}"))?;
} else if line.contains("[GNUPG:] GET_LINE") {
stdin
.write_all(b"\n")
.map_err(|e| format!("Failed to answer GnuPG prompt: {e}"))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush GnuPG prompt: {e}"))?;
}
}
drop(stdin);
let status = child
.wait()
.map_err(|e| format!("Failed to wait for GnuPG passphrase change: {e}"))?;
if bad_passphrase || !status.success() {
return Err("GnuPG rejected the OpenPGP passphrase change".to_string());
}
let mut export = Command::new(gpg_binary)
.arg("--homedir")
.arg(&work_dir)
.args([
"--no-options",
"--batch",
"--yes",
"--pinentry-mode",
"loopback",
"--passphrase-fd",
"0",
"--armor",
"--export-secret-keys",
])
.arg(&safe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start GnuPG private-key export: {e}"))?;
if let Some(mut input) = export.stdin.take() {
input
.write_all(new_passphrase.as_bytes())
.map_err(|e| format!("Failed to provide GnuPG export passphrase: {e}"))?;
input
.write_all(b"\n")
.map_err(|e| format!("Failed to provide GnuPG export passphrase: {e}"))?;
}
let output = export
.wait_with_output()
.map_err(|e| format!("Failed to export rewrapped OpenPGP key: {e}"))?;
if !output.status.success() || output.stdout.is_empty() {
return Err("GnuPG did not return the rewrapped OpenPGP private key".to_string());
}
let armor = String::from_utf8(output.stdout)
.map_err(|_| "GnuPG returned non-UTF-8 armored private-key data".to_string())?;
let imported = import_key(armor.as_bytes())?;
if normalize_fingerprint(&imported.fingerprint) != safe {
return Err("Rewrapped OpenPGP key fingerprint changed unexpectedly".to_string());
}
let secret = imported
.secret_armor
.ok_or_else(|| "Rewrapped key contains no private material".to_string())?;
test_secret_key_passphrase(&secret, new_passphrase).map_err(|_| {
"Rewrapped OpenPGP key could not be unlocked with the new passphrase".to_string()
})?;
store_secret_key(data_dir, user_hash, &safe, &secret)?;
Ok(())
})();
let _ = fs::remove_dir_all(&work_dir);
result
}
#[cfg(unix)]
fn set_dir_permissions(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Failed to protect PGP key directory: {e}"))
}
#[cfg(not(unix))]
fn set_dir_permissions(_path: &Path) -> Result<(), String> {
Ok(())
}
#[cfg(unix)]
fn write_private_file(path: &Path, data: &[u8]) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("Failed to create OpenPGP private key: {e}"))?;
file.write_all(data)
.map_err(|e| format!("Failed to write OpenPGP private key: {e}"))?;
file.sync_all()
.map_err(|e| format!("Failed to sync OpenPGP private key: {e}"))
}
#[cfg(not(unix))]
fn write_private_file(path: &Path, data: &[u8]) -> Result<(), String> {
fs::write(path, data).map_err(|e| format!("Failed to write OpenPGP private key: {e}"))
}
fn extract_email(user_id: &str) -> String {
if let Some(start) = user_id.rfind('<')
&& let Some(end_rel) = user_id[start + 1..].find('>')
{
let value = user_id[start + 1..start + 1 + end_rel].trim();
if value.contains('@') {
return value.to_ascii_lowercase();
}
}
user_id
.split_whitespace()
.map(|part| part.trim_matches(|c: char| matches!(c, '<' | '>' | '(' | ')' | ',' | ';')))
.find(|part| part.contains('@'))
.unwrap_or("")
.to_ascii_lowercase()
}
pub fn normalize_fingerprint(value: &str) -> String {
value
.chars()
.filter(|c| c.is_ascii_hexdigit())
.flat_map(char::to_uppercase)
.collect()
}
#[derive(Debug, Clone, SerdeSerialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessedAttachment {
pub filename: Option<String>,
pub content_type: String,
pub size: usize,
pub data_base64: String,
}
#[derive(Debug, Clone, SerdeSerialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessedMessage {
pub pgp: bool,
pub encrypted: bool,
pub decrypted: bool,
pub signed: bool,
pub verified: bool,
pub signer_fingerprint: Option<String>,
pub signer_email: Option<String>,
pub signer_trust: Option<String>,
pub text: Option<String>,
pub html: Option<String>,
pub attachments: Vec<ProcessedAttachment>,
}
pub fn looks_like_openpgp(raw: &[u8]) -> bool {
let lower = String::from_utf8_lossy(raw).to_ascii_lowercase();
lower.contains("multipart/encrypted") && lower.contains("application/pgp-encrypted")
|| lower.contains("multipart/signed") && lower.contains("application/pgp-signature")
|| lower.contains("-----begin pgp message-----")
|| lower.contains("-----begin pgp signed message-----")
}
pub fn process_message(
raw: &[u8],
secret_armors: &[String],
passphrase: &str,
public_keys: &[(String, String)],
) -> Result<ProcessedMessage, String> {
let (headers, body) = split_headers_body(raw);
let content_type = header_value(headers, "content-type").unwrap_or_default();
let content_type_lower = content_type.to_ascii_lowercase();
if content_type_lower.starts_with("multipart/encrypted")
&& content_type_lower.contains("application/pgp-encrypted")
{
let boundary = mime_boundary(&content_type)
.ok_or_else(|| "OpenPGP/MIME encrypted message has no boundary".to_string())?;
let parts = split_multipart(body, &boundary)?;
if parts.len() < 2 {
return Err(
"OpenPGP/MIME encrypted message is missing its encrypted payload".to_string(),
);
}
let encrypted_bytes = decoded_part_contents(&parts[1]);
let encrypted_text = String::from_utf8(encrypted_bytes)
.map_err(|_| "OpenPGP encrypted payload is not valid ASCII armor".to_string())?;
let public_armors: Vec<String> =
public_keys.iter().map(|(_, armor)| armor.clone()).collect();
let mut last_error = "No private OpenPGP key could decrypt this message".to_string();
for secret in secret_armors {
match decrypt_inline(&encrypted_text, secret, passphrase, &public_armors) {
Ok(decrypted) => {
let nested = decrypted.plaintext.as_bytes();
let mut processed = if is_multipart_signed(nested) {
process_multipart_signed(nested, public_keys)?
} else {
let mut clear = parse_clear_mime(nested)?;
clear.signed = decrypted.signed;
clear.verified = decrypted.verified;
clear.signer_fingerprint = decrypted.signer_fingerprint;
clear
};
processed.pgp = true;
processed.encrypted = true;
processed.decrypted = true;
return Ok(processed);
}
Err(error) => last_error = error,
}
}
return Err(last_error);
}
if is_multipart_signed(raw) {
return process_multipart_signed(raw, public_keys);
}
let raw_text = String::from_utf8_lossy(raw);
if let Some(block) = extract_armored_block(&raw_text, "PGP MESSAGE") {
let public_armors: Vec<String> =
public_keys.iter().map(|(_, armor)| armor.clone()).collect();
let mut last_error = "No private OpenPGP key could decrypt this inline message".to_string();
for secret in secret_armors {
match decrypt_inline(block, secret, passphrase, &public_armors) {
Ok(result) => {
return Ok(ProcessedMessage {
pgp: true,
encrypted: true,
decrypted: true,
signed: result.signed,
verified: result.verified,
signer_fingerprint: result.signer_fingerprint,
signer_email: None,
signer_trust: None,
text: Some(result.plaintext),
html: None,
attachments: vec![],
});
}
Err(error) => last_error = error,
}
}
return Err(last_error);
}
if let Some(block) = extract_armored_block(&raw_text, "PGP SIGNED MESSAGE") {
for (fingerprint, public) in public_keys {
if let Ok(text) = verify_inline(block, public) {
return Ok(ProcessedMessage {
pgp: true,
encrypted: false,
decrypted: false,
signed: true,
verified: true,
signer_fingerprint: Some(fingerprint.clone()),
signer_email: None,
signer_trust: None,
text: Some(text),
html: None,
attachments: vec![],
});
}
}
return Ok(ProcessedMessage {
pgp: true,
encrypted: false,
decrypted: false,
signed: true,
verified: false,
signer_fingerprint: None,
signer_email: None,
signer_trust: None,
text: Some(block.to_string()),
html: None,
attachments: vec![],
});
}
Ok(ProcessedMessage {
pgp: false,
encrypted: false,
decrypted: false,
signed: false,
verified: false,
signer_fingerprint: None,
signer_email: None,
signer_trust: None,
text: None,
html: None,
attachments: vec![],
})
}
fn is_multipart_signed(raw: &[u8]) -> bool {
let (headers, _) = split_headers_body(raw);
header_value(headers, "content-type")
.map(|value| {
let lower = value.to_ascii_lowercase();
lower.starts_with("multipart/signed") && lower.contains("application/pgp-signature")
})
.unwrap_or(false)
}
fn process_multipart_signed(
raw: &[u8],
public_keys: &[(String, String)],
) -> Result<ProcessedMessage, String> {
let (headers, body) = split_headers_body(raw);
let content_type = header_value(headers, "content-type").unwrap_or_default();
let boundary = mime_boundary(&content_type)
.ok_or_else(|| "OpenPGP/MIME signed message has no boundary".to_string())?;
let parts = split_multipart(body, &boundary)?;
if parts.len() < 2 {
return Err("OpenPGP/MIME signature part is missing".to_string());
}
let signature = String::from_utf8(decoded_part_contents(&parts[1]))
.map_err(|_| "OpenPGP signature is not valid ASCII armor".to_string())?;
let mut verified = false;
let mut signer = None;
for (fingerprint, public) in public_keys {
if verify_detached(&parts[0], &signature, public).is_ok()
|| verify_detached(&with_final_crlf(&parts[0]), &signature, public).is_ok()
{
verified = true;
signer = Some(fingerprint.clone());
break;
}
}
let mut clear = parse_clear_mime(&parts[0])?;
clear.pgp = true;
clear.signed = true;
clear.verified = verified;
clear.signer_fingerprint = signer;
Ok(clear)
}
fn parse_clear_mime(bytes: &[u8]) -> Result<ProcessedMessage, String> {
if bytes.len() > MAX_DECRYPTED_MESSAGE_BYTES {
return Err("Decrypted OpenPGP MIME payload exceeds the safe processing limit".to_string());
}
use base64::Engine;
use mail_parser::MimeHeaders;
let parsed = mail_parser::MessageParser::default()
.parse(bytes)
.ok_or_else(|| "Failed to parse decrypted OpenPGP MIME payload".to_string())?;
let text = parsed.body_text(0).map(|value| value.to_string());
let has_html = parsed.parts.iter().any(|part| {
part.content_type()
.is_some_and(|ct| ct.ctype() == "text" && ct.subtype() == Some("html"))
});
let html = if has_html {
parsed.body_html(0).map(|value| value.to_string())
} else {
None
};
let mut attachments = Vec::new();
for attachment in parsed.attachments() {
let data = attachment.contents();
let content_type = attachment.content_type().map_or_else(
|| "application/octet-stream".to_string(),
|ct| {
ct.subtype()
.map(|sub| format!("{}/{}", ct.ctype(), sub))
.unwrap_or_else(|| ct.ctype().to_string())
},
);
attachments.push(ProcessedAttachment {
filename: attachment.attachment_name().map(|value| value.to_string()),
content_type,
size: data.len(),
data_base64: base64::engine::general_purpose::STANDARD.encode(data),
});
}
Ok(ProcessedMessage {
pgp: true,
encrypted: false,
decrypted: false,
signed: false,
verified: false,
signer_fingerprint: None,
signer_email: None,
signer_trust: None,
text,
html,
attachments,
})
}
fn decoded_part_contents(part: &[u8]) -> Vec<u8> {
if let Some(parsed) = mail_parser::MessageParser::default().parse(part)
&& let Some(root) = parsed.parts.first()
{
return root.contents().to_vec();
}
split_headers_body(part).1.to_vec()
}
fn split_headers_body(raw: &[u8]) -> (&[u8], &[u8]) {
if let Some(pos) = find_bytes(raw, b"\r\n\r\n") {
return (&raw[..pos], &raw[pos + 4..]);
}
if let Some(pos) = find_bytes(raw, b"\n\n") {
return (&raw[..pos], &raw[pos + 2..]);
}
(raw, &[])
}
fn header_value(headers: &[u8], wanted: &str) -> Option<String> {
let text = String::from_utf8_lossy(headers);
let mut current_name = String::new();
let mut current_value = String::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with(' ') || line.starts_with('\t') {
if !current_name.is_empty() {
current_value.push(' ');
current_value.push_str(line.trim());
}
continue;
}
if current_name.eq_ignore_ascii_case(wanted) {
return Some(current_value.trim().to_string());
}
if let Some((name, value)) = line.split_once(':') {
current_name = name.trim().to_string();
current_value = value.trim().to_string();
}
}
current_name
.eq_ignore_ascii_case(wanted)
.then(|| current_value.trim().to_string())
}
fn mime_boundary(content_type: &str) -> Option<String> {
for parameter in content_type.split(';').skip(1) {
let (name, value) = parameter.trim().split_once('=')?;
if name.trim().eq_ignore_ascii_case("boundary") {
return Some(value.trim().trim_matches('"').to_string());
}
}
None
}
fn split_multipart(body: &[u8], boundary: &str) -> Result<Vec<Vec<u8>>, String> {
if boundary.is_empty() || boundary.len() > 200 {
return Err("Invalid MIME boundary".to_string());
}
let marker = format!("--{boundary}").into_bytes();
let mut positions = Vec::new();
let mut cursor = 0usize;
while cursor < body.len() {
let Some(rel) = find_bytes(&body[cursor..], &marker) else {
break;
};
let pos = cursor + rel;
if pos == 0 || body.get(pos.wrapping_sub(1)) == Some(&b'\n') {
positions.push(pos);
}
cursor = pos.saturating_add(marker.len());
}
let mut parts = Vec::new();
for window in positions.windows(2) {
let start_marker = window[0];
let next_marker = window[1];
let after = start_marker + marker.len();
if body.get(after..after + 2) == Some(b"--") {
break;
}
let mut start = after;
if body.get(start..start + 2) == Some(b"\r\n") {
start += 2;
} else if body.get(start) == Some(&b'\n') {
start += 1;
}
let mut end = next_marker;
if end >= 2 && &body[end - 2..end] == b"\r\n" {
end -= 2;
} else if end >= 1 && body[end - 1] == b'\n' {
end -= 1;
}
if end >= start {
parts.push(body[start..end].to_vec());
}
}
if parts.is_empty() {
return Err("Could not split OpenPGP MIME multipart body".to_string());
}
Ok(parts)
}
fn with_final_crlf(data: &[u8]) -> Vec<u8> {
let mut value = data.to_vec();
if !value.ends_with(b"\r\n") {
value.extend_from_slice(b"\r\n");
}
value
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn extract_armored_block<'a>(text: &'a str, kind: &str) -> Option<&'a str> {
let begin = format!("-----BEGIN {kind}-----");
let end = format!(
"-----END {}-----",
if kind == "PGP SIGNED MESSAGE" {
"PGP SIGNATURE"
} else {
kind
}
);
let start = text.find(&begin)?;
let tail = &text[start..];
let finish = tail.find(&end)? + end.len();
Some(&tail[..finish])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openpgp_roundtrip_sign_encrypt_and_import() {
let key =
generate_key("RustBox Test", "pgp-test@example.invalid", "").expect("generate key");
let secret = key.secret_armor.as_deref().expect("secret armor");
let signed = inline_sign("hello rustbox", secret, "").expect("sign");
let verified = verify_inline(&signed, &key.public_armor).expect("verify");
assert!(verified.contains("hello rustbox"));
let encrypted = inline_encrypt(
"secret rustbox",
std::slice::from_ref(&key.public_armor),
Some((secret, "")),
)
.expect("encrypt");
let decrypted = decrypt_inline(
&encrypted,
secret,
"",
std::slice::from_ref(&key.public_armor),
)
.expect("decrypt");
assert_eq!(decrypted.plaintext, "secret rustbox");
assert!(decrypted.signed);
assert!(decrypted.verified);
let imported_public = import_key(key.public_armor.as_bytes()).expect("import public armor");
assert_eq!(imported_public.fingerprint, key.fingerprint);
let imported_secret = import_key(secret.as_bytes()).expect("import secret armor");
assert_eq!(imported_secret.fingerprint, key.fingerprint);
assert!(imported_secret.secret_armor.is_some());
}
#[test]
fn pgp_mime_signed_and_encrypted_processing() {
let key =
generate_key("RustBox Test", "mime-test@example.invalid", "").expect("generate key");
let secret = key.secret_armor.as_deref().expect("secret armor");
let clear = b"Content-Type: text/plain; charset=utf-8\r\n\r\nHello MIME";
let signature = sign_detached(clear, secret, "").expect("sign mime");
let signed = format!(
"Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=\"rb\"\r\n\r\n--rb\r\n{}\r\n--rb\r\nContent-Type: application/pgp-signature\r\n\r\n{}\r\n--rb--\r\n",
String::from_utf8_lossy(clear),
signature
);
let public = vec![(key.fingerprint.clone(), key.public_armor.clone())];
let processed =
process_message(signed.as_bytes(), &[], "", &public).expect("process signed mime");
assert!(processed.signed);
assert!(processed.verified);
let encrypted =
encrypt_bytes(clear, std::slice::from_ref(&key.public_armor)).expect("encrypt mime");
let encrypted_mime = format!(
"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"rbe\"\r\n\r\n--rbe\r\nContent-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n--rbe\r\nContent-Type: application/octet-stream\r\n\r\n{encrypted}\r\n--rbe--\r\n"
);
let secrets = vec![secret.to_string()];
let processed = process_message(encrypted_mime.as_bytes(), &secrets, "", &public)
.expect("process encrypted mime");
assert!(processed.encrypted);
assert!(processed.decrypted);
assert_eq!(processed.text.as_deref(), Some("Hello MIME"));
}
#[cfg(unix)]
#[test]
fn private_key_storage_is_isolated_and_restrictive() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join(format!("rustbox-pgp-test-{}", uuid::Uuid::new_v4()));
let root_text = root.to_string_lossy().into_owned();
let fingerprint = "0123456789ABCDEF0123456789ABCDEF01234567";
let first = store_secret_key(&root_text, "user-a", fingerprint, "PRIVATE-A")
.expect("store first private key");
let second = store_secret_key(&root_text, "user-b", fingerprint, "PRIVATE-B")
.expect("store second private key");
assert_ne!(
first, second,
"different users must never share a private-key path"
);
assert!(first.contains("user-a/pgp/private"));
assert!(second.contains("user-b/pgp/private"));
assert_eq!(
fs::read_to_string(&first).expect("read first key"),
"PRIVATE-A"
);
assert_eq!(
fs::read_to_string(&second).expect("read second key"),
"PRIVATE-B"
);
let first_mode = fs::metadata(&first)
.expect("first key metadata")
.permissions()
.mode()
& 0o777;
let first_dir_mode = fs::metadata(private_key_dir(&root_text, "user-a"))
.expect("first key directory metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(first_mode, 0o600);
assert_eq!(first_dir_mode, 0o700);
assert!(store_secret_key(&root_text, "user-a", "../../escape", "bad").is_err());
fs::remove_dir_all(&root).expect("cleanup PGP test directory");
}
}