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, } #[derive(Debug, Clone, SerdeSerialize)] #[serde(rename_all = "camelCase")] pub struct DecryptResult { pub plaintext: String, pub signed: bool, pub verified: bool, pub signer_fingerprint: Option, } pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result { 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 { 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 { 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 { let fingerprint = normalize_fingerprint(&format!("{}", public.primary_key.fingerprint())); let key_id = fingerprint .chars() .rev() .take(16) .collect::() .chars() .rev() .collect::(); 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 { 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 { 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 { 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 { 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 { 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 { 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, 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, 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 { 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 { 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(¤t, 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, 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, pub signer_email: Option, pub signer_trust: Option, pub text: Option, pub html: Option, pub attachments: Vec, } 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 { 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 = 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 = 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 { 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 { 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 { 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 { 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 { 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>, 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 { 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 { 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"); } }