From 1dbc89cc112f88a2c512b13c656d6af13dba2ab3 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 16:55:45 -0400 Subject: [PATCH] mesh-llm: offer envelope (sprout-core) + desktop building blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B0 — sprout-core/src/mesh_llm.rs (new): MeshLlmOffer envelope, the content of a kind:31990 event. Schema versioned (v: u32), with deny_unknown_fields at the top level and a freeform 'extra' Value escape hatch. ResourceCaps + ModelOffer sub-structs. d_tag charset is limited to [A-Za-z0-9_-] (NIP-33 stability). 9 unit tests covering round-trip JSON, optional caps, unknown-field rejection, d_tag validation, is_publishable rule set. B2 — desktop/src-tauri/src/mesh_llm/endpoint.rs: persists the iroh endpoint keypair to {app_data_dir}/mesh_iroh.key as 32 hex bytes. Atomic write via tempfile.persist; corrupt files quarantined to .bad.{epoch} (same pattern as identity.key). 2 unit tests. Design note in the module doc: we deliberately do NOT derive the iroh key from the Nostr key, because that would couple key rotation (rotating the Nostr key would silently break active offers) and invent a new key-custody convention. Separate file, same Tauri sandbox. B3 — desktop/src-tauri/src/mesh_llm/nip98.rs: build_nip98_bearer(keys, iroh_relay_public_url) signs a kind:27235 event with the user's Nostr key over the canonical relay URL (sprout_auth::nip98_canonical_url with path '/relay'), base64-encodes the event JSON. This is the exact token the relay's iroh_relay::verify_bearer decodes + verifies. 3 unit tests. B-offer prefs — desktop/src-tauri/src/mesh_llm/offer.rs: persisted ComputeSharingPrefs (the avatar-menu sliders). Default is disabled, 1 concurrent consumer cap. build_offer() returns None when disabled so callers know to *delete* any prior offer rather than re-publish. JSON round-trip + helper tests, 4 tests. Workspace deps added: - desktop pulls sprout-auth (for the canonical URL helper, nostr-free at the API surface so the 0.36/0.37 nostr split doesn't matter). - desktop pulls iroh-base = =1.0.0-rc.0 with the 'key' feature for SecretKey/PublicKey/EndpointId. - desktop pulls thiserror = '2'. Tests: 9 desktop mesh_llm tests pass + 9 sprout-core mesh_llm tests pass. Workspace clippy + fmt clean (relay side; desktop has expected dead_code warnings until B4-B6 wire these in). Note: desktop crate requires sidecar binary stubs in desktop/src-tauri/binaries/ to typecheck; created via the existing 'just _ensure-sidecar-stubs' helper. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-core/src/lib.rs | 2 + crates/sprout-core/src/mesh_llm.rs | 286 +++++++++++++++++++++ desktop/src-tauri/Cargo.lock | 224 +++++++++++++++- desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/mesh_llm/endpoint.rs | 145 +++++++++++ desktop/src-tauri/src/mesh_llm/mod.rs | 24 ++ desktop/src-tauri/src/mesh_llm/nip98.rs | 105 ++++++++ desktop/src-tauri/src/mesh_llm/offer.rs | 192 ++++++++++++++ 9 files changed, 979 insertions(+), 3 deletions(-) create mode 100644 crates/sprout-core/src/mesh_llm.rs create mode 100644 desktop/src-tauri/src/mesh_llm/endpoint.rs create mode 100644 desktop/src-tauri/src/mesh_llm/mod.rs create mode 100644 desktop/src-tauri/src/mesh_llm/nip98.rs create mode 100644 desktop/src-tauri/src/mesh_llm/offer.rs diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index a0395a060..cecc3a61a 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod filter; pub mod git_perms; /// Sprout kind number registry — custom event type constants. pub mod kind; +/// Mesh-LLM compute-offer envelope (kind:31990 event content). +pub mod mesh_llm; /// Network utilities — SSRF-safe IP classification. pub mod network; /// Agent observer frame helpers. diff --git a/crates/sprout-core/src/mesh_llm.rs b/crates/sprout-core/src/mesh_llm.rs new file mode 100644 index 000000000..72ec9568d --- /dev/null +++ b/crates/sprout-core/src/mesh_llm.rs @@ -0,0 +1,286 @@ +//! Mesh-LLM compute offer envelope (kind:31990 event content). +//! +//! Published by Sprout members willing to share their local LLM/compute with +//! the rest of the relay. Consumers (other Sprout members) subscribe to +//! kind:31990 events scoped to relay membership and pick an offer that +//! matches their request. +//! +//! # Schema +//! +//! The event content is a JSON-serialised [`MeshLlmOffer`]. The event itself +//! is a NIP-33 parameterized-replaceable event addressed by +//! `(pubkey, kind:31990, d_tag)` where `d_tag` is the [`MeshLlmOffer::d_tag`]. +//! This means a member can replace their own offer atomically (e.g. when the +//! VRAM cap changes or a model is loaded/unloaded) without leaking dangling +//! stale offers. +//! +//! # Trust model +//! +//! The signing pubkey of the kind:31990 event is the Nostr identity of the +//! offering member; the event flows through the existing NIP-43 fan-out, so +//! only relay members ever see it. The iroh [`endpoint_id`](MeshLlmOffer::endpoint_id) +//! is a separate ed25519 keypair under the same member's control — the +//! Nostr signature on the kind:31990 event is what binds those two +//! identities together. +//! +//! When a consumer connects to the offered iroh endpoint, the consumer's own +//! NIP-98 bearer (signed with its Nostr key, NOT its iroh key) is what the +//! receiving relay uses to gate admission. So the chain of trust is: +//! +//! - The 31990 event proves "Nostr pubkey N offers compute via iroh endpoint E". +//! - The NIP-98 bearer on the iroh connection proves "Nostr pubkey N' is the +//! connecting party". +//! - Sprout's [`check_relay_membership`] confirms N' is a relay member. +//! +//! There is no need to also bind N' ↔ iroh-client-endpoint cryptographically: +//! once the membership decision allows the connection, the QUIC stream itself +//! is end-to-end-encrypted between the two iroh endpoints. The offering side +//! sees only `(member-pubkey N', iroh-endpoint E')`, both authenticated. + +use serde::{Deserialize, Serialize}; + +/// The full content of a kind:31990 event. +/// +/// Serialized to JSON and placed in the event's `content` field. The event's +/// `d` tag should equal [`MeshLlmOffer::d_tag`] so the event is a stable +/// addressable replacement target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MeshLlmOffer { + /// Schema version. Bumped on breaking changes. Current: `1`. + pub v: u32, + + /// Stable identifier for *this offering node* under the publisher's + /// pubkey. A member may publish multiple offers (e.g. one per host they + /// own, or one per GPU); each gets a distinct `d_tag`. + /// + /// MUST be ≤64 chars, ASCII alphanumeric + `-` + `_`. The same value + /// must be used as the kind:31990 event's `d` tag so replaces are + /// atomic. + pub d_tag: String, + + /// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh + /// renders it) of the offering node's iroh endpoint. Consumers dial + /// this through an iroh `NodeAddr`. + pub endpoint_id: String, + + /// Iroh relay URL through which the offering endpoint is reachable. + /// + /// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from + /// the publisher's view of NIP-11 `iroh_relay_url`. If multiple Sprout + /// relays are bridged into the same membership scope in the future, + /// this lets a consumer reach an offer behind a different host. + pub iroh_relay_url: String, + + /// Resource caps the offering side promises to honour for any single + /// consumer at a time. The publisher should re-publish (replacing the + /// previous event) whenever these change materially. + pub caps: ResourceCaps, + + /// Models this node is willing to serve. Empty list = "negotiate at + /// connect time"; non-empty = the consumer should pick one of these. + #[serde(default)] + pub models: Vec, + + /// Free-form opaque metadata field, reserved for future extensions + /// (e.g. region, accelerator type, presence-style state). + /// + /// Stored as `serde_json::Value` so additions don't require a schema + /// bump. `deny_unknown_fields` above keeps the *top-level* schema + /// strict; freeform extension lives here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// Resource caps the offering side commits to for a single consumer. +/// +/// Caps are *per-consumer* upper bounds — the offering side may host +/// multiple concurrent consumers, each subject to these caps. The +/// `max_concurrency` field expresses how many concurrent consumers the node +/// will accept across all consumers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceCaps { + /// Max VRAM (megabytes) the offering side will commit to a single + /// request. `None` = no cap advertised (consumer decides whether to + /// proceed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_vram_mb: Option, + + /// Max system RAM (megabytes) the offering side will commit to a + /// single request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_ram_mb: Option, + + /// Max number of concurrent consumers the offering node will accept + /// across all currently-running requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, +} + +/// A single model the offering node is prepared to serve. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModelOffer { + /// Model identifier. Convention: HuggingFace-style `org/name[:tag]`, + /// or `local:` for ad-hoc local files. Free-form string; + /// the consumer side is responsible for matching this against its own + /// requested model. + pub id: String, + + /// Optional human-readable label for UI surfaces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + + /// Approximate context window this model serves (tokens). Used for + /// UI hints; not enforced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_tokens: Option, +} + +impl MeshLlmOffer { + /// Maximum length of a `d_tag` string. Mirrors NIP-33's general rule + /// that `d` tags should be short and stable. + pub const MAX_D_TAG_LEN: usize = 64; + + /// Validate that a `d_tag` is well-formed: ≤64 chars, ASCII + /// alphanumeric / `-` / `_`. + pub fn is_valid_d_tag(d_tag: &str) -> bool { + !d_tag.is_empty() + && d_tag.len() <= Self::MAX_D_TAG_LEN + && d_tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + } + + /// Returns true if every required field is well-formed for publishing. + /// + /// This is a *publisher-side* sanity check; consumers should be + /// permissive in what they accept as long as serde-deserialization + /// succeeds. + pub fn is_publishable(&self) -> bool { + self.v == 1 + && Self::is_valid_d_tag(&self.d_tag) + && !self.endpoint_id.is_empty() + && !self.iroh_relay_url.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> MeshLlmOffer { + MeshLlmOffer { + v: 1, + d_tag: "node-1".to_string(), + endpoint_id: "1234abcd".to_string(), + iroh_relay_url: "https://relay.example.com/iroh".to_string(), + caps: ResourceCaps { + max_vram_mb: Some(24_000), + max_ram_mb: Some(64_000), + max_concurrency: Some(2), + }, + models: vec![ModelOffer { + id: "meta-llama/Llama-3-8B".to_string(), + label: Some("Llama 3 8B".to_string()), + context_tokens: Some(8192), + }], + extra: None, + } + } + + #[test] + fn round_trip_via_json() { + let offer = sample(); + let s = serde_json::to_string(&offer).expect("serialise"); + let back: MeshLlmOffer = serde_json::from_str(&s).expect("deserialise"); + assert_eq!(offer, back); + } + + #[test] + fn optional_caps_default_to_none() { + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r/", + "caps": {} + }"#; + let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal"); + assert!(offer.caps.max_vram_mb.is_none()); + assert!(offer.caps.max_ram_mb.is_none()); + assert!(offer.caps.max_concurrency.is_none()); + assert!(offer.models.is_empty()); + } + + #[test] + fn unknown_top_level_field_rejected() { + // deny_unknown_fields catches schema drift. + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": {}, + "wat": "lol" + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn unknown_caps_field_rejected() { + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": { "wat": 7 } + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn extra_freeform_passes_through() { + let offer = MeshLlmOffer { + extra: Some(serde_json::json!({"region": "us-east", "gpu": "H100"})), + ..sample() + }; + let s = serde_json::to_string(&offer).unwrap(); + let back: MeshLlmOffer = serde_json::from_str(&s).unwrap(); + assert_eq!(offer, back); + } + + #[test] + fn d_tag_validation() { + assert!(MeshLlmOffer::is_valid_d_tag("node-1")); + assert!(MeshLlmOffer::is_valid_d_tag("a")); + assert!(MeshLlmOffer::is_valid_d_tag(&"a".repeat(64))); + assert!(!MeshLlmOffer::is_valid_d_tag("")); + assert!(!MeshLlmOffer::is_valid_d_tag(&"a".repeat(65))); + assert!(!MeshLlmOffer::is_valid_d_tag("node 1")); + assert!(!MeshLlmOffer::is_valid_d_tag("node/1")); + assert!(!MeshLlmOffer::is_valid_d_tag("nodé")); + } + + #[test] + fn is_publishable_rejects_bad_d_tag() { + let mut offer = sample(); + offer.d_tag = "bad tag with spaces".to_string(); + assert!(!offer.is_publishable()); + } + + #[test] + fn is_publishable_rejects_wrong_version() { + let mut offer = sample(); + offer.v = 2; + assert!(!offer.is_publishable()); + } + + #[test] + fn is_publishable_rejects_empty_endpoint() { + let mut offer = sample(); + offer.endpoint_id = String::new(); + assert!(!offer.is_publishable()); + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b87b7273a..168b999e1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -403,6 +403,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base58ck" version = "0.1.0" @@ -854,6 +860,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "combine" version = "4.6.7" @@ -911,6 +923,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1154,6 +1175,44 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -1200,6 +1259,26 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + [[package]] name = "dbus" version = "0.9.11" @@ -1244,7 +1323,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1266,10 +1345,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1292,6 +1373,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1445,6 +1527,31 @@ dependencies = [ "libm", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -1570,6 +1677,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1934,11 +2047,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2201,6 +2316,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2559,6 +2683,28 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh-base" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more 2.1.1", + "digest 0.11.2", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2995,6 +3141,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "n0-error" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3649,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -5024,6 +5191,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5136,6 +5313,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5224,6 +5407,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sprout" version = "0.1.0" @@ -5239,6 +5433,7 @@ dependencies = [ "futures-util", "hex", "infer", + "iroh-base", "libc", "neteq", "nostr 0.36.0", @@ -5253,6 +5448,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sherpa-onnx", + "sprout-auth", "sprout-core", "sprout-persona", "sprout-sdk", @@ -5270,6 +5466,7 @@ dependencies = [ "tauri-plugin-websocket", "tauri-plugin-window-state", "tempfile", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tokio-util", @@ -5280,17 +5477,37 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "sprout-auth" +version = "0.1.0" +dependencies = [ + "hex", + "nostr 0.36.0", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "sprout-core", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "sprout-core" version = "0.1.0" dependencies = [ "chrono", "hex", + "hmac 0.13.0", "nostr 0.36.0", "percent-encoding", "rand 0.10.1", "serde", "serde_json", + "sha2 0.11.0", "subtle", "thiserror 2.0.18", "url", @@ -6301,6 +6518,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -8085,7 +8303,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.14.0", "lzma-rs", "memchr", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d880acff2..4e052432d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -52,9 +52,11 @@ nostr-compat = { package = "nostr", version = "0.36" } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } url = "2" +sprout-auth = { path = "../../crates/sprout-auth" } sprout-core = { path = "../../crates/sprout-core" } sprout-persona = { path = "../../crates/sprout-persona" } sprout-sdk = { path = "../../crates/sprout-sdk" } +iroh-base = { version = "=1.0.0-rc.0", features = ["key"] } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -73,5 +75,6 @@ earshot = "1.0" rubato = "2.0" audioadapter-buffers = "3.0" tempfile = "3" +thiserror = "2" [dev-dependencies] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0443243a0..81a699963 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod events; mod huddle; mod managed_agents; mod media_proxy; +mod mesh_llm; mod migration; mod models; pub mod nostr_convert; diff --git a/desktop/src-tauri/src/mesh_llm/endpoint.rs b/desktop/src-tauri/src/mesh_llm/endpoint.rs new file mode 100644 index 000000000..df5a34b0d --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/endpoint.rs @@ -0,0 +1,145 @@ +//! Iroh endpoint keypair: persisted per Sprout install. +//! +//! The user's Nostr identity (`identity.key`) is separate from the iroh +//! endpoint identity. The Nostr key signs kind:31990 offers and the NIP-98 +//! admission bearer; the iroh key proves possession of the iroh `EndpointId` +//! during QUIC handshake. The kind:31990 event's Nostr signature binds the +//! two identities together — anyone who trusts the Nostr pubkey can trust +//! the advertised endpoint id, because nobody else could have signed that +//! offer. +//! +//! We deliberately do **not** derive the iroh key from the Nostr key: +//! +//! - It would couple key rotation: rotating the Nostr key would silently +//! change the iroh endpoint id, breaking active offers. +//! - It would force a particular HKDF over the Nostr seckey, picking a new +//! custody convention nobody else implements. +//! - The iroh key is generated once, never leaves the desktop, and is +//! already inside the same Tauri sandbox as `identity.key`. Two files, +//! one trust boundary. + +use std::path::{Path, PathBuf}; + +use iroh_base::SecretKey; +use tauri::{AppHandle, Manager}; + +const KEY_FILENAME: &str = "mesh_iroh.key"; + +/// Errors loading or creating the iroh endpoint keypair. +#[derive(Debug, thiserror::Error)] +pub enum EndpointKeyError { + /// Couldn't determine the Tauri app data dir. + #[error("app data dir: {0}")] + AppDataDir(String), + /// Filesystem I/O failure. + #[error("filesystem: {0}")] + Io(String), + /// On-disk key file exists but is malformed. + #[error("malformed key file: {0}")] + MalformedKeyFile(String), +} + +/// Resolve the iroh endpoint key file path under the Tauri app data dir. +fn key_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| EndpointKeyError::AppDataDir(e.to_string()))?; + std::fs::create_dir_all(&data_dir).map_err(|e| EndpointKeyError::Io(e.to_string()))?; + Ok(data_dir.join(KEY_FILENAME)) +} + +/// Load the persisted iroh endpoint keypair, generating + saving one on +/// first run. Mirrors the pattern in [`crate::app_state::resolve_persisted_identity`]. +/// +/// File format: 32 raw secret-key bytes encoded as lower-case hex on a single +/// line. Matches what `iroh_base::SecretKey`'s `FromStr` accepts. +pub fn load_or_create_endpoint_key(app: &AppHandle) -> Result { + let path = key_path(app)?; + + if path.exists() { + match load_key_file(&path) { + Ok(k) => return Ok(k), + Err(e) => { + // Quarantine corrupt files so we never overwrite a usable + // backup — same pattern as `identity.key`. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let bad = path.with_extension(format!("bad.{ts}")); + let _ = std::fs::rename(&path, &bad); + eprintln!( + "sprout-desktop: corrupt mesh_iroh.key ({e}), quarantined to {}", + bad.display(), + ); + } + } + } + + let key = SecretKey::generate(); + save_key_file(&path, &key)?; + eprintln!( + "sprout-desktop: generated and saved mesh iroh endpoint pubkey {}", + key.public(), + ); + Ok(key) +} + +fn load_key_file(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| EndpointKeyError::Io(e.to_string()))?; + let trimmed = content.trim(); + trimmed + .parse::() + .map_err(|e| EndpointKeyError::MalformedKeyFile(e.to_string())) +} + +fn save_key_file(path: &Path, key: &SecretKey) -> Result<(), EndpointKeyError> { + let bytes = key.to_bytes(); + let hex = hex::encode(bytes); + // Atomic write: write to a temp file in the same dir, fsync, rename. + let dir = path + .parent() + .ok_or_else(|| EndpointKeyError::Io("key path has no parent".to_string()))?; + let tmp = tempfile::NamedTempFile::new_in(dir) + .map_err(|e| EndpointKeyError::Io(format!("temp file: {e}")))?; + std::fs::write(tmp.path(), hex.as_bytes()) + .map_err(|e| EndpointKeyError::Io(format!("write temp: {e}")))?; + tmp.persist(path) + .map_err(|e| EndpointKeyError::Io(format!("rename temp: {e}")))?; + // No fsync of the directory here — matches the existing identity.key path. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// Round-trip a generated key through the save/load functions. + #[test] + fn round_trip_save_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mesh_iroh.key"); + let original = SecretKey::generate(); + save_key_file(&path, &original).expect("save"); + let loaded = load_key_file(&path).expect("load"); + assert_eq!(original.to_bytes(), loaded.to_bytes()); + } + + /// A truncated/corrupted file is rejected with `MalformedKeyFile`. + #[test] + fn corrupt_file_returns_malformed() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mesh_iroh.key"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"not a valid hex key").unwrap(); + drop(f); + let err = load_key_file(&path).expect_err("should fail"); + match err { + EndpointKeyError::MalformedKeyFile(_) => {} + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs new file mode 100644 index 000000000..e6a4491ed --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -0,0 +1,24 @@ +//! Mesh-LLM client: discover, dial, and publish kind:31990 offers. +//! +//! This module is the desktop-side counterpart to `sprout-relay`'s embedded +//! iroh-relay (see `crates/sprout-relay/src/iroh_relay.rs`). The relay gates +//! admission with NIP-98 + relay membership; this module signs that bearer +//! token, dials offers advertised under kind:31990, and publishes our own +//! offer when the user enables compute-sharing. +//! +//! ## Submodules +//! +//! - [`endpoint`]: long-lived iroh endpoint keypair persisted at +//! `{app_data_dir}/mesh_iroh.key`. +//! - [`nip98`]: build the NIP-98 bearer event signed with the user's Nostr +//! key for a given canonical relay URL. +//! - [`offer`]: load/save the user's mesh-LLM offer preferences +//! (VRAM/RAM/concurrency caps, models). + +pub mod endpoint; +pub mod nip98; +pub mod offer; + +pub use endpoint::{load_or_create_endpoint_key, EndpointKeyError}; +pub use nip98::{build_nip98_bearer, Nip98BearerError}; +pub use offer::{ComputeSharingPrefs, OfferPrefsError}; diff --git a/desktop/src-tauri/src/mesh_llm/nip98.rs b/desktop/src-tauri/src/mesh_llm/nip98.rs new file mode 100644 index 000000000..1e821459a --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/nip98.rs @@ -0,0 +1,105 @@ +//! NIP-98 bearer token builder for iroh-relay admission. +//! +//! Signs a kind:27235 event over the canonical iroh-relay URL using the +//! user's Nostr identity, then base64-encodes the event JSON. The receiving +//! relay's `sprout_relay::iroh_relay` access callback decodes + verifies +//! this exact bearer string. +//! +//! Both sides use the same `sprout_auth::nip98_canonical_url` helper, so +//! path-prefix / trailing-slash / localhost-vs-127.0.0.1 drift cannot +//! create undebuggable per-connection denials. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + +const IROH_RELAY_PATH: &str = "/relay"; +const NIP98_METHOD: &str = "GET"; + +/// Errors produced while building a NIP-98 bearer. +#[derive(Debug, thiserror::Error)] +pub enum Nip98BearerError { + /// `iroh_relay_url` from NIP-11 wasn't a parseable URL. + #[error("invalid iroh relay URL: {0}")] + InvalidUrl(String), + /// `nostr` library failed to construct/sign the event. + #[error("event signing failed: {0}")] + Sign(String), + /// Tag construction failed (should never happen for static "u"/"method"). + #[error("tag construction failed: {0}")] + Tag(String), +} + +/// Build the `Authorization: Bearer ` value for an iroh-relay +/// admission request. +/// +/// `iroh_relay_public_url` is the value taken verbatim from the target +/// relay's NIP-11 `iroh_relay_url` field. We canonicalise it the same way +/// the relay does before signing the `u` tag. +pub fn build_nip98_bearer( + keys: &Keys, + iroh_relay_public_url: &str, +) -> Result { + let canonical = sprout_auth::nip98_canonical_url(iroh_relay_public_url, IROH_RELAY_PATH) + .ok_or_else(|| Nip98BearerError::InvalidUrl(iroh_relay_public_url.to_string()))?; + + let tags = vec![ + Tag::parse(["u", &canonical]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?, + Tag::parse(["method", NIP98_METHOD]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?, + ]; + + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| Nip98BearerError::Sign(e.to_string()))?; + + let json = event.as_json(); + Ok(STANDARD.encode(json)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signs_for_canonical_url() { + let keys = Keys::generate(); + let token = + build_nip98_bearer(&keys, "https://relay.example.com/iroh").expect("build bearer"); + // Round-trip through base64 -> JSON to confirm the event has the + // canonical URL in its `u` tag. + let bytes = STANDARD.decode(&token).expect("base64 decode"); + let json = String::from_utf8(bytes).expect("utf8"); + assert!( + json.contains("\"u\""), + "bearer event should carry `u` tag: {json}", + ); + assert!( + json.contains("https://relay.example.com/iroh/relay"), + "bearer event should canonicalise the URL: {json}", + ); + assert!( + json.contains("\"method\""), + "bearer event should carry `method` tag", + ); + } + + #[test] + fn rejects_unparseable_url() { + let keys = Keys::generate(); + let err = build_nip98_bearer(&keys, "definitely not a url").expect_err("should fail"); + match err { + Nip98BearerError::InvalidUrl(_) => {} + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn different_keys_produce_different_bearers() { + let a = Keys::generate(); + let b = Keys::generate(); + let ta = build_nip98_bearer(&a, "https://relay.example.com/iroh").unwrap(); + let tb = build_nip98_bearer(&b, "https://relay.example.com/iroh").unwrap(); + assert_ne!(ta, tb); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/offer.rs b/desktop/src-tauri/src/mesh_llm/offer.rs new file mode 100644 index 000000000..441b0accd --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/offer.rs @@ -0,0 +1,192 @@ +//! Persisted compute-sharing preferences (the avatar-menu sliders). +//! +//! When the user turns on compute sharing and dials the VRAM/RAM/concurrency +//! sliders in the bottom-left avatar menu, those preferences live in +//! `{app_data_dir}/mesh_offer.json`. The publisher reads this file when it +//! builds a kind:31990 event; the settings UI reads + writes it via Tauri +//! commands. +//! +//! Keeping the prefs as a plain JSON file (rather than baking them into the +//! kind:31990 event directly) lets the user toggle sharing without +//! republishing on every restart and makes the file inspectable for support. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use sprout_core::mesh_llm::{MeshLlmOffer, ModelOffer, ResourceCaps}; +use tauri::{AppHandle, Manager}; + +const OFFER_FILENAME: &str = "mesh_offer.json"; +const DEFAULT_D_TAG: &str = "default"; + +/// Errors loading or saving offer preferences. +#[derive(Debug, thiserror::Error)] +pub enum OfferPrefsError { + /// Couldn't determine the Tauri app data dir. + #[error("app data dir: {0}")] + AppDataDir(String), + /// Filesystem I/O failure. + #[error("filesystem: {0}")] + Io(String), + /// JSON parse / serialize failure. + #[error("json: {0}")] + Json(String), +} + +/// Persisted compute-sharing preferences. +/// +/// `enabled = false` is the default; the publisher must skip publishing in +/// that case and **must delete any previously-published offer** (NIP-09 or +/// kind:31990 with empty content per NIP-33's replace-with-empty convention). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComputeSharingPrefs { + /// Whether the user has opted in to sharing compute. Default: `false`. + pub enabled: bool, + + /// Caps the user wants to advertise. `None` on a field = "no cap"; the + /// publisher passes `Some(0)` through unchanged because the schema + /// allows it (consumers should treat 0 as "explicit zero"). + pub caps: ResourceCaps, + + /// Models the user wants to advertise. May be empty. + pub models: Vec, + + /// Persistent `d_tag` for the user's offer. Generated once on first + /// enable and re-used so replaces target the same address. + pub d_tag: String, +} + +impl Default for ComputeSharingPrefs { + fn default() -> Self { + Self { + enabled: false, + caps: ResourceCaps { + max_vram_mb: None, + max_ram_mb: None, + max_concurrency: Some(1), + }, + models: vec![], + d_tag: DEFAULT_D_TAG.to_string(), + } + } +} + +impl ComputeSharingPrefs { + /// Builds the kind:31990 offer envelope to publish. Returns `None` if + /// sharing is disabled; the publisher should then *delete* any prior + /// offer rather than calling this. + pub fn build_offer( + &self, + endpoint_id: &str, + iroh_relay_url: &str, + ) -> Option { + if !self.enabled { + return None; + } + Some(MeshLlmOffer { + v: 1, + d_tag: self.d_tag.clone(), + endpoint_id: endpoint_id.to_string(), + iroh_relay_url: iroh_relay_url.to_string(), + caps: self.caps.clone(), + models: self.models.clone(), + extra: None, + }) + } +} + +fn prefs_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| OfferPrefsError::AppDataDir(e.to_string()))?; + std::fs::create_dir_all(&data_dir).map_err(|e| OfferPrefsError::Io(e.to_string()))?; + Ok(data_dir.join(OFFER_FILENAME)) +} + +/// Load persisted prefs; returns [`ComputeSharingPrefs::default`] if the file +/// is absent. On parse errors, returns the error verbatim — callers should +/// surface it in the settings UI rather than silently resetting. +pub fn load_prefs(app: &AppHandle) -> Result { + let path = prefs_path(app)?; + if !path.exists() { + return Ok(ComputeSharingPrefs::default()); + } + let content = std::fs::read_to_string(&path).map_err(|e| OfferPrefsError::Io(e.to_string()))?; + serde_json::from_str(&content).map_err(|e| OfferPrefsError::Json(e.to_string())) +} + +/// Atomically replace the on-disk prefs file. +pub fn save_prefs(app: &AppHandle, prefs: &ComputeSharingPrefs) -> Result<(), OfferPrefsError> { + let path = prefs_path(app)?; + let dir = path + .parent() + .ok_or_else(|| OfferPrefsError::Io("prefs path has no parent".to_string()))?; + let json = serde_json::to_string_pretty(prefs).map_err(|e| OfferPrefsError::Json(e.to_string()))?; + let tmp = tempfile::NamedTempFile::new_in(dir) + .map_err(|e| OfferPrefsError::Io(format!("temp file: {e}")))?; + std::fs::write(tmp.path(), json.as_bytes()) + .map_err(|e| OfferPrefsError::Io(format!("write temp: {e}")))?; + tmp.persist(&path) + .map_err(|e| OfferPrefsError::Io(format!("rename temp: {e}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled() { + let prefs = ComputeSharingPrefs::default(); + assert!(!prefs.enabled); + assert_eq!(prefs.caps.max_concurrency, Some(1)); + assert_eq!(prefs.d_tag, DEFAULT_D_TAG); + } + + #[test] + fn build_offer_returns_none_when_disabled() { + let prefs = ComputeSharingPrefs::default(); + assert!( + prefs + .build_offer("endpoint", "https://relay/iroh") + .is_none() + ); + } + + #[test] + fn build_offer_returns_envelope_when_enabled() { + let prefs = ComputeSharingPrefs { + enabled: true, + ..Default::default() + }; + let offer = prefs + .build_offer("endpoint-id-hex", "https://relay.example.com/iroh") + .expect("offer"); + assert_eq!(offer.endpoint_id, "endpoint-id-hex"); + assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh"); + assert!(offer.is_publishable()); + } + + /// Round-trip prefs through serde so the on-disk format stays stable. + #[test] + fn round_trip_via_json() { + let prefs = ComputeSharingPrefs { + enabled: true, + caps: ResourceCaps { + max_vram_mb: Some(8192), + max_ram_mb: Some(16_000), + max_concurrency: Some(3), + }, + models: vec![ModelOffer { + id: "qwen/Qwen2.5-7B-Instruct".to_string(), + label: Some("Qwen 2.5 7B".to_string()), + context_tokens: Some(32_768), + }], + d_tag: "node-laptop".to_string(), + }; + let s = serde_json::to_string(&prefs).unwrap(); + let back: ComputeSharingPrefs = serde_json::from_str(&s).unwrap(); + assert_eq!(prefs, back); + } +}