diff --git a/Cargo.lock b/Cargo.lock index 59e72c25..a1fcf204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,9 +933,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -1066,7 +1066,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout 0.2.4", + "inout 0.2.5", ] [[package]] @@ -2013,9 +2013,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flatbuffers" @@ -2696,7 +2696,7 @@ dependencies = [ "log", "rustls", "rustls-platform-verifier", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -3392,7 +3392,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -3616,9 +3616,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] @@ -4352,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.45.5" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "aes", "base64 0.22.1", @@ -4378,7 +4378,7 @@ dependencies = [ [[package]] name = "nostr-blossom" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "base64 0.22.1", "bitcoin_hashes 1.2.0", @@ -4391,10 +4391,9 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", - "futures-core", "nostr", "nostr-sdk", "opaquerr 0.2.0", @@ -4405,7 +4404,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "nostr", "opaquerr 0.2.0", @@ -4414,7 +4413,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "nostr", "opaquerr 0.2.0", @@ -4423,7 +4422,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "indexmap", "lru", @@ -4435,7 +4434,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.3" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", "flatbuffers", @@ -4450,7 +4449,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "btreecap", "nostr", @@ -4461,7 +4460,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.4" -source = "git+https://github.com/rust-nostr/nostr#de3031aa99986eb29b3f34580355f9a099e4c927" +source = "git+https://github.com/rust-nostr/nostr#20096765b95f4840dd3df66cad49db35e9634462" dependencies = [ "async-utility", "async-wsocket", @@ -5943,7 +5942,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -7164,9 +7163,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "bytes", "libc", @@ -7656,14 +7655,14 @@ dependencies = [ "serde_json", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -8043,9 +8042,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -8056,14 +8055,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 673e65b1..5a53fcf6 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; use anyhow::{Error, anyhow}; #[cfg(not(target_arch = "wasm32"))] @@ -260,15 +261,9 @@ impl NostrRegistry { let uri = NostrConnectUri::parse(base)?; // Construct the nostr connect signer - let mut signer = NostrConnect::new( - uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); + let signer = NostrConnect::builder(uri, keys) + .auth_url_handler(Arc::new(CoopAuthUrlHandler)) + .build()?; this.update(cx, |this, cx| { this.set_signer(signer, cx); @@ -447,16 +442,9 @@ impl NostrRegistry { let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); cx.spawn(async move |this, cx| { - // Construct the nostr connect signer - let mut signer = NostrConnect::new( - connect_uri, - keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - )?; - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); + let signer = NostrConnect::builder(connect_uri, keys) + .auth_url_handler(Arc::new(CoopAuthUrlHandler)) + .build()?; // Verify the bunker responds before persisting the credential let public_key = signer.get_public_key_async().await?; diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md new file mode 100644 index 00000000..f8e8b46c --- /dev/null +++ b/docs/web-of-trust.md @@ -0,0 +1,201 @@ +# Web of Trust — implementation plan + +A plan for scoring strangers by the user's follow graph, based on how Amethyst +implements web of trust. The goal is to make the screening dialog and the chat +requests list more useful: "3 of your contacts follow this person" instead of a +bare npub, and a way to let trusted strangers skip screening. + +## 1. How Amethyst does it + +Amethyst (vitorpamplona/amethyst) has two independent WoT layers: + +**Layer 1 — follow-graph score (`WoTService`)**, in +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`: + +- For every pubkey `X`, the score is the number of people the user follows who + also follow `X` (friends-of-friends, computed only from kind 3 contact lists). +- The user's own follow set and each followed user's kind 3 feed the graph; the + service keeps a reverse index `target -> {followers}` and derives scores from + set sizes. +- Purely in memory, rebuilt on start; a single writer coroutine serializes + updates via an op channel and diffs each follower's list on republish, so a + changed kind 3 only touches the entries it added/removed. +- Guardrails: WoT is disabled when the user follows more than `MAX_FOLLOWS` + (2000) accounts, and each ingested contact list is capped + (`MAX_FOLLOWS_PER_EVENT`, 5000) to bound hostile publishers. +- A `ready` flag (set on first EOSE) tells the UI when scores are usable. +- Consumers sort people follows-first, then by score (`PeopleOrder.kt`) and show + a desktop badge. + +**Layer 2 — NIP-85 trust-provider index**, in +`commons/.../wot/network/` and `quartz/.../nip85TrustedAssertions/users/index/`: + +- The user names a "trust provider" (e.g. Brainstorm/GrapeRank) in a kind 10040 + event; the provider publishes a signed rank card (kind 30382) for every member + of the user's extended network (~150–300k events). +- The client downloads the whole set once, verifies every signature, and stores + a compact binary index (~6 MB: 128-bit key prefixes + rank/hops/followers + arrays) with cursor-based incremental sync (NIP-45 COUNT + NIP-77 negentropy). +- The gate is `accepted(pk) = pk == me || pk in follows || rank(pk) >= minScore` + and it drives the DM Known/New split, the "Curated" notification filter, push + filtering, and collapsing out-of-network replies. Exemptions live in one + predicate (`TrustVerdicts.kt` / `CuratedTrustFilter.kt`) so in-app and push + can't drift: zaps always pass, chats follow the DM rule, everything else is + gated on network membership. + +### What we take, what we skip + +| Amethyst piece | Take? | +|---|---| +| Friends-of-friends score, reverse index, diffing, caps, ready flag | Yes — this is the whole feature for coop | +| Follows-first ordering helper | Only if we ever list non-contacts; not needed now | +| NIP-85 provider onboarding, kind 30382 index, negentropy sync, binary files | No — massive sync + storage complexity for a DM client | +| One shared exemption predicate per surface | Yes as a principle: gate requests in one place | + +Layer 2 exists because Amethyst gates a 300k-user social graph. Coop gates +incoming DMs; the follow graph (one hop of contact lists) is enough signal and +needs no new events, relays, or storage formats. + +## 2. Design: `WebOfTrust` in the `person` crate + +One new logical component, one new file: `crates/person/src/wot.rs`, re-exported +from `crates/person/src/lib.rs` and initialized in `person::init` as a global +entity, exactly like `PersonRegistry`. + +### State + +```rust +pub struct WebOfTrust { + /// The signed-in user's contacts (latest kind 3) + follows: HashSet, + /// The current user, excluded from credit + self_key: Option, + /// Per-follower cached follow set, for diffing on republish + lists: HashMap>, + /// Reverse index: target -> contacts who follow them + reverse: HashMap>, + /// Disabled when the follow set exceeds MAX_FOLLOWS + disabled: bool, + ready: bool, + tasks, subscriptions, ... +} +``` + +API (all reads are plain methods; writes go through the entity): + +```rust +pub fn score(&self, key: &PublicKey) -> usize // reverse[key].len() +pub fn is_in_network(&self, key: &PublicKey) -> bool // follows || score >= 1 +pub fn mutuals(&self, key: &PublicKey) -> Vec // who vouches, for the dialog +pub fn is_ready(&self) -> bool +pub fn is_disabled(&self) -> bool +``` + +Events: `WoTEvent::Updated` and `WoTEvent::Ready`, emitted after hydration and +on every graph change; consumers subscribe like they do to `PersonRegistry`. + +### Data flow — reuses the existing pattern end to end + +Same shape as `PersonRegistry::new` (background listener -> flume channel -> +entity update -> `cx.emit`): + +1. **Hydrate from the local database** on creation and on + `StateEvent::SignerChanged`: + - own kind 3 (newest event, authors sorted by `Reverse(created_at)` like + `Directory::track` does) -> `follows`; + - one batch query `Filter::new().kind(Kind::ContactList).authors(follows)` + -> dedupe per author keeping the newest -> seed `lists`/`reverse`. +2. **Stay fresh**: one persistent subscription with a dedicated + `SubscriptionId` (add `WOT_CONTACTS` beside `DEVICE_GIFTWRAP`/`USER_GIFTWRAP` + in `state::constants`), filter `kind(Kind::ContactList).authors(follows)`. + The background listener accepts any kind 3 whose author is in `follows` + (don't couple to subscription ids), diffs it against the cached list, and + updates the reverse index. Re-issue the subscription when the user's own + contact list changes. +3. **Rules ported from `WoTService`**: + - exclude the follower and `self_key` from every ingested list; + - on follow-set change, un-credit removed followers from the reverse index; + - `MAX_FOLLOWS = 2000` disables the service (clear state, emit ready); + - `MAX_TAGS_PER_LIST = 5000` caps one follower's ingested list; + - reset everything on sign-out / account switch. + +No persistence: the graph rebuilds from the LMDB cache on every start, the same +way `ChatRegistry` rebuilds rooms. No new relay events, no settings file, no new +dependencies. + +## 3. Consumers (in order) + +### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`) + +Replace `check_wot`'s database query (which only sees contact lists that happen +to be cached, so "mutual contacts" is usually empty) with the registry: + +- `mutual_contacts = wot.mutuals(&public_key)` — the people **the user follows** + who follow the stranger, which is the correct WoT semantic and matches + Amethyst's score. +- The existing "Mutual contacts" dialog keeps working unchanged: it already + renders a `Vec` through a `Directory`. +- When `wot.is_disabled()` or not ready, fall back to the current query so the + dialog degrades to today's behavior. + +### Phase 2 — requests list badge + +Where request rooms are rendered, show the mutual count for the sender: + +- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and + `crates/workspace/src/panels/greeter.rs` (`load`): one caption + `"N mutual contacts"` under the request preview, via the room's other member. +- Subscribe to `WoTEvent::Updated` where `PersonEvent` is already subscribed so + rows re-render when the graph changes. + +### Phase 3 — opt-in auto-trust (behind a setting) + +The payoff: strangers vouched for by the user's contacts can skip screening. + +- Add `pub wot_auto_trust: bool` to `setting_accessors!` in + `crates/settings/src/lib.rs` (default `false`) and a toggle in the existing + preferences screen (`crates/workspace/src/dialogs/settings.rs`). +- When enabled, apply it in one place — `ChatRegistry::new_message` in + `crates/chat/src/lib.rs`, next to the existing promotion rule — so in-app and + any future consumer can't drift (Amethyst's lesson from `TrustVerdicts`): + + ```rust + if this.kind == RoomKind::Request + && (message.rumor.pubkey == public_key + || (AppSettings::get_wot_auto_trust(cx) && wot.is_in_network(&message.rumor.pubkey))) + { + this.set_ongoing(cx); + } + ``` + + Group requests stay conservative: every member must be in the network (or + already a contact) to promote, mirroring how `is_contact` works today. + +Deliberately not planned: sorting contacts (the new-chat dialog lists only +follows already), notification feeds (coop has none), muting/spam scoring. + +## 4. Non-goals + +- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank + thresholds — revisit only if coop ever needs a network-scale gate. +- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query + away on restart. +- Background jobs / WorkManager equivalents — hydration is two local queries. + +## 5. Testing + +- Unit tests for the pure graph logic in `wot.rs` (no `cx` needed): seeding, + per-follower diffing, follow-set removal un-crediting, self/follower + exclusion, both caps and the disabled transition. +- One GPUI test for hydration ordering (newest kind 3 wins) using + `TestAppContext`; use `cx.background_executor().timer(...)` for any waits per + the project guidelines. + +## 6. References + +- `WoTService.kt` — the model this plan ports: + https://github.com/vitorpamplona/amethyst/blob/main/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt +- `PeopleOrder.kt`, `CuratedTrustFilter.kt`, `TrustVerdicts.kt` — consumers and + the shared-predicate pattern. +- Design doc for Amethyst's NIP-85 index (the layer we skip, with the reasoning + and measurements): `commons/plans/2026-10-07-wot-network-index.md` on main.