fumi/core/src/export.rs

458 lines
17 KiB
Rust

//! The gsmol export format: a sealed bundle of mail, contacts and pins a
//! gsmol or fumi client can restore. The payload is sealed to a key derived
//! from the master, so a backup file is only readable where the identity
//! already lives; the master itself is never in the file. Field names,
//! encodings and units match gsmol's `store.js` exactly, so exports are
//! interchangeable between the clients.
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use crate::crypto::{b32, hkdf_sha256, unb32};
use crate::error::Error;
use crate::store::Store;
use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS};
/// The HKDF label gsmol wrote originally; the format's version lives in the
/// container's `gsmolExport` field, not here.
const EXPORT_LABEL: &[u8] = b"gsmol/1 export";
fn export_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
hkdf_sha256(master, b"", EXPORT_LABEL, KEY_LEN)
.try_into()
.unwrap()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(text: &str) -> Option<Vec<u8>> {
if text.len() % 2 != 0 {
return None;
}
(0..text.len() / 2)
.map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok())
.collect()
}
fn seal(key: &[u8; KEY_LEN], plaintext: &[u8]) -> ([u8; 12], Vec<u8>) {
// The key is the same on every export, so a fresh nonce per seal is what
// rules out reuse — the one place a random nonce is mandatory.
let mut nonce = [0u8; 12];
OsRng.fill_bytes(&mut nonce);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let sealed = cipher
.encrypt(
&Nonce::from(nonce),
Payload {
msg: plaintext,
aad: b"",
},
)
.expect("encryption failed");
(nonce, sealed)
}
fn open(key: &[u8; KEY_LEN], nonce: &[u8], sealed: &[u8]) -> Option<Vec<u8>> {
if nonce.len() != 12 {
return None;
}
ChaCha20Poly1305::new(Key::from_slice(key))
.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: sealed,
aad: b"",
},
)
.ok()
}
#[derive(Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct HistoryEntry {
key: String,
/// When the key stopped being current, in epoch milliseconds.
until: i64,
}
#[derive(Serialize, Deserialize)]
struct ContactExport {
key: String,
verified: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
history: Vec<HistoryEntry>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InboxRow {
id: String,
received_at: i64,
envelope: String,
tier: u8,
kept_on_server: bool,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SentRow {
id: String,
recipient: String,
sent_at: i64,
envelope: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bundle {
servers: std::collections::BTreeMap<String, String>,
contacts: std::collections::BTreeMap<String, ContactExport>,
inbox: Vec<InboxRow>,
sent: Vec<SentRow>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Container {
gsmol_export: u8,
exported_at: i64,
nonce: String,
ciphertext: String,
}
/// What an import did, in the shape gsmol's importer reports. A conflicting
/// pin or contact is counted, not applied: an existing trust binding changes
/// only by explicit user action, never silently.
#[derive(Debug, Default)]
pub struct ImportSummary {
pub pins_added: usize,
pub pins_conflicted: usize,
pub contacts_added: usize,
pub contacts_conflicted: usize,
pub mail_added: usize,
pub malformed: usize,
}
impl std::fmt::Display for ImportSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} messages, {} contacts ({} conflicted), {} server keys ({} conflicted), {} malformed",
self.mail_added,
self.contacts_added,
self.contacts_conflicted,
self.pins_added,
self.pins_conflicted,
self.malformed
)
}
}
/// Exports the store as a gsmol v2 container: pins, contacts with their key
/// history, and sealed mail, ChaCha20-Poly1305-sealed to the master-derived
/// key. Mail stays sealed; the master never enters the file.
pub fn export(store: &Store, master: &[u8; KEY_LEN]) -> Result<String, Error> {
let mut servers = std::collections::BTreeMap::new();
for (host, key) in store.pins()? {
servers.insert(host, b32(&key));
}
let mut contacts = std::collections::BTreeMap::new();
for (address, key, verified, _) in store.contact_rows()? {
let history = store
.history(&address)?
.into_iter()
.map(|(key, until)| HistoryEntry {
key: b32(&key),
until,
})
.collect();
contacts.insert(address, ContactExport { key: b32(&key), verified, history });
}
// Each folder listing carries its own tier, so rows round-trip into the
// tier they were fetched into.
let inbox = store
.mail("inbox")?
.into_iter()
.map(|row| inbox_row(row, TIER_MAIN))
.chain(store.mail("requests")?.into_iter().map(|row| inbox_row(row, TIER_REQUESTS)))
.collect::<Vec<_>>();
let sent = store
.mail("sent")?
.into_iter()
.map(|row| SentRow {
id: hex(&row.id),
recipient: row.recipient.unwrap_or_default(),
sent_at: row.at,
envelope: data_encoding::BASE64.encode(&row.envelope),
})
.collect();
let payload = serde_json::to_vec(&Bundle {
servers,
contacts,
inbox,
sent,
})
.map_err(|e| Error::Other(format!("export serialization failed: {e}")))?;
let (nonce, sealed) = seal(&export_key(master), &payload);
let container = Container {
gsmol_export: 2,
// Milliseconds, the unit gsmol stamps the container with; the mail
// and history rows inside keep their own units.
exported_at: crate::store::now() * 1000,
nonce: data_encoding::BASE64.encode(&nonce),
ciphertext: data_encoding::BASE64.encode(&sealed),
};
serde_json::to_string_pretty(&container)
.map_err(|e| Error::Other(format!("export serialization failed: {e}")))
}
fn inbox_row(row: crate::store::Stored, tier: u8) -> InboxRow {
InboxRow {
id: hex(&row.id),
received_at: row.at,
envelope: data_encoding::BASE64.encode(&row.envelope),
tier,
kept_on_server: row.kept,
}
}
/// Imports a gsmol export. Mail is stored sealed and marked seen, so a later
/// fetch does not re-store it; pins and contacts merge without clobbering;
/// one malformed record is skipped and counted, never fatal.
pub fn import(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<ImportSummary, Error> {
let container: Container = serde_json::from_str(text)
.map_err(|_| Error::Other("not a gsmol export file".into()))?;
if container.gsmol_export != 2 {
return Err(Error::Other("not a gsmol export file".into()));
}
let nonce = data_encoding::BASE64
.decode(container.nonce.as_bytes())
.map_err(|_| Error::Other("not a gsmol export file".into()))?;
let sealed = data_encoding::BASE64
.decode(container.ciphertext.as_bytes())
.map_err(|_| Error::Other("not a gsmol export file".into()))?;
let plaintext = open(&export_key(master), &nonce, &sealed)
.ok_or_else(|| {
Error::Other("couldn't decrypt — exported by a different identity, or the file is corrupted".into())
})?;
let payload: Bundle = serde_json::from_slice(&plaintext)
.map_err(|_| Error::Other("not a gsmol export file".into()))?;
let mut summary = ImportSummary::default();
for (host, key) in &payload.servers {
let key: Option<[u8; KEY_LEN]> = unb32(key).ok().and_then(|k| k.try_into().ok());
// A backup pin key is a bare host (the default port) or host:port,
// scoping exactly as the live store does; the port splits from the
// right, so a host without a valid port suffix stays whole.
let (host, port) = match host.rsplit_once(':') {
Some((h, p)) => match p.parse::<u16>() {
Ok(port) => (h.to_string(), port),
Err(_) => (host.clone(), crate::address::DEFAULT_PORT),
},
None => (host.clone(), crate::address::DEFAULT_PORT),
};
match key {
None => summary.malformed += 1,
Some(key) => match store.server_pin(&host, port)? {
None => {
store.pin_server(&host, port, &key)?;
summary.pins_added += 1;
}
Some(existing) if existing != key => summary.pins_conflicted += 1,
Some(_) => {}
},
}
}
for (address, contact) in &payload.contacts {
let key: Option<[u8; KEY_LEN]> =
unb32(&contact.key).ok().and_then(|k| k.try_into().ok());
match key {
None => summary.malformed += 1,
Some(key) => match store.contact(address)? {
None => {
store.save_contact(address, &key, contact.verified)?;
summary.contacts_added += 1;
}
Some((existing, _)) if existing != key => summary.contacts_conflicted += 1,
Some(_) => {}
},
}
// History is a record, not a trust binding: entries are additive and
// never clobber anything, so they merge in both directions.
for entry in &contact.history {
if let Some(old) = unb32(&entry.key).ok().and_then(|k| k.try_into().ok()) {
store.save_history(address, &old, entry.until)?;
} else {
summary.malformed += 1;
}
}
}
for row in &payload.inbox {
let id: Option<[u8; ID_LEN]> = unhex(&row.id).and_then(|k| k.try_into().ok());
let envelope = data_encoding::BASE64
.decode(row.envelope.as_bytes())
.ok()
.filter(|e| !e.is_empty());
match (id, envelope) {
(Some(id), Some(envelope)) if !store.stored("inbox", &id)? => {
// store_inbox marks the message seen, so a later fetch of a
// still-kept server copy cannot store it twice.
store.store_inbox(
&id,
&envelope,
row.received_at,
row.tier == TIER_REQUESTS,
row.kept_on_server,
)?;
summary.mail_added += 1;
}
(Some(_), Some(_)) => {}
_ => summary.malformed += 1,
}
}
for row in &payload.sent {
let id: Option<[u8; ID_LEN]> = unhex(&row.id).and_then(|k| k.try_into().ok());
let envelope = data_encoding::BASE64
.decode(row.envelope.as_bytes())
.ok()
.filter(|e| !e.is_empty());
match (id, envelope) {
(Some(id), Some(envelope)) if !store.stored("sent", &id)? => {
store.store_sent(&id, &row.recipient, &envelope, row.sent_at)?;
summary.mail_added += 1;
}
(Some(_), Some(_)) => {}
_ => summary.malformed += 1,
}
}
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded_store() -> (Store, [u8; KEY_LEN]) {
let store = Store::open_in_memory().unwrap();
let master = [7u8; KEY_LEN];
store.pin_server("example.org", crate::address::DEFAULT_PORT, &[1u8; KEY_LEN]).unwrap();
store
.save_contact("alice@example.org", &[2u8; KEY_LEN], true)
.unwrap();
store
.save_history("alice@example.org", &[9u8; KEY_LEN], 500)
.unwrap();
store
.store_inbox(&[3u8; ID_LEN], b"envelope", 42, false, true)
.unwrap();
store
.store_inbox(&[4u8; ID_LEN], b"request", 43, true, false)
.unwrap();
store.store_sent(&[5u8; ID_LEN], "bob@example.org", b"sent", 44).unwrap();
(store, master)
}
#[test]
fn export_import_round_trip() {
let (store, master) = seeded_store();
let file = export(&store, &master).unwrap();
let fresh = Store::open_in_memory().unwrap();
let summary = import(&fresh, &master, &file).unwrap();
assert_eq!(summary.mail_added, 3);
assert_eq!(summary.pins_added, 1);
assert_eq!(summary.contacts_added, 1);
assert_eq!(summary.malformed, 0);
assert_eq!(fresh.server_pin("example.org", crate::address::DEFAULT_PORT).unwrap(), Some([1u8; KEY_LEN]));
let (key, verified) = fresh.contact("alice@example.org").unwrap().unwrap();
assert_eq!(key, [2u8; KEY_LEN]);
assert!(verified);
assert_eq!(fresh.history("alice@example.org").unwrap(), [([9u8; KEY_LEN], 500)]);
assert_eq!(fresh.mail("inbox").unwrap().len(), 1);
assert_eq!(fresh.mail("requests").unwrap().len(), 1);
assert_eq!(fresh.mail("inbox").unwrap()[0].kept, true);
assert_eq!(fresh.mail("sent").unwrap().len(), 1);
// Importing marks mail seen, so a later fetch cannot re-store it.
assert!(fresh.seen(&[3u8; ID_LEN]).unwrap());
}
#[test]
fn a_different_identity_cannot_open_it() {
let (store, master) = seeded_store();
let file = export(&store, &master).unwrap();
let fresh = Store::open_in_memory().unwrap();
let err = import(&fresh, &[8u8; KEY_LEN], &file).unwrap_err();
assert!(err.to_string().contains("couldn't decrypt"));
assert_eq!(fresh.mail("all").unwrap().len(), 0);
}
#[test]
fn import_never_clobbers_a_trust_binding() {
let (store, master) = seeded_store();
let file = export(&store, &master).unwrap();
let mine = Store::open_in_memory().unwrap();
mine.pin_server("example.org", crate::address::DEFAULT_PORT, &[6u8; KEY_LEN]).unwrap();
mine.save_contact("alice@example.org", &[7u8; KEY_LEN], false)
.unwrap();
let summary = import(&mine, &master, &file).unwrap();
assert_eq!(summary.pins_conflicted, 1);
assert_eq!(summary.contacts_conflicted, 1);
assert_eq!(mine.server_pin("example.org", crate::address::DEFAULT_PORT).unwrap(), Some([6u8; KEY_LEN]));
let (key, _) = mine.contact("alice@example.org").unwrap().unwrap();
assert_eq!(key, [7u8; KEY_LEN]);
}
#[test]
fn malformed_records_are_skipped_not_fatal() {
let (store, master) = seeded_store();
let file = export(&store, &master).unwrap();
// Corrupt one inbox row's id inside the payload by re-sealing a
// hand-edited payload with the same key.
let container: Container = serde_json::from_str(&file).unwrap();
let nonce = data_encoding::BASE64.decode(container.nonce.as_bytes()).unwrap();
let sealed = data_encoding::BASE64.decode(container.ciphertext.as_bytes()).unwrap();
let plaintext = open(&export_key(&master), &nonce, &sealed).unwrap();
let mut payload: Bundle = serde_json::from_slice(&plaintext).unwrap();
payload.inbox[0].id = "zz".to_string();
payload.servers.insert("bad".to_string(), "not-base32!".to_string());
let (nonce, sealed) = seal(&export_key(&master), &serde_json::to_vec(&payload).unwrap());
let file = serde_json::to_string(&Container {
gsmol_export: 2,
exported_at: 0,
nonce: data_encoding::BASE64.encode(&nonce),
ciphertext: data_encoding::BASE64.encode(&sealed),
})
.unwrap();
let fresh = Store::open_in_memory().unwrap();
let summary = import(&fresh, &master, &file).unwrap();
assert_eq!(summary.malformed, 2);
assert_eq!(summary.mail_added, 2);
assert_eq!(summary.pins_added, 1);
}
#[test]
fn not_a_gsmol_file_is_refused() {
let fresh = Store::open_in_memory().unwrap();
assert!(import(&fresh, &[7u8; KEY_LEN], "{}").is_err());
assert!(import(&fresh, &[7u8; KEY_LEN], "[]").is_err());
// A v1 file is the pre-encryption shape gsmol still accepts; this
// importer takes the sealed v2 container only.
let v1 = r#"{"gsmolExport": 1, "servers": {}}"#;
assert!(import(&fresh, &[7u8; KEY_LEN], v1).is_err());
}
}