458 lines
17 KiB
Rust
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());
|
|
}
|
|
}
|