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:
Shautvast 2026-02-24 16:36:38 +01:00
parent fa1d1a6cb7
commit 2ef6a0fd24
4 changed files with 1041 additions and 14 deletions

View file

@ -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> {

View file

@ -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 {

View file

@ -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))
}

View 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()
}
}