community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one
This commit is contained in:
1 parent
13c44f731a
commit
4378c4c0cc
14 files changed
+1387
-145
No files matched your search
@@ -0,0 +1,443 @@
|
||||
//! 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::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),
|
||||
}
|
||||
|
||||
#[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(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("freenet"))
|
||||
}
|
||||
fn load(app: &tauri::AppHandle) -> Ledger {
|
||||
dir(app)
|
||||
.ok()
|
||||
.and_then(|d| std::fs::read(d.join("inbox-ledger.json")).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("inbox-ledger.json"), 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 adapter = app.state::<AppState>().freenet.clone();
|
||||
let owner = own_key(app).await?;
|
||||
let secret = box_secret(app, true)?.expect("minted");
|
||||
let mut l = load(app);
|
||||
let first = l.desc_seq == 0;
|
||||
let seq = l.desc_seq + 1;
|
||||
let key_id = if l.box_key_id == 0 { 1 } else { l.box_key_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<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
|
||||
}
|
||||
|
||||
/// 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 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),
|
||||
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user