//! The encrypted inbox (F5 → the app): the second carry for everything the //! friend and share handshakes send. A persona that has published an //! InboxDescriptor can be written to by anyone who can resolve it: the //! sender seals the cargo to the recipient's box key, signs the envelope //! header with its persona key, and OFFERS the envelope to the recipient's //! inbox contract, whose merge admits, dedups and caps it. The recipient //! reads its own inbox, opens what it can, acts on it, and prunes what it //! handled with an owner-signed tombstone record. //! //! Custody, for now: the X25519 box secret lives beside the node in this //! device's app folder (`inbox-box.key`). The design wants it in the //! identity delegate — that is the next delegate generation, a migration //! event — so a persona restored elsewhere from the recovery file has to //! publish a fresh box key, and mail sealed to the old one is unreadable. //! The UI says so. //! //! Adapter rules from F5 apply verbatim: read before the first write to a //! contract this node never held (`get_then_update`), read-and-bump the //! descriptor and prune sequences, and an unresolvable descriptor means //! "unreachable through Freenet" — never a guess. use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use freenet_adapter::FreenetAdapter; use persona_state::friend as fr; use persona_state::inbox as ib; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::ledger; use crate::persona; use crate::state::AppState; const INBOX_WASM: &[u8] = include_bytes!("../assets/inbox_contract.wasm"); const DESC_WASM: &[u8] = include_bytes!("../assets/inbox_desc_contract.wasm"); pub const EVENT: &str = "community:inbox"; /// How long an envelope we send stays valid. const ENVELOPE_TTL_SECS: u64 = 7 * 24 * 3600; /// A descriptor lookup that found nothing is not repeated for this long. const DESC_CACHE_TTL: Duration = Duration::from_secs(600); /// What travels sealed inside an envelope: the same things the PM carry /// moves in the clear. Bincode variant order is the wire — append only. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Cargo { FriendRequest(fr::FriendRequest), FriendAssertion(fr::FriendAssertion), FriendDecline([u8; 16]), Share(ShareCargo), SpaceInvite(SpaceInvite), SpaceKey(SpaceKeyCargo), ProposalPing(ProposalPing), SpaceJoin(SpaceJoin), SpaceLeave(SpaceLeave), } /// The answer to an invitation: "here is the key I will wear in your Space." /// The room key is what lands on the published roster; `proof` is the joiner's /// PERSONA signature over it, and it stops at the owner — publishing it would /// tie the two together again and undo the point. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceJoin { pub space_id: [u8; 16], pub owner: [u8; 32], pub room_key: [u8; 32], pub proof: Vec, } /// "I am giving up my seat." The owner cannot see a member walk away — the /// roster is theirs to publish — so leaving says so, sealed. `worn` is the key /// on the roster, and `proof` is signed BY that key: holding the seat is the /// whole authority to vacate it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceLeave { pub space_id: [u8; 16], pub owner: [u8; 32], pub worn: [u8; 32], pub proof: Vec, } /// "Someone put a proposal on your playlist's board." Carries only enough to /// name it in a toast — the board itself stays the truth, and this is a /// best-effort nudge: the proposal stands whether or not the ping lands. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProposalPing { pub playlist_uuid: [u8; 16], pub name: String, pub proposal_id: [u8; 16], } /// "You are on the roster now, here is where the Space is" — and, for a /// private Space, the epoch's key. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceInvite { pub space_id: [u8; 16], pub owner: [u8; 32], pub name: String, pub private: bool, pub epoch: u64, pub key: Option<[u8; 32]>, } /// A rotated space key for members who stay after a removal. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceKeyCargo { pub space_id: [u8; 16], pub owner: [u8; 32], pub epoch: u64, pub key: [u8; 32], } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShareCargo { pub owner: [u8; 32], pub playlist_uuid: [u8; 16], pub name: String, pub entry_count: u32, } #[derive(Default, Serialize, Deserialize)] struct Ledger { /// Sequence of the descriptor we last published (0 = never). #[serde(default)] desc_seq: u64, #[serde(default)] box_key_id: u32, /// Envelope ids already acted on (b58) — pruned on the next check. #[serde(default)] handled: Vec, #[serde(default)] prune_seq: u64, #[serde(default)] last_check: u64, #[serde(default)] quarantined: u64, } #[derive(Debug, Serialize)] pub struct InboxStatus { pub published: bool, pub descriptor_seq: u64, pub box_key_id: u32, pub last_check: u64, pub handled_count: usize, pub quarantined: u64, } fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn rand32() -> [u8; 32] { use rand::RngCore; let mut n = [0u8; 32]; rand::thread_rng().fill_bytes(&mut n); n } fn rand16() -> [u8; 16] { use rand::RngCore; let mut n = [0u8; 16]; rand::thread_rng().fill_bytes(&mut n); n } fn b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } pub(crate) fn key_of(b58s: &str) -> Result<[u8; 32], String> { bs58::decode(b58s) .into_vec() .map_err(|e| format!("bad key: {e}"))? .as_slice() .try_into() .map_err(|_| "key is not 32 bytes".to_string()) } fn dir(app: &tauri::AppHandle) -> Result { Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet")) } /// Both the file the ledger lives in and the key its write lock is taken on. const LEDGER_FILE: &str = "inbox-ledger.json"; fn load(app: &tauri::AppHandle) -> Ledger { dir(app) .ok() .and_then(|d| std::fs::read(d.join(LEDGER_FILE)).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { let d = dir(app)?; std::fs::create_dir_all(&d).map_err(|e| e.to_string())?; std::fs::write(d.join(LEDGER_FILE), serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?) .map_err(|e| e.to_string()) } /// The box secret, minted on first use. 32 raw bytes in the app folder — /// see the custody note above. fn box_secret(app: &tauri::AppHandle, mint: bool) -> Result, String> { let p = dir(app)?.join("inbox-box.key"); if let Ok(b) = std::fs::read(&p) { if b.len() == 32 { return Ok(Some(b.as_slice().try_into().unwrap())); } } if !mint { return Ok(None); } let s = rand32(); std::fs::create_dir_all(p.parent().unwrap()).map_err(|e| e.to_string())?; std::fs::write(&p, s).map_err(|e| format!("could not keep the box key: {e}"))?; Ok(Some(s)) } async fn own_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { let adapter = app.state::().freenet.clone(); let info = persona::current(&adapter) .await? .ok_or("create a SoulStream identity first")?; key_of(&info.public_key) } pub fn status(app: &tauri::AppHandle) -> InboxStatus { let l = load(app); InboxStatus { published: l.desc_seq > 0, descriptor_seq: l.desc_seq, box_key_id: l.box_key_id, last_check: l.last_check, handled_count: l.handled.len(), quarantined: l.quarantined, } } // ---------------------------------------------------------------- descriptor /// Publish (or re-publish) how to reach this persona. Minting the box key /// and publishing the descriptor is what makes the inbox exist. pub async fn publish(app: &tauri::AppHandle) -> Result { let _ledger = ledger::hold(LEDGER_FILE).await; let adapter = app.state::().freenet.clone(); let owner = own_key(app).await?; // Whether we already had a box key, read before minting one. let had_key = box_secret(app, false)?.is_some(); let secret = box_secret(app, true)?.expect("minted"); let mut l = load(app); // A box key with no sequence is the signature of a ledger that lost a // write: the key is minted inside this function, just before the put. Ask // the network what it holds rather than trusting the zero, or we would // re-put an existing contract at a sequence it already has and the // descriptor would never move again. let published = if l.desc_seq == 0 && had_key { published_descriptor(app, &owner).await? } else { None }; if let Some(p) = &published { log::warn!( "inbox: ledger said never published but the network holds sequence {} — bumping from there", p.sequence ); } let net_seq = published.as_ref().map(|p| p.sequence).unwrap_or(0); let seq = l.desc_seq.max(net_seq) + 1; let first = seq == 1; let key_id = match (l.box_key_id, published.as_ref().map(|p| p.box_key_id)) { (0, Some(id)) if id > 0 => id, (0, _) => 1, (id, _) => id, }; let mut d = ib::InboxDescriptor { schema_version: ib::SCHEMA_VERSION, owner, sequence: seq, inbox_family: ib::INBOX_PARAMS_FAMILY.to_vec(), box_public_key: ib::box_public(&secret), box_key_id: key_id, max_envelope_bytes: ib::MAX_CIPHERTEXT as u32, admission_policy: vec!["known-signed".into()], sig: vec![], }; d.sig = persona::sign(&adapter, ib::DESC_DOMAIN, ib::sign_payload(&ib::desc_sig_body(&d))).await?; let bytes = bincode::serialize(&d).map_err(|e| e.to_string())?; let params = ib::make_desc_params(&owner); if first { adapter .contract_put(DESC_WASM.to_vec(), ¶ms, bytes, false) .await .map(|_| ()) } else { adapter.contract_update(DESC_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) } .map_err(|e| e.to_string())?; l.desc_seq = seq; l.box_key_id = key_id; save(app, &l)?; // Our own inbox contract must exist for anyone to write into it. let empty = bincode::serialize(&ib::InboxState { schema_version: ib::SCHEMA_VERSION, owner, envelopes: vec![], prune: None, }) .map_err(|e| e.to_string())?; let _ = adapter .contract_put(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner), empty, false) .await; log::info!("inbox: descriptor published (seq {seq}, box key {key_id})"); Ok(status(app)) } struct DescCache { at: Instant, desc: Option, } static DESC_CACHE: OnceLock>> = OnceLock::new(); /// Resolve someone's descriptor through the network. None = unreachable /// through Freenet (no descriptor, or one that does not verify). pub async fn resolve(app: &tauri::AppHandle, key: &[u8; 32]) -> Option { let cache = DESC_CACHE.get_or_init(Default::default); if let Some(c) = cache.lock().unwrap().get(key) { if c.at.elapsed() < DESC_CACHE_TTL { return c.desc.clone(); } } let adapter = app.state::().freenet.clone(); let ids = FreenetAdapter::contract_ids(DESC_WASM.to_vec(), &ib::make_desc_params(key)); let desc = match adapter.contract_get(&ids.instance, false).await { Ok(bytes) => ib::validate_descriptor(key, &bytes), Err(_) => None, }; cache .lock() .unwrap() .insert(*key, DescCache { at: Instant::now(), desc: desc.clone() }); desc } /// Our own descriptor exactly as the network holds it. Deliberately skips the /// resolve cache: the whole point is to catch a ledger that lost a write. async fn published_descriptor( app: &tauri::AppHandle, owner: &[u8; 32], ) -> Result, String> { let adapter = app.state::().freenet.clone(); let ids = FreenetAdapter::contract_ids(DESC_WASM.to_vec(), &ib::make_desc_params(owner)); match adapter.contract_get(&ids.instance, false).await { Ok(bytes) => Ok(ib::validate_descriptor(owner, &bytes)), // The contract genuinely is not out there: this really is a first put. Err(e) if e.to_string().contains("not found") => Ok(None), // Anything else means we could not ask. Refusing is the module rule — // guessing "nothing" here re-puts an existing descriptor at a sequence // the network already holds, and it would never move again. Err(e) => Err(format!("could not read the published descriptor: {e}")), } } /// True when we can reach `key` through Freenet right now. pub async fn reachable(app: &tauri::AppHandle, key: &[u8; 32]) -> bool { resolve(app, key).await.is_some() } // ---------------------------------------------------------------- send /// Seal cargo to a persona and offer it to their inbox. Errors when they /// have no descriptor — the caller falls back to the Soulseek carry. pub async fn send(app: &tauri::AppHandle, to: &[u8; 32], cargo: &Cargo) -> Result<(), String> { let desc = resolve(app, to) .await .ok_or("they have not published an inbox — unreachable through Freenet")?; let adapter = app.state::().freenet.clone(); let me = own_key(app).await?; let payload = bincode::serialize(cargo).map_err(|e| e.to_string())?; if payload.len() > desc.max_envelope_bytes as usize { return Err("cargo is over their envelope cap".into()); } let now = now_secs(); let mut e = ib::Envelope { envelope_id: rand16(), recipient: *to, sender: ib::SenderClass::Known(me), created_at: now, expires_at: now + ENVELOPE_TTL_SECS, admission: vec![], sealed: vec![], sender_sig: vec![], }; let header = ib::envelope_header_bytes(&e); e.sealed = ib::seal(&desc.box_public_key, &e.envelope_id, &header, &payload, rand32()); e.sender_sig = persona::sign(&adapter, ib::ENVELOPE_DOMAIN, ib::sign_payload(&header)).await?; let offer = bincode::serialize(&ib::InboxState { schema_version: ib::SCHEMA_VERSION, owner: *to, envelopes: vec![e], prune: None, }) .map_err(|e| e.to_string())?; adapter .get_then_update(INBOX_WASM.to_vec(), &ib::make_inbox_params(to), offer) .await .map_err(|e| e.to_string())?; log::info!("inbox: sealed cargo offered to {}", b58(to)); Ok(()) } // ---------------------------------------------------------------- receive /// What one check found. #[derive(Debug, Serialize)] pub struct CheckResult { pub new_items: usize, pub quarantined: usize, pub pruned: usize, } /// Read our inbox, open what we can, act on it, prune what we handled. pub async fn check(app: &tauri::AppHandle) -> Result { let _ledger = ledger::hold(LEDGER_FILE).await; let adapter = app.state::().freenet.clone(); let owner = own_key(app).await?; let Some(secret) = box_secret(app, false)? else { return Err("no inbox published yet".into()); }; let ids = FreenetAdapter::contract_ids(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner)); let bytes = match adapter.contract_get(&ids.instance, false).await { Ok(b) => b, Err(e) if e.to_string().contains("not found") => Vec::new(), Err(e) => return Err(e.to_string()), }; let state = ib::merge_inbox(&owner, &[&bytes]); let mut l = load(app); let handled: BTreeSet = l.handled.iter().cloned().collect(); let mut newly: Vec<[u8; 16]> = Vec::new(); let mut new_items = 0usize; let mut quarantined = 0usize; for e in &state.envelopes { let id = b58(&e.envelope_id); if handled.contains(&id) { continue; } let ib::SenderClass::Known(sender) = e.sender else { // Anonymous admission is not offered by our descriptor; anything // that arrives that way is dropped. newly.push(e.envelope_id); quarantined += 1; continue; }; match ib::open(&secret, e).and_then(|b| bincode::deserialize::(&b).ok()) { Some(cargo) => { dispatch(app, sender, cargo).await; new_items += 1; } None => quarantined += 1, } newly.push(e.envelope_id); } let pruned = newly.len(); if !newly.is_empty() { // Read-and-bump: the record's seq must beat whatever the network holds. let seq = state.prune.as_ref().map(|p| p.seq).unwrap_or(0).max(l.prune_seq) + 1; let mut ids_all: Vec<[u8; 16]> = state .prune .as_ref() .map(|p| p.removed_ids.clone()) .unwrap_or_default(); ids_all.extend(newly.iter().copied()); if ids_all.len() > ib::MAX_TOMBSTONES { ids_all.drain(..ids_all.len() - ib::MAX_TOMBSTONES); } let mut rec = ib::PruneRecord { seq, removed_ids: ids_all, floor_expiry: now_secs().saturating_sub(1), sig: vec![], }; rec.sig = persona::sign(&adapter, ib::ENVELOPE_DOMAIN, ib::sign_payload(&ib::prune_sig_body(&rec))).await?; let st = bincode::serialize(&ib::InboxState { schema_version: ib::SCHEMA_VERSION, owner, envelopes: vec![], prune: Some(rec), }) .map_err(|e| e.to_string())?; if let Err(e) = adapter .get_then_update(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner), st) .await { log::warn!("inbox: prune not applied ({e}) — handled ids stay in the ledger"); } else { l.prune_seq = seq; } for id in &newly { l.handled.push(b58(id)); } if l.handled.len() > 2048 { l.handled.drain(..l.handled.len() - 2048); } } l.last_check = now_secs(); l.quarantined += quarantined as u64; save(app, &l)?; let _ = app.emit(EVENT, new_items); Ok(CheckResult { new_items, quarantined, pruned }) } async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) { match cargo { Cargo::FriendRequest(r) => crate::friends::on_inbox_request(app, sender, r).await, Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await, Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id).await, Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s).await, Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i).await, Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k).await, Cargo::ProposalPing(p) => crate::proposals::on_inbox_proposal(app, sender, p), Cargo::SpaceJoin(j) => crate::spaces::on_inbox_join(app, sender, j).await, Cargo::SpaceLeave(l) => crate::spaces::on_inbox_leave(app, sender, l).await, } } // ---------------------------------------------------------------- commands #[tauri::command] pub fn inbox_status(app: tauri::AppHandle) -> InboxStatus { status(&app) } #[tauri::command] pub async fn inbox_publish(app: tauri::AppHandle) -> Result { publish(&app).await } #[tauri::command] pub async fn inbox_check(app: tauri::AppHandle) -> Result { check(&app).await } #[cfg(test)] mod tests { use super::*; #[test] fn contract_generations_are_pinned() { let owner = [7u8; 32]; let inbox = FreenetAdapter::contract_ids(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner)); let desc = FreenetAdapter::contract_ids(DESC_WASM.to_vec(), &ib::make_desc_params(&owner)); assert_eq!(inbox.code_hash, "UjK4sLZqGSE2qEXwQYMvNFsTiuLSYjMJuwzVbhAtXKR"); assert_eq!(desc.code_hash, "B4A2nMJgSKzgo72gaDJyTpFNTmjBynLFga3EiNoSVstv"); } /// The wire is the variant ORDER: bincode writes the index, so inserting a /// variant renumbers every one after it and silently breaks every sealed /// message already in flight. New cargo goes on the END, and this pins it. #[test] fn cargo_variant_indices_are_the_wire() { let index = |c: &Cargo| { let b = bincode::serialize(c).unwrap(); u32::from_le_bytes(b[..4].try_into().unwrap()) }; let share = Cargo::Share(ShareCargo { owner: [1u8; 32], playlist_uuid: [2u8; 16], name: "n".into(), entry_count: 1, }); let key = Cargo::SpaceKey(SpaceKeyCargo { space_id: [3u8; 16], owner: [4u8; 32], epoch: 9, key: [5u8; 32], }); let ping = Cargo::ProposalPing(ProposalPing { playlist_uuid: [6u8; 16], name: "Late Night Ambient".into(), proposal_id: [7u8; 16], }); assert_eq!(index(&share), 3, "Share moved — sealed shares in flight are now unreadable"); assert_eq!(index(&key), 5, "SpaceKey moved — sealed keys in flight are now unreadable"); let join = Cargo::SpaceJoin(SpaceJoin { space_id: [8u8; 16], owner: [9u8; 32], room_key: [10u8; 32], proof: vec![1, 2, 3], }); let leave = Cargo::SpaceLeave(SpaceLeave { space_id: [11u8; 16], owner: [12u8; 32], worn: [13u8; 32], proof: vec![4, 5, 6], }); assert_eq!(index(&ping), 6, "a new variant must be appended, never inserted"); assert_eq!(index(&join), 7, "a new variant must be appended, never inserted"); assert_eq!(index(&leave), 8, "a new variant must be appended, never inserted"); let back: Cargo = bincode::deserialize(&bincode::serialize(&ping).unwrap()).unwrap(); match back { Cargo::ProposalPing(p) => { assert_eq!(p.name, "Late Night Ambient"); assert_eq!(p.proposal_id, [7u8; 16]); } other => panic!("round-tripped into {other:?}"), } } }