community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one
This commit is contained in:
1 parent
13c44f731a
commit
4378c4c0cc
14 files changed
+1367
-125
No files matched your search
Generated
+110
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<u8>,
|
||||
/// Sender ephemeral X25519 public key (32) ‖ ChaCha20-Poly1305
|
||||
/// ciphertext of the cargo.
|
||||
pub sealed: Vec<u8>,
|
||||
/// Known sender's signature over the header (delegate-signed).
|
||||
pub sender_sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PruneSigBody<'a> {
|
||||
seq: u64,
|
||||
removed_ids: &'a [[u8; 16]],
|
||||
floor_expiry: u64,
|
||||
}
|
||||
|
||||
pub fn prune_sig_body(p: &PruneRecord) -> Vec<u8> {
|
||||
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<Envelope>,
|
||||
pub prune: Option<PruneRecord>,
|
||||
}
|
||||
|
||||
pub fn make_inbox_params(owner: &[u8; 32]) -> Vec<u8> {
|
||||
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<InboxState> = Vec::new();
|
||||
for bytes in candidates {
|
||||
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(st) = bincode::deserialize::<InboxState>(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<Envelope> = 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<Envelope>> = BTreeMap::new();
|
||||
let mut anon_pool: Vec<Envelope> = 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<Envelope> = 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<u8>,
|
||||
pub box_public_key: [u8; 32],
|
||||
pub box_key_id: u32,
|
||||
pub max_envelope_bytes: u32,
|
||||
pub admission_policy: Vec<String>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
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<u8> {
|
||||
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<InboxDescriptor> {
|
||||
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<u8> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+180
-109
@@ -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,29 +388,45 @@ 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) {
|
||||
// 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;
|
||||
}
|
||||
(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 {
|
||||
_ => match slsk_link::ask(app, &from).await {
|
||||
Ok(l) if l.persona_key == b58(&req.sender) => {
|
||||
slsk_link::remember(&app, l.clone());
|
||||
slsk_link::remember(app, l.clone());
|
||||
Some(l)
|
||||
}
|
||||
Ok(_) => {
|
||||
@@ -390,51 +440,60 @@ async fn on_request(app: tauri::AppHandle, from: String, req: fr::FriendRequest)
|
||||
},
|
||||
};
|
||||
let Some(link) = bound else { return };
|
||||
(link.username, "pm")
|
||||
}
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! The encrypted inbox (F5 → the app): the second carry for everything the
|
||||
//! friend and share handshakes send. A persona that has published an
|
||||
//! InboxDescriptor can be written to by anyone who can resolve it: the
|
||||
//! sender seals the cargo to the recipient's box key, signs the envelope
|
||||
//! header with its persona key, and OFFERS the envelope to the recipient's
|
||||
//! inbox contract, whose merge admits, dedups and caps it. The recipient
|
||||
//! reads its own inbox, opens what it can, acts on it, and prunes what it
|
||||
//! handled with an owner-signed tombstone record.
|
||||
//!
|
||||
//! Custody, for now: the X25519 box secret lives beside the node in this
|
||||
//! device's app folder (`inbox-box.key`). The design wants it in the
|
||||
//! identity delegate — that is the next delegate generation, a migration
|
||||
//! event — so a persona restored elsewhere from the recovery file has to
|
||||
//! publish a fresh box key, and mail sealed to the old one is unreadable.
|
||||
//! The UI says so.
|
||||
//!
|
||||
//! Adapter rules from F5 apply verbatim: read before the first write to a
|
||||
//! contract this node never held (`get_then_update`), read-and-bump the
|
||||
//! descriptor and prune sequences, and an unresolvable descriptor means
|
||||
//! "unreachable through Freenet" — never a guess.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use freenet_adapter::FreenetAdapter;
|
||||
use persona_state::friend as fr;
|
||||
use persona_state::inbox as ib;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::persona;
|
||||
use crate::state::AppState;
|
||||
|
||||
const INBOX_WASM: &[u8] = include_bytes!("../assets/inbox_contract.wasm");
|
||||
const DESC_WASM: &[u8] = include_bytes!("../assets/inbox_desc_contract.wasm");
|
||||
pub const EVENT: &str = "community:inbox";
|
||||
/// How long an envelope we send stays valid.
|
||||
const ENVELOPE_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||
/// A descriptor lookup that found nothing is not repeated for this long.
|
||||
const DESC_CACHE_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
/// What travels sealed inside an envelope: the same things the PM carry
|
||||
/// moves in the clear. Bincode variant order is the wire — append only.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Cargo {
|
||||
FriendRequest(fr::FriendRequest),
|
||||
FriendAssertion(fr::FriendAssertion),
|
||||
FriendDecline([u8; 16]),
|
||||
Share(ShareCargo),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShareCargo {
|
||||
pub owner: [u8; 32],
|
||||
pub playlist_uuid: [u8; 16],
|
||||
pub name: String,
|
||||
pub entry_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Ledger {
|
||||
/// Sequence of the descriptor we last published (0 = never).
|
||||
#[serde(default)]
|
||||
desc_seq: u64,
|
||||
#[serde(default)]
|
||||
box_key_id: u32,
|
||||
/// Envelope ids already acted on (b58) — pruned on the next check.
|
||||
#[serde(default)]
|
||||
handled: Vec<String>,
|
||||
#[serde(default)]
|
||||
prune_seq: u64,
|
||||
#[serde(default)]
|
||||
last_check: u64,
|
||||
#[serde(default)]
|
||||
quarantined: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InboxStatus {
|
||||
pub published: bool,
|
||||
pub descriptor_seq: u64,
|
||||
pub box_key_id: u32,
|
||||
pub last_check: u64,
|
||||
pub handled_count: usize,
|
||||
pub quarantined: u64,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn rand32() -> [u8; 32] {
|
||||
use rand::RngCore;
|
||||
let mut n = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut n);
|
||||
n
|
||||
}
|
||||
fn rand16() -> [u8; 16] {
|
||||
use rand::RngCore;
|
||||
let mut n = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut n);
|
||||
n
|
||||
}
|
||||
fn b58(bytes: &[u8]) -> String {
|
||||
bs58::encode(bytes).into_string()
|
||||
}
|
||||
pub(crate) fn key_of(b58s: &str) -> Result<[u8; 32], String> {
|
||||
bs58::decode(b58s)
|
||||
.into_vec()
|
||||
.map_err(|e| format!("bad key: {e}"))?
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| "key is not 32 bytes".to_string())
|
||||
}
|
||||
|
||||
fn dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("freenet"))
|
||||
}
|
||||
fn load(app: &tauri::AppHandle) -> Ledger {
|
||||
dir(app)
|
||||
.ok()
|
||||
.and_then(|d| std::fs::read(d.join("inbox-ledger.json")).ok())
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
|
||||
let d = dir(app)?;
|
||||
std::fs::create_dir_all(&d).map_err(|e| e.to_string())?;
|
||||
std::fs::write(d.join("inbox-ledger.json"), serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The box secret, minted on first use. 32 raw bytes in the app folder —
|
||||
/// see the custody note above.
|
||||
fn box_secret(app: &tauri::AppHandle, mint: bool) -> Result<Option<[u8; 32]>, String> {
|
||||
let p = dir(app)?.join("inbox-box.key");
|
||||
if let Ok(b) = std::fs::read(&p) {
|
||||
if b.len() == 32 {
|
||||
return Ok(Some(b.as_slice().try_into().unwrap()));
|
||||
}
|
||||
}
|
||||
if !mint {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = rand32();
|
||||
std::fs::create_dir_all(p.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&p, s).map_err(|e| format!("could not keep the box key: {e}"))?;
|
||||
Ok(Some(s))
|
||||
}
|
||||
|
||||
async fn own_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let info = persona::current(&adapter)
|
||||
.await?
|
||||
.ok_or("create a SoulStream identity first")?;
|
||||
key_of(&info.public_key)
|
||||
}
|
||||
|
||||
pub fn status(app: &tauri::AppHandle) -> InboxStatus {
|
||||
let l = load(app);
|
||||
InboxStatus {
|
||||
published: l.desc_seq > 0,
|
||||
descriptor_seq: l.desc_seq,
|
||||
box_key_id: l.box_key_id,
|
||||
last_check: l.last_check,
|
||||
handled_count: l.handled.len(),
|
||||
quarantined: l.quarantined,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- descriptor
|
||||
|
||||
/// Publish (or re-publish) how to reach this persona. Minting the box key
|
||||
/// and publishing the descriptor is what makes the inbox exist.
|
||||
pub async fn publish(app: &tauri::AppHandle) -> Result<InboxStatus, String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let owner = own_key(app).await?;
|
||||
let secret = box_secret(app, true)?.expect("minted");
|
||||
let mut l = load(app);
|
||||
let first = l.desc_seq == 0;
|
||||
let seq = l.desc_seq + 1;
|
||||
let key_id = if l.box_key_id == 0 { 1 } else { l.box_key_id };
|
||||
let mut d = ib::InboxDescriptor {
|
||||
schema_version: ib::SCHEMA_VERSION,
|
||||
owner,
|
||||
sequence: seq,
|
||||
inbox_family: ib::INBOX_PARAMS_FAMILY.to_vec(),
|
||||
box_public_key: ib::box_public(&secret),
|
||||
box_key_id: key_id,
|
||||
max_envelope_bytes: ib::MAX_CIPHERTEXT as u32,
|
||||
admission_policy: vec!["known-signed".into()],
|
||||
sig: vec![],
|
||||
};
|
||||
d.sig = persona::sign(&adapter, ib::DESC_DOMAIN, ib::sign_payload(&ib::desc_sig_body(&d))).await?;
|
||||
let bytes = bincode::serialize(&d).map_err(|e| e.to_string())?;
|
||||
let params = ib::make_desc_params(&owner);
|
||||
if first {
|
||||
adapter
|
||||
.contract_put(DESC_WASM.to_vec(), ¶ms, bytes, false)
|
||||
.await
|
||||
.map(|_| ())
|
||||
} else {
|
||||
adapter.contract_update(DESC_WASM.to_vec(), ¶ms, bytes).await.map(|_| ())
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
l.desc_seq = seq;
|
||||
l.box_key_id = key_id;
|
||||
save(app, &l)?;
|
||||
// Our own inbox contract must exist for anyone to write into it.
|
||||
let empty = bincode::serialize(&ib::InboxState {
|
||||
schema_version: ib::SCHEMA_VERSION,
|
||||
owner,
|
||||
envelopes: vec![],
|
||||
prune: None,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = adapter
|
||||
.contract_put(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner), empty, false)
|
||||
.await;
|
||||
log::info!("inbox: descriptor published (seq {seq}, box key {key_id})");
|
||||
Ok(status(app))
|
||||
}
|
||||
|
||||
struct DescCache {
|
||||
at: Instant,
|
||||
desc: Option<ib::InboxDescriptor>,
|
||||
}
|
||||
static DESC_CACHE: OnceLock<Mutex<HashMap<[u8; 32], DescCache>>> = OnceLock::new();
|
||||
|
||||
/// Resolve someone's descriptor through the network. None = unreachable
|
||||
/// through Freenet (no descriptor, or one that does not verify).
|
||||
pub async fn resolve(app: &tauri::AppHandle, key: &[u8; 32]) -> Option<ib::InboxDescriptor> {
|
||||
let cache = DESC_CACHE.get_or_init(Default::default);
|
||||
if let Some(c) = cache.lock().unwrap().get(key) {
|
||||
if c.at.elapsed() < DESC_CACHE_TTL {
|
||||
return c.desc.clone();
|
||||
}
|
||||
}
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let ids = FreenetAdapter::contract_ids(DESC_WASM.to_vec(), &ib::make_desc_params(key));
|
||||
let desc = match adapter.contract_get(&ids.instance, false).await {
|
||||
Ok(bytes) => ib::validate_descriptor(key, &bytes),
|
||||
Err(_) => None,
|
||||
};
|
||||
cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(*key, DescCache { at: Instant::now(), desc: desc.clone() });
|
||||
desc
|
||||
}
|
||||
|
||||
/// True when we can reach `key` through Freenet right now.
|
||||
pub async fn reachable(app: &tauri::AppHandle, key: &[u8; 32]) -> bool {
|
||||
resolve(app, key).await.is_some()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- send
|
||||
|
||||
/// Seal cargo to a persona and offer it to their inbox. Errors when they
|
||||
/// have no descriptor — the caller falls back to the Soulseek carry.
|
||||
pub async fn send(app: &tauri::AppHandle, to: &[u8; 32], cargo: &Cargo) -> Result<(), String> {
|
||||
let desc = resolve(app, to)
|
||||
.await
|
||||
.ok_or("they have not published an inbox — unreachable through Freenet")?;
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let me = own_key(app).await?;
|
||||
let payload = bincode::serialize(cargo).map_err(|e| e.to_string())?;
|
||||
if payload.len() > desc.max_envelope_bytes as usize {
|
||||
return Err("cargo is over their envelope cap".into());
|
||||
}
|
||||
let now = now_secs();
|
||||
let mut e = ib::Envelope {
|
||||
envelope_id: rand16(),
|
||||
recipient: *to,
|
||||
sender: ib::SenderClass::Known(me),
|
||||
created_at: now,
|
||||
expires_at: now + ENVELOPE_TTL_SECS,
|
||||
admission: vec![],
|
||||
sealed: vec![],
|
||||
sender_sig: vec![],
|
||||
};
|
||||
let header = ib::envelope_header_bytes(&e);
|
||||
e.sealed = ib::seal(&desc.box_public_key, &e.envelope_id, &header, &payload, rand32());
|
||||
e.sender_sig = persona::sign(&adapter, ib::ENVELOPE_DOMAIN, ib::sign_payload(&header)).await?;
|
||||
let offer = bincode::serialize(&ib::InboxState {
|
||||
schema_version: ib::SCHEMA_VERSION,
|
||||
owner: *to,
|
||||
envelopes: vec![e],
|
||||
prune: None,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
adapter
|
||||
.get_then_update(INBOX_WASM.to_vec(), &ib::make_inbox_params(to), offer)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::info!("inbox: sealed cargo offered to {}", b58(to));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- receive
|
||||
|
||||
/// What one check found.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckResult {
|
||||
pub new_items: usize,
|
||||
pub quarantined: usize,
|
||||
pub pruned: usize,
|
||||
}
|
||||
|
||||
/// Read our inbox, open what we can, act on it, prune what we handled.
|
||||
pub async fn check(app: &tauri::AppHandle) -> Result<CheckResult, String> {
|
||||
let adapter = app.state::<AppState>().freenet.clone();
|
||||
let owner = own_key(app).await?;
|
||||
let Some(secret) = box_secret(app, false)? else {
|
||||
return Err("no inbox published yet".into());
|
||||
};
|
||||
let ids = FreenetAdapter::contract_ids(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner));
|
||||
let bytes = match adapter.contract_get(&ids.instance, false).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.to_string().contains("not found") => Vec::new(),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let state = ib::merge_inbox(&owner, &[&bytes]);
|
||||
let mut l = load(app);
|
||||
let handled: BTreeSet<String> = l.handled.iter().cloned().collect();
|
||||
let mut newly: Vec<[u8; 16]> = Vec::new();
|
||||
let mut new_items = 0usize;
|
||||
let mut quarantined = 0usize;
|
||||
for e in &state.envelopes {
|
||||
let id = b58(&e.envelope_id);
|
||||
if handled.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let ib::SenderClass::Known(sender) = e.sender else {
|
||||
// Anonymous admission is not offered by our descriptor; anything
|
||||
// that arrives that way is dropped.
|
||||
newly.push(e.envelope_id);
|
||||
quarantined += 1;
|
||||
continue;
|
||||
};
|
||||
match ib::open(&secret, e).and_then(|b| bincode::deserialize::<Cargo>(&b).ok()) {
|
||||
Some(cargo) => {
|
||||
dispatch(app, sender, cargo).await;
|
||||
new_items += 1;
|
||||
}
|
||||
None => quarantined += 1,
|
||||
}
|
||||
newly.push(e.envelope_id);
|
||||
}
|
||||
let pruned = newly.len();
|
||||
if !newly.is_empty() {
|
||||
// Read-and-bump: the record's seq must beat whatever the network holds.
|
||||
let seq = state.prune.as_ref().map(|p| p.seq).unwrap_or(0).max(l.prune_seq) + 1;
|
||||
let mut ids_all: Vec<[u8; 16]> = state
|
||||
.prune
|
||||
.as_ref()
|
||||
.map(|p| p.removed_ids.clone())
|
||||
.unwrap_or_default();
|
||||
ids_all.extend(newly.iter().copied());
|
||||
if ids_all.len() > ib::MAX_TOMBSTONES {
|
||||
ids_all.drain(..ids_all.len() - ib::MAX_TOMBSTONES);
|
||||
}
|
||||
let mut rec = ib::PruneRecord {
|
||||
seq,
|
||||
removed_ids: ids_all,
|
||||
floor_expiry: now_secs().saturating_sub(1),
|
||||
sig: vec![],
|
||||
};
|
||||
rec.sig = persona::sign(&adapter, ib::ENVELOPE_DOMAIN, ib::sign_payload(&ib::prune_sig_body(&rec))).await?;
|
||||
let st = bincode::serialize(&ib::InboxState {
|
||||
schema_version: ib::SCHEMA_VERSION,
|
||||
owner,
|
||||
envelopes: vec![],
|
||||
prune: Some(rec),
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Err(e) = adapter
|
||||
.get_then_update(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner), st)
|
||||
.await
|
||||
{
|
||||
log::warn!("inbox: prune not applied ({e}) — handled ids stay in the ledger");
|
||||
} else {
|
||||
l.prune_seq = seq;
|
||||
}
|
||||
for id in &newly {
|
||||
l.handled.push(b58(id));
|
||||
}
|
||||
if l.handled.len() > 2048 {
|
||||
l.handled.drain(..l.handled.len() - 2048);
|
||||
}
|
||||
}
|
||||
l.last_check = now_secs();
|
||||
l.quarantined += quarantined as u64;
|
||||
save(app, &l)?;
|
||||
let _ = app.emit(EVENT, new_items);
|
||||
Ok(CheckResult { new_items, quarantined, pruned })
|
||||
}
|
||||
|
||||
async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) {
|
||||
match cargo {
|
||||
Cargo::FriendRequest(r) => crate::friends::on_inbox_request(app, sender, r).await,
|
||||
Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await,
|
||||
Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id),
|
||||
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- commands
|
||||
|
||||
#[tauri::command]
|
||||
pub fn inbox_status(app: tauri::AppHandle) -> InboxStatus {
|
||||
status(&app)
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn inbox_publish(app: tauri::AppHandle) -> Result<InboxStatus, String> {
|
||||
publish(&app).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn inbox_check(app: tauri::AppHandle) -> Result<CheckResult, String> {
|
||||
check(&app).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn contract_generations_are_pinned() {
|
||||
let owner = [7u8; 32];
|
||||
let inbox = FreenetAdapter::contract_ids(INBOX_WASM.to_vec(), &ib::make_inbox_params(&owner));
|
||||
let desc = FreenetAdapter::contract_ids(DESC_WASM.to_vec(), &ib::make_desc_params(&owner));
|
||||
assert_eq!(inbox.code_hash, "UjK4sLZqGSE2qEXwQYMvNFsTiuLSYjMJuwzVbhAtXKR");
|
||||
assert_eq!(desc.code_hash, "B4A2nMJgSKzgo72gaDJyTpFNTmjBynLFga3EiNoSVstv");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
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(),
|
||||
};
|
||||
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}");
|
||||
}
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 @@
|
||||
<div class="top">
|
||||
<button class="avbtn" onclick={() => (community.drawer = f.username)}><Identicon seed={f.persona_key} size={44} presence={presenceOf(f.username)} /></button>
|
||||
<div>
|
||||
<div class="t">{display(f.username)} wants to be friends</div>
|
||||
<div class="s">{f.username} · SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
|
||||
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>signed request · their ID proven over Soulseek</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
|
||||
<div class="t">{f.username ? display(f.username) : community.remoteProfiles[f.persona_key]?.display_name || `SoulStream ID ${fpShort(f.persona_key)}`} wants to be friends</div>
|
||||
<div class="s">{f.username ? `${f.username} · ` : ""}SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
|
||||
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>{f.carry === "inbox" ? "sealed · via your encrypted inbox · signed by their key" : "signed request · their ID proven over Soulseek"}</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bot">
|
||||
@@ -587,10 +603,11 @@
|
||||
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||||
<div>
|
||||
<div class="t">{c.name}</div>
|
||||
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by <button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button> {fmtWhen(c.received_at * 1000)}</div>
|
||||
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by {#if c.from_username}<button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button>{:else}SoulStream ID {fpShort(c.from_key)}{/if} {fmtWhen(c.received_at * 1000)}</div>
|
||||
<div class="prov">
|
||||
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
|
||||
<span>signed by {fpShort(c.owner_key)}</span>
|
||||
{#if c.carry === "inbox"}<span>·</span><span><Icon name="lock" size={9} /> sealed · via your encrypted inbox</span>{/if}
|
||||
{#if pv && !pv.pending && !pv.error && !pv.withdrawn}<span>·</span><span class="chip ok"><Icon name="check" size={9} />verified · revision {pv.revision_seq}</span>{/if}
|
||||
{#if pv?.withdrawn}<span>·</span><span class="chip warn">withdrawn by the owner</span>{/if}
|
||||
{#if pv?.error}<span>·</span><span class="chip err">{pv.error}</span>{/if}
|
||||
@@ -837,6 +854,10 @@
|
||||
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
|
||||
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
|
||||
<span class="sp"></span>
|
||||
<form class="addrow" onsubmit={(e) => { e.preventDefault(); const k = addKey.trim(); if (k) { void friendRequestByKey(k); addKey = ""; } }}>
|
||||
<div class="qbox slim"><Icon name="link" size={13} /><input placeholder="Friend by SoulStream ID" bind:value={addKey} disabled={!nodeUp || !key} title="Sends a sealed friend request through their encrypted inbox — they must have published one" /></div>
|
||||
<button class="ax-ghost sm" type="submit" disabled={!nodeUp || !key || !addKey.trim() || !!community.busy["friend:" + addKey.trim()]}>Ask</button>
|
||||
</form>
|
||||
<form class="addrow" onsubmit={(e) => { e.preventDefault(); void addContact(); }}>
|
||||
<div class="qbox slim"><Icon name="plus" size={13} /><input placeholder="Add by Soulseek username" bind:value={addName} disabled={!online} /></div>
|
||||
<button class="ax-ghost sm" type="submit" disabled={!online || !addName.trim() || adding}>Add</button>
|
||||
@@ -1237,6 +1258,18 @@
|
||||
<span>Load remote profile images</span>
|
||||
<span class="hint r">verified, resized, cached by hash</span>
|
||||
</label>
|
||||
{#if key}
|
||||
<div class="noderow">
|
||||
{#if community.inbox?.published}
|
||||
<span class="chip ok"><Icon name="lock" size={10} />encrypted inbox · key {community.inbox.box_key_id}</span>
|
||||
<button class="ax-ghost sm" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
|
||||
<span class="hint">{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</span>
|
||||
{:else}
|
||||
<button class="ax-fill sm" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an encrypted inbox"}</button>
|
||||
<span class="hint">lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<button class="tog big" class:on={freenetOn} role="switch" aria-checked={freenetOn} title={freenetOn ? "Turn the Freenet layer off" : "Turn the Freenet layer on"} onclick={() => setFreenetEnabled(!freenetOn)} disabled={!!community.busy.freenet}></button>
|
||||
|
||||
Reference in new issue
Block a user