use std::{
collections::BTreeMap,
hash::{BuildHasher, Hasher},
io::Cursor,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use kernoid_e2e::{
Kind, PublicKey, SecretKey,
compose::{self, Mailbox as Address},
keys::{normalize_recovery_code, recovery_code},
mime,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
Client, Error, MAIL, Result, SUBMISSION,
mail::{
BodyPart, BodyValue, Draft, Email, EmailAddress, EmailSummary, Identity, InlineFile, Inner, Mailbox, Sealed,
role,
},
set_error,
};
pub const OPENPGP: &str = "https://kernoid.dev/jmap/openpgp";
#[derive(Debug, Clone, Deserialize)]
pub struct KeyCopy {
#[serde(default)]
pub protected: String,
#[serde(default)]
pub salt: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Key {
pub id: String,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub public_key: String,
#[serde(default)]
pub retired: Option<String>,
#[serde(default)]
pub copies: BTreeMap<String, KeyCopy>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default)]
pub mail: bool,
#[serde(default)]
pub policy: Policy,
#[serde(default)]
pub recovery_key: Option<RecoveryKey>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Policy {
#[serde(default)]
pub mail: String,
#[serde(default)]
pub recovery: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoveryKey {
#[serde(default)]
pub public_key: String,
}
#[derive(Debug, Clone)]
pub enum Plan {
Sealed(Vec<PublicKey>),
Plain(String),
}
#[derive(Debug, Clone, Copy)]
pub struct Outgoing<'a> {
pub account: &'a str,
pub identity: &'a Identity,
pub draft: &'a Draft,
pub files: &'a [PathBuf],
pub boxes: &'a [Mailbox],
}
#[derive(Default)]
pub struct E2e {
pub available: bool,
pub keys: Vec<Key>,
pub settings: Settings,
current: Option<SecretKey>,
unlocked: Vec<SecretKey>,
known: BTreeMap<String, Vec<PublicKey>>,
}
impl std::fmt::Debug for E2e {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("E2e")
.field("available", &self.available)
.field("keys", &self.keys.len())
.field("unlocked", &self.current.is_some())
.finish_non_exhaustive()
}
}
fn decode(text: &str) -> Result<Vec<u8>> {
STANDARD.decode(text).map_err(|_| Error::Protocol("not base64".into()))
}
fn list<T: serde::de::DeserializeOwned>(result: &Value) -> Result<Vec<T>> {
serde_json::from_value(result.get("list").cloned().unwrap_or(Value::Null))
.map_err(|error| Error::Protocol(error.to_string()))
}
impl E2e {
pub async fn load(client: &Client) -> Result<E2e> {
if !client.session.capabilities.contains_key(OPENPGP) {
return Ok(E2e::default());
}
let account = client.primary(OPENPGP)?;
let responses = client
.request(
&[OPENPGP],
vec![
("OpenPgpKey/get", json!({ "accountId": account }), "k"),
("E2eSettings/get", json!({ "accountId": account }), "s"),
],
)
.await?;
let keys = list(responses.get("k").unwrap_or(&Value::Null))?;
let settings =
list::<Settings>(responses.get("s").unwrap_or(&Value::Null))?.into_iter().next().unwrap_or_default();
Ok(E2e { available: true, keys, settings, ..E2e::default() })
}
pub async fn refresh(&mut self, client: &Client) -> Result<()> {
let fresh = E2e::load(client).await?;
self.available = fresh.available;
self.keys = fresh.keys;
self.settings = fresh.settings;
Ok(())
}
pub async fn setup(&mut self, client: &Client, kind: Kind) -> Result<String> {
if !self.available {
return Err(Error::E2e("the server has no end-to-end encryption".into()));
}
if self.has_key() {
return Err(Error::E2e("the account has a key already".into()));
}
let account = client.primary(OPENPGP)?;
let mail_account = client.session.primary(MAIL).unwrap_or(&account).to_owned();
let mut addresses: Vec<String> =
match client.call(&[MAIL, SUBMISSION], "Identity/get", json!({ "accountId": mail_account })).await {
Ok(result) => list::<Identity>(&result)?.into_iter().map(|i| i.email.to_lowercase()).collect(),
Err(_) => Vec::new(),
};
addresses.push(client.session.username.to_lowercase());
addresses.sort();
addresses.dedup();
addresses.retain(|a| a.contains('@'));
let secret = tokio::task::spawn_blocking(move || {
let names: Vec<&str> = addresses.iter().map(String::as_str).collect();
SecretKey::generate(kind, &names)
})
.await
.map_err(|error| Error::E2e(error.to_string()))??;
let code = recovery_code();
let protected = STANDARD.encode(secret.protect(&normalize_recovery_code(&code))?);
let mut copies = json!({ "recovery": { "protected": protected, "salt": null } });
if self.settings.policy.recovery
&& let Some(recovery) = &self.settings.recovery_key
{
let tenant = PublicKey::parse(&decode(recovery.public_key.trim())?)?;
let armored = secret.export()?;
let sealed = kernoid_e2e::message::encrypt(armored.as_bytes(), &[&tenant], None, false)?;
copies["tenant"] = json!({ "protected": STANDARD.encode(sealed), "salt": null });
}
let public = STANDARD.encode(secret.public().to_bytes()?);
let create = json!({ "create": { "key": { "publicKey": public, "copies": copies } } });
client.set(&[OPENPGP], "OpenPgpKey", &account, create, json!({})).await?;
self.refresh(client).await?;
self.unlocked = vec![secret.clone()];
self.current = Some(secret);
Ok(code)
}
pub fn current_key(&self) -> Option<&Key> {
self.keys.iter().find(|key| key.retired.is_none())
}
pub fn has_key(&self) -> bool {
self.current_key().is_some()
}
pub fn is_unlocked(&self) -> bool {
self.current.is_some()
}
pub fn unlock(&mut self, code: &str) -> Result<()> {
let key = self.current_key().ok_or_else(|| Error::E2e("this account has no key".into()))?;
let copy =
key.copies.get("recovery").ok_or_else(|| Error::E2e("the key has no copy for a recovery code".into()))?;
let code = normalize_recovery_code(code);
let current = SecretKey::unlock(&decode(©.protected)?, &code)?;
if !current.fingerprint().eq_ignore_ascii_case(&key.id) {
return Err(Error::E2e("the copy is not of the key in use".into()));
}
let mut unlocked = vec![current.clone()];
for key in self.keys.iter().filter(|key| key.retired.is_some()) {
let Some(copy) = key.copies.get("recovery") else { continue };
if let Ok(secret) = decode(©.protected).and_then(|p| Ok(SecretKey::unlock(&p, &code)?)) {
unlocked.push(secret);
}
}
self.unlocked = unlocked;
self.current = Some(current);
Ok(())
}
pub fn lock(&mut self) {
self.current = None;
self.unlocked.clear();
}
pub async fn lookup(&mut self, client: &Client, addresses: &[String]) -> Result<BTreeMap<String, Vec<PublicKey>>> {
let mut wanted: Vec<String> = addresses.iter().map(|a| a.to_lowercase()).collect();
wanted.sort();
wanted.dedup();
wanted.retain(|address| !self.known.contains_key(address));
if !wanted.is_empty() {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Found {
public_key: String,
}
#[derive(Deserialize)]
struct Entry {
email: String,
#[serde(default)]
keys: Vec<Found>,
}
let account = client.primary(OPENPGP)?;
let result =
client.call(&[OPENPGP], "OpenPgpKey/lookup", json!({ "accountId": account, "emails": wanted })).await?;
for entry in list::<Entry>(&result)? {
let keys =
entry.keys.iter().filter_map(|key| PublicKey::parse(&decode(&key.public_key).ok()?).ok()).collect();
self.known.insert(entry.email.to_lowercase(), keys);
}
for address in wanted {
self.known.entry(address).or_default();
}
}
Ok(addresses
.iter()
.map(|a| (a.to_lowercase(), self.known.get(&a.to_lowercase()).cloned().unwrap_or_default()))
.collect())
}
fn secrets(&self) -> Vec<&SecretKey> {
self.unlocked.iter().collect()
}
pub fn open_summary(&self, email: &mut EmailSummary) {
if !email.encrypted || email.opened || self.unlocked.is_empty() {
return;
}
let Some(sealed) = email.encrypted_summary.as_deref().and_then(|s| STANDARD.decode(s).ok()) else { return };
if let Ok(summary) = mime::decrypt_summary(&sealed, &self.secrets()) {
email.subject = summary.subject;
email.preview = summary.preview;
email.has_attachment = summary.has_attachment;
email.opened = true;
}
}
pub async fn open(&mut self, client: &Client, account: &str, mut email: Email) -> Result<Email> {
if !email.summary.encrypted {
return Ok(email);
}
self.open_summary(&mut email.summary);
let mut locked = email.clone();
locked.text_body.clear();
locked.html_body.clear();
locked.attachments.clear();
locked.body_values.clear();
locked.sealed = Some(Sealed { locked: true, ..Sealed::default() });
let Some(blob) = email.blob_id.clone().filter(|_| self.is_unlocked()) else { return Ok(locked) };
let mut raw = Vec::new();
client.download(account, &blob, "message.eml", "message/rfc822", &mut raw, &mut |_, _| {}).await?;
let senders: Vec<String> = email.summary.from.iter().flatten().map(|a| a.email.clone()).collect();
let senders: Vec<PublicKey> = if senders.is_empty() {
Vec::new()
} else {
self.lookup(client, &senders).await.map(|found| found.into_values().flatten().collect()).unwrap_or_default()
};
let senders: Vec<&PublicKey> = senders.iter().collect();
let Ok(opened) = mime::decrypt_message(&raw, &self.secrets(), &senders) else { return Ok(locked) };
let Ok(parsed) = compose::parse(&opened.data) else { return Ok(locked) };
let addresses = |list: Vec<Address>| -> Vec<EmailAddress> {
list.into_iter().map(|a| EmailAddress { name: a.name, email: a.email }).collect()
};
let part = |id: &str, kind: &str, size: usize| BodyPart {
part_id: Some(id.to_owned()),
blob_id: None,
kind: kind.to_owned(),
name: None,
size: size as u64,
inner: None,
};
email.summary.subject = parsed.subject;
if !parsed.from.is_empty() {
email.summary.from = Some(addresses(parsed.from));
}
if !parsed.to.is_empty() {
email.summary.to = Some(addresses(parsed.to));
}
email.cc = Some(addresses(parsed.cc));
email.reply_to = Some(addresses(parsed.reply_to));
email.sent_at = parsed.sent_at.or(email.sent_at);
email.message_id = parsed.message_id.map(|id| vec![id]).or(email.message_id);
if !parsed.references.is_empty() {
email.references = Some(parsed.references);
}
email.text_body.clear();
email.html_body.clear();
email.body_values.clear();
if let Some(text) = parsed.text {
email.text_body.push(part("text", "text/plain", text.len()));
email.body_values.insert("text".into(), BodyValue { value: text });
}
if let Some(html) = parsed.html {
email.html_body.push(part("html", "text/html", html.len()));
email.body_values.insert("html".into(), BodyValue { value: html });
} else {
email.html_body = email.text_body.clone();
}
email.attachments = parsed
.attachments
.into_iter()
.map(|p| BodyPart {
part_id: None,
blob_id: None,
kind: p.media_type,
name: p.name,
size: p.size as u64,
inner: Some(p.index),
})
.collect();
email.summary.has_attachment = !email.attachments.is_empty();
email.inner = Some(Inner(opened.data));
email.sealed = Some(Sealed { locked: false, signed: opened.signed, verified: opened.verified_by.is_some() });
Ok(email)
}
fn own_key(&self, client: &Client, account: &str, identity: &Identity) -> std::result::Result<&SecretKey, String> {
if !self.available {
return Err("the server has no end-to-end encryption".into());
}
let Some(current) = &self.current else {
return Err(if self.has_key() {
"your key is locked (K unlocks it)".into()
} else {
"your account has no key (made in the web app)".into()
});
};
if client.session.primary(MAIL) != Some(account) {
return Err("it is not sent from your own account".into());
}
if !current.public().addresses().iter().any(|a| a.eq_ignore_ascii_case(&identity.email)) {
return Err(format!("your key is not for {}", identity.email));
}
Ok(current)
}
pub async fn plan(&mut self, client: &Client, account: &str, identity: &Identity, draft: &Draft) -> Plan {
let own = match self.own_key(client, account, identity) {
Ok(key) => key.public(),
Err(reason) => return Plan::Plain(reason),
};
if !draft.bcc.is_empty() {
return Plan::Plain("it has Bcc recipients, whom an encrypted message would show".into());
}
let addresses: Vec<String> = draft.to.iter().chain(&draft.cc).map(|a| a.email.clone()).collect();
if addresses.is_empty() {
return Plan::Plain("it has no recipients".into());
}
let found = match self.lookup(client, &addresses).await {
Ok(found) => found,
Err(error) => return Plan::Plain(format!("the keys could not be looked up: {error}")),
};
let mut keys = Vec::new();
for (address, list) in found {
let usable: Vec<PublicKey> = list.into_iter().filter(PublicKey::can_encrypt).collect();
if usable.is_empty() {
return Plan::Plain(format!("{address} has no key"));
}
keys.extend(usable);
}
keys.push(own);
Plan::Sealed(keys)
}
pub fn draft_plan(&self, client: &Client, account: &str, identity: &Identity, draft: &Draft) -> Plan {
match self.own_key(client, account, identity) {
Ok(_) if !draft.bcc.is_empty() => Plan::Plain("an encrypted draft cannot keep Bcc".into()),
Ok(key) => Plan::Sealed(vec![key.public()]),
Err(reason) => Plan::Plain(reason),
}
}
pub async fn send(&self, client: &Client, outgoing: Outgoing<'_>, recipients: &[PublicKey]) -> Result<()> {
self.store(client, outgoing, &[], recipients, true).await
}
pub async fn keep(&self, client: &Client, outgoing: Outgoing<'_>, keys: &[PublicKey]) -> Result<()> {
self.store(client, outgoing, &[], keys, false).await
}
pub async fn send_with(
&self,
client: &Client,
outgoing: Outgoing<'_>,
inline: &[InlineFile],
recipients: &[PublicKey],
) -> Result<()> {
self.store(client, outgoing, inline, recipients, true).await
}
pub async fn keep_with(
&self,
client: &Client,
outgoing: Outgoing<'_>,
inline: &[InlineFile],
keys: &[PublicKey],
) -> Result<()> {
self.store(client, outgoing, inline, keys, false).await
}
async fn store(
&self,
client: &Client,
outgoing: Outgoing<'_>,
inline: &[InlineFile],
recipients: &[PublicKey],
submit: bool,
) -> Result<()> {
let Outgoing { account, identity, draft, files, boxes } = outgoing;
let own = self.own_key(client, account, identity).map_err(Error::E2e)?;
let drafts = role(boxes, "drafts").ok_or_else(|| Error::Protocol("there is no Drafts mailbox".into()))?;
let mut attachments = Vec::new();
for file in &draft.attachments {
let mut data = Vec::new();
client.download(account, &file.blob_id, &file.name, &file.kind, &mut data, &mut |_, _| {}).await?;
attachments.push(compose::Attachment {
name: file.name.clone(),
media_type: file.kind.clone(),
data: STANDARD.encode(data),
});
}
for path in files {
let data = std::fs::read(path).map_err(|error| Error::Protocol(format!("{}: {error}", path.display())))?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("attachment").to_owned();
let media_type = crate::files::media_type(&name).to_owned();
attachments.push(compose::Attachment { name, media_type, data: STANDARD.encode(data) });
}
for file in inline {
attachments.push(compose::Attachment {
name: file.name.clone(),
media_type: file.media_type.clone(),
data: STANDARD.encode(&file.data),
});
}
let address = |a: &EmailAddress| Address { name: a.name.clone(), email: a.email.clone() };
let strip = |ids: &[String]| -> Vec<String> {
ids.iter().map(|id| id.trim().trim_start_matches('<').trim_end_matches('>').to_owned()).collect()
};
let domain = identity.email.rsplit_once('@').map(|(_, d)| d).unwrap_or("localhost");
let message = compose::Draft {
from: Some(Address {
name: (!identity.name.is_empty()).then(|| identity.name.clone()),
email: identity.email.clone(),
}),
to: draft.to.iter().map(address).collect(),
cc: draft.cc.iter().map(address).collect(),
reply_to: Vec::new(),
subject: draft.subject.clone(),
message_id: format!("{}@{domain}", random_id()),
in_reply_to: strip(&draft.in_reply_to),
references: strip(&draft.references),
date: chrono::Local::now().to_rfc2822(),
text: draft.body.clone(),
html: None,
attachments,
};
let raw = compose::build(&message)?;
let recipients: Vec<&PublicKey> = recipients.iter().collect();
let encrypted = mime::encrypt_message(&raw, &recipients, Some(own))?;
let summary = mime::encrypted_summary(&raw, &[&own.public()])?;
let size = encrypted.len() as u64;
let upload = client.upload(account, "message/rfc822", Cursor::new(encrypted), size, |_, _| {}).await?;
let mut calls = vec![(
"Email/import",
json!({ "accountId": account, "emails": { "draft": {
"blobId": upload.blob_id, "mailboxIds": { drafts.id.as_str(): true },
"keywords": { "$draft": true, "$seen": true }, "kernoid:encryptedSummary": STANDARD.encode(summary),
} } }),
"e",
)];
if submit {
let mut update = json!({ "keywords/$draft": null });
if let Some(sent) = role(boxes, "sent") {
update["mailboxIds"] = json!({ sent.id.as_str(): true });
}
let rcpt: Vec<Value> =
draft.to.iter().chain(&draft.cc).map(|a| json!({ "email": a.email, "parameters": null })).collect();
calls.push((
"EmailSubmission/set",
json!({
"accountId": account,
"create": { "send": {
"identityId": identity.id, "emailId": "#draft",
"envelope": { "mailFrom": { "email": identity.email, "parameters": null }, "rcptTo": rcpt },
} },
"onSuccessUpdateEmail": { "#send": update },
}),
"s",
));
}
let responses = client.request(&[MAIL, SUBMISSION, OPENPGP], calls).await?;
for (_, result, _) in &responses.0 {
set_error(result)?;
}
Ok(())
}
}
fn random_id() -> String {
let state = std::collections::hash_map::RandomState::new();
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let half = |n: u8| {
let mut hasher = state.build_hasher();
hasher.write_u128(now);
hasher.write_u32(std::process::id());
hasher.write_u8(n);
hasher.finish()
};
format!("{:016x}{:016x}", half(0), half(1))
}