Add SMTP server (step 7) with PGP/MIME external send
Implements a minimal ESMTP listener (AUTH LOGIN/PLAIN, MAIL FROM, RCPT TO, DATA, QUIT) that sends via the ProtonMail v4 API (create draft → send). - ProtonMail internal recipients: Type 1 (encrypt to recipient's ECDH key) - External recipients: Type 32 PGP/MIME — detached signature in a separate application/pgp-signature MIME part so the body arrives clean in Gmail. The MIME entity is wrapped in an OpenPGP-signed message (Signature=1) to satisfy ProtonMail's mandatory signature check on all external sends. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fa1d1a6cb7
commit
2ef6a0fd24
4 changed files with 1041 additions and 14 deletions
|
|
@ -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<String>,
|
||||
/// 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<EmailAddressOwned>,
|
||||
#[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<SendPackage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendPackage {
|
||||
#[serde(rename = "Addresses")]
|
||||
addresses: std::collections::HashMap<String, AddressPackage>,
|
||||
#[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<MessageSessionKey>,
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[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<String, String>,
|
||||
}
|
||||
|
||||
// ── 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<String, String> {
|
||||
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<CreateDraftPayload> =
|
||||
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<Vec<Address>, String> {
|
||||
|
|
|
|||
|
|
@ -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::<String>()
|
||||
.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<String, String> {
|
||||
// From<SignedSecretKey> 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<String, String> {
|
||||
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<String, String> {
|
||||
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<u8>, Vec<u8>), 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<u8>, Vec<u8>), 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<u8>, Vec<u8>), 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<u8>, Vec<u8>), 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<String
|
|||
Message::from_armor_single(Cursor::new(encrypted_body.as_bytes()))
|
||||
.map_err(|e| format!("parse PGP message: {e}"))?;
|
||||
|
||||
// Log the PKESK key IDs from incoming messages — this tells us exactly
|
||||
// which key ID ProtonMail used to encrypt to our address, giving us
|
||||
// the ground truth for what key ID our draft bodies must reference.
|
||||
static LOGGED_ONCE: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
if !LOGGED_ONCE.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
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();
|
||||
eprintln!(" [incoming-pkesk] version={ver:?} recipient={kid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try each key until one succeeds (the right one matches the PKESK key ID).
|
||||
let mut last_err = String::from("no keys provided");
|
||||
for key in keys {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ mod auth;
|
|||
mod config;
|
||||
mod crypto;
|
||||
mod imap_server;
|
||||
mod smtp_server;
|
||||
mod srp;
|
||||
mod store;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use api::{ApiClient, LABEL_INBOX};
|
||||
|
|
@ -22,6 +24,14 @@ pub struct BridgeState {
|
|||
pub http_client: reqwest::Client,
|
||||
pub session: auth::Session,
|
||||
pub config: config::Config,
|
||||
/// ProtonMail address ID → email pairs (for `AddressID` in draft creation).
|
||||
pub sender_addresses: Vec<(String, String)>,
|
||||
/// 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<String, String>,
|
||||
/// 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<String, usize>,
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<Mutex<BridgeState>>;
|
||||
|
|
@ -59,8 +69,10 @@ async fn main() {
|
|||
|
||||
let api = ApiClient::new(&client, &session);
|
||||
|
||||
// Unlock user + address keys.
|
||||
let key_pool = match unlock_key_pool(&api, &config).await {
|
||||
// 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}");
|
||||
|
|
@ -68,6 +80,7 @@ async fn main() {
|
|||
}
|
||||
};
|
||||
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<Vec<crypto::PrivateKey>, String> {
|
||||
) -> Result<(Vec<crypto::PrivateKey>, Vec<(String, String)>, HashMap<String, String>, HashMap<String, usize>), 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<crypto::PrivateKey> = Vec::new();
|
||||
let mut own_public_keys: HashMap<String, String> = HashMap::new();
|
||||
let mut sender_key_indices: HashMap<String, usize> = 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))
|
||||
}
|
||||
|
|
|
|||
351
proton-bridge/src/smtp_server.rs
Normal file
351
proton-bridge/src/smtp_server.rs
Normal file
|
|
@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<String> = Vec::new();
|
||||
let mut in_data = false;
|
||||
let mut data_lines: Vec<String> = 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 <b64>") 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 <CRLF>.<CRLF>\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<crypto::PrivateKey> =
|
||||
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::<String>()
|
||||
);
|
||||
eprintln!(
|
||||
" [send] SEIPD {} bytes, first 12: {}",
|
||||
enc_body.len(),
|
||||
enc_body.iter().take(12).map(|b| format!("{b:02x}")).collect::<String>()
|
||||
);
|
||||
(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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue