Files
SoulStream/src-tauri/src/inbox.rs
T

609 lines
22 KiB
Rust

//! 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<u8>,
}
/// "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<u8>,
}
/// "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<String>,
#[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<PathBuf, String> {
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<Option<[u8; 32]>, 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::<AppState>().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<InboxStatus, String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let adapter = app.state::<AppState>().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(), &params, bytes, false)
.await
.map(|_| ())
} else {
adapter.contract_update(DESC_WASM.to_vec(), &params, 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<ib::InboxDescriptor>,
}
static DESC_CACHE: OnceLock<Mutex<HashMap<[u8; 32], DescCache>>> = 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<ib::InboxDescriptor> {
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::<AppState>().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<Option<ib::InboxDescriptor>, String> {
let adapter = app.state::<AppState>().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::<AppState>().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<CheckResult, String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let adapter = app.state::<AppState>().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<String> = 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::<Cargo>(&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<InboxStatus, String> {
publish(&app).await
}
#[tauri::command]
pub async fn inbox_check(app: tauri::AppHandle) -> Result<CheckResult, String> {
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:?}"),
}
}
}