feat: gsmol-compatible backup export and import behind the store feature
This commit is contained in:
parent
96d2412370
commit
4a780ed648
6 changed files with 528 additions and 17 deletions
|
|
@ -46,6 +46,10 @@ fumi send <address> [--subject S] [--body TEXT | --file F | -] [--header K:V] [-
|
||||||
fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
|
fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
|
||||||
fumi delete <id>... remove from the server explicitly
|
fumi delete <id>... remove from the server explicitly
|
||||||
fumi list [--sent] [--requests]
|
fumi list [--sent] [--requests]
|
||||||
|
|
||||||
|
# backups
|
||||||
|
fumi export [FILE] write a gsmol backup: sealed mail, contacts, pins — never the master
|
||||||
|
fumi import-backup <FILE> restore one; merges, never overwrites a trust binding
|
||||||
fumi read <id> [--sent]
|
fumi read <id> [--sent]
|
||||||
|
|
||||||
# RNS carrier (built with --features rns / nix build .#rns); addresses select
|
# RNS carrier (built with --features rns / nix build .#rns); addresses select
|
||||||
|
|
@ -116,6 +120,7 @@ An importer should set `user_version` to 2 (a v1 store is migrated on open inste
|
||||||
| `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session |
|
| `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session |
|
||||||
| `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation; store-taking items behind the `store` feature |
|
| `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation; store-taking items behind the `store` feature |
|
||||||
| `core/src/store.rs` | SQLite behind the `store` feature: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema |
|
| `core/src/store.rs` | SQLite behind the `store` feature: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema |
|
||||||
|
| `core/src/export.rs` | the gsmol backup format behind the `store` feature: sealed payload of mail, contacts and pins, interchangeable with gsmol exports |
|
||||||
| `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping |
|
| `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping |
|
||||||
| `core/vectors.json` | the committed reference vectors, the tests' source of truth |
|
| `core/vectors.json` | the committed reference vectors, the tests' source of truth |
|
||||||
| `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output |
|
| `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output |
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ enum Command {
|
||||||
Import { uri: String },
|
Import { uri: String },
|
||||||
/// List known keys and how each was learned
|
/// List known keys and how each was learned
|
||||||
Contacts,
|
Contacts,
|
||||||
|
/// Write a gsmol backup: sealed mail, contacts and pins, never the master
|
||||||
|
Export {
|
||||||
|
/// Output file
|
||||||
|
#[arg(default_value = "smol-backup.json")]
|
||||||
|
file: PathBuf,
|
||||||
|
},
|
||||||
|
/// Restore a gsmol backup file; merges, never overwrites a trust binding
|
||||||
|
ImportBackup { file: PathBuf },
|
||||||
/// Issue an accept token, admit to the main tier, sync the set
|
/// Issue an accept token, admit to the main tier, sync the set
|
||||||
Accept { address: String },
|
Accept { address: String },
|
||||||
/// Withdraw a contact's accept token, sync the set
|
/// Withdraw a contact's accept token, sync the set
|
||||||
|
|
@ -192,6 +200,8 @@ fn run(cli: Cli) -> Result<()> {
|
||||||
Command::Resolve { address } => resolve(&store, address, cli.timeout),
|
Command::Resolve { address } => resolve(&store, address, cli.timeout),
|
||||||
Command::Import { uri } => import(&store, uri),
|
Command::Import { uri } => import(&store, uri),
|
||||||
Command::Contacts => contacts(&store),
|
Command::Contacts => contacts(&store),
|
||||||
|
Command::Export { file } => export_backup(&cli.key, &store, file),
|
||||||
|
Command::ImportBackup { file } => import_backup(&cli.key, &store, file),
|
||||||
Command::Accept { address } => accept(&cli.key, &store, address, cli.timeout),
|
Command::Accept { address } => accept(&cli.key, &store, address, cli.timeout),
|
||||||
Command::Block { address } => block(&cli.key, &store, address, cli.timeout),
|
Command::Block { address } => block(&cli.key, &store, address, cli.timeout),
|
||||||
Command::Send { .. } => send(&cli, &store),
|
Command::Send { .. } => send(&cli, &store),
|
||||||
|
|
@ -448,6 +458,23 @@ fn contacts(store: &Store) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn export_backup(key: &PathBuf, store: &Store, file: &PathBuf) -> Result<()> {
|
||||||
|
let master = load_master(key)?;
|
||||||
|
let text = fumi::export::export(store, &master)?;
|
||||||
|
std::fs::write(file, text)?;
|
||||||
|
println!("wrote {} (only the master can open it)", file.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_backup(key: &PathBuf, store: &Store, file: &PathBuf) -> Result<()> {
|
||||||
|
let master = load_master(key)?;
|
||||||
|
let text = std::fs::read_to_string(file)
|
||||||
|
.with_context(|| format!("reading {}", file.display()))?;
|
||||||
|
let summary = fumi::export::import(store, &master, &text)?;
|
||||||
|
println!("{summary}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// An address plus the key we hold for it, for commands naming a contact.
|
/// An address plus the key we hold for it, for commands naming a contact.
|
||||||
fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])> {
|
fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])> {
|
||||||
let addr = Address::parse(text)?;
|
let addr = Address::parse(text)?;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ default = ["store", "bundled-sqlite"]
|
||||||
# one. Off, the crate keeps identities, addresses, seal/open and the raw
|
# one. Off, the crate keeps identities, addresses, seal/open and the raw
|
||||||
# transport operations for hosts with their own database and thin FFI
|
# transport operations for hosts with their own database and thin FFI
|
||||||
# consumers that need only the crypto and envelope layer.
|
# consumers that need only the crypto and envelope layer.
|
||||||
store = ["dep:rusqlite"]
|
store = ["dep:rusqlite", "dep:serde", "dep:serde_json"]
|
||||||
# Bundle libsqlite3 so the store needs no system SQLite; embedders with their
|
# Bundle libsqlite3 so the store needs no system SQLite; embedders with their
|
||||||
# own SQLite (Android's NDK case) build with default-features = false.
|
# own SQLite (Android's NDK case) build with default-features = false.
|
||||||
bundled-sqlite = ["rusqlite/bundled"]
|
bundled-sqlite = ["rusqlite/bundled"]
|
||||||
|
|
@ -29,6 +29,8 @@ hmac = "0.12"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
rusqlite = { version = "0.32", optional = true }
|
rusqlite = { version = "0.32", optional = true }
|
||||||
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
data-encoding = "2"
|
data-encoding = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
448
core/src/export.rs
Normal file
448
core/src/export.rs
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
//! 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());
|
||||||
|
match key {
|
||||||
|
None => summary.malformed += 1,
|
||||||
|
Some(key) => match store.server_pin(host)? {
|
||||||
|
None => {
|
||||||
|
store.pin_server(host, &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", &[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").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", &[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").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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,10 @@ pub mod address;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
// The gsmol export format is sealed mail plus the store's trust rows, so it
|
||||||
|
// exists only alongside the store.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
|
pub mod export;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
#[cfg(feature = "rns")]
|
#[cfg(feature = "rns")]
|
||||||
pub mod rns;
|
pub mod rns;
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,21 @@ impl Store {
|
||||||
.and_then(|k| k.try_into().ok()))
|
.and_then(|k| k.try_into().ok()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every pin, host and key, ordered by host — the export's server map.
|
||||||
|
pub fn pins(&self) -> Result<Vec<(String, [u8; KEY_LEN])>, Error> {
|
||||||
|
let db = self.db();
|
||||||
|
let mut stmt = db.prepare("SELECT host, static FROM servers ORDER BY host")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(host, key)| Some((host, key.try_into().ok()?)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), Error> {
|
pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), Error> {
|
||||||
self.db()
|
self.db()
|
||||||
.execute(
|
.execute(
|
||||||
|
|
@ -541,6 +556,21 @@ impl Store {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a message id is already stored in `folder` ("inbox" or "sent"),
|
||||||
|
/// so an import can skip rather than re-store.
|
||||||
|
pub fn stored(&self, folder: &str, id: &[u8; ID_LEN]) -> Result<bool, Error> {
|
||||||
|
let table = match folder {
|
||||||
|
"sent" => "sent",
|
||||||
|
_ => "inbox",
|
||||||
|
};
|
||||||
|
Ok(self
|
||||||
|
.one::<i64>(
|
||||||
|
&format!("SELECT 1 FROM {table} WHERE id = ?1"),
|
||||||
|
&[id],
|
||||||
|
)?
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// One folder, ordered by arrival or sending time. `folder` is "inbox",
|
/// One folder, ordered by arrival or sending time. `folder` is "inbox",
|
||||||
/// "requests", "sent" or "all".
|
/// "requests", "sent" or "all".
|
||||||
pub fn mail(&self, folder: &str) -> Result<Vec<Stored>, Error> {
|
pub fn mail(&self, folder: &str) -> Result<Vec<Stored>, Error> {
|
||||||
|
|
@ -597,13 +627,16 @@ mod tests {
|
||||||
core::array::from_fn(|i| i as u8)
|
core::array::from_fn(|i| i as u8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh scratch path per call: a monotonic counter, so two tests or
|
||||||
|
/// two runs cannot collide the way pid-and-second can.
|
||||||
|
fn scratch_path(tag: &str) -> std::path::PathBuf {
|
||||||
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!("fumi-store-{tag}-{n}-{}.db", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
fn temp_store(tag: &str) -> Store {
|
fn temp_store(tag: &str) -> Store {
|
||||||
let path = std::env::temp_dir().join(format!(
|
Store::open(&scratch_path(tag)).expect("open store")
|
||||||
"fumi-store-{tag}-{}-{}.db",
|
|
||||||
std::process::id(),
|
|
||||||
now()
|
|
||||||
));
|
|
||||||
Store::open(&path).expect("open store")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -761,11 +794,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_is_versioned_and_refuses_a_newer_store() {
|
fn schema_is_versioned_and_refuses_a_newer_store() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = scratch_path("schema");
|
||||||
"fumi-store-schema-{}-{}.db",
|
|
||||||
std::process::id(),
|
|
||||||
now()
|
|
||||||
));
|
|
||||||
drop(Store::open(&path).expect("open store"));
|
drop(Store::open(&path).expect("open store"));
|
||||||
let db = Connection::open(&path).unwrap();
|
let db = Connection::open(&path).unwrap();
|
||||||
let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0)).unwrap();
|
let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0)).unwrap();
|
||||||
|
|
@ -783,11 +812,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v1_store_migrates_at_open() {
|
fn v1_store_migrates_at_open() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = scratch_path("v1");
|
||||||
"fumi-store-v1-{}-{}.db",
|
|
||||||
std::process::id(),
|
|
||||||
now()
|
|
||||||
));
|
|
||||||
// A schema-version-1 store: the inbox has no `kept` column and the
|
// A schema-version-1 store: the inbox has no `kept` column and the
|
||||||
// history table does not exist yet.
|
// history table does not exist yet.
|
||||||
let db = Connection::open(&path).unwrap();
|
let db = Connection::open(&path).unwrap();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue