diff --git a/proton-bridge/src/api.rs b/proton-bridge/src/api.rs index f7c0a3f..95145a1 100644 --- a/proton-bridge/src/api.rs +++ b/proton-bridge/src/api.rs @@ -109,6 +109,117 @@ struct DeleteRequest<'a> { ids: &'a [String], } +// ── Create draft ────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct CreateDraftRequest { + #[serde(rename = "Message")] + message: DraftMessage, + /// Always empty for new messages (no attachments). + /// Must be present in the request to match go-proton-api behaviour. + #[serde(rename = "AttachmentKeyPackets")] + attachment_key_packets: Vec, + /// Draft action: 0 = new message (not a reply/forward). + /// go-proton-api always serialises this field (no omitempty). + #[serde(rename = "Action")] + action: u32, +} + +#[derive(Serialize)] +struct DraftMessage { + #[serde(rename = "AddressID")] + address_id: String, + #[serde(rename = "Subject")] + subject: String, + #[serde(rename = "Sender")] + sender: EmailAddressOwned, + #[serde(rename = "ToList")] + to_list: Vec, + #[serde(rename = "MIMEType")] + mime_type: String, + #[serde(rename = "Body")] + body: String, +} + +#[derive(Serialize)] +struct EmailAddressOwned { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "Address")] + address: String, +} + +#[derive(Deserialize)] +struct CreateDraftPayload { + #[serde(rename = "Message")] + message: CreatedMessage, +} + +#[derive(Deserialize)] +struct CreatedMessage { + #[serde(rename = "ID")] + id: String, +} + +// ── Send draft ──────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct SendDraftRequest { + #[serde(rename = "Packages")] + packages: Vec, +} + +#[derive(Serialize)] +struct SendPackage { + #[serde(rename = "Addresses")] + addresses: std::collections::HashMap, + #[serde(rename = "MIMEType")] + mime_type: String, + /// Encryption scheme for this package. + /// 1 = InternalScheme (ProtonMail), 4 = ClearScheme (external cleartext). + #[serde(rename = "Type")] + send_type: u32, + /// Base64-encoded SEIPD (Type 1) or base64 plaintext (Type 4). + #[serde(rename = "Body")] + body: String, + /// Session key for cleartext (Type 4) packages. Absent for Type 1. + #[serde(rename = "BodyKey")] + #[serde(skip_serializing_if = "Option::is_none")] + body_key: Option, + /// Per-attachment session keys (encrypted). Omitted when empty (matches + /// go-proton-api `omitempty` behaviour — ProtonMail 500s on `{}`). + #[serde(rename = "AttachmentKeys")] + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] + attachment_keys: std::collections::HashMap, +} + +/// Cleartext session key for Type 4 (external) packages. +#[derive(Serialize)] +struct MessageSessionKey { + #[serde(rename = "Key")] + key: String, + #[serde(rename = "Algorithm")] + algorithm: String, +} + +#[derive(Serialize)] +struct AddressPackage { + /// 1 = ProtonMail internal (encrypted), 4 = external (cleartext). + #[serde(rename = "Type")] + send_type: u32, + /// Base64-encoded PKESK — only present for Type 1. + #[serde(rename = "BodyKeyPacket")] + #[serde(skip_serializing_if = "Option::is_none")] + body_key_packet: Option, + #[serde(rename = "Signature")] + signature: u32, + /// Per-attachment key packets for this recipient. Omitted when empty + /// (matches go-proton-api `omitempty` — ProtonMail 500s on `{}`). + #[serde(rename = "AttachmentKeyPackets")] + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] + attachment_key_packets: std::collections::HashMap, +} + // ── Public keys ─────────────────────────────────────────────────────────────── /// One armored public key for a recipient. @@ -393,6 +504,152 @@ impl<'a> ApiClient<'a> { Ok(env.data.key_salts) } + /// Create a draft message and return its ProtonMail message ID. + /// + /// `address_id` is the ProtonMail address ID of the sender (from `/core/v4/addresses`). + /// `body` should be a PGP-armored ciphertext encrypted to the sender's own address key. + pub async fn create_draft( + &self, + address_id: &str, + subject: &str, + from_addr: &str, + from_name: &str, + to_addr: &str, + to_name: &str, + body: &str, + ) -> Result { + let [h0, h1] = self.auth_headers(); + let req = CreateDraftRequest { + message: DraftMessage { + address_id: address_id.to_string(), + subject: subject.to_string(), + sender: EmailAddressOwned { + name: from_name.to_string(), + address: from_addr.to_string(), + }, + to_list: vec![EmailAddressOwned { + name: to_name.to_string(), + address: to_addr.to_string(), + }], + mime_type: "text/plain".to_string(), + body: body.to_string(), + }, + attachment_key_packets: vec![], + action: 0, + }; + // Log the full request JSON (body is long, show first 300 chars for structure check). + let req_json = serde_json::to_string(&req).unwrap_or_else(|e| e.to_string()); + eprintln!( + "SMTP create_draft POST /mail/v4/messages AddressID={address_id} to={to_addr}\n\ + [req-json first 300]: {}", + &req_json[..req_json.len().min(300)] + ); + let resp = self + .client + .post(format!("{}/mail/v4/messages", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .json(&req) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("create_draft {status}: {body}")); + } + let env: Envelope = + resp.json().await.map_err(|e| e.to_string())?; + if env.code != 1000 { + return Err(format!("create_draft: API code {}", env.code)); + } + Ok(env.data.message.id) + } + + /// Send a previously-created draft to one recipient. + /// + /// `send_type`: 1 = ProtonMail internal (encrypted), 4 = external (cleartext). + /// For Type 1: `body_b64` = base64(SEIPD), `key_packet_b64` = base64(PKESK). + /// For Type 4: `body_b64` = base64(SEIPD), `key_packet_b64` = base64(AES-256 session key). + pub async fn send_draft( + &self, + draft_id: &str, + to_addr: &str, + send_type: u32, + body_b64: &str, + // For Type 1: base64 PKESK; for Type 4: base64 raw AES session key. + key_b64: &str, + // Whether the encrypted body contains a PGP-signed message. + // ProtonMail requires Signature=1 for Type 4 external sends. + body_signed: bool, + ) -> Result<(), String> { + let [h0, h1] = self.auth_headers(); + let mut addrs = std::collections::HashMap::new(); + addrs.insert( + to_addr.to_string(), + AddressPackage { + send_type, + body_key_packet: if send_type == 1 { + Some(key_b64.to_string()) + } else { + None + }, + signature: u32::from(body_signed), + attachment_key_packets: std::collections::HashMap::new(), + }, + ); + // For external (Type 4 / Type 32) packages ProtonMail requires a BodyKey with + // the raw session key + algorithm name so it can store an encrypted copy for + // the sender's Sent folder. + let body_key = if (send_type == 4 || send_type == 32) && !key_b64.is_empty() { + Some(MessageSessionKey { + key: key_b64.to_string(), + algorithm: "aes256".to_string(), + }) + } else { + None + }; + // Type 32 (PGP/MIME) packages carry a multipart body; all others are text/plain. + let mime_type = if send_type == 32 { + "multipart/mixed".to_string() + } else { + "text/plain".to_string() + }; + let req = SendDraftRequest { + packages: vec![SendPackage { + addresses: addrs, + mime_type, + send_type, + body: body_b64.to_string(), + body_key, + attachment_keys: std::collections::HashMap::new(), + }], + }; + // Note: send uses POST to the same path as the draft — no "/send" suffix. + // (go-proton-api: SendDraft does POST /{id}, UpdateDraft does PUT /{id}) + eprintln!( + "SMTP send_draft POST /mail/v4/messages/{draft_id}\n body: {}", + serde_json::to_string(&req).unwrap_or_else(|e| e.to_string()) + ); + let resp = self + .client + .post(format!("{}/mail/v4/messages/{}", API_BASE, draft_id)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .json(&req) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("send_draft {status}: {body}")); + } + Ok(()) + } + /// Fetch the user's email addresses and their associated private keys. /// Messages are encrypted to address keys, not user keys. pub async fn get_addresses(&self) -> Result, String> { diff --git a/proton-bridge/src/crypto.rs b/proton-bridge/src/crypto.rs index 3849736..dedd64b 100644 --- a/proton-bridge/src/crypto.rs +++ b/proton-bridge/src/crypto.rs @@ -15,8 +15,16 @@ use std::io::Cursor; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use pgp::types::PublicKeyTrait; -use pgp::{Deserializable, Message, SignedSecretKey}; +use chrono::SubsecRound; +use pgp::composed::message::Edata; +use pgp::composed::{SignedPublicSubKey, StandaloneSignature}; +use pgp::crypto::hash::HashAlgorithm; +use pgp::crypto::public_key::PublicKeyAlgorithm; +use pgp::crypto::sym::SymmetricKeyAlgorithm; +use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData, SymEncryptedProtectedData}; +use pgp::ser::Serialize; +use pgp::types::{KeyVersion, PublicKeyTrait}; +use pgp::{Deserializable, Message, SignedPublicKey, SignedSecretKey}; use crate::srp::bcrypt_base64_encode; @@ -136,6 +144,11 @@ pub fn derive_key_passphrase_pwhash(password: &str, key_salt_b64: &str) -> Resul // ── Private key management ──────────────────────────────────────────────────── /// A parsed, ready-to-use private key. +/// +/// `SignedSecretKey` is `Clone`, so `PrivateKey` derives it too. We clone the +/// signing key out of `BridgeState` (under the Mutex lock) so that async SMTP +/// send code can use it without re-acquiring the lock. +#[derive(Clone)] pub struct PrivateKey { inner: SignedSecretKey, passphrase: String, @@ -171,6 +184,19 @@ impl PrivateKey { self.passphrase.chars().rev().take(8).collect::() .chars().rev().collect() } + + /// Return the public key corresponding to this private key, armored. + /// + /// Used to self-encrypt draft bodies before uploading. Extracting from + /// the private key guarantees the ECDH subkey ID matches what ProtonMail + /// has stored for the address — unlike `get_public_keys()` which can + /// return a different key. + pub fn public_key_armored(&self) -> Result { + // From for SignedPublicKey strips all private material. + SignedPublicKey::from(self.inner.clone()) + .to_armored_string(Default::default()) + .map_err(|e| format!("armor own public key: {e}")) + } } /// Print the key IDs of every key in the pool for diagnostics. @@ -181,6 +207,29 @@ 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 { + eprintln!(" [key-ids] {label}: failed to parse public key"); + return; + }; + eprintln!( + " [key-ids] {label} primary: id={:?} algo={:?}", + pk.key_id(), + pk.algorithm() + ); + for (i, sk) in pk.public_subkeys.iter().enumerate() { + eprintln!( + " [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 { @@ -378,6 +427,304 @@ pub fn diagnose_subkey_passphrase(key: &PrivateKey, candidates: &[(&str, &str)]) } } +// ── Message encryption ──────────────────────────────────────────────────────── + +/// Return the encryption-capable subkey of a `SignedPublicKey`. +/// +/// Modern ProtonMail keys have an EdDSA primary key (signing only) and an +/// ECDH subkey (encryption). `SignedPublicKey::encrypt` always delegates to +/// the primary key, so we must select the ECDH subkey explicitly. +fn enc_subkey(pub_key: &SignedPublicKey) -> Result<&SignedPublicSubKey, String> { + pub_key + .public_subkeys + .iter() + .find(|sk| !matches!(sk.algorithm(), PublicKeyAlgorithm::EdDSALegacy | PublicKeyAlgorithm::DSA)) + .ok_or_else(|| "no encryption subkey found in public key".to_string()) +} + +/// Encrypt `plaintext` to an armored public key; return an armored PGP message. +/// Used to self-encrypt the draft body before creating a ProtonMail draft. +pub fn encrypt_body_armored(plaintext: &str, pub_key_armored: &str) -> Result { + let (pub_key, _) = + SignedPublicKey::from_armor_single(Cursor::new(pub_key_armored.as_bytes())) + .map_err(|e| format!("parse pub key: {e}"))?; + let subkey = enc_subkey(&pub_key)?; + let msg = Message::new_literal("", plaintext); + let mut rng = rand::thread_rng(); + let encrypted = msg + .encrypt_to_keys_seipdv1(&mut rng, SymmetricKeyAlgorithm::AES256, &[subkey]) + .map_err(|e| format!("encrypt: {e}"))?; + encrypted + .to_armored_string(Default::default()) + .map_err(|e| format!("armor: {e}")) +} + +/// Sign `plaintext` with `signing_key`, then encrypt to `pub_key_armored`. +/// +/// ProtonMail requires draft bodies to be signed (Code 2001 "missing message +/// signature" is returned when sending an unsigned draft). The signature is +/// embedded inside the encrypted payload (sign-then-encrypt order). +pub fn encrypt_sign_body_armored( + plaintext: &str, + pub_key_armored: &str, + signing_key: &PrivateKey, +) -> Result { + let (pub_key, _) = + SignedPublicKey::from_armor_single(Cursor::new(pub_key_armored.as_bytes())) + .map_err(|e| format!("parse pub key: {e}"))?; + let subkey = enc_subkey(&pub_key)?; + let msg = Message::new_literal("", plaintext); + let mut rng = rand::thread_rng(); + let passphrase = signing_key.passphrase.clone(); + let signed = msg + .sign(&mut rng, &signing_key.inner, || passphrase, HashAlgorithm::SHA2_256) + .map_err(|e| format!("sign: {e}"))?; + let encrypted = signed + .encrypt_to_keys_seipdv1(&mut rng, SymmetricKeyAlgorithm::AES256, &[subkey]) + .map_err(|e| format!("encrypt: {e}"))?; + encrypted + .to_armored_string(Default::default()) + .map_err(|e| format!("armor: {e}")) +} + +/// Encrypt `plaintext` to an armored public key; return `(key_packet, encrypted_body)`. +/// +/// `key_packet` is the PKESK packet (session key encrypted to recipient's key). +/// `encrypted_body` is the SEIPD packet (symmetrically encrypted plaintext). +/// Both are raw OpenPGP packets — base64-encode them for the ProtonMail send API. +pub fn encrypt_body_for_send( + plaintext: &str, + pub_key_armored: &str, +) -> Result<(Vec, Vec), String> { + use pgp::ser::Serialize; + + let (pub_key, _) = + SignedPublicKey::from_armor_single(Cursor::new(pub_key_armored.as_bytes())) + .map_err(|e| format!("parse pub key: {e}"))?; + let subkey = enc_subkey(&pub_key)?; + let msg = Message::new_literal("", plaintext); + let mut rng = rand::thread_rng(); + let encrypted = msg + .encrypt_to_keys_seipdv1(&mut rng, SymmetricKeyAlgorithm::AES256, &[subkey]) + .map_err(|e| format!("encrypt: {e}"))?; + + if let Message::Encrypted { esk, edata } = encrypted { + // Serialize the first PKESK (session key encrypted to recipient). + let mut key_packet = Vec::new(); + for item in &esk { + item.to_writer(&mut key_packet).map_err(|e| e.to_string())?; + break; // only one recipient + } + // Serialize the SEIPD (symmetrically encrypted body). + let mut body = Vec::new(); + edata.to_writer(&mut body).map_err(|e| e.to_string())?; + Ok((key_packet, body)) + } else { + Err("encrypt_to_keys_seipdv1 did not return Encrypted variant".to_string()) + } +} + +/// 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}"))?; + + eprintln!( + " [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)`. +/// +/// Used for ProtonMail PGPMIMEScheme (Type 32) external sends: +/// - `seipd_bytes` → base64 → `Body` in the send package +/// - `session_key_bytes` → base64 → `BodyKey.Key` (cleartext session key) +/// +/// The recipient receives a proper PGP/MIME email: the signature lives in a +/// separate `application/pgp-signature` MIME part so the message body is clean. +pub fn build_pgp_mime_for_external_send( + plaintext: &str, + signing_key: &PrivateKey, +) -> Result<(Vec, Vec), String> { + let mut rng = rand::thread_rng(); + + // Generate a random MIME boundary. + let b: [u8; 16] = rand::random(); + let boundary: String = b.iter().map(|x| format!("{x:02x}")).collect(); + + // The signed body part (text/plain with its MIME headers). + // This is the exact byte sequence that the detached signature covers. + let body_part = format!( + "Content-Type: text/plain; charset=utf-8\r\n\ + Content-Transfer-Encoding: quoted-printable\r\n\ + \r\n\ + {plaintext}" + ); + + // Build a detached signature over the body part bytes. + let key = &signing_key.inner; + let passphrase = signing_key.passphrase.clone(); + + let hashed = vec![ + Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint())), + Subpacket::regular(SubpacketData::SignatureCreationTime( + chrono::Utc::now().trunc_subsecs(0), + )), + ]; + let unhashed = vec![Subpacket::regular(SubpacketData::Issuer(key.key_id()))]; + + let mut config = match key.version() { + KeyVersion::V4 => { + SignatureConfig::v4(SignatureType::Binary, key.algorithm(), HashAlgorithm::SHA2_256) + } + v => return Err(format!("unsupported key version {v:?}")), + }; + config.hashed_subpackets = hashed; + config.unhashed_subpackets = unhashed; + + let sig = config + .sign(key, || passphrase, Cursor::new(body_part.as_bytes())) + .map_err(|e| format!("detached sign: {e}"))?; + + let armored_sig = StandaloneSignature::new(sig) + .to_armored_string(Default::default()) + .map_err(|e| format!("armor signature: {e}"))?; + + // Assemble the full multipart/signed MIME entity (headers + body). + // This entire entity is encrypted in the SEIPD so ProtonMail can relay it. + let mime_entity = format!( + "Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; \ + micalg=pgp-sha256; boundary=\"{boundary}\"\r\n\ + \r\n\ + --{boundary}\r\n\ + {body_part}\r\n\ + --{boundary}\r\n\ + Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n\ + Content-Description: OpenPGP digital signature\r\n\ + Content-Disposition: attachment; filename=\"signature.asc\"\r\n\ + \r\n\ + {armored_sig}\r\n\ + --{boundary}--\r\n" + ); + + // ProtonMail requires Signature=1 for all external sends and verifies the + // OpenPGP signature on the package body. Sign the MIME entity as an OpenPGP + // message (OnePassSig + LiteralData + Signature) before encrypting — exactly + // like the Type 4 path, but the literal-data payload is the MIME entity instead + // of raw plaintext. ProtonMail verifies the OpenPGP signature, then relays the + // literal-data content (the multipart/signed MIME body) to the external recipient. + let passphrase2 = signing_key.passphrase.clone(); + let literal = Message::new_literal_bytes("", mime_entity.as_bytes()); + let signed = literal + .sign(&mut rng, key, || passphrase2, HashAlgorithm::SHA2_256) + .map_err(|e| format!("sign MIME entity: {e}"))?; + let literal_bytes = signed.to_bytes().map_err(|e| format!("to_bytes: {e}"))?; + + let session_key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng); + let seipd = SymEncryptedProtectedData::encrypt_seipdv1( + &mut rng, + SymmetricKeyAlgorithm::AES256, + &session_key, + &literal_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}"))?; + + eprintln!( + " [send] PGP/MIME SEIPD: {} bytes, session_key {} bytes, boundary={boundary}", + seipd_bytes.len(), + session_key.len() + ); + Ok((seipd_bytes, session_key.to_vec())) +} + /// Decrypt a ProtonMail message body. /// /// `encrypted_body` is the PGP-armored ciphertext from `Message.body`. @@ -389,6 +736,23 @@ pub fn decrypt_body(encrypted_body: &str, keys: &[&PrivateKey]) -> Result, + /// email → armored public key for draft self-encryption. + /// Uses the first active key per address (matches addrKR.FirstKey() in go-proton-api). + pub own_public_keys: HashMap, + /// email → index in `key_pool` of the signing key for that address. + /// The signing key is the same first-active address key used for `own_public_keys`. + pub sender_key_indices: HashMap, } pub type SharedState = Arc>; @@ -59,15 +69,18 @@ async fn main() { let api = ApiClient::new(&client, &session); - // Unlock user + address keys. - let key_pool = match unlock_key_pool(&api, &config).await { - Ok(k) => k, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; + // Unlock user + address keys, collect address ID/email pairs, and extract + // own public keys directly from address private keys for draft self-encryption. + let (key_pool, sender_addresses, own_public_keys, sender_key_indices) = + match unlock_key_pool(&api, &config).await { + Ok(k) => k, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; println!("{} key(s) in pool", key_pool.len()); + crypto::dump_key_pool(&key_pool); // Load the initial inbox into the message store. let (messages, total) = match api.list_messages(LABEL_INBOX, 0, 50).await { @@ -87,6 +100,9 @@ async fn main() { http_client: client, session, config: config.clone(), + sender_addresses, + own_public_keys, + sender_key_indices, })); // Spawn IMAP server. @@ -98,6 +114,15 @@ async fn main() { } }); + // Spawn SMTP server. + let smtp_state = Arc::clone(&state); + let smtp_port = config.bridge.smtp_port; + tokio::spawn(async move { + if let Err(e) = smtp_server::run(smtp_state, smtp_port).await { + eprintln!("SMTP server error: {e}"); + } + }); + println!( "Bridge ready — IMAP :{}, SMTP :{} (Ctrl-C to stop)", config.bridge.imap_port, config.bridge.smtp_port @@ -114,7 +139,7 @@ async fn main() { async fn unlock_key_pool( api: &ApiClient<'_>, config: &config::Config, -) -> Result, String> { +) -> Result<(Vec, Vec<(String, String)>, HashMap, HashMap), String> { // Fetch user keys, address keys, and bcrypt salts in parallel. let (user_keys_res, addresses_res, key_salts_res) = tokio::join!( api.get_user_keys(), @@ -179,8 +204,13 @@ async fn unlock_key_pool( } // Phase 2: unlock address keys via token (newer accounts) or shared passphrase. + // Extract the FIRST active key per address as the own public key for draft + // self-encryption — this matches addrKR.FirstKey() used by go-proton-api. let mut key_pool: Vec = Vec::new(); + let mut own_public_keys: HashMap = HashMap::new(); + let mut sender_key_indices: HashMap = HashMap::new(); for addr in &addresses { + let mut first_active_key_done = false; for ak in &addr.keys { if ak.active != 1 { continue; @@ -200,10 +230,27 @@ async fn unlock_key_pool( match crypto::PrivateKey::unlock(&ak.private_key, &pp) { Ok(k) => { println!( - "address key {} ({}) unlocked", + "address key {} ({}) unlocked primary={}", &ak.id[..8.min(ak.id.len())], - addr.email + addr.email, + ak.primary ); + // Use the first active key per address (matches addrKR.FirstKey()). + if !first_active_key_done { + match k.public_key_armored() { + Ok(pk) => { + eprintln!( + " [own-key] {} → first-active key primary={}", + addr.email, ak.primary + ); + own_public_keys.insert(addr.email.clone(), pk); + // Record which key_pool index to use for signing. + sender_key_indices.insert(addr.email.clone(), key_pool.len()); + } + Err(e) => eprintln!("extract pub key for {}: {e}", addr.email), + } + first_active_key_done = true; + } key_pool.push(k); } Err(e) => eprintln!( @@ -217,5 +264,13 @@ async fn unlock_key_pool( // Include user keys as fallback. key_pool.extend(user_private_keys); - Ok(key_pool) + + // Collect (address_id, email) pairs for use when creating drafts. + let sender_addresses: Vec<(String, String)> = addresses + .iter() + .map(|a| (a.id.clone(), a.email.clone())) + .collect(); + + println!("{} own public key(s) from address keys", own_public_keys.len()); + Ok((key_pool, sender_addresses, own_public_keys, sender_key_indices)) } diff --git a/proton-bridge/src/smtp_server.rs b/proton-bridge/src/smtp_server.rs new file mode 100644 index 0000000..c05d76e --- /dev/null +++ b/proton-bridge/src/smtp_server.rs @@ -0,0 +1,351 @@ +/// Step 7: Local SMTP server. +/// +/// Implements a minimal ESMTP listener (AUTH LOGIN/PLAIN, MAIL FROM, RCPT TO, +/// DATA, QUIT) on plain TCP. On DATA completion it encrypts and sends via +/// the ProtonMail v4 API (create draft → send). +use std::sync::Arc; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::api::ApiClient; +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?; + println!("SMTP listening on port {port}"); + loop { + let (socket, addr) = listener.accept().await?; + eprintln!("SMTP: connection from {addr}"); + let state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket, state).await { + eprintln!("SMTP connection error: {e}"); + } + }); + } +} + +// ── Per-connection handler ──────────────────────────────────────────────────── + +async fn handle_connection( + socket: TcpStream, + state: SharedState, +) -> Result<(), Box> { + let (reader, mut writer) = socket.into_split(); + let mut lines = BufReader::new(reader).lines(); + + let mut authenticated = false; + let mut mail_from = String::new(); + let mut rcpt_to: Vec = Vec::new(); + let mut in_data = false; + let mut data_lines: Vec = Vec::new(); + // auth_step: 0 = not in AUTH, 1 = sent "Username:", 2 = sent "Password:" + let mut auth_step: u8 = 0; + + writer.write_all(b"220 ProtonBridge ESMTP ready\r\n").await?; + + while let Some(raw) = lines.next_line().await? { + let line = raw.trim_end().to_string(); + if line.is_empty() && !in_data { + continue; + } + eprintln!("SMTP < {line}"); + + // DATA body accumulation (dot-stuffing per RFC 5321 §4.5.2). + if in_data { + if line == "." { + in_data = false; + let resp = + handle_send(&mail_from, &rcpt_to, &data_lines, &state).await; + eprintln!("SMTP > {}", resp.trim_end()); + writer.write_all(resp.as_bytes()).await?; + mail_from.clear(); + rcpt_to.clear(); + data_lines.clear(); + } else { + // Strip leading dot (dot-stuffing). + let stored = if line.starts_with("..") { + line[1..].to_string() + } else { + line + }; + data_lines.push(stored); + } + continue; + } + + // AUTH LOGIN multi-step: waiting for username. + if auth_step == 1 { + // Username line (base64) — we accept any username. + auth_step = 2; + let resp = format!("334 {}\r\n", B64.encode("Password:")); + eprintln!("SMTP > 334 (Password:)"); + writer.write_all(resp.as_bytes()).await?; + continue; + } + + // AUTH LOGIN multi-step: waiting for password. + if auth_step == 2 { + let pass = B64 + .decode(line.trim()) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + .unwrap_or_default(); + let local_pw = state.lock().await.config.bridge.local_password.clone(); + auth_step = 0; + let resp = if pass == local_pw { + authenticated = true; + "235 2.7.0 Authentication successful\r\n".to_string() + } else { + "535 5.7.8 Authentication credentials invalid\r\n".to_string() + }; + eprintln!("SMTP > {}", resp.trim_end()); + writer.write_all(resp.as_bytes()).await?; + continue; + } + + let upper = line.to_ascii_uppercase(); + + let resp: String = if upper.starts_with("EHLO") || upper.starts_with("HELO") { + "250-ProtonBridge\r\n250-AUTH LOGIN PLAIN\r\n250 OK\r\n".to_string() + } else if upper.starts_with("AUTH LOGIN") { + auth_step = 1; + format!("334 {}\r\n", B64.encode("Username:")) + } else if upper.starts_with("AUTH PLAIN") { + // Credentials may be inline ("AUTH PLAIN ") or on the next line. + let creds = if line.len() > "AUTH PLAIN ".len() { + line["AUTH PLAIN ".len()..].trim().to_string() + } else { + // Send "334 " challenge and wait. + writer.write_all(b"334 \r\n").await?; + match lines.next_line().await? { + Some(l) => l.trim_end().to_string(), + None => break, + } + }; + // PLAIN format: \0authzid\0authcid\0passwd (RFC 4616). + let decoded = B64.decode(creds.trim()).unwrap_or_default(); + let parts: Vec<&[u8]> = decoded.splitn(3, |&b| b == 0).collect(); + let pass = parts + .get(2) + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or(""); + let local_pw = state.lock().await.config.bridge.local_password.clone(); + if pass == local_pw { + authenticated = true; + "235 2.7.0 Authentication successful\r\n".to_string() + } else { + "535 5.7.8 Authentication credentials invalid\r\n".to_string() + } + } else if upper.starts_with("MAIL FROM:") { + if !authenticated { + "530 5.7.0 Authentication required\r\n".to_string() + } else { + mail_from = extract_angle(&line).to_string(); + "250 OK\r\n".to_string() + } + } else if upper.starts_with("RCPT TO:") { + if !authenticated { + "530 5.7.0 Authentication required\r\n".to_string() + } else { + rcpt_to.push(extract_angle(&line).to_string()); + "250 OK\r\n".to_string() + } + } else if upper == "DATA" { + if rcpt_to.is_empty() { + "503 5.5.1 RCPT TO required before DATA\r\n".to_string() + } else { + in_data = true; + "354 Start mail input; end with .\r\n".to_string() + } + } else if upper == "RSET" { + mail_from.clear(); + rcpt_to.clear(); + data_lines.clear(); + "250 OK\r\n".to_string() + } else if upper == "NOOP" { + "250 OK\r\n".to_string() + } else if upper.starts_with("QUIT") { + writer.write_all(b"221 Bye\r\n").await?; + break; + } else { + "502 5.5.1 Command not implemented\r\n".to_string() + }; + + eprintln!("SMTP > {}", resp.trim_end()); + writer.write_all(resp.as_bytes()).await?; + } + Ok(()) +} + +// ── DATA handler ────────────────────────────────────────────────────────────── + +async fn handle_send( + from: &str, + to: &[String], + data_lines: &[String], + state: &SharedState, +) -> String { + // Extract Subject from DATA headers (lines before the first blank line). + let blank = data_lines.iter().position(|l| l.is_empty()).unwrap_or(0); + let subject = data_lines[..blank] + .iter() + .find(|l| l.to_ascii_lowercase().starts_with("subject:")) + .map(|l| l["Subject:".len()..].trim().to_string()) + .unwrap_or_default(); + let plaintext = data_lines[blank + 1..].join("\n"); + + for recipient in to { + if let Err(e) = + send_to_one(from, recipient, &subject, &plaintext, state).await + { + // Log the full error in the bridge console; return a short fixed + // message to skim so the status line doesn't overflow. + eprintln!("SMTP send to {recipient} FAILED:\n {e}"); + return "554 5.3.0 Delivery failed — see bridge log\r\n".to_string(); + } + } + "250 2.0.0 OK: Message queued\r\n".to_string() +} + +async fn send_to_one( + from: &str, + to: &str, + subject: &str, + plaintext: &str, + state: &SharedState, +) -> Result<(), String> { + // Hold the lock for the entire crypto section (sign + encrypt is CPU-only, no .await). + // Draft body must be signed+encrypted: ProtonMail verifies the draft body signature + // during send_draft processing. + let (http_client, session, address_id, draft_body, signing_key_opt) = { + let st = state.lock().await; + // Look up the ProtonMail AddressID for the sender email. + let (aid, canonical_email) = st + .sender_addresses + .iter() + .find(|(_, email)| email.eq_ignore_ascii_case(from)) + .map(|(id, email)| (id.clone(), email.clone())) + .unwrap_or_else(|| { + st.sender_addresses + .first() + .map(|(id, email)| (id.clone(), email.clone())) + .unwrap_or_default() + }); + // Look up the own public key for this specific sender address. + let opk = st + .own_public_keys + .get(&canonical_email) + .cloned() + .or_else(|| st.own_public_keys.values().next().cloned()) + .unwrap_or_default(); + eprintln!(" [send] own_pub_key for {canonical_email}: {} chars", opk.len()); + + if opk.is_empty() { + return Err("No cached sender public key — cannot encrypt draft body".to_string()); + } + crypto::log_public_key_ids(&opk, "own-priv-derived"); + + // Find + clone the signing key for this address (first-active address key, same as opk). + // PrivateKey is Clone (SignedSecretKey derives Clone), so we clone once here and + // use the clone again later for the Type 4 send package signature. + let key_idx = st + .sender_key_indices + .get(&canonical_email) + .or_else(|| st.sender_key_indices.values().next()) + .copied(); + let signing_key: Option = + key_idx.and_then(|i| st.key_pool.get(i)).cloned(); + + // Sign + encrypt the draft body inside the lock (CPU-only, safe). + let db = match &signing_key { + Some(sk) => crypto::encrypt_sign_body_armored(plaintext, &opk, sk) + .map_err(|e| format!("sign+encrypt draft body: {e}"))?, + None => { + eprintln!(" [send] no signing key found, falling back to unsigned encrypt"); + crypto::encrypt_body_armored(plaintext, &opk) + .map_err(|e| format!("encrypt draft body: {e}"))? + } + }; + + (st.http_client.clone(), st.session.clone(), aid, db, signing_key) + }; + let api = ApiClient::new(&http_client, &session); + + // Compare own-key IDs from two sources for diagnostics. + let server_keys = api.get_public_keys(from).await.unwrap_or_default(); + eprintln!(" [key-cmp] get_public_keys({from}) returned {} key(s)", server_keys.len()); + for (i, rk) in server_keys.iter().enumerate() { + crypto::log_public_key_ids(&rk.public_key, &format!("server-key[{i}] flags=0x{:x}", rk.flags)); + } + + // Log what key ID is actually embedded in the draft body's PKESK. + crypto::dump_message_pkesk(&draft_body); + + // Fetch recipient's public keys to decide send encryption type. + let recipient_keys = api.get_public_keys(to).await.unwrap_or_default(); + + eprintln!( + " [send] {} recipient key(s) found for {to}", + recipient_keys.len() + ); + // send_type, body, key/session-key, and whether the body is signed (Signature flag). + let (send_type, body_b64, key_packet_b64, body_signed) = if let Some(rkey) = + recipient_keys.iter().find(|k| k.flags & 1 != 0) + { + // Type 1: ProtonMail internal — encrypt to recipient's key. + eprintln!(" [send] using key flags=0x{:x}, first 30 chars of armored key: {:.30}", rkey.flags, rkey.public_key.trim()); + let (pkesk, enc_body) = crypto::encrypt_body_for_send(plaintext, &rkey.public_key)?; + eprintln!( + " [send] PKESK {} bytes, first 12: {}", + pkesk.len(), + pkesk.iter().take(12).map(|b| format!("{b:02x}")).collect::() + ); + eprintln!( + " [send] SEIPD {} bytes, first 12: {}", + enc_body.len(), + enc_body.iter().take(12).map(|b| format!("{b:02x}")).collect::() + ); + (1u32, B64.encode(&enc_body), B64.encode(&pkesk), false) + } else { + // Type 32: PGP/MIME — detached signature in a separate MIME part so the + // message body arrives clean (no PGP armor visible in Gmail / other clients). + // ProtonMail decrypts the SEIPD using BodyKey and relays the multipart/signed + // MIME entity to the external recipient over SMTP. + let signing_key = signing_key_opt.as_ref() + .ok_or("no signing key available for external send")?; + let (seipd, session_key) = crypto::build_pgp_mime_for_external_send(plaintext, signing_key) + .map_err(|e| format!("PGP/MIME external body: {e}"))?; + // Signature=1: ProtonMail validates the OpenPGP signature on every external + // send package. The SEIPD now contains an OpenPGP-signed message whose + // literal-data payload is the full multipart/signed MIME entity — ProtonMail + // verifies the OpenPGP sig, then relays the MIME payload to the recipient. + (32u32, B64.encode(&seipd), B64.encode(&session_key), true) + }; + + let draft_id = api + .create_draft(&address_id, subject, from, "", to, "", &draft_body) + .await?; + eprintln!("SMTP draft created: {draft_id}"); + + api.send_draft(&draft_id, to, send_type, &body_b64, &key_packet_b64, body_signed) + .await?; + eprintln!("SMTP sent to {to} (type {send_type})"); + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Extract the address inside `<...>`, or the trimmed string if no angle brackets. +fn extract_angle(s: &str) -> &str { + if let (Some(start), Some(end)) = (s.find('<'), s.rfind('>')) { + &s[start + 1..end] + } else { + s.trim() + } +}