diff --git a/Cargo.lock b/Cargo.lock index 9e5951c043..d19342367f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" dependencies = [ "keyring-core", "log", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -1114,8 +1114,12 @@ dependencies = [ "clap", "diffy", "dirs", + "futures-util", "hex", + "httpdate", "infer", + "keyring 3.6.3", + "libc", "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", @@ -1126,6 +1130,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.29.0", "url", "uuid", ] @@ -2606,6 +2611,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", + "dbus", + "fastrand", + "hkdf 0.12.4", + "num", + "once_cell", + "openssl", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "defmt" version = "1.1.1" @@ -3892,6 +3927,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -4818,6 +4862,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "openssl", + "secret-service 4.0.0", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "keyring" version = "4.2.0" @@ -5020,6 +5079,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.9.0" @@ -5512,7 +5581,7 @@ dependencies = [ "http-body-util", "httparse", "iroh", - "keyring", + "keyring 4.2.0", "libc", "mdns-sd", "mesh-llm-api-server", @@ -5599,7 +5668,7 @@ dependencies = [ "dirs", "ed25519-dalek", "hex", - "keyring", + "keyring 4.2.0", "rand 0.10.1", "serde", "serde_json", @@ -8828,7 +8897,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -8856,7 +8925,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -9105,6 +9174,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "futures-util", + "generic-array", + "hkdf 0.12.4", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2 0.10.9", + "zbus 4.4.0", +] + [[package]] name = "secret-service" version = "5.2.0" @@ -9115,13 +9203,26 @@ dependencies = [ "cbc 0.2.1", "futures-util", "getrandom 0.4.3", - "hkdf", + "hkdf 0.13.0", "hybrid-array", "num", "once_cell", "serde", "sha2 0.11.0", - "zbus", + "zbus 5.19.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", ] [[package]] @@ -9980,7 +10081,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", + "hkdf 0.13.0", "hmac 0.13.0", "itoa", "log", @@ -11976,6 +12077,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -12302,6 +12412,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xet-client" version = "1.6.0" @@ -12497,6 +12617,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.19.0" @@ -12527,9 +12679,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -12539,8 +12691,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" dependencies = [ "keyring-core", - "secret-service", - "zbus", + "secret-service 5.2.0", + "zbus 5.19.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -12553,9 +12718,20 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -12566,7 +12742,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -12725,6 +12901,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.15.0" @@ -12736,8 +12925,21 @@ dependencies = [ "serde", "winnow 1.0.3", "zcheapstr", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -12750,7 +12952,18 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", - "zvariant_utils", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 25a39ea4c7..e0925d9489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ anyhow = "1" # Utilities uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +httpdate = "1" # JWT / JWS verification (NIP-FI federated identity assertions) jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee..fa08534db8 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -40,6 +40,7 @@ uuid = { workspace = true } # RFC3339 observer timestamps for owner-reviewed agent draft requests chrono = { workspace = true } +httpdate = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } @@ -86,10 +87,24 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } +[target.'cfg(unix)'.dependencies] +# getuid for the desktop provider's same-user check +libc = "0.2" + +# OS keychain for paired desktop-provider tokens (never a file) +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "=3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "=3.6.3", default-features = false, features = ["sync-secret-service", "crypto-rust", "vendored"] } + [dev-dependencies] # Scratch files for channel-templates.json fixtures in tests tempfile = "3" # Minimal HTTP test server for retry/policy integration tests axum = { workspace = true } +# A raw WebSocket relay for NIP-FI close-classification tests +tokio-tungstenite = { workspace = true } +futures-util = { workspace = true } # `test-util` enables paused-time control for deterministic timeout tests tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 7aa760aebc..5a8e39d617 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -20,6 +20,45 @@ export BUZZ_PRIVATE_KEY="nsec1..." buzz channels list ``` +### Which credentials are used + +The CLI uses the first of these that is configured: + +1. **An explicit key** (`--private-key` or `BUZZ_PRIVATE_KEY`), with the + headless NIP-FI settings below when they are set. +2. **The Buzz desktop** (macOS and Linux), through a token it gave this + process or one saved by `buzz auth desktop pair`. The desktop signs and + supplies NIP-FI evidence itself; headless NIP-FI settings are ignored, with + a warning naming them. + +With neither, commands fail with an `auth_error`. + +### Federated identity (NIP-FI) + +A relay that enforces federated identity also needs an identity assertion on +every protected request (`docs/nips/NIP-FI.md`). Give the CLI your NIP-FA +adapter session and it requests one; the adapter mints it +(`docs/nips/NIP-FA.md` §Relay assertion). The CLI caches the assertion for the +process, requests a new one shortly before it expires, and sends it as +`Nostr-Federated-Identity` on HTTP requests and the WebSocket upgrade. + +The session is a human sign-in for your own key: the adapter issues +assertions only for the key bound to the signed-in user. It is not an agent +grant, and it cannot authorize an agent key. + +```bash +export BUZZ_PRIVATE_KEY="nsec1..." +export BUZZ_FEDERATED_IDENTITY_ADAPTER="https://adapter.example" +export BUZZ_FEDERATED_IDENTITY_TOKEN="..." # your adapter session token; env only, never a flag +buzz channels list +``` + +The token goes only to the adapter, never through a redirect, and never into +output. Adapter outages (network errors, 429, 5xx) are retried a bounded number +of times and then fail as a retryable `adapter_error` before anything reaches +the relay; an adapter refusal is an `auth_error` and is not retried. On relays without NIP-FI, leave both unset: nothing changes. An +enforcing relay's `401` without them is an `auth_error` naming both settings. + ## Usage All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict. @@ -223,6 +262,9 @@ buzz [flags] ├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Buzz Relay REST API │ (clap) (handlers) (reqwest) │ + ├─ credentials.rs (identity, signer, NIP-FI evidence; every request + │ is authorized through client.rs `authorize`) + ├─ federated_identity.rs (NIP-FA adapter session → NIP-FI assertion) ├─ validate.rs (UUID, hex, content size, percent-encode) └─ error.rs (CliError → JSON stderr + exit code) diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index e5f2513069..54708af764 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -1,9 +1,10 @@ //! Owner-reviewed agent draft requests published through Buzz observer frames. -use buzz_core::observer::{encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY}; -use nostr::{Event, Keys, PublicKey}; +use buzz_core::observer::{OBSERVER_FRAME_TELEMETRY, OBSERVER_MAX_PLAINTEXT_LEN}; +use nostr::{Event, PublicKey}; use serde::Serialize; +use crate::credentials::Credentials; use crate::error::CliError; const AGENT_REQUEST_KIND: &str = "agent_management_request"; @@ -99,8 +100,8 @@ fn optional(value: Option, label: &str) -> Result, CliErr value.map(|value| required(value, label, 300)).transpose() } -fn build( - keys: &Keys, +async fn build( + credentials: &Credentials, owner: &PublicKey, channel_id: String, request_kind: &'static str, @@ -123,17 +124,23 @@ fn build( request, }, }; - let encrypted = encrypt_observer_payload(keys, owner, &payload) - .map_err(|error| CliError::Other(format!("could not encrypt draft request: {error}")))?; - let event = buzz_sdk::build_agent_observer_frame( + let plaintext = serde_json::to_string(&payload) + .map_err(|error| CliError::Other(format!("could not encode draft request: {error}")))?; + if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN { + return Err(CliError::Usage(format!( + "draft request is too large: {} bytes (max {OBSERVER_MAX_PLAINTEXT_LEN})", + plaintext.len() + ))); + } + let encrypted = credentials.nip44_encrypt(owner, &plaintext).await?; + let builder = buzz_sdk::build_agent_observer_frame( &owner.to_hex(), - &keys.public_key().to_hex(), + &credentials.public_key().to_hex(), OBSERVER_FRAME_TELEMETRY, &encrypted, ) - .map_err(|error| CliError::Other(format!("could not build draft request: {error}")))? - .sign_with_keys(keys) - .map_err(|error| CliError::Other(format!("could not sign draft request: {error}")))?; + .map_err(|error| CliError::Other(format!("could not build draft request: {error}")))?; + let event = credentials.sign(builder).await?; Ok(BuiltDraftRequest { event, request_id, @@ -141,8 +148,8 @@ fn build( }) } -pub fn build_create( - keys: &Keys, +pub(crate) async fn build_create( + credentials: &Credentials, owner: &PublicKey, draft: CreateAgentDraft, ) -> Result { @@ -155,17 +162,18 @@ pub fn build_create( system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?, }; build( - keys, + credentials, owner, channel_id, AGENT_REQUEST_KIND, "create", request, ) + .await } -pub fn build_update( - keys: &Keys, +pub(crate) async fn build_update( + credentials: &Credentials, owner: &PublicKey, draft: UpdateAgentDraft, ) -> Result { @@ -206,17 +214,18 @@ pub fn build_update( )); } build( - keys, + credentials, owner, channel_id, AGENT_REQUEST_KIND, "update", request, ) + .await } -pub fn build_project_channel( - keys: &Keys, +pub(crate) async fn build_project_channel( + credentials: &Credentials, owner: &PublicKey, draft: CreateProjectChannelDraft, ) -> Result { @@ -242,28 +251,30 @@ pub fn build_project_channel( template_name: optional(draft.template_name, "template")?, }; build( - keys, + credentials, owner, home_channel_id, PROJECT_CHANNEL_REQUEST_KIND, "create", request, ) + .await } #[cfg(test)] mod tests { use super::*; use buzz_core::observer::{decrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_TAG}; + use nostr::Keys; const CHANNEL: &str = "7c07e659-3610-42f4-9a5e-1e9973c09da9"; - #[test] - fn create_is_owner_encrypted_and_matches_desktop_contract() { + #[tokio::test] + async fn create_is_owner_encrypted_and_matches_desktop_contract() { let agent = Keys::generate(); let owner = Keys::generate(); let built = build_create( - &agent, + &Credentials::from_keys(agent.clone()), &owner.public_key(), CreateAgentDraft { channel_id: CHANNEL.into(), @@ -271,6 +282,7 @@ mod tests { system_prompt: "Find sources.".into(), }, ) + .await .unwrap(); assert_eq!(built.event.kind.as_u16(), 24_200); @@ -306,10 +318,10 @@ mod tests { assert!(payload["payload"]["request"].get("respondTo").is_none()); } - #[test] - fn update_requires_a_change() { + #[tokio::test] + async fn update_requires_a_change() { let error = build_update( - &Keys::generate(), + &Credentials::from_keys(Keys::generate()), &Keys::generate().public_key(), UpdateAgentDraft { channel_id: CHANNEL.into(), @@ -322,14 +334,15 @@ mod tests { respond_to: None, }, ) + .await .unwrap_err(); assert!(error.to_string().contains("at least one field")); } - #[test] - fn create_rejects_invalid_channel() { + #[tokio::test] + async fn create_rejects_invalid_channel() { let error = build_create( - &Keys::generate(), + &Credentials::from_keys(Keys::generate()), &Keys::generate().public_key(), CreateAgentDraft { channel_id: "general".into(), @@ -337,16 +350,17 @@ mod tests { system_prompt: "Help".into(), }, ) + .await .unwrap_err(); assert!(error.to_string().contains("invalid channel UUID")); } - #[test] - fn project_channel_request_is_owner_encrypted() { + #[tokio::test] + async fn project_channel_request_is_owner_encrypted() { let agent = Keys::generate(); let owner = Keys::generate(); let built = build_project_channel( - &agent, + &Credentials::from_keys(agent.clone()), &owner.public_key(), CreateProjectChannelDraft { home_channel_id: CHANNEL.into(), @@ -357,6 +371,7 @@ mod tests { template_name: Some("Release team".into()), }, ) + .await .unwrap(); let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); diff --git a/crates/buzz-cli/src/cached_assertion.rs b/crates/buzz-cli/src/cached_assertion.rs new file mode 100644 index 0000000000..d5df360c9a --- /dev/null +++ b/crates/buzz-cli/src/cached_assertion.rs @@ -0,0 +1,128 @@ +//! The one cached NIP-FI assertion an evidence link holds for its relay, +//! shared by the headless adapter session and the Buzz desktop link: one +//! `exp` parser and one refresh rule. + +use tokio::sync::Mutex; + +use crate::error::CliError; + +/// Refresh at least this long before expiry. +const REFRESH_MARGIN_SECS: u64 = 30; + +/// An evidence link's current assertion, or its answer that it has none. +#[derive(Default)] +pub(crate) struct CachedAssertion(Mutex, u64)>>); + +impl CachedAssertion { + /// The cached answer until its refresh point, else `fetch`'s. A fetched + /// assertion must be a compact JWS with an `exp`; `None` is kept until + /// the next command. Concurrent callers share one fetch. + pub(crate) async fn get(&self, fetch: F) -> Result, CliError> + where + F: std::future::Future, CliError>>, + { + let mut cached = self.0.lock().await; + if let Some((assertion, refresh)) = cached.as_ref() { + if unix_now() < *refresh { + return Ok(assertion.clone()); + } + } + let assertion = fetch.await?; + let refresh = match assertion.as_deref() { + Some(jws) => refresh_at( + unix_now(), + jws_exp(jws).ok_or_else(|| { + CliError::Auth("the evidence source returned an unreadable assertion".into()) + })?, + ), + None => u64::MAX, + }; + *cached = Some((assertion.clone(), refresh)); + Ok(assertion) + } +} + +/// Refresh at the earlier of half-life and [`REFRESH_MARGIN_SECS`] before +/// `exp`; when that margin is already past, at half-life, so a short-lived +/// assertion is not fetched again on every request. +pub(crate) fn refresh_at(now: u64, exp: u64) -> u64 { + let half_life = now + exp.saturating_sub(now) / 2; + match exp.saturating_sub(REFRESH_MARGIN_SECS) { + early if early > now => early.min(half_life), + _ => half_life, + } +} + +/// The `exp` claim of a structurally valid compact JWS: three non-empty +/// unpadded base64url segments, a JSON-object header naming `alg`, and a +/// JSON-object payload with an integer `exp`. The relay verifies the +/// signature; the CLI only refuses output it could not present or refresh. +pub(crate) fn jws_exp(token: &str) -> Option { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + let mut segments = token.split('.'); + let (Some(header), Some(payload), Some(signature), None) = ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ) else { + return None; + }; + let json = |segment: &str| -> Option> { + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).ok()?).ok() + }; + if signature.is_empty() || URL_SAFE_NO_PAD.decode(signature).is_err() { + return None; + } + json(header)?.get("alg")?.as_str()?; + json(payload)?.get("exp")?.as_u64() +} + +pub(crate) fn unix_now() -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + #[cfg(test)] + let now = now + test_clock::SKEW.with(std::cell::Cell::get); + now +} + +/// A clock tests move forward instead of sleeping. Per thread, so it moves +/// only for the test (and its current-thread runtime) that advances it. +#[cfg(test)] +pub(crate) mod test_clock { + thread_local! { + pub(super) static SKEW: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + pub(crate) fn advance(secs: u64) { + SKEW.with(|skew| skew.set(skew.get() + secs)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_is_at_the_earlier_of_half_life_and_the_margin() { + assert_eq!(refresh_at(1_000, 1_300), 1_150, "long-lived: half-life"); + assert_eq!(refresh_at(1_000, 1_060), 1_030); + assert_eq!(refresh_at(1_000, 1_040), 1_010, "short-lived: the margin"); + assert_eq!(refresh_at(1_000, 1_020), 1_010, "margin past: half-life"); + assert_eq!(refresh_at(1_000, 1_001), 1_000); + } + + #[test] + fn jws_exp_needs_a_compact_jws_with_alg_and_exp() { + let payload = "eyJleHAiOjE3OTAwMDAzMDB9"; // {"exp":1790000300} + let header = "eyJhbGciOiJFUzI1NiJ9"; // {"alg":"ES256"} + assert_eq!( + jws_exp(&format!("{header}.{payload}.c2ln")), + Some(1_790_000_300) + ); + assert_eq!(jws_exp(&format!("e30.{payload}.c2ln")), None, "no alg"); + assert_eq!(jws_exp(&format!("{header}.{payload}")), None); + assert_eq!(jws_exp(&format!("{header}.{payload}.c2ln.x")), None); + } +} diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 1b9726940f..9a03e40629 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1,13 +1,18 @@ use std::future::Future; use std::time::Duration; -use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use nostr::{EventBuilder, JsonUtil, Kind, PublicKey, Tag}; use sha2::{Digest, Sha256}; +use crate::credentials::{ + Credentials, FEDERATED_IDENTITY_ADAPTER_ENV, FEDERATED_IDENTITY_TOKEN_ENV, +}; use crate::error::CliError; +/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport). +const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity"; + /// Descriptor returned by the relay after a successful upload. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BlobDescriptor { @@ -75,40 +80,6 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; /// Maximum file size for video uploads (500 MB). const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; -/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value. -/// -/// The event includes: -/// - `u` tag: the full request URL -/// - `method` tag: HTTP method (GET, POST, PUT, DELETE) -/// - `payload` tag: SHA-256 hex of the request body (if present) -fn sign_nip98( - keys: &Keys, - method: &str, - url: &str, - body: Option<&[u8]>, -) -> Result { - let mut tags = vec![ - Tag::parse(["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, - Tag::parse(["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, - // Nonce prevents replay rejection for rapid-fire requests with identical bodies. - Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?, - ]; - if let Some(b) = body { - let hash = hex::encode(Sha256::digest(b)); - tags.push( - Tag::parse(["payload", &hash]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?, - ); - } - let event = EventBuilder::new(Kind::Custom(27235), "") - .tags(tags) - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?; - let json = event.as_json(); - Ok(format!("Nostr {}", B64.encode(json.as_bytes()))) -} - fn relay_server_tag(relay_url: &str) -> Option { let authority = buzz_core::tenant::relay_url_authority(relay_url); if authority.is_empty() { @@ -119,6 +90,14 @@ fn relay_server_tag(relay_url: &str) -> Option { } /// Maximum number of attempts per request (initial attempt + two retries). +/// The WebSocket connect and upgrade, before any NIP-42 wait. +const WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// Connections a WebSocket publish may open to complete NIP-42 sign-in. +const WS_SIGN_IN_ATTEMPTS: u32 = 3; +/// Times an HTTP proof is signed while its NIP-FI assertion keeps expiring. +const AUTHORIZE_ROUNDS: u32 = 3; +/// How old an HTTP proof may be when sent, well inside the relay's 60 s. +const PROOF_MAX_AGE_SECS: u64 = 30; const RETRY_MAX_ATTEMPTS: u32 = 3; /// Base sleep durations for full-jitter exponential backoff. @@ -322,61 +301,45 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result Result { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use nostr::Timestamp; - - let now = Timestamp::now().as_secs(); - let exp_str = (now + 60).to_string(); - let domain = relay_server_tag(media_url) - .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?; - let tags = vec![ - Tag::parse(["t", "get"]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, - ]; - - let auth_event = EventBuilder::new(Kind::from(24242), "Get media") - .tags(tags) - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; - - Ok(format!( - "Nostr {}", - URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) - )) +/// Blossom (BUD-01) `t=get` proof for `media_url`, scoped to its server. +fn blossom_get_event(media_url: &str) -> Result { + blossom_event( + "get", + None, + relay_server_tag(media_url) + .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?, + "Get media", + ) } -fn sign_blossom_upload( - keys: &Keys, - sha256: &str, - _mime: &str, - relay_url: &str, -) -> Result { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use nostr::Timestamp; +/// Blossom (BUD-02) `t=upload` proof for blob `sha256` on this relay. +fn blossom_upload_event(sha256: &str, relay_url: &str) -> Result { + blossom_event( + "upload", + Some(sha256), + relay_server_tag(relay_url) + .ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?, + "Upload file", + ) +} - let now = Timestamp::now().as_secs(); - let exp_str = (now + 60).to_string(); - let domain = relay_server_tag(relay_url) - .ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?; - - let tags = vec![ - Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["x", sha256]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, - ]; - - let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") - .tags(tags) - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; - - Ok(format!( - "Nostr {}", - URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) - )) +fn blossom_event( + verb: &str, + sha256: Option<&str>, + server: String, + content: &str, +) -> Result { + let exp = (nostr::Timestamp::now().as_secs() + 60).to_string(); + let tag = |parts: &[&str]| { + Tag::parse(parts.iter().copied()).map_err(|e| CliError::Other(e.to_string())) + }; + let mut tags = vec![tag(&["t", verb])?]; + if let Some(sha256) = sha256 { + tags.push(tag(&["x", sha256])?); + } + tags.push(tag(&["expiration", &exp])?); + tags.push(tag(&["server", &server])?); + Ok(EventBuilder::new(Kind::from(24242), content).tags(tags)) } #[cfg(test)] @@ -446,19 +409,12 @@ mod media_download_tests { #[test] fn media_get_auth_header_is_server_scoped() { - let keys = Keys::generate(); + let keys = nostr::Keys::generate(); let hash = "a".repeat(64); - let header = sign_blossom_get( - &keys, - &format!("https://relay.example:443/media/{hash}.jpg"), - ) - .unwrap(); - let encoded = header.strip_prefix("Nostr ").unwrap(); - let json = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(encoded) + let event = blossom_get_event(&format!("https://relay.example:443/media/{hash}.jpg")) + .unwrap() + .sign_with_keys(&keys) .unwrap(); - let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); - event.verify().unwrap(); assert_eq!(event.kind, Kind::from(24242)); let tags: Vec> = event @@ -513,14 +469,48 @@ fn advance_query_cursor( Ok(()) } +/// How a protected relay request proves key possession in `Authorization`. +enum Proof<'a> { + /// NIP-98 over the request method, URL and body. + Nip98 { + method: &'a str, + url: &'a str, + body: Option<&'a [u8]>, + }, + /// Blossom kind-24242 upload proof for blob `sha256`. + BlossomUpload { sha256: &'a str }, + /// Blossom kind-24242 read proof for `url`. + BlossomGet { url: &'a str }, +} + +impl<'a> Proof<'a> { + fn post(url: &'a str, body: &'a [u8]) -> Self { + Self::Nip98 { + method: "POST", + url, + body: Some(body), + } + } +} + pub struct BuzzClient { http: reqwest::Client, relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place" - keys: Keys, - /// Optional NIP-OA auth tag injected into every signed event. - auth_tag: Option, - /// Raw JSON of the auth tag for the `x-auth-tag` HTTP header. - auth_tag_json: Option, + /// The configured URL as a WebSocket URL, path and trailing slash kept, + /// as NIP-42 AUTH must name it. + ws_url: String, + /// Identity, signer, NIP-OA auth tag and NIP-FI evidence source. + credentials: Credentials, + /// Whether any request has carried a NIP-FI assertion, for error hints. + assertion_sent: std::sync::atomic::AtomicBool, +} + +/// A credential header value, marked sensitive so `Debug` output redacts it. +fn sensitive(value: String) -> Result { + let mut value = reqwest::header::HeaderValue::try_from(value) + .map_err(|_| CliError::Auth("credential is not a valid header value".into()))?; + value.set_sensitive(true); + Ok(value) } impl BuzzClient { @@ -533,29 +523,34 @@ impl BuzzClient { /// - `BUZZ_TIMEOUT_SECS` — per-request total timeout (default 30 s) /// /// A value of zero for either variable is treated as invalid and falls back to the default. - pub fn new( - relay_url: String, - keys: Keys, - auth_tag: Option, - auth_tag_json: Option, - ) -> Result { + /// A client for the relay at `relay_url`, as configured. + pub fn new(relay_url: String, credentials: Credentials) -> Result { let http = reqwest::Client::builder() + // Never replay credentials to a redirect target: reqwest strips + // `Authorization` across origins but not `x-auth-tag` or the + // NIP-FI assertion. A 3xx surfaces as a relay error instead. + .redirect(reqwest::redirect::Policy::none()) .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) .build() .map_err(|e| CliError::Other(e.to_string()))?; Ok(Self { http, - relay_url, - keys, - auth_tag, - auth_tag_json, + ws_url: to_ws_url(&relay_url), + relay_url: normalize_relay_url(&relay_url), + credentials, + assertion_sent: Default::default(), }) } - /// Get the keypair. - pub fn keys(&self) -> &Keys { - &self.keys + /// The public key this client acts as. + pub fn public_key(&self) -> PublicKey { + self.credentials.public_key() + } + + /// The credentials this client acts with. + pub fn credentials(&self) -> &Credentials { + &self.credentials } /// Get the relay base URL. @@ -569,9 +564,9 @@ impl BuzzClient { /// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the /// owner pubkey lives at index 1. pub fn auth_tag_owner_hex(&self) -> Option { - self.auth_tag - .as_ref() - .map(|t| t.as_slice()) + self.credentials + .auth_tag() + .map(|t| t.tag.as_slice()) .and_then(|slice| slice.get(1).cloned()) } @@ -580,23 +575,21 @@ impl BuzzClient { /// All event creation should go through this method to ensure consistent /// auth tag injection. Callers MUST NOT add `auth` tags to the builder /// before calling this method. - pub fn sign_event(&self, builder: EventBuilder) -> Result { - let builder = if let Some(ref tag) = self.auth_tag { - builder.tags([tag.clone()]) - } else { - builder + pub async fn sign_event(&self, builder: EventBuilder) -> Result { + let auth_tag = self.credentials.auth_tag(); + let builder = match auth_tag { + Some(tag) => builder.tags([tag.tag.clone()]), + None => builder, }; - let event = builder - .sign_with_keys(&self.keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = self.credentials.sign(builder).await?; - // Enforce: auth tags may only come from self.auth_tag injection. + // Enforce: auth tags may only come from the credentials' auth tag. let auth_count = event .tags .iter() .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) .count(); - let expected = if self.auth_tag.is_some() { 1 } else { 0 }; + let expected = if auth_tag.is_some() { 1 } else { 0 }; if auth_count != expected { return Err(CliError::Other(format!( "event has {auth_count} auth tags — expected {expected}; \ @@ -607,11 +600,146 @@ impl BuzzClient { Ok(event) } - /// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation). - fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - match self.auth_tag_json { - Some(ref json) => req.header("x-auth-tag", json), - None => req, + /// The one place protected relay requests get their credentials: + /// `proof` in `Authorization`, the NIP-OA `x-auth-tag` (relay membership + /// delegation) when configured, and the NIP-FI `Nostr-Federated-Identity` + /// assertion when an evidence source is configured. Called once per + /// attempt so every retry carries a fresh proof. + async fn authorize( + &self, + req: reqwest::RequestBuilder, + proof: Proof<'_>, + ) -> Result { + // Evidence first: a refresh may wait out adapter retries, and the + // proof must be signed after it, just before sending, to stay fresh. + // Signing may wait out throttling in turn, so evidence that is due + // for refresh by then is fetched again, and the proof re-signed when + // the assertion changed or that fetch aged the proof. + let mut assertion = self.evidence().await?; + let mut round = 1; + let authorization = loop { + // The proof is dated before signing, which may be held for long. + let signed_at = crate::cached_assertion::unix_now(); + let authorization = self.proof(&proof).await?; + let current = self.evidence().await?; + let age = crate::cached_assertion::unix_now().saturating_sub(signed_at); + if current == assertion && age < PROOF_MAX_AGE_SECS { + break authorization; + } + if round == AUTHORIZE_ROUNDS { + return Err(CliError::Unavailable( + "the NIP-FI assertion kept expiring while the proof was signed".into(), + )); + } + (assertion, round) = (current, round + 1); + }; + let mut req = req.header(reqwest::header::AUTHORIZATION, sensitive(authorization)?); + if let Some(tag) = self.credentials.auth_tag() { + req = req.header("x-auth-tag", sensitive(tag.json.clone())?); + } + if let Some(assertion) = assertion { + req = req.header( + FEDERATED_IDENTITY_HEADER, + sensitive(format!("Bearer {assertion}"))?, + ); + } + Ok(req) + } + + /// `proof`, signed now, as an `Authorization` value. + async fn proof(&self, proof: &Proof<'_>) -> Result { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + Ok(match *proof { + Proof::Nip98 { method, url, body } => { + format!("Nostr {}", self.credentials.nip98(method, url, body).await?) + } + Proof::BlossomUpload { sha256 } => { + let event = self + .credentials + .sign(blossom_upload_event(sha256, &self.relay_url)?) + .await?; + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) + } + Proof::BlossomGet { url } => { + let event = self.credentials.sign(blossom_get_event(url)?).await?; + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) + } + }) + } + + /// The NIP-FI assertion as WebSocket upgrade headers (empty without an + /// evidence source). + async fn upgrade_headers(&self) -> Result { + let mut headers = buzz_ws_client::HeaderMap::new(); + if let Some(assertion) = self.evidence().await? { + let mut value = + buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}")) + .map_err(|_| CliError::Auth("assertion is not a valid header value".into()))?; + value.set_sensitive(true); + headers.insert(FEDERATED_IDENTITY_HEADER, value); + } + Ok(headers) + } + + /// The NIP-FI assertion for this relay, noting that one was sent. + async fn evidence(&self) -> Result, CliError> { + let assertion = self.credentials.evidence().await?; + if assertion.is_some() { + self.assertion_sent + .store(true, std::sync::atomic::Ordering::Relaxed); + } + Ok(assertion) + } + + /// Map a non-2xx relay response to a `CliError`. + /// + /// The relay's `{"error": …}` / `{"message": …}` field becomes the + /// message. A 401 without a NIP-FI evidence source names the settings, + /// since an enforcing relay answers every request that way; a 403 with an + /// auth tag configured hints that the tag may be stale. + fn relay_error(&self, status: u16, body: String) -> CliError { + let message = extract_relay_message_field(&body).unwrap_or(body); + let message = message.trim_end().to_owned(); + let sent = self + .assertion_sent + .load(std::sync::atomic::Ordering::Relaxed); + let desktop = self.credentials.is_desktop(); + if status == 401 && !sent && desktop { + return CliError::Auth(format!( + "relay requires authentication ({message}); if it enforces federated identity \ + (NIP-FI), the Buzz desktop has no assertion for this relay: sign in to this \ + community in Buzz, then retry" + )); + } + if status == 401 && !sent && !self.credentials.has_evidence_source() { + return CliError::Auth(format!( + "relay requires authentication ({message}); if it enforces federated identity \ + (NIP-FI), set {FEDERATED_IDENTITY_TOKEN_ENV} to your own NIP-FA sign-in session \ + (a human session for this key, not an agent grant) and \ + {FEDERATED_IDENTITY_ADAPTER_ENV} to its adapter, which mints the assertion" + )); + } + if status == 403 && self.credentials.auth_tag().is_some() { + let hint = if desktop { + "the Buzz desktop's NIP-OA auth tag may be stale or revoked; check this \ + identity's owner in Buzz" + } else { + "BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it" + }; + return CliError::Relay { + status, + body: format!("{message} ({hint})"), + }; + } + CliError::Relay { + status, + body: message, } } @@ -750,16 +878,17 @@ impl BuzzClient { /// Used only for NIP-IA identity archive/unarchive requests (kind /// 9035/9036), whose optional `auth` tag is a *content-level* /// owner-of-agent attestation about the *target* identity — unrelated to - /// this client's own NIP-OA membership delegation (`self.auth_tag`, + /// this client's own NIP-OA membership delegation (the credentials' auth tag, /// which [`sign_event`] injects into every other event and which /// `submit_event` separately attaches via the `x-auth-tag` HTTP header). /// Routing an identity archive request through `sign_event` would either /// silently drop the caller's owner attestation or double up an /// unrelated tag. - pub fn sign_event_unchecked(&self, builder: EventBuilder) -> Result { - builder - .sign_with_keys(&self.keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}"))) + pub async fn sign_event_unchecked( + &self, + builder: EventBuilder, + ) -> Result { + self.credentials.sign(builder).await } /// GET a public, unauthenticated relay endpoint (e.g. the NIP-11 `/info` @@ -796,17 +925,16 @@ impl BuzzClient { let body = body.clone(); let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() .await?; + let resp = req.send().await?; self.handle_response(resp).await } }) @@ -826,17 +954,16 @@ impl BuzzClient { let body = body.clone(); let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() .await?; + let resp = req.send().await?; self.handle_response(resp).await } }) @@ -854,11 +981,17 @@ impl BuzzClient { self.with_retry_body(|| { let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "GET", &url, None)?; - let resp = self - .with_auth_tag(self.http.get(&url).header("Authorization", auth)) - .send() + let req = self + .authorize( + self.http.get(&url), + Proof::Nip98 { + method: "GET", + url: &url, + body: None, + }, + ) .await?; + let resp = req.send().await?; self.handle_response(resp).await } }) @@ -884,17 +1017,16 @@ impl BuzzClient { let body_bytes = body_bytes.clone(); let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body_bytes), + .body(body_bytes.clone()), + Proof::post(&url, &body_bytes), ) - .send() .await?; + let resp = req.send().await?; // 204 No Content: return empty string rather than failing on // an empty body that cannot be parsed as JSON. if resp.status() == reqwest::StatusCode::NO_CONTENT { @@ -915,29 +1047,27 @@ impl BuzzClient { ) -> Result { let url = format!("{}{path}", self.relay_url); let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?; - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; let unknown = |detail: String| CliError::DeliveryUnknown(detail); let http = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) .build()?; - let response = self - .with_auth_tag( + let req = self + .authorize( http.post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() - .await - .map_err(|e| { - if e.is_connect() || e.is_builder() { - CliError::Network(e) - } else { - unknown(e.to_string()) - } - })?; + .await?; + let response = req.send().await.map_err(|e| { + if e.is_connect() || e.is_builder() { + CliError::Network(e) + } else { + unknown(e.to_string()) + } + })?; let status = response.status(); let body = response.text().await.map_err(|e| unknown(e.to_string()))?; let message = extract_relay_message_field(&body).unwrap_or_else(|| body.clone()); @@ -948,10 +1078,7 @@ impl BuzzClient { return Err(unknown(format!("HTTP {}: {message}", status.as_u16()))); } if !status.is_success() { - return Err(CliError::Relay { - status: status.as_u16(), - body: message, - }); + return Err(self.relay_error(status.as_u16(), body)); } Ok(body) } @@ -989,18 +1116,17 @@ impl BuzzClient { // Re-sign NIP-98 each attempt: the nonce tag generates a fresh // event ID, keeping retries safe against the relay's replay guard. - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let send_result: Result = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") .body(body.clone()), + Proof::post(&url, &body), ) - .send() - .await - .map_err(CliError::from); + .await?; + let send_result: Result = + req.send().await.map_err(CliError::from); match send_result { Err(e) => { @@ -1086,26 +1212,7 @@ impl BuzzClient { })?; // Map the body through handle_response's error logic inline. if !resp_was_success(status) { - let message = serde_json::from_str::(&body_text) - .ok() - .and_then(|v| { - v.get("error") - .or_else(|| v.get("message")) - .and_then(|m| m.as_str()) - .map(str::to_string) - }) - .unwrap_or(body_text); - let message = if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() { - format!( - "{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)" - ) - } else { - message - }; - return Err(CliError::Relay { - status, - body: message, - }); + return Err(self.relay_error(status, body_text)); } return Ok(body_text); } @@ -1134,25 +1241,36 @@ impl BuzzClient { serde_json::to_vec(&event) .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?, ); + // Whether any attempt reached the relay without an authoritative + // answer. A later failure, even one before sending, cannot clear it. + let maybe_delivered = std::sync::atomic::AtomicBool::new(false); let result = self .with_retry_body(|| { let body = body.clone(); let url = url.clone(); + let maybe_delivered = &maybe_delivered; async move { // Re-sign NIP-98 each attempt: the nonce tag generates a fresh // event ID, keeping retries safe against the relay's replay guard. - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() .await?; - self.handle_response(resp).await + let sent = match req.send().await { + Ok(resp) => self.handle_response(resp).await, + Err(e) => Err(e.into()), + }; + if let Err(ref e) = sent { + if is_stored_event_exhaustion_ambiguous(e) { + maybe_delivered.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + sent } }) .await; @@ -1162,7 +1280,13 @@ impl BuzzClient { // Connect failures stay Network (retryable:true) — definitively never received. // Canonical pre-ingest 429 (Relay{429}) stays retryable — definitively not stored. if let Err(ref e) = result { - if is_stored_event_exhaustion_ambiguous(e) { + // Once an attempt may have been stored, no later answer settles + // the outcome, except a canvas 409, which the canvas command + // resolves by looking the canvas up. + let settled = matches!(e, CliError::Relay { status: 409, body } if body.contains("canvas changed")); + if is_stored_event_exhaustion_ambiguous(e) + || (maybe_delivered.load(std::sync::atomic::Ordering::Relaxed) && !settled) + { let kind_u16 = event.kind.as_u16(); return Err(CliError::DeliveryUnknown(format!( "stored event (kind {kind_u16}) outcome unknown after all attempts: {e}" @@ -1179,15 +1303,70 @@ impl BuzzClient { /// `buzz_ws_client::publish_event` which handles connect, NIP-42 auth, /// EVENT send, OK wait, and graceful close. pub async fn publish_ephemeral_event(&self, event: nostr::Event) -> Result { - let ws_url = to_ws_url(&self.relay_url); - // Hard cap — inner wait ceilings sum to 70 s; connect time and network RTT are - // additional overhead absorbed by this budget. - // See buzz_ws_client::{AUTH_CHALLENGE_TIMEOUT_SECS, AUTH_OK_TIMEOUT_SECS, - // PUBLISH_OK_TIMEOUT_SECS} for the inner ceilings. - let ok = - buzz_ws_client::publish_event(&ws_url, event, &self.keys, self.auth_tag.as_ref(), 75) - .await - .map_err(|e| CliError::Other(e.to_string()))?; + let ws_url = &self.ws_url; + // Two kinds of deadline: each network phase is capped as a whole, + // writes included (the connect below, then buzz_ws_client's challenge, + // AUTH, EVENT and close phases), and NIP-42 signing between them is + // bounded by the signer itself. A + // desktop provider may wait out throttling and hold `sign_event` for a + // person's decision, which no relay budget should cut short. + let signer = crate::credentials::CapturingSigner::new(&self.credentials); + let ws_error = |e: buzz_ws_client::WsClientError| match e.upgrade_rejection() { + // A denial decided on the upgrade is an HTTP response (NIP-FI). + Some((status, body)) => self.relay_error(status, body), + // A post-upgrade denial (e.g. a NIP-FI key mismatch) is a 1008 close. + None if matches!(e, buzz_ws_client::WsClientError::PolicyClose(_)) => { + CliError::Auth(e.to_string()) + } + None => CliError::Other(e.to_string()), + }; + // + // The relay closes a socket that has not signed in within seconds, + // so a slow or throttled sign-in signature reconnects and signs the + // new challenge, a bounded number of times, with current evidence: + // the old assertion may have expired meanwhile. A throttled sign-in + // is waited out between connections, never on one. EVENT, signed + // before any of this, is sent only once AUTH succeeds. + let mut attempt = 1; + let mut conn = loop { + let headers = self.upgrade_headers().await?; + let mut conn = tokio::time::timeout( + WS_CONNECT_TIMEOUT, + buzz_ws_client::NostrWsConnection::connect_with_headers(ws_url, headers), + ) + .await + .map_err(|_| CliError::Other("relay WebSocket connect timed out".into()))? + .map_err(ws_error)?; + let signed_in = conn + .authenticate(&signer, self.credentials.auth_tag().map(|t| &t.tag)) + .await; + let Err(e) = signed_in else { break conn }; + let failure = signer.take_failure(); + let lost = matches!( + e, + buzz_ws_client::WsClientError::ConnectionClosed + | buzz_ws_client::WsClientError::WebSocket(_) + ); + let (failure, wait) = failure.unzip(); + let wait = wait.flatten(); + let throttled = matches!(failure, Some(CliError::Throttled(_))); + let _ = conn.disconnect().await; + if !(lost || throttled) { + return Err(failure.unwrap_or_else(|| ws_error(e))); + } + if attempt == WS_SIGN_IN_ATTEMPTS { + return Err(failure.unwrap_or_else(|| { + CliError::Unavailable(format!( + "the relay closed the WebSocket before sign-in completed, \ + {WS_SIGN_IN_ATTEMPTS} times: {e}" + )) + })); + } + tokio::time::sleep(wait.unwrap_or_else(|| jitter_delay(attempt - 1))).await; + attempt += 1; + }; + let ok = conn.send_event(event).await.map_err(ws_error)?; + let _ = conn.disconnect().await; if !ok.accepted { return Err(CliError::Relay { @@ -1262,25 +1441,23 @@ impl BuzzClient { let mime = mime.clone(); let sha256 = sha256.clone(); async move { - let auth_header = - sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .put(&url) .timeout(upload_timeout) - .header("Authorization", auth_header) .header("Content-Type", &mime) .header("X-SHA-256", &sha256) .body(upload_body), + Proof::BlossomUpload { sha256: &sha256 }, ) - .send() .await?; + let resp = req.send().await?; let status = resp.status(); if !status.is_success() { let s = status.as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status: s, body }); + return Err(self.relay_error(s, body)); } resp.json::().await.map_err(CliError::from) } @@ -1309,23 +1486,22 @@ impl BuzzClient { let mime = mime.clone(); let sha256 = sha256.clone(); async move { - let auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .put(&legacy_url) .timeout(upload_timeout) - .header("Authorization", auth_header) .header("Content-Type", &mime) .header("X-SHA-256", &sha256) .body(upload_body), + Proof::BlossomUpload { sha256: &sha256 }, ) - .send() .await?; + let resp = req.send().await?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status, body }); + return Err(self.relay_error(status, body)); } resp.json::().await.map_err(CliError::from) } @@ -1347,15 +1523,14 @@ impl BuzzClient { let url = url.clone(); let client = client.clone(); async move { - let auth_header = sign_blossom_get(&self.keys, &url)?; - let resp = self - .with_auth_tag(client.get(&url).header("Authorization", auth_header)) - .send() + let req = self + .authorize(client.get(&url), Proof::BlossomGet { url: &url }) .await?; + let resp = req.send().await?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status, body }); + return Err(self.relay_error(status, body)); } resp.bytes().await.map_err(CliError::Network) } @@ -1367,28 +1542,7 @@ impl BuzzClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - let message = serde_json::from_str::(&body) - .ok() - .and_then(|v| { - v.get("error") - .or_else(|| v.get("message")) - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or(body); - if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() { - let message = format!( - "{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)" - ); - return Err(CliError::Relay { - status, - body: message, - }); - } - return Err(CliError::Relay { - status, - body: message, - }); + return Err(self.relay_error(status, body)); } Ok(resp.text().await?) } @@ -1754,8 +1908,15 @@ mod retry_policy_tests { } fn test_client(base_url: &str) -> BuzzClient { - let keys = Keys::generate(); - BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() + } + + fn client_keys(client: &BuzzClient) -> Keys { + client.credentials().test_keys() } fn make_moderation_event(keys: &Keys, kind: u16) -> nostr::Event { @@ -1783,7 +1944,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9040); + let event = make_moderation_event(&client_keys(&client), 9040); let err = client.submit_event(event).await.unwrap_err(); assert!( matches!(err, CliError::DeliveryUnknown(_)), @@ -1823,7 +1984,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9041); + let event = make_moderation_event(&client_keys(&client), 9041); let t0 = std::time::Instant::now(); let result = client.submit_event(event).await; let elapsed = t0.elapsed(); @@ -1856,7 +2017,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9040); + let event = make_moderation_event(&client_keys(&client), 9040); let err = client.submit_event(event).await.unwrap_err(); // Must be Relay(429), not DeliveryUnknown. @@ -1884,7 +2045,7 @@ mod retry_policy_tests { let (url, attempts) = test_server(|_n| (StatusCode::BAD_GATEWAY, "bad gateway".to_string())).await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9042); + let event = make_moderation_event(&client_keys(&client), 9042); let err = client.submit_event(event).await.unwrap_err(); assert!( matches!(err, CliError::DeliveryUnknown(_)), @@ -1912,7 +2073,7 @@ mod retry_policy_tests { let base = format!("http://{addr}"); let client = test_client(&base); - let event = make_moderation_event(client.keys(), 9040); + let event = make_moderation_event(&client_keys(&client), 9040); let err = client.submit_event(event).await.unwrap_err(); // Must be Network (retryable), not DeliveryUnknown (retryable:false). assert!( @@ -1943,7 +2104,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let result = client.submit_event(event).await; assert!( result.is_ok(), @@ -2203,7 +2364,7 @@ mod retry_policy_tests { let base = format!("http://{addr}"); let client = test_client(&base); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let result = client.submit_event(event).await; assert!( result.is_ok(), @@ -2365,7 +2526,7 @@ mod retry_policy_tests { let base = format!("http://{addr}"); let client = test_client(&base); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let err = client.submit_event(event).await.unwrap_err(); // Final error must be DeliveryUnknown — relay may have accepted any attempt. @@ -2397,7 +2558,7 @@ mod retry_policy_tests { let (url, attempts) = test_server(|_n| (StatusCode::BAD_GATEWAY, "bad gateway".to_string())).await; let client = test_client(&url); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let err = client.submit_event(event).await.unwrap_err(); assert!( @@ -2410,13 +2571,35 @@ mod retry_policy_tests { "all 3 attempts must fire before surfacing DeliveryUnknown" ); } + + /// The first send is stored but its answer is lost; the retry is then + /// refused before ingest. The refusal cannot undo the first send, so the + /// outcome stays unknown rather than a plain rejection. + #[tokio::test] + async fn a_403_after_a_lost_stored_send_stays_delivery_unknown() { + let (url, attempts) = test_server(|n| match n { + 1 => (StatusCode::BAD_GATEWAY, "bad gateway".to_string()), + _ => ( + StatusCode::FORBIDDEN, + r#"{"error":"restricted: not allowed"}"#.to_string(), + ), + }) + .await; + let client = test_client(&url); + let event = make_stored_event(&client_keys(&client)); + let err = client.submit_event(event).await.unwrap_err(); + + assert!(matches!(err, CliError::DeliveryUnknown(_)), "{err:?}"); + assert!(!crate::error::is_retryable_error(&err)); + assert_eq!(attempts.load(Ordering::SeqCst), 2, "no retry after the 403"); + } } #[cfg(test)] mod tests { use super::{ advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, - normalize_events, BuzzClient, + normalize_events, BuzzClient, Credentials, Proof, }; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2536,21 +2719,19 @@ mod tests { (tag, json) } - #[test] - fn sign_event_unchecked_does_not_inject_ambient_auth_tag() { + #[tokio::test] + async fn sign_event_unchecked_does_not_inject_ambient_auth_tag() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( "https://test.relay".into(), - keys, - Some(auth_tag), - Some(auth_json), + Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json), ) .unwrap(); let builder = EventBuilder::new(Kind::Custom(9035), "archive").tags([Tag::parse(["-"]).unwrap()]); - let event = client.sign_event_unchecked(builder).unwrap(); + let event = client.sign_event_unchecked(builder).await.unwrap(); let auth_tags: Vec<_> = event .tags @@ -2564,15 +2745,13 @@ mod tests { ); } - #[test] - fn sign_event_unchecked_preserves_callers_content_auth_tag() { + #[tokio::test] + async fn sign_event_unchecked_preserves_callers_content_auth_tag() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( "https://test.relay".into(), - keys, - Some(auth_tag), - Some(auth_json), + Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json), ) .unwrap(); @@ -2586,7 +2765,7 @@ mod tests { let builder = EventBuilder::new(Kind::Custom(9035), "archive") .tags([Tag::parse(["-"]).unwrap(), content_auth]); - let event = client.sign_event_unchecked(builder).unwrap(); + let event = client.sign_event_unchecked(builder).await.unwrap(); let auth_tags: Vec<_> = event .tags @@ -2601,21 +2780,23 @@ mod tests { assert_eq!(auth_tags[0].as_slice()[1], "c".repeat(64)); } - #[test] - fn with_auth_tag_sets_header_when_configured() { + #[tokio::test] + async fn authorize_sets_auth_tag_header_when_configured() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( "https://test.relay".into(), - keys, - Some(auth_tag), - Some(auth_json.clone()), + Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json.clone()), ) .unwrap(); - let req = client.http.post("https://test.relay/events"); - let req = client.with_auth_tag(req); - let built = req.build().unwrap(); + let url = "https://test.relay/events"; + let built = client + .authorize(client.http.post(url), Proof::post(url, b"{}")) + .await + .unwrap() + .build() + .unwrap(); let header = built .headers() .get("x-auth-tag") @@ -2627,17 +2808,1352 @@ mod tests { ); } - #[test] - fn with_auth_tag_omits_header_when_not_configured() { - let keys = Keys::generate(); - let client = BuzzClient::new("https://test.relay".into(), keys, None, None).unwrap(); + #[tokio::test] + async fn authorize_omits_optional_headers_when_not_configured() { + let client = BuzzClient::new( + "https://test.relay".into(), + Credentials::from_keys(Keys::generate()), + ) + .unwrap(); - let req = client.http.post("https://test.relay/events"); - let req = client.with_auth_tag(req); - let built = req.build().unwrap(); + let url = "https://test.relay/events"; + let built = client + .authorize(client.http.post(url), Proof::post(url, b"{}")) + .await + .unwrap() + .build() + .unwrap(); + assert!(built.headers().get("authorization").is_some()); assert!( built.headers().get("x-auth-tag").is_none(), "x-auth-tag header must not be present when no auth tag is configured" ); + assert!( + built.headers().get("nostr-federated-identity").is_none(), + "no NIP-FI header without an evidence source" + ); + } +} + +/// NIP-FI evidence on every protected route (Review-Proven Rule #2: every +/// request path that carries auth is enumerated here, plus the WebSocket +/// upgrade). One mock origin serves both the NIP-FA adapter and the relay. +#[cfg(test)] +mod federated_identity_route_tests { + use std::sync::{Arc, Mutex}; + + use axum::extract::Request; + use axum::http::{HeaderMap, StatusCode}; + use axum::response::IntoResponse; + use nostr::{EventBuilder, Keys, Kind}; + + use super::BuzzClient; + use crate::credentials::Credentials; + use crate::error::CliError; + use crate::federated_identity::AdapterSession; + + use crate::federated_identity::test_support::{self, count, raw_adapter}; + + const HASH: &str = "4f2b5e7c1a9d3e6f8b0c2d4e6f8a0b1c3d5e7f9a1b3c5d7e9f0a2b4c6d8e0f1a"; + + #[derive(Default)] + struct Log { + mints: usize, + /// The last assertion the adapter issued. + assertion: String, + requests: Vec<(String, String, HeaderMap)>, + } + + /// Serve the adapter and relay on one loopback origin. Relay routes + /// answer `deny` when set, otherwise a plausible success; `PUT /upload` + /// is 404 so the legacy `/media/upload` fallback runs too. + async fn origin(deny: Option) -> (String, Arc>) { + let log: Arc> = Arc::default(); + let state = log.clone(); + let app = axum::Router::new() + .route( + "/v1/identity/assertions", + axum::routing::post({ + let log = log.clone(); + move |body: axum::body::Bytes| { + let log = log.clone(); + async move { + let req: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default(); + let expires_at = nostr::Timestamp::now().as_secs() + 300; + let assertion = test_support::assertion(pubkey, expires_at); + let mut log = log.lock().unwrap(); + log.mints += 1; + log.assertion = assertion.clone(); + axum::Json(serde_json::json!({ + "assertion": assertion, + "nostr_pubkey": pubkey, + "expires_at": expires_at, + })) + } + } + }), + ) + .fallback(move |req: Request| { + let log = state.clone(); + async move { + let method = req.method().to_string(); + let path = req.uri().path().to_owned(); + log.lock().unwrap().requests.push(( + method.clone(), + path.clone(), + req.headers().clone(), + )); + if let Some(status) = deny { + return (status, r#"{"error":"authentication required"}"#).into_response(); + } + let body = match (method.as_str(), path.as_str()) { + ("PUT", "/upload") => return StatusCode::NOT_FOUND.into_response(), + // No WebSocket server: the test only needs the upgrade's headers. + ("GET", "/") => return StatusCode::FORBIDDEN.into_response(), + ("PUT", "/media/upload") => serde_json::json!({ + "url": format!("http://x/media/{HASH}.png"), + "sha256": HASH, + "size": 8, + "type": "image/png", + "uploaded": 0, + }) + .to_string(), + ("POST", "/query") => "[]".to_owned(), + ("POST", "/count") => r#"{"count":0}"#.to_owned(), + _ => r#"{"accepted":true}"#.to_owned(), + }; + (StatusCode::OK, body).into_response() + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (base, log) + } + + fn client(base: &str, evidence: bool) -> BuzzClient { + if evidence { + client_via(base, base) + } else { + BuzzClient::new(base.to_owned(), Credentials::from_keys(Keys::generate())).unwrap() + } + } + + /// A client for the relay at `relay` that mints from the adapter at `adapter`. + fn client_via(relay: &str, adapter: &str) -> BuzzClient { + let credentials = Credentials::from_keys(Keys::generate()) + .with_evidence(AdapterSession::new(adapter, "token", relay).unwrap()); + BuzzClient::new(relay.to_owned(), credentials).unwrap() + } + + /// Drive every protected request path once, ignoring results (the WS + /// publish fails by design once its upgrade headers are recorded). + async fn exercise_every_route(client: &BuzzClient) -> Vec> { + let keys = client.credentials().test_keys(); + let png = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); + std::fs::write(png.path(), b"\x89PNG\r\n\x1a\n").unwrap(); + let filter = serde_json::json!({"kinds": [1]}); + let body = serde_json::json!({"k": "v"}); + let event = |kind: u16| { + EventBuilder::new(Kind::Custom(kind), "") + .sign_with_keys(&keys) + .unwrap() + }; + let mut results = vec![ + client.query(&filter).await, + client.query_multi(&[filter.clone(), filter.clone()]).await, + client.count(&filter).await, + client.get_authed("/api/get").await, + client.post_json_authed("/api/post", &body).await, + client.post_json_once_authed("/api/post-once", &body).await, + client.submit_event(event(1)).await, + client.submit_event(event(9041)).await, + client.publish_ephemeral_event(event(20001)).await, + ]; + results.push( + client + .upload_file(png.path().to_str().unwrap()) + .await + .map(|d| d.url), + ); + results.push( + client + .download_media(&format!("{HASH}.png")) + .await + .map(|_| String::new()), + ); + results + } + + /// Each `(method, path)` the CLI sends on a protected route. + const PROTECTED: &[(&str, &str)] = &[ + ("POST", "/query"), + ("POST", "/count"), + ("GET", "/api/get"), + ("POST", "/api/post"), + ("POST", "/api/post-once"), + ("POST", "/events"), + ("GET", "/"), + ("PUT", "/upload"), + ("PUT", "/media/upload"), + ]; + + fn covered(log: &Log) -> Vec<(String, String)> { + let mut seen: Vec<(String, String)> = log + .requests + .iter() + .map(|(m, p, _)| (m.clone(), p.clone())) + .collect(); + seen.sort(); + seen.dedup(); + seen + } + + /// The relay proof is signed after the assertion arrives, so a slow + /// mint (adapter retries can exceed NIP-98's 60 s window) cannot age it. + #[tokio::test] + async fn the_relay_proof_is_signed_after_a_slow_mint() { + use base64::Engine; + let minted_at: Arc> = Arc::default(); + let proofs: Arc>> = Arc::default(); + let app = axum::Router::new() + .route( + "/v1/identity/assertions", + axum::routing::post({ + let minted_at = minted_at.clone(); + move |body: axum::body::Bytes| { + let minted_at = minted_at.clone(); + async move { + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + let req: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default(); + let now = nostr::Timestamp::now().as_secs(); + *minted_at.lock().unwrap() = now; + axum::Json(serde_json::json!({ + "assertion": test_support::assertion(pubkey, now + 300), + "nostr_pubkey": pubkey, + "expires_at": now + 300, + })) + } + } + }), + ) + .fallback({ + let proofs = proofs.clone(); + move |headers: HeaderMap| { + let proofs = proofs.clone(); + async move { + let auth = headers["authorization"].to_str().unwrap().to_owned(); + proofs.lock().unwrap().push(auth); + (StatusCode::OK, "{}") + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + client(&base, true).get_authed("/api/get").await.unwrap(); + + let proof = proofs.lock().unwrap()[0].clone(); + let json = base64::engine::general_purpose::STANDARD + .decode(proof.trim_start_matches("Nostr ")) + .unwrap(); + let event: nostr::Event = serde_json::from_slice(&json).unwrap(); + assert!( + event.created_at.as_secs() >= *minted_at.lock().unwrap(), + "the proof was signed before the assertion arrived" + ); + } + + #[tokio::test] + async fn every_protected_route_and_the_upgrade_carry_the_assertion() { + let (base, log) = origin(None).await; + let results = exercise_every_route(&client(&base, true)).await; + + let log = log.lock().unwrap(); + for (method, path) in PROTECTED { + assert!( + covered(&log).contains(&(method.to_string(), path.to_string())), + "{method} {path} was not exercised" + ); + } + assert!( + log.requests + .iter() + .any(|(_, p, _)| p.starts_with("/media/") && p != "/media/upload"), + "GET /media/ was not exercised" + ); + for (method, path, headers) in &log.requests { + assert_eq!( + headers + .get("nostr-federated-identity") + .and_then(|v| v.to_str().ok()), + Some(format!("Bearer {}", log.assertion).as_str()), + "{method} {path}" + ); + if path != "/" { + assert!( + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("Nostr ")), + "{method} {path}: Authorization proof" + ); + } + } + assert_eq!(log.mints, 1, "one assertion serves the whole process"); + // Everything but the WS publish (no WS server) succeeds. + for (i, result) in results.iter().enumerate() { + if i != 8 { + assert!(result.is_ok(), "route {i}: {result:?}"); + } + } + } + + #[tokio::test] + async fn exempt_routes_and_relays_without_a_source_get_no_assertion() { + let (base, log) = origin(None).await; + let plain = client(&base, false); + exercise_every_route(&plain).await; + plain.get_public("/info").await.unwrap(); + + { + let log = log.lock().unwrap(); + assert_eq!(log.mints, 0); + assert!(log.requests.len() >= PROTECTED.len()); + for (method, path, headers) in &log.requests { + assert!( + headers.get("nostr-federated-identity").is_none(), + "{method} {path}" + ); + } + } + + // An exempt route never mints or presents evidence, even with a source. + let (base, log) = origin(None).await; + client(&base, true).get_public("/info").await.unwrap(); + let log = log.lock().unwrap(); + assert_eq!(log.mints, 0); + assert!(log.requests[0].2.get("nostr-federated-identity").is_none()); + } + + /// An enforcing relay's 401 with no evidence source names the settings, + /// on HTTP and on the WebSocket upgrade, and is not retried. + #[tokio::test] + async fn a_401_without_a_source_names_the_evidence_settings() { + let (base, log) = origin(Some(StatusCode::UNAUTHORIZED)).await; + let plain = client(&base, false); + let keys = plain.credentials().test_keys(); + let ws_event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + for err in [ + plain.query(&serde_json::json!({})).await.unwrap_err(), + plain.publish_ephemeral_event(ws_event).await.unwrap_err(), + ] { + assert!( + matches!(&err, CliError::Auth(m) + if m.contains("BUZZ_FEDERATED_IDENTITY_TOKEN") + && m.contains("BUZZ_FEDERATED_IDENTITY_ADAPTER")), + "{err:?}" + ); + } + assert_eq!(log.lock().unwrap().requests.len(), 2, "401 is not retried"); + + // With a source configured, a 401 is the relay's verdict, not a hint. + let (base, _) = origin(Some(StatusCode::UNAUTHORIZED)).await; + let err = client(&base, true) + .query(&serde_json::json!({})) + .await + .unwrap_err(); + assert!( + matches!(err, CliError::Relay { status: 401, .. }), + "{err:?}" + ); + } + + /// The paths a failed mint must stop before anything reaches the relay: + /// a read, a post-once write, a moderation command, a stored message + /// (`messages send`) and the WebSocket upgrade (`users set-presence`). + /// Returns each path's error and how many mints that one call made. + async fn every_path_once( + client: &BuzzClient, + mints: &std::sync::atomic::AtomicUsize, + ) -> Vec<(&'static str, CliError, usize)> { + let keys = client.credentials().test_keys(); + let event = |kind: u16| { + EventBuilder::new(Kind::Custom(kind), "hi") + .sign_with_keys(&keys) + .unwrap() + }; + let body = serde_json::json!({"k": "v"}); + let mut out = Vec::new(); + let mut record = |path, result: Result, before| { + out.push((path, result.unwrap_err(), count(mints) - before)); + }; + let before = count(mints); + record( + "read", + client.query(&serde_json::json!({"kinds": [1]})).await, + before, + ); + let before = count(mints); + record( + "post-once", + client.post_json_once_authed("/api/post-once", &body).await, + before, + ); + let before = count(mints); + record("moderation", client.submit_event(event(9041)).await, before); + let before = count(mints); + record("message", client.submit_event(event(9)).await, before); + let before = count(mints); + record( + "websocket", + client.publish_ephemeral_event(event(20001)).await, + before, + ); + out + } + + /// An adapter 500 or 503 is retried a bounded number of times on every + /// path, then fails as a retryable adapter error. Nothing reaches the + /// relay, so a message is never reported as possibly delivered. + #[tokio::test] + async fn adapter_5xx_is_retried_on_every_path_and_never_reaches_the_relay() { + for response in [test_support::HTTP_500, test_support::HTTP_503] { + let (relay, log) = origin(None).await; + let (adapter, mints) = raw_adapter(response).await; + let client = client_via(&relay, &adapter); + for (path, err, attempts) in every_path_once(&client, &mints).await { + assert!(matches!(err, CliError::Adapter(_)), "{path}: {err:?}"); + assert!(crate::error::is_retryable_error(&err), "{path}"); + assert_eq!(attempts, 3, "{path}: three mints, no more"); + } + assert!( + log.lock().unwrap().requests.is_empty(), + "the relay saw a request" + ); + } + } + + /// A first send whose outcome is lost, then an assertion refresh that + /// fails, is still possibly delivered: the adapter error must not invite + /// an outer re-sign. + #[tokio::test] + async fn a_refresh_failure_after_a_lost_send_stays_delivery_unknown() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let mints = Arc::new(AtomicUsize::new(0)); + let sends = Arc::new(AtomicUsize::new(0)); + let app = axum::Router::new() + .route( + "/v1/identity/assertions", + axum::routing::post({ + let mints = mints.clone(); + move |body: axum::body::Bytes| { + let mints = mints.clone(); + async move { + if mints.fetch_add(1, Ordering::SeqCst) > 0 { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let req: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default(); + let expires_at = nostr::Timestamp::now().as_secs() + 300; + axum::Json(serde_json::json!({ + "assertion": test_support::assertion(pubkey, expires_at), + "nostr_pubkey": pubkey, + "expires_at": expires_at, + })) + .into_response() + } + } + }), + ) + .fallback({ + let sends = sends.clone(); + move || { + let sends = sends.clone(); + async move { + sends.fetch_add(1, Ordering::SeqCst); + // The relay may have stored it; the proxy lost the answer + // after the assertion reached its refresh boundary. + crate::cached_assertion::test_clock::advance(300); + StatusCode::BAD_GATEWAY.into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let client = client_via(&base, &base); + let keys = client.credentials().test_keys(); + let event = EventBuilder::new(Kind::Custom(9), "hi") + .sign_with_keys(&keys) + .unwrap(); + let err = client.submit_event(event).await.unwrap_err(); + assert!(matches!(err, CliError::DeliveryUnknown(_)), "{err:?}"); + assert!(!crate::error::is_retryable_error(&err)); + assert_eq!( + sends.load(Ordering::SeqCst), + 1, + "one send reached the relay" + ); + assert_eq!( + mints.load(Ordering::SeqCst), + 4, + "one mint, then three failed refreshes" + ); + } + + /// A 403 whose body is cut off is one refusal on every path, not a + /// retryable network error, and nothing reaches the relay. + #[tokio::test] + async fn a_truncated_adapter_403_is_a_single_refusal_on_every_path() { + let (relay, log) = origin(None).await; + let (adapter, mints) = raw_adapter(test_support::TRUNCATED_403).await; + let client = client_via(&relay, &adapter); + for (path, err, attempts) in every_path_once(&client, &mints).await { + assert!(matches!(err, CliError::Auth(_)), "{path}: {err:?}"); + assert!(!crate::error::is_retryable_error(&err), "{path}"); + assert_eq!(attempts, 1, "{path}: one mint"); + } + assert!( + log.lock().unwrap().requests.is_empty(), + "the relay saw a request" + ); + } + + /// A relay redirect to another origin is never followed: the assertion, + /// proof and auth tag stay with the relay that was asked. + #[tokio::test] + async fn a_cross_origin_relay_redirect_is_not_followed() { + let (target, target_log) = origin(None).await; + let location = format!("{target}/query"); + let app = axum::Router::new().fallback(move || { + let location = location.clone(); + async move { (StatusCode::TEMPORARY_REDIRECT, [("location", location)]) } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + // Mint from the target origin's adapter so the client has an assertion to leak. + let client = client_via(&relay, &target); + let body = serde_json::json!({"k": "v"}); + for err in [ + client.query(&serde_json::json!({})).await.unwrap_err(), + client.get_authed("/api/get").await.unwrap_err(), + client + .post_json_authed("/api/post", &body) + .await + .unwrap_err(), + ] { + assert!( + matches!(err, CliError::Relay { status: 307, .. }), + "{err:?}" + ); + } + let log = target_log.lock().unwrap(); + assert_eq!(log.mints, 1); + assert!( + log.requests.is_empty(), + "redirect followed: {:?}", + covered(&log) + ); + } + + /// A WebSocket refused after NIP-42 with a policy close (a NIP-FI key + /// mismatch) is an auth error, exit 3, not a generic failure. + #[tokio::test] + async fn a_websocket_policy_close_is_an_auth_error() { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + use tokio_tungstenite::tungstenite::Message; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await + .unwrap(); + let _auth = ws.next().await; + let _ = ws + .send(Message::Close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "auth-required: key mismatch".into(), + }))) + .await; + }); + let client = client(&relay, false); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&client.credentials().test_keys()) + .unwrap(); + let err = client.publish_ephemeral_event(event).await.unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("key mismatch")), + "{err:?}" + ); + assert_eq!(crate::error::exit_code(&err), 3); + assert!(!crate::error::is_retryable_error(&err)); + } +} + +/// A desktop that stays `rate_limited`, or throttles and then holds a +/// signature, through every command path that signs or encrypts. +#[cfg(all(test, unix))] +mod desktop_throttle_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use nostr::{EventBuilder, Kind}; + + use super::ws_deadline_tests::ws_relay; + use super::BuzzClient; + use crate::desktop_provider::{tests as dp, Provider}; + use crate::error::CliError; + + /// A desktop holding `keys` that answers `rate_limited` to `throttled`. + async fn client(relay: &str, keys: &nostr::Keys, throttled: &'static str) -> BuzzClient { + let answer = dp::desktop_answers(keys.clone(), vec![relay.into()]); + let (endpoint, _) = dp::provider(move |version, request| { + if request["method"] == throttled { + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}); + } + answer(version, request) + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + BuzzClient::new(relay.into(), credentials).unwrap() + } + + /// A desktop user's 401 and 403 hints name what they can act on in Buzz, + /// not the headless settings. + #[tokio::test] + async fn desktop_users_get_hints_for_the_desktop() { + let app = axum::Router::new().fallback(|req: axum::extract::Request| async move { + let status = if req.uri().path() == "/forbidden" { + 403 + } else { + 401 + }; + ( + axum::http::StatusCode::from_u16(status).unwrap(), + r#"{"error":"no"}"#, + ) + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let keys = nostr::Keys::generate(); + let owner = nostr::Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &keys.public_key(), "").unwrap(); + let answer = dp::desktop_answers(keys.clone(), vec![relay.clone()]); + let (endpoint, _) = + dp::provider(move |version, request| match request["method"].as_str() { + Some("federated_identity") => serde_json::json!({"result": ""}), + Some("auth_tag") => serde_json::json!({"result": tag}), + Some("describe") => { + let mut described = answer(version, request); + let mut body: serde_json::Value = + serde_json::from_str(described["result"].as_str().unwrap()).unwrap(); + body["methods"] + .as_array_mut() + .unwrap() + .push("auth_tag".into()); + described["result"] = body.to_string().into(); + described + } + _ => answer(version, request), + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + let client = BuzzClient::new(relay, credentials).unwrap(); + + let err = client.get_authed("/api").await.unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("in Buzz")), + "{err:?}" + ); + let err = client.get_authed("/forbidden").await.unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("in Buzz") && !text.contains("BUZZ_AUTH_TAG"), + "{text}" + ); + } + + /// A desktop holding `keys` whose assertions live `lifetime` s and + /// which answers `sign_event` with `sign` (`None`: sign it), logging the + /// sign-in challenges it signs. + async fn desktop( + relay: &str, + keys: &nostr::Keys, + assertion: impl Fn() -> String + Send + Sync + 'static, + sign: impl Fn(&nostr::UnsignedEvent) -> Option + Send + Sync + 'static, + ) -> (BuzzClient, super::ws_deadline_tests::Log) { + let signed: super::ws_deadline_tests::Log = Default::default(); + let log = signed.clone(); + let answer = dp::desktop_answers(keys.clone(), vec![relay.into()]); + let (endpoint, _) = + dp::provider(move |version, request| match request["method"].as_str() { + Some("federated_identity") => { + serde_json::json!({ "result": assertion() }) + } + Some("sign_event") => { + let unsigned = ::from_json( + request["params"][0].as_str().unwrap(), + ) + .unwrap(); + if let Some(refusal) = sign(&unsigned) { + return refusal; + } + if let Some(c) = unsigned.tags.find(nostr::TagKind::Challenge) { + log.lock().unwrap().push(c.content().unwrap().to_owned()); + } + answer(version, request) + } + _ => answer(version, request), + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + (BuzzClient::new(relay.into(), credentials).unwrap(), signed) + } + + /// A desktop allowing one sign-in signature per 300 ms against a relay + /// that drops unsigned sockets after 200 ms: the throttle is waited out + /// between connections, so only the live challenge is signed, with a + /// fresh assertion on the reconnect after the old one expired. + #[tokio::test] + async fn a_throttled_sign_in_reconnects_with_fresh_evidence() { + use super::ws_deadline_tests::deadline_relay; + let keys = nostr::Keys::generate(); + let (relay, conns, _, bearers) = + deadline_relay(std::time::Duration::from_millis(200)).await; + let last = std::sync::Mutex::new(std::time::Instant::now()); + let (c, signed) = desktop(&relay, &keys, || dp::assertion(40), move |_| { + let mut last = last.lock().unwrap(); + if last.elapsed() < std::time::Duration::from_millis(300) { + crate::cached_assertion::test_clock::advance(60); + return Some( + serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}), + ); + } + *last = std::time::Instant::now(); + None + }) + .await; + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + c.publish_ephemeral_event(event).await.unwrap(); + assert_eq!(conns.load(Ordering::SeqCst), 2); + assert_eq!( + *signed.lock().unwrap(), + ["c1"], + "the dead challenge is never signed" + ); + let bearers = bearers.lock().unwrap(); + assert_ne!( + bearers[0], bearers[1], + "the reconnect brings a fresh assertion" + ); + } + + /// An HTTP relay logging each request's NIP-FI assertion. + async fn logging_relay() -> (String, super::ws_deadline_tests::Log) { + let seen: super::ws_deadline_tests::Log = Default::default(); + let log = seen.clone(); + let app = axum::Router::new().fallback(move |headers: axum::http::HeaderMap| { + let bearer = headers["nostr-federated-identity"].to_str().unwrap(); + log.lock() + .unwrap() + .push(bearer.trim_start_matches("Bearer ").to_owned()); + async { axum::Json(serde_json::json!([])) } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (relay, seen) + } + + /// A NIP-98 signature that outlives the assertion is signed again after + /// fresh evidence, and goes out with both current. + #[tokio::test] + async fn a_slow_http_signature_goes_out_with_fresh_evidence() { + let (relay, seen) = logging_relay().await; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop( + &relay, + &nostr::Keys::generate(), + || dp::assertion(40), + move |_| { + if n.fetch_add(1, Ordering::SeqCst) == 0 { + crate::cached_assertion::test_clock::advance(60); + } + None + }, + ) + .await; + c.query(&serde_json::json!({})).await.unwrap(); + assert_eq!(signs.load(Ordering::SeqCst), 2, "the proof is signed again"); + let sent = crate::cached_assertion::jws_exp(&seen.lock().unwrap()[0]).unwrap(); + assert!( + sent > crate::cached_assertion::unix_now(), + "the assertion sent is current" + ); + } + + /// A post-sign refresh that waits and returns the same assertion still + /// gets a proof signed after it. + #[tokio::test] + async fn a_slow_refresh_of_the_same_assertion_re_signs_the_proof() { + let (relay, seen) = logging_relay().await; + let exp = crate::cached_assertion::unix_now() + 600; + let fetches = AtomicUsize::new(0); + let same = move || { + if fetches.fetch_add(1, Ordering::SeqCst) == 1 { + // The desktop waits out throttling before it answers. + crate::cached_assertion::test_clock::advance(45); + } + dp::assertion(exp - crate::cached_assertion::unix_now()) + }; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop(&relay, &nostr::Keys::generate(), same, move |_| { + if n.fetch_add(1, Ordering::SeqCst) == 0 { + crate::cached_assertion::test_clock::advance(310); + } + None + }) + .await; + c.query(&serde_json::json!({})).await.unwrap(); + assert_eq!( + signs.load(Ordering::SeqCst), + 2, + "the aged proof is signed again" + ); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + /// A signature slower than a proof's lifetime is signed again even when + /// the evidence did not change. + #[tokio::test] + async fn a_slow_signature_with_unchanged_evidence_is_signed_again() { + let (relay, seen) = logging_relay().await; + let exp = crate::cached_assertion::unix_now() + 3600; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop( + &relay, + &nostr::Keys::generate(), + move || dp::assertion(exp - crate::cached_assertion::unix_now()), + move |_| { + if n.fetch_add(1, Ordering::SeqCst) == 0 { + crate::cached_assertion::test_clock::advance(65); + } + None + }, + ) + .await; + c.query(&serde_json::json!({})).await.unwrap(); + assert_eq!( + signs.load(Ordering::SeqCst), + 2, + "the stale proof is signed again" + ); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + /// Evidence that keeps expiring while the proof is signed ends retryable + /// after three rounds, sending nothing. + #[tokio::test] + async fn evidence_that_keeps_expiring_ends_retryable() { + let (relay, seen) = logging_relay().await; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop( + &relay, + &nostr::Keys::generate(), + || dp::assertion(40), + move |_| { + n.fetch_add(1, Ordering::SeqCst); + crate::cached_assertion::test_clock::advance(60); + None + }, + ) + .await; + let err = c.query(&serde_json::json!({})).await.unwrap_err(); + assert!(matches!(err, CliError::Unavailable(_)), "{err:?}"); + assert_eq!(signs.load(Ordering::SeqCst), 3); + assert!(seen.lock().unwrap().is_empty()); + } + + /// An HTTP relay answering every query with no events. + async fn http_relay() -> String { + let app = axum::Router::new().fallback(|| async { axum::Json(serde_json::json!([])) }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + relay + } + + fn assert_throttled(err: CliError) { + assert!(matches!(err, CliError::Throttled(_)), "{err:?}"); + assert!(crate::error::is_retryable_error(&err)); + assert_eq!(crate::error::exit_code(&err), 2); + } + + #[tokio::test] + async fn persistent_throttling_is_retryable_on_every_signing_path() { + let keys = nostr::Keys::generate(); + let relay = http_relay().await; + let c = client(&relay, &keys, "sign_event").await; + + // NIP-98 on an ordinary request. + assert_throttled(c.query(&serde_json::json!({})).await.unwrap_err()); + // Event signing. + assert_throttled( + c.sign_event(EventBuilder::new(Kind::TextNote, "x")) + .await + .unwrap_err(), + ); + + // Memory encryption. + let c = client(&relay, &keys, "nip44_encrypt").await; + let owner = nostr::Keys::generate().public_key().to_hex(); + assert_throttled( + crate::commands::mem::cmd_set(&c, "mem/a", "v", Some(&owner), false) + .await + .unwrap_err(), + ); + } + + #[tokio::test] + async fn persistent_throttling_during_nip42_is_retryable() { + let keys = nostr::Keys::generate(); + let (relay, auths, events) = ws_relay().await; + let c = client(&relay, &keys, "sign_event").await; + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + assert_throttled(c.publish_ephemeral_event(event).await.unwrap_err()); + assert_eq!(auths.load(Ordering::SeqCst), 0); + assert_eq!(events.load(Ordering::SeqCst), 0); + } + + /// NIP-42 signing that is throttled, then held for a decision, completes + /// the publish: provider time is outside the relay's deadlines, and the + /// relay sees one AUTH and one EVENT. + #[tokio::test(flavor = "multi_thread")] + async fn a_throttled_then_held_nip42_signature_completes_without_replay() { + let keys = nostr::Keys::generate(); + let (relay, auths, events) = ws_relay().await; + let signs = Arc::new(AtomicUsize::new(0)); + let counted = signs.clone(); + let answer = dp::desktop_answers(keys.clone(), vec![relay.clone()]); + let (endpoint, _) = dp::provider(move |version, request| { + if request["method"] == "sign_event" { + if counted.fetch_add(1, Ordering::SeqCst) == 0 { + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}); + } + // Held for a person's decision. + std::thread::sleep(std::time::Duration::from_secs(2)); + } + answer(version, request) + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + let c = BuzzClient::new(relay, credentials).unwrap(); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + c.publish_ephemeral_event(event).await.unwrap(); + assert_eq!(signs.load(Ordering::SeqCst), 2); + assert_eq!(auths.load(Ordering::SeqCst), 1); + assert_eq!(events.load(Ordering::SeqCst), 1); + } +} + +/// WebSocket publish deadlines, on a paused clock: a signer may take as long +/// as it needs, while each network phase, writes included, is capped. +#[cfg(test)] +mod ws_deadline_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + use futures_util::{SinkExt, StreamExt}; + use nostr::{Event, EventBuilder, Keys, Kind, NostrSigner, PublicKey, UnsignedEvent}; + use tokio_tungstenite::tungstenite::Message; + + use super::BuzzClient; + use crate::credentials::Credentials; + use crate::error::CliError; + + /// A WebSocket relay that sends a challenge, then OKs AUTH and one event. + /// Returns its URL and the count of AUTH and EVENT frames it received. + pub(super) async fn ws_relay() -> (String, Arc, Arc) { + let (auths, events) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let (a, e) = (auths.clone(), events.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (a, e) = (a.clone(), e.clone()); + tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = ws + .send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await; + while let Some(Ok(Message::Text(text))) = ws.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let counter = if frame[0] == "AUTH" { &a } else { &e }; + counter.fetch_add(1, Ordering::SeqCst); + let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]); + let _ = ws.send(Message::Text(ok.to_string().into())).await; + } + }); + } + }); + (relay, auths, events) + } + + /// Keys that take `delay` to sign, like a signer held for a decision. + /// The clock is paused only while signing (after the real connect), so + /// the delay passes at once unless an earlier timer fires first. + #[derive(Debug)] + struct Slow(Keys, Duration); + + /// Keys whose first signature takes `delay`, then sign at once. + #[derive(Debug)] + struct SlowFirst(Slow, std::sync::atomic::AtomicBool); + + impl NostrSigner for Slow { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + nostr::signer::SignerBackend::Custom("slow".into()) + } + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> { + self.0.get_public_key() + } + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + Box::pin(async move { + tokio::time::pause(); + tokio::time::sleep(self.1).await; + tokio::time::resume(); + self.0.sign_event(unsigned).await + }) + } + fn nip04_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_encrypt(p, c) + } + fn nip04_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_decrypt(p, c) + } + fn nip44_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_encrypt(p, c) + } + fn nip44_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_decrypt(p, c) + } + } + + impl NostrSigner for SlowFirst { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + self.0.backend() + } + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> { + self.0.get_public_key() + } + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + if self.1.swap(false, Ordering::SeqCst) { + self.0.sign_event(unsigned) + } else { + self.0 .0.sign_event(unsigned) + } + } + fn nip04_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_encrypt(p, c) + } + fn nip04_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_decrypt(p, c) + } + fn nip44_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_encrypt(p, c) + } + fn nip44_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_decrypt(p, c) + } + } + + /// A relay that, like buzz-relay, drops a socket not signed in within + /// 5 s; otherwise as [`ws_relay`]. Returns its URL, its connection + /// count, and the challenges each AUTH answered. + /// A relay closing sockets not signed in within `deadline`: the + /// connection count, the challenges answered and each upgrade's NIP-FI + /// header. + #[allow(clippy::result_large_err)] // tungstenite's handshake callback type + pub(super) async fn deadline_relay(deadline: Duration) -> (String, Arc, Log, Log) { + let conns = Arc::new(AtomicUsize::new(0)); + let (answered, bearers): (Log, Log) = Default::default(); + let (n, log, seen) = (conns.clone(), answered.clone(), bearers.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let challenge = format!("c{}", n.fetch_add(1, Ordering::SeqCst)); + let (log, seen) = (log.clone(), seen.clone()); + tokio::spawn(async move { + let record = + |req: &tokio_tungstenite::tungstenite::handshake::server::Request, res| { + let bearer = req.headers().get("nostr-federated-identity"); + let bearer = bearer.map(|v| v.to_str().unwrap().to_owned()); + seen.lock().unwrap().push(bearer.unwrap_or_default()); + Ok(res) + }; + let mut ws = tokio_tungstenite::accept_hdr_async(stream, record) + .await + .unwrap(); + let hello = serde_json::json!(["AUTH", challenge]).to_string(); + let _ = ws.send(Message::Text(hello.into())).await; + let deadline = tokio::time::sleep(deadline); + tokio::pin!(deadline); + let mut authed = false; + loop { + let next = tokio::select! { + _ = &mut deadline, if !authed => return, + next = ws.next() => next, + }; + let Some(Ok(Message::Text(text))) = next else { + return; + }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + if frame[0] == "AUTH" { + let tags = frame[1]["tags"].as_array().unwrap(); + let c = tags.iter().find(|t| t[0] == "challenge").unwrap(); + log.lock().unwrap().push(c[1].as_str().unwrap().to_owned()); + authed = true; + } + let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]); + let _ = ws.send(Message::Text(ok.to_string().into())).await; + } + }); + } + }); + (relay, conns, answered, bearers) + } + + pub(super) type Log = Arc>>; + + /// A sign-in signature that comes back after the relay's 5 s deadline + /// reconnects and signs the new challenge, never the old one. + #[tokio::test] + async fn a_late_sign_in_reconnects_and_signs_the_new_challenge() { + let keys = Keys::generate(); + let (relay, conns, answered, _) = deadline_relay(Duration::from_secs(5)).await; + let signer = Arc::new(SlowFirst( + Slow(keys.clone(), Duration::from_secs(8)), + std::sync::atomic::AtomicBool::new(true), + )); + let c = + BuzzClient::new(relay, Credentials::from_signer(signer, keys.public_key())).unwrap(); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + c.publish_ephemeral_event(event).await.unwrap(); + assert_eq!(conns.load(Ordering::SeqCst), 2); + assert_eq!( + *answered.lock().unwrap(), + ["c1"], + "only the new challenge is signed" + ); + } + + /// A signer that is always too slow ends retryable after the bound. + #[tokio::test] + async fn a_sign_in_always_too_late_ends_retryable() { + let keys = Keys::generate(); + let (relay, conns, ..) = deadline_relay(Duration::from_secs(5)).await; + let c = client(relay, &keys, Duration::from_secs(8)); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + let err = c.publish_ephemeral_event(event).await.unwrap_err(); + assert!(crate::error::is_retryable_error(&err), "{err:?}"); + assert_eq!( + conns.load(Ordering::SeqCst), + super::WS_SIGN_IN_ATTEMPTS as usize + ); + } + + fn client(relay: String, keys: &Keys, delay: Duration) -> BuzzClient { + let signer = Arc::new(Slow(keys.clone(), delay)); + BuzzClient::new(relay, Credentials::from_signer(signer, keys.public_key())).unwrap() + } + + /// Signing held for 300 s, past the old 225 s single cap, still publishes, + /// once. + #[tokio::test] + async fn a_signature_held_past_every_relay_cap_still_publishes_once() { + let keys = Keys::generate(); + let (relay, auths, events) = ws_relay().await; + let c = client(relay, &keys, Duration::from_secs(300)); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + let started = tokio::time::Instant::now(); + c.publish_ephemeral_event(event).await.unwrap(); + assert!(started.elapsed() >= Duration::from_secs(300)); + assert!( + started.elapsed() < Duration::from_secs(330), + "real time stays short" + ); + assert_eq!(auths.load(Ordering::SeqCst), 1); + assert_eq!(events.load(Ordering::SeqCst), 1); + } + + /// A relay that OKs AUTH and then stops reading stalls the EVENT write; + /// the publish fails within its phase cap and is not sent again. + #[tokio::test] + async fn a_stalled_event_write_fails_within_its_phase_cap() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + let accepted = Arc::new(AtomicUsize::new(0)); + let count = accepted.clone(); + // The relay pauses the clock once it has OKed AUTH, so the EVENT + // phase runs on virtual time from that mark. + let authed = Arc::new(std::sync::Mutex::new(None)); + let mark = authed.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + count.fetch_add(1, Ordering::SeqCst); + let mark = mark.clone(); + tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = ws + .send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await; + if let Some(Ok(Message::Text(text))) = ws.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]); + let _ = ws.send(Message::Text(ok.to_string().into())).await; + tokio::time::pause(); + *mark.lock().unwrap() = Some(tokio::time::Instant::now()); + } + // Hold the socket open without reading. + std::future::pending::<()>().await; + drop(ws); + }); + } + }); + let keys = Keys::generate(); + let c = BuzzClient::new(relay, Credentials::from_keys(keys.clone())).unwrap(); + let event = EventBuilder::new(Kind::Custom(20001), "x".repeat(32 * 1024 * 1024)) + .sign_with_keys(&keys) + .unwrap(); + // An unbounded write would wait here for the hour, not forever. + let err = tokio::time::timeout(Duration::from_secs(3600), c.publish_ephemeral_event(event)) + .await + .expect("the stalled write is bounded") + .unwrap_err(); + let stalled = authed.lock().unwrap().expect("AUTH was OKed").elapsed(); + // From the OK the client has at most the rest of the AUTH phase (the + // paused clock jumps to its read deadline before the OK is read) and + // the whole EVENT phase. + use buzz_ws_client::connection::{AUTH_OK_TIMEOUT_SECS, PUBLISH_OK_TIMEOUT_SECS}; + let cap = Duration::from_secs(AUTH_OK_TIMEOUT_SECS + 1 + PUBLISH_OK_TIMEOUT_SECS + 1); + assert!(stalled <= cap, "{stalled:?} {err:?}"); + assert!( + matches!(err, CliError::Other(ref m) if m.contains("Timeout")), + "{err:?}" + ); + assert_eq!(accepted.load(Ordering::SeqCst), 1); + } + + #[test] + fn auth_keeps_the_configured_relay_path() { + let client = BuzzClient::new( + "wss://host.example/team/".into(), + Credentials::from_keys(Keys::generate()), + ) + .unwrap(); + assert_eq!(client.ws_url, "wss://host.example/team/"); + assert_eq!(client.relay_url, "https://host.example/team"); } } diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index fc32edcb9f..9b98129254 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -18,14 +18,15 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { let owner = require_owner(client)?; let built = build_create( - client.keys(), + client.credentials(), &owner, CreateAgentDraft { channel_id: channel, display_name, system_prompt: read_or_stdin(&system_prompt)?, }, - )?; + ) + .await?; let response = client.publish_ephemeral_event(built.event).await?; let mut output: serde_json::Value = serde_json::from_str(&response) .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; @@ -55,7 +56,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { let owner = require_owner(client)?; let built = build_update( - client.keys(), + client.credentials(), &owner, UpdateAgentDraft { channel_id: channel, @@ -67,7 +68,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli model, respond_to: respond_to.map(RespondToArg::to_wire), }, - )?; + ) + .await?; let response = client.publish_ephemeral_event(built.event).await?; let mut output: serde_json::Value = serde_json::from_str(&response) .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; @@ -93,7 +95,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli admin, } => { validate_hex64(&target_pubkey)?; - let signer_hex = client.keys().public_key().to_hex(); + let signer_hex = client.public_key().to_hex(); let auth = resolve_auth( client, &target_pubkey, @@ -110,7 +112,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli auth.as_ref(), ) .map_err(|e| CliError::Usage(format!("invalid archive request: {e}")))?; - let event = client.sign_event_unchecked(builder)?; + let event = client.sign_event_unchecked(builder).await?; let event_id = event.id.to_hex(); client.submit_event(event).await?; println!( @@ -132,7 +134,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli admin, } => { validate_hex64(&target_pubkey)?; - let signer_hex = client.keys().public_key().to_hex(); + let signer_hex = client.public_key().to_hex(); let auth = resolve_auth( client, &target_pubkey, @@ -148,7 +150,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli auth.as_ref(), ) .map_err(|e| CliError::Usage(format!("invalid unarchive request: {e}")))?; - let event = client.sign_event_unchecked(builder)?; + let event = client.sign_event_unchecked(builder).await?; let event_id = event.id.to_hex(); client.submit_event(event).await?; println!( @@ -889,7 +891,11 @@ mod tests { fn test_client(base_url: &str) -> crate::client::BuzzClient { let keys = nostr::Keys::generate(); - crate::client::BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + crate::client::BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap() } fn kind0_response(signer_hex: &str) -> String { diff --git a/crates/buzz-cli/src/commands/channel_unread.rs b/crates/buzz-cli/src/commands/channel_unread.rs index 5d9ec21489..c4ba9df681 100644 --- a/crates/buzz-cli/src/commands/channel_unread.rs +++ b/crates/buzz-cli/src/commands/channel_unread.rs @@ -147,7 +147,7 @@ fn newest_by_d(events: Vec) -> HashMap { async fn member_channel_ids(client: &BuzzClient) -> Result, CliError> { let filter = json!({ "kinds": [KIND_GROUP_MEMBERS], - "#p": [client.keys().public_key().to_hex()], + "#p": [client.public_key().to_hex()], }); let events = client .query_all_bounded(filter, MEMBER_CHANNEL_LIMIT) @@ -241,7 +241,7 @@ async fn count_channel( .query_paginated(filter, bounds.events_per_channel) .await?; let capped = events.len() >= bounds.events_per_channel as usize; - let me = client.keys().public_key().to_hex(); + let me = client.public_key().to_hex(); let unread = events .iter() .filter(|e| is_unread(e, &me, last_read_at, state)) @@ -454,7 +454,11 @@ mod tests { async fn world() -> World { let relay = Shared::default(); let url = fake_relay(relay.clone()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); World { client, relay, @@ -478,7 +482,7 @@ mod tests { } fn member(&self, channel: &str) { - let me = self.client.keys().public_key(); + let me = self.client.public_key(); let tags = vec![nostr::Tag::identifier(channel), nostr::Tag::public_key(me)]; self.push(self.signed(&Keys::generate(), 39002, tags, 1)); } @@ -509,7 +513,12 @@ mod tests { fn read_state(&self, slot: &str, contexts: Value, at: u64) { let plaintext = json!({"v": 1, "client_id": slot, "contexts": contexts}).to_string(); - self.push(slot_event(self.client.keys(), slot, &plaintext, at)); + self.push(slot_event( + &self.client.credentials().test_keys(), + slot, + &plaintext, + at, + )); } fn queries_for(&self, kind: u64) -> Vec { @@ -535,7 +544,7 @@ mod tests { #[tokio::test] async fn counts_unread_with_desktop_rules() { let mut w = world().await; - let me = w.client.keys().clone(); + let me = w.client.credentials().test_keys(); let other = w.other.clone(); for (id, name) in [(GEN, "general"), (DEV, "dev"), (OLD, "old")] { w.member(id); @@ -706,7 +715,12 @@ mod tests { w.member(GEN); w.channel(GEN, "general", &[]); w.read_state("a", json!({GEN: 1}), 1); - w.push(slot_event(w.client.keys(), "b", "garbage", 2)); + w.push(slot_event( + &w.client.credentials().test_keys(), + "b", + "garbage", + 2, + )); let err = unread(&w.client, &[], None, &BOUNDS).await.unwrap_err(); assert!(err.to_string().contains("unreadable"), "{err}"); diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index ee92017570..b8f4e8b196 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -36,7 +36,7 @@ pub async fn cmd_list_channels( let effective_limit = limit.unwrap_or(500); let events = if member == Some(true) { // Step 1: find channel IDs where we're a member (kind:39002) - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let member_filter = serde_json::json!({ "kinds": [39002], "#p": [my_pk], @@ -457,7 +457,7 @@ pub async fn cmd_restore_canvas( let builder = buzz_sdk::build_set_canvas_after_head(channel_uuid, &content, head_id, head_created_at) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let our_id = event.id.to_hex(); let submit_result = client.submit_event(event).await; @@ -733,7 +733,7 @@ pub async fn cmd_create_channel( ) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; print_create_response(&resp, "channel_id", &channel_uuid.to_string()); Ok(()) @@ -1493,7 +1493,7 @@ pub async fn cmd_create_channel_from_template( // be selected. let owner = client .auth_tag_owner_hex() - .unwrap_or_else(|| client.keys().public_key().to_hex()); + .unwrap_or_else(|| client.public_key().to_hex()); let resolved = build_roster_resolution(client, &owner, &template.agents).await?; @@ -1519,7 +1519,7 @@ pub async fn cmd_create_channel_from_template( unlisted, ) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await?; let mut canvas_applied = false; @@ -1532,7 +1532,7 @@ pub async fn cmd_create_channel_from_template( // `Some("none")` to match the create-assertion convention. let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some("none")) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await?; Ok(()) } @@ -1554,7 +1554,7 @@ pub async fn cmd_create_channel_from_template( Some(buzz_sdk::MemberRole::Bot), ) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await?; Ok(()) } @@ -1707,7 +1707,7 @@ pub async fn cmd_update_channel( ) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1723,7 +1723,7 @@ pub async fn cmd_set_channel_topic( let builder = buzz_sdk::build_set_topic(channel_uuid, topic) .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1739,7 +1739,7 @@ pub async fn cmd_set_channel_purpose( let builder = buzz_sdk::build_set_purpose(channel_uuid, purpose) .map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1751,7 +1751,7 @@ pub async fn cmd_join_channel(client: &BuzzClient, channel_id: &str) -> Result<( let builder = buzz_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1763,7 +1763,7 @@ pub async fn cmd_leave_channel(client: &BuzzClient, channel_id: &str) -> Result< let builder = buzz_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1775,7 +1775,7 @@ pub async fn cmd_archive_channel(client: &BuzzClient, channel_id: &str) -> Resul let builder = buzz_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1787,7 +1787,7 @@ pub async fn cmd_unarchive_channel(client: &BuzzClient, channel_id: &str) -> Res let builder = buzz_sdk::build_unarchive(channel_uuid) .map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1799,7 +1799,7 @@ pub async fn cmd_delete_channel(client: &BuzzClient, channel_id: &str) -> Result let builder = buzz_sdk::build_delete_channel(channel_uuid) .map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1830,7 +1830,7 @@ pub async fn cmd_add_channel_member( let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1847,7 +1847,7 @@ pub async fn cmd_remove_channel_member( let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1891,7 +1891,7 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), &content, ) .tags([]); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -1932,7 +1932,7 @@ pub async fn cmd_set_canvas( } .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -2294,8 +2294,11 @@ mod tests { let keys = nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") .expect("valid test key"); - BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None) - .expect("client construction should not fail") + BuzzClient::new( + "ws://localhost:3000".to_string(), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction should not fail") } #[tokio::test] @@ -3402,8 +3405,11 @@ mod tests { let keys = nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") .expect("valid test key"); - let client = BuzzClient::new(format!("http://{addr}"), keys, None, None) - .expect("client construction should not fail"); + let client = BuzzClient::new( + format!("http://{addr}"), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction should not fail"); let map = fetch_candidate_hints( &client, @@ -3520,7 +3526,11 @@ mod set_canvas_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } fn tag_value(event: &Value, key: &str) -> Option { @@ -3889,7 +3899,11 @@ mod restore_canvas_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } /// Return all filter objects from all /query calls flattened into one vec. @@ -4715,7 +4729,11 @@ mod restore_canvas_already_current_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } /// The already-current short-circuit must return Ok (exit 0) AND emit @@ -4803,7 +4821,11 @@ mod unlisted_support_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } fn has_unlisted_true(event: &Value) -> bool { diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e411827..65df2bda56 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -6,7 +6,7 @@ use crate::validate::{parse_uuid, sdk_err, validate_hex64}; /// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let limit = limit.unwrap_or(50).min(200); let filter = serde_json::json!({ "kinds": [41001], @@ -67,7 +67,7 @@ pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), .collect::, _>>()?; tags.push(Tag::parse(["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?); let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; // Try to extract relay-assigned channel_id from response message. @@ -101,7 +101,7 @@ pub async fn cmd_hide_dm(client: &BuzzClient, channel_id: &str) -> Result<(), Cl .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; let builder = EventBuilder::new(Kind::Custom(buzz_sdk::kind::KIND_DM_HIDE as u16), "").tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -118,7 +118,7 @@ pub async fn cmd_add_dm_member( validate_hex64(pubkey)?; let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index 32aef2f927..0dc4388c0c 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -120,7 +120,7 @@ async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> { /// Fetch the caller's own current custom emoji set (latest kind:30030 under /// the d-tag, authored by the caller). Empty when none published yet. async fn fetch_own_emoji(client: &BuzzClient) -> Result, CliError> { - let me = client.keys().public_key().to_hex(); + let me = client.public_key().to_hex(); let filter = serde_json::json!({ "kinds": [buzz_sdk::kind::KIND_EMOJI_SET], "#d": [CUSTOM_EMOJI_SET_D_TAG], @@ -147,7 +147,7 @@ async fn fetch_own_emoji(client: &BuzzClient) -> Result, CliErr async fn publish_own_set(client: &BuzzClient, emojis: &[CustomEmoji]) -> Result<(), CliError> { let builder = buzz_sdk::build_custom_emoji_set(emojis) .map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -636,7 +636,11 @@ mod tests { use tokio::net::TcpListener; fn test_client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } /// Fake relay: serves a `/query` endpoint returning the given JSON body, diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index 64d82d5714..c5f655ced7 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -14,7 +14,7 @@ pub async fn cmd_get_feed( types: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let limit = limit.unwrap_or(20).min(50); let mut filter = serde_json::json!({ diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs index 0352d2cdb3..b09a9da216 100644 --- a/crates/buzz-cli/src/commands/gifs.rs +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -40,37 +40,6 @@ pub(crate) fn safe_relay_path(path: &str) -> bool { && !path.split('/').any(|seg| seg == "." || seg == "..") } -// --------------------------------------------------------------------------- -// Customer ID derivation -// --------------------------------------------------------------------------- - -/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. -/// -/// KLIPY requires a per-installation identifier that is stable and anonymous. -/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the -/// input is public, so the ID is computable by any observer, and the same value -/// would appear across all relays (cross-relay linkability). -/// -/// Instead, we domain-separate with the relay URL and sign with the *secret* key: -/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` -/// This is: -/// - **stable**: deterministic given the same keypair + relay. -/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. -/// - **not computable from public data**: requires secret key material. -/// - **stateless**: no file I/O, no storage. -/// -/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for -/// KLIPY's per-installation needs. -fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(secret_key_bytes); - hasher.update(b"\0"); // domain separator - hasher.update(relay_url.as_bytes()); - let hash = hasher.finalize(); - hex::encode(&hash[..16]) // 16 bytes → 32 hex chars -} - // --------------------------------------------------------------------------- // Locale // --------------------------------------------------------------------------- @@ -336,10 +305,9 @@ pub(crate) async fn search_entries( locale: Option<&str>, ) -> Result, CliError> { let (search_path, _) = resolve_gif_descriptor(client).await?; - let cid = customer_id( - client.keys().secret_key().as_secret_bytes(), - client.relay_url(), - ); + let cid = client + .credentials() + .relay_scoped_customer_id(client.relay_url()); let locale = locale.map(|l| l.to_string()).unwrap_or_else(default_locale); let body = serde_json::json!({ @@ -358,10 +326,9 @@ pub(crate) async fn search_entries( /// `{"accepted": true}` on success. pub async fn cmd_share(client: &BuzzClient, slug: &str) -> Result<(), CliError> { let (_, share_path) = resolve_gif_descriptor(client).await?; - let cid = customer_id( - client.keys().secret_key().as_secret_bytes(), - client.relay_url(), - ); + let cid = client + .credentials() + .relay_scoped_customer_id(client.relay_url()); let body = serde_json::json!({ "slug": slug, @@ -429,51 +396,6 @@ mod tests { assert!(!safe_relay_path("//")); } - // ----------------------------------------------------------------------- - // customer_id - // ----------------------------------------------------------------------- - - #[test] - fn customer_id_is_32_hex_chars_and_stable() { - let sk = [0xab_u8; 32]; - let id = customer_id(&sk, "https://relay.example"); - assert_eq!(id.len(), 32); - assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - assert_eq!(id, customer_id(&sk, "https://relay.example")); - } - - #[test] - fn customer_id_is_relay_scoped() { - let sk = [0xcd_u8; 32]; - let id_a = customer_id(&sk, "https://relay-a.example"); - let id_b = customer_id(&sk, "https://relay-b.example"); - assert_ne!( - id_a, id_b, - "same key, different relay → different customer_id" - ); - } - - #[test] - fn customer_id_differs_for_different_keys() { - let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); - let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); - assert_ne!(id_a, id_b); - } - - #[test] - fn customer_id_not_equal_to_pubkey_hash() { - // The customer_id must NOT be derivable from the public key alone. - use sha2::{Digest, Sha256}; - let sk = [0xde_u8; 32]; - // What the old pubkey-hash approach would have produced (approximately): - let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); - let actual = customer_id(&sk, "https://relay.example"); - assert_ne!( - actual, naive_hash, - "customer_id must not equal SHA-256(pubkey_hex)[..16]" - ); - } - // ----------------------------------------------------------------------- // default_locale // ----------------------------------------------------------------------- @@ -783,7 +705,11 @@ mod tests { /// Client without an auth tag — used for basic NIP-98 / body / path tests. fn test_client(base_url: &str) -> BuzzClient { let keys = Keys::generate(); - BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap() } /// Client with a synthetic `x-auth-tag` — used to assert that the header @@ -803,9 +729,7 @@ mod tests { let tag = Tag::parse(tag_vec).unwrap(); let client = BuzzClient::new( base_url.to_string(), - keys, - Some(tag), - Some(tag_json.clone()), + crate::credentials::Credentials::from_keys(keys).with_auth_tag(tag, tag_json.clone()), ) .unwrap(); (client, tag_json) diff --git a/crates/buzz-cli/src/commands/huddles.rs b/crates/buzz-cli/src/commands/huddles.rs index db931b1691..e0aec693dd 100644 --- a/crates/buzz-cli/src/commands/huddles.rs +++ b/crates/buzz-cli/src/commands/huddles.rs @@ -763,7 +763,11 @@ mod tests { } fn client(base: &str) -> BuzzClient { - BuzzClient::new(base.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } #[tokio::test] diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 7c90d47b42..ded3d42973 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -255,7 +255,7 @@ pub async fn cmd_create_issue( let builder = with_git_provenance( buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in @@ -366,7 +366,7 @@ async fn publish_issue_assignment_operation( owner: repo_owner.to_string(), id: repo_id.to_string(), }; - let signer = client.keys().public_key().to_hex(); + let signer = client.public_key().to_hex(); let is_self_service = assignees.len() == 1 && assignees[0].eq_ignore_ascii_case(&signer) && !signer.eq_ignore_ascii_case(repo_owner); @@ -398,7 +398,7 @@ async fn publish_issue_assignment_operation( } } .map(|builder| builder.custom_created_at(Timestamp::from_secs(context.created_at))); - let event = client.sign_event(builder.map_err(sdk_err)?)?; + let event = client.sign_event(builder.map_err(sdk_err)?).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -583,7 +583,7 @@ pub async fn cmd_issue_status( let builder = with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/mem.rs b/crates/buzz-cli/src/commands/mem.rs index eb15921bd4..00485d6c14 100644 --- a/crates/buzz-cli/src/commands/mem.rs +++ b/crates/buzz-cli/src/commands/mem.rs @@ -19,9 +19,7 @@ use std::time::SystemTime; use sha2::{Digest, Sha256}; -use buzz_core::engram::{ - self, conversation_key, d_tag, normalize_slug, select_head, validate_and_decrypt, Body, Listing, -}; +use buzz_core::engram::{self, normalize_slug, select_head, Body, Listing}; use buzz_core::kind::KIND_AGENT_ENGRAM; use nostr::PublicKey; @@ -64,16 +62,16 @@ fn resolve_reader( } let agent = PublicKey::from_hex(agent) .map_err(|e| CliError::Usage(format!("--agent must be a 64-hex pubkey: {e}")))?; - if agent == client.keys().public_key() { + if agent == client.public_key() { return Err(CliError::Usage( "--agent must differ from the CLI identity; omit --agent for agent-side reads" .into(), )); } - return Ok((agent, client.keys().public_key(), agent)); + return Ok((agent, client.public_key(), agent)); } - let agent = client.keys().public_key(); + let agent = client.public_key(); let owner = resolve_owner(client, owner_flag)?; Ok((agent, owner, owner)) } @@ -132,6 +130,48 @@ fn parse_events(json: &str) -> Result, CliError> { Ok(out) } +/// NIP-AE read validation through the client's credentials: the envelope, +/// then NIP-44 decryption, then the slug re-deriving to the `d` tag. +/// `Ok(None)` means the event is not a valid engram and is skipped; `Err` is +/// a credential failure, which aborts rather than reading as "no entries". +async fn open_engram( + client: &BuzzClient, + ev: &nostr::Event, + agent: &PublicKey, + owner: &PublicKey, + their_pubkey: &PublicKey, +) -> Result, CliError> { + let Ok(d_value) = engram::validate_envelope(ev, agent, owner) else { + return Ok(None); + }; + let credentials = client.credentials(); + let Some(plaintext) = credentials.nip44_decrypt(their_pubkey, &ev.content).await? else { + return Ok(None); + }; + let Ok(body) = Body::from_json_bytes(plaintext.as_bytes()) else { + return Ok(None); + }; + let derived = credentials.engram_d_tag(their_pubkey, body.slug()).await?; + Ok(engram::check_d_tag(&d_value, &derived).ok().map(|()| body)) +} + +/// Encrypt `body` to `owner` and sign the `kind:30174` event with the +/// client's credentials (NIP-AE write path). +async fn seal_engram( + client: &BuzzClient, + owner: &PublicKey, + body: &Body, + created_at: u64, +) -> Result { + let build_failed = |e: engram::EngramError| CliError::Other(format!("build event failed: {e}")); + let credentials = client.credentials(); + let plaintext = engram::encode_body(body).map_err(build_failed)?; + let ciphertext = credentials.nip44_encrypt(owner, &plaintext).await?; + let d = credentials.engram_d_tag(owner, body.slug()).await?; + let builder = engram::event_builder(owner, &d, ciphertext, created_at).map_err(build_failed)?; + credentials.sign(builder).await +} + /// Fetch the head event for `slug`, returning `(Option, Option)`. async fn fetch_head( client: &BuzzClient, @@ -139,13 +179,15 @@ async fn fetch_head( owner: &PublicKey, slug: &str, ) -> Result<(Option, Option), CliError> { - let their_pubkey = if client.keys().public_key() == *agent { + let their_pubkey = if client.public_key() == *agent { owner } else { agent }; - let k_c = conversation_key(client.keys().secret_key(), their_pubkey); - let d = d_tag(&k_c, slug); + let d = client + .credentials() + .engram_d_tag(their_pubkey, slug) + .await?; let filter = serde_json::json!({ "kinds": [KIND_AGENT_ENGRAM], @@ -164,9 +206,8 @@ async fn fetch_head( if ev.verify().is_err() { continue; } - match validate_and_decrypt(&ev, agent, owner, client.keys().secret_key(), their_pubkey) { - Ok(body) => valid_with_body.push((ev, body)), - Err(_) => continue, + if let Some(body) = open_engram(client, &ev, agent, owner, their_pubkey).await? { + valid_with_body.push((ev, body)); } } if valid_with_body.is_empty() { @@ -219,15 +260,8 @@ pub async fn cmd_ls( else { continue; }; - let body = match validate_and_decrypt( - &ev, - &agent, - &owner, - client.keys().secret_key(), - &their_pubkey, - ) { - Ok(b) => b, - Err(_) => continue, + let Some(body) = open_engram(client, &ev, &agent, &owner, &their_pubkey).await? else { + continue; }; groups.entry(d_value).or_default().push((ev, body)); } @@ -357,14 +391,12 @@ pub async fn cmd_set( value: Some(value), } }; - let agent_pubkey = client.keys().public_key(); + let agent_pubkey = client.public_key(); let (head, _) = fetch_head(client, &agent_pubkey, &owner, &slug).await?; let prior_created_at = head.map(|e| e.created_at.as_secs()); let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); - let agent = client.keys(); - let event = engram::build_event(agent, &owner, &body, created_at) - .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let event = seal_engram(client, &owner, &body, created_at).await?; let id = event.id.to_hex(); submit_engram(client, event).await?; eprintln!("wrote {slug} (event {id}, created_at {created_at})"); @@ -598,7 +630,7 @@ pub async fn cmd_patch( }; let owner = resolve_owner(client, owner_flag)?; - let agent_pubkey = client.keys().public_key(); + let agent_pubkey = client.public_key(); let (head, current) = fetch_value(client, &agent_pubkey, &owner, &slug).await?; // Base-hash gate: concurrent-edit safety. @@ -688,9 +720,7 @@ pub async fn cmd_patch( let prior_created_at = Some(head.created_at.as_secs()); let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); - let agent = client.keys(); - let event = engram::build_event(agent, &owner, &body, created_at) - .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let event = seal_engram(client, &owner, &body, created_at).await?; let id = event.id.to_hex(); submit_engram(client, event).await?; eprintln!("wrote {slug} (event {id}, created_at {created_at}, sha256 {new_hash})"); @@ -720,14 +750,12 @@ pub async fn cmd_rm( slug: slug.clone(), value: None, }; - let agent_pubkey = client.keys().public_key(); + let agent_pubkey = client.public_key(); let (head, _) = fetch_head(client, &agent_pubkey, &owner, &slug).await?; let prior_created_at = head.map(|e| e.created_at.as_secs()); let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); - let agent = client.keys(); - let event = engram::build_event(agent, &owner, &body, created_at) - .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let event = seal_engram(client, &owner, &body, created_at).await?; let id = event.id.to_hex(); submit_engram(client, event).await?; eprintln!("tombstoned {slug} (event {id}, created_at {created_at})"); @@ -785,8 +813,134 @@ mod tests { // verify base-hash from the shell. Hard-coded vectors from the NIST and // common quick-check inputs. + /// Through the desktop, an engram with bad ciphertext is skipped while + /// a valid one opens; a credential failure aborts instead of reading as + /// "no entries". + #[cfg(unix)] + #[tokio::test] + async fn the_desktop_skips_bad_ciphertext_but_aborts_on_credential_failure() { + use crate::desktop_provider::{tests as dp, Provider}; + const RELAY: &str = "wss://community.example"; + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let body = Body::Memory { + slug: "mem/a".into(), + value: Some("v".into()), + }; + let valid = engram::build_event(&agent, &owner.public_key(), &body, 1).unwrap(); + let k_c = engram::conversation_key(agent.secret_key(), &owner.public_key()); + let corrupt = engram::event_builder( + &owner.public_key(), + &engram::d_tag(&k_c, "mem/a"), + "AAAA".into(), + 2, + ) + .unwrap() + .sign_with_keys(&agent) + .unwrap(); + let open = |endpoint| async move { + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(p) + .await + .unwrap(); + BuzzClient::new("http://127.0.0.1:9".into(), credentials).unwrap() + }; + let (endpoint, _) = dp::desktop(owner.clone(), vec![RELAY.into()]).await; + let client = open(endpoint).await; + let (a, o) = (agent.public_key(), owner.public_key()); + let opened = open_engram(&client, &valid, &a, &o, &a).await.unwrap(); + assert!(matches!(opened, Some(Body::Memory { .. }))); + assert!(open_engram(&client, &corrupt, &a, &o, &a) + .await + .unwrap() + .is_none()); + + let (endpoint, _) = dp::provider(move |_, r| match r["method"].as_str() { + Some("describe") => serde_json::json!({"result": serde_json::json!({ + "pubkey": owner.public_key().to_hex(), "relays": [RELAY], "methods": [], + }).to_string()}), + _ => serde_json::json!({"result": "", "error": "unavailable"}), + }) + .await; + let client = open(endpoint).await; + assert!(open_engram(&client, &valid, &a, &o, &a).await.is_err()); + } + + /// A desktop that throttles partway through `buzz mem ls` is waited out, + /// and the list completes. + #[cfg(unix)] + #[tokio::test] + async fn a_list_completes_when_the_desktop_throttles_partway() { + use crate::desktop_provider::{tests as dp, Provider}; + use std::sync::atomic::{AtomicUsize, Ordering}; + let agent = nostr::Keys::generate(); + let owner = nostr::Keys::generate().public_key(); + let events: Vec = (0..3) + .map(|i| { + let body = Body::Memory { + slug: format!("mem/{i}"), + value: Some("v".into()), + }; + let ev = engram::build_event(&agent, &owner, &body, 1).unwrap(); + serde_json::to_value(&ev).unwrap() + }) + .collect(); + let app = axum::Router::new().route( + "/query", + axum::routing::post(move || async move { axum::Json(events) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + // The second decrypt is refused once with `rate_limited`. + let decrypts = std::sync::Arc::new(AtomicUsize::new(0)); + let counted = decrypts.clone(); + let answer = dp::desktop_answers(agent.clone(), vec![relay.clone()]); + let (endpoint, seen) = dp::provider(move |version, request| { + if request["method"] == "nip44_decrypt" && counted.fetch_add(1, Ordering::SeqCst) == 1 { + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}); + } + answer(version, request) + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + let client = BuzzClient::new(relay, credentials).unwrap(); + + cmd_ls(&client, Some(&owner.to_hex()), None, true) + .await + .unwrap(); + assert_eq!( + decrypts.load(Ordering::SeqCst), + 4, + "three entries, one retry" + ); + let d_tags = seen + .lock() + .unwrap() + .iter() + .filter(|(_, _, r)| r["method"] == "engram_d_tag") + .count(); + assert_eq!(d_tags, 3); + } + fn test_client(keys: nostr::Keys) -> BuzzClient { - BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None).unwrap() + BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap() } #[test] diff --git a/crates/buzz-cli/src/commands/message_report.rs b/crates/buzz-cli/src/commands/message_report.rs index 55630df59e..26db0f265a 100644 --- a/crates/buzz-cli/src/commands/message_report.rs +++ b/crates/buzz-cli/src/commands/message_report.rs @@ -82,7 +82,7 @@ pub async fn cmd_report_message( } .map_err(|e| CliError::Usage(format!("invalid report: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client .submit_event(event) .await @@ -177,7 +177,11 @@ mod tests { } fn client(url: String) -> BuzzClient { - BuzzClient::new(url, Keys::generate(), None, None).unwrap() + BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } fn parse(args: &[&str]) -> Result { @@ -260,7 +264,11 @@ mod tests { let ok = r#"{"event_id":"e1","accepted":true,"message":""}"#; let (url, captured) = fake_relay(message_by(&me), StatusCode::OK, ok).await; - let client = BuzzClient::new(url, keys.clone(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(keys.clone()), + ) + .unwrap(); cmd_report_message( &client, ReportParams { @@ -282,7 +290,8 @@ mod tests { ); let (url, captured) = fake_relay(serde_json::json!([]), StatusCode::OK, ok).await; - let client = BuzzClient::new(url, keys, None, None).unwrap(); + let client = + BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap(); cmd_report_message( &client, ReportParams { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 65dfdbe3ca..6e3d476823 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -769,7 +769,7 @@ pub async fn cmd_send_message( } }; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let emitted_mentions = event_mention_pubkeys(&event); let resp = client.submit_event(event).await?; let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) @@ -865,7 +865,7 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re buzz_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref()) .map_err(|e| CliError::Other(format!("build_diff_message failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -896,7 +896,7 @@ pub async fn cmd_delete_message( ) .map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -919,7 +919,7 @@ pub async fn cmd_edit_message( let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content) .map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -950,7 +950,7 @@ pub async fn cmd_vote_on_post( let builder = buzz_sdk::build_vote(channel_uuid, target_eid, vote_dir) .map_err(|e| CliError::Other(format!("build_vote failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -1156,8 +1156,11 @@ mod tests { #[tokio::test] async fn malformed_channel_is_rejected_before_thread_fetch() { - let client = - BuzzClient::new("http://127.0.0.1:1".into(), Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + "http://127.0.0.1:1".into(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); let error = cmd_get_thread( &client, "not-a-uuid", @@ -1773,7 +1776,11 @@ mod tests { // Content contains `:wave:` which resolves in the palette. // The submitted event must carry an `emoji` tag for `wave`. let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); cmd_send_message(&client, send_params("hello :wave: everyone")) .await @@ -1825,7 +1832,11 @@ mod tests { // Content has no `:` at all — the palette query must be skipped // entirely (zero RTTs), and the submitted event must have no emoji tags. let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); cmd_send_message(&client, send_params("plain message no colons")) .await @@ -1904,7 +1915,11 @@ mod tests { let addr: StdSocketAddr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let url = format!("http://{addr}"); - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); // Must not return Err — a palette failure is a soft warning. cmd_send_message(&client, send_params(":wave: message with emoji candidate")) @@ -2025,7 +2040,11 @@ mod tests { stored_message(&"c".repeat(64), other_channel, "elsewhere", &[]), ]) .await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); let send = |from_thread: &str, kind: Option| { let mut params = send_params("decision: ship it"); diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs index c53aecaf85..34393caebc 100644 --- a/crates/buzz-cli/src/commands/moderation.rs +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -42,7 +42,7 @@ async fn cmd_ban( let expiry = resolve_expiry(expires_in, expires_at); let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -52,7 +52,7 @@ async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { validate_hex64(pubkey)?; let builder = buzz_sdk::build_moderation_unban(pubkey) .map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -70,7 +70,7 @@ async fn cmd_timeout( .ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?; let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -80,7 +80,7 @@ async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError validate_hex64(pubkey)?; let builder = buzz_sdk::build_moderation_untimeout(pubkey) .map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -96,7 +96,7 @@ async fn cmd_resolve( validate_hex64(report)?; let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason) .map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index 08ef345be1..9c58dd2196 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -166,7 +166,7 @@ fn parse_events(json: &str) -> Result, CliError> { /// needs its presence to decide first-publish vs. update / deletable-or-not. /// (Quinn's option (b) — single tag-parser, isolated.) pub async fn fetch_own_note(client: &BuzzClient, slug: &str) -> Result, CliError> { - let me = client.keys().public_key(); + let me = client.public_key(); let filter = serde_json::json!({ "kinds": [KIND_LONG_FORM], "authors": [me.to_hex()], @@ -203,7 +203,7 @@ pub async fn fetch_by_slug(client: &BuzzClient, slug: &str) -> Result /// kind:0 profiles. Exact-one match required; ambiguity is a hard error. pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result { if author_flag == "me" { - return Ok(client.keys().public_key()); + return Ok(client.public_key()); } if validate_hex64(author_flag).is_ok() { return PublicKey::from_hex(author_flag) @@ -539,7 +539,7 @@ pub async fn cmd_set( now_secs(), )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id; let me = event.pubkey; let raw = client.submit_event(event).await?; @@ -718,7 +718,7 @@ pub async fn cmd_rm(client: &BuzzClient, slug: &str) -> Result<(), CliError> { // Read-before-write: only the author can delete their own note, and we // want a clear "nothing to delete" signal rather than emitting a kind:5 // for a coordinate that was never published. - let me = client.keys().public_key(); + let me = client.public_key(); if fetch_own_note(client, slug).await?.is_none() { return Err(CliError::NotFound(format!( "no note {slug:?} found for you ({}); nothing to delete", @@ -728,7 +728,7 @@ pub async fn cmd_rm(client: &BuzzClient, slug: &str) -> Result<(), CliError> { let coord = coord_for(&me, slug); let builder = build_rm_event(&coord)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id; let raw = client.submit_event(event).await?; let parsed: serde_json::Value = serde_json::from_str(&raw) diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 413934a3c1..283cfa43e3 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -50,7 +50,7 @@ pub async fn cmd_send_patch( let builder = with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -184,7 +184,7 @@ pub async fn cmd_patch_status( let builder = with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 74c580a6d6..47a76357e8 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -59,7 +59,7 @@ pub async fn cmd_open_pr( let builder = with_git_provenance( buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in @@ -107,7 +107,7 @@ pub async fn cmd_update_pr( let builder = with_git_provenance( buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -217,7 +217,7 @@ pub async fn cmd_pr_status( let builder = with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs index 887239f704..4a04bcf977 100644 --- a/crates/buzz-cli/src/commands/project_channel.rs +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -34,7 +34,7 @@ pub async fn resolve_or_ensure_repo_for_channel( return Ok(repo); } - let caller = client.keys().public_key().to_hex(); + let caller = client.public_key().to_hex(); if let Some(repo) = repos.iter().find_map(|event| { event .pubkey @@ -201,7 +201,7 @@ async fn ensure_default_repo( let repo_id = repo_id_from_project_slug(&slug)?; let name = project_name(project).unwrap_or_else(|| slug.clone()); let name = truncate_repo_name(&name); - let caller = client.keys().public_key().to_hex(); + let caller = client.public_key().to_hex(); if let Some(existing) = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await? @@ -223,7 +223,7 @@ async fn ensure_default_repo( &[], Some(channel), )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let raw = client.submit_event(event).await?; let winner = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await?; verify_default_repo_write(&raw, winner.as_ref(), channel)?; @@ -474,7 +474,11 @@ mod tests { socket.write_all(response.as_bytes()).await.unwrap(); } }); - let client = crate::client::BuzzClient::new(base_url, keys, None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap(); let result = ensure_default_repo(&client, requested, &project).await; diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 3edad04b41..949ffa661c 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -47,7 +47,7 @@ async fn cmd_add_channel_draft( let owner = PublicKey::parse(&owner_hex) .map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?; let built = build_project_channel( - client.keys(), + client.credentials(), &owner, CreateProjectChannelDraft { home_channel_id: home_channel, @@ -57,7 +57,8 @@ async fn cmd_add_channel_draft( ttl_seconds, template_name, }, - )?; + ) + .await?; let response = client.publish_ephemeral_event(built.event).await?; let mut output: serde_json::Value = serde_json::from_str(&response) .map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?; @@ -173,7 +174,7 @@ pub async fn add_repos_to_own_project( repos: &[String], ) -> Result { validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); + let caller_pubkey = client.public_key().to_hex(); let new_members: Vec = repos .iter() @@ -222,7 +223,7 @@ pub async fn add_repos_to_own_project( } let builder = rebuild_project(&head.content, tags, next_ts)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await } @@ -233,7 +234,7 @@ pub async fn try_add_own_repo_to_channel_project( repo_id: &str, ) -> Result<(), CliError> { let projects = fetch_projects_for_channel(client, channel).await?; - let caller = client.keys().public_key().to_hex(); + let caller = client.public_key().to_hex(); let Some(event) = projects.iter().find(|candidate| { candidate.pubkey.to_hex().eq_ignore_ascii_case(&caller) && !project_is_unlisted(candidate) }) else { @@ -259,7 +260,7 @@ async fn fetch_project( crate::validate::validate_hex64(pk)?; pk.to_string() } - None => client.keys().public_key().to_hex(), + None => client.public_key().to_hex(), }; let filter = serde_json::json!({ "kinds": [KIND_PROJECT], @@ -306,7 +307,7 @@ async fn submit_project( builder: EventBuilder, link_slug: Option<&str>, ) -> Result<(), CliError> { - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let owner = event.pubkey.to_hex(); let raw = client.submit_event(event).await?; let response = parse_write_response(&raw, "project changed concurrently; retry")?; @@ -369,7 +370,7 @@ pub async fn cmd_create( // ── Local validation (all checks before any .await) ─────────────────── validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); + let caller_pubkey = client.public_key().to_hex(); // Expand and validate repo coordinates. let mut members: Vec = repos @@ -492,7 +493,7 @@ pub async fn cmd_list( crate::validate::validate_hex64(pk)?; pk.to_string() } - None => client.keys().public_key().to_hex(), + None => client.public_key().to_hex(), }; let mut filter = serde_json::json!({ "kinds": [KIND_PROJECT], @@ -525,7 +526,7 @@ pub async fn cmd_remove_repo( repos: &[String], ) -> Result<(), CliError> { validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); + let caller_pubkey = client.public_key().to_hex(); // ── Local validation before any .await ──────────────────────────────── let to_remove: Vec = repos @@ -697,12 +698,12 @@ pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; let next_ts = next_timestamp(&head, Timestamp::now())?; - let pubkey_hex = client.keys().public_key().to_hex(); + let pubkey_hex = client.public_key().to_hex(); let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? .custom_created_at(next_ts); - let event = client.sign_event(tombstone)?; + let event = client.sign_event(tombstone).await?; let raw = client.submit_event(event).await?; parse_write_response(&raw, "delete event was dominated; a newer head exists")?; @@ -742,7 +743,7 @@ async fn ensure_default_create_repo( &[], Some(channel), )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let raw = client.submit_event(event).await?; let winner = fetch_own_repo_announcement(client, &repo_id).await?; verify_default_repo_write(&raw, winner.as_ref(), channel)?; @@ -956,7 +957,11 @@ mod tests { socket.write_all(response.as_bytes()).await.unwrap(); } }); - let client = crate::client::BuzzClient::new(base_url, keys, None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap(); let result = cmd_create( &client, "app", @@ -1029,8 +1034,11 @@ mod tests { .await .unwrap(); }); - let client = - crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap(); let projects = fetch_projects_for_channel(&client, channel).await.unwrap(); assert!(projects.is_empty()); @@ -1077,8 +1085,11 @@ mod tests { ); socket.write_all(response.as_bytes()).await.unwrap(); }); - let client = - crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap(); let projects = fetch_projects_for_channel_bounded(&client, channel, 1) .await @@ -1518,8 +1529,11 @@ mod tests { // Port 9 is the discard protocol — any real connect will be refused // immediately, but the guard fires before the first await so this // never reaches the network. - let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) - .expect("client construction"); + let client = crate::client::BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction"); let err = cmd_update( &client, "my-slug", None, false, // name / clear_name @@ -1544,8 +1558,11 @@ mod tests { fn discard_client() -> crate::client::BuzzClient { let keys = nostr::Keys::generate(); - crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) - .expect("client construction") + crate::client::BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction") } /// Creating without --repo or --channel must fail locally; the default diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d30131..f684a8beca 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -24,7 +24,7 @@ pub async fn cmd_add_reaction( .map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))? }; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -37,10 +37,8 @@ pub async fn cmd_remove_reaction( emoji: &str, ) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = client.keys(); - // Find our reaction event by querying kind:7 reactions on this event from us - let my_pk = keys.public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let filter = serde_json::json!({ "kinds": [7], "#e": [event_id], @@ -70,7 +68,7 @@ pub async fn cmd_remove_reaction( let builder = buzz_sdk::build_remove_reaction(reaction_eid) .map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); diff --git a/crates/buzz-cli/src/commands/read_state.rs b/crates/buzz-cli/src/commands/read_state.rs index 471ed6dc22..c7aad6260e 100644 --- a/crates/buzz-cli/src/commands/read_state.rs +++ b/crates/buzz-cli/src/commands/read_state.rs @@ -10,7 +10,6 @@ use std::collections::HashMap; use buzz_core::kind::KIND_READ_STATE; -use nostr::nips::nip44; use nostr::Event; use serde_json::{json, Value}; @@ -53,7 +52,7 @@ impl ReadState { /// a relay without the extension is an error rather than a partial read. pub(crate) async fn fetch(client: &BuzzClient) -> Result { let community_id = discover_snapshot(client).await?; - let me = client.keys().public_key().to_hex(); + let me = client.public_key().to_hex(); let filter = json!({ "kinds": [KIND_READ_STATE], "authors": [me], @@ -61,7 +60,7 @@ pub(crate) async fn fetch(client: &BuzzClient) -> Result { }); let raw = client.query(&filter).await?; let events = snapshot_events(&raw, &community_id, &me)?; - merge(client.keys(), events) + merge(client.credentials(), events).await } fn cannot_prove_complete(detail: impl std::fmt::Display) -> CliError { @@ -200,8 +199,11 @@ fn decode(event: &Event, plaintext: &str) -> Result, /// Verify, decrypt and max-merge raw relay events. Events that are not read-state /// coordinates are ignored; a coordinate that cannot be read is an error. -pub(crate) fn merge(keys: &nostr::Keys, raw: Vec) -> Result { - let me = keys.public_key(); +pub(crate) async fn merge( + credentials: &crate::credentials::Credentials, + raw: Vec, +) -> Result { + let me = credentials.public_key(); let mut heads: HashMap = HashMap::new(); for value in raw { let event: Event = serde_json::from_value(value) @@ -234,8 +236,10 @@ pub(crate) fn merge(keys: &nostr::Keys, raw: Vec) -> Result = heads.into_iter().collect(); ordered.sort_by(|a, b| a.0.cmp(&b.0)); for (d_tag, event) in ordered { - let plaintext = nip44::decrypt(keys.secret_key(), &me, &event.content) - .map_err(|e| unreadable(&event, e))?; + let plaintext = credentials + .nip44_decrypt(&me, &event.content) + .await? + .ok_or_else(|| unreadable(&event, "not valid NIP-44 ciphertext for your key"))?; match decode(&event, &plaintext)? { Ok(contexts) => { for (key, ts) in contexts { @@ -253,10 +257,22 @@ pub(crate) fn merge(keys: &nostr::Keys, raw: Vec) -> Result) -> Result { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(super::merge( + &crate::credentials::Credentials::from_keys(keys.clone()), + raw, + )) + } + pub(crate) const COMMUNITY: &str = "5f0c1b9e-2d0b-4f4e-9a7c-0d6f3f3f3f3f"; pub(crate) fn snapshot_envelope(pubkey: &str, events: Vec) -> Value { @@ -432,7 +448,11 @@ pub(crate) mod tests { let url = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); ( - BuzzClient::new(url, keys.clone(), None, None).unwrap(), + BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(keys.clone()), + ) + .unwrap(), bodies, ) } diff --git a/crates/buzz-cli/src/commands/repo_default_branch.rs b/crates/buzz-cli/src/commands/repo_default_branch.rs index 23aba9f542..24659df4ed 100644 --- a/crates/buzz-cli/src/commands/repo_default_branch.rs +++ b/crates/buzz-cli/src/commands/repo_default_branch.rs @@ -52,7 +52,7 @@ pub(super) async fn dispatch( } => (id, owner, Some((branch, expected_manifest))), }; crate::validate::validate_repo_id(&id)?; - let owner = owner.unwrap_or_else(|| client.keys().public_key().to_hex()); + let owner = owner.unwrap_or_else(|| client.public_key().to_hex()); crate::validate::validate_hex64(&owner)?; let path = format!("/git/{owner}/{id}/default-branch"); let result = match update { @@ -168,7 +168,8 @@ mod tests { }); let app = Router::new().route(&path, route); let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); - let client = BuzzClient::new(url, keys, None, None).unwrap(); + let client = + BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap(); let result = dispatch( ReposDefaultBranchCmd::Get { id: "demo".into(), @@ -276,7 +277,8 @@ mod tests { }), ); let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); - let client = BuzzClient::new(url, keys, None, None).unwrap(); + let client = + BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap(); let result = crate::commands::repos::dispatch( crate::ReposCmd::DefaultBranch(ReposDefaultBranchCmd::Set { id: "demo".into(), diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 3e6b0ac15c..e408f7be85 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -20,7 +20,7 @@ pub(crate) async fn fetch_own_repo_announcement( ) -> Result, CliError> { let filter = serde_json::json!({ "kinds": [KIND_GIT_REPO_ANNOUNCEMENT], - "authors": [client.keys().public_key().to_hex()], + "authors": [client.public_key().to_hex()], "#d": [repo_id], "limit": 1, }); @@ -194,7 +194,7 @@ fn validate_write_response(raw: &str) -> Result { } async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let raw = client.submit_event(event).await?; println!("{}", validate_write_response(&raw)?); Ok(()) @@ -260,7 +260,7 @@ pub async fn cmd_create_repo( relays, channel, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let owner = event.pubkey.to_hex(); let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in @@ -313,7 +313,7 @@ pub async fn cmd_list_repos( crate::validate::validate_hex64(pk)?; pk.to_string() } - None => client.keys().public_key().to_hex(), + None => client.public_key().to_hex(), }; let mut filter = serde_json::json!({ diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index 89c028a8ae..e6db415b13 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -33,7 +33,7 @@ pub async fn cmd_publish_note( let builder = buzz_sdk::build_note(content, reply_id) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -61,7 +61,7 @@ pub async fn cmd_set_contact_list( let builder = buzz_sdk::build_contact_list(&contacts) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -175,7 +175,7 @@ pub async fn cmd_set_list( } let builder = EventBuilder::new(Kind::Custom(kind), content).tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 997cec4912..4b5a895a63 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -39,7 +39,7 @@ pub async fn cmd_get_users( return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into())); } - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let authors: Vec<&str> = if pubkeys.is_empty() { vec![my_pk.as_str()] } else { @@ -89,7 +89,7 @@ pub async fn cmd_get_users( fn effective_owner(client: &BuzzClient) -> String { client .auth_tag_owner_hex() - .unwrap_or_else(|| client.keys().public_key().to_hex()) + .unwrap_or_else(|| client.public_key().to_hex()) } fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result, CliError> { @@ -419,7 +419,7 @@ pub async fn cmd_set_profile( ) .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -431,7 +431,7 @@ pub async fn cmd_set_profile( async fn fetch_current_profile( client: &BuzzClient, ) -> Result, CliError> { - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let filter = serde_json::json!({ "kinds": [0], "authors": [my_pk], @@ -510,7 +510,7 @@ pub(crate) fn presence_subject(event: &serde_json::Value) -> &str { /// publishes the event directly — bypassing the HTTP bridge. pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), CliError> { let builder = buzz_sdk::build_presence_update(status).map_err(crate::validate::sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.publish_ephemeral_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -590,7 +590,7 @@ async fn publish_status( let expires_at = expires.map(|value| parse_expiry(value, now)).transpose()?; let builder = buzz_sdk::build_user_status_expiring(text, emoji, expires_at) .map_err(crate::validate::sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; Ok(normalize_write_response(&resp)) } @@ -703,7 +703,7 @@ async fn read_statuses( } } if authors.is_empty() { - authors.push(client.keys().public_key().to_hex()); + authors.push(client.public_key().to_hex()); } let filter = serde_json::json!({ "kinds": [buzz_core::kind::KIND_USER_STATUS], @@ -853,7 +853,11 @@ mod tests { async fn setup() -> (BuzzClient, Shared) { let relay = Shared::default(); let url = fake_relay(relay.clone()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); (client, relay) } @@ -978,7 +982,7 @@ mod tests { #[tokio::test] async fn status_read_hides_expired_and_cleared_and_reports_the_rest() { let (client, relay) = setup().await; - let me = client.keys().clone(); + let me = client.credentials().test_keys(); let (expired, cleared, emoji_only, odd, timed) = ( Keys::generate(), Keys::generate(), diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 0028dfc766..aa0f5f047b 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -106,7 +106,7 @@ pub async fn cmd_create_workflow( let workflow_id = uuid::Uuid::new_v4(); let builder = buzz_sdk::build_workflow_def(channel_uuid, workflow_id, &yaml_definition) .map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; let final_workflow_id = extract_relay_response_field(&resp, "workflow_id") @@ -141,7 +141,7 @@ pub async fn cmd_update_workflow( let builder = buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition, expected_revision) .map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -151,11 +151,9 @@ pub async fn cmd_update_workflow( /// Delete a workflow — sign and submit a kind:5 deletion event. pub async fn cmd_delete_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> { let wf_uuid = parse_uuid(workflow_id)?; - let keys = client.keys(); - let builder = - buzz_sdk::build_workflow_delete(&keys.public_key().to_hex(), wf_uuid).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + buzz_sdk::build_workflow_delete(&client.public_key().to_hex(), wf_uuid).map_err(sdk_err)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -190,12 +188,12 @@ pub async fn cmd_trigger_workflow( &content, ) .tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); } else { let builder = buzz_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); } @@ -217,7 +215,7 @@ pub async fn cmd_approve_step( let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes())); let builder = buzz_sdk::build_workflow_approval(&token_hash, approved, content).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); diff --git a/crates/buzz-cli/src/credentials.rs b/crates/buzz-cli/src/credentials.rs new file mode 100644 index 0000000000..033ba022f8 --- /dev/null +++ b/crates/buzz-cli/src/credentials.rs @@ -0,0 +1,805 @@ +//! The CLI's credential chain: which identity it acts as, how it signs, and +//! which NIP-FI evidence it presents to the relay. +//! +//! Every relay request goes through [`Credentials`]. Signing and NIP-44 go +//! through a [`NostrSigner`], so a credential link never has to hand the CLI a +//! secret key. [`Credentials::evidence`] supplies the `Nostr-Federated-Identity` +//! assertion when an evidence source is configured and `None` otherwise, so one +//! code path serves relays with and without NIP-FI. +//! +//! [`resolve`] tries each link in order; the first configured link wins. An +//! explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first link, so a +//! caller that chooses a key always gets that key. + +use std::sync::Arc; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use nostr::{Event, EventBuilder, JsonUtil, Keys, NostrSigner, PublicKey, Tag, UnsignedEvent}; +use sha2::{Digest, Sha256}; + +use crate::error::CliError; +pub use crate::federated_identity::{ + AdapterSession, FEDERATED_IDENTITY_ADAPTER_ENV, FEDERATED_IDENTITY_TOKEN_ENV, +}; + +/// Raw credential settings, from flags and the environment. +#[derive(Default)] +pub struct Settings { + /// `--private-key` / `BUZZ_PRIVATE_KEY`. + pub private_key: Option, + /// `--auth-tag` / `BUZZ_AUTH_TAG` (NIP-OA owner attestation). + pub auth_tag: Option, + /// `BUZZ_FEDERATED_IDENTITY_TOKEN` (NIP-FA adapter session token). + pub federated_identity_token: Option, + /// `BUZZ_FEDERATED_IDENTITY_ADAPTER` (NIP-FA adapter base URL). + pub federated_identity_adapter: Option, +} + +impl Settings { + /// Read the evidence-source settings from the environment. Flags for the + /// session token are deliberately absent: command lines are visible to + /// other local users. + pub fn with_env_evidence(mut self) -> Self { + let non_empty = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty()); + self.federated_identity_token = non_empty(FEDERATED_IDENTITY_TOKEN_ENV); + self.federated_identity_adapter = non_empty(FEDERATED_IDENTITY_ADAPTER_ENV); + self + } +} + +/// A verified NIP-OA auth tag and its canonical JSON for the `x-auth-tag` header. +pub struct AuthTag { + pub tag: Tag, + pub json: String, +} + +/// The identity the CLI acts as for this process. +pub struct Credentials { + public_key: PublicKey, + signer: Arc, + /// Key material, present only when the link holds the key locally. + local_keys: Option, + auth_tag: Option, + evidence: Option, + /// The Buzz desktop, when it is the link that holds the key. + #[cfg(unix)] + desktop: Option>, +} + +/// Walk the credential chain for `relay_url` and return the first link that +/// applies: the explicit key, then (on Unix) the Buzz desktop through an +/// inherited or paired token. +pub async fn resolve(settings: Settings, relay_url: &str) -> Result { + if let Some(credentials) = resolve_explicit(&settings, relay_url)? { + return Ok(credentials); + } + #[cfg(unix)] + if let Some(provider) = crate::desktop_provider::discover(relay_url).await? { + if let Some(warning) = ignored_headless_settings(&settings) { + eprintln!("{warning}"); + } + return Credentials::from_desktop(provider).await; + } + Err(CliError::Auth( + "no credentials: set BUZZ_PRIVATE_KEY, pass --private-key, or run \ + `buzz auth desktop pair` with Buzz running" + .into(), + )) +} + +/// The explicit-key link, with its headless evidence source. Without an +/// explicit key the evidence settings are not read: they belong to that key. +fn resolve_explicit(settings: &Settings, relay_url: &str) -> Result, CliError> { + if settings.private_key.is_none() { + return Ok(None); + } + let evidence = evidence_source(settings, relay_url)?; + explicit_key(settings, evidence) +} + +/// The warning for headless evidence settings a desktop link leaves unused. +#[cfg_attr(not(unix), allow(dead_code))] +fn ignored_headless_settings(settings: &Settings) -> Option { + let ignored: Vec<&str> = [ + ( + settings.federated_identity_token.is_some(), + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ( + settings.federated_identity_adapter.is_some(), + FEDERATED_IDENTITY_ADAPTER_ENV, + ), + ] + .into_iter() + .filter_map(|(set, name)| set.then_some(name)) + .collect(); + (!ignored.is_empty()).then(|| { + format!( + "warning: the Buzz desktop link supplies this identity's evidence; ignoring {} \ + (set BUZZ_PRIVATE_KEY to use them)", + ignored.join(" and ") + ) + }) +} + +/// Link 1: an explicit key from `--private-key` or `BUZZ_PRIVATE_KEY`. +fn explicit_key( + settings: &Settings, + evidence: Option, +) -> Result, CliError> { + let Some(raw) = settings.private_key.as_deref() else { + return Ok(None); + }; + let keys = + Keys::parse(raw).map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; + let auth_tag = settings + .auth_tag + .as_deref() + .filter(|input| !input.is_empty()) + .map(|input| parse_auth_tag(input, &keys.public_key())) + .transpose()?; + Ok(Some(Credentials { + auth_tag, + evidence, + ..Credentials::from_keys(keys) + })) +} + +/// The headless NIP-FI evidence source: a NIP-FA adapter session. Both +/// settings are required together; one without the other is a configuration +/// error rather than a silent fallback to no evidence. +fn evidence_source( + settings: &Settings, + relay_url: &str, +) -> Result, CliError> { + match ( + settings.federated_identity_token.as_deref(), + settings.federated_identity_adapter.as_deref(), + ) { + (None, None) => Ok(None), + (Some(token), Some(adapter)) => AdapterSession::new(adapter, token, relay_url).map(Some), + (Some(_), None) => Err(CliError::Auth(format!( + "{FEDERATED_IDENTITY_TOKEN_ENV} is set but {FEDERATED_IDENTITY_ADAPTER_ENV} is not" + ))), + (None, Some(_)) => Err(CliError::Auth(format!( + "{FEDERATED_IDENTITY_ADAPTER_ENV} is set but {FEDERATED_IDENTITY_TOKEN_ENV} is not" + ))), + } +} + +/// Parse and verify a NIP-OA auth tag for `public_key`. +/// +/// `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw +/// shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input +/// edge only. The SDK grammar and the `x-auth-tag` wire format stay strict +/// JSON; all validation and signature verification happen on the strict path. +fn parse_auth_tag(input: &str, public_key: &PublicKey) -> Result { + let json = crate::normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; + buzz_sdk::nip_oa::verify_auth_tag(&json, public_key).map_err(|e| { + CliError::Auth(format!( + "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", + public_key.to_hex() + )) + })?; + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; + Ok(AuthTag { + tag, + json: canonical, + }) +} + +/// The credentials' signer for NIP-42, keeping a desktop signing failure as +/// a [`CliError`]: protocol code carries only its text, and a throttled +/// desktop must stay retryable. +#[derive(Debug)] +pub struct CapturingSigner { + signer: Arc, + #[cfg(unix)] + desktop: Option>, + failure: std::sync::Mutex)>>, +} + +impl CapturingSigner { + pub fn new(credentials: &Credentials) -> Self { + Self { + signer: credentials.signer.clone(), + #[cfg(unix)] + desktop: credentials.desktop.clone(), + failure: std::sync::Mutex::new(None), + } + } + + /// The desktop signing failure, if one happened, with how long to wait + /// before a fresh sign-in when the desktop throttled one. + pub fn take_failure(&self) -> Option<(CliError, Option)> { + self.failure.lock().ok()?.take() + } +} + +impl NostrSigner for CapturingSigner { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + self.signer.backend() + } + + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> { + self.signer.get_public_key() + } + + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return Box::pin(async move { + provider.sign_or_wait(unsigned).await.map_err(|e| { + let text = nostr::signer::SignerError::from(e.0.to_string()); + if let Ok(mut slot) = self.failure.lock() { + slot.get_or_insert(e); + } + text + }) + }); + } + self.signer.sign_event(unsigned) + } + + fn nip04_encrypt<'b>( + &'b self, + peer: &'b PublicKey, + content: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip04_encrypt(peer, content) + } + + fn nip04_decrypt<'b>( + &'b self, + peer: &'b PublicKey, + payload: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip04_decrypt(peer, payload) + } + + fn nip44_encrypt<'b>( + &'b self, + peer: &'b PublicKey, + content: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip44_encrypt(peer, content) + } + + fn nip44_decrypt<'b>( + &'b self, + peer: &'b PublicKey, + payload: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip44_decrypt(peer, payload) + } +} + +impl Credentials { + /// Credentials for an in-memory key with no auth tag and no evidence source. + pub fn from_keys(keys: Keys) -> Self { + Self { + public_key: keys.public_key(), + signer: Arc::new(keys.clone()), + local_keys: Some(keys), + auth_tag: None, + evidence: None, + #[cfg(unix)] + desktop: None, + } + } + + /// Credentials signing through `signer` alone, for tests. + #[cfg(test)] + pub(crate) fn from_signer(signer: Arc, public_key: PublicKey) -> Self { + Self { + public_key, + signer, + local_keys: None, + auth_tag: None, + evidence: None, + #[cfg(unix)] + desktop: None, + } + } + + /// Credentials backed by the Buzz desktop: it signs, encrypts and supplies + /// NIP-FI evidence; the private key never reaches this process. + #[cfg(unix)] + pub(crate) async fn from_desktop( + provider: Arc, + ) -> Result { + let public_key = provider.public_key(); + let auth_tag = provider + .auth_tag() + .await? + .map(|json| parse_auth_tag(&json, &public_key)) + .transpose()?; + Ok(Self { + public_key, + signer: provider.clone(), + local_keys: None, + auth_tag, + evidence: None, + desktop: Some(provider), + }) + } + + /// Attach a verified NIP-OA auth tag. + #[cfg(test)] + pub fn with_auth_tag(mut self, tag: Tag, json: String) -> Self { + self.auth_tag = Some(AuthTag { tag, json }); + self + } + + /// The local keys behind a [`Self::from_keys`] credential. + #[cfg(test)] + pub fn test_keys(&self) -> Keys { + self.local_keys + .clone() + .expect("test credentials hold local keys") + } + + /// Attach a NIP-FA evidence source. + #[cfg(test)] + pub fn with_evidence(mut self, evidence: AdapterSession) -> Self { + self.evidence = Some(evidence); + self + } + + /// The public key this process acts as. + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + /// The NIP-OA auth tag, if configured. + pub fn auth_tag(&self) -> Option<&AuthTag> { + self.auth_tag.as_ref() + } + + /// The NIP-AE engram `d` tag for `slug` in the conversation with `peer`: + /// HMAC-SHA256 over the NIP-44 conversation key, so it needs the key + /// itself rather than a signature (`docs/nips/NIP-AE.md`). + pub async fn engram_d_tag(&self, peer: &PublicKey, slug: &str) -> Result { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.engram_d_tag(peer, slug).await; + } + let keys = self.local_keys.as_ref().ok_or_else(|| { + CliError::Auth( + "buzz mem needs a local key: set BUZZ_PRIVATE_KEY or pass --private-key".into(), + ) + })?; + let k_c = buzz_core::engram::conversation_key(keys.secret_key(), peer); + Ok(buzz_core::engram::d_tag(&k_c, slug)) + } + + /// A stable, anonymous, relay-scoped id for the GIF provider (see + /// [`customer_id`]). A link without local key material gets a random + /// per-process id instead, as Buzz Desktop does per install. + pub fn relay_scoped_customer_id(&self, relay_url: &str) -> String { + match self.local_keys.as_ref() { + Some(keys) => customer_id(keys.secret_key().as_secret_bytes(), relay_url), + None => { + static PROCESS_ID: std::sync::OnceLock = std::sync::OnceLock::new(); + PROCESS_ID + .get_or_init(|| uuid::Uuid::new_v4().simple().to_string()) + .clone() + } + } + } + + /// Whether the Buzz desktop is the link that holds the key. + pub fn is_desktop(&self) -> bool { + #[cfg(unix)] + return self.desktop.is_some(); + #[cfg(not(unix))] + false + } + + /// Whether a NIP-FI evidence source is configured. + pub fn has_evidence_source(&self) -> bool { + #[cfg(unix)] + if self.desktop.is_some() { + return true; + } + self.evidence.is_some() + } + + /// The NIP-FI assertion for the relay the credentials were resolved + /// for, or `None` when no evidence source is configured. + pub async fn evidence(&self) -> Result, CliError> { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.federated_identity().await; + } + match &self.evidence { + Some(source) => { + let keys = self.local_keys.as_ref().ok_or_else(|| { + CliError::Auth("a headless evidence source needs a local key".into()) + })?; + source.assertion(keys).await + } + None => Ok(None), + } + } + + /// Sign `builder` as this identity. Callers own the tags; auth-tag + /// injection is [`crate::client::BuzzClient::sign_event`]'s job. + pub async fn sign(&self, builder: EventBuilder) -> Result { + self.sign_unsigned(builder.build(self.public_key)).await + } + + /// Sign `unsigned` as this identity. A desktop failure keeps its own + /// error, so a throttled desktop stays retryable. + pub async fn sign_unsigned(&self, unsigned: UnsignedEvent) -> Result { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.sign(unsigned).await; + } + let event = self + .signer + .sign_event(unsigned) + .await + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + if event.pubkey != self.public_key || event.verify().is_err() { + return Err(CliError::Auth( + "signer returned an event that is not validly signed by this identity".into(), + )); + } + Ok(event) + } + + /// NIP-44 encrypt `plaintext` to `peer` as this identity. + pub async fn nip44_encrypt( + &self, + peer: &PublicKey, + plaintext: &str, + ) -> Result { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.nip44_encrypt(peer, plaintext).await; + } + self.signer + .nip44_encrypt(peer, plaintext) + .await + .map_err(|e| CliError::Other(format!("NIP-44 encryption failed: {e}"))) + } + + /// NIP-44 decrypt `payload` from `peer` as this identity. + /// NIP-44 decrypt `payload` from `peer`. `Ok(None)` means the payload is + /// not valid ciphertext for this key; `Err` means the signer itself failed. + /// A local key decides that itself, and the desktop reports it as + /// `invalid_request`; any other signer's failure is `Err`, since it cannot + /// be told apart from an unavailable credential. + pub async fn nip44_decrypt( + &self, + peer: &PublicKey, + payload: &str, + ) -> Result, CliError> { + if let Some(keys) = &self.local_keys { + return Ok(nostr::nips::nip44::decrypt(keys.secret_key(), peer, payload).ok()); + } + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.nip44_decrypt(peer, payload).await; + } + self.signer + .nip44_decrypt(peer, payload) + .await + .map(Some) + .map_err(|e| CliError::Auth(format!("NIP-44 decryption failed: {e}"))) + } + + /// Sign a NIP-98 HTTP auth event (kind:27235) for `method` + `url` and + /// return it base64-encoded, ready for a `Nostr ` header. + /// + /// The event carries the `u`, `method` and (with a body) `payload` tags, + /// plus a nonce so rapid identical requests don't trip replay guards. + pub async fn nip98( + &self, + method: &str, + url: &str, + body: Option<&[u8]>, + ) -> Result { + let event = self + .sign(crate::federated_identity::nip98_event(method, url, body)?) + .await?; + Ok(B64.encode(event.as_json().as_bytes())) + } +} + +/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. +/// +/// KLIPY requires a per-installation identifier that is stable and anonymous. +/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the +/// input is public, so the ID is computable by any observer, and the same value +/// would appear across all relays (cross-relay linkability). +/// +/// Instead, we domain-separate with the relay URL and sign with the *secret* key: +/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` +/// This is: +/// - **stable**: deterministic given the same keypair + relay. +/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. +/// - **not computable from public data**: requires secret key material. +/// - **stateless**: no file I/O, no storage. +/// +/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for +/// KLIPY's per-installation needs. +fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(secret_key_bytes); + hasher.update(b"\0"); // domain separator + hasher.update(relay_url.as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..16]) // 16 bytes → 32 hex chars +} + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // customer_id + // ----------------------------------------------------------------------- + + #[test] + fn customer_id_is_32_hex_chars_and_stable() { + let sk = [0xab_u8; 32]; + let id = customer_id(&sk, "https://relay.example"); + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(id, customer_id(&sk, "https://relay.example")); + } + + #[test] + fn customer_id_is_relay_scoped() { + let sk = [0xcd_u8; 32]; + let id_a = customer_id(&sk, "https://relay-a.example"); + let id_b = customer_id(&sk, "https://relay-b.example"); + assert_ne!( + id_a, id_b, + "same key, different relay → different customer_id" + ); + } + + #[test] + fn customer_id_differs_for_different_keys() { + let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); + let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); + assert_ne!(id_a, id_b); + } + + #[test] + fn customer_id_not_equal_to_pubkey_hash() { + // The customer_id must NOT be derivable from the public key alone. + let sk = [0xde_u8; 32]; + // What the old pubkey-hash approach would have produced (approximately): + let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); + let actual = customer_id(&sk, "https://relay.example"); + assert_ne!( + actual, naive_hash, + "customer_id must not equal SHA-256(pubkey_hex)[..16]" + ); + } + + fn explicit(settings: Settings) -> Result { + resolve_explicit(&settings, "https://relay.example").map(|c| c.expect("explicit key")) + } + + fn settings(key: Option<&str>, token: Option<&str>, adapter: Option<&str>) -> Settings { + Settings { + private_key: key.map(str::to_owned), + auth_tag: None, + federated_identity_token: token.map(str::to_owned), + federated_identity_adapter: adapter.map(str::to_owned), + } + } + + #[test] + fn explicit_key_is_the_first_link() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_secret_hex(); + let credentials = explicit(settings(Some(&nsec), None, None)).unwrap(); + assert_eq!(credentials.public_key(), keys.public_key()); + assert!(!credentials.has_evidence_source()); + } + + #[test] + fn no_link_is_an_auth_error_naming_the_settings() { + assert!( + resolve_explicit(&settings(None, None, None), "https://relay.example") + .unwrap() + .is_none() + ); + } + + /// Headless evidence settings belong to the explicit key: without one + /// they are passed over (and named in a warning), not an error. + #[test] + fn headless_settings_without_a_key_are_passed_over_and_named() { + for (token, adapter) in [(Some("t"), None), (Some("t"), Some("https://a.example"))] { + let unused = settings(None, token, adapter); + assert!(resolve_explicit(&unused, "https://relay.example") + .unwrap() + .is_none()); + let warning = ignored_headless_settings(&unused).unwrap(); + assert!(warning.contains(FEDERATED_IDENTITY_TOKEN_ENV), "{warning}"); + assert_eq!( + warning.contains(FEDERATED_IDENTITY_ADAPTER_ENV), + adapter.is_some(), + "{warning}" + ); + } + assert!(ignored_headless_settings(&settings(None, None, None)).is_none()); + } + + /// The evidence source is bound to the relay when credentials are + /// resolved, so a relay NIP-FA cannot serve fails then, not mid-command. + #[test] + fn the_evidence_source_binds_its_relay_at_resolution() { + let nsec = Keys::generate().secret_key().to_secret_hex(); + let headless = settings(Some(&nsec), Some("t"), Some("https://adapter.example")); + assert!(resolve_explicit(&headless, "https://relay.example").is_ok()); + let err = resolve_explicit(&headless, "https://relay.example:80") + .err() + .unwrap(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + } + + #[test] + fn evidence_settings_must_come_together() { + let nsec = Keys::generate().secret_key().to_secret_hex(); + for (token, adapter, missing) in [ + (Some("t"), None, FEDERATED_IDENTITY_ADAPTER_ENV), + ( + None, + Some("https://adapter.example"), + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ] { + let err = explicit(settings(Some(&nsec), token, adapter)) + .err() + .unwrap(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains(missing)), + "{token:?} {adapter:?}: {err}" + ); + } + let credentials = explicit(settings( + Some(&nsec), + Some("t"), + Some("https://adapter.example"), + )) + .unwrap(); + assert!(credentials.has_evidence_source()); + } + + #[tokio::test] + async fn key_derived_operations_match_the_protocol_definitions() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let credentials = Credentials::from_keys(agent.clone()); + // NIP-AE: either side of the pair derives the same d tag. + let owner_side = buzz_core::engram::d_tag( + &buzz_core::engram::conversation_key(owner.secret_key(), &agent.public_key()), + "core", + ); + assert_eq!( + credentials + .engram_d_tag(&owner.public_key(), "core") + .await + .unwrap(), + owner_side + ); + assert_eq!( + credentials.relay_scoped_customer_id("https://a.example"), + customer_id(agent.secret_key().as_secret_bytes(), "https://a.example") + ); + } + + #[tokio::test] + async fn sign_rejects_events_from_another_key() { + #[derive(Debug)] + struct WrongKey(Keys); + impl NostrSigner for WrongKey { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + nostr::signer::SignerBackend::Custom("test".into()) + } + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> + { + Box::pin(async move { Ok(self.0.public_key()) }) + } + fn sign_event( + &self, + unsigned: nostr::UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> + { + let other = Keys::generate(); + Box::pin(async move { + EventBuilder::new(unsigned.kind, unsigned.content) + .sign_with_keys(&other) + .map_err(nostr::signer::SignerError::backend) + }) + } + fn nip04_encrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + fn nip04_decrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + fn nip44_encrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + fn nip44_decrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + } + + let keys = Keys::generate(); + let credentials = Credentials { + public_key: keys.public_key(), + signer: Arc::new(WrongKey(keys)), + local_keys: None, + auth_tag: None, + evidence: None, + #[cfg(unix)] + desktop: None, + }; + let err = credentials + .sign(EventBuilder::text_note("hi")) + .await + .err() + .unwrap(); + assert!(matches!(err, CliError::Auth(_)), "{err}"); + + // Without local key material: key-derived operations degrade or refuse. + let peer = Keys::generate().public_key(); + assert!(credentials.engram_d_tag(&peer, "core").await.is_err()); + let relay = "https://relay.example"; + let id = credentials.relay_scoped_customer_id(relay); + assert_eq!( + id, + credentials.relay_scoped_customer_id(relay), + "one id per process" + ); + assert_ne!( + id, + customer_id(&[0; 32], relay), + "no local key: a random id, not a key derivation" + ); + assert_eq!(id.len(), 32); + } +} diff --git a/crates/buzz-cli/src/desktop_provider.rs b/crates/buzz-cli/src/desktop_provider.rs new file mode 100644 index 0000000000..b0a4247768 --- /dev/null +++ b/crates/buzz-cli/src/desktop_provider.rs @@ -0,0 +1,2196 @@ +//! The desktop link of the credential chain: a running Buzz Desktop acts as +//! one identity over loopback (`docs/local-credential-provider.md`). +//! +//! The caller never holds the private key. It finds a provider through an +//! inherited `BUZZ_CREDENTIAL_PROVIDER` token or a paired token in the OS +//! keychain, and sends each request on a connection whose server side it has +//! confirmed belongs to the same OS user, for inherited and paired tokens +//! alike. +//! +//! Version 1 has no Windows transport, so the link is compiled for Unix only. + +use std::sync::Arc; +use std::time::Duration; + +use nostr::signer::{SignerBackend, SignerError}; +use nostr::{Event, JsonUtil, PublicKey, Tag, UnsignedEvent}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::error::CliError; + +pub const PROVIDER_ENV: &str = "BUZZ_CREDENTIAL_PROVIDER"; + +/// The protocol version this caller speaks. +const VERSION: u32 = 1; +const VERSION_HEADER: &str = "Buzz-Credential-Provider"; + +/// Methods the provider may hold open for a person's decision (up to 120s). +pub(crate) const HELD_TIMEOUT: Duration = Duration::from_secs(150); +const QUICK_TIMEOUT: Duration = Duration::from_secs(10); +/// `lsof` can take seconds on a busy Mac. +const SAME_USER_TIMEOUT: Duration = Duration::from_secs(5); +/// Every result is built from a request's params, and the desktop reads at +/// most 2 MiB of request body, so no result is larger than that in bytes. +/// Escaping a result as a JSON string grows it at most sixfold: a decrypted +/// NIP-44 plaintext (extended lengths have no 64 KiB ceiling) may be all +/// control characters, each becoming `\u00XX`. The 64 KiB on top covers a +/// signed event's added `id`, `pubkey` and `sig`, the envelope and the headers. +const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; +const MAX_RESPONSE_BYTES: usize = 6 * MAX_REQUEST_BYTES + 64 * 1024; + +/// `rate_limited` refuses before acting, so the same envelope is re-sent: +/// up to this many times, each after `retry_after` capped at the wait below. +const RATE_LIMIT_RETRIES: u32 = 4; +const RATE_LIMIT_MAX_WAIT_SECS: u64 = 30; +/// One `retry_after` second; tests shorten it so throttling stays fast. +const RETRY_AFTER_UNIT: Duration = if cfg!(test) { + Duration::from_millis(10) +} else { + Duration::from_secs(1) +}; + +/// A provider failure, by the contract's error code. +#[derive(Debug)] +pub enum ProviderError { + /// `revoked`: a paired token is dropped and the next paired one tried; + /// an inherited one fails the command. + Revoked, + /// No common protocol version: the provider does not apply. + NoCommonVersion, + /// `unknown_method`: degrade where the caller can. + UnknownMethod, + /// Any other code. Unrecognized codes are reported as `denied`. + Code { + code: &'static str, + message: Option, + retry_after: Option, + }, + /// The provider could not be reached or answered outside the contract. + Transport(String), + /// The same-user check ran out of time before anything was sent. + Unconfirmed, +} + +impl ProviderError { + fn into_cli(self, method: &str) -> CliError { + match self { + Self::Revoked => CliError::Auth( + "the Buzz desktop revoked this CLI's token; run `buzz auth desktop pair` again" + .into(), + ), + Self::NoCommonVersion => CliError::Auth( + "the Buzz desktop speaks no credential-provider version this CLI supports".into(), + ), + Self::UnknownMethod => CliError::Auth(format!( + "the Buzz desktop does not implement `{method}`; update Buzz" + )), + Self::Code { + code, + message, + retry_after, + } => { + let detail = message.map(|m| format!(": {m}")).unwrap_or_default(); + match code { + "approval_required" => { + CliError::Auth(format!("approve this request in Buzz, then retry{detail}")) + } + "rate_limited" => CliError::Throttled(format!( + "the Buzz desktop is rate-limiting this CLI; retry in {}s{detail}", + retry_after.unwrap_or(1) + )), + "unavailable" => { + CliError::Auth(format!("the Buzz desktop cannot act now{detail}")) + } + "invalid_request" => CliError::Other(format!( + "the Buzz desktop rejected `{method}` as malformed{detail}" + )), + _ => CliError::Auth(format!("the Buzz desktop refused `{method}`{detail}")), + } + } + Self::Transport(why) => CliError::Auth(format!("Buzz desktop: {why}")), + Self::Unconfirmed => CliError::Unavailable( + "Buzz desktop: the same-user check timed out; nothing was sent".into(), + ), + } + } +} + +/// `unsigned` dated now, if it is a short-lived auth proof (NIP-42, NIP-98 +/// or Blossom); a Blossom `expiration` moves by the same amount. +fn renewed(mut unsigned: UnsignedEvent) -> UnsignedEvent { + if !matches!(unsigned.kind.as_u16(), 22242 | 27235 | 24242) { + return unsigned; + } + let now = nostr::Timestamp::now(); + let shift = now.as_secs().saturating_sub(unsigned.created_at.as_secs()); + if shift == 0 { + return unsigned; + } + let tags = unsigned + .tags + .clone() + .to_vec() + .into_iter() + .map(|tag| match tag.as_slice() { + [name, exp] if name == "expiration" => match exp.parse::() { + Ok(exp) => Tag::parse(["expiration", &(exp + shift).to_string()]).unwrap_or(tag), + Err(_) => tag, + }, + _ => tag, + }); + unsigned.tags = nostr::Tags::from_list(tags.collect()); + unsigned.created_at = now; + unsigned.id = None; + unsigned.ensure_id(); + unsigned +} + +/// The contract's codes; anything else is `denied`. +fn known_code(code: &str) -> &'static str { + match code { + "approval_required" => "approval_required", + "rate_limited" => "rate_limited", + "unavailable" => "unavailable", + "invalid_request" => "invalid_request", + "unsupported_version" => "unsupported_version", + _ => "denied", + } +} + +fn held(method: &str) -> bool { + matches!(method, "sign_event" | "nip44_decrypt" | "pair") +} + +/// A provider endpoint: `http://127.0.0.1:/`. +#[derive(Clone, Debug)] +pub struct Endpoint { + port: u16, + path: String, +} + +impl Endpoint { + pub fn parse(raw: &str) -> Result { + let invalid = + || CliError::Auth("credential provider URL must be http://127.0.0.1:/…".into()); + let url = url::Url::parse(raw).map_err(|_| invalid())?; + if url.scheme() != "http" + || url.host() != Some(url::Host::Ipv4(std::net::Ipv4Addr::LOCALHOST)) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(invalid()); + } + let port = url.port().ok_or_else(invalid)?; + let mut path = url.path().to_owned(); + if let Some(query) = url.query() { + path = format!("{path}?{query}"); + } + Ok(Self { port, path }) + } +} + +/// Exchange one request on `stream` (one request per connection) and return +/// the HTTP status and body. +/// +/// This is a minimal HTTP/1.1 exchange rather than reqwest or hyper because +/// the same-user check in [`connect`] must vet the very TCP connection the +/// token is then sent on; a pooled client opens its own connections, which +/// the check never saw. +async fn exchange( + mut stream: TcpStream, + endpoint: &Endpoint, + token: Option<&str>, + version: u32, + body: &[u8], +) -> Result<(u16, Vec), ProviderError> { + let io = + |e: std::io::Error| ProviderError::Transport(format!("connection failed: {}", e.kind())); + let mut head = format!( + "POST {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\n{VERSION_HEADER}: {version}\r\nConnection: close\r\n", + endpoint.path, + endpoint.port, + body.len() + ); + if let Some(token) = token { + head.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes()).await.map_err(io)?; + stream.write_all(body).await.map_err(io)?; + let mut raw = Vec::new(); + (&mut stream) + .take(MAX_RESPONSE_BYTES as u64 + 1) + .read_to_end(&mut raw) + .await + .map_err(io)?; + if raw.len() > MAX_RESPONSE_BYTES { + return Err(ProviderError::Transport("response too large".into())); + } + parse_response(&raw).ok_or_else(|| ProviderError::Transport("malformed HTTP response".into())) +} + +/// Parse an HTTP/1.1 response read to EOF: status plus body, decoding +/// `Content-Length` or chunked framing. +fn parse_response(raw: &[u8]) -> Option<(u16, Vec)> { + let split = raw.windows(4).position(|w| w == b"\r\n\r\n")?; + let head = std::str::from_utf8(&raw[..split]).ok()?; + let rest = &raw[split + 4..]; + let mut lines = head.split("\r\n"); + let status: u16 = lines.next()?.split(' ').nth(1)?.parse().ok()?; + let mut length = None; + let mut chunked = false; + for line in lines { + let (name, value) = line.split_once(':')?; + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + length = Some(value.parse::().ok()?); + } else if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value.eq_ignore_ascii_case("chunked"); + } + } + if chunked { + let mut body = Vec::new(); + let mut at = rest; + loop { + let end = at.windows(2).position(|w| w == b"\r\n")?; + let size_hex = std::str::from_utf8(&at[..end]) + .ok()? + .split(';') + .next()? + .trim(); + let size = usize::from_str_radix(size_hex, 16).ok()?; + at = &at[end + 2..]; + if size == 0 { + return Some((status, body)); + } + body.extend_from_slice(at.get(..size)?); + at = at.get(size + 2..)?; + } + } + match length { + Some(n) => Some((status, rest.get(..n)?.to_vec())), + None => Some((status, rest.to_vec())), + } +} + +// --------------------------------------------------------------------------- +// Same-user check +// --------------------------------------------------------------------------- + +/// Connect to the provider port. With `check`, confirm by numeric user ID that +/// a process of this user holds the server side of this very connection, and +/// refuse to use the connection otherwise. +async fn connect(port: u16, check: bool) -> Result { + let stream = tokio::time::timeout(QUICK_TIMEOUT, TcpStream::connect(("127.0.0.1", port))) + .await + .map_err(|_| ProviderError::Transport("connection timed out".into()))? + .map_err(|e| ProviderError::Transport(format!("not reachable: {}", e.kind())))?; + if check { + let local = stream + .local_addr() + .map_err(|_| ProviderError::Transport("no local address".into()))? + .port(); + confirm_same_user(server_side_uid(port, local)).await?; + } + Ok(stream) +} + +/// Refuse unless `owner` finds this user's UID. A lookup that runs out of +/// time decided nothing, so it is retryable; any other answer is final. +async fn confirm_same_user( + owner: impl std::future::Future>, +) -> Result<(), ProviderError> { + match tokio::time::timeout(SAME_USER_TIMEOUT, owner).await { + Ok(Some(uid)) if uid == current_uid() => Ok(()), + Ok(_) => Err(ProviderError::Transport( + "could not confirm the provider runs as this user; nothing was sent".into(), + )), + Err(_) => Err(ProviderError::Unconfirmed), + } +} + +fn current_uid() -> u32 { + #[cfg(test)] + if let Ok(uid) = tests::CALLER_UID.try_with(|uid| *uid) { + return uid; + } + // SAFETY: getuid has no preconditions and cannot fail. + unsafe { libc::getuid() } +} + +/// The owner UID of the socket `127.0.0.1:server -> 127.0.0.1:client`. +#[cfg(target_os = "linux")] +async fn server_side_uid(server: u16, client: u16) -> Option { + let table = tokio::fs::read_to_string("/proc/net/tcp").await.ok()?; + proc_net_tcp_uid(&table, server, client) +} + +#[cfg(any(target_os = "linux", test))] +fn proc_net_tcp_uid(table: &str, server: u16, client: u16) -> Option { + let local = format!("0100007F:{server:04X}"); + let remote = format!("0100007F:{client:04X}"); + table.lines().skip(1).find_map(|line| { + let cols: Vec<&str> = line.split_whitespace().collect(); + (cols.get(1) == Some(&local.as_str()) && cols.get(2) == Some(&remote.as_str())) + .then(|| cols.get(7)?.parse().ok()) + .flatten() + }) +} + +/// macOS: `lsof` lists the socket's owning process with its UID. +#[cfg(not(target_os = "linux"))] +async fn server_side_uid(server: u16, client: u16) -> Option { + let output = tokio::process::Command::new("/usr/sbin/lsof") + .args(["-nP", "-F", "un", &format!("-iTCP@127.0.0.1:{server}")]) + .kill_on_drop(true) + .output() + .await + .ok()?; + lsof_uid(&String::from_utf8_lossy(&output.stdout), server, client) +} + +#[cfg(any(not(target_os = "linux"), test))] +fn lsof_uid(output: &str, server: u16, client: u16) -> Option { + let want = format!("n127.0.0.1:{server}->127.0.0.1:{client}"); + let mut uid = None; + for line in output.lines() { + if let Some(value) = line.strip_prefix('u') { + uid = value.parse().ok(); + } else if line.strip_prefix('p').is_some() { + uid = None; + } else if line == want { + return uid; + } + } + None +} + +// --------------------------------------------------------------------------- +// Provider client +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +struct Envelope { + #[serde(default)] + id: String, + #[serde(default)] + result: String, + error: Option, + message: Option, + retry_after: Option, + versions: Option>, +} + +/// Send one envelope and return its `result`, negotiating the version once +/// and waiting out `rate_limited` a bounded number of times. Transport +/// failures are never re-sent: the provider may have acted. +async fn call( + endpoint: &Endpoint, + token: Option<&str>, + same_user: bool, + method: &str, + params: &[String], +) -> Result { + call_with( + endpoint, + token, + same_user, + method, + RATE_LIMIT_RETRIES, + &|| params.to_vec(), + ) + .await +} + +/// [`call`], building `params` afresh for every envelope, so a re-send after +/// a refusal can carry a renewed auth proof. +async fn call_with( + endpoint: &Endpoint, + token: Option<&str>, + same_user: bool, + method: &str, + retries: u32, + params: &(dyn Fn() -> Vec + Sync), +) -> Result { + let mut version = VERSION; + let mut negotiated = false; + let mut throttled = 0; + loop { + let id = uuid::Uuid::new_v4().simple().to_string(); + let body = serde_json::to_vec(&serde_json::json!({ + "id": id, "method": method, "params": params(), + })) + .map_err(|_| ProviderError::Transport("request serialization failed".into()))?; + let timeout = if held(method) { + HELD_TIMEOUT + } else { + QUICK_TIMEOUT + }; + let attempt = async { + let stream = connect(endpoint.port, same_user).await?; + exchange(stream, endpoint, token, version, &body).await + }; + let (_, raw) = tokio::time::timeout(timeout, attempt) + .await + .map_err(|_| ProviderError::Transport(format!("`{method}` timed out")))??; + let envelope: Envelope = serde_json::from_slice(&raw) + .map_err(|_| ProviderError::Transport("unreadable response".into()))?; + if envelope.id != id { + return Err(ProviderError::Transport("response id mismatch".into())); + } + // Decide on `error`, never on the HTTP status. + let Some(code) = envelope.error.filter(|c| !c.is_empty()) else { + return Ok(envelope.result); + }; + match code.as_str() { + "revoked" => return Err(ProviderError::Revoked), + "unknown_method" => return Err(ProviderError::UnknownMethod), + "unsupported_version" if !negotiated => { + negotiated = true; + let common = envelope + .versions + .unwrap_or_default() + .into_iter() + .filter(|v| *v <= VERSION && *v != version) + .max(); + match common { + Some(v) => version = v, + None => return Err(ProviderError::NoCommonVersion), + } + } + "unsupported_version" => return Err(ProviderError::NoCommonVersion), + // A wait past the cap is reported at once, with its real length. + "rate_limited" + if throttled < retries + && envelope.retry_after.unwrap_or(1) <= RATE_LIMIT_MAX_WAIT_SECS => + { + throttled += 1; + tokio::time::sleep(throttle_wait(envelope.retry_after)).await; + } + other => { + return Err(ProviderError::Code { + code: known_code(other), + message: envelope.message, + retry_after: envelope.retry_after, + }) + } + } + } +} + +/// How long to wait out a `rate_limited` answer. +pub(crate) fn throttle_wait(retry_after: Option) -> Duration { + RETRY_AFTER_UNIT * retry_after.unwrap_or(1).clamp(1, RATE_LIMIT_MAX_WAIT_SECS) as u32 +} + +#[derive(serde::Deserialize)] +struct Describe { + pubkey: String, + #[serde(default)] + relays: Vec, + #[serde(default)] + methods: Vec, +} + +/// A connected provider acting as one identity. +pub struct Provider { + endpoint: Endpoint, + /// The relay it was opened for, as the desktop binds it. + relay: String, + token: String, + same_user: bool, + public_key: PublicKey, + methods: Vec, + cached: crate::cached_assertion::CachedAssertion, +} + +impl std::fmt::Debug for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Provider") + .field("port", &self.endpoint.port) + .field("public_key", &self.public_key.to_hex()) + .finish_non_exhaustive() + } +} + +/// Why a provider was not used for this relay. +pub enum Skip { + /// The token was revoked: the caller drops it. + Revoked, + /// The provider does not apply (relay not bound, no common version). + NotApplicable, + /// The endpoint could not be reached: nothing was decided. + Unreachable, + /// The same-user check timed out before anything was sent. + Unconfirmed, +} + +/// A relay URL as the local provider binds it: `http`/`https` read as +/// `ws`/`wss`, then URL normalization (lowercase host, no default port, `/` +/// for an empty path). The path and query count: one host can serve several +/// communities. This is not NIP-FA's audience form; the desktop derives that. +pub fn provider_relay_url(raw: &str) -> Result { + let invalid = || CliError::Usage(format!("invalid relay URL: {raw}")); + let mut url = url::Url::parse(raw).map_err(|_| invalid())?; + let scheme = match url.scheme() { + "http" | "ws" => "ws", + "https" | "wss" => "wss", + _ => return Err(invalid()), + }; + if !url.username().is_empty() || url.password().is_some() || url.host().is_none() { + return Err(invalid()); + } + url.set_fragment(None); + // Changing between special schemes keeps host, port and path. + url.set_scheme(scheme).map_err(|()| invalid())?; + // Re-parse so a default port for the new scheme is dropped. + url::Url::parse(url.as_str()) + .map(|u| u.to_string()) + .map_err(|_| invalid()) +} + +fn same_relay(a: &str, b: &str) -> bool { + provider_relay_url(b).is_ok_and(|b| b == a) +} + +impl Provider { + /// Connect with `token` and confirm the provider acts for `relay_url`. + pub async fn open( + endpoint: Endpoint, + token: String, + same_user: bool, + relay_url: &str, + ) -> Result, Skip>, CliError> { + let raw = match call(&endpoint, Some(&token), same_user, "describe", &[]).await { + Ok(raw) => raw, + Err(ProviderError::Revoked) => return Ok(Err(Skip::Revoked)), + Err(ProviderError::NoCommonVersion) => return Ok(Err(Skip::NotApplicable)), + Err(ProviderError::Transport(_)) => return Ok(Err(Skip::Unreachable)), + Err(ProviderError::Unconfirmed) => return Ok(Err(Skip::Unconfirmed)), + Err(e) => return Err(e.into_cli("describe")), + }; + let describe: Describe = serde_json::from_str(&raw) + .map_err(|_| CliError::Auth("Buzz desktop: unreadable `describe` answer".into()))?; + let public_key = PublicKey::from_hex(&describe.pubkey) + .map_err(|_| CliError::Auth("Buzz desktop: invalid identity public key".into()))?; + let relay = provider_relay_url(relay_url)?; + if !describe.relays.iter().any(|r| same_relay(&relay, r)) { + return Ok(Err(Skip::NotApplicable)); + } + Ok(Ok(Arc::new(Self { + endpoint, + relay, + token, + same_user, + public_key, + methods: describe.methods, + cached: Default::default(), + }))) + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + /// Sign `unsigned` as this identity; the result is checked. + pub async fn sign(&self, unsigned: UnsignedEvent) -> Result { + self.sign_or_wait(unsigned).await.map_err(|(e, _)| e) + } + + /// [`Self::sign`], except that a throttled NIP-42 sign-in is not waited + /// out here: its challenge dies with the socket meanwhile, so it fails + /// at once with the wait, for the caller to reconnect after. + pub async fn sign_or_wait( + &self, + unsigned: UnsignedEvent, + ) -> Result)> { + let retries = if unsigned.kind == nostr::Kind::Authentication { + 0 + } else { + RATE_LIMIT_RETRIES + }; + // A re-sent auth proof is renewed so it is fresh when it reaches the + // relay; any other event is signed exactly as built. + let signed = call_with( + &self.endpoint, + Some(&self.token), + self.same_user, + "sign_event", + retries, + &|| vec![renewed(unsigned.clone()).as_json()], + ) + .await + .map_err(|e| { + let wait = match &e { + ProviderError::Code { + code: "rate_limited", + retry_after, + .. + } => Some(throttle_wait(*retry_after)), + _ => None, + }; + (e.into_cli("sign_event"), wait) + })?; + let refused = |why: &str| (CliError::Auth(format!("Buzz desktop returned {why}")), None); + let event = Event::from_json(&signed).map_err(|_| refused("an unreadable event"))?; + if event.pubkey != self.public_key || event.verify().is_err() { + return Err(refused("an invalid signature")); + } + Ok(event) + } + + /// NIP-44 encrypt `content` to `peer` as this identity. + pub async fn nip44_encrypt(&self, peer: &PublicKey, content: &str) -> Result { + self.call("nip44_encrypt", &[peer.to_hex(), content.to_owned()]) + .await + } + + async fn call(&self, method: &str, params: &[String]) -> Result { + call( + &self.endpoint, + Some(&self.token), + self.same_user, + method, + params, + ) + .await + .map_err(|e| e.into_cli(method)) + } + + /// The identity's NIP-OA `auth` tag JSON, if it has one. + pub async fn auth_tag(&self) -> Result, CliError> { + if !self.methods.iter().any(|m| m == "auth_tag") { + return Ok(None); + } + match call( + &self.endpoint, + Some(&self.token), + self.same_user, + "auth_tag", + &[], + ) + .await + { + Ok(tag) => Ok(Some(tag).filter(|t| !t.is_empty())), + Err(ProviderError::UnknownMethod) => Ok(None), + Err(e) => Err(e.into_cli("auth_tag")), + } + } + + /// The NIP-FI assertion for the relay it was opened for, cached until + /// shortly before its `exp`. `None` when the desktop has none for it. + pub async fn federated_identity(&self) -> Result, CliError> { + let params = [self.relay.clone()]; + self.cached + .get(async { + match call( + &self.endpoint, + Some(&self.token), + self.same_user, + "federated_identity", + ¶ms, + ) + .await + { + Ok(a) if a.is_empty() => Ok(None), + Ok(a) => Ok(Some(a)), + Err(ProviderError::UnknownMethod) => Ok(None), + Err(e) => Err(e.into_cli("federated_identity")), + } + }) + .await + } + + /// NIP-44 decrypt `payload` from `peer`. `Ok(None)` when the desktop + /// answers `invalid_request`: the payload is not valid ciphertext for this + /// identity (the CLI's params are always well formed). Any other failure, + /// such as revoked, denied or unreachable, is an error. + pub async fn nip44_decrypt( + &self, + peer: &PublicKey, + payload: &str, + ) -> Result, CliError> { + match call( + &self.endpoint, + Some(&self.token), + self.same_user, + "nip44_decrypt", + &[peer.to_hex(), payload.to_owned()], + ) + .await + { + Ok(plaintext) => Ok(Some(plaintext)), + Err(ProviderError::Code { + code: "invalid_request", + .. + }) => Ok(None), + Err(e) => Err(e.into_cli("nip44_decrypt")), + } + } + + /// The NIP-AE `d` tag for `slug`, derived by the desktop. + pub async fn engram_d_tag(&self, peer: &PublicKey, slug: &str) -> Result { + let tag = self + .call("engram_d_tag", &[peer.to_hex(), slug.to_owned()]) + .await?; + if tag.len() != 64 || !tag.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(CliError::Auth( + "Buzz desktop returned an invalid engram d tag".into(), + )); + } + Ok(tag) + } +} + +fn signer_error(e: CliError) -> SignerError { + SignerError::from(e.to_string()) +} + +impl nostr::NostrSigner for Provider { + fn backend(&self) -> SignerBackend<'_> { + SignerBackend::Custom("buzz-desktop".into()) + } + + fn get_public_key(&self) -> nostr::util::BoxedFuture<'_, Result> { + Box::pin(async move { Ok(self.public_key) }) + } + + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + Box::pin(async move { Provider::sign(self, unsigned).await.map_err(signer_error) }) + } + + fn nip04_encrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async { Err("the Buzz desktop provider does not offer NIP-04".into()) }) + } + + fn nip04_decrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async { Err("the Buzz desktop provider does not offer NIP-04".into()) }) + } + + fn nip44_encrypt<'a>( + &'a self, + peer: &'a PublicKey, + content: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async move { + Provider::nip44_encrypt(self, peer, content) + .await + .map_err(signer_error) + }) + } + + fn nip44_decrypt<'a>( + &'a self, + peer: &'a PublicKey, + payload: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async move { + self.call("nip44_decrypt", &[peer.to_hex(), payload.to_owned()]) + .await + .map_err(signer_error) + }) + } +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +struct Inherited { + url: String, + token: String, +} + +/// The inherited token from `BUZZ_CREDENTIAL_PROVIDER`, if set. +pub fn inherited() -> Result, CliError> { + let Some(raw) = std::env::var(PROVIDER_ENV) + .ok() + .filter(|v| !v.trim().is_empty()) + else { + return Ok(None); + }; + let inherited: Inherited = serde_json::from_str(&raw) + .map_err(|_| CliError::Auth(format!("{PROVIDER_ENV} is not valid JSON")))?; + let endpoint = Endpoint::parse(&inherited.url)?; + if !usable_token(&inherited.token) { + return Err(CliError::Auth(format!( + "{PROVIDER_ENV} carries an unusable token" + ))); + } + Ok(Some((endpoint, inherited.token))) +} + +fn usable_token(token: &str) -> bool { + !token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic()) +} + +#[derive(serde::Deserialize)] +struct Advertisement { + instance: String, + url: String, +} + +fn advertisement_dir() -> Option { + std::env::var_os("HOME") + .map(|home| std::path::Path::new(&home).join(".buzz-foundation/credential-providers")) +} + +/// Every advertisement that passes the ownership and permission checks: +/// a `0700` directory and files owned by this user with no group or other +/// bits. Anything else is ignored, so another user cannot plant one. +fn advertisements_in(dir: &std::path::Path) -> Vec<(String, Endpoint)> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let uid = current_uid(); + let private = + |m: &std::fs::Metadata, mode: u32| m.uid() == uid && m.permissions().mode() & 0o777 == mode; + let Ok(meta) = std::fs::symlink_metadata(dir) else { + return Vec::new(); + }; + if !meta.is_dir() || !private(&meta, 0o700) { + return Vec::new(); + } + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut found: Vec<(String, Endpoint)> = entries + .flatten() + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + !name.starts_with('.') && name.ends_with(".json") + }) + .filter_map(|e| { + let meta = std::fs::symlink_metadata(e.path()).ok()?; + if !meta.is_file() || meta.uid() != uid || meta.permissions().mode() & 0o077 != 0 { + return None; + } + let ad: Advertisement = serde_json::from_slice(&std::fs::read(e.path()).ok()?).ok()?; + Some((ad.instance, Endpoint::parse(&ad.url).ok()?)) + }) + .collect(); + found.sort_by(|a, b| a.0.cmp(&b.0)); + found +} + +fn advertisements() -> Vec<(String, Endpoint)> { + advertisement_dir().map_or_else(Vec::new, |d| advertisements_in(&d)) +} + +// --------------------------------------------------------------------------- +// Paired tokens, in the OS keychain +// --------------------------------------------------------------------------- + +const KEYCHAIN_SERVICE: &str = "buzz-cli"; +const KEYCHAIN_ACCOUNT: &str = "desktop-pairings"; + +/// One pairing per instance. +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct Pairings(std::collections::BTreeMap); + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +struct Pairing { + token: String, + pubkey: String, + relays: Vec, +} + +/// A held lock; dropping it releases the lock. +trait Held {} +impl Held for T {} + +/// Where the pairing map lives. Every change goes through [`update`], which +/// holds `lock` only for one reload-change-write. +trait PairingStore: Send + Sync { + fn read(&self) -> Result, CliError>; + /// Write `json`, or delete the entry for `None`. + fn write(&self, json: Option<&str>) -> Result<(), CliError>; + /// A cross-process lock, released when the guard drops. + fn lock(&self) -> Result, CliError>; +} + +struct Keychain; + +fn no_keychain(e: keyring::Error) -> CliError { + CliError::Auth(format!( + "no OS keychain is reachable ({e}); on Linux start a Secret Service such as \ + gnome-keyring, or set BUZZ_PRIVATE_KEY instead. Paired tokens are never written to files." + )) +} + +impl Keychain { + fn entry() -> Result { + keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).map_err(no_keychain) + } +} + +impl PairingStore for Keychain { + fn read(&self) -> Result, CliError> { + match Self::entry()?.get_password() { + Ok(json) => Ok(Some(json)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(no_keychain(e)), + } + } + + fn write(&self, json: Option<&str>) -> Result<(), CliError> { + let entry = Self::entry()?; + match json { + Some(json) => entry.set_password(json).map_err(no_keychain), + None => match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(no_keychain(e)), + }, + } + } + + /// `flock` on `~/.buzz-foundation/cli-pairings.lock` (no secret in it). + fn lock(&self) -> Result, CliError> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; + let failed = |e: std::io::Error| CliError::Other(format!("pairing lock failed: {e}")); + let dir = advertisement_dir() + .and_then(|d| d.parent().map(std::path::Path::to_path_buf)) + .ok_or_else(|| CliError::Other("HOME is not set".into()))?; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir) + .map_err(failed)?; + // An existing directory keeps whatever mode it had; tighten it. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(failed)?; + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .custom_flags(libc::O_NOFOLLOW) + .open(dir.join("cli-pairings.lock")) + .map_err(failed)?; + // SAFETY: a valid open descriptor; flock has no other preconditions. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(failed(std::io::Error::last_os_error())); + } + Ok(Box::new(file)) + } +} + +/// The stored map. An unreadable entry is refused, never treated as empty, +/// so no write can replace it with less. +fn parse_pairings(json: Option) -> Result { + match json { + None => Ok(Pairings::default()), + Some(json) => serde_json::from_str(&json).map_err(|_| { + CliError::Auth( + "the CLI's paired tokens in the OS keychain are unreadable; \ + run `buzz auth desktop unpair` to clear them, then pair again" + .into(), + ) + }), + } +} + +/// Reload the map under the lock, apply `change`, and write it back. +fn update( + store: &dyn PairingStore, + change: impl FnOnce(&mut Pairings) -> R, +) -> Result { + let _guard = store.lock()?; + let mut pairings = parse_pairings(store.read()?)?; + let result = change(&mut pairings); + if pairings.0.is_empty() { + store.write(None)?; + } else { + let json = serde_json::to_string(&pairings) + .map_err(|e| CliError::Other(format!("pairing serialization failed: {e}")))?; + store.write(Some(&json))?; + } + Ok(result) +} + +/// Drop each revoked pairing, unless it was replaced since it was read. +fn forget_revoked(store: &dyn PairingStore, revoked: &[(String, String)]) { + if revoked.is_empty() { + return; + } + let _ = update(store, |pairings| { + for (instance, token) in revoked { + if pairings.0.get(instance).is_some_and(|p| &p.token == token) { + pairings.0.remove(instance); + } + } + }); +} + +// --------------------------------------------------------------------------- +// The chain link, and pairing +// --------------------------------------------------------------------------- + +/// The desktop link for `relay_url`. An inherited token decides the identity: +/// when it cannot serve, the command fails, and a paired token (which acts as +/// the human) is never tried, so an agent's process acts only as that agent +/// unless the owner approves pairing it. Without an +/// inherited token, the first paired token whose instance is advertised. +/// `None` when no paired token applies, so the chain moves on. +pub async fn discover(relay_url: &str) -> Result>, CliError> { + discover_with(inherited()?, &Keychain, advertisements, relay_url).await +} + +async fn discover_with( + inherited: Option<(Endpoint, String)>, + store: &dyn PairingStore, + advertised: impl FnOnce() -> Vec<(String, Endpoint)>, + relay_url: &str, +) -> Result>, CliError> { + match inherited { + Some((endpoint, token)) => inherited_link(endpoint, token, relay_url).await.map(Some), + None => paired_link(store, advertised(), relay_url).await, + } +} + +async fn inherited_link( + endpoint: Endpoint, + token: String, + relay_url: &str, +) -> Result, CliError> { + match Provider::open(endpoint, token, true, relay_url).await? { + Ok(provider) => Ok(provider), + Err(Skip::Revoked) => Err(CliError::Auth( + "the Buzz desktop revoked this agent's credential; restart the agent in Buzz".into(), + )), + Err(Skip::NotApplicable) => Err(CliError::Auth(format!( + "this agent's Buzz credential does not act on {relay_url}" + ))), + Err(Skip::Unreachable) => Err(CliError::Auth( + "the Buzz desktop that launched this agent is not reachable".into(), + )), + Err(Skip::Unconfirmed) => Err(ProviderError::Unconfirmed.into_cli("describe")), + } +} + +async fn paired_link( + store: &dyn PairingStore, + advertised: Vec<(String, Endpoint)>, + relay_url: &str, +) -> Result>, CliError> { + // A missing keychain only means there are no paired tokens to use here. + let Ok(Ok(pairings)) = store.read().map(parse_pairings) else { + return Ok(None); + }; + let relay = provider_relay_url(relay_url)?; + let mut revoked = Vec::new(); + let mut found = Ok(None); + for (instance, endpoint) in advertised { + let Some(pairing) = pairings.0.get(&instance) else { + continue; + }; + if !pairing.relays.iter().any(|r| same_relay(&relay, r)) { + continue; + } + match Provider::open(endpoint, pairing.token.clone(), true, relay_url).await { + Ok(Ok(provider)) => { + found = Ok(Some(provider)); + break; + } + Ok(Err(Skip::Revoked)) => { + revoked.push((instance.clone(), pairing.token.clone())); + eprintln!( + "warning: Buzz ({instance}) revoked this CLI's pairing; \ + run `buzz auth desktop pair` to pair again" + ); + } + // A stale or unconfirmed advertisement decides nothing: skip it. + Ok(Err(Skip::NotApplicable | Skip::Unreachable | Skip::Unconfirmed)) => {} + // The desktop answered (denied, unavailable, ...): that is final. + Err(e) => { + found = Err(e); + break; + } + } + } + forget_revoked(store, &revoked); + found +} + +#[derive(serde::Deserialize)] +struct Paired { + token: String, + instance: String, + pubkey: String, + relays: Vec, +} + +/// Ask the running desktop to pair this CLI for `relay_url`, holding until the +/// person decides, and store the token in the OS keychain. +pub async fn pair( + relay_url: &str, + client_name: &str, + instance: Option<&str>, +) -> Result { + let mut found = advertisements(); + if let Some(want) = instance { + found.retain(|(i, _)| i == want); + } + let (instance, endpoint) = match found.len() { + 0 => { + return Err(CliError::Auth( + "no running Buzz desktop found; open Buzz and try again".into(), + )) + } + 1 => found.remove(0), + _ => { + let names: Vec<_> = found.iter().map(|(i, _)| i.as_str()).collect(); + return Err(CliError::Usage(format!( + "several Buzz desktops are running ({}); pass --instance", + names.join(", ") + ))); + } + }; + pair_with(&Keychain, instance, endpoint, relay_url, client_name).await +} + +async fn pair_with( + store: &dyn PairingStore, + instance: String, + endpoint: Endpoint, + relay_url: &str, + client_name: &str, +) -> Result { + // Fail on a missing or unreadable keychain before asking for approval. + parse_pairings(store.read()?)?; + let relay = provider_relay_url(relay_url)?; + let raw = call( + &endpoint, + None, + true, + "pair", + &[client_name.to_owned(), relay], + ) + .await + .map_err(|e| e.into_cli("pair"))?; + let paired: Paired = serde_json::from_str(&raw) + .map_err(|_| CliError::Auth("Buzz desktop: unreadable `pair` answer".into()))?; + if paired.instance != instance + || !usable_token(&paired.token) + || PublicKey::from_hex(&paired.pubkey).is_err() + { + return Err(CliError::Auth( + "Buzz desktop returned an invalid pairing".into(), + )); + } + let summary = serde_json::json!({ + "instance": instance, + "pubkey": paired.pubkey, + "relays": paired.relays, + }); + // Reload at commit time: only this instance changes, and a new pairing + // replaces its old token. + update(store, |pairings| { + pairings.0.insert( + instance, + Pairing { + token: paired.token, + pubkey: paired.pubkey, + relays: paired.relays, + }, + ); + })?; + Ok(summary) +} + +/// Forget paired tokens: one instance's, or all of them. Local only. +pub fn unpair(instance: Option<&str>) -> Result { + unpair_with(&Keychain, instance) +} + +fn unpair_with( + store: &dyn PairingStore, + instance: Option<&str>, +) -> Result { + let removed = match instance { + Some(i) => update(store, |pairings| { + pairings + .0 + .remove(i) + .map(|_| vec![i.to_owned()]) + .unwrap_or_default() + })?, + // Clearing everything needs no read, so it also clears an unreadable entry. + None => { + let _guard = store.lock()?; + let removed = store + .read()? + .map(|json| parse_pairings(Some(json)).map(|p| p.0.into_keys().collect())) + .transpose() + .unwrap_or_default() + .unwrap_or_default(); + store.write(None)?; + removed + } + }; + Ok(serde_json::json!({ "removed": removed })) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::cached_assertion::unix_now; + + tokio::task_local! { + /// Pretend the caller runs as this user ID. + pub(crate) static CALLER_UID: u32; + } + use std::sync::Mutex as StdMutex; + + #[test] + fn endpoint_must_be_loopback_http_ipv4() { + assert!(Endpoint::parse("http://127.0.0.1:4100/rpc").is_ok()); + for bad in [ + "https://127.0.0.1:4100/rpc", + "http://localhost:4100/rpc", + "http://[::1]:4100/rpc", + "http://user@127.0.0.1:4100/rpc", + "http://10.0.0.1:4100/rpc", + "http://127.0.0.1/rpc", + ] { + assert!(Endpoint::parse(bad).is_err(), "{bad}"); + } + } + + #[test] + fn unrecognized_codes_are_denied() { + assert_eq!(known_code("approval_required"), "approval_required"); + assert_eq!(known_code("brand_new_code"), "denied"); + } + + #[test] + fn held_methods_get_the_long_timeout() { + assert!(held("sign_event") && held("nip44_decrypt") && held("pair")); + assert!(!held("describe") && !held("federated_identity")); + } + + /// Serves `body` as one raw HTTP response and returns what `exchange` makes of it. + async fn exchanged(body: Vec) -> Result<(u16, Vec), ProviderError> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + // Read the whole request first: closing with any of it unread + // resets the connection and loses the reply. + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n{}") { + let mut chunk = [0u8; 4096]; + match tokio::io::AsyncReadExt::read(&mut stream, &mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.shutdown().await; + }); + let endpoint = Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(); + let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let reply = exchange(stream, &endpoint, Some("t"), VERSION, b"{}").await; + server.abort(); + reply + } + + #[tokio::test] + async fn the_largest_signed_event_fits_and_more_is_refused() { + const CANVAS_BYTES: usize = 512 * 1024; + // An event at the desktop's limit whose content is all quotes, so + // every content byte doubles again when the event is the result string. + let shell = + r#"{"id":"","pubkey":"","created_at":0,"kind":40100,"tags":[],"content":"","sig":""}"#; + let content = "\"".repeat((CANVAS_BYTES - shell.len()) / 2); + let event = serde_json::json!({ + "id": "", "pubkey": "", "created_at": 0, "kind": 40100, "tags": [], + "content": content, "sig": "", + }) + .to_string(); + assert!(event.len() <= CANVAS_BYTES && event.len() > CANVAS_BYTES - 16); + // An agent note isn't bound by the relay frame: 64 KiB of content and + // 99 quote-filled 4 KiB tags, over the old Canvas-based cap once escaped. + let tags: Vec<_> = (0..99) + .map(|i| serde_json::json!(["t", format!("{i}{}", "\"".repeat(4000))])) + .collect(); + let note = serde_json::json!({ + "id": "", "pubkey": "", "created_at": 0, "kind": 30023, "tags": tags, + "content": "\"".repeat(64 * 1024), "sig": "", + }) + .to_string(); + // A decrypt whose base64 payload fills the request: its plaintext, + // three quarters of that, all control characters (`\u0001`, six bytes). + let plaintext = "\u{1}".repeat(MAX_REQUEST_BYTES / 4 * 3 - 64 * 1024); + for result in [event, note, plaintext] { + let body = serde_json::json!({ "id": "1", "result": result }) + .to_string() + .into_bytes(); + let (status, parsed) = exchanged(body.clone()).await.unwrap(); + assert_eq!((status, parsed), (200, body)); + } + let runaway = vec![b'x'; MAX_RESPONSE_BYTES]; + assert!(matches!( + exchanged(runaway).await, + Err(ProviderError::Transport(message)) if message == "response too large" + )); + } + + #[test] + fn parses_content_length_and_chunked_responses() { + let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhiEXTRA"; + assert_eq!(parse_response(fixed), Some((200, b"hi".to_vec()))); + let chunked = + b"HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"; + assert_eq!(parse_response(chunked), Some((403, b"abcde".to_vec()))); + assert_eq!(parse_response(b"garbage"), None); + } + + #[test] + fn proc_net_tcp_finds_the_server_side_of_this_connection() { + let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\ + 0: 0100007F:1004 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 1\n\ + 1: 0100007F:1004 0100007F:C350 01 00000000:00000000 00:00000000 00000000 502 0 2\n\ + 2: 0100007F:C350 0100007F:1004 01 00000000:00000000 00:00000000 00000000 501 0 3\n"; + assert_eq!(proc_net_tcp_uid(table, 0x1004, 0xC350), Some(502)); + assert_eq!(proc_net_tcp_uid(table, 0x1004, 0xC351), None); + } + + #[test] + fn lsof_finds_the_server_side_of_this_connection() { + let out = "p100\nu501\nn127.0.0.1:50000->127.0.0.1:4100\np200\nu502\nn127.0.0.1:4100->127.0.0.1:50000\n"; + assert_eq!(lsof_uid(out, 4100, 50000), Some(502)); + assert_eq!(lsof_uid(out, 4100, 50001), None); + } + + #[test] + fn advertisements_require_private_ownership() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let ads = dir.path().join("providers"); + std::fs::create_dir(&ads).unwrap(); + let write = |name: &str, mode: u32| { + let path = ads.join(name); + std::fs::write( + &path, + r#"{"instance":"xyz.block.buzz","url":"http://127.0.0.1:4100/rpc"}"#, + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + }; + write("good.json", 0o600); + write("open.json", 0o644); + write(".partial.json", 0o600); + std::fs::set_permissions(&ads, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + advertisements_in(&ads).is_empty(), + "a group-readable directory is ignored" + ); + std::fs::set_permissions(&ads, std::fs::Permissions::from_mode(0o700)).unwrap(); + let found = advertisements_in(&ads); + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, "xyz.block.buzz"); + assert_eq!(found[0].1.port, 4100); + } + + // ----------------------------------------------------------------------- + // Against a mock provider + // ----------------------------------------------------------------------- + + pub(crate) type Seen = Arc, String, serde_json::Value)>>>; + + /// A provider answering each envelope with `respond(version, request)`. + pub(crate) async fn provider(respond: F) -> (Endpoint, Seen) + where + F: Fn(&str, &serde_json::Value) -> serde_json::Value + Send + Sync + 'static, + { + use axum::http::HeaderMap; + let seen: Seen = Arc::default(); + let log = seen.clone(); + let respond = Arc::new(respond); + let app = axum::Router::new().route( + "/rpc", + axum::routing::post(move |headers: HeaderMap, body: axum::body::Bytes| { + let (log, respond) = (log.clone(), respond.clone()); + async move { + let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let header = |n: &str| { + headers + .get(n) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }; + let version = header(VERSION_HEADER).unwrap_or_default(); + let mut answer = respond(&version, &request); + answer["id"] = request["id"].clone(); + log.lock() + .unwrap() + .push((header("authorization"), version, request)); + axum::Json(answer) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + ( + Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(), + seen, + ) + } + + const RELAY: &str = "wss://community.example"; + + fn describe(relays: &[&str]) -> serde_json::Value { + let pubkey = nostr::Keys::generate().public_key().to_hex(); + serde_json::json!({"result": serde_json::json!({ + "instance": "xyz.block.buzz", "pubkey": pubkey, "role": "owner", + "relays": relays, "methods": ["get_public_key", "sign_event"], + }).to_string()}) + } + + #[tokio::test] + async fn describe_sends_token_and_version_and_binds_the_relay() { + let (endpoint, seen) = provider(|_, _| describe(&[RELAY])).await; + let opened = Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap(); + assert!(opened.is_ok()); + let seen = seen.lock().unwrap(); + assert_eq!(seen[0].0.as_deref(), Some("Bearer tok")); + assert_eq!(seen[0].1, "1"); + assert_eq!(seen[0].2["method"], "describe"); + } + + #[tokio::test] + async fn short_and_long_assertions_refresh_at_the_earlier_point() { + use base64::Engine; + for (lifetime, refresh) in [(20, 10), (300, 150)] { + let (endpoint, seen) = provider(move |_, request| match request["method"].as_str() { + Some("federated_identity") => { + let b64 = |v: serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string()) + }; + let jws = format!( + "{}.{}.c2ln", + b64(serde_json::json!({"alg": "ES256"})), + b64(serde_json::json!({"exp": unix_now() + lifetime})) + ); + serde_json::json!({ "result": jws }) + } + _ => describe(&[RELAY]), + }) + .await; + let Ok(Ok(provider)) = Provider::open(endpoint, "tok".into(), false, RELAY).await + else { + panic!("the provider should open"); + }; + let mints = || { + let seen = seen.lock().unwrap(); + seen.iter() + .filter(|s| s.2["method"] == "federated_identity") + .count() + }; + provider.federated_identity().await.unwrap(); + crate::cached_assertion::test_clock::advance(refresh - 1); + provider.federated_identity().await.unwrap(); + assert_eq!(mints(), 1, "lifetime {lifetime}"); + crate::cached_assertion::test_clock::advance(1); + provider.federated_identity().await.unwrap(); + assert_eq!(mints(), 2, "lifetime {lifetime}"); + } + } + + #[tokio::test] + async fn an_unbound_relay_or_revoked_token_falls_through() { + let (endpoint, _) = provider(|_, _| describe(&["wss://other.example"])).await; + let opened = Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap(); + assert!(matches!(opened, Err(Skip::NotApplicable))); + let (endpoint, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + let opened = Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap(); + assert!(matches!(opened, Err(Skip::Revoked))); + } + + #[tokio::test] + async fn version_negotiation_retries_once_or_gives_up() { + let (endpoint, seen) = provider(|_, _| { + serde_json::json!({"result": "", "error": "unsupported_version", "versions": [2, 3]}) + }) + .await; + let err = call(&endpoint, Some("tok"), false, "describe", &[]) + .await + .unwrap_err(); + assert!(matches!(err, ProviderError::NoCommonVersion)); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + /// A throttled auth proof is re-sent dated now (its Blossom expiration + /// moved with it); a content event is re-sent exactly as built. + #[tokio::test] + async fn a_throttled_auth_proof_is_renewed_and_a_content_event_is_not() { + use std::sync::atomic::{AtomicBool, Ordering}; + let keys = nostr::Keys::generate(); + let answer = desktop_answers(keys.clone(), vec![RELAY.into()]); + let throttle = Arc::new(AtomicBool::new(false)); + let gate = throttle.clone(); + let (endpoint, seen) = provider(move |version, request| { + if request["method"] == "sign_event" && gate.swap(false, Ordering::SeqCst) { + // As long as a real throttle takes, at least a clock second. + std::thread::sleep(Duration::from_millis(1100)); + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}); + } + answer(version, request) + }) + .await; + let desktop = match Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap() + { + Ok(p) => p, + Err(_) => panic!("provider not opened"), + }; + let expiration = |e: &Event| e.tags.expiration().map(|t| t.as_secs()); + + let proof = nostr::EventBuilder::new(nostr::Kind::from(24242), "Upload file") + .tags([Tag::parse(["expiration", &(unix_now() + 60).to_string()]).unwrap()]) + .build(keys.public_key()); + throttle.store(true, Ordering::SeqCst); + let signed = desktop.sign(proof.clone()).await.unwrap(); + assert!( + signed.created_at > proof.created_at, + "the proof kept its old date" + ); + assert_eq!( + expiration(&signed).unwrap() - signed.created_at.as_secs(), + 60, + "the expiration did not move with the date" + ); + + let mut content = + nostr::EventBuilder::new(nostr::Kind::TextNote, "hi").build(keys.public_key()); + let id = content.id(); + throttle.store(true, Ordering::SeqCst); + let signed = desktop.sign(content.clone()).await.unwrap(); + assert_eq!(signed.created_at, content.created_at); + assert_eq!(signed.id, id); + assert!(seen.lock().unwrap().len() >= 5); + } + + /// A same-user lookup that runs out of time is retryable; a wrong or + /// missing owner stays a refusal. + #[tokio::test(start_paused = true)] + async fn a_slow_same_user_check_is_retryable_and_a_mismatch_is_not() { + let slow = async { + tokio::time::sleep(Duration::from_secs(4)).await; + Some(current_uid()) + }; + assert!( + confirm_same_user(slow).await.is_ok(), + "4 s is within the budget" + ); + + let err = confirm_same_user(std::future::pending()).await.unwrap_err(); + assert!(crate::error::is_retryable_error(&err.into_cli("describe"))); + + for owner in [Some(current_uid() + 1), None] { + let err = confirm_same_user(async { owner }).await.unwrap_err(); + assert!(!crate::error::is_retryable_error(&err.into_cli("describe"))); + } + } + + #[tokio::test] + async fn rate_limited_is_waited_out_then_reported_as_retryable() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let asked = Arc::new(AtomicUsize::new(0)); + let count = asked.clone(); + let (endpoint, seen) = provider(move |_, _| match count.fetch_add(1, Ordering::SeqCst) { + 0 => serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}), + _ => serde_json::json!({"result": "signed"}), + }) + .await; + let started = std::time::Instant::now(); + let result = call(&endpoint, Some("tok"), false, "sign_event", &[]).await; + assert_eq!(result.unwrap(), "signed"); + assert!(started.elapsed() >= RETRY_AFTER_UNIT, "honors retry_after"); + assert_eq!(seen.lock().unwrap().len(), 2); + + let (endpoint, seen) = provider( + |_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 0}), + ) + .await; + // `retry_after: 0` still waits the 1 s floor, so this takes a few seconds. + let err = call(&endpoint, Some("tok"), false, "sign_event", &[]) + .await + .unwrap_err(); + assert_eq!(seen.lock().unwrap().len(), 1 + RATE_LIMIT_RETRIES as usize); + let err = err.into_cli("sign_event"); + assert!(matches!(err, CliError::Throttled(_)), "{err:?}"); + assert!(crate::error::is_retryable_error(&err)); + } + + #[tokio::test] + async fn a_cooldown_past_the_cap_is_reported_without_retrying() { + let (endpoint, seen) = provider( + |_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 300}), + ) + .await; + let err = call(&endpoint, None, false, "pair", &[]).await.unwrap_err(); + assert_eq!(seen.lock().unwrap().len(), 1); + let err = err.into_cli("pair"); + assert!( + matches!(&err, CliError::Throttled(m) if m.contains("retry in 300s")), + "{err:?}" + ); + } + + #[tokio::test] + async fn an_error_envelope_for_another_request_is_refused() { + let (endpoint, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + // The mock echoes the request id; a raw server that does not is below. + assert!(matches!( + call(&endpoint, Some("tok"), false, "describe", &[]).await, + Err(ProviderError::Revoked) + )); + let app = axum::Router::new().route( + "/rpc", + axum::routing::post(|| async { + axum::Json(serde_json::json!({"id": "other", "result": "", "error": "revoked"})) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let endpoint = Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(); + assert!(matches!( + call(&endpoint, Some("tok"), false, "describe", &[]).await, + Err(ProviderError::Transport(_)) + )); + } + + #[tokio::test] + async fn an_unknown_error_code_is_denied() { + let (endpoint, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "brand_new"})).await; + let err = call(&endpoint, Some("tok"), false, "sign_event", &[]) + .await + .unwrap_err(); + assert!(matches!(err, ProviderError::Code { code: "denied", .. })); + } + + #[tokio::test] + async fn same_user_check_accepts_this_users_provider() { + let (endpoint, _) = provider(|_, _| describe(&[RELAY])).await; + let opened = Provider::open(endpoint, "tok".into(), true, RELAY) + .await + .unwrap(); + assert!(opened.is_ok()); + } + + // ----------------------------------------------------------------------- + // A desktop holding a real key, and an in-memory pairing store + // ----------------------------------------------------------------------- + + /// The assertion the mock desktop hands out for any relay. + pub(crate) fn mock_assertion() -> String { + assertion(600) + } + + /// An assertion that expires `lifetime` s from now. + pub(crate) fn assertion(lifetime: u64) -> String { + use base64::Engine; + let b64 = |v: serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string()) + }; + format!( + "{}.{}.c2ln", + b64(serde_json::json!({"alg": "ES256", "typ": "nip-fi+jwt"})), + b64(serde_json::json!({"exp": unix_now() + lifetime})) + ) + } + + /// A desktop acting as `keys` for `relays`: it signs, encrypts, decrypts + /// (answering `invalid_request` for bad ciphertext, as #2260 does), + /// derives engram `d` tags and hands out [`mock_assertion`]. + pub(crate) async fn desktop(keys: nostr::Keys, relays: Vec) -> (Endpoint, Seen) { + provider(desktop_answers(keys, relays)).await + } + + /// [`desktop`]'s answers, for a provider that wraps them. + pub(crate) fn desktop_answers( + keys: nostr::Keys, + relays: Vec, + ) -> impl Fn(&str, &serde_json::Value) -> serde_json::Value + Send + Sync + 'static { + move |_, request| { + let params: Vec = + serde_json::from_value(request["params"].clone()).unwrap_or_default(); + let peer = || PublicKey::from_hex(¶ms[0]).unwrap(); + let ok = |result: String| serde_json::json!({ "result": result }); + match request["method"].as_str().unwrap_or_default() { + "describe" => ok(serde_json::json!({ + "instance": "xyz.block.buzz", "pubkey": keys.public_key().to_hex(), + "role": "owner", "relays": relays, + "methods": ["sign_event", "nip44_decrypt", "federated_identity"], + }) + .to_string()), + "sign_event" => { + let unsigned = UnsignedEvent::from_json(¶ms[0]).unwrap(); + ok(unsigned.sign_with_keys(&keys).unwrap().as_json()) + } + "nip44_encrypt" => ok(nostr::nips::nip44::encrypt( + keys.secret_key(), + &peer(), + ¶ms[1], + nostr::nips::nip44::Version::V2, + ) + .unwrap()), + "nip44_decrypt" => { + match nostr::nips::nip44::decrypt(keys.secret_key(), &peer(), ¶ms[1]) { + Ok(plain) => ok(plain), + Err(_) => serde_json::json!({"result": "", "error": "invalid_request"}), + } + } + "engram_d_tag" => { + let k_c = buzz_core::engram::conversation_key(keys.secret_key(), &peer()); + ok(buzz_core::engram::d_tag(&k_c, ¶ms[1])) + } + "federated_identity" => ok(mock_assertion()), + _ => serde_json::json!({"result": "", "error": "unknown_method"}), + } + } + } + + #[derive(Default)] + struct MemoryStore { + json: StdMutex>, + lock: StdMutex<()>, + } + + impl MemoryStore { + fn with(pairings: &[(&str, &str, &[&str])]) -> Self { + let map: std::collections::BTreeMap<_, _> = pairings + .iter() + .map(|(instance, token, relays)| { + let pairing = Pairing { + token: (*token).into(), + pubkey: nostr::Keys::generate().public_key().to_hex(), + relays: relays.iter().map(|r| (*r).to_owned()).collect(), + }; + ((*instance).to_owned(), pairing) + }) + .collect(); + let store = Self::default(); + *store.json.lock().unwrap() = Some(serde_json::to_string(&Pairings(map)).unwrap()); + store + } + + fn instances(&self) -> Vec { + parse_pairings(self.json.lock().unwrap().clone()) + .unwrap() + .0 + .into_keys() + .collect() + } + } + + impl PairingStore for MemoryStore { + fn read(&self) -> Result, CliError> { + Ok(self.json.lock().unwrap().clone()) + } + fn write(&self, json: Option<&str>) -> Result<(), CliError> { + *self.json.lock().unwrap() = json.map(str::to_owned); + Ok(()) + } + fn lock(&self) -> Result, CliError> { + Ok(Box::new(self.lock.lock().unwrap())) + } + } + + fn methods(seen: &Seen) -> Vec { + seen.lock() + .unwrap() + .iter() + .map(|(_, _, r)| r["method"].as_str().unwrap_or_default().to_owned()) + .collect() + } + + /// An agent whose inherited token is revoked or not bound to the relay + /// fails, even with a valid owner pairing advertised: the owner's + /// desktop gets no request at all. + #[tokio::test] + async fn an_agent_never_falls_back_to_the_owner_pairing() { + let (owner, owner_seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let store = MemoryStore::with(&[("xyz.block.buzz", "owner-token", &[RELAY])]); + let (revoked, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + let (unbound, _) = provider(|_, _| describe(&["wss://other.example"])).await; + for agent in [revoked, unbound] { + let ads = || vec![("xyz.block.buzz".to_owned(), owner.clone())]; + let result = discover_with(Some((agent, "agent".into())), &store, ads, RELAY).await; + assert!(matches!(result, Err(CliError::Auth(_))), "{result:?}"); + } + assert!(owner_seen.lock().unwrap().is_empty(), "the owner was asked"); + // Without an inherited token the same pairing is used. + let ads = || vec![("xyz.block.buzz".to_owned(), owner.clone())]; + assert!(discover_with(None, &store, ads, RELAY) + .await + .unwrap() + .is_some()); + assert_eq!( + owner_seen.lock().unwrap()[0].0.as_deref(), + Some("Bearer owner-token") + ); + } + + #[test] + fn relay_urls_bind_whole_after_normalization() { + let norm = |u: &str| provider_relay_url(u).unwrap(); + assert_eq!(norm("https://Host.example"), "wss://host.example/"); + assert_eq!(norm("wss://host.example:443/"), "wss://host.example/"); + assert_eq!(norm("http://127.0.0.1:3000"), "ws://127.0.0.1:3000/"); + assert_eq!(norm("ws://127.0.0.1:80/a"), "ws://127.0.0.1/a"); + assert_eq!(norm("wss://host.example/a?x=1"), "wss://host.example/a?x=1"); + assert_ne!(norm("wss://host.example/a"), norm("wss://host.example/b")); + assert_ne!(norm("ws://host.example"), norm("wss://host.example")); + assert!(provider_relay_url("ftp://host.example").is_err()); + } + + #[tokio::test] + async fn a_token_binds_its_relay_path_and_plain_ws() { + let bound = vec![ + "wss://host.example/a".to_owned(), + "ws://127.0.0.1:7000".to_owned(), + ]; + let (endpoint, _) = desktop(nostr::Keys::generate(), bound).await; + let open = |relay: &'static str| { + let endpoint = endpoint.clone(); + async move { + Provider::open(endpoint, "t".into(), false, relay) + .await + .unwrap() + } + }; + assert!(open("https://host.example/a").await.is_ok()); + assert!(matches!( + open("https://host.example/b").await, + Err(Skip::NotApplicable) + )); + assert!(matches!( + open("https://host.example").await, + Err(Skip::NotApplicable) + )); + assert!(open("http://127.0.0.1:7000/").await.is_ok()); + } + + #[tokio::test] + async fn bad_ciphertext_is_invalid_but_a_credential_failure_is_an_error() { + let keys = nostr::Keys::generate(); + let peer = nostr::Keys::generate(); + let (endpoint, _) = desktop(keys.clone(), vec![RELAY.into()]).await; + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + let good = nostr::nips::nip44::encrypt( + peer.secret_key(), + &keys.public_key(), + "hi", + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + let plain = p.nip44_decrypt(&peer.public_key(), &good).await.unwrap(); + assert_eq!(plain.as_deref(), Some("hi")); + assert!(p + .nip44_decrypt(&peer.public_key(), "AAAA") + .await + .unwrap() + .is_none()); + for code in ["revoked", "denied", "unavailable"] { + let (endpoint, _) = provider(move |_, r| match r["method"].as_str() { + Some("describe") => describe(&[RELAY]), + _ => serde_json::json!({"result": "", "error": code}), + }) + .await; + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + assert!( + p.nip44_decrypt(&peer.public_key(), &good).await.is_err(), + "{code}" + ); + } + } + + #[tokio::test] + async fn a_paired_desktop_that_refuses_fails_and_revoked_entries_are_forgotten() { + let (denied, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "denied"})).await; + let store = MemoryStore::with(&[("a", "ta", &[RELAY])]); + let ads = || vec![("a".to_owned(), denied)]; + assert!(discover_with(None, &store, ads, RELAY).await.is_err()); + + let (revoked, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]); + let ads = || vec![("a".to_owned(), revoked), ("b".to_owned(), good)]; + assert!(discover_with(None, &store, ads, RELAY) + .await + .unwrap() + .is_some()); + assert_eq!(store.instances(), ["b"]); + } + + /// A pairing waiting for approval does not hold the store, and its commit + /// reloads: a concurrent unpair stays unpaired and a concurrent pairing + /// is kept. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_pair_and_unpair_keep_each_others_changes() { + let pubkey = nostr::Keys::generate().public_key().to_hex(); + // `started` says the approval is pending; `release` lets it answer. + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (release, release_rx) = std::sync::mpsc::channel::<()>(); + let (started_tx, release_rx) = (StdMutex::new(started_tx), StdMutex::new(release_rx)); + let (slow, _) = provider(move |_, _| { + started_tx.lock().unwrap().send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + serde_json::json!({"result": serde_json::json!({ + "token": "tb", "instance": "b", "pubkey": pubkey, "relays": [RELAY], + }).to_string()}) + }) + .await; + let store = Arc::new(MemoryStore::with(&[("a", "ta", &[RELAY])])); + let pairing = { + let store = store.clone(); + tokio::spawn( + async move { pair_with(store.as_ref(), "b".into(), slow, RELAY, "cli").await }, + ) + }; + tokio::task::spawn_blocking(move || started.recv().unwrap()) + .await + .unwrap(); + unpair_with(store.as_ref(), Some("a")).unwrap(); + update(store.as_ref(), |p| { + assert!(!p.0.contains_key("b")); + p.0.insert( + "c".into(), + Pairing { + token: "tc".into(), + pubkey: String::new(), + relays: vec![], + }, + ); + }) + .unwrap(); + release.send(()).unwrap(); + pairing.await.unwrap().unwrap(); + assert_eq!(store.instances(), ["b", "c"]); + } + + /// Through the CLI's own wiring of `--relay`, a community ending in a + /// slash reaches the desktop exactly as configured, for inherited + /// discovery, evidence and pairing. + #[tokio::test] + async fn a_non_root_trailing_slash_relay_reaches_the_desktop_unchanged() { + use clap::Parser; + const TEAM: &str = "wss://host.example/team/"; + let parse = |args: &[&str]| crate::Cli::try_parse_from(args).unwrap(); + let store = MemoryStore::default(); + + let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![TEAM.into()]).await; + let cli = parse(&["buzz", "--relay", TEAM, "users", "get"]); + let (_, client) = crate::connect(&cli.relay, cli.command, async |_, _| unreachable!(), { + let store = &store; + async move |relay| { + let provider = + discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay) + .await? + .unwrap(); + crate::credentials::Credentials::from_desktop(provider).await + } + }) + .await + .unwrap() + .unwrap(); + client.credentials().evidence().await.unwrap(); + let asked = seen.lock().unwrap().last().unwrap().2.clone(); + assert_eq!(asked["params"], serde_json::json!([TEAM])); + + let pubkey = nostr::Keys::generate().public_key().to_hex(); + let (endpoint, seen) = provider(move |_, _| { + serde_json::json!({"result": serde_json::json!({ + "token": "tok", "instance": "i", "pubkey": pubkey, "relays": [TEAM], + }).to_string()}) + }) + .await; + let cli = parse(&[ + "buzz", "--relay", TEAM, "auth", "desktop", "pair", "--name", "cli", + ]); + let paired = crate::connect( + &cli.relay, + cli.command, + async |sub, relay| { + let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, .. }) = sub else { + unreachable!() + }; + pair_with(&store, "i".into(), endpoint, relay, &name) + .await + .map(drop) + }, + async |_| unreachable!(), + ) + .await + .unwrap(); + assert!(paired.is_none()); + let asked = seen.lock().unwrap()[0].2.clone(); + assert_eq!(asked["params"], serde_json::json!(["cli", TEAM])); + } + + #[tokio::test] + async fn an_unreadable_store_is_refused_not_overwritten() { + let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let store = MemoryStore::default(); + *store.json.lock().unwrap() = Some("not json".into()); + assert!( + pair_with(&store, "xyz.block.buzz".into(), endpoint, RELAY, "cli") + .await + .is_err() + ); + assert!(seen.lock().unwrap().is_empty(), "approval was requested"); + assert!(unpair_with(&store, Some("a")).is_err()); + assert_eq!(store.json.lock().unwrap().as_deref(), Some("not json")); + unpair_with(&store, None).unwrap(); + assert!(store.json.lock().unwrap().is_none()); + } + + /// `pair` sends the whole bound URL, and the token it gets back is stored. + #[tokio::test] + async fn pair_sends_the_bound_url_and_stores_the_token() { + let pubkey = nostr::Keys::generate().public_key().to_hex(); + let (endpoint, seen) = provider(move |_, _| { + serde_json::json!({"result": serde_json::json!({ + "token": "tok", "instance": "i", "pubkey": pubkey, + "relays": ["wss://host.example/a"], + }).to_string()}) + }) + .await; + let store = MemoryStore::default(); + pair_with( + &store, + "i".into(), + endpoint, + "https://host.example/a", + "cli", + ) + .await + .unwrap(); + let request = seen.lock().unwrap()[0].2.clone(); + assert_eq!( + request["params"], + serde_json::json!(["cli", "wss://host.example/a"]) + ); + assert_eq!(store.instances(), ["i"]); + unpair_with(&store, Some("i")).unwrap(); + assert!(store.json.lock().unwrap().is_none()); + } + + /// When this user cannot be confirmed on the server side, not one byte + /// (so no token) reaches the endpoint. + #[tokio::test] + async fn an_unconfirmed_user_gets_nothing_sent() { + let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let opened = CALLER_UID + .scope( + u32::MAX - 1, + Provider::open(endpoint, "secret".into(), true, RELAY), + ) + .await + .unwrap(); + assert!(matches!(opened, Err(Skip::Unreachable))); + assert!(seen.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn signing_goes_through_the_desktop_and_is_verified() { + use nostr::NostrSigner; + let keys = nostr::Keys::generate(); + let (endpoint, seen) = desktop(keys.clone(), vec![RELAY.into()]).await; + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + let unsigned = + nostr::EventBuilder::new(nostr::Kind::Custom(9), "hi").build(keys.public_key()); + let event = p.sign_event(unsigned).await.unwrap(); + assert!(event.verify().is_ok() && event.pubkey == keys.public_key()); + assert_eq!(methods(&seen), ["describe", "sign_event"]); + } + + /// The desktop's bare compact assertion reaches the relay with exactly + /// one `Bearer` scheme (the shape #2260's `federated_identity` returns). + #[tokio::test] + async fn the_desktop_assertion_is_sent_with_one_bearer_prefix() { + use axum::http::HeaderMap; + let seen: Arc>> = Arc::default(); + let log = seen.clone(); + let app = axum::Router::new().fallback(move |headers: HeaderMap| { + let log = log.clone(); + async move { + let value = headers + .get("nostr-federated-identity") + .and_then(|v| v.to_str().ok()); + log.lock() + .unwrap() + .push(value.unwrap_or_default().to_owned()); + axum::Json(serde_json::json!([])) + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let (endpoint, _) = desktop(nostr::Keys::generate(), vec![relay.clone()]).await; + let p = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(p) + .await + .unwrap(); + let client = crate::client::BuzzClient::new(relay, credentials).unwrap(); + client + .query(&serde_json::json!({"kinds": [9]})) + .await + .unwrap(); + let header = seen.lock().unwrap()[0].clone(); + assert_eq!(header.matches("Bearer").count(), 1, "{header}"); + assert_eq!( + header.strip_prefix("Bearer ").unwrap().split('.').count(), + 3 + ); + } +} diff --git a/crates/buzz-cli/src/error.rs b/crates/buzz-cli/src/error.rs index 2edcd6aa9d..1cd3855a37 100644 --- a/crates/buzz-cli/src/error.rs +++ b/crates/buzz-cli/src/error.rs @@ -39,6 +39,25 @@ pub enum CliError { #[error("delivery unknown: {0}")] DeliveryUnknown(String), + /// The NIP-FA identity adapter stayed unavailable through its bounded + /// retries. Nothing was sent to the relay, so the command is safe to + /// re-run later; relay retry loops never repeat it. + #[error("identity adapter error: {0}")] + Adapter(String), + + /// The local credential provider stayed `rate_limited` through its + /// bounded waits. It refused before acting, so a later re-run is safe. + /// Only the Unix desktop provider reports it. + #[cfg_attr(not(unix), allow(dead_code))] + #[error("rate limited: {0}")] + Throttled(String), + + /// The relay or the Buzz desktop could not be used now (the relay kept + /// closing the WebSocket before sign-in, or the desktop's same-user check + /// timed out). Nothing was sent, so a later re-run is safe. + #[error("unavailable: {0}")] + Unavailable(String), + /// Catch-all for unexpected failures #[error("{0}")] Other(String), @@ -47,7 +66,7 @@ pub enum CliError { /// Walk the full `std::error::Error::source()` chain on a `reqwest::Error` /// and render it as a colon-separated string, e.g. /// `error sending request: dns error: failed to lookup address information: ...` -fn fmt_reqwest_error(e: &reqwest::Error) -> String { +pub(crate) fn fmt_reqwest_error(e: &reqwest::Error) -> String { let mut msg = e.to_string(); let mut source: &dyn std::error::Error = e; while let Some(cause) = source.source() { @@ -65,7 +84,9 @@ fn fmt_reqwest_error(e: &reqwest::Error) -> String { /// /// Transport-level network errors (connect failure, timeout, mid-request, /// mid-body transfer, or body decode failure) and relay overload responses -/// (429 / 502 / 503 / 504) are retryable. `DeliveryUnknown` is never +/// (429 / 502 / 503 / 504) are retryable, as are a desktop that stayed +/// `rate_limited` (`Throttled`) and an adapter that stayed +/// unavailable (`Adapter`: nothing reached the relay). `DeliveryUnknown` is never /// retryable: the operation may already have executed. All other errors /// indicate a permanent failure: auth, bad input, builder errors, or logic /// errors. @@ -79,6 +100,7 @@ pub fn is_retryable_error(e: &CliError) -> bool { || net_err.is_decode() } CliError::Relay { status, .. } => matches!(status, 429 | 502 | 503 | 504), + CliError::Adapter(_) | CliError::Throttled(_) | CliError::Unavailable(_) => true, CliError::DeliveryUnknown(_) => false, _ => false, } @@ -103,6 +125,7 @@ pub fn exit_code(e: &CliError) -> i32 { CliError::Conflict(_) => 5, CliError::NotFound(_) => 1, CliError::DeliveryUnknown(_) => 2, + CliError::Adapter(_) | CliError::Throttled(_) | CliError::Unavailable(_) => 2, CliError::Other(_) => 4, } } @@ -125,6 +148,9 @@ pub fn print_error(e: &CliError) { CliError::Conflict(_) => "conflict", CliError::NotFound(_) => "not_found", CliError::DeliveryUnknown(_) => "delivery_unknown", + CliError::Adapter(_) => "adapter_error", + CliError::Throttled(_) => "rate_limited", + CliError::Unavailable(_) => "unavailable", CliError::Other(_) => "error", }; let obj = serde_json::json!({ diff --git a/crates/buzz-cli/src/federated_identity.rs b/crates/buzz-cli/src/federated_identity.rs new file mode 100644 index 0000000000..355a0e30be --- /dev/null +++ b/crates/buzz-cli/src/federated_identity.rs @@ -0,0 +1,992 @@ +//! Headless NIP-FI evidence: mint relay assertions from a NIP-FA adapter +//! session (`docs/nips/NIP-FA.md` §Relay assertion). +//! +//! The adapter session token (`BUZZ_FEDERATED_IDENTITY_TOKEN`) proves who the +//! user is; a NIP-98 proof proves the CLI controls the key. The adapter answers +//! with a short-lived assertion that the CLI attaches as +//! `Nostr-Federated-Identity: Bearer …` on every protected relay request. +//! +//! One assertion is cached per process and re-minted shortly before it +//! expires. The session token is sent only to the adapter's origin, never +//! through a redirect, and never appears in errors. +//! +//! A mint retries network failures, 429 and any 5xx with bounded backoff +//! (NIP-FA §Denials), then fails as [`CliError::Adapter`]: a pre-send failure +//! that relay retry loops never repeat and never report as possibly +//! delivered. A refusal is never retried, even when its body is unreadable. + +use std::time::Duration; + +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; + +use crate::cached_assertion::{unix_now, CachedAssertion}; +use crate::error::CliError; + +/// Environment variable carrying a NIP-FA adapter session token. +pub const FEDERATED_IDENTITY_TOKEN_ENV: &str = "BUZZ_FEDERATED_IDENTITY_TOKEN"; +/// Environment variable carrying the NIP-FA adapter base URL. +pub const FEDERATED_IDENTITY_ADAPTER_ENV: &str = "BUZZ_FEDERATED_IDENTITY_ADAPTER"; + +/// NIP-FA assertion endpoint, relative to the adapter base. +const ASSERTIONS_PATH: &str = "/v1/identity/assertions"; + +/// Largest adapter response body the CLI reads. +const MAX_RESPONSE_BYTES: usize = 64 * 1024; + +/// Attempts per mint, and the backoff ceiling before each retry. +const MINT_ATTEMPTS: usize = 3; +const MINT_BACKOFF: [Duration; MINT_ATTEMPTS - 1] = + [Duration::from_millis(250), Duration::from_millis(750)]; +/// The longest adapter `Retry-After` the CLI waits out before a retry. +const MINT_RETRY_AFTER_MAX: Duration = Duration::from_secs(30); + +/// A NIP-FA adapter session that mints NIP-FI assertions. +pub struct AdapterSession { + endpoint: url::Url, + /// The relay it mints for, in NIP-FA form, bound at creation. + relay_url: String, + token: String, + http: reqwest::Client, + cached: CachedAssertion, +} + +impl std::fmt::Debug for AdapterSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AdapterSession") + .field("endpoint", &self.endpoint.as_str()) + .finish_non_exhaustive() + } +} + +#[derive(serde::Deserialize)] +struct AssertionResponse { + assertion: String, + nostr_pubkey: String, + expires_at: u64, +} + +#[derive(serde::Deserialize)] +struct DenialBody { + error: String, +} + +/// The NIP-FA canonical relay URL: `wss://host[:port]`, default port omitted, +/// no trailing slash, path, query or fragment. +pub fn canonical_relay_url(relay_url: &str) -> Result { + // `wss://host` means 443, so an explicit `:80` would silently alias it; + // NIP-FA has no relay on port 80. The URL parser drops `:80` for + // `http`/`ws`, so read the authority as written. + if explicit_port(relay_url) == Some(80) { + return Err(CliError::Usage(format!( + "NIP-FA does not support a relay on port 80: {relay_url}" + ))); + } + let authority = buzz_core::tenant::relay_url_authority(relay_url); + if authority.is_empty() { + return Err(CliError::Usage(format!("invalid relay URL: {relay_url}"))); + } + Ok(format!("wss://{authority}")) +} + +/// The port written in `url`'s authority, as a number, if any. +fn explicit_port(url: &str) -> Option { + let rest = url.split_once("://")?.1; + let authority = rest.split(['/', '?', '#']).next()?; + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let (host, port) = host_port.rsplit_once(':')?; + if host.is_empty() || port.contains(']') { + return None; + } + port.parse().ok() +} + +/// An adapter `Retry-After`: delta-seconds or an HTTP-date in any of the +/// three RFC 9110 formats. +fn parse_retry_after(value: &str, now: u64) -> Option { + let value = value.trim(); + if let Ok(secs) = value.parse() { + return Some(Duration::from_secs(secs)); + } + let at = httpdate::parse_http_date(value) + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + Some(Duration::from_secs(at.saturating_sub(now))) +} + +/// Read at most `MAX_RESPONSE_BYTES` of the body; `None` when it is larger. +async fn bounded_body(mut resp: reqwest::Response) -> Result>, reqwest::Error> { + let mut body = Vec::new(); + while let Some(chunk) = resp.chunk().await? { + if body.len() + chunk.len() > MAX_RESPONSE_BYTES { + return Ok(None); + } + body.extend_from_slice(&chunk); + } + Ok(Some(body)) +} + +fn refused(detail: impl std::fmt::Display) -> CliError { + CliError::Auth(format!( + "the identity adapter refused the assertion request ({detail})" + )) +} + +/// Map a NIP-FA denial to the error the CLI reports (NIP-FA §Denials). +/// Retryable statuses (429, 5xx) are handled before this runs. +fn denial(status: u16, code: Option<&str>, relay_url: &str) -> CliError { + match (status, code) { + (401, Some("session_required" | "session_expired")) => CliError::Auth(format!( + "the identity adapter session is missing, invalid or expired ({}): sign in again \ + and update {FEDERATED_IDENTITY_TOKEN_ENV}", + code.unwrap_or_default() + )), + (403, Some("authorization_denied")) => CliError::Auth(format!( + "the identity adapter denied an assertion for this key on {relay_url} \ + (authorization_denied)" + )), + (403, Some("binding_mismatch")) => CliError::Auth( + "the identity adapter has bound this user to a different Nostr key \ + (binding_mismatch)" + .into(), + ), + (403, Some("invalid_proof")) => { + CliError::Auth("the identity adapter rejected the NIP-98 proof (invalid_proof)".into()) + } + (400, Some(code @ "invalid_request")) | (413, Some(code @ "request_too_large")) => { + refused(code) + } + (status, _) => refused(format!("HTTP {status}")), + } +} + +impl AdapterSession { + /// A session minting for `relay_url` from the adapter at `adapter_base`. + /// The base must be `https` (or `http` on a loopback host, for local + /// development) with no query, fragment or credentials. + pub fn new(adapter_base: &str, token: &str, relay_url: &str) -> Result { + let relay_url = canonical_relay_url(relay_url)?; + let invalid = |why: &str| CliError::Auth(format!("{FEDERATED_IDENTITY_ADAPTER_ENV} {why}")); + let base = url::Url::parse(adapter_base.trim()).map_err(|_| invalid("is not a URL"))?; + let loopback = match base.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(name)) => name == "localhost", + None => false, + }; + match base.scheme() { + "https" => {} + "http" if loopback => {} + _ => return Err(invalid("must use https")), + } + if base.query().is_some() + || base.fragment().is_some() + || !base.username().is_empty() + || base.password().is_some() + { + return Err(invalid("must not carry a query, fragment or credentials")); + } + let endpoint = url::Url::parse(&format!( + "{}{ASSERTIONS_PATH}", + base.as_str().trim_end_matches('/') + )) + .map_err(|_| invalid("is not a URL"))?; + let token = token.trim(); + if token.is_empty() || token.contains(['\r', '\n']) { + return Err(CliError::Auth(format!( + "{FEDERATED_IDENTITY_TOKEN_ENV} is not a usable token" + ))); + } + let http = reqwest::Client::builder() + // NIP-FA: never replay the session token or proof to a redirect target. + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .connect_timeout(Duration::from_secs(10)) + .build()?; + Ok(Self { + endpoint, + relay_url, + token: token.to_owned(), + http, + cached: CachedAssertion::default(), + }) + } + + /// The assertion for its relay, minted with `keys` on first use and + /// re-minted at its refresh point. Concurrent callers share one mint. + pub async fn assertion(&self, keys: &Keys) -> Result, CliError> { + self.cached + .get(async { self.mint(&self.relay_url, keys).await.map(Some) }) + .await + } + + /// Mint with bounded retries; a refusal or a definitive error returns at once. + async fn mint(&self, relay_url: &str, keys: &Keys) -> Result { + let mut attempt = 0; + loop { + match self.mint_once(relay_url, keys).await? { + Ok(issued) => return Ok(issued), + Err(transient) if attempt + 1 == MINT_ATTEMPTS => { + return Err(CliError::Adapter(format!( + "the identity adapter could not issue an assertion after \ + {MINT_ATTEMPTS} attempts ({transient}); retry later" + ))); + } + Err(transient) => { + let wait = transient.retry_after.map_or_else( + || MINT_BACKOFF[attempt].mul_f64(rand::random::()), + |d| d.min(MINT_RETRY_AFTER_MAX), + ); + tokio::time::sleep(wait).await; + attempt += 1; + } + } + } + } + + /// One mint attempt. The outer `Err` ends the mint; the inner `Err` is a + /// transient failure worth another attempt. + async fn mint_once( + &self, + relay_url: &str, + keys: &Keys, + ) -> Result, CliError> { + let pubkey = keys.public_key().to_hex(); + let body = serde_json::to_vec(&serde_json::json!({ + "relay_url": relay_url, + "nostr_pubkey": pubkey, + })) + .map_err(|e| CliError::Other(format!("assertion request serialization failed: {e}")))?; + + // A fresh proof per attempt: NIP-FA proofs are single-use. + let proof = nip98_event("POST", self.endpoint.as_str(), Some(&body))? + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?; + use base64::Engine; + let proof = base64::engine::general_purpose::STANDARD.encode(proof.as_json()); + let mut proof = reqwest::header::HeaderValue::try_from(format!("Nostr {proof}")) + .map_err(|_| CliError::Other("NIP-98 proof is not a valid header value".into()))?; + proof.set_sensitive(true); + let sent = self + .http + .post(self.endpoint.clone()) + .bearer_auth(&self.token) + .header("Nostr-Authorization", proof) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .body(body) + .send() + .await; + let resp = match sent { + Ok(resp) => resp, + Err(e) if e.is_builder() => return Err(CliError::Network(e.without_url())), + Err(e) => return Ok(Err(Transient::from(e))), + }; + let status = resp.status().as_u16(); + if status == 429 || resp.status().is_server_error() { + // NIP-FA: honor `Retry-After` (delta-seconds) on 429 and 503. + let retry_after = matches!(status, 429 | 503) + .then(|| resp.headers().get(reqwest::header::RETRY_AFTER)) + .flatten() + .and_then(|v| parse_retry_after(v.to_str().ok()?, unix_now())); + return Ok(Err(Transient { + why: format!("HTTP {status}"), + retry_after, + })); + } + let body = bounded_body(resp).await; + if status != 200 { + // The status is the answer; a lost or unreadable body only loses the code. + let code = body + .ok() + .flatten() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .map(|d| d.error); + return Err(denial(status, code.as_deref(), relay_url)); + } + match body { + Ok(body) => accept(body.as_deref(), &pubkey).map(Ok), + // A 200 whose body never arrived is a transport failure, not a rejected 200. + Err(e) => Ok(Err(Transient::from(e))), + } + } +} + +/// A mint failure worth another attempt, after `retry_after` when the +/// adapter gave one. +struct Transient { + why: String, + retry_after: Option, +} + +impl From for Transient { + fn from(e: reqwest::Error) -> Self { + Self { + why: crate::error::fmt_reqwest_error(&e.without_url()), + retry_after: None, + } + } +} + +impl std::fmt::Display for Transient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.why) + } +} + +/// Validate a 200 response (NIP-FA: a rejected 200 is a refusal). +fn accept(body: Option<&[u8]>, pubkey: &str) -> Result { + let response: AssertionResponse = body + .and_then(|b| serde_json::from_slice(b).ok()) + .ok_or_else(|| refused("unreadable assertion response"))?; + if response.nostr_pubkey != pubkey { + return Err(refused("assertion names a different key")); + } + if response.expires_at <= unix_now() { + return Err(refused("assertion already expired")); + } + match crate::cached_assertion::jws_exp(&response.assertion) { + None => return Err(refused("assertion is not a compact JWS")), + Some(exp) if exp != response.expires_at => { + return Err(refused("assertion exp does not match expires_at")); + } + Some(_) => {} + } + Ok(response.assertion) +} + +/// The NIP-98 HTTP auth event (kind:27235) for `method` + `url`: `u`, +/// `method`, a nonce (so rapid identical requests don't trip replay guards) +/// and, with a body, its `payload` hash. Mints and relay requests sign it. +pub(crate) fn nip98_event( + method: &str, + url: &str, + body: Option<&[u8]>, +) -> Result { + let tag = |parts: &[&str]| { + Tag::parse(parts.iter().copied()).map_err(|e| CliError::Other(format!("tag error: {e}"))) + }; + let mut tags = vec![ + tag(&["u", url])?, + tag(&["method", method])?, + tag(&["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + tags.push(tag(&["payload", &hex::encode(Sha256::digest(body))])?); + } + Ok(EventBuilder::new(Kind::HttpAuth, "").tags(tags)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const RELAY: &str = "https://relay.example.com/"; + + #[test] + fn canonical_relay_url_follows_nip_fa() { + for (input, expected) in [ + ("https://relay.example.com", "wss://relay.example.com"), + ("https://Relay.Example.com/", "wss://relay.example.com"), + ("https://relay.example.com:443", "wss://relay.example.com"), + ( + "https://relay.example.com:8443/x?y", + "wss://relay.example.com:8443", + ), + ("http://localhost:3000", "wss://localhost:3000"), + ("https://[2001:db8::1]:8443", "wss://[2001:db8::1]:8443"), + ] { + assert_eq!(canonical_relay_url(input).unwrap(), expected, "{input}"); + } + assert!(canonical_relay_url("not a url").is_err()); + for port_80 in [ + "wss://relay.example.com:80", + "https://relay.example.com:80/x", + "http://relay.example.com:80", + "ws://user@relay.example.com:80", + "http://[::1]:80", + "http://relay.example.com:080", + ] { + assert!( + matches!(canonical_relay_url(port_80), Err(CliError::Usage(_))), + "{port_80}" + ); + } + assert!(canonical_relay_url("http://[::80]").is_ok()); + assert!(canonical_relay_url("http://relay.example.com:8080").is_ok()); + } + + #[test] + fn retry_after_takes_seconds_or_an_http_date() { + let now = 784_111_777; // Sun, 06 Nov 1994 08:49:37 GMT + assert_eq!(parse_retry_after(" 7 ", now), Some(Duration::from_secs(7))); + assert_eq!( + parse_retry_after("Sun, 06 Nov 1994 08:50:07 GMT", now), + Some(Duration::from_secs(30)) + ); + assert_eq!( + parse_retry_after("Sun, 06 Nov 1994 08:49:00 GMT", now), + Some(Duration::ZERO) + ); + assert_eq!( + parse_retry_after("Sunday, 06-Nov-94 08:50:07 GMT", now), + Some(Duration::from_secs(30)) + ); + assert_eq!( + parse_retry_after("Sun Nov 6 08:50:07 1994", now), + Some(Duration::from_secs(30)) + ); + assert_eq!(parse_retry_after("soon", now), None); + } + + #[test] + fn adapter_base_must_be_https_or_loopback_http() { + for ok in [ + "https://adapter.example", + "https://adapter.example/base/", + "http://127.0.0.1:8080", + "http://[::1]:8080", + "http://localhost:8080", + ] { + assert!(AdapterSession::new(ok, "token", RELAY).is_ok(), "{ok}"); + } + for bad in [ + "http://adapter.example", + "ftp://adapter.example", + "https://adapter.example/?q=1", + "https://adapter.example/#f", + "https://user:pw@adapter.example", + "adapter.example", + ] { + assert!(AdapterSession::new(bad, "token", RELAY).is_err(), "{bad}"); + } + assert!(AdapterSession::new("https://adapter.example", " ", RELAY).is_err()); + assert!(AdapterSession::new("https://adapter.example", "a\nb", RELAY).is_err()); + let session = AdapterSession::new("https://adapter.example/base/", "token", RELAY).unwrap(); + assert_eq!( + session.endpoint.as_str(), + "https://adapter.example/base/v1/identity/assertions" + ); + } + + // ----------------------------------------------------------------------- + // Mint against a mock NIP-FA adapter + // ----------------------------------------------------------------------- + + use std::sync::{Arc, Mutex as StdMutex}; + + use axum::http::{HeaderMap, StatusCode}; + use axum::response::IntoResponse; + use base64::Engine; + use nostr::{JsonUtil, Keys}; + + use super::test_support::{self, count, raw_adapter}; + + const TOKEN: &str = "adapter-session-token"; + + /// What the adapter saw: request headers and the JSON body. + type Seen = Arc>>; + + /// A mock adapter answering every assertion request with `respond(seen + /// request body)`. Returns the adapter base URL and the request log. + async fn adapter(respond: F) -> (String, Seen) + where + F: Fn(&serde_json::Value) -> (StatusCode, String) + Send + Sync + 'static, + { + let seen: Seen = Arc::default(); + let log = seen.clone(); + let respond = Arc::new(respond); + let app = axum::Router::new().route( + ASSERTIONS_PATH, + axum::routing::post(move |headers: HeaderMap, body: axum::body::Bytes| { + let log = log.clone(); + let respond = respond.clone(); + async move { + let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let (status, body) = respond(&json); + log.lock().unwrap().push((headers, json)); + (status, [("content-type", "application/json")], body).into_response() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), seen) + } + + /// A realistic NIP-FI assertion for `pubkey` expiring at `expires_at`. + fn issued(pubkey: &str, expires_at: u64) -> (StatusCode, String) { + issued_as( + pubkey, + expires_at, + &test_support::assertion(pubkey, expires_at), + ) + } + + fn issued_as(pubkey: &str, expires_at: u64, assertion: &str) -> (StatusCode, String) { + ( + StatusCode::OK, + serde_json::json!({ + "assertion": assertion, + "nostr_pubkey": pubkey, + "expires_at": expires_at, + }) + .to_string(), + ) + } + + fn denied(status: StatusCode, code: &str) -> (StatusCode, String) { + ( + status, + serde_json::json!({"error": code, "message": "denied"}).to_string(), + ) + } + + #[tokio::test] + async fn mint_sends_the_nip_fa_request_and_caches_per_relay() { + let (base, seen) = + adapter(|req| issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + 300)).await; + let keys = Keys::generate(); + let credentials = keys.clone(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + + let first = session.assertion(&credentials).await.unwrap(); + assert!(first + .as_deref() + .and_then(crate::cached_assertion::jws_exp) + .is_some()); + for _ in 0..2 { + let assertion = session.assertion(&credentials).await.unwrap(); + assert_eq!(assertion, first); + } + assert_eq!(seen.lock().unwrap().len(), 1, "one mint serves the process"); + + let (headers, body) = seen.lock().unwrap()[0].clone(); + assert_eq!( + body, + serde_json::json!({ + "relay_url": "wss://relay.example.com", + "nostr_pubkey": keys.public_key().to_hex(), + }) + ); + assert_eq!(headers["authorization"], format!("Bearer {TOKEN}")); + // NIP-98 proof bound to the endpoint, method and body. + let proof = headers["nostr-authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap() + .to_owned(); + let proof = base64::engine::general_purpose::STANDARD + .decode(proof) + .unwrap(); + let proof = nostr::Event::from_json(proof).unwrap(); + proof.verify().unwrap(); + assert_eq!(proof.kind, nostr::Kind::HttpAuth); + assert_eq!(proof.pubkey, keys.public_key()); + let tag = |name: &str| { + proof + .tags + .iter() + .find(|t| t.kind().to_string() == name) + .and_then(|t| t.content()) + .map(str::to_owned) + }; + assert_eq!(tag("u").unwrap(), format!("{base}{ASSERTIONS_PATH}")); + assert_eq!(tag("method").unwrap(), "POST"); + let sent = serde_json::to_vec(&body).unwrap(); + assert_eq!( + tag("payload").unwrap(), + hex::encode(::digest(&sent)) + ); + } + + #[tokio::test] + async fn an_assertion_near_expiry_is_reminted() { + // A 2 s lifetime refreshes at half-life, so the second call re-mints. + let (base, seen) = + adapter(|req| issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + 2)).await; + let credentials = Keys::generate(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + session.assertion(&credentials).await.unwrap(); + tokio::time::sleep(Duration::from_millis(2_100)).await; + session.assertion(&credentials).await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 2); + } + + #[tokio::test] + async fn short_and_long_assertions_refresh_at_the_earlier_point() { + // 20 s refreshes at half-life (10 s); 300 s at half-life (150 s), + // earlier than 30 s before expiry. + for (lifetime, refresh) in [(20, 10), (300, 150)] { + let (base, seen) = adapter(move |req| { + issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + lifetime) + }) + .await; + let keys = Keys::generate(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + session.assertion(&keys).await.unwrap(); + crate::cached_assertion::test_clock::advance(refresh - 1); + session.assertion(&keys).await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 1, "lifetime {lifetime}"); + crate::cached_assertion::test_clock::advance(1); + session.assertion(&keys).await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 2, "lifetime {lifetime}"); + } + } + + /// NIP-FA §Denials: every status/code the adapter can answer, plus the + /// 200 responses a client must refuse. Retryable answers stay retryable + /// (`CliError::Relay` with the status) for the request's retry loop; + /// everything else is an auth error. No error ever carries the token. + #[tokio::test] + async fn denial_table() { + let other = Keys::generate().public_key().to_hex(); + type Respond = Box (StatusCode, String) + Send + Sync>; + let cases: Vec<(&str, Respond, Option, &str)> = vec![ + ( + "invalid_request", + Box::new(|_| denied(StatusCode::BAD_REQUEST, "invalid_request")), + None, + "invalid_request", + ), + ( + "session_required", + Box::new(|_| denied(StatusCode::UNAUTHORIZED, "session_required")), + None, + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ( + "session_expired", + Box::new(|_| denied(StatusCode::UNAUTHORIZED, "session_expired")), + None, + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ( + "authorization_denied", + Box::new(|_| denied(StatusCode::FORBIDDEN, "authorization_denied")), + None, + "authorization_denied", + ), + ( + "invalid_proof", + Box::new(|_| denied(StatusCode::FORBIDDEN, "invalid_proof")), + None, + "invalid_proof", + ), + ( + "binding_mismatch", + Box::new(|_| denied(StatusCode::FORBIDDEN, "binding_mismatch")), + None, + "binding_mismatch", + ), + ( + "request_too_large", + Box::new(|_| denied(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large")), + None, + "request_too_large", + ), + ( + "unknown 4xx", + Box::new(|_| (StatusCode::IM_A_TEAPOT, "{}".into())), + None, + "HTTP 418", + ), + ( + "rate_limited", + Box::new(|_| denied(StatusCode::TOO_MANY_REQUESTS, "rate_limited")), + Some(429), + "HTTP 429", + ), + ( + "internal error", + Box::new(|_| (StatusCode::INTERNAL_SERVER_ERROR, String::new())), + Some(500), + "HTTP 500", + ), + ( + "not implemented", + Box::new(|_| (StatusCode::NOT_IMPLEMENTED, "".into())), + Some(501), + "HTTP 501", + ), + ( + "issuance_unavailable", + Box::new(|_| denied(StatusCode::SERVICE_UNAVAILABLE, "issuance_unavailable")), + Some(503), + "HTTP 503", + ), + ( + "exp disagrees with expires_at", + Box::new(|pk| { + let exp = unix_now() + 300; + issued_as(pk, exp, &test_support::assertion(pk, exp + 60)) + }), + None, + "does not match expires_at", + ), + ( + "empty signature", + Box::new(|pk| { + let exp = unix_now() + 300; + let jws = test_support::assertion(pk, exp); + let unsigned = &jws[..=jws.rfind('.').unwrap()]; + issued_as(pk, exp, unsigned) + }), + None, + "compact JWS", + ), + ( + "four segments", + Box::new(|pk| { + let exp = unix_now() + 300; + issued_as( + pk, + exp, + &format!("{}.c2ln", test_support::assertion(pk, exp)), + ) + }), + None, + "compact JWS", + ), + ( + "header is not JSON", + Box::new(|pk| issued_as(pk, unix_now() + 300, "bm90LWpzb24.e30.c2ln")), + None, + "compact JWS", + ), + ( + "wrong pubkey", + Box::new(move |_| issued(&other, unix_now() + 300)), + None, + "different key", + ), + ( + "expired", + Box::new(|pk| issued(pk, unix_now() - 1)), + None, + "expired", + ), + ( + "not a compact JWS", + Box::new(|pk| issued_as(pk, unix_now() + 300, "not-a-jws")), + None, + "compact JWS", + ), + ( + "unreadable", + Box::new(|_| (StatusCode::OK, "{\"assertion\":".into())), + None, + "unreadable", + ), + ( + "oversized", + Box::new(|_| (StatusCode::OK, "x".repeat(MAX_RESPONSE_BYTES + 1))), + None, + "unreadable", + ), + ]; + for (name, respond, retry_status, expected) in cases { + let (base, seen) = + adapter(move |req| respond(req["nostr_pubkey"].as_str().unwrap_or_default())).await; + let credentials = Keys::generate(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + let err = session.assertion(&credentials).await.unwrap_err(); + let message = err.to_string(); + assert!(message.contains(expected), "{name}: {message}"); + assert!(!message.contains(TOKEN), "{name}: token leaked: {message}"); + // Transient answers are retried, then fail as a retryable + // adapter error; refusals are a single attempt. + let attempts = match (retry_status, &err) { + (Some(_), CliError::Adapter(_)) => MINT_ATTEMPTS, + (None, CliError::Auth(_)) => 1, + _ => panic!("{name}: unexpected error class {err:?}"), + }; + assert_eq!( + crate::error::is_retryable_error(&err), + retry_status.is_some(), + "{name}" + ); + assert_eq!(seen.lock().unwrap().len(), attempts, "{name}: attempts"); + // A failed mint is not cached: the next call asks again. + let _ = session.assertion(&credentials).await; + assert_eq!( + seen.lock().unwrap().len(), + 2 * attempts, + "{name}: failure not cached" + ); + } + } + + /// NIP-FA: the token and proof never follow a redirect. + #[tokio::test] + async fn a_redirect_is_refused_and_never_followed() { + let (target, target_seen) = + adapter(|req| issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + 300)).await; + let location = format!("{target}{ASSERTIONS_PATH}"); + let app = axum::Router::new().route( + ASSERTIONS_PATH, + axum::routing::post(move || { + let location = location.clone(); + async move { + ( + StatusCode::TEMPORARY_REDIRECT, + [("location", location)], + String::new(), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + let err = session.assertion(&Keys::generate()).await.unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("HTTP 307")), + "{err:?}" + ); + assert!(target_seen.lock().unwrap().is_empty(), "redirect followed"); + } + + #[tokio::test] + async fn an_unreachable_adapter_is_retried_then_a_retryable_adapter_error_without_the_token() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + let err = session.assertion(&Keys::generate()).await.unwrap_err(); + assert!( + matches!(&err, CliError::Adapter(m) if m.contains("3 attempts")), + "{err:?}" + ); + assert!(crate::error::is_retryable_error(&err)); + assert!(!format!("{err:?}").contains(TOKEN)); + } + + /// A refusal whose body is cut off is still a refusal: one attempt, not + /// retryable. A cut-off 200 is a transport failure and is retried. + #[tokio::test] + async fn a_truncated_body_keeps_the_status_meaning() { + let credentials = Keys::generate(); + let (base, seen) = raw_adapter(test_support::TRUNCATED_403).await; + let err = AdapterSession::new(&base, TOKEN, RELAY) + .unwrap() + .assertion(&credentials) + .await + .unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("HTTP 403")), + "{err:?}" + ); + assert!(!crate::error::is_retryable_error(&err)); + assert_eq!(count(&seen), 1, "a refusal is not retried"); + + let (base, seen) = raw_adapter( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 200\r\nconnection: close\r\n\r\n{\"assert", + ) + .await; + let err = AdapterSession::new(&base, TOKEN, RELAY) + .unwrap() + .assertion(&credentials) + .await + .unwrap_err(); + assert!(matches!(&err, CliError::Adapter(_)), "{err:?}"); + assert_eq!(count(&seen), MINT_ATTEMPTS); + } + + #[test] + fn debug_output_never_contains_the_token() { + let session = + AdapterSession::new("https://adapter.example", "s3cr3t-token", RELAY).unwrap(); + assert!(!format!("{session:?}").contains("s3cr3t-token")); + } +} + +/// Fixtures shared with the relay-client tests. +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// A NIP-FI human assertion shaped like a real adapter's: ES256 header + /// with `typ: nip-fi+jwt`, the NIP-FI claims, and a 64-byte signature. + /// The CLI never verifies the signature; the relay does. + pub(crate) fn assertion(pubkey: &str, exp: u64) -> String { + let segment = |v: serde_json::Value| URL_SAFE_NO_PAD.encode(v.to_string()); + format!( + "{}.{}.{}", + segment(serde_json::json!({"alg": "ES256", "typ": "nip-fi+jwt", "kid": "k1"})), + segment(serde_json::json!({ + "iss": "https://adapter.example", + "sub": "user-1", + "aud": "https://relay.example.com", + "iat": exp.saturating_sub(300), + "exp": exp, + "jti": "a1b2c3", + "nostr_pubkey": pubkey, + })), + URL_SAFE_NO_PAD.encode([7u8; 64]), + ) + } + + /// An adapter that answers every connection with the raw HTTP bytes + /// `response` and then closes it, so a short body is a truncated one. + /// Returns the base URL and the number of requests it answered. + pub(crate) async fn raw_adapter(response: &'static str) -> (String, Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let count = Arc::new(AtomicUsize::new(0)); + let seen = count.clone(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + seen.fetch_add(1, Ordering::SeqCst); + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + // Read the head and the (small) body before answering. + while let Ok(n) = socket.read(&mut buf).await { + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&request); + if let Some(head_end) = text.find("\r\n\r\n") { + let length = text[..head_end] + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + }) + .unwrap_or(0); + if request.len() >= head_end + 4 + length { + break; + } + } + } + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + (base, count) + } + + /// Raw responses for [`raw_adapter`]. + pub(crate) const HTTP_500: &str = + "HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + pub(crate) const HTTP_503: &str = + "HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + /// A 403 whose body is cut off after a few bytes. + pub(crate) const TRUNCATED_403: &str = "HTTP/1.1 403 Forbidden\r\ncontent-type: application/json\r\ncontent-length: 200\r\nconnection: close\r\n\r\n{\"error\":\"auth"; + + pub(crate) fn count(counter: &AtomicUsize) -> usize { + counter.load(Ordering::SeqCst) + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index ceea5af059..40c58992a7 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,7 +1,12 @@ pub mod agent_management; +mod cached_assertion; mod client; mod commands; +mod credentials; +#[cfg(unix)] +mod desktop_provider; mod error; +mod federated_identity; mod help_tree; mod links; mod validate; @@ -9,7 +14,6 @@ mod validate; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use client::BuzzClient; use error::CliError; -use nostr::Keys; use uuid::Uuid; /// Run the Buzz CLI from raw arguments (including `argv[0]`). @@ -125,8 +129,13 @@ Buzz CLI — interact with a Buzz relay Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] - BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] + BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [unless a Buzz desktop provides it] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] + BUZZ_FEDERATED_IDENTITY_TOKEN Your NIP-FA sign-in session token [optional] + BUZZ_FEDERATED_IDENTITY_ADAPTER NIP-FA adapter base URL [optional] + Set both for relays that enforce federated identity (NIP-FI). The token + is a human session for your own key, not an agent grant. The CLI requests + an assertion, the adapter mints it, and the CLI sends it on every request. BUZZ_OUTPUT_FORMAT Default --format; unknown values fall back to json [optional] The 'pack' subcommand runs locally and does not require a relay connection. @@ -329,11 +338,60 @@ enum Cmd { /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), + /// Credentials: pair this CLI with the running Buzz desktop + #[command(subcommand)] + Auth(AuthCmd), /// Community moderation — reports queue, bans, timeouts, audit trail #[command(subcommand)] Moderation(ModerationCmd), } +#[derive(Subcommand)] +enum AuthCmd { + /// The Buzz desktop as a credential provider (macOS and Linux) + #[command(subcommand)] + Desktop(DesktopAuthCmd), +} + +#[derive(Subcommand)] +enum DesktopAuthCmd { + /// Ask the running Buzz desktop to let this CLI act as you on --relay. + /// Approve in Buzz; the token is stored in the OS keychain. + Pair { + /// Name shown in the desktop's approval prompt + #[arg(long, default_value = "buzz CLI")] + name: String, + /// Desktop instance, when more than one is running + #[arg(long)] + instance: Option, + }, + /// Forget paired tokens (all instances unless --instance). Local only. + Unpair { + #[arg(long)] + instance: Option, + }, +} + +#[cfg(unix)] +async fn run_auth(cmd: AuthCmd, relay_url: &str) -> Result<(), CliError> { + let AuthCmd::Desktop(cmd) = cmd; + let out = match cmd { + DesktopAuthCmd::Pair { name, instance } => { + desktop_provider::pair(relay_url, &name, instance.as_deref()).await? + } + DesktopAuthCmd::Unpair { instance } => desktop_provider::unpair(instance.as_deref())?, + }; + println!("{out}"); + Ok(()) +} + +#[cfg(not(unix))] +async fn run_auth(_: AuthCmd, _: &str) -> Result<(), CliError> { + Err(CliError::Usage( + "the Buzz desktop credential provider is not available on Windows yet".into(), + )) +} + #[derive(Clone, Copy, clap::ValueEnum)] pub enum RespondToArg { #[value(name = "owner-only")] @@ -2275,7 +2333,7 @@ pub enum ModerationCmd { /// and the BIP-340 signature. Inputs that are already valid JSON — or not /// recognizable as the shorthand — are returned unchanged so the strict /// parser reports the error on the original bytes. -fn normalize_auth_tag_input(input: &str) -> String { +pub(crate) fn normalize_auth_tag_input(input: &str) -> String { let trimmed = input.trim(); if serde_json::from_str::(trimmed).is_ok() { return trimmed.to_owned(); @@ -2296,9 +2354,28 @@ fn normalize_auth_tag_input(input: &str) -> String { trimmed.to_owned() } -async fn run(cli: Cli) -> Result<(), CliError> { - let relay_url = client::normalize_relay_url(&cli.relay); +/// Binds the configured community: `auth` pairs it, anything else builds +/// the client. A desktop binds the whole community URL, trailing slash +/// included, so pairing and the credential chain take it as configured; only +/// requests use the trimmed base, which the client derives. The two steps are parameters so a test can +/// run them against a fake desktop. +pub(crate) async fn connect( + configured: &str, + command: Cmd, + pair: impl AsyncFnOnce(AuthCmd, &str) -> Result<(), CliError>, + credentials: impl AsyncFnOnce(&str) -> Result, +) -> Result, CliError> { + // Pairing runs before any credential exists. + if let Cmd::Auth(sub) = command { + pair(sub, configured).await?; + return Ok(None); + } + let credentials = credentials(configured).await?; + let client = BuzzClient::new(configured.to_owned(), credentials)?; + Ok(Some((command, client))) +} +async fn run(cli: Cli) -> Result<(), CliError> { // Pack commands are local-only — no relay connection needed. if let Cmd::Pack(ref sub) = cli.command { return match sub { @@ -2307,44 +2384,23 @@ async fn run(cli: Cli) -> Result<(), CliError> { }; } - // Auth: private key is required for all relay operations. - // The keypair IS the identity — no tokens, no other auth. - let private_key_str = cli.private_key.ok_or_else(|| { - CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into()) - })?; - let keys = Keys::parse(&private_key_str) - .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; - - // NIP-OA: parse and verify the auth tag if provided. - // - // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw - // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input - // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict - // JSON; all validation and signature verification happen on the strict - // path below, unchanged. - let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref input) if !input.is_empty() => { - let json = normalize_auth_tag_input(input); - let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) - .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { - CliError::Auth(format!( - "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", - keys.public_key().to_hex() - )) - })?; - // Canonical wire form derives from the parsed-and-verified tag - // (same shape as buzz-acp's RestClient), never from raw input. - let canonical = serde_json::to_string(tag.as_slice()) - .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; - (Some(tag), Some(canonical)) - } - _ => (None, None), + // Credential chain: the first link that applies to this relay is the + // identity (see `credentials`). + let settings = credentials::Settings { + private_key: cli.private_key, + auth_tag: cli.auth_tag, + ..Default::default() + } + .with_env_evidence(); + let Some((command, client)) = connect(&cli.relay, cli.command, run_auth, async |relay| { + credentials::resolve(settings, relay).await + }) + .await? + else { + return Ok(()); }; - let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; - - match cli.command { + match command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, @@ -2368,7 +2424,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, - Cmd::Pack(_) => unreachable!("handled above"), + Cmd::Pack(_) | Cmd::Auth(_) => unreachable!("handled above"), } } @@ -2561,6 +2617,7 @@ mod tests { fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ "agents", + "auth", "canvas", "channels", "dms", diff --git a/crates/buzz-core/src/engram.rs b/crates/buzz-core/src/engram.rs index 7f38347f80..f2f30c3b6e 100644 --- a/crates/buzz-core/src/engram.rs +++ b/crates/buzz-core/src/engram.rs @@ -438,6 +438,27 @@ pub fn build_event( body: &Body, created_at: u64, ) -> Result { + let plaintext = encode_body(body)?; + let k_c = conversation_key(agent_keys.secret_key(), owner_pubkey); + let ciphertext = nip44::encrypt( + agent_keys.secret_key(), + owner_pubkey, + &plaintext, + Version::V2, + ) + .map_err(|e| EngramError::Encrypt(e.to_string()))?; + let d = d_tag(&k_c, body.slug()); + event_builder(owner_pubkey, &d, ciphertext, created_at)? + .sign_with_keys(agent_keys) + .map_err(|e| EngramError::Sign(e.to_string())) +} + +/// The NIP-44 plaintext for `body`. Returns `BodyTooLarge` if it exceeds +/// 65,535 bytes. +/// +/// Together with [`event_builder`], this lets a caller whose key lives behind +/// a signer build an engram from separate encrypt, `d`-tag and sign steps. +pub fn encode_body(body: &Body) -> Result { let plaintext = body.to_json_bytes(); if plaintext.len() > NIP44_PLAINTEXT_MAX { return Err(EngramError::BodyTooLarge(plaintext.len())); @@ -446,30 +467,28 @@ pub fn build_event( // this is always Ok. We still verify rather than `.expect()` so a future // change to the serializer can't silently introduce a panic on the hot // path. - let plaintext_str = std::str::from_utf8(&plaintext) - .map_err(|e| EngramError::Encrypt(format!("body JSON not UTF-8: {e}")))?; + String::from_utf8(plaintext) + .map_err(|e| EngramError::Encrypt(format!("body JSON not UTF-8: {e}"))) +} - let k_c = conversation_key(agent_keys.secret_key(), owner_pubkey); - let ciphertext = nip44::encrypt( - agent_keys.secret_key(), - owner_pubkey, - plaintext_str, - Version::V2, - ) - .map_err(|e| EngramError::Encrypt(e.to_string()))?; - - let d = d_tag(&k_c, body.slug()); +/// The unsigned `kind:30174` event for an already-encrypted body: `d` is the +/// slug's [`d_tag`], `ciphertext` the NIP-44 payload to the owner. +pub fn event_builder( + owner_pubkey: &PublicKey, + d: &str, + ciphertext: String, + created_at: u64, +) -> Result { let tags = vec![ - Tag::parse(["d", &d]).map_err(|e| EngramError::Encrypt(e.to_string()))?, + Tag::parse(["d", d]).map_err(|e| EngramError::Encrypt(e.to_string()))?, Tag::parse(["p", &owner_pubkey.to_hex()]) .map_err(|e| EngramError::Encrypt(e.to_string()))?, ]; - - EventBuilder::new(Kind::Custom(KIND_AGENT_ENGRAM as u16), ciphertext) - .tags(tags) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(agent_keys) - .map_err(|e| EngramError::Sign(e.to_string())) + Ok( + EventBuilder::new(Kind::Custom(KIND_AGENT_ENGRAM as u16), ciphertext) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)), + ) } /// Validate an event against *Head selection* rules (1) and (5) and return @@ -492,6 +511,40 @@ pub fn validate_and_decrypt( my_seckey: &SecretKey, their_pubkey: &PublicKey, ) -> Result { + let d_value = validate_envelope(event, expected_agent, expected_owner)?; + + // Decrypt. `K_c` is symmetric per NIP-44, so the caller's `(my_seckey, + // their_pubkey)` pair yields the same conversation key regardless of + // whether the caller is the agent or the owner. + let plaintext = nip44::decrypt(my_seckey, their_pubkey, &event.content) + .map_err(|_| EngramError::Decrypt)?; + let body = Body::from_json_bytes(plaintext.as_bytes())?; + + // Rule (4): body slug re-derives to the event's d tag. + let k_c = conversation_key(my_seckey, their_pubkey); + check_d_tag(&d_value, &d_tag(&k_c, body.slug()))?; + Ok(body) +} + +/// Rule (4): the decrypted body's slug must re-derive (`derived`) to the +/// event's `d` tag (`d_value`, from [`validate_envelope`]). +pub fn check_d_tag(d_value: &str, derived: &str) -> Result<(), EngramError> { + if derived != d_value { + return Err(EngramError::InvalidEnvelope( + "body slug does not re-derive to d tag".into(), + )); + } + Ok(()) +} + +/// The envelope half of [`validate_and_decrypt`]: kind, author, exactly one +/// canonical `d` tag and exactly one `p` tag naming the owner. Returns the +/// `d` value for [`check_d_tag`] once the content is decrypted. +pub fn validate_envelope( + event: &Event, + expected_agent: &PublicKey, + expected_owner: &PublicKey, +) -> Result { if event.kind.as_u16() as u32 != KIND_AGENT_ENGRAM { return Err(EngramError::InvalidEnvelope(format!( "wrong kind: {}", @@ -537,23 +590,7 @@ pub fn validate_and_decrypt( "p tag != expected_owner".into(), )); } - - // Decrypt. `K_c` is symmetric per NIP-44, so the caller's `(my_seckey, - // their_pubkey)` pair yields the same conversation key regardless of - // whether the caller is the agent or the owner. - let plaintext = nip44::decrypt(my_seckey, their_pubkey, &event.content) - .map_err(|_| EngramError::Decrypt)?; - let body = Body::from_json_bytes(plaintext.as_bytes())?; - - // Rule (4): body slug re-derives to the event's d tag. - let k_c = conversation_key(my_seckey, their_pubkey); - let derived = d_tag(&k_c, body.slug()); - if derived != d_value { - return Err(EngramError::InvalidEnvelope( - "body slug does not re-derive to d tag".into(), - )); - } - Ok(body) + Ok(d_value) } /// Pick the head from a set of events targeting the same slug — greatest diff --git a/crates/buzz-test-client/src/lib.rs b/crates/buzz-test-client/src/lib.rs index c30fa0f8aa..25e47dbe4c 100644 --- a/crates/buzz-test-client/src/lib.rs +++ b/crates/buzz-test-client/src/lib.rs @@ -71,7 +71,10 @@ impl From for TestClientError { WsClientError::EventBuilder(s) => TestClientError::EventBuilder(s), WsClientError::Url(s) => TestClientError::Url(s), WsClientError::Timeout => TestClientError::Timeout, - WsClientError::ConnectionClosed => TestClientError::ConnectionClosed, + // Relay e2e tests treat a policy close as a close. + WsClientError::ConnectionClosed | WsClientError::PolicyClose(_) => { + TestClientError::ConnectionClosed + } WsClientError::UnexpectedMessage(s) => TestClientError::UnexpectedMessage(s), WsClientError::AuthFailed(s) => TestClientError::AuthFailed(s), WsClientError::EventRejected(s) => TestClientError::EventRejected(s), diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index c2b1cc8772..e69941293a 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; use futures_util::{SinkExt, StreamExt}; -use nostr::{Event, Keys, Tag}; +use nostr::{Event, NostrSigner, Tag}; use serde_json::{json, Value}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -26,6 +26,20 @@ pub const AUTH_OK_TIMEOUT_SECS: u64 = 20; /// Seconds to wait for the relay's OK response to a published event. pub const PUBLISH_OK_TIMEOUT_SECS: u64 = 30; +/// Cap on a graceful close. +pub const CLOSE_TIMEOUT_SECS: u64 = 5; + +/// Run one network phase, writes included, within its read `wait` plus a +/// second, so a read that times out reports its own error first. +async fn phase( + wait: Duration, + io: impl std::future::Future>, +) -> Result { + tokio::time::timeout(wait + Duration::from_secs(1), io) + .await + .map_err(|_| WsClientError::Timeout)? +} + /// A NIP-42-capable WebSocket connection to a Nostr relay. pub struct NostrWsConnection { ws: WsStream, @@ -37,29 +51,29 @@ pub struct NostrWsConnection { } impl NostrWsConnection { - /// Connects to the relay at `url` and performs NIP-42 authentication with `keys`. + /// Connects to the relay at `url` and performs NIP-42 authentication with `signer`. /// /// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event. - pub async fn connect_authenticated( + pub async fn connect_authenticated( url: &str, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, ) -> Result { - Self::connect_authenticated_with_headers(url, keys, auth_tag, HeaderMap::new()).await + Self::connect_authenticated_with_headers(url, signer, auth_tag, HeaderMap::new()).await } /// [`Self::connect_authenticated`], sending `headers` on the WebSocket upgrade request. /// /// Use this for credentials the relay checks at upgrade time, such as a NIP-FI /// `Nostr-Federated-Identity` assertion that must pair with the NIP-42 AUTH key. - pub async fn connect_authenticated_with_headers( + pub async fn connect_authenticated_with_headers( url: &str, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, headers: HeaderMap, ) -> Result { let mut conn = Self::connect_with_headers(url, headers).await?; - conn.authenticate(keys, auth_tag).await?; + conn.authenticate(signer, auth_tag).await?; Ok(conn) } @@ -125,26 +139,29 @@ impl NostrWsConnection { } } - /// Performs NIP-42 authentication using `keys` against the connected relay. + /// Performs NIP-42 authentication using `signer` against the connected relay. /// /// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event. - pub async fn authenticate( + pub async fn authenticate( &mut self, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, ) -> Result<(), WsClientError> { - let challenge = self - .wait_for_auth_challenge(Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS)) - .await?; + // Each network phase is capped as a whole, writes (and Pongs sent + // while reading) included. Signing between them is the signer's to + // bound: a remote signer may hold it for a person's decision. + let wait = Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS); + let challenge = phase(wait, self.wait_for_auth_challenge(wait)).await?; - let auth_event = build_auth_event(&challenge, &self.relay_url, keys, auth_tag)?; + let auth_event = build_auth_event(&challenge, &self.relay_url, signer, auth_tag).await?; let event_id = auth_event.id.to_hex(); - self.send_raw(&json!(["AUTH", auth_event])).await?; - - let ok = self - .wait_for_ok(&event_id, Duration::from_secs(AUTH_OK_TIMEOUT_SECS)) - .await?; + let wait = Duration::from_secs(AUTH_OK_TIMEOUT_SECS); + let ok = phase(wait, async { + self.send_raw(&json!(["AUTH", auth_event])).await?; + self.wait_for_ok(&event_id, wait).await + }) + .await?; if !ok.accepted { return Err(WsClientError::AuthFailed(ok.message)); } @@ -156,9 +173,12 @@ impl NostrWsConnection { /// Sends a signed event to the relay and waits for the OK response. pub async fn send_event(&mut self, event: Event) -> Result { let event_id = event.id.to_hex(); - self.send_raw(&json!(["EVENT", event])).await?; - self.wait_for_ok(&event_id, Duration::from_secs(PUBLISH_OK_TIMEOUT_SECS)) - .await + let wait = Duration::from_secs(PUBLISH_OK_TIMEOUT_SECS); + phase(wait, async { + self.send_raw(&json!(["EVENT", event])).await?; + self.wait_for_ok(&event_id, wait).await + }) + .await } /// Receives the next relay message, waiting up to `timeout_dur`. @@ -185,9 +205,11 @@ impl NostrWsConnection { Ok((Instant::now(), msg)) } - /// Closes the WebSocket connection gracefully. + /// Closes the WebSocket connection gracefully, within [`CLOSE_TIMEOUT_SECS`]. pub async fn disconnect(mut self) -> Result<(), WsClientError> { - self.ws.close(None).await?; + tokio::time::timeout(Duration::from_secs(CLOSE_TIMEOUT_SECS), self.ws.close(None)) + .await + .map_err(|_| WsClientError::Timeout)??; Ok(()) } @@ -218,7 +240,7 @@ impl NostrWsConnection { Message::Ping(data) => { self.ws.send(Message::Pong(data)).await?; } - Message::Close(_) => return Err(WsClientError::ConnectionClosed), + Message::Close(frame) => return Err(closed(frame)), _ => {} } } @@ -278,7 +300,7 @@ impl NostrWsConnection { Message::Ping(data) => { self.ws.send(Message::Pong(data)).await?; } - Message::Close(_) => return Err(WsClientError::ConnectionClosed), + Message::Close(frame) => return Err(closed(frame)), _ => {} } } @@ -332,7 +354,7 @@ impl NostrWsConnection { Message::Ping(data) => { self.ws.send(Message::Pong(data)).await?; } - Message::Close(_) => return Err(WsClientError::ConnectionClosed), + Message::Close(frame) => return Err(closed(frame)), _ => {} } } @@ -341,19 +363,24 @@ impl NostrWsConnection { /// One-shot helper: connect, authenticate, send one event, disconnect. /// -/// Establishes a fresh WebSocket connection, completes NIP-42 authentication, -/// publishes `event`, waits for the relay's OK response, then closes the -/// connection. The entire operation is bounded by `timeout_secs`. -pub async fn publish_event( +/// Establishes a fresh WebSocket connection carrying `headers` on the upgrade +/// (e.g. a NIP-FI `Nostr-Federated-Identity` assertion), completes NIP-42 +/// authentication with `signer`, publishes `event`, waits for the relay's OK +/// response, then closes the connection. The entire operation is bounded by +/// `timeout_secs`. +pub async fn publish_event( relay_url: &str, event: Event, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, + headers: HeaderMap, timeout_secs: u64, ) -> Result { let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async { - let mut conn = NostrWsConnection::connect(relay_url).await?; - conn.authenticate(keys, auth_tag).await?; + let mut conn = NostrWsConnection::connect_authenticated_with_headers( + relay_url, signer, auth_tag, headers, + ) + .await?; let ok = conn.send_event(event).await?; let _ = conn.disconnect().await; Ok::<_, WsClientError>(ok) @@ -363,6 +390,18 @@ pub async fn publish_event( result } +/// The error for a close frame: a policy close keeps its reason, since a +/// relay denial is not an ordinary connection loss. +fn closed(frame: Option) -> WsClientError { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + match frame { + Some(frame) if frame.code == CloseCode::Policy => { + WsClientError::PolicyClose(frame.reason.to_string()) + } + _ => WsClientError::ConnectionClosed, + } +} + #[cfg(test)] mod tests { use super::*; @@ -420,4 +459,42 @@ mod tests { Some(HeaderValue::from_static("Bearer header.payload.signature")) ); } + + /// A relay that answers NIP-42 AUTH by closing with `code`, as the relay + /// does on a NIP-FI key mismatch (1008). + async fn close_after_auth(code: u16) -> WsClientError { + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await + .unwrap(); + let _auth = ws.next().await; + ws.send(Message::Close(Some(CloseFrame { + code: CloseCode::from(code), + reason: "auth-required: key mismatch".into(), + }))) + .await + .unwrap(); + }); + NostrWsConnection::connect_authenticated(&url, &nostr::Keys::generate(), None) + .await + .err() + .unwrap() + } + + #[tokio::test] + async fn a_policy_close_keeps_its_reason_and_other_closes_do_not() { + let err = close_after_auth(1008).await; + assert!( + matches!(&err, WsClientError::PolicyClose(r) if r.contains("key mismatch")), + "{err:?}" + ); + let err = close_after_auth(1000).await; + assert!(matches!(err, WsClientError::ConnectionClosed), "{err:?}"); + } } diff --git a/crates/buzz-ws-client/src/error.rs b/crates/buzz-ws-client/src/error.rs index c8781bb591..429d50b4eb 100644 --- a/crates/buzz-ws-client/src/error.rs +++ b/crates/buzz-ws-client/src/error.rs @@ -27,6 +27,11 @@ pub enum WsClientError { #[error("Connection closed unexpectedly")] ConnectionClosed, + /// The relay closed the connection with a policy close (1008), as a + /// NIP-FI denial does; the string is the close reason. + #[error("Connection refused by relay policy: {0}")] + PolicyClose(String), + /// The relay sent a message that was not expected at this point. #[error("Unexpected relay message: {0}")] UnexpectedMessage(String), @@ -49,3 +54,20 @@ impl From for WsClientError { WsClientError::EventBuilder(e.to_string()) } } + +impl WsClientError { + /// The HTTP status and body when the relay refused the WebSocket upgrade + /// with an HTTP response (e.g. a NIP-FI `401`/`403` decided at upgrade time). + pub fn upgrade_rejection(&self) -> Option<(u16, String)> { + match self { + WsClientError::WebSocket(tokio_tungstenite::tungstenite::Error::Http(response)) => { + let body = response.body().as_deref().unwrap_or_default(); + Some(( + response.status().as_u16(), + String::from_utf8_lossy(body).into_owned(), + )) + } + _ => None, + } + } +} diff --git a/crates/buzz-ws-client/src/message.rs b/crates/buzz-ws-client/src/message.rs index 3c646bc8ab..fffc63e48a 100644 --- a/crates/buzz-ws-client/src/message.rs +++ b/crates/buzz-ws-client/src/message.rs @@ -1,4 +1,4 @@ -use nostr::{Event, EventBuilder, Keys, RelayUrl, Tag}; +use nostr::{Event, EventBuilder, NostrSigner, RelayUrl, Tag}; use serde_json::Value; use crate::error::WsClientError; @@ -166,15 +166,15 @@ pub fn parse_relay_message(text: &str) -> Result { } } -/// Builds a NIP-42 AUTH event, optionally injecting a NIP-OA auth tag. +/// Builds a NIP-42 AUTH event signed by `signer`, optionally injecting a NIP-OA auth tag. /// /// The `auth_tag` parameter allows callers to attach a workspace-scoped /// authorization tag (e.g. `["auth", ""]`) alongside the standard /// relay and challenge tags required by NIP-42. -pub fn build_auth_event( +pub async fn build_auth_event( challenge: &str, relay_url: &str, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, ) -> Result { let url = RelayUrl::parse(relay_url).map_err(|e| WsClientError::Url(e.to_string()))?; @@ -185,6 +185,7 @@ pub fn build_auth_event( builder }; builder - .sign_with_keys(keys) + .sign(signer) + .await .map_err(|e| WsClientError::EventBuilder(e.to_string())) } diff --git a/docs/local-credential-provider.md b/docs/local-credential-provider.md new file mode 100644 index 0000000000..2124572c62 --- /dev/null +++ b/docs/local-credential-provider.md @@ -0,0 +1,424 @@ +# Local credential provider + +`draft` + +This document defines how a local process, such as the `buzz` CLI, asks a running +Buzz Desktop to act as one Nostr identity: to give its public key, sign events, +encrypt and decrypt with NIP-44, return NIP-FI evidence for a relay, and return +its NIP-OA `auth` tag. The private key and the sign-in session stay in the +desktop. + +The CLI is the reference client and lives in this repository +(`crates/buzz-cli`). Buzz Desktop ([block/buzz-app](https://github.com/block/buzz-app)) +is the reference provider. Any client or provider that follows this document +interoperates with both. + +## Goals + +- **One identity per caller.** A caller holds a token for exactly one identity. + The desktop never decides on the caller's behalf which identity to use. +- **An agent's process gets only that agent's token.** The desktop gives a + process it launches for an agent a token for that agent alone. Acting as the + owner takes the owner's own approval, given when pairing. +- **The desktop sees what it signs.** The caller sends the complete unsigned + event; the desktop classifies it into an operation and applies a policy before + signing. There is no blind signing of opaque bytes. +- **Old and new versions interoperate.** The bundled CLI follows the desktop's + pinned revision of this repository, but a user can also run a different CLI. + An older client must work against a newer desktop, and the reverse. + +## Non-goals + +- Remote signing. The transport is loopback only. [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) + covers remote signers. +- Windows. Version 1 has no Windows transport. See [Platforms](#platforms). +- Deciding which approval UI the desktop shows. This document fixes what needs + approval, not how it is presented. + +## Terms + +- **Provider**: the desktop process that holds the key and answers requests. +- **Caller**: the local process that sends requests. +- **Token**: an opaque bearer secret that names one identity and the relays it + may act on. Tokens carry at least 128 bits of entropy. +- **Instance**: one installed desktop build, named by a stable identifier such + as its bundle identifier. Production and staging builds are separate + instances and issue separate tokens. +- **Bound relay**: a community relay URL a token may act on. Relay URLs are + compared whole after URL normalization: `http`/`https` read as `ws`/`wss`, + lowercase host, no default port, `/` for an empty path. The path and query + count, because one host can serve several communities. A caller sends the + bound URL itself in `pair` and `federated_identity`; NIP-FA audience + canonicalization is the provider's job. + +## Discovery + +A caller looks for a provider in this order. The first source that yields a +token and a reachable endpoint is used. + +An inherited token decides the identity. A caller that has one MUST use only +it: when it is revoked, does not apply to the relay or fails, the caller fails. +It MUST NOT go on to a paired token, which acts as the human. This keeps a +well-behaved caller from acting as the owner by accident. It is not a boundary +against an agent that can run its own commands. See +[Security considerations](#security-considerations). + +### 1. Inherited token + +When the desktop launches a process for an agent, through any agent harness, it +sets: + +```text +BUZZ_CREDENTIAL_PROVIDER={"url":"http://127.0.0.1:/","token":""} +``` + +- The token's identity is that agent. It is never the owner. +- The token MUST reach only processes that act for that agent. A harness that + hosts several agents in one process MUST give each agent's tool processes + their own agent's token, and MUST NOT expose one agent's token to another + agent's tools. +- The desktop SHOULD revoke the token when the process it launched exits, and + MUST revoke it on the triggers in [Revocation](#revocation). +- A caller MUST reject a `url` whose scheme is not `http`, whose host is not + exactly `127.0.0.1`, or that carries user information. +- A caller also runs the same-user check below for an inherited token: if the + desktop exits while the agent keeps running, another user may bind the + freed port. + +The desktop MUST set this variable only for processes it launches for an agent, +and only in their own environment. It MUST NOT write the token to disk. + +### 2. Paired token + +A human's own terminal, or a service the human runs such as slack2buzz, gets a +token by [pairing](#pairing). The caller stores the token in the OS keychain +(Keychain on macOS, the Secret Service on Linux), never in a file: + +- The CLI keeps one keychain entry, service `buzz-cli` and account + `desktop-pairings`, that maps each instance to its token, identity and bound + relays. Each change reloads the entry under a short cross-process lock and + touches only its own instance; the lock is never held across an approval + wait. An entry that cannot be read is refused, never overwritten. +- A paired caller only tries a paired token when no inherited token is set. + A desktop that answers `denied` or `unavailable` fails the command; only an + unreachable or unconfirmed advertisement is skipped. +- When no keychain is reachable, pairing MUST fail with an error that says so. + A caller MUST NOT fall back to writing the token to disk. +- A paired token survives a desktop restart. The desktop keeps it until it is + revoked or expires (see [Revocation](#revocation)), so a caller pairs once. + +The token does not say where the desktop is listening, because the port changes +on every launch. The desktop advertises its current endpoint, which holds no +secret, in: + +```text +~/.buzz-foundation/credential-providers/.json +{"instance":"","url":"http://127.0.0.1:/"} +``` + +- The desktop writes the file whole under a dot name and renames it into place, + and removes it when it quits. +- The directory MUST be mode `0700` and owned by the user, and each file MUST be + owned by the user with no group or other permission bits. A caller MUST + ignore any file that fails these checks, so another user cannot plant one. +- A caller uses the advertisement whose `instance` matches its stored token. +- **Same-user check.** An advertised port may now belong to another user's + process, for example after the desktop crashed. Before it sends a token to an + advertised endpoint, a caller MUST connect and confirm, by numeric user ID, + that a process of the same user holds the server side of that connection + (`lsof` on macOS, `/proc/net/tcp` on Linux). If it cannot confirm this within + about one second, it MUST send nothing on that connection. This is the check + `buzz-agent-command` performs for advertised agent sessions + (block/buzz-app `crates/agent-command`). +- A refused connection means the desktop is not running. The caller MAY delete + that advertisement. + +### 3. No provider + +Without an inherited token, if no paired token yields a provider, the desktop +link in the caller's credential chain does not apply, and the caller moves on +to its next source. A caller with an inherited token never falls through to a +paired token: when the inherited token cannot serve, the caller fails. + +## Transport + +- HTTP/1.1 `POST` to the discovered `url`, on `127.0.0.1` only. The provider + MUST bind only to `127.0.0.1`. +- `Content-Type: application/json`. +- `Authorization: Bearer ` on every request except `pair`. +- `Buzz-Credential-Provider: ` on every request. See + [Versions](#versions). +- One request per connection is sufficient. Callers MUST NOT rely on keep-alive. + +### Envelope + +Requests and responses use the NIP-46 message shape, unencrypted because they +never leave the machine. That lets both sides reuse existing NIP-46 types. + +```json +{"id":"","method":"","params":["", "..."]} +``` + +```json +{"id":"","result":""} +{"id":"","result":"","error":"","message":"","retry_after":30} +``` + +- `params` and `result` are strings, as in NIP-46. Structured values are + JSON-encoded into the string. +- On failure, `error` is a machine code from [Errors](#errors). `message` is + optional text for a person. `retry_after` is present only for `rate_limited`. +- A response is at most 12,352 KiB (status line, headers and body); larger ones + are refused. +- Both sides MUST ignore fields they do not recognize. + +## Methods + +### NIP-46 methods + +These have the same parameters and results as in NIP-46. + +| Method | Params | Result | +|---|---|---| +| `get_public_key` | `[]` | Hex public key of the token's identity | +| `sign_event` | `[]` | Signed event JSON | +| `nip44_encrypt` | `[, ]` | NIP-44 payload | +| `nip44_decrypt` | `[<peer pubkey hex>, <payload>]` | Plaintext | + +For `sign_event`, the unsigned event carries `kind`, `tags`, `content` and +`created_at`. If it carries `pubkey`, the value MUST equal the token's identity, +or the provider MUST answer `denied`. The provider sets `pubkey`, computes `id` +and signs. `sign_event` is subject to the [operation policy](#operation-policy). + +### Buzz methods + +| Method | Params | Result | +|---|---|---| +| `describe` | `[]` | JSON: identity, role, bound relays and methods | +| `federated_identity` | `[<relay URL>]` | NIP-FI assertion, or empty string | +| `auth_tag` | `[]` | JSON-encoded NIP-OA `auth` tag, or empty string | +| `engram_d_tag` | `[<peer pubkey hex>, <slug>]` | NIP-AE `d` tag for that slug | +| `pair` | `[<client name>, <relay URL>]` | JSON: token and identity | + +`describe` returns: + +```json +{ + "instance": "<instance>", + "pubkey": "<hex>", + "role": "agent", + "owner_pubkey": "<hex>", + "relays": ["wss://community.example"], + "methods": ["get_public_key", "sign_event", "..."] +} +``` + +- `role` is `agent` or `owner`. `owner_pubkey` is present only for `agent`. +- A caller MUST NOT use the provider for a relay that is not in `relays`. It + treats the provider as not applicable to that relay: with a paired token it + moves on to its next source; with an inherited token it fails (see + [Discovery](#discovery)). + +`federated_identity` returns a current [NIP-FI](nips/NIP-FI.md) assertion +whose `nostr_pubkey` is the token's identity and whose audience is the given +relay. It returns an empty string when the relay does not advertise NIP-FI or +the desktop has no enterprise login for it, so a caller sends no evidence on +such relays. It answers `denied` for a relay that is not bound to the token, +and `unavailable` when the relay requires NIP-FI but the desktop's session for +it has lapsed. A caller SHOULD cache the assertion until shortly before its +`exp` and MUST NOT write it to disk. + +`auth_tag` returns the agent's NIP-OA `auth` tag as a JSON array, for example +`["auth","<owner>","<conditions>","<sig>"]`, or an empty string for an `owner` +token. + +`engram_d_tag` returns the 64-hex `d` tag that [NIP-AE](nips/NIP-AE.md#addressing) +derives from the identity's NIP-44 conversation key with `peer` and the slug. +The provider MUST answer `invalid_request` for a slug outside NIP-AE's grammar, +so the method is not a general keyed-hash oracle. Engram content uses +`nip44_encrypt` and `nip44_decrypt` with the same peer, so no method exposes the +conversation key itself. + +The provider never returns the private key or a NIP-44 conversation key. A +caller feature that needs either one, rather than an operation built on it, +needs an explicit key. + +`pair` is described under [Pairing](#pairing). + +## Pairing + +`pair` is the only method accepted without a token. The provider MUST: + +1. Show the human an approval that names the client and the relay, and the + calling process when the provider can identify it. +2. Hold the request open until the human decides, for at most 120 seconds. +3. On approval, answer with + `{"token":"<token>","instance":"<instance>","pubkey":"<hex>","relays":["<relay>"]}`. + The identity is the human's own key for that relay. +4. On refusal, answer `denied`. On timeout, answer `approval_required`. + +The provider MUST rate-limit `pair` so that a local process cannot flood the +human with approvals. A caller MUST treat a new pairing for an instance as +replacing its old token. + +## Operation policy + +The provider classifies each `sign_event` request before signing. Callers MUST +NOT rely on the policy for safety; it exists so that a token holder, such as a +prompt-injected agent, cannot do more than the identity's ordinary work without +a person noticing. + +### Auth proofs + +These kinds are accepted only when bound to one of the token's relays, and only +when fresh: `created_at` within 60 seconds of the provider's clock. + +| Kind | Requirement | +|---|---| +| `22242` NIP-42 AUTH | Exactly one `relay` tag, equal to a bound relay after URL normalization, and exactly one `challenge` tag | +| `27235` NIP-98 HTTP auth | Exactly one `u` tag whose origin is a bound relay's origin (`wss`→`https`, `ws`→`http`), and exactly one `method` tag | +| `24242` Blossom auth | Exactly one `server` tag equal to a bound relay's host, a `t` tag of `upload` or `get`, and an `expiration` no later than `created_at + 60` | + +A NIP-98 event for any other origin is denied. This rules out using the desktop +to sign in to other services, including a NIP-FA adapter: a caller using the +provider gets NIP-FI evidence from `federated_identity` and never mints its own. + +### Ordinary content + +Kinds an identity uses for everyday work are signed without a prompt: messages +and their edits, reactions, threads and forum posts, typing and presence, read +state and user status, the identity's own lists (mute, pin, bookmark, emoji, +follow sets), canvases, artifacts, git patches, issues and status events, DM +open and hide, archive requests, workflow triggers, jobs, agent memory +(`30174`) and observer frames. The provider keeps the exact table, keyed by the +constants in `crates/buzz-core/src/kind.rs`. + +### Sensitive operations + +These kinds change who the identity is, what it controls or what others see +about it. For each request the provider MUST either get a person's approval, +holding the request open for at most 120 seconds, or refuse it with `denied`. +If it asks and gets no decision, it answers `approval_required`; on refusal, +`denied`. + +| Category | Kinds | +|---|---| +| Profile and relays | `0` profile, `10002` relay list, `10100` agent profile | +| Social graph | `3` contact list | +| Deletion | `5` deletion, `9005` group delete event, `45011` artifact removal | +| Membership and admin | `9000`–`9009` group admin, `9021`/`9022` join and leave, `28936` NIP-43 leave, `41011` DM add member | +| Moderation | `9040`–`9044` | +| Agent and team management | `30175` persona, `30176` team, `30177` managed agent, `30179` private managed agent | +| Workflow authority | `30620` workflow definition, `46030`/`46031` approval grant and deny | +| Repositories and projects | `30617` repository announcement, `30618` repository state, `30621` project | + +The provider MAY remember an approval for a short, visible period, such as +repeated deletions in one session, provided the human can see and revoke it. + +### Unknown kinds + +Any kind not in the provider's table is denied, with a `message` naming the +kind. A caller that needs it can use an explicit key instead. Adding a kind to +the table is an additive provider change and does not change the version. + +### Encryption + +`nip44_encrypt` and `nip44_decrypt` act only as the token's identity, so an +agent token can read only what was encrypted to the agent. They are subject to +the token's rate limits. The provider MAY require approval for decryption with a +peer other than the identity itself on an `owner` token. + +## Limits + +- The provider SHOULD rate-limit each token per method and answer + `rate_limited` with `retry_after` in seconds. +- Tokens SHOULD expire. An inherited token lives no longer than the process it + was issued to. A paired token SHOULD expire after a period of disuse that the + human can see. + +## Revocation + +The provider MUST revoke tokens when: + +- the human revokes the caller in the desktop, +- the owner signs out or the desktop's account changes, which revokes every + token, +- an agent is deleted, which revokes that agent's tokens, or +- a token expires. + +A revoked, expired or unknown token gets `revoked`. A caller holding a revoked +paired token SHOULD delete it and tell the human to pair again. + +## Errors + +| Code | HTTP | Meaning | Caller behavior | +|---|---|---|---| +| `approval_required` | 403 | A person must approve and did not decide in time | Tell the human to approve in Buzz, then retry | +| `denied` | 403 | Policy or the human refused | Fail; do not retry | +| `rate_limited` | 429 | Too many requests | Wait `retry_after` seconds | +| `revoked` | 401 | Token revoked, expired or unknown | Drop a paired token and try the next paired one; with an inherited token, fail (see [Discovery](#discovery)) | +| `unavailable` | 503 | The desktop cannot act now, for example signed out or its sign-in session lapsed | Fail with the message | +| `unsupported_version` | 400 | See [Versions](#versions) | Retry with a common version | +| `unknown_method` | 400 | The provider does not implement the method | Degrade (see below) | +| `invalid_request` | 400 | Malformed envelope or params. For `nip44_decrypt`, also a payload that is not valid ciphertext for this identity | Fail; for `nip44_decrypt`, treat the payload as invalid (NIP-AE filters it) | + +Callers MUST decide on `error`, not on the HTTP status, and MUST treat an +unrecognized code as `denied`. + +`sign_event`, `nip44_decrypt` and `pair` can be held for up to 120 seconds, so +callers MUST allow at least 150 seconds for them. Other methods SHOULD answer +within a few seconds. + +## Versions + +The protocol version is a positive integer. This document defines version `1`. + +- A caller sends the highest version it supports in `Buzz-Credential-Provider`. +- A provider that does not support that version answers `unsupported_version` + with a `versions` field listing the versions it supports, for example + `"versions":[1,2]`. The caller retries with the highest version both support, + or treats the provider as not applicable if there is none. +- A provider MUST keep supporting every version that a CLI it bundles, or a CLI + released in the previous six months, speaks. + +Additive changes do not change the version: new methods, new optional fields, +new error codes (treated as `denied` by older callers), and new kinds in the +policy table. Removing or changing the meaning of a method, field or code does. + +When a provider answers `unknown_method`, a caller degrades rather than fails +where it can: no `auth_tag` means the caller sends no `auth` tag, and no +`federated_identity` means it sends no NIP-FI evidence. + +## Platforms + +Version 1 supports macOS and Linux. On Windows the desktop MUST NOT set +`BUZZ_CREDENTIAL_PROVIDER` or write advertisements, and callers MUST skip the +desktop link, because loopback has no same-user check there. A named pipe with +an access control list is the planned Windows transport. + +## Security considerations + +- **Loopback cannot tell processes apart.** Tokens, not the transport, + identify callers. The same-user check stops a token reaching another user's + process; it does not stop another process of the same user, which already has + the user's files. +- **Evidence leaves the desktop.** The caller talks to the relay directly, so + `federated_identity` hands it the assertion. NIP-FI's + [assertion–key binding](nips/NIP-FI.md#assertionkey-binding) means a leaked + assertion is useless without the identity's key. +- **Tokens in the environment.** Child processes of an agent inherit + `BUZZ_CREDENTIAL_PROVIDER` and act as that agent. That matches the agent's + existing authority. +- **An agent running as its owner's user can reach the owner's paired tokens.** + The no-fallback rule binds only callers that follow it. An agent that can run + commands as the same OS user can unset `BUZZ_CREDENTIAL_PROVIDER`, or read the + paired tokens from the OS keychain itself, and then act as the owner. On + macOS the keychain item belongs to the `buzz` binary, so it opens with no + prompt. On Linux, Secret Service is readable once it is unlocked. The + same-user check does not help here, because the caller really is the same + user. Where agents should not act as their owner, sandbox them so they cannot + reach the owner's keychain, or run them as a separate OS user with their own + key. The desktop serves only callers running as its own user, so a + separate-user agent cannot use it. +- **Approval fatigue.** The provider prompts only for sensitive operations, + never for every signature, so prompts stay meaningful.