diff --git a/Cargo.lock b/Cargo.lock index f8b6cb0..4b0eb16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -512,6 +522,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -524,6 +558,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + [[package]] name = "cmov" version = "0.5.4" @@ -726,6 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2230,6 +2276,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -3121,6 +3176,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.81" @@ -3236,8 +3297,10 @@ version = "1.6.0-alpha" dependencies = [ "bincode", "blake3", + "chacha20poly1305", "ed25519-dalek", "serde", + "x25519-dalek", ] [[package]] @@ -3367,6 +3430,17 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5653,6 +5727,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6593,6 +6677,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xml-rs" version = "0.8.29" @@ -6677,6 +6773,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/crates/persona-state/Cargo.toml b/crates/persona-state/Cargo.toml index a57de67..e084ae8 100644 --- a/crates/persona-state/Cargo.toml +++ b/crates/persona-state/Cargo.toml @@ -13,6 +13,8 @@ serde.workspace = true bincode = "1" ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } blake3 = "1" +x25519-dalek = { version = "2", features = ["static_secrets"] } +chacha20poly1305 = "0.10" [dev-dependencies] ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/crates/persona-state/src/inbox.rs b/crates/persona-state/src/inbox.rs new file mode 100644 index 0000000..ad3dbeb --- /dev/null +++ b/crates/persona-state/src/inbox.rs @@ -0,0 +1,468 @@ +//! The invitation inbox — F5's exact shapes: recipient-addressed sealed +//! envelopes in a bounded multi-writer contract with a deterministic merge +//! (validate → dedup → caps → tombstones), an owner PruneRecord, and the +//! owner-signed InboxDescriptor that says how to reach a persona. BYTE- +//! COMPATIBILITY IS THE CONTRACT: the bundled inbox / descriptor wasm +//! validate exactly these (built from research/freenet-f5, code hashes +//! pinned by the app's tests). +//! +//! Signing rides `soulstream.friend/v1` — the delegate generation in the +//! app has no inbox domain yet (F5's deferred item); the contracts were +//! built to that. Sealing: X25519 (sender ephemeral → recipient box key) +//! into ChaCha20-Poly1305 with the signed header as AAD. + +use ed25519_dalek::VerifyingKey; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const SCHEMA_VERSION: u32 = 1; +pub const INBOX_PARAMS_FAMILY: &[u8] = b"soulstream.inbox.v1"; +pub const DESC_PARAMS_FAMILY: &[u8] = b"soulstream.inbox-desc.v1"; +pub const ENVELOPE_DOMAIN: &str = "soulstream.friend/v1"; +pub const DESC_DOMAIN: &str = "soulstream.friend/v1"; + +pub const MAX_STATE_BYTES: usize = 1024 * 1024; +pub const MAX_ENVELOPES: usize = 64; +pub const PER_SENDER_CAP: usize = 4; +pub const ANON_POOL_CAP: usize = 8; +pub const MAX_CIPHERTEXT: usize = 8 * 1024; +pub const MAX_AAD: usize = 256; +pub const MAX_LIFETIME_SECS: u64 = 30 * 24 * 3600; +pub const MAX_TOMBSTONES: usize = 256; +pub const ANON_POW_BITS: u32 = 12; + +// ---------------------------------------------------------------- envelope + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SenderClass { + Known([u8; 32]), + Anonymous, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Envelope { + pub envelope_id: [u8; 16], + pub recipient: [u8; 32], + pub sender: SenderClass, + pub created_at: u64, + pub expires_at: u64, + /// Known: empty. Anonymous: a nonce whose blake3(id ‖ nonce) carries + /// ANON_POW_BITS leading zero bits. + pub admission: Vec, + /// Sender ephemeral X25519 public key (32) ‖ ChaCha20-Poly1305 + /// ciphertext of the cargo. + pub sealed: Vec, + /// Known sender's signature over the header (delegate-signed). + pub sender_sig: Vec, +} + +#[derive(Serialize)] +struct EnvelopeHeader<'a> { + envelope_id: &'a [u8; 16], + recipient: &'a [u8; 32], + sender: &'a SenderClass, + created_at: u64, + expires_at: u64, +} + +/// The signed / authenticated header — also the AAD for the seal. +pub fn envelope_header_bytes(e: &Envelope) -> Vec { + bincode::serialize(&EnvelopeHeader { + envelope_id: &e.envelope_id, + recipient: &e.recipient, + sender: &e.sender, + created_at: e.created_at, + expires_at: e.expires_at, + }) + .expect("serializes") +} + +/// What the delegate is handed to sign for a header / prune / descriptor. +pub fn sign_payload(body: &[u8]) -> Vec { + blake3::hash(body).as_bytes().to_vec() +} + +pub fn box_key(shared_secret: &[u8; 32]) -> [u8; 32] { + blake3::derive_key("soulstream.inbox-box.v1", shared_secret) +} +pub fn box_nonce(eph_pub: &[u8; 32], envelope_id: &[u8; 16]) -> [u8; 12] { + let mut input = Vec::with_capacity(48); + input.extend_from_slice(eph_pub); + input.extend_from_slice(envelope_id); + let h = blake3::hash(&input); + h.as_bytes()[..12].try_into().unwrap() +} + +pub fn anon_admission_ok(envelope_id: &[u8; 16], admission: &[u8]) -> bool { + if admission.len() != 16 { + return false; + } + let mut input = Vec::with_capacity(32); + input.extend_from_slice(envelope_id); + input.extend_from_slice(admission); + let h = blake3::hash(&input); + let mut zeros = 0u32; + for b in h.as_bytes() { + if *b == 0 { + zeros += 8; + } else { + zeros += b.leading_zeros(); + break; + } + } + zeros >= ANON_POW_BITS +} + +#[derive(Debug, PartialEq, Eq)] +pub enum EnvelopeInvalid { + AadTooBig(usize), + CiphertextTooBig(usize), + WrongRecipient, + LifetimeInsane, + BadSenderKey, + BadSenderSig, + BadAdmission, + SealedTooShort, +} + +/// Structural + authorization validation, cheapest first. +pub fn validate_envelope(owner: &[u8; 32], e: &Envelope) -> Result<(), EnvelopeInvalid> { + if e.sealed.len() > 32 + MAX_CIPHERTEXT { + return Err(EnvelopeInvalid::CiphertextTooBig(e.sealed.len())); + } + if e.sealed.len() < 32 + 16 { + return Err(EnvelopeInvalid::SealedTooShort); + } + if e.admission.len() > MAX_AAD { + return Err(EnvelopeInvalid::AadTooBig(e.admission.len())); + } + if &e.recipient != owner { + return Err(EnvelopeInvalid::WrongRecipient); + } + if e.expires_at <= e.created_at || e.expires_at - e.created_at > MAX_LIFETIME_SECS { + return Err(EnvelopeInvalid::LifetimeInsane); + } + match &e.sender { + SenderClass::Known(key) => { + if VerifyingKey::from_bytes(key).is_err() { + return Err(EnvelopeInvalid::BadSenderKey); + } + if !crate::verify_sig_in( + ENVELOPE_DOMAIN, + key, + &sign_payload(&envelope_header_bytes(e)), + &e.sender_sig, + ) { + return Err(EnvelopeInvalid::BadSenderSig); + } + } + SenderClass::Anonymous => { + if !anon_admission_ok(&e.envelope_id, &e.admission) { + return Err(EnvelopeInvalid::BadAdmission); + } + } + } + Ok(()) +} + +// ---------------------------------------------------------------- sealing + +/// Seal `cargo` to a recipient's box key. `eph_secret` is 32 fresh random +/// bytes from the caller (this crate draws no entropy of its own). +pub fn seal( + recipient_box_pub: &[u8; 32], + envelope_id: &[u8; 16], + header_aad: &[u8], + cargo: &[u8], + eph_secret: [u8; 32], +) -> Vec { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + let eph = x25519_dalek::StaticSecret::from(eph_secret); + let eph_pub = x25519_dalek::PublicKey::from(&eph); + let shared = eph.diffie_hellman(&x25519_dalek::PublicKey::from(*recipient_box_pub)); + let key = box_key(shared.as_bytes()); + let nonce = box_nonce(eph_pub.as_bytes(), envelope_id); + let ct = ChaCha20Poly1305::new(Key::from_slice(&key)) + .encrypt(Nonce::from_slice(&nonce), Payload { msg: cargo, aad: header_aad }) + .expect("seal"); + let mut out = eph_pub.as_bytes().to_vec(); + out.extend_from_slice(&ct); + out +} + +/// Open an envelope with the recipient's box secret. None = not for this +/// key, or tampered — the caller quarantines, never crashes. +pub fn open(box_secret: &[u8; 32], e: &Envelope) -> Option> { + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; + use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + if e.sealed.len() < 32 { + return None; + } + let eph_pub: [u8; 32] = e.sealed[..32].try_into().ok()?; + let secret = x25519_dalek::StaticSecret::from(*box_secret); + let shared = secret.diffie_hellman(&x25519_dalek::PublicKey::from(eph_pub)); + let key = box_key(shared.as_bytes()); + let nonce = box_nonce(&eph_pub, &e.envelope_id); + ChaCha20Poly1305::new(Key::from_slice(&key)) + .decrypt( + Nonce::from_slice(&nonce), + Payload { msg: &e.sealed[32..], aad: &envelope_header_bytes(e) }, + ) + .ok() +} + +/// The X25519 public key for a box secret. +pub fn box_public(box_secret: &[u8; 32]) -> [u8; 32] { + let s = x25519_dalek::StaticSecret::from(*box_secret); + *x25519_dalek::PublicKey::from(&s).as_bytes() +} + +// ---------------------------------------------------------------- state + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PruneRecord { + pub seq: u64, + pub removed_ids: Vec<[u8; 16]>, + /// Everything with expires_at <= this floor stays out permanently. + pub floor_expiry: u64, + pub sig: Vec, +} + +#[derive(Serialize)] +struct PruneSigBody<'a> { + seq: u64, + removed_ids: &'a [[u8; 16]], + floor_expiry: u64, +} + +pub fn prune_sig_body(p: &PruneRecord) -> Vec { + bincode::serialize(&PruneSigBody { + seq: p.seq, + removed_ids: &p.removed_ids, + floor_expiry: p.floor_expiry, + }) + .expect("serializes") +} + +pub fn prune_valid(owner: &[u8; 32], p: &PruneRecord) -> bool { + p.removed_ids.len() <= MAX_TOMBSTONES + && crate::verify_sig_in(ENVELOPE_DOMAIN, owner, &sign_payload(&prune_sig_body(p)), &p.sig) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InboxState { + pub schema_version: u32, + pub owner: [u8; 32], + pub envelopes: Vec, + pub prune: Option, +} + +pub fn make_inbox_params(owner: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(INBOX_PARAMS_FAMILY.len() + 32); + p.extend_from_slice(INBOX_PARAMS_FAMILY); + p.extend_from_slice(owner); + p +} + +/// Deterministic multi-writer merge. Inputs are RAW candidate states (any +/// mix of replicas and single-envelope offers); output is the bounded +/// canonical state. Mirrors the bundled contract exactly. +pub fn merge_inbox(owner: &[u8; 32], candidates: &[&[u8]]) -> InboxState { + let mut best_prune: Option<(u64, [u8; 32], PruneRecord)> = None; + let mut parsed: Vec = Vec::new(); + for bytes in candidates { + if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES { + continue; + } + let Ok(st) = bincode::deserialize::(bytes) else { continue }; + if st.schema_version != SCHEMA_VERSION || &st.owner != owner { + continue; + } + if let Some(p) = &st.prune { + if prune_valid(owner, p) { + let rank = (p.seq, *blake3::hash(&prune_sig_body(p)).as_bytes()); + let better = match &best_prune { + None => true, + Some((s, h, _)) => rank > (*s, *h), + }; + if better { + best_prune = Some((rank.0, rank.1, p.clone())); + } + } + } + parsed.push(st); + } + let prune = best_prune.map(|(_, _, p)| p); + + let mut by_id: BTreeMap<[u8; 16], (Envelope, [u8; 32])> = BTreeMap::new(); + for st in &parsed { + for e in &st.envelopes { + if validate_envelope(owner, e).is_err() { + continue; + } + if let Some(p) = &prune { + if p.removed_ids.contains(&e.envelope_id) || e.expires_at <= p.floor_expiry { + continue; + } + } + let h = *blake3::hash(&bincode::serialize(e).unwrap()).as_bytes(); + match by_id.get(&e.envelope_id) { + Some((_, existing_h)) if existing_h <= &h => {} + _ => { + by_id.insert(e.envelope_id, (e.clone(), h)); + } + } + } + } + + let mut all: Vec = by_id.into_values().map(|(e, _)| e).collect(); + all.sort_by_key(|e| (e.created_at, e.envelope_id)); + let mut per_sender: BTreeMap<[u8; 32], Vec> = BTreeMap::new(); + let mut anon_pool: Vec = Vec::new(); + for e in all { + match &e.sender { + SenderClass::Known(k) => per_sender.entry(*k).or_default().push(e), + SenderClass::Anonymous => { + if anon_pool.len() < ANON_POOL_CAP { + anon_pool.push(e); + } + } + } + } + let mut survivors: Vec = anon_pool; + for (_, mut list) in per_sender { + let keep_from = list.len().saturating_sub(PER_SENDER_CAP); + survivors.extend(list.drain(keep_from..)); + } + survivors.sort_by_key(|e| (e.created_at, e.envelope_id)); + survivors.truncate(MAX_ENVELOPES); + InboxState { + schema_version: SCHEMA_VERSION, + owner: *owner, + envelopes: survivors, + prune, + } +} + +// ---------------------------------------------------------------- descriptor + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InboxDescriptor { + pub schema_version: u32, + pub owner: [u8; 32], + pub sequence: u64, + pub inbox_family: Vec, + pub box_public_key: [u8; 32], + pub box_key_id: u32, + pub max_envelope_bytes: u32, + pub admission_policy: Vec, + pub sig: Vec, +} + +#[derive(Serialize)] +struct DescSigBody<'a> { + schema_version: u32, + owner: &'a [u8; 32], + sequence: u64, + inbox_family: &'a [u8], + box_public_key: &'a [u8; 32], + box_key_id: u32, + max_envelope_bytes: u32, + admission_policy: &'a [String], +} + +pub fn desc_sig_body(d: &InboxDescriptor) -> Vec { + bincode::serialize(&DescSigBody { + schema_version: d.schema_version, + owner: &d.owner, + sequence: d.sequence, + inbox_family: &d.inbox_family, + box_public_key: &d.box_public_key, + box_key_id: d.box_key_id, + max_envelope_bytes: d.max_envelope_bytes, + admission_policy: &d.admission_policy, + }) + .expect("serializes") +} + +pub fn make_desc_params(owner: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(DESC_PARAMS_FAMILY.len() + 32); + p.extend_from_slice(DESC_PARAMS_FAMILY); + p.extend_from_slice(owner); + p +} + +pub fn validate_descriptor(owner: &[u8; 32], bytes: &[u8]) -> Option { + if bytes.len() > 4096 { + return None; + } + let d: InboxDescriptor = bincode::deserialize(bytes).ok()?; + if d.schema_version != SCHEMA_VERSION || &d.owner != owner { + return None; + } + crate::verify_sig_in(DESC_DOMAIN, owner, &sign_payload(&desc_sig_body(&d)), &d.sig).then_some(d) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn sign(key: &SigningKey, payload: &[u8]) -> Vec { + key.sign(&crate::signable(ENVELOPE_DOMAIN, payload)).to_bytes().to_vec() + } + + #[test] + fn seal_open_merge_and_prune_hold_their_gate() { + let recipient = SigningKey::from_bytes(&[1; 32]); + let sender = SigningKey::from_bytes(&[2; 32]); + let owner = recipient.verifying_key().to_bytes(); + let box_secret = [7u8; 32]; + let box_pub = box_public(&box_secret); + + // a sealed, signed envelope opens with the right key and not another + let mut e = Envelope { + envelope_id: [3; 16], + recipient: owner, + sender: SenderClass::Known(sender.verifying_key().to_bytes()), + created_at: 100, + expires_at: 100 + 3600, + admission: vec![], + sealed: vec![], + sender_sig: vec![], + }; + e.sealed = seal(&box_pub, &e.envelope_id, &envelope_header_bytes(&e), b"hello", [9u8; 32]); + e.sender_sig = sign(&sender, &sign_payload(&envelope_header_bytes(&e))); + assert_eq!(validate_envelope(&owner, &e), Ok(())); + assert_eq!(open(&box_secret, &e).as_deref(), Some(&b"hello"[..])); + assert_eq!(open(&[8u8; 32], &e), None); + // a tampered header breaks both the signature and the AAD + let mut t = e.clone(); + t.expires_at += 1; + assert_eq!(validate_envelope(&owner, &t), Err(EnvelopeInvalid::BadSenderSig)); + assert_eq!(open(&box_secret, &t), None); + + // merge: an offer state with the envelope lands; a prune removes it for good + let offer = bincode::serialize(&InboxState { schema_version: SCHEMA_VERSION, owner, envelopes: vec![e.clone()], prune: None }).unwrap(); + let merged = merge_inbox(&owner, &[&offer]); + assert_eq!(merged.envelopes.len(), 1); + let mut prune = PruneRecord { seq: 1, removed_ids: vec![e.envelope_id], floor_expiry: 0, sig: vec![] }; + prune.sig = sign(&recipient, &sign_payload(&prune_sig_body(&prune))); + let pruned = bincode::serialize(&InboxState { schema_version: SCHEMA_VERSION, owner, envelopes: vec![], prune: Some(prune) }).unwrap(); + let after = merge_inbox(&owner, &[&offer, &pruned]); + assert!(after.envelopes.is_empty()); + assert_eq!(after.prune.as_ref().map(|p| p.seq), Some(1)); + // a forged prune has no authority + let mut forged = PruneRecord { seq: 5, removed_ids: vec![e.envelope_id], floor_expiry: 0, sig: vec![] }; + forged.sig = sign(&sender, &sign_payload(&prune_sig_body(&forged))); + let fb = bincode::serialize(&InboxState { schema_version: SCHEMA_VERSION, owner, envelopes: vec![], prune: Some(forged) }).unwrap(); + assert_eq!(merge_inbox(&owner, &[&offer, &fb]).envelopes.len(), 1); + + // descriptor round trip + let mut d = InboxDescriptor { schema_version: SCHEMA_VERSION, owner, sequence: 1, inbox_family: INBOX_PARAMS_FAMILY.to_vec(), box_public_key: box_pub, box_key_id: 1, max_envelope_bytes: MAX_CIPHERTEXT as u32, admission_policy: vec!["known-signed".into()], sig: vec![] }; + d.sig = sign(&recipient, &sign_payload(&desc_sig_body(&d))); + let db = bincode::serialize(&d).unwrap(); + assert_eq!(validate_descriptor(&owner, &db), Some(d)); + } +} diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index 9185139..be111cf 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; pub mod slsk_link; pub mod friend; pub mod playlist; +pub mod inbox; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/src-tauri/assets/inbox_contract.wasm b/src-tauri/assets/inbox_contract.wasm new file mode 100644 index 0000000..7cfd103 Binary files /dev/null and b/src-tauri/assets/inbox_contract.wasm differ diff --git a/src-tauri/assets/inbox_desc_contract.wasm b/src-tauri/assets/inbox_desc_contract.wasm new file mode 100644 index 0000000..2e52096 Binary files /dev/null and b/src-tauri/assets/inbox_desc_contract.wasm differ diff --git a/src-tauri/src/friends.rs b/src-tauri/src/friends.rs index 3b10e67..6a31fbe 100644 --- a/src-tauri/src/friends.rs +++ b/src-tauri/src/friends.rs @@ -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?` asks, `SSFRIEND1!` +//! PM wire: `SSFRIEND1?` asks, `SSFRIEND1!` //! accepts or revokes, `SSFRIEND1-` 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, - /// 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, } -/// 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 { @@ -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::(); let guard = st.client.lock().await; guard @@ -211,21 +220,52 @@ fn decode Deserialize<'de>>(b64: &str) -> Option { 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 { let adapter = app.state::().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 { +async fn start_request( + app: &tauri::AppHandle, + their: [u8; 32], + username: String, +) -> Result { 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().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 { + 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 { + 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 { 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 Result Result Vec { // ---------------------------------------------------------------- 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::(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::(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 Result { + request_by_key(&app, &persona_key).await +} +#[tauri::command] pub async fn friend_accept(app: tauri::AppHandle, id: String) -> Result { accept(&app, &id).await } diff --git a/src-tauri/src/inbox.rs b/src-tauri/src/inbox.rs new file mode 100644 index 0000000..77a294d --- /dev/null +++ b/src-tauri/src/inbox.rs @@ -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, + #[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(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, 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 adapter = app.state::().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, +} +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 +} + +/// 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 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), + 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 { + 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"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6b99db..5939d85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs index d345335..29954fd 100644 --- a/src-tauri/src/shared_playlists.rs +++ b/src-tauri/src/shared_playlists.rs @@ -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::(&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 } diff --git a/src-tauri/src/slsk_link.rs b/src-tauri/src/slsk_link.rs index 1a85598..52b6f46 100644 --- a/src-tauri/src/slsk_link.rs +++ b/src-tauri/src/slsk_link.rs @@ -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 { + 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] diff --git a/src/lib/api.js b/src/lib/api.js index b0f2aa8..40a27d8 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -158,6 +158,7 @@ export const slskLinkCheck = (username) => invoke("slsk_link_check", { username export const slskLinkForget = (username) => invoke("slsk_link_forget", { username }); export const friendsList = () => invoke("friends_list"); export const friendRequest = (username) => invoke("friend_request", { username }); +export const friendRequestByKey = (personaKey) => invoke("friend_request_by_key", { personaKey }); export const friendAccept = (id) => invoke("friend_accept", { id }); export const friendDecline = (id) => invoke("friend_decline", { id }); export const friendRemove = (id) => invoke("friend_remove", { id }); @@ -170,6 +171,9 @@ export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_imp export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId }); export const sharesInbox = () => invoke("shares_inbox"); export const shareDismiss = (id) => invoke("share_dismiss", { id }); +export const inboxStatus = () => invoke("inbox_status"); +export const inboxPublish = () => invoke("inbox_publish"); +export const inboxCheck = () => invoke("inbox_check"); export const buddyList = () => invoke("buddy_list"); export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index 7bfe4bc..13b6241 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -71,6 +71,10 @@ export const community = $state({ sharePreviews: {}, /** shared_playlists_mine: what this device has published. */ published: [], + /** inbox_status: the encrypted inbox's state on this device. */ + inbox: null, + /** What the last check found ({new_items, quarantined, pruned}). */ + inboxLast: null, /** persona key → ProfileView | {none} | {error} | {pending}: other * people's signed profiles, read once per session when surfaced. */ remoteProfiles: {}, @@ -323,7 +327,7 @@ export async function initCommunity() { if (p.status === 1 || p.status === 2) noteSeen(p.username); }); } - await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished()]); + await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox()]); await refreshPersona(); } @@ -489,7 +493,33 @@ export const shareImport = (card) => }); +// ---- the encrypted inbox -------------------------------------------------------------- +export async function refreshInbox() { + try { + community.inbox = await api.inboxStatus(); + } catch { + // stays as it was + } +} +export const publishInbox = () => + run("inbox", async () => { + community.inbox = await api.inboxPublish(); + }); +/** Read our inbox through the node, act on sealed cargo, prune it. */ +export const checkInbox = () => + run("inboxcheck", async () => { + community.inboxLast = await api.inboxCheck(); + await Promise.all([refreshInbox(), refreshFriends(), refreshShares()]); + return community.inboxLast; + }); +export const friendRequestByKey = (personaKey) => friendAction("friend:" + personaKey, () => api.friendRequestByKey(personaKey)); +export const friendshipForKey = (key) => { + const mine = community.friends.filter((f) => f.persona_key === key); + return mine.find((f) => f.state === "friends") ?? mine[0] ?? null; +}; + + // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // through this, never through a bare import (which would be a second copy). if (import.meta.env.DEV) globalThis.__community = community; -if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished }; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index a71e6a1..2cee5f8 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -77,6 +77,9 @@ shareDismiss, sharedPublish, sharedWithdraw, + publishInbox, + checkInbox, + friendRequestByKey, } from "../lib/community.svelte.js"; onMount(() => { @@ -432,11 +435,24 @@ const id = await shareImport(card); if (id) goToView("playlist", { playlistId: id, label: card.name }); } + // The inbox is polled while this page is open and the node is up: on + // arrival and every 90 s. Sealed cargo becomes friend requests and share + // cards through the same stores the PM carry feeds. + $effect(() => { + if (!nodeUp || !key || !community.inbox?.published) return; + untrack(() => void checkInbox()); + const t = setInterval(() => void checkInbox(), 90_000); + return () => clearInterval(t); + }); + let addKey = $state(""); const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase(); $effect(() => { const lk = drawerLink; if (lk) untrack(() => loadRemoteProfile(lk.persona_key)); }); + $effect(() => { + for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key)); + }); // Discover's similar-users shelf asks the server once per session. $effect(() => { if (community.section === "discover" && online) untrack(() => loadSimilar()); @@ -564,9 +580,9 @@
-
{display(f.username)} wants to be friends
-
{f.username} · SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}
-
SoulStreamsigned request · their ID proven over Soulseek·open until {fmtWhen(f.expires_at * 1000)}
+
{f.username ? display(f.username) : community.remoteProfiles[f.persona_key]?.display_name || `SoulStream ID ${fpShort(f.persona_key)}`} wants to be friends
+
{f.username ? `${f.username} · ` : ""}SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}
+
SoulStream{f.carry === "inbox" ? "sealed · via your encrypted inbox · signed by their key" : "signed request · their ID proven over Soulseek"}·open until {fmtWhen(f.expires_at * 1000)}
@@ -587,10 +603,11 @@
{c.name}
-
{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by {fmtWhen(c.received_at * 1000)}
+
{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by {#if c.from_username}{:else}SoulStream ID {fpShort(c.from_key)}{/if} {fmtWhen(c.received_at * 1000)}
SoulStream signed by {fpShort(c.owner_key)} + {#if c.carry === "inbox"}· sealed · via your encrypted inbox{/if} {#if pv && !pv.pending && !pv.error && !pv.withdrawn}·verified · revision {pv.revision_seq}{/if} {#if pv?.withdrawn}·withdrawn by the owner{/if} {#if pv?.error}·{pv.error}{/if} @@ -837,6 +854,10 @@ +
{ e.preventDefault(); const k = addKey.trim(); if (k) { void friendRequestByKey(k); addKey = ""; } }}> +
+ +
{ e.preventDefault(); void addContact(); }}>
@@ -1237,6 +1258,18 @@ Load remote profile images verified, resized, cached by hash + {#if key} +
+ {#if community.inbox?.published} + encrypted inbox · key {community.inbox.box_key_id} + + {community.inbox.last_check ? `checked ${fmtWhen(community.inbox.last_check * 1000)} · ` : ""}friend requests and shares arrive sealed to a key kept on this device; a persona restored elsewhere publishes a new one + {:else} + + lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between + {/if} +
+ {/if} {/if}