diff --git a/proton-bridge/src/api.rs b/proton-bridge/src/api.rs index 3ad5081..b37dba9 100644 --- a/proton-bridge/src/api.rs +++ b/proton-bridge/src/api.rs @@ -72,8 +72,6 @@ struct MessageListPayload { /// Full message with (still-encrypted) body. #[derive(Debug, Clone, Deserialize)] pub struct Message { - #[serde(rename = "ID")] - pub id: String, #[serde(rename = "Subject")] pub subject: String, #[serde(rename = "Sender")] @@ -250,8 +248,6 @@ pub struct UserKey { #[serde(rename = "PrivateKey")] pub private_key: String, /// 1 = primary key. - #[serde(rename = "Primary")] - pub primary: u32, #[serde(rename = "Active")] pub active: u32, } diff --git a/proton-bridge/src/crypto.rs b/proton-bridge/src/crypto.rs index 5c4cc35..3bfa768 100644 --- a/proton-bridge/src/crypto.rs +++ b/proton-bridge/src/crypto.rs @@ -25,10 +25,7 @@ use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData, SymE use pgp::ser::Serialize; use pgp::types::{KeyVersion, PublicKeyTrait}; use pgp::{Deserializable, Message, SignedPublicKey, SignedSecretKey}; -use tracing::{error, info, warn}; -use crate::srp::bcrypt_base64_encode; - -// ── Key passphrase derivation ───────────────────────────────────────────────── +use tracing::info; /// Derive the passphrase used to unlock a user private key. /// @@ -59,90 +56,6 @@ pub fn derive_key_passphrase(password: &str, key_salt_b64: &str) -> Result Result { - if key_salt_b64.is_empty() { - return Ok(password.to_string()); - } - - // bcrypt alphabet — same index ordering bcryptjs uses in its decode table. - const ALPHA: &[u8] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let mut table = [255u8; 256]; - for (i, &c) in ALPHA.iter().enumerate() { - table[c as usize] = i as u8; - } - - // bcryptjs reads the first 22 chars after "$2y$10$", which are the first 22 - // chars of key_salt_b64 (standard base64 of 16 bytes = 22 data chars + "=="). - let chars: Vec = key_salt_b64.bytes().take(22).collect(); - - // Replicate bcryptjs base64_decode exactly: BREAK on any invalid char. - // Any byte not written stays 0 (bcryptjs initialises its salt array to zeroes). - let mut salt = [0u8; 16]; - let mut pos = 0usize; - let mut i = 0usize; - while pos < 16 && i + 1 < chars.len() { - let c1 = table[chars[i] as usize] as u32; - let c2 = table[chars[i + 1] as usize] as u32; - if c1 == 255 || c2 == 255 { break; } // bcryptjs breaks on invalid c1 or c2 - if pos < 16 { salt[pos] = ((c1 << 2) | ((c2 & 0x30) >> 4)) as u8; pos += 1; } - - if i + 2 >= chars.len() { break; } - let c3 = table[chars[i + 2] as usize] as u32; - if c3 == 255 { break; } // bcryptjs breaks on invalid c3 - if pos < 16 { salt[pos] = (((c2 & 0x0f) << 4) | ((c3 & 0x3c) >> 2)) as u8; pos += 1; } - - if i + 3 >= chars.len() { break; } - let c4 = table[chars[i + 3] as usize] as u32; - if c4 == 255 { break; } // bcryptjs breaks on invalid c4 - if pos < 16 { salt[pos] = (((c3 & 0x03) << 6) | c4) as u8; pos += 1; } - - i += 4; - } - - info!(" [bcrypt-js] effective salt bytes: {}", - salt.iter().map(|b| format!("{b:02x}")).collect::()); - - let parts = bcrypt::hash_with_salt(password, 10, salt) - .map_err(|e| format!("bcrypt-js decode error: {e}"))?; - Ok(parts.format_for_version(bcrypt::Version::TwoY)) -} - -/// Same derivation using pwhash (kept for comparison / fallback). -pub fn derive_key_passphrase_pwhash(password: &str, key_salt_b64: &str) -> Result { - if key_salt_b64.is_empty() { - return Ok(password.to_string()); - } - - let salt_bytes = B64.decode(key_salt_b64).map_err(|e| e.to_string())?; - let encoded_salt = bcrypt_base64_encode(&salt_bytes); - - let hashed = pwhash::bcrypt::hash_with( - pwhash::bcrypt::BcryptSetup { - salt: Some(&encoded_salt), - cost: Some(10), - variant: Some(pwhash::bcrypt::BcryptVariant::V2y), - }, - password, - ) - .map_err(|e| format!("bcrypt error (pwhash): {e}"))?; - - Ok(hashed.to_string()) -} - -// ── Private key management ──────────────────────────────────────────────────── - /// A parsed, ready-to-use private key. /// /// `SignedSecretKey` is `Clone`, so `PrivateKey` derives it too. We clone the @@ -207,50 +120,6 @@ pub fn dump_key_pool(pool: &[PrivateKey]) { } } -/// Print primary key ID + all subkey IDs for a PGP-armored public key. -/// Useful to compare against the PKESK recipient key ID in the draft body. -pub fn log_public_key_ids(pub_key_armored: &str, label: &str) { - let Ok((pk, _)) = - SignedPublicKey::from_armor_single(Cursor::new(pub_key_armored.as_bytes())) - else { - error!(" [key-ids] {label}: failed to parse public key"); - return; - }; - info!( - " [key-ids] {label} primary: id={:?} algo={:?}", - pk.key_id(), - pk.algorithm() - ); - for (i, sk) in pk.public_subkeys.iter().enumerate() { - info!( - " [key-ids] {label} subkey[{i}]: id={:?} algo={:?}", - sk.key_id(), - sk.algorithm() - ); - } -} - -/// Print the PKESK recipient key IDs of a PGP-armored message. -pub fn dump_message_pkesk(armored: &str) { - let Ok((msg, _)) = Message::from_armor_single(Cursor::new(armored.as_bytes())) else { - warn!(" [body-diag] failed to parse message armor"); - return; - }; - if let pgp::Message::Encrypted { esk, .. } = &msg { - for e in esk { - if let pgp::composed::message::Esk::PublicKeyEncryptedSessionKey(pkesk) = e { - let kid = pkesk.id().map(|k| format!("{k:?}")).unwrap_or("(anon)".into()); - let ver = pkesk.version(); - info!(" [body-diag] PKESK version={ver:?} recipient={kid}"); - } - } - } else { - error!(" [body-diag] message is not Encrypted variant"); - } -} - -// ── Message decryption ──────────────────────────────────────────────────────── - /// Decrypt an address key's `Token` field using the user's private key. /// /// In newer ProtonMail accounts, each address key's passphrase is stored as a @@ -294,141 +163,6 @@ pub fn decrypt_token(encrypted_token: &str, user_key: &PrivateKey) -> Result [u8; 32] { - use sha2::Digest; - let body: Vec = s2k_salt.iter().chain(passphrase.as_bytes()).copied().collect(); - let mut hasher = Sha256::new(); - let mut total = 0u64; - while total < count { - let rem = (count - total) as usize; - if rem >= body.len() { - hasher.update(&body); - total += body.len() as u64; - } else { - hasher.update(&body[..rem]); - total = count; - } - } - hasher.finalize().into() - } - - fn check_sha1(enc_data: &[u8], aes_key: &[u8], iv: &[u8]) -> (bool, String) { - use sha1::Digest; - let mut pt = enc_data.to_vec(); - let Ok(d) = Decryptor::::new_from_slices(aes_key, iv) else { - return (false, "cfb-err".into()); - }; - d.decrypt(&mut pt); - let preview: String = pt.iter().take(6).map(|b| format!("{b:02x}")).collect(); - if pt.len() < 20 { return (false, preview); } - let split = pt.len() - 20; - let actual: [u8; 20] = Sha1::digest(&pt[..split]).into(); - let ok = actual.as_slice() == &pt[split..]; - (ok, preview) - } - - for sk in &key.inner.secret_subkeys { - info!(" [diag] subkey {:?}", sk.key_id()); - match sk.key.secret_params() { - SecretParams::Plain(_) => eprintln!(" [diag] subkey is unencrypted"), - SecretParams::Encrypted(enc) => { - let enc_data = enc.data(); - let s2k_params = enc.string_to_key_params(); - - if let S2kParams::Cfb { sym_alg, s2k, iv } = s2k_params { - let key_size = sym_alg.key_size(); - info!( - " [diag] enc_data ({} bytes): {}", - enc_data.len(), - enc_data.iter().map(|b| format!("{b:02x}")).collect::() - ); - info!( - " [diag] iv: {}", - iv.iter().map(|b| format!("{b:02x}")).collect::() - ); - - // Extract raw S2K params for manual comparison. - let raw_params: Option<([u8; 8], u8)> = - if let StringToKey::IteratedAndSalted { salt, count, .. } = s2k { - Some((*salt, *count)) - } else { None }; - - for (label, passphrase) in candidates { - // Path A: rpgp's derive_key - let (rpgp_ok, rpgp_preview, rpgp_key4) = match s2k.derive_key(passphrase, key_size) { - Ok(aes_key) => { - let k4: String = aes_key.iter().take(4) - .map(|b| format!("{b:02x}")).collect(); - let (ok, preview) = check_sha1(enc_data, &aes_key, iv); - (ok, preview, k4) - } - Err(e) => { - info!(" [diag] {label} → S2K error: {e}"); - continue; - } - }; - - // Path B+C: manual S2K with decoded count (65536) and raw count (96) - if let Some((s2k_salt, count_byte)) = raw_params { - let decoded_count = (16u64 + (count_byte as u64 & 15)) - << ((count_byte as u64 >> 4) + 6); - let raw_count = count_byte as u64; - - let key_dec = manual_s2k_sha256(passphrase, &s2k_salt, decoded_count); - let key_raw_c = manual_s2k_sha256(passphrase, &s2k_salt, raw_count); - let k4_dec: String = key_dec.iter().take(4) - .map(|b| format!("{b:02x}")).collect(); - let k4_raw: String = key_raw_c.iter().take(4) - .map(|b| format!("{b:02x}")).collect(); - let (ok_dec, _) = check_sha1(enc_data, &key_dec[..key_size], iv); - let (ok_raw, _) = check_sha1(enc_data, &key_raw_c[..key_size], iv); - - let keys_agree = rpgp_key4 == k4_dec; - let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" }; - info!( - " [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview}) \ - rpgp={rpgp_key4} man65k={k4_dec}(ok:{ok_dec}) \ - man96={k4_raw}(ok:{ok_raw}) agree:{keys_agree}" - ); - if rpgp_ok || ok_dec || ok_raw { - info!(" [diag] *** CORRECT PASSPHRASE: {label} ***"); - } - } else { - let sha1_str = if rpgp_ok { "OK ✓" } else { "FAIL" }; - info!(" [diag] {label} → SHA-1 {sha1_str} (pt:{rpgp_preview})"); - if rpgp_ok { info!(" [diag] *** CORRECT PASSPHRASE: {label} ***"); } - } - } - } else { - info!(" [diag] non-CFB S2K params: {s2k_params:?}"); - } - } - } - } -} - -// ── Message encryption ──────────────────────────────────────────────────────── - /// Return the encryption-capable subkey of a `SignedPublicKey`. /// /// Modern ProtonMail keys have an EdDSA primary key (signing only) and an @@ -524,97 +258,6 @@ pub fn encrypt_body_for_send( } } -/// Encrypt `plaintext` with a fresh random AES-256 session key. -/// -/// Returns `(seipd_bytes, session_key_bytes)`. -/// -/// Used for ProtonMail ClearScheme (Type 4) external sends: -/// - `seipd_bytes` → base64-encode → `Body` in the send package -/// - `session_key_bytes` → base64-encode → `BodyKey.Key` (cleartext session key) -/// -/// ProtonMail receives the SEIPD + session key and uses them to relay the -/// message to the external recipient over SMTP. -pub fn encrypt_body_for_external_send(plaintext: &str) -> Result<(Vec, Vec), String> { - let mut rng = rand::thread_rng(); - - // Serialise the plaintext as an OpenPGP literal data packet. - let literal = Message::new_literal("", plaintext); - let plaintext_bytes = literal.to_bytes().map_err(|e| format!("to_bytes: {e}"))?; - - // Generate a fresh 32-byte AES-256 session key. - let session_key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng); - - // Symmetrically encrypt with the session key (SEIPDv1, no PKESK/SKESK). - let seipd = SymEncryptedProtectedData::encrypt_seipdv1( - &mut rng, - SymmetricKeyAlgorithm::AES256, - &session_key, - &plaintext_bytes, - ) - .map_err(|e| format!("encrypt_seipdv1: {e}"))?; - - // Wrap in Edata so to_writer calls write_packet, which prepends the - // OpenPGP packet tag (tag 18) + length before the version byte + ciphertext. - // Calling seipd.to_writer() directly only serialises the packet body - // (version byte 0x01 + data) without any packet framing. - let mut seipd_bytes = Vec::new(); - Edata::SymEncryptedProtectedData(seipd) - .to_writer(&mut seipd_bytes) - .map_err(|e| format!("serialize seipd: {e}"))?; - - Ok((seipd_bytes, session_key.to_vec())) -} - -/// Sign `plaintext` with `signing_key`, encrypt the signed payload with a fresh -/// AES-256 session key, and return `(seipd_bytes, session_key_bytes)`. -/// -/// Used for ProtonMail ClearScheme (Type 4) external sends where ProtonMail -/// requires a signature inside the encrypted body (`Signature=1`): -/// - `seipd_bytes` → base64 → `Body` in the send package -/// - `session_key_bytes` → base64 → `BodyKey.Key` (cleartext session key) -pub fn encrypt_sign_body_for_external_send( - plaintext: &str, - signing_key: &PrivateKey, -) -> Result<(Vec, Vec), String> { - let mut rng = rand::thread_rng(); - - // Sign the literal message with the sender's address key. - let literal = Message::new_literal("", plaintext); - let passphrase = signing_key.passphrase.clone(); - let signed = literal - .sign(&mut rng, &signing_key.inner, || passphrase, HashAlgorithm::SHA2_256) - .map_err(|e| format!("sign external body: {e}"))?; - - // Serialize the signed message (OnePassSignature + LiteralData + Signature). - let signed_bytes = signed - .to_bytes() - .map_err(|e| format!("signed.to_bytes: {e}"))?; - - // Generate a fresh 32-byte AES-256 session key. - let session_key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng); - - // Symmetrically encrypt the signed payload (SEIPDv1, no PKESK/SKESK). - let seipd = SymEncryptedProtectedData::encrypt_seipdv1( - &mut rng, - SymmetricKeyAlgorithm::AES256, - &session_key, - &signed_bytes, - ) - .map_err(|e| format!("encrypt_seipdv1: {e}"))?; - - let mut seipd_bytes = Vec::new(); - Edata::SymEncryptedProtectedData(seipd) - .to_writer(&mut seipd_bytes) - .map_err(|e| format!("serialize seipd: {e}"))?; - - info!( - " [send] signed+encrypted SEIPD: {} bytes, session_key {} bytes", - seipd_bytes.len(), - session_key.len() - ); - Ok((seipd_bytes, session_key.to_vec())) -} - /// Build a PGP/MIME (`multipart/signed`) body encrypted in a SEIPD. /// /// Returns `(seipd_bytes, session_key_bytes)`. diff --git a/proton-bridge/src/imap_server.rs b/proton-bridge/src/imap_server.rs index 8795488..6b95465 100644 --- a/proton-bridge/src/imap_server.rs +++ b/proton-bridge/src/imap_server.rs @@ -13,12 +13,6 @@ use crate::{crypto, SharedState}; // ── Public entry point ──────────────────────────────────────────────────────── -pub async fn run(state: SharedState, port: u16) -> Result<(), Box> { - let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?; - info!("IMAP listening on port {port}"); - run_with_listener(state, listener).await -} - /// Accept connections on an already-bound listener. Used by the embedded bridge /// so ports are guaranteed to be occupied before `start()` returns. pub async fn run_with_listener( @@ -149,10 +143,7 @@ async fn cmd_select(tag: &str, state: &SharedState) -> String { * 0 RECENT\r\n\ * FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n\ * OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n\ - * OK [UIDVALIDITY 1] UIDs valid\r\n\ - * OK [UIDNEXT {next}] Predicted next UID\r\n\ {tag} OK [READ-WRITE] SELECT completed\r\n", - next = count + 1, ) } @@ -300,10 +291,12 @@ async fn cmd_search(tag: &str, criteria: &str, state: &SharedState) -> String { .store .search_subject_or_from(query); let nums: Vec = seqs.iter().map(|n| n.to_string()).collect(); - format!( - "* SEARCH {}\r\n{tag} OK SEARCH completed\r\n", - nums.join(" ") - ) + let search_line = if nums.is_empty() { + "* SEARCH\r\n".to_string() + } else { + format!("* SEARCH {}\r\n", nums.join(" ")) + }; + format!("{search_line}{tag} OK SEARCH completed\r\n") } // ── STORE ───────────────────────────────────────────────────────────────────── diff --git a/proton-bridge/src/smtp_server.rs b/proton-bridge/src/smtp_server.rs index 2077a7e..263a0fd 100644 --- a/proton-bridge/src/smtp_server.rs +++ b/proton-bridge/src/smtp_server.rs @@ -15,12 +15,6 @@ use crate::{crypto, SharedState}; // ── Public entry point ──────────────────────────────────────────────────────── -pub async fn run(state: SharedState, port: u16) -> Result<(), Box> { - let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?; - info!("SMTP listening on port {port}"); - run_with_listener(state, listener).await -} - /// Accept connections on an already-bound listener. Used by the embedded bridge /// so ports are guaranteed to be occupied before `start()` returns. pub async fn run_with_listener( diff --git a/src/config.rs b/src/config.rs index 0155550..cae6e21 100644 --- a/src/config.rs +++ b/src/config.rs @@ -101,15 +101,13 @@ impl Config { use crate::credentials; match self.provider { Provider::Imap => { - if let Some(ref mut imap) = self.imap { - if imap.password.is_none() { - imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?); - } + if let Some(ref mut imap) = self.imap + && imap.password.is_none() + { + imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?); } - if let Some(ref mut smtp) = self.smtp { - if smtp.password.is_none() { - smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?); - } + if let Some(ref mut smtp) = self.smtp && smtp.password.is_none(){ + smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?); } } Provider::Proton => { @@ -122,11 +120,11 @@ impl Config { credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok(); } } - if let Some(ref mut bridge) = self.bridge { - if bridge.local_password.is_none() { - bridge.local_password = - Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?); - } + if let Some(ref mut bridge) = self.bridge + && bridge.local_password.is_none() + { + bridge.local_password = + Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?); } } } diff --git a/src/inbox.rs b/src/inbox.rs index a30654e..8d0f745 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -11,10 +11,10 @@ pub(crate) struct Inbox { /// Refresh inbox (full reload). Reconnects on error. pub(crate) fn refresh(session: &mut Option, config: &Config) -> Result { - if let Some(s) = session.as_mut() { - if s.noop().is_ok() { - return fetch_latest(s); - } + if let Some(s) = session.as_mut() + && s.noop().is_ok() + { + return fetch_latest(s); } *session = None; @@ -75,13 +75,19 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result { let messages = s - .fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]") + .fetch( + range, + "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]", + ) .map_err(|e| e.to_string())?; Ok(parse_emails(&messages)) } ImapSession::Tls(s) => { let messages = s - .fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]") + .fetch( + range, + "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]", + ) .map_err(|e| e.to_string())?; Ok(parse_emails(&messages)) } diff --git a/src/lib.rs b/src/lib.rs index b277c49..bb56a78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -234,7 +234,7 @@ pub fn main( emails = merged; oldest_seq = emails.last().map(|e| e.seq); - has_older = oldest_seq.map_or(false, |s| s > 1); + has_older = oldest_seq.is_some_and( |s| s > 1); error = None; loading = false; if !emails.is_empty() {