mesh-llm: offer envelope (sprout-core) + desktop building blocks

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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-19 16:55:45 -04:00
co-authored by Dawn
parent db6c1103aa
commit 1dbc89cc11
9 changed files with 979 additions and 3 deletions
+2
View File
@@ -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.
+286
View File
@@ -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<ModelOffer>,
/// 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<serde_json::Value>,
}
/// 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<u32>,
/// 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<u32>,
/// 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<u32>,
}
/// 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:<filename>` 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<String>,
/// 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<u32>,
}
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::<MeshLlmOffer>(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::<MeshLlmOffer>(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());
}
}
+221 -3
View File
@@ -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",
+3
View File
@@ -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]
+1
View File
@@ -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;
+145
View File
@@ -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<PathBuf, EndpointKeyError> {
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<SecretKey, EndpointKeyError> {
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<SecretKey, EndpointKeyError> {
let content =
std::fs::read_to_string(path).map_err(|e| EndpointKeyError::Io(e.to_string()))?;
let trimmed = content.trim();
trimmed
.parse::<SecretKey>()
.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:?}"),
}
}
}
+24
View File
@@ -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};
+105
View File
@@ -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 <token>` 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<String, Nip98BearerError> {
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);
}
}
+192
View File
@@ -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<ModelOffer>,
/// 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<MeshLlmOffer> {
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<PathBuf, OfferPrefsError> {
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<ComputeSharingPrefs, OfferPrefsError> {
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);
}
}