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:
KeelrLeek committed 2026-09-02 15:26:30 -05:00
1 parent 13c44f731a
commit 4378c4c0cc
14 files changed
+1387 -145

No files matched your search

Binary file not shown.
Binary file not shown.
+193 -122
View File
@@ -4,18 +4,21 @@
//! shown as exactly what it is. Proofs stay on this device; nothing is
//! published.
//!
//! Carry today: the same Soulseek private-message channel the link proof
//! uses (the encrypted inbox of F5 takes over later). The server can see
//! that one account wrote to another; it cannot forge who wrote, which is
//! the one thing these messages need from it.
//! Two carries, chosen per relationship and remembered: the ENCRYPTED
//! INBOX (inbox.rs — sealed to the recipient's box key, offered to their
//! contract) whenever the other persona has published one, else the
//! Soulseek private-message channel the link proof uses. The server can
//! see that one account wrote to another on the PM carry; it cannot forge
//! who wrote, which is the one thing that carry needs from it.
//!
//! Wire: `SSFRIEND1?<b64 request>` asks, `SSFRIEND1!<b64 assertion>`
//! PM wire: `SSFRIEND1?<b64 request>` asks, `SSFRIEND1!<b64 assertion>`
//! accepts or revokes, `SSFRIEND1-<b58 relationship id>` declines. An
//! incoming REQUEST is bound to its sender by the link handshake: the
//! request names a persona key, and the sender is asked (over PM, with a
//! fresh nonce) to prove that key is theirs before the request is shown.
//! That ask waits on a PM, so the pump hands friend messages to a task —
//! awaiting inline would block the very loop that delivers the answer.
//! incoming PM REQUEST is bound to its sender by the link handshake (the
//! request names a key; the sender proves it). An inbox request needs no
//! such step: the envelope header is signed by the same key the request
//! names. That link ask waits on a PM, so PM friend messages are handled
//! on their own task — awaiting inline would block the loop that delivers
//! the answer.
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -26,6 +29,7 @@ use persona_state::slsk_link::normalize_username;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::inbox::{self, Cargo};
use crate::persona;
use crate::slsk_link;
use crate::state::AppState;
@@ -33,13 +37,17 @@ use crate::state::AppState;
const REQ_PREFIX: &str = "SSFRIEND1?";
const ASSERT_PREFIX: &str = "SSFRIEND1!";
const DECLINE_PREFIX: &str = "SSFRIEND1-";
/// A request stays open this long.
const REQUEST_TTL_SECS: u64 = 7 * 24 * 3600;
pub const EVENT: &str = "community:friends";
fn carry_pm() -> String {
"pm".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Relationship {
/// Their Soulseek account as the server spelled it when it last spoke.
/// Their Soulseek account as the server spelled it — may be empty for a
/// relationship that only ever travelled the inbox.
username: String,
username_normalized: String,
their_key: String,
@@ -48,11 +56,13 @@ struct Relationship {
request_from_me: bool,
#[serde(default)]
assertions: Vec<fr::FriendAssertion>,
/// Local-only stances the verifier has no signature for.
#[serde(default)]
declined_by_me: bool,
#[serde(default)]
declined_by_them: bool,
/// "inbox" | "pm" — how the last message travelled; replies follow it.
#[serde(default = "carry_pm")]
carry: String,
created_at: u64,
updated_at: u64,
}
@@ -63,7 +73,6 @@ struct Store {
relationships: BTreeMap<String, Relationship>,
}
/// What the UI sees: one row per relationship, status from the verifier.
#[derive(Debug, Serialize)]
pub struct RelationshipView {
pub id: String,
@@ -74,6 +83,7 @@ pub struct RelationshipView {
/// friends | pending-out | pending-in | accepted-waiting | declined |
/// revoked | expired | reacceptance-required
pub state: String,
pub carry: String,
pub since: u64,
pub updated_at: u64,
pub expires_at: u64,
@@ -95,12 +105,7 @@ fn b58(bytes: &[u8]) -> String {
bs58::encode(bytes).into_string()
}
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())
inbox::key_of(b58s)
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
@@ -178,6 +183,7 @@ fn view_of(id: &str, r: &Relationship, now: u64) -> RelationshipView {
persona_key: r.their_key.clone(),
direction: if r.request_from_me { "out" } else { "in" },
state: state.to_string(),
carry: r.carry.clone(),
since,
updated_at: r.updated_at,
expires_at: r.request.expires_at,
@@ -192,6 +198,9 @@ async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
key_of(&info.public_key)
}
async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> {
if to.is_empty() {
return Err("no Soulseek account is known for them".into());
}
let st = app.state::<AppState>();
let guard = st.client.lock().await;
guard
@@ -211,21 +220,52 @@ fn decode<T: for<'de> Deserialize<'de>>(b64: &str) -> Option<T> {
bincode::deserialize(&bytes).ok()
}
/// Deliver by the relationship's carry; an inbox that went unreachable
/// falls back to the PM carry when an account is known.
async fn deliver(app: &tauri::AppHandle, rel: &mut Relationship, cargo: Cargo, pm_text: String) -> Result<(), String> {
if rel.carry == "inbox" {
match key_of(&rel.their_key) {
Ok(k) => match inbox::send(app, &k, &cargo).await {
Ok(()) => return Ok(()),
Err(e) => log::info!("friends: inbox carry failed ({e}); trying the Soulseek carry"),
},
Err(e) => return Err(e),
}
}
send_pm(app, &rel.username, &pm_text).await?;
rel.carry = "pm".into();
Ok(())
}
async fn sign_assertion(app: &tauri::AppHandle, mut a: fr::FriendAssertion) -> Result<fr::FriendAssertion, String> {
let adapter = app.state::<AppState>().freenet.clone();
a.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::assertion_sign_payload(&a)).await?;
Ok(a)
}
fn assertion_for(rel: &Relationship, me: [u8; 32], their: [u8; 32], accepted: bool, now: u64) -> fr::FriendAssertion {
fr::FriendAssertion {
relationship_id: rel.request.relationship_id,
signer: me,
peer: their,
request_hash: fr::request_hash(&rel.request),
accepted,
seq: now,
sig: vec![],
}
}
// ---------------------------------------------------------------- outgoing
/// Ask `username` to be friends. Needs their verified link (the drawer's
/// "Ask for their SoulStream ID") — the request is addressed to a KEY.
pub async fn request(app: &tauri::AppHandle, username: &str) -> Result<RelationshipView, String> {
async fn start_request(
app: &tauri::AppHandle,
their: [u8; 32],
username: String,
) -> Result<RelationshipView, String> {
let me = my_key(app).await?;
let link = slsk_link::known_link(app, username)
.ok_or("verify their SoulStream ID first (the drawer's Ask button)")?;
let their = key_of(&link.persona_key)?;
if their == me {
return Err("that is your own identity".into());
}
let now = now_secs();
let mut req = fr::FriendRequest {
relationship_id: nonce16(),
@@ -239,29 +279,47 @@ pub async fn request(app: &tauri::AppHandle, username: &str) -> Result<Relations
let adapter = app.state::<AppState>().freenet.clone();
req.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::request_sign_payload(&req)).await?;
let id = b58(&req.relationship_id);
let rel = Relationship {
username: link.username.clone(),
username_normalized: link.username_normalized.clone(),
their_key: link.persona_key.clone(),
let mut rel = Relationship {
username_normalized: normalize_username(&username),
username,
their_key: b58(&their),
my_key: b58(&me),
request: req.clone(),
request_from_me: true,
assertions: vec![],
declined_by_me: false,
declined_by_them: false,
carry: if inbox::reachable(app, &their).await { "inbox".into() } else { "pm".into() },
created_at: now,
updated_at: now,
};
send_pm(app, &link.username, &encode(REQ_PREFIX, &req)?).await?;
deliver(app, &mut rel, Cargo::FriendRequest(req.clone()), encode(REQ_PREFIX, &req)?).await?;
let mut store = load(app);
store.relationships.insert(id.clone(), rel);
save(app, &store)?;
log::info!("friends: request sent to {}", link.username);
Ok(view_of(&id, &store.relationships[&id], now))
let rel = &store.relationships[&id];
log::info!("friends: request sent to {} via {}", if rel.username.is_empty() { &rel.their_key } else { &rel.username }, rel.carry);
Ok(view_of(&id, rel, now))
}
/// Ask a Soulseek account to be friends. Needs their verified link — the
/// request is addressed to a KEY.
pub async fn request(app: &tauri::AppHandle, username: &str) -> Result<RelationshipView, String> {
let link = slsk_link::known_link(app, username)
.ok_or("verify their SoulStream ID first (the drawer's Ask button)")?;
start_request(app, key_of(&link.persona_key)?, link.username.clone()).await
}
/// Ask a SoulStream ID directly — only through their inbox.
pub async fn request_by_key(app: &tauri::AppHandle, persona_key: &str) -> Result<RelationshipView, String> {
let their = key_of(persona_key)?;
if !inbox::reachable(app, &their).await {
return Err("they have not published an inbox — reachable only through a Soulseek account".into());
}
let username = slsk_link::username_for_key(app, persona_key).unwrap_or_default();
start_request(app, their, username).await
}
/// Accept an incoming request: sign our assertion, send it, remember it.
/// Friendship is still one-sided until their reciprocal assertion arrives.
pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
let me = my_key(app).await?;
let mut store = load(app);
@@ -271,20 +329,8 @@ pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView
}
let their = key_of(&rel.their_key)?;
let now = now_secs();
let a = sign_assertion(
app,
fr::FriendAssertion {
relationship_id: rel.request.relationship_id,
signer: me,
peer: their,
request_hash: fr::request_hash(&rel.request),
accepted: true,
seq: now,
sig: vec![],
},
)
.await?;
send_pm(app, &rel.username, &encode(ASSERT_PREFIX, &a)?).await?;
let a = sign_assertion(app, assertion_for(rel, me, their, true, now)).await?;
deliver(app, rel, Cargo::FriendAssertion(a.clone()), encode(ASSERT_PREFIX, &a)?).await?;
rel.assertions.push(a);
rel.declined_by_me = false;
rel.updated_at = now;
@@ -299,7 +345,7 @@ pub async fn decline(app: &tauri::AppHandle, id: &str) -> Result<RelationshipVie
let now = now_secs();
rel.declined_by_me = true;
rel.updated_at = now;
let _ = send_pm(app, &rel.username, &format!("{DECLINE_PREFIX}{id}")).await;
let _ = deliver(app, rel, Cargo::FriendDecline(rel.request.relationship_id), format!("{DECLINE_PREFIX}{id}")).await;
let v = view_of(id, rel, now);
save(app, &store)?;
Ok(v)
@@ -313,20 +359,8 @@ pub async fn remove(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView
let rel = store.relationships.get_mut(id).ok_or("no such relationship")?;
let their = key_of(&rel.their_key)?;
let now = now_secs();
let a = sign_assertion(
app,
fr::FriendAssertion {
relationship_id: rel.request.relationship_id,
signer: me,
peer: their,
request_hash: fr::request_hash(&rel.request),
accepted: false,
seq: now,
sig: vec![],
},
)
.await?;
let _ = send_pm(app, &rel.username, &encode(ASSERT_PREFIX, &a)?).await;
let a = sign_assertion(app, assertion_for(rel, me, their, false, now)).await?;
let _ = deliver(app, rel, Cargo::FriendAssertion(a.clone()), encode(ASSERT_PREFIX, &a)?).await;
rel.assertions.push(a);
rel.updated_at = now;
let v = view_of(id, rel, now);
@@ -354,87 +388,112 @@ pub fn list(app: &tauri::AppHandle) -> Vec<RelationshipView> {
// ---------------------------------------------------------------- incoming
async fn on_request(app: tauri::AppHandle, from: String, req: fr::FriendRequest) {
/// Who a message came from, as far as its carry can vouch.
enum From {
/// A Soulseek account the server attributed.
Pm(String),
/// A persona key that signed the envelope header.
Inbox([u8; 32]),
}
async fn store_request(app: &tauri::AppHandle, from: From, req: fr::FriendRequest) {
if !fr::verify_request(&req) {
log::warn!("friends: request from {from} does not verify");
log::warn!("friends: a request does not verify");
return;
}
let Ok(me) = my_key(&app).await else {
log::debug!("friends: request from {from}, but no persona here to receive it");
let Ok(me) = my_key(app).await else {
log::debug!("friends: a request arrived, but no persona is here to receive it");
return;
};
if req.recipient != me {
log::debug!("friends: request from {from} is addressed to another persona");
return;
}
let now = now_secs();
if now > req.expires_at {
return;
}
// The request names a key; the sender must prove it is theirs.
let bound = match slsk_link::known_link(&app, &from) {
Some(l) if l.persona_key == b58(&req.sender) => Some(l),
_ => match slsk_link::ask(&app, &from).await {
Ok(l) if l.persona_key == b58(&req.sender) => {
slsk_link::remember(&app, l.clone());
Some(l)
// Bind the request's key to whoever sent it.
let (username, carry) = match from {
From::Inbox(sender) => {
if sender != req.sender {
log::warn!("friends: an inbox request names a key other than its envelope's signer");
return;
}
Ok(_) => {
log::warn!("friends: {from} proved a different key than the request names");
None
}
Err(e) => {
log::warn!("friends: could not bind {from} to the request's key: {e}");
None
}
},
(slsk_link::username_for_key(app, &b58(&sender)).unwrap_or_default(), "inbox")
}
From::Pm(from) => {
let bound = match slsk_link::known_link(app, &from) {
Some(l) if l.persona_key == b58(&req.sender) => Some(l),
_ => match slsk_link::ask(app, &from).await {
Ok(l) if l.persona_key == b58(&req.sender) => {
slsk_link::remember(app, l.clone());
Some(l)
}
Ok(_) => {
log::warn!("friends: {from} proved a different key than the request names");
None
}
Err(e) => {
log::warn!("friends: could not bind {from} to the request's key: {e}");
None
}
},
};
let Some(link) = bound else { return };
(link.username, "pm")
}
};
let Some(link) = bound else { return };
let id = b58(&req.relationship_id);
let mut store = load(&app);
let mut store = load(app);
if store.relationships.contains_key(&id) {
return;
}
let who = if username.is_empty() { b58(&req.sender) } else { username.clone() };
store.relationships.insert(
id,
Relationship {
username: link.username,
username_normalized: link.username_normalized,
their_key: link.persona_key,
username_normalized: normalize_username(&username),
username,
their_key: b58(&req.sender),
my_key: b58(&me),
request: req,
request_from_me: false,
assertions: vec![],
declined_by_me: false,
declined_by_them: false,
carry: carry.into(),
created_at: now,
updated_at: now,
},
);
if let Err(e) = save(&app, &store) {
log::warn!("friends: could not store the request from {from}: {e}");
if let Err(e) = save(app, &store) {
log::warn!("friends: could not store the request from {who}: {e}");
} else {
log::info!("friends: request from {from} verified and waiting");
log::info!("friends: request from {who} verified and waiting (via {carry})");
}
}
async fn on_assertion(app: tauri::AppHandle, from: String, a: fr::FriendAssertion) {
async fn apply_assertion(app: &tauri::AppHandle, from: From, a: fr::FriendAssertion) {
if !fr::verify_assertion(&a) {
log::warn!("friends: assertion from {from} does not verify");
log::warn!("friends: an assertion does not verify");
return;
}
let id = b58(&a.relationship_id);
let mut store = load(&app);
let mut store = load(app);
let Some(rel) = store.relationships.get_mut(&id) else {
log::debug!("friends: assertion from {from} for an unknown relationship");
log::debug!("friends: assertion for an unknown relationship");
return;
};
if normalize_username(&from) != rel.username_normalized
let sender_ok = match &from {
From::Pm(u) => normalize_username(u) == rel.username_normalized,
From::Inbox(k) => b58(k) == rel.their_key,
};
if !sender_ok
|| b58(&a.signer) != rel.their_key
|| b58(&a.peer) != rel.my_key
|| a.request_hash != fr::request_hash(&rel.request)
{
log::warn!("friends: assertion from {from} does not belong to its relationship");
log::warn!("friends: an assertion does not belong to its relationship");
return;
}
if rel
@@ -447,27 +506,17 @@ async fn on_assertion(app: tauri::AppHandle, from: String, a: fr::FriendAssertio
let now = now_secs();
rel.assertions.push(a.clone());
rel.updated_at = now;
if let From::Inbox(_) = from {
rel.carry = "inbox".into();
}
// Their acceptance of OUR request completes with our own assertion.
let mine_exists = rel.assertions.iter().any(|x| b58(&x.signer) == rel.my_key);
if a.accepted && rel.request_from_me && !mine_exists {
if let (Ok(me), Ok(their)) = (key_of(&rel.my_key), key_of(&rel.their_key)) {
match sign_assertion(
&app,
fr::FriendAssertion {
relationship_id: rel.request.relationship_id,
signer: me,
peer: their,
request_hash: fr::request_hash(&rel.request),
accepted: true,
seq: now,
sig: vec![],
},
)
.await
{
match sign_assertion(app, assertion_for(rel, me, their, true, now)).await {
Ok(mine) => {
if let Ok(text) = encode(ASSERT_PREFIX, &mine) {
let _ = send_pm(&app, &rel.username, &text).await;
let _ = deliver(app, rel, Cargo::FriendAssertion(mine.clone()), text).await;
}
rel.assertions.push(mine);
}
@@ -476,17 +525,22 @@ async fn on_assertion(app: tauri::AppHandle, from: String, a: fr::FriendAssertio
}
}
let state = view_of(&id, rel, now).state;
if let Err(e) = save(&app, &store) {
log::warn!("friends: could not store the assertion from {from}: {e}");
let who = if rel.username.is_empty() { rel.their_key.clone() } else { rel.username.clone() };
if let Err(e) = save(app, &store) {
log::warn!("friends: could not store an assertion: {e}");
} else {
log::info!("friends: {from} → {state}");
log::info!("friends: {who} → {state}");
}
}
fn on_decline(app: &tauri::AppHandle, from: &str, id: &str) {
fn apply_decline(app: &tauri::AppHandle, from: From, id: &str) {
let mut store = load(app);
let Some(rel) = store.relationships.get_mut(id) else { return };
if normalize_username(from) != rel.username_normalized || !rel.request_from_me {
let sender_ok = match &from {
From::Pm(u) => normalize_username(u) == rel.username_normalized,
From::Inbox(k) => b58(k) == rel.their_key,
};
if !sender_ok || !rel.request_from_me {
return;
}
rel.declined_by_them = true;
@@ -494,6 +548,17 @@ fn on_decline(app: &tauri::AppHandle, from: &str, id: &str) {
let _ = save(app, &store);
}
/// Sealed cargo from inbox.rs: the envelope's signer is the sender.
pub(crate) async fn on_inbox_request(app: &tauri::AppHandle, sender: [u8; 32], req: fr::FriendRequest) {
store_request(app, From::Inbox(sender), req).await;
}
pub(crate) async fn on_inbox_assertion(app: &tauri::AppHandle, sender: [u8; 32], a: fr::FriendAssertion) {
apply_assertion(app, From::Inbox(sender), a).await;
}
pub(crate) fn on_inbox_decline(app: &tauri::AppHandle, sender: [u8; 32], id: [u8; 16]) {
apply_decline(app, From::Inbox(sender), &b58(&id));
}
/// The event pump hands every private message here after the link proof.
/// True means it was ours and must not surface in the Inbox. Requests and
/// assertions are handled on their own task (see the module note).
@@ -501,18 +566,20 @@ pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
let msg = message.trim();
if let Some(rest) = msg.strip_prefix(REQ_PREFIX) {
if let Some(req) = decode::<fr::FriendRequest>(rest) {
tauri::async_runtime::spawn(on_request(app.clone(), from.to_string(), req));
let (app, from) = (app.clone(), from.to_string());
tauri::async_runtime::spawn(async move { store_request(&app, From::Pm(from), req).await });
}
return true;
}
if let Some(rest) = msg.strip_prefix(ASSERT_PREFIX) {
if let Some(a) = decode::<fr::FriendAssertion>(rest) {
tauri::async_runtime::spawn(on_assertion(app.clone(), from.to_string(), a));
let (app, from) = (app.clone(), from.to_string());
tauri::async_runtime::spawn(async move { apply_assertion(&app, From::Pm(from), a).await });
}
return true;
}
if let Some(rest) = msg.strip_prefix(DECLINE_PREFIX) {
on_decline(app, from, rest.trim());
apply_decline(app, From::Pm(from.to_string()), rest.trim());
return true;
}
false
@@ -529,6 +596,10 @@ pub async fn friend_request(app: tauri::AppHandle, username: String) -> Result<R
request(&app, &username).await
}
#[tauri::command]
pub async fn friend_request_by_key(app: tauri::AppHandle, persona_key: String) -> Result<RelationshipView, String> {
request_by_key(&app, &persona_key).await
}
#[tauri::command]
pub async fn friend_accept(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
accept(&app, &id).await
}
+443
View File
@@ -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(), &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
}
/// 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");
}
}
+5
View File
@@ -8,6 +8,7 @@ mod friends;
mod persona;
mod hero_crop;
mod import;
mod inbox;
mod hero_detector;
mod loudness;
mod lyric_align;
@@ -133,6 +134,7 @@ pub fn run() {
slsk_link::slsk_link_forget,
friends::friends_list,
friends::friend_request,
friends::friend_request_by_key,
friends::friend_accept,
friends::friend_decline,
friends::friend_remove,
@@ -145,6 +147,9 @@ pub fn run() {
shared_playlists::share_send,
shared_playlists::shares_inbox,
shared_playlists::share_dismiss,
inbox::inbox_status,
inbox::inbox_publish,
inbox::inbox_check,
commands::buddy_list,
commands::buddy_add,
commands::buddy_remove,
+76 -17
View File
@@ -28,6 +28,7 @@ use persona_state::slsk_link::normalize_username;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use crate::inbox::{self, Cargo, ShareCargo};
use crate::persona;
use crate::pipeline::LibraryState;
use crate::playlist_formats::{ParsedEntry, ParsedPlaylist};
@@ -59,7 +60,14 @@ pub struct Published {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShareCard {
pub id: String,
/// Empty when it arrived sealed from a persona this device has no
/// Soulseek account for; `from_key` is then the only name.
pub from_username: String,
#[serde(default)]
pub from_key: String,
/// "pm" | "inbox" — how it arrived.
#[serde(default = "carry_pm")]
pub carry: String,
pub owner_key: String,
pub playlist_uuid: String,
pub name: String,
@@ -67,6 +75,10 @@ pub struct ShareCard {
pub received_at: u64,
}
fn carry_pm() -> String {
"pm".into()
}
#[derive(Default, Serialize, Deserialize)]
struct Ledger {
#[serde(default)]
@@ -452,6 +464,24 @@ pub async fn send(app: &tauri::AppHandle, username: &str, playlist_id: i64) -> R
name: rec.name.clone(),
entry_count: rec.entry_count,
};
// Sealed through their inbox when they have one; the Soulseek carry otherwise.
if let Some(link) = crate::slsk_link::known_link(app, username) {
if let Ok(their) = bytes32(&link.persona_key) {
let cargo = Cargo::Share(ShareCargo {
owner,
playlist_uuid: r.playlist_uuid,
name: r.name.clone(),
entry_count: r.entry_count,
});
match inbox::send(app, &their, &cargo).await {
Ok(()) => {
log::info!("shared playlists: sent {:?} to {username} via inbox", rec.name);
return Ok(());
}
Err(e) => log::info!("shared playlists: inbox carry failed ({e}); trying the Soulseek carry"),
}
}
}
use base64::Engine;
let bytes = bincode::serialize(&r).map_err(|e| e.to_string())?;
let text = format!("{SHARE_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(bytes));
@@ -460,6 +490,38 @@ pub async fn send(app: &tauri::AppHandle, username: &str, playlist_id: i64) -> R
Ok(())
}
fn keep_card(app: &tauri::AppHandle, card: ShareCard) {
let who = if card.from_username.is_empty() { card.from_key.clone() } else { card.from_username.clone() };
let mut ledger = load(app);
ledger.inbox.retain(|c| c.id != card.id);
ledger.inbox.push(card);
if let Err(e) = save(app, &ledger) {
log::warn!("shared playlists: could not keep the share from {who}: {e}");
} else {
log::info!("shared playlists: share received from {who}");
}
}
/// Sealed cargo from inbox.rs: the envelope's signer is the sender.
pub(crate) fn on_inbox_share(app: &tauri::AppHandle, sender: [u8; 32], s: ShareCargo) {
let from_key = b58(&sender);
let from_username = crate::slsk_link::username_for_key(app, &from_key).unwrap_or_default();
keep_card(
app,
ShareCard {
id: format!("{}:{}:{}", from_key, b58(&s.owner), b58(&s.playlist_uuid)),
from_username,
from_key,
carry: "inbox".into(),
owner_key: b58(&s.owner),
playlist_uuid: b58(&s.playlist_uuid),
name: cap(&s.name, pl::MAX_NAME_BYTES),
entry_count: s.entry_count,
received_at: now_secs(),
},
);
}
/// The event pump hands private messages here after the link and friend
/// handshakes. True means it was a share reference, now a card in the Inbox.
pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
@@ -467,23 +529,20 @@ pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
use base64::Engine;
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(rest.trim()) else { return true };
let Ok(r) = bincode::deserialize::<ShareRef>(&bytes) else { return true };
let card = ShareCard {
id: format!("{}:{}:{}", normalize_username(from), b58(&r.owner), b58(&r.playlist_uuid)),
from_username: from.to_string(),
owner_key: b58(&r.owner),
playlist_uuid: b58(&r.playlist_uuid),
name: cap(&r.name, pl::MAX_NAME_BYTES),
entry_count: r.entry_count,
received_at: now_secs(),
};
let mut ledger = load(app);
ledger.inbox.retain(|c| c.id != card.id);
ledger.inbox.push(card);
if let Err(e) = save(app, &ledger) {
log::warn!("shared playlists: could not keep the share from {from}: {e}");
} else {
log::info!("shared playlists: share received from {from}");
}
keep_card(
app,
ShareCard {
id: format!("{}:{}:{}", normalize_username(from), b58(&r.owner), b58(&r.playlist_uuid)),
from_username: from.to_string(),
from_key: String::new(),
carry: "pm".into(),
owner_key: b58(&r.owner),
playlist_uuid: b58(&r.playlist_uuid),
name: cap(&r.name, pl::MAX_NAME_BYTES),
entry_count: r.entry_count,
received_at: now_secs(),
},
);
true
}
+16
View File
@@ -293,6 +293,22 @@ pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) {
let _ = save(app, &links);
}
/// The Soulseek account a verified persona key belongs to, if this device
/// verified one (our own included).
pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option<String> {
let links = load(app);
if let Some(o) = &links.own {
if o.persona_key == persona_key {
return Some(o.username.clone());
}
}
links
.others
.values()
.find(|l| l.persona_key == persona_key)
.map(|l| l.username.clone())
}
// ---------------------------------------------------------------- commands
#[tauri::command]