From 6e3d634a57f4a3302cac228475fe779cf0644482 Mon Sep 17 00:00:00 2001 From: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz> Date: Mon, 14 Sep 2026 16:00:12 +0200 Subject: [PATCH] refactor(desktop): capture caller authority behind local capabilities Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/active_user_signer.rs | 214 ++++++++++++++++-- .../src-tauri/src/active_user_signer_tests.rs | 62 ++++- desktop/src-tauri/src/app_state_accessors.rs | 30 +++ desktop/src-tauri/src/archive/prepare.rs | 24 +- .../src/commands/agents_deletion_witness.rs | 22 +- desktop/src-tauri/src/commands/channels.rs | 10 +- .../src/commands/live_signer_tests.rs | 13 +- desktop/src-tauri/src/commands/media.rs | 176 +++++++++++--- .../src-tauri/src/commands/media_download.rs | 178 +++++++++++---- .../src/commands/media_fetch_cancellation.rs | 20 +- desktop/src-tauri/src/commands/media_raw.rs | 16 +- .../src/commands/media_signer_tests.rs | 19 +- .../src-tauri/src/commands/media_transcode.rs | 17 +- .../src/commands/media_upload_progress.rs | 12 +- desktop/src-tauri/src/commands/messages.rs | 8 +- desktop/src-tauri/src/commands/profile.rs | 40 +++- desktop/src-tauri/src/invocation_authority.rs | 16 ++ desktop/src-tauri/src/lib.rs | 2 + .../agent_snapshot_envelope/signer.rs | 14 +- desktop/src-tauri/src/media_read.rs | 108 +++++++++ desktop/src-tauri/src/native_relay_client.rs | 115 +++++++--- desktop/src-tauri/src/owner_authorization.rs | 5 +- desktop/src-tauri/src/relay.rs | 123 +++++----- desktop/src-tauri/src/relay/submit.rs | 61 ++--- desktop/src-tauri/src/user_operation.rs | 15 ++ docs/local-user-capabilities.md | 1 + 26 files changed, 1020 insertions(+), 301 deletions(-) create mode 100644 desktop/src-tauri/src/invocation_authority.rs create mode 100644 desktop/src-tauri/src/media_read.rs diff --git a/desktop/src-tauri/src/active_user_signer.rs b/desktop/src-tauri/src/active_user_signer.rs index 598d655f3d..639103bad1 100644 --- a/desktop/src-tauri/src/active_user_signer.rs +++ b/desktop/src-tauri/src/active_user_signer.rs @@ -67,6 +67,17 @@ impl AgentCapabilities for Keys { } } +/// Backend-owned lifetime of a captured capability. It must never resolve a +/// replacement identity. Local capabilities omit this contract and do not expire. +pub(crate) trait CapabilityLifetime: std::fmt::Debug + Send + Sync { + /// Distinguishes replacement sessions even when their public keys match. + fn generation(&self) -> u64; + /// Reject a capability that can no longer authorize work. + fn check(&self) -> Result<(), String>; + /// Resolve when this capability can no longer authorize work. + fn canceled(&self) -> BoxedFuture<'_, ()>; +} + /// A signer's immutable identity and its asynchronous rust-nostr capability. /// No secret-key accessor is exposed by this boundary. #[derive(Clone, Debug)] @@ -74,6 +85,8 @@ pub(crate) struct ActiveUserSigner { public_key: PublicKey, signer: Arc, agent_capabilities: Option>, + validity: Option>, + local_crypto: bool, } impl ActiveUserSigner { @@ -84,6 +97,8 @@ impl ActiveUserSigner { public_key: AgentCapabilities::public_key(keys.as_ref()), signer: keys.clone(), agent_capabilities: Some(keys), + validity: None, + local_crypto: true, } } @@ -95,20 +110,33 @@ impl ActiveUserSigner { public_key, signer, agent_capabilities: None, + validity: None, + local_crypto: false, }) } - /// Attest to an agent using the captured owner, preserving exact conditions. + /// Bind this captured capability to a backend-owned immutable lifetime. + #[cfg(test)] + pub(crate) fn with_lifetime(mut self, lifetime: Arc) -> Self { + self.validity = Some(lifetime); + self + } + + /// Attest to one agent with the exact NIP-OA conditions, using this owner's + /// captured backend. The JSON string is the existing SDK caller format. pub(crate) async fn authorize_agent( &self, agent: &PublicKey, conditions: &str, ) -> Result { - self.agent_capabilities - .as_ref() - .ok_or("signer has no owner authorization")? - .authorize_agent(agent, conditions) - .await + self.run(async { + self.agent_capabilities + .as_ref() + .ok_or("signer has no owner authorization")? + .authorize_agent(agent, conditions) + .await + }) + .await } /// Validate and decrypt an agent memory with this captured owner. Invalid @@ -119,11 +147,14 @@ impl ActiveUserSigner { event: &Event, agent: &PublicKey, ) -> Result, String> { - self.agent_capabilities - .as_ref() - .ok_or("signer cannot validate keyed agent memory addresses")? - .read_agent_memory(event, agent) - .await + self.run(async { + self.agent_capabilities + .as_ref() + .ok_or("signer cannot validate keyed agent memory addresses")? + .read_agent_memory(event, agent) + .await + }) + .await } #[cfg(test)] @@ -131,11 +162,79 @@ impl ActiveUserSigner { self.with_test_agent_capabilities(Arc::new(keys)) } + /// Attach purpose-specific capabilities only when they attest to this identity. #[cfg(test)] - fn with_test_agent_capabilities(mut self, capabilities: Arc) -> Self { - assert_eq!(self.public_key, capabilities.public_key()); + pub(crate) fn with_agent_capabilities( + mut self, + capabilities: Arc, + ) -> Result { + if self.public_key != capabilities.public_key() { + return Err("agent capabilities do not match signer identity".into()); + } self.agent_capabilities = Some(capabilities); - self + Ok(self) + } + + #[cfg(test)] + fn with_test_agent_capabilities(self, capabilities: Arc) -> Self { + self.with_agent_capabilities(capabilities) + .expect("test capabilities must match signer identity") + } + + /// Distinguish invalid local ciphertext from unavailable cryptography. + /// Only the locally constructed Keys capability gives a definitive negative verdict. + pub(crate) async fn decrypt_record( + &self, + author: &PublicKey, + content: &str, + ) -> Result>, String> { + self.check_valid()?; + match self.nip44_decrypt(author, content).await { + Ok(text) => Ok(Some(zeroize::Zeroizing::new(text))), + Err(_) if self.has_local_crypto() && self.check_valid().is_ok() => Ok(None), + Err(_) => Err("archive decryption backend unavailable".into()), + } + } + + /// Only a local library decrypt error is definitive ciphertext invalidity. + /// Remote operational errors must never be treated as processed records. + pub(crate) fn has_local_crypto(&self) -> bool { + self.local_crypto + } + + /// Native generation supplements pubkey equality, including same-key reauthentication. + pub(crate) fn generation(&self) -> Option { + self.validity.as_ref().map(|v| v.generation()) + } + + /// Reject a held capability after logout, expiry or replacement. + pub(crate) fn check_valid(&self) -> Result<(), String> { + self.validity.as_ref().map_or(Ok(()), |v| v.check()) + } + + /// Wait for this captured capability to end; local capabilities do not expire. + pub(crate) async fn canceled(&self) { + if let Some(validity) = &self.validity { + validity.canceled().await; + } else { + std::future::pending::<()>().await; + } + } + + /// Fence an existing foreground effect, including admission, transport and result. + /// An already-transmitted request cannot be recalled from the relay. + pub(crate) async fn run( + &self, + work: impl std::future::Future>, + ) -> Result { + self.check_valid()?; + let result = if let Some(validity) = &self.validity { + tokio::select! { biased; _ = validity.canceled() => Err("native authentication canceled or expired".into()), result = work => result } + } else { + work.await + }; + self.check_valid()?; + result } /// The identity captured with this signing capability. @@ -150,10 +249,13 @@ impl ActiveUserSigner { /// Build with the captured identity and asynchronously sign without re-fetching it. pub(crate) async fn sign_event(&self, builder: EventBuilder) -> Result { - self.signer - .sign_event(builder.build(self.public_key)) - .await - .map_err(|e| e.to_string()) + self.run(async { + self.signer + .sign_event(builder.build(self.public_key)) + .await + .map_err(|e| e.to_string()) + }) + .await } } @@ -163,11 +265,27 @@ impl NostrSigner for ActiveUserSigner { } fn get_public_key(&self) -> BoxedFuture<'_, Result> { - Box::pin(async { Ok(self.public_key) }) + Box::pin(async { + self.check_valid() + .map_err(|e| SignerError::backend(std::io::Error::other(e)))?; + Ok(self.public_key) + }) } fn sign_event(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result> { - self.signer.sign_event(unsigned) + if self.validity.is_none() { + return self.signer.sign_event(unsigned); + } + Box::pin(async move { + self.run(async { + self.signer + .sign_event(unsigned) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| SignerError::backend(std::io::Error::other(e))) + }) } fn nip04_encrypt<'a>( @@ -175,7 +293,19 @@ impl NostrSigner for ActiveUserSigner { public_key: &'a PublicKey, content: &'a str, ) -> BoxedFuture<'a, Result> { - self.signer.nip04_encrypt(public_key, content) + if self.validity.is_none() { + return self.signer.nip04_encrypt(public_key, content); + } + Box::pin(async move { + self.run(async { + self.signer + .nip04_encrypt(public_key, content) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| SignerError::backend(std::io::Error::other(e))) + }) } fn nip04_decrypt<'a>( @@ -183,7 +313,19 @@ impl NostrSigner for ActiveUserSigner { public_key: &'a PublicKey, content: &'a str, ) -> BoxedFuture<'a, Result> { - self.signer.nip04_decrypt(public_key, content) + if self.validity.is_none() { + return self.signer.nip04_decrypt(public_key, content); + } + Box::pin(async move { + self.run(async { + self.signer + .nip04_decrypt(public_key, content) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| SignerError::backend(std::io::Error::other(e))) + }) } fn nip44_encrypt<'a>( @@ -191,7 +333,19 @@ impl NostrSigner for ActiveUserSigner { public_key: &'a PublicKey, content: &'a str, ) -> BoxedFuture<'a, Result> { - self.signer.nip44_encrypt(public_key, content) + if self.validity.is_none() { + return self.signer.nip44_encrypt(public_key, content); + } + Box::pin(async move { + self.run(async { + self.signer + .nip44_encrypt(public_key, content) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| SignerError::backend(std::io::Error::other(e))) + }) } fn nip44_decrypt<'a>( @@ -199,7 +353,19 @@ impl NostrSigner for ActiveUserSigner { public_key: &'a PublicKey, content: &'a str, ) -> BoxedFuture<'a, Result> { - self.signer.nip44_decrypt(public_key, content) + if self.validity.is_none() { + return self.signer.nip44_decrypt(public_key, content); + } + Box::pin(async move { + self.run(async { + self.signer + .nip44_decrypt(public_key, content) + .await + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| SignerError::backend(std::io::Error::other(e))) + }) } } diff --git a/desktop/src-tauri/src/active_user_signer_tests.rs b/desktop/src-tauri/src/active_user_signer_tests.rs index 3716fbc32c..9d0a8390c7 100644 --- a/desktop/src-tauri/src/active_user_signer_tests.rs +++ b/desktop/src-tauri/src/active_user_signer_tests.rs @@ -313,8 +313,68 @@ async fn auxiliary_operations_await_captured_backend_without_local_fallback() { } #[test] -#[should_panic(expected = "assertion `left == right` failed")] +#[should_panic(expected = "test capabilities must match signer identity")] fn auxiliary_capability_must_match_captured_identity() { ActiveUserSigner::local(Keys::generate()) .with_test_agent_capabilities(Arc::new(Keys::generate())); } + +#[derive(Debug)] +struct TestLifetime(tokio_util::sync::CancellationToken); +impl super::CapabilityLifetime for TestLifetime { + fn generation(&self) -> u64 { + 17 + } + fn check(&self) -> Result<(), String> { + if self.0.is_cancelled() { + Err("retired capability".into()) + } else { + Ok(()) + } + } + fn canceled(&self) -> nostr::util::BoxedFuture<'_, ()> { + Box::pin(self.0.cancelled()) + } +} + +#[tokio::test] +async fn captured_lifetime_cancels_pending_work_without_replacement() { + let lifetime = Arc::new(TestLifetime(tokio_util::sync::CancellationToken::new())); + let signer = ActiveUserSigner::local(Keys::generate()).with_lifetime(lifetime.clone()); + assert_eq!(signer.generation(), Some(17)); + let entered = Arc::new(tokio::sync::Notify::new()); + let started = entered.clone(); + let task = tokio::spawn(async move { + signer + .run(async move { + started.notify_one(); + std::future::pending::>().await + }) + .await + }); + entered.notified().await; + lifetime.0.cancel(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .is_err() + ); +} + +#[tokio::test] +async fn local_lifetime_does_not_change_success_or_error() { + let signer = ActiveUserSigner::local(Keys::generate()); + assert_eq!(signer.generation(), None); + assert_eq!(signer.run(async { Ok(19) }).await, Ok(19)); + assert_eq!( + signer.run(async { Err::<(), _>("original".into()) }).await, + Err("original".into()) + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(1), signer.canceled()) + .await + .is_err() + ); +} diff --git a/desktop/src-tauri/src/app_state_accessors.rs b/desktop/src-tauri/src/app_state_accessors.rs index caa2d1b060..7f11384f2e 100644 --- a/desktop/src-tauri/src/app_state_accessors.rs +++ b/desktop/src-tauri/src/app_state_accessors.rs @@ -10,6 +10,36 @@ use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::ManagedAgentRuntimeKey; impl AppState { + /// Capture the authority supplied by this renderer realm. Local builds have no realm token. + pub(crate) fn renderer_signer( + &self, + _generation: Option, + ) -> Result { + self.active_signer() + } + + /// Capture the signer for an explicit destination before any asynchronous work. + pub(crate) fn renderer_signer_at( + &self, + generation: Option, + _relay: &str, + ) -> Result { + self.renderer_signer(generation) + } + + /// Capture media authority; local recovery may retain unsigned media reads. + pub(crate) fn media_read_scope( + &self, + _generation: Option, + _operation_generation: u64, + ) -> Result { + Ok(crate::media_read::MediaReadScope::new( + self.active_signer().ok(), + crate::relay::relay_api_base_url_with_override(self), + None, + )) + } + /// Compatibility local-key access, preserving historical recovery exceptions. /// New signing paths must use active_signer/signing_keys instead. pub(crate) fn local_identity_keys(&self) -> Result { diff --git a/desktop/src-tauri/src/archive/prepare.rs b/desktop/src-tauri/src/archive/prepare.rs index af6f7d876c..5f23419bbc 100644 --- a/desktop/src-tauri/src/archive/prepare.rs +++ b/desktop/src-tauri/src/archive/prepare.rs @@ -1,6 +1,5 @@ //! Prepare archive crypto outside SQLite, then persist accepted records atomically. -use nostr::{signer::SignerBackend, NostrSigner, PublicKey}; use rusqlite::{Connection, Transaction, TransactionBehavior}; use zeroize::Zeroizing; @@ -98,25 +97,6 @@ fn finish_body(batch: &mut PreparedBatch, source: Parsed, plaintext: Option<&str batch.ready.push(ready); } -/// `Ok(None)` means invalid local ciphertext, not an unavailable backend. -/// rust-nostr erases signer error types. Only its Keys backend is known to -/// perform purely local NIP-44 decryption (this crate enables `nip44`); every -/// other backend error must conservatively propagate, without string matching. -/// PR2 can refine remote invalid-data verdicts here when its protocol supports -/// them; crypto callers and the preparation/commit boundary need not change. -async fn decrypt_body( - signer: &dyn NostrSigner, - author: &PublicKey, - content: &str, -) -> Result>, String> { - match signer.nip44_decrypt(author, content).await { - Ok(text) => Ok(Some(Zeroizing::new(text))), - Err(_) if matches!(signer.backend(), SignerBackend::Keys) => Ok(None), - // Do not expose backend error strings: they can contain sensitive data. - Err(_) => Err("archive decryption backend unavailable".to_owned()), - } -} - /// Decrypt using the captured owner without holding a connection or store lock. /// Local invalid bodies retain the historical raw+NULL observer index behavior; /// invalid metrics are dropped rather than stored as ciphertext. @@ -134,7 +114,9 @@ pub(super) async fn prepare_archive( let text = if requires_crypto(&source) && buzz_core_pkg::observer::content_looks_like_nip44(&source.event.content) { - decrypt_body(signer.signer(), &source.event.pubkey, &source.event.content).await? + signer + .decrypt_record(&source.event.pubkey, &source.event.content) + .await? } else { None }; diff --git a/desktop/src-tauri/src/commands/agents_deletion_witness.rs b/desktop/src-tauri/src/commands/agents_deletion_witness.rs index 4cecb1fc0e..9ed363843f 100644 --- a/desktop/src-tauri/src/commands/agents_deletion_witness.rs +++ b/desktop/src-tauri/src/commands/agents_deletion_witness.rs @@ -71,7 +71,10 @@ pub(crate) struct AgentDeletionWitness { impl AgentTombstone { pub(crate) async fn sign(self, signer: &ActiveUserSigner) -> AgentDeletionWitness { let outcome = async { - if signer.public_key().to_hex() != self.owner { + self.signer.check_valid()?; + if signer.public_key().to_hex() != self.owner + || signer.generation() != self.signer.generation() + { return Err("agent tombstone signer does not own captured scope".into()); } let signer = &self.signer; @@ -145,6 +148,7 @@ pub(crate) fn commit_agent_deletions( ) -> Result { let mut databases: Vec<(PathBuf, rusqlite::Connection)> = Vec::new(); let mut ready = Vec::new(); + let mut captured_signers = Vec::new(); for witness in work { let witness = match witness { Ok(witness) => witness, @@ -153,6 +157,11 @@ pub(crate) fn commit_agent_deletions( continue; } }; + witness.plan.signer.check_valid()?; + captured_signers.push(witness.plan.signer.clone()); + if witness.plan.signer.generation().is_some() { + witness.outcome.as_ref().map_err(Clone::clone)?; + } let path = &witness.plan.db_path; let index = match databases.iter().position(|(existing, _)| existing == path) { Some(index) => index, @@ -187,6 +196,12 @@ pub(crate) fn commit_agent_deletions( Err(error) => ready.push(Err(error)), } } + // SQLite busy waits can outlive the initial admission check. Fence again + // immediately before the irreversible synchronous body (including plans + // whose retention I/O failed under the existing best-effort policy). + for signer in captured_signers { + signer.check_valid()?; + } let result = delete()?; for witness in ready { let outcome = witness.and_then(|(index, witness)| { @@ -282,11 +297,14 @@ impl AgentDeletionWitness { /// Strict standalone commit for boot recovery and test adapters. Direct /// deletions use the best-effort batch coordinator before disk mutation. pub(crate) fn commit(self) -> Result<(), String> { + self.plan.signer.check_valid()?; let mut conn = open_retention_db(&self.plan.db_path)?; let tx = conn .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|e| e.to_string())?; - // Recheck the retained head after acquiring SQLite write ownership. + // SQLite acquisition can wait behind another writer while native auth + // is canceled or replaced. Fence the captured session after that wait. + self.plan.signer.check_valid()?; if head_token(get_retained_event( &tx, self.plan.kind, diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index e4ca7ac7a8..144c2ca855 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -637,10 +637,16 @@ pub async fn change_channel_member_role( } #[tauri::command] -pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { +pub async fn join_channel( + channel_id: String, + expected_generation: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let signer = state.renderer_signer(expected_generation)?; + let relay_base = relay_api_base_url_with_override(&state); let uuid = parse_channel_uuid(&channel_id)?; let builder = events::build_join(uuid)?; - submit_event(builder, &state).await?; + submit_event_at(builder, &state, &relay_base, &signer).await?; Ok(()) } diff --git a/desktop/src-tauri/src/commands/live_signer_tests.rs b/desktop/src-tauri/src/commands/live_signer_tests.rs index 6439a54b13..119366e8fe 100644 --- a/desktop/src-tauri/src/commands/live_signer_tests.rs +++ b/desktop/src-tauri/src/commands/live_signer_tests.rs @@ -183,6 +183,7 @@ async fn live_message_command_pins_thread_query_event_auth_and_cursor() { Some(kind), Some(base), Some(owner), + None, app.state(), ) .await @@ -232,6 +233,7 @@ async fn live_message_failure_and_scope_validation_never_egress() { None, None, None, + None, app.state(), ) .await @@ -378,7 +380,15 @@ async fn live_deferred_profile_keeps_monotonic_template_and_captured_scope() { let base = f.base.clone(); let owner = f.controlled.keys.public_key().to_hex(); let task = tokio::spawn(async move { - update_profile_at_relay(base, owner, Some("old".into()), "new".into(), app.state()).await + update_profile_at_relay( + base, + owner, + Some("old".into()), + "new".into(), + None, + app.state(), + ) + .await }); f.controlled.wait_entered().await; f.switch_identity_and_relay(); @@ -455,6 +465,7 @@ async fn profile_edit_retains_owner_and_relay_across_read_write_reread() { None, Some("About".into()), None, + None, app.state::(), ) .await diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 2bc239daf2..bbe65a49f5 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -11,8 +11,8 @@ use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_ use super::media_filename::sanitize_filename; use super::media_transcode::{ - has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, - transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes, + has_heic_extension, is_heic_file, is_video_file, + transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes_with_cancellation, }; use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; @@ -396,44 +396,144 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { /// One upload destination/identity captured before picker or transcode waits. /// Video and poster uploads and legacy retries must all retain this binding. -struct MediaUploadScope { +pub(crate) struct MediaUploadScope { signer: ActiveUserSigner, relay_base: String, + cancellation: CancellationToken, + cancellation_task: tokio::task::JoinHandle<()>, +} + +impl Drop for MediaUploadScope { + fn drop(&mut self) { + // Dropping an IPC future must also stop its blocking transcode worker. + if self.signer.generation().is_some() { + self.cancellation.cancel(); + } + self.cancellation_task.abort(); + } } impl MediaUploadScope { - fn capture(state: &AppState) -> Result { + fn capture(state: &AppState, parent: Option<&CancellationToken>) -> Result { // Workspace installation uses this lock too: capture one coherent pair, // not an old signer with a newly installed relay. Release before I/O. - let _generation = state + let _admission = state .operation_generation .lock() .map_err(|e| e.to_string())?; + let signer = state.active_signer()?; + Self::with_signer(state, parent, signer) + } + + fn with_signer( + state: &AppState, + parent: Option<&CancellationToken>, + signer: ActiveUserSigner, + ) -> Result { + let cancellation = parent + .map(|token| { + if signer.generation().is_some() { + token.child_token() + } else { + token.clone() + } + }) + .unwrap_or_default(); + let cancel = cancellation.clone(); + let lifetime = signer.clone(); + let cancellation_task = tokio::spawn(async move { + lifetime.canceled().await; + cancel.cancel(); + }); Ok(Self { - signer: state.active_signer()?, + signer, relay_base: relay_api_base_url_with_override(state), + cancellation, + cancellation_task, }) } + pub(super) fn with_parent(mut self, parent: Option<&CancellationToken>) -> Self { + self.cancellation_task.abort(); + self.cancellation = parent + .map(|token| { + if self.signer.generation().is_some() { + token.child_token() + } else { + token.clone() + } + }) + .unwrap_or_default(); + let cancel = self.cancellation.clone(); + let signer = self.signer.clone(); + self.cancellation_task = tokio::spawn(async move { + signer.canceled().await; + cancel.cancel(); + }); + self + } +} + +impl<'de, R: tauri::Runtime> tauri::ipc::CommandArg<'de, R> for MediaUploadScope { + fn from_command( + command: tauri::ipc::CommandItem<'de, R>, + ) -> Result { + let state = command + .message + .state_ref() + .try_get::() + .ok_or_else(|| tauri::ipc::InvokeError::from("native app state unavailable"))?; + let _admission = state + .operation_generation + .lock() + .map_err(|e| e.to_string())?; + let generation = crate::invocation_authority::invocation_generation( + command.message.payload(), + command.message.headers(), + ); + let signer = state.renderer_signer(generation)?; + Self::with_signer(&state, None, signer).map_err(Into::into) + } } pub(crate) async fn upload_image_bytes( body: Vec, state: &AppState, ) -> Result { - let upload = MediaUploadScope::capture(state)?; + let upload = MediaUploadScope::capture(state, None)?; let mime = detect_and_validate_mime(&body)?; if !mime.starts_with("image/") { return Err("profile avatar must be an image".to_string()); } let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, state, None, None, &upload).await + do_upload::(body, &mime, state, None, None, &upload).await } -async fn do_upload( +async fn do_upload( body: Vec, mime: &str, state: &AppState, - progress: Option<(tauri::AppHandle, String)>, + progress: Option<(tauri::AppHandle, String)>, + cancellation: Option<&CancellationToken>, + upload: &MediaUploadScope, +) -> Result { + upload + .signer + .run(do_upload_captured( + body, + mime, + state, + progress, + cancellation, + upload, + )) + .await +} + +async fn do_upload_captured( + body: Vec, + mime: &str, + state: &AppState, + progress: Option<(tauri::AppHandle, String)>, cancellation: Option<&CancellationToken>, upload: &MediaUploadScope, ) -> Result { @@ -506,8 +606,8 @@ pub async fn upload_media( file_path: String, is_temp: bool, state: State<'_, AppState>, + upload: MediaUploadScope, ) -> Result { - let upload = MediaUploadScope::capture(&state)?; let path = std::path::Path::new(&file_path); let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?; @@ -526,12 +626,17 @@ pub async fn upload_media( drop(file); if is_temp { + let _admission = state + .operation_generation + .lock() + .map_err(|e| e.to_string())?; + upload.signer.check_valid()?; let _ = std::fs::remove_file(&fd_path); } let mime = detect_and_validate_mime(&body)?; let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, &state, None, None, &upload).await + do_upload::(body, &mime, &state, None, None, &upload).await } /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → @@ -541,11 +646,11 @@ pub async fn upload_media( /// not an image (videos and non-image files error out; HEIC/HEIF still /// transcode to JPEG, which is an image). This keeps discarded/non-image /// files from ever leaving the client on image-only surfaces. -async fn process_picked_path( +async fn process_picked_path( path: std::path::PathBuf, state: &AppState, images_only: bool, - progress: Option<(tauri::AppHandle, String)>, + progress: Option<(tauri::AppHandle, String)>, upload: &MediaUploadScope, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a @@ -560,6 +665,7 @@ async fn process_picked_path( // All sync I/O (sniff, transcode, read) runs off the async runtime to // avoid blocking Tokio worker threads during long ffmpeg transcodes. + let cancellation = upload.cancellation.clone(); let (body, poster_bytes) = tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { use std::io::Read; @@ -579,7 +685,8 @@ async fn process_picked_path( // completes — this prevents the inode from being unlinked or // the resolved path from becoming stale during the ffmpeg run. let fd_path = fd_real_path(&file)?; - let result = transcode_and_extract_poster(&fd_path); + let result = + transcode_and_extract_poster_with_cancellation(&fd_path, Some(&cancellation)); drop(file); // release fd only after ffmpeg is done result } else if heic_by_ext || is_heic_file(&header[..n]) { @@ -588,7 +695,11 @@ async fn process_picked_path( // fd's real path so ffmpeg reads the pinned inode, and keep // `file` alive until the transcode finishes. let fd_path = fd_real_path(&file)?; - let result = transcode_heic_path_to_jpeg_bytes(&fd_path).map(|jpeg| (jpeg, None)); + let result = transcode_heic_path_to_jpeg_bytes_with_cancellation( + &fd_path, + Some(&cancellation), + ) + .map(|jpeg| (jpeg, None)); drop(file); // release fd only after ffmpeg is done result } else { @@ -602,6 +713,7 @@ async fn process_picked_path( .await .map_err(|e| format!("transcode task failed: {e}"))??; + upload.signer.check_valid()?; let mime = detect_and_validate_mime(&body)?; let body = sanitize_image_for_upload(body, &mime)?; @@ -615,12 +727,13 @@ async fn process_picked_path( // the video descriptor is returned without an image field. let mut descriptor = do_upload(body, &mime, state, progress, None, upload).await?; if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", state, None, None, upload).await { + match do_upload::(poster, "image/jpeg", state, None, None, upload).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } } + upload.signer.check_valid()?; descriptor.filename = path .file_name() .and_then(|n| n.to_str()) @@ -650,8 +763,8 @@ pub async fn pick_and_upload_media( app: tauri::AppHandle, progress_id: Option, state: State<'_, AppState>, + upload: MediaUploadScope, ) -> Result, String> { - let upload = MediaUploadScope::capture(&state)?; use tauri_plugin_dialog::DialogExt; let (tx, rx) = tokio::sync::oneshot::channel(); @@ -661,7 +774,11 @@ pub async fn pick_and_upload_media( let _ = tx.send(paths); }); - let file_paths = match rx.await.map_err(|_| "dialog cancelled".to_string())? { + let file_paths = match upload + .signer + .run(async { rx.await.map_err(|_| "dialog cancelled".to_string()) }) + .await? + { Some(paths) => paths, None => return Ok(Vec::new()), }; @@ -691,8 +808,8 @@ pub async fn pick_and_upload_media( pub async fn pick_and_upload_image( app: tauri::AppHandle, state: State<'_, AppState>, + upload: MediaUploadScope, ) -> Result, String> { - let upload = MediaUploadScope::capture(&state)?; use tauri_plugin_dialog::DialogExt; let (tx, rx) = tokio::sync::oneshot::channel(); @@ -706,25 +823,29 @@ pub async fn pick_and_upload_image( let _ = tx.send(path); }); - let file_path = match rx.await.map_err(|_| "dialog cancelled".to_string())? { + let file_path = match upload + .signer + .run(async { rx.await.map_err(|_| "dialog cancelled".to_string()) }) + .await? + { Some(path) => path, None => return Ok(None), }; let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, true, None, &upload).await?; + let descriptor = process_picked_path::(path, &state, true, None, &upload).await?; Ok(Some(descriptor)) } -pub(super) async fn upload_media_bytes_inner( +pub(super) async fn upload_media_bytes_in_scope( data: Vec, filename: Option, progress_id: Option, - app: tauri::AppHandle, + app: tauri::AppHandle, state: State<'_, AppState>, - cancellation: Option<&CancellationToken>, + upload: MediaUploadScope, ) -> Result { - let upload = MediaUploadScope::capture(&state)?; + let cancellation = Some(&upload.cancellation); if data.is_empty() { return Err("empty upload".to_string()); } @@ -802,12 +923,13 @@ pub(super) async fn upload_media_bytes_inner( emit_media_upload_phase(&app, progress_id.as_deref(), "finishing"); if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", &state, None, cancellation, &upload).await { + match do_upload::(poster, "image/jpeg", &state, None, cancellation, &upload).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } } + upload.signer.check_valid()?; descriptor.filename = filename.as_deref().map(|name| { let upload_name = if is_voice_note { voice_note_mp4_filename(name) diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index a48eb4fa43..54ae47c70c 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -5,8 +5,8 @@ use tokio_util::sync::CancellationToken; use crate::app_state::AppState; use crate::commands::clipboard::with_clipboard; -use crate::commands::export_util::save_bytes_with_dialog; -use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth}; +use crate::commands::export_util::pick_save_path; +use crate::commands::media::detect_and_validate_mime; use crate::commands::media_filename::sanitize_filename; use crate::commands::{ personas::{ @@ -17,6 +17,7 @@ use crate::commands::{ decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES, }, }; +use crate::media_read::MediaReadScope; use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. @@ -67,10 +68,11 @@ pub async fn download_image( url: String, app: tauri::AppHandle, state: State<'_, AppState>, + scope: MediaReadScope, ) -> Result { // SSRF protection: only allow downloads from the relay's /media/ path. - let relay_base = relay_api_base_url_with_override(&state); - validate_download_url(&url, &relay_base)?; + let relay_base = &scope.base; + validate_download_url(&url, relay_base)?; // Infer filename from the URL path (e.g. "abcdef123.jpg" from a Blossom URL). let filename = url::Url::parse(&url) @@ -90,12 +92,18 @@ pub async fn download_image( .unwrap_or("png") .to_string(); - let bytes = fetch_blob_bytes(&url, &state).await?; + let bytes = fetch_blob_bytes_in_scope(&url, &state, MAX_DOWNLOAD_BYTES, None, &scope).await?; // Validate the downloaded content is actually a supported media type. detect_and_validate_mime(&bytes)?; - save_bytes_with_dialog(&app, &filename, "Images", &[&ext], &bytes).await + save_download( + &scope, + &state, + pick_save_path(&app, &filename, "Images", &[&ext]), + &bytes, + ) + .await } /// Download an arbitrary file attachment from a relay `/media/` URL and save it @@ -114,10 +122,11 @@ pub async fn download_file( filename: String, app: tauri::AppHandle, state: State<'_, AppState>, + scope: MediaReadScope, ) -> Result { // SSRF protection: only allow downloads from the relay's /media/ path. - let relay_base = relay_api_base_url_with_override(&state); - validate_download_url(&url, &relay_base)?; + let relay_base = &scope.base; + validate_download_url(&url, relay_base)?; // The imeta filename is the only human-readable name we have; sanitize it // so directory traversal / control characters can never reach the dialog. @@ -129,7 +138,7 @@ pub async fn download_file( .and_then(|e| e.to_str()) .map(|e| e.to_string()); - let bytes = fetch_blob_bytes(&url, &state).await?; + let bytes = fetch_blob_bytes_in_scope(&url, &state, MAX_DOWNLOAD_BYTES, None, &scope).await?; // Reuse the upload-side allow/deny policy: rejects executables, HTML, and // other types the relay would never have accepted, while permitting the @@ -138,7 +147,13 @@ pub async fn download_file( // Generic filter: an arbitrary attachment is not necessarily an image. let extensions: Vec<&str> = ext.as_deref().into_iter().collect(); - save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await + save_download( + &scope, + &state, + pick_save_path(&app, &filename, "All Files", &extensions), + &bytes, + ) + .await } /// Copy an image from a relay media URL directly to the system clipboard. @@ -155,11 +170,12 @@ pub async fn copy_image_to_clipboard( url: String, app: tauri::AppHandle, state: State<'_, AppState>, + scope: MediaReadScope, ) -> Result<(), String> { - let relay_base = relay_api_base_url_with_override(&state); - validate_download_url(&url, &relay_base)?; + let relay_base = &scope.base; + validate_download_url(&url, relay_base)?; - let bytes = fetch_blob_bytes(&url, &state).await?; + let bytes = fetch_blob_bytes_in_scope(&url, &state, MAX_DOWNLOAD_BYTES, None, &scope).await?; detect_and_validate_mime(&bytes)?; let img = @@ -176,26 +192,16 @@ pub async fn copy_image_to_clipboard( let (width, height) = (rgba.width() as usize, rgba.height() as usize); let raw = rgba.into_raw(); - // arboard requires main-thread access on macOS. Use a sync channel so the - // async command can await the result. - let (tx, rx) = std::sync::mpsc::sync_channel::>(1); - let clipboard_app = app.clone(); - app.run_on_main_thread(move || { - let result = with_clipboard(&clipboard_app, |clipboard| { + dispatch_media_effect(app, scope, move |clipboard_app| { + with_clipboard(clipboard_app, |clipboard| { clipboard.set_image(arboard::ImageData { width, height, bytes: std::borrow::Cow::Owned(raw), }) - }); - // Ignore send errors — the receiver dropped only if the command was - // cancelled, in which case nobody is waiting for the result. - let _ = tx.send(result); + }) }) - .map_err(|e| format!("main thread dispatch failed: {e}"))?; - - rx.recv() - .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? + .await } /// Write text to the system clipboard through the native shell. @@ -208,30 +214,70 @@ pub async fn copy_text_to_clipboard( text: String, html: Option, app: tauri::AppHandle, + scope: MediaReadScope, ) -> Result<(), String> { - let (tx, rx) = std::sync::mpsc::sync_channel::>(1); - let clipboard_app = app.clone(); - app.run_on_main_thread(move || { - let result = with_clipboard(&clipboard_app, |clipboard| { + dispatch_media_effect(app, scope, move |clipboard_app| { + with_clipboard(clipboard_app, |clipboard| { if let Some(html) = html { clipboard.set_html(html, Some(text)) } else { clipboard.set_text(text) } - }); - let _ = tx.send(result); + }) }) - .map_err(|e| format!("main thread dispatch failed: {e}"))?; - - rx.recv() - .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? + .await } -/// Fetch blob bytes from a (pre-validated) relay media URL through the app's -/// HTTP client, enforcing the download size cap. The caller is responsible for -/// validating the URL origin and for any content-type checks on the result. -async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result, String> { - fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES, None).await +/// Queue a capability-bound effect; native execution revalidates after waiting +/// for the main thread, not just when the command enqueues its closure. +pub(crate) fn admitted_media_effect( + app: tauri::AppHandle, + scope: MediaReadScope, + effect: impl FnOnce(&tauri::AppHandle) -> Result<(), String> + Send + 'static, +) -> ( + impl FnOnce() + Send + 'static, + tokio::sync::oneshot::Receiver>, +) { + let (tx, rx) = tokio::sync::oneshot::channel(); + let work = move || { + let state = tauri::Manager::state::(&app); + let result = scope.commit(&state, || effect(&app)); + let _ = tx.send(result); + }; + (work, rx) +} + +async fn dispatch_media_effect( + app: tauri::AppHandle, + scope: MediaReadScope, + effect: impl FnOnce(&tauri::AppHandle) -> Result<(), String> + Send + 'static, +) -> Result<(), String> { + let (work, rx) = admitted_media_effect(app.clone(), scope.clone(), effect); + app.run_on_main_thread(work) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + scope + .run(async { + rx.await + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? + }) + .await +} + +/// The selected destination remains inert until admission, even when the native +/// dialog completes after logout. Dialog cancellation does not write anything. +pub(crate) async fn save_download( + scope: &MediaReadScope, + state: &AppState, + selection: impl std::future::Future, String>>, + bytes: &[u8], +) -> Result { + let Some(path) = scope.run(selection).await? else { + return Ok(false); + }; + scope.commit(state, || { + std::fs::write(path, bytes).map_err(|e| format!("Failed to write file: {e}"))?; + Ok(true) + }) } /// The command-facing error for a media-fetch response status, or `None` if @@ -259,17 +305,55 @@ pub(super) async fn fetch_blob_bytes_with_cap( cap: u64, cancellation: Option<&CancellationToken>, ) -> Result, String> { + let scope = MediaReadScope::capture_command(state)?; + fetch_blob_bytes_in_scope(url, state, cap, cancellation, &scope).await +} + +pub(super) async fn fetch_blob_bytes_in_scope( + url: &str, + state: &State<'_, AppState>, + cap: u64, + cancellation: Option<&CancellationToken>, + scope: &MediaReadScope, +) -> Result, String> { + // Validate again at the captured capability boundary. A caller's earlier + // validation must never authorize a token minted for a replacement relay. + validate_download_url(url, &scope.base)?; + let work = scope.run(fetch_blob_bytes_captured( + url, + state, + cap, + cancellation, + scope, + )); + if let Some(cancellation) = cancellation { + tokio::select! { biased; + _ = cancellation.cancelled() => Err("media fetch cancelled".into()), + result = work => result, + } + } else { + work.await + } +} + +async fn fetch_blob_bytes_captured( + url: &str, + state: &State<'_, AppState>, + cap: u64, + cancellation: Option<&CancellationToken>, + scope: &MediaReadScope, +) -> Result, String> { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("media fetch cancelled".into()); + } // Fetch bytes via the no-redirect media client (goes through the VPN tunnel). // A no-redirect client keeps the minted media auth token from being // forwarded across origins by a relay-issued 3xx (redirect-hop SSRF); a // 3xx is returned verbatim and rejected by the `is_success` check below. let mut req = state.media_fetch_client.get(url).timeout(DOWNLOAD_TIMEOUT); - // Every caller pre-validates `url` against the relay origin via - // `validate_download_url`, satisfying the mint_media_get_auth safety - // contract (the token never leaves the relay origin). - let relay_base = relay_api_base_url_with_override(state); - if let Some(auth) = mint_media_get_auth(state, &relay_base).await { + // URL and authorization belong to the same captured relay. + if let Some(auth) = scope.authorization().await? { req = req.header("authorization", auth); } diff --git a/desktop/src-tauri/src/commands/media_fetch_cancellation.rs b/desktop/src-tauri/src/commands/media_fetch_cancellation.rs index 6d29e0cd9d..1c26e299d4 100644 --- a/desktop/src-tauri/src/commands/media_fetch_cancellation.rs +++ b/desktop/src-tauri/src/commands/media_fetch_cancellation.rs @@ -8,9 +8,9 @@ use tokio_util::sync::CancellationToken; use crate::app_state::AppState; use crate::commands::media::detect_and_validate_mime; use crate::commands::media_download::{ - fetch_blob_bytes_with_cap, validate_download_url, MAX_DOWNLOAD_BYTES, + fetch_blob_bytes_in_scope, validate_download_url, MAX_DOWNLOAD_BYTES, }; -use crate::relay::relay_api_base_url_with_override; +use crate::media_read::MediaReadScope; #[derive(Default)] struct MediaFetchCancellations { @@ -79,14 +79,20 @@ pub async fn fetch_media_bytes( url: String, request_id: Option, state: tauri::State<'_, AppState>, + scope: MediaReadScope, ) -> Result { let cancellation = begin_media_fetch(request_id.as_deref()); let result = async { - let relay_base = relay_api_base_url_with_override(&state); - validate_download_url(&url, &relay_base)?; - let bytes = - fetch_blob_bytes_with_cap(&url, &state, MAX_DOWNLOAD_BYTES, cancellation.as_ref()) - .await?; + let relay_base = &scope.base; + validate_download_url(&url, relay_base)?; + let bytes = fetch_blob_bytes_in_scope( + &url, + &state, + MAX_DOWNLOAD_BYTES, + cancellation.as_ref(), + &scope, + ) + .await?; detect_and_validate_mime(&bytes)?; Ok(tauri::ipc::Response::new(bytes)) } diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs index 9708157162..e1e44cbf0c 100644 --- a/desktop/src-tauri/src/commands/media_raw.rs +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -7,7 +7,7 @@ use tauri::{ use crate::app_state::AppState; use super::{ - media::{upload_media_bytes_inner, BlobDescriptor}, + media::{upload_media_bytes_in_scope, BlobDescriptor, MediaUploadScope}, media_upload_progress::{ begin_media_upload, cancel_media_upload as cancel_registered_media_upload, finish_media_upload, @@ -26,15 +26,16 @@ pub async fn upload_media_bytes( progress_id: Option, app: tauri::AppHandle, state: State<'_, AppState>, + upload: MediaUploadScope, ) -> Result { let cancellation = begin_media_upload(progress_id.as_deref()); - let result = upload_media_bytes_inner( + let result = upload_media_bytes_in_scope( data, filename, progress_id.clone(), app, state, - cancellation.as_ref(), + upload.with_parent(cancellation.as_ref()), ) .await; finish_media_upload(progress_id.as_deref()); @@ -75,10 +76,11 @@ pub fn release_media_upload(progress_id: String) { /// Upload raw IPC bytes without expanding a large browser File into JSON. #[tauri::command] -pub async fn upload_media_bytes_raw( +pub async fn upload_media_bytes_raw( request: Request<'_>, - app: tauri::AppHandle, + app: tauri::AppHandle, state: State<'_, AppState>, + upload: MediaUploadScope, ) -> Result { let data = match request.body() { InvokeBody::Raw(data) => data.clone(), @@ -88,13 +90,13 @@ pub async fn upload_media_bytes_raw( let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; let cancellation = begin_media_upload(progress_id.as_deref()); - let result = upload_media_bytes_inner( + let result = upload_media_bytes_in_scope( data, filename, progress_id.clone(), app, state, - cancellation.as_ref(), + upload.with_parent(cancellation.as_ref()), ) .await; finish_media_upload(progress_id.as_deref()); diff --git a/desktop/src-tauri/src/commands/media_signer_tests.rs b/desktop/src-tauri/src/commands/media_signer_tests.rs index 5a1c9ae278..d40542b7f8 100644 --- a/desktop/src-tauri/src/commands/media_signer_tests.rs +++ b/desktop/src-tauri/src/commands/media_signer_tests.rs @@ -72,8 +72,8 @@ async fn captured_upload_keeps_relay_owner_and_body_through_legacy_retry() { *state.test_signer.lock().unwrap() = Some(ActiveUserSigner::new(controlled.clone()).await.unwrap()); *state.relay_url_override.lock().unwrap() = Some(base.clone()); - let upload = MediaUploadScope::capture(&state).unwrap(); - let send = do_upload( + let upload = MediaUploadScope::capture(&state, None).unwrap(); + let send = do_upload::( vec![1, 2, 3, 4], "application/octet-stream", &state, @@ -117,3 +117,18 @@ async fn captured_upload_keeps_relay_owner_and_body_through_legacy_retry() { ); } } + +#[tokio::test] +async fn local_upload_scope_drop_preserves_existing_worker_cancellation() { + let state = crate::app_state::build_app_state(); + let parent = CancellationToken::new(); + let scope = MediaUploadScope::capture(&state, Some(&parent)).unwrap(); + let worker_token = scope.cancellation.clone(); + assert!(!worker_token.is_cancelled()); + drop(scope); + assert!(!worker_token.is_cancelled()); + assert!(!parent.is_cancelled()); + let scope = MediaUploadScope::capture(&state, Some(&parent)).unwrap(); + parent.cancel(); + assert!(scope.cancellation.is_cancelled()); +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 30f9269f53..b2bc6996ac 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -425,13 +425,7 @@ fn transcode_heic_to_jpeg( /// Transcode a HEIC/HEIF still image (from a path) to JPEG bytes. /// /// Resolves ffmpeg, transcodes, reads the JPEG bytes, and cleans up the temp -/// file. Mirrors `transcode_and_extract_poster` but for images (no poster). -pub(super) fn transcode_heic_path_to_jpeg_bytes( - source: &std::path::Path, -) -> Result, String> { - transcode_heic_path_to_jpeg_bytes_with_cancellation(source, None) -} - +/// file. Mirrors `transcode_and_extract_poster_with_cancellation` but for images (no poster). pub(super) fn transcode_heic_path_to_jpeg_bytes_with_cancellation( source: &std::path::Path, cancellation: Option<&CancellationToken>, @@ -530,12 +524,6 @@ fn extract_poster_frame_with_cancellation( /// /// Poster extraction is best-effort — if it fails, returns `None` for the poster /// and the video bytes are still valid. All temp files are cleaned up. -pub(super) fn transcode_and_extract_poster( - source: &std::path::Path, -) -> Result<(Vec, Option>), String> { - transcode_and_extract_poster_with_cancellation(source, None) -} - pub(super) fn transcode_and_extract_poster_with_cancellation( source: &std::path::Path, cancellation: Option<&CancellationToken>, @@ -840,7 +828,8 @@ mod tests { ); // Transcode to JPEG bytes and verify the JPEG magic. - let jpeg = transcode_heic_path_to_jpeg_bytes(&heic_path).expect("transcode to jpeg"); + let jpeg = transcode_heic_path_to_jpeg_bytes_with_cancellation(&heic_path, None) + .expect("transcode to jpeg"); let _ = std::fs::remove_file(&heic_path); assert!(jpeg.len() > 2, "empty jpeg output"); assert_eq!(&jpeg[0..2], &[0xFF, 0xD8], "output is not a JPEG"); diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 5ed3f78652..e5ca7f2b98 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -59,19 +59,19 @@ pub(super) fn finish_media_upload(progress_id: Option<&str>) { } } -pub(super) struct UploadAttempt<'a> { +pub(super) struct UploadAttempt<'a, R: tauri::Runtime> { pub url: String, pub auth_header: &'a str, pub mime: &'a str, pub sha256: &'a str, pub body: bytes::Bytes, - pub progress: Option<&'a (tauri::AppHandle, String)>, + pub progress: Option<&'a (tauri::AppHandle, String)>, pub cancellation: Option<&'a CancellationToken>, } -pub(super) async fn send_upload_attempt( +pub(super) async fn send_upload_attempt( state: &AppState, - attempt: UploadAttempt<'_>, + attempt: UploadAttempt<'_, R>, ) -> Result { let UploadAttempt { url, @@ -133,8 +133,8 @@ pub(super) async fn send_upload_attempt( response.map_err(|error| classify_request_error(&error)) } -pub(super) fn emit_media_upload_phase( - app: &tauri::AppHandle, +pub(super) fn emit_media_upload_phase( + app: &tauri::AppHandle, progress_id: Option<&str>, phase: &'static str, ) { diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index ed9f66747a..c0ac5cd92c 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -417,6 +417,7 @@ pub async fn send_channel_message( kind: Option, expected_relay_url: Option, expected_signer_pubkey: Option, + expected_generation: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -439,7 +440,7 @@ pub async fn send_channel_message( // exact snapshot signs the event and its NIP-98 auth below. let relay_base = crate::relay::relay_api_base_url_with_override(&state); assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?; - let signing_keys = state.active_signer()?; + let signing_keys = state.renderer_signer(expected_generation)?; assert_expected_signer( expected_signer_pubkey.as_deref(), &signing_keys.public_key().to_hex(), @@ -836,7 +837,10 @@ pub async fn send_managed_agent_channel_message( created_at, }) }; - work.await + match &owner { + Some(owner) => owner.signer.run(work).await, + None => work.await, + } } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 71c8360f68..61a4d39dcf 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -39,11 +39,11 @@ pub async fn update_profile( avatar_url: Option, about: Option, nip05_handle: Option, + expected_generation: Option, state: State<'_, AppState>, ) -> Result { // Read-merge-write: kind 0 is a full profile snapshot. - let signer = state.active_signer()?; - let relay_base = crate::relay::relay_api_base_url_with_override(&state); + let (signer, relay_base) = capture_profile_scope(&state, expected_generation, None)?; let my_pubkey = signer.public_key().to_hex(); let prior_events = query_relay_at_with_signer( &state, @@ -109,11 +109,12 @@ pub async fn update_profile_at_relay( expected_pubkey: String, expected_avatar_url: Option, avatar_url: String, + expected_generation: Option, state: State<'_, AppState>, ) -> Result { - let signer = capture_expected_signer(&state, &expected_pubkey)?; - - let api_base_url = relay_http_base_url(&relay_url); + let (signer, api_base_url) = + capture_profile_scope(&state, expected_generation, Some(&relay_url))?; + assert_expected_profile_identity(&signer, &expected_pubkey)?; let filter = serde_json::json!({ "kinds": [0], "authors": [expected_pubkey], @@ -171,15 +172,30 @@ fn build_deferred_profile_event( ) } -fn capture_expected_signer( +// Capture one destination before acquiring remote workspace authority. The caller's +// generation is mandatory remotely, including for delayed same-pubkey callbacks. +fn capture_profile_scope( state: &AppState, + expected_generation: Option, + explicit_relay: Option<&str>, +) -> Result<(crate::active_user_signer::ActiveUserSigner, String), String> { + let configured_relay = crate::relay::relay_ws_url_with_override(state); + let configured_base = relay_http_base_url(&configured_relay); + let destination = explicit_relay + .map(relay_http_base_url) + .unwrap_or(configured_base.clone()); + let signer = state.renderer_signer_at(expected_generation, &destination)?; + Ok((signer, destination)) +} + +fn assert_expected_profile_identity( + signer: &crate::active_user_signer::ActiveUserSigner, expected_pubkey: &str, -) -> Result { - let signer = state.active_signer()?; +) -> Result<(), String> { if signer.public_key().to_hex() != expected_pubkey { return Err("profile identity changed before avatar save".to_string()); } - Ok(signer) + Ok(()) } fn normalized_avatar_url(avatar_url: Option<&str>) -> Option<&str> { @@ -431,8 +447,9 @@ mod tests { let original = state.signing_keys().expect("signable identity"); let original_pubkey = original.public_key().to_hex(); - let captured = capture_expected_signer(&state, &original_pubkey) + let (captured, _) = capture_profile_scope(&state, None, None) .expect("matching identity should be captured"); + assert_expected_profile_identity(&captured, &original_pubkey).unwrap(); state .replace_local_identity_keys(nostr::Keys::generate()) .unwrap(); @@ -447,7 +464,8 @@ mod tests { original_pubkey ); assert_eq!( - capture_expected_signer(&state, &original_pubkey).unwrap_err(), + assert_expected_profile_identity(&state.active_signer().unwrap(), &original_pubkey) + .unwrap_err(), "profile identity changed before avatar save" ); } diff --git a/desktop/src-tauri/src/invocation_authority.rs b/desktop/src-tauri/src/invocation_authority.rs new file mode 100644 index 0000000000..7bf61a07b3 --- /dev/null +++ b/desktop/src-tauri/src/invocation_authority.rs @@ -0,0 +1,16 @@ +//! Shared IPC authority metadata; interpreted only by the identity boundary. + +pub(crate) fn invocation_generation( + body: &tauri::ipc::InvokeBody, + headers: &tauri::http::HeaderMap, +) -> Option { + match body { + tauri::ipc::InvokeBody::Json(body) => body + .get("expectedGeneration") + .and_then(serde_json::Value::as_u64), + tauri::ipc::InvokeBody::Raw(_) => headers + .get("x-buzz-identity-generation") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()), + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9833b511b9..11fd06039c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod events; mod huddle; mod identity_storage; mod initial_window; +mod invocation_authority; mod key_backup; mod link_preview_tags; mod linux_media; @@ -21,6 +22,7 @@ mod linux_media; mod macos_notifications; mod managed_agents; mod media_proxy; +mod media_read; #[cfg(feature = "mesh-llm")] mod mesh_llm; #[cfg(not(feature = "mesh-llm"))] diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope/signer.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope/signer.rs index f7de2cde91..d3f4b8e7a5 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope/signer.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope/signer.rs @@ -36,6 +36,7 @@ pub(crate) async fn encrypt_snapshot_envelope_with_signer( .nip44_encrypt(agent, plaintext) .await .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?; + owner.check_valid()?; validate_envelope(&envelope)?; Ok(envelope) } @@ -53,8 +54,16 @@ pub(crate) async fn decrypt_envelope_with_signer( let result = owner .nip44_decrypt(&agent, &envelope.encryption.ciphertext) .await; - // Preserve the local locked-card refusal for failed decryption. - let plaintext = result.map_err(|_| LOCKED_CARD_REFUSAL.to_string())?; + owner.check_valid()?; + // Keep the local locked-card refusal; remote session/transport/response + // failures must propagate, never masquerade as a malformed card or fallback. + let plaintext = result.map_err(|e| { + if owner.generation().is_some() { + format!("Failed to decrypt card manifest: {e}") + } else { + LOCKED_CARD_REFUSAL.into() + } + })?; if plaintext.len() > NIP44_PLAINTEXT_MAX { return Err(LOCKED_CARD_REFUSAL.into()); } @@ -80,5 +89,6 @@ pub(crate) async fn encode_locked_snapshot_png_with_signer( return Err("Locked card envelope exceeds the maximum size.".into()); } let png = encode_chunk_payload_png(&json, avatar_bytes)?; + owner.check_valid()?; Ok(png) } diff --git a/desktop/src-tauri/src/media_read.rs b/desktop/src-tauri/src/media_read.rs new file mode 100644 index 0000000000..4a810ef38d --- /dev/null +++ b/desktop/src-tauri/src/media_read.rs @@ -0,0 +1,108 @@ +//! Captured identity, relay and lifetime shared by media read transports. +use crate::app_state::AppState; +use crate::commands::media::{sign_blossom_get_auth_header, MEDIA_GET_AUTH_EXPIRY_SECS}; + +/// A media read never looks up replacement credentials after admission. +#[derive(Clone)] +pub(crate) struct MediaReadScope { + operation: Option, + signer: Option, + pub(crate) base: String, +} + +impl MediaReadScope { + pub(crate) fn capture_renderer( + state: &AppState, + generation: Option, + ) -> Result { + let generation_guard = state + .operation_generation + .lock() + .map_err(|e| e.to_string())?; + state.media_read_scope(generation, *generation_guard) + } + + pub(crate) fn new( + signer: Option, + base: String, + operation: Option, + ) -> Self { + Self { + signer, + base, + operation, + } + } + + pub(crate) fn requires_session(&self) -> bool { + self.operation.is_some() + } + + /// Command entrypoints capture before their first await. IPC admission is + /// separate; this pins all downstream signing and HTTP to the same identity. + pub(crate) fn capture_command(state: &AppState) -> Result { + let generation = state.active_signer().ok().and_then(|s| s.generation()); + Self::capture_renderer(state, generation) + } + + /// Only local recovery can read unsigned. Operational signing errors propagate. + pub(crate) async fn authorization(&self) -> Result, String> { + match &self.signer { + Some(signer) => { + match sign_blossom_get_auth_header(signer, &self.base, MEDIA_GET_AUTH_EXPIRY_SECS) + .await + { + Ok(header) => Ok(Some(header)), + Err(error) if !self.requires_session() => { + eprintln!("buzz-desktop: media get auth signing failed (unsigned request): {error}"); + Ok(None) + } + Err(error) => Err(error), + } + } + None => Ok(None), + } + } + + /// Serialize a final native effect with session replacement. No await and no + /// credential lookup is allowed inside this effect. Local behavior is unchanged. + pub(crate) fn commit( + &self, + state: &AppState, + effect: impl FnOnce() -> Result, + ) -> Result { + let _admission = self + .operation + .as_ref() + .map(|operation| operation.admit(state)) + .transpose()?; + effect() + } + + pub(crate) async fn run( + &self, + work: impl std::future::Future>, + ) -> Result { + match &self.signer { + Some(signer) => signer.run(work).await, + None => work.await, + } + } +} + +impl<'de, R: tauri::Runtime> tauri::ipc::CommandArg<'de, R> for MediaReadScope { + fn from_command( + command: tauri::ipc::CommandItem<'de, R>, + ) -> Result { + let state = command + .message + .state_ref() + .try_get::() + .ok_or_else(|| tauri::ipc::InvokeError::from("native app state unavailable"))?; + let generation = crate::invocation_authority::invocation_generation( + command.message.payload(), + command.message.headers(), + ); + Self::capture_renderer(&state, generation).map_err(Into::into) + } +} diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs index 6ef7ca8f6d..d89a5e9f1c 100644 --- a/desktop/src-tauri/src/native_relay_client.rs +++ b/desktop/src-tauri/src/native_relay_client.rs @@ -91,7 +91,7 @@ pub(crate) struct NativeRelayClient { } struct ManagedSession { - scope: (String, String), + scope: (String, String, Option), session: Arc, } @@ -145,8 +145,16 @@ impl NativeRelayClient { relay_url: String, signer: ActiveUserSigner, ) -> Arc { - let scope = (relay_url.clone(), signer.public_key().to_hex()); + let scope = ( + relay_url.clone(), + signer.public_key().to_hex(), + signer.generation(), + ); let mut current = self.current.lock().await; + if signer.check_valid().is_err() { + return start_managed(relay_url, signer, None); + } + if let Some(managed) = current.as_ref().filter(|managed| managed.scope == scope) { return Arc::clone(&managed.session); } @@ -196,8 +204,28 @@ impl NativeRelayClient { relay_url: String, signer: ActiveUserSigner, ) -> SessionLease { - let scope = (relay_url.clone(), signer.public_key().to_hex()); + let scope = ( + relay_url.clone(), + signer.public_key().to_hex(), + signer.generation(), + ); let mut current = self.current.lock().await; + // Old private leases cannot refill a cleared cache, even on same-key login. + if signer.check_valid().is_err() { + return SessionLease { + session: start_managed(relay_url, signer, None), + private: true, + }; + } + if current + .as_ref() + .is_some_and(|s| s.session.signer.check_valid().is_err()) + { + if let Some(old) = current.take() { + old.session.shutdown(); + } + } + if let Some(managed) = current.as_ref() { return if managed.scope == scope { SessionLease { @@ -245,6 +273,7 @@ impl NativeRelayClient { } pub(crate) struct RelaySession { + signer: ActiveUserSigner, state: Arc>, requests: Arc>>, /// The archive is the sole persistent-event consumer. Sending through its @@ -322,32 +351,39 @@ impl RelaySession { filter: serde_json::Value, timeout: Duration, ) -> Result, String> { + self.signer.check_valid()?; let id = format!("native-fetch-{}", uuid::Uuid::new_v4()); - let (complete, result) = oneshot::channel(); - self.requests.lock().await.insert( - id.clone(), - PendingRequest { - events: Vec::new(), - complete, - }, - ); - { - let mut state = self.state.lock().await; - state.transient.push(Subscription { - id: id.clone(), - filter, - }); - } - let _ = self.wake.try_send(()); + let outcome = self + .signer + .run(async { + let (complete, result) = oneshot::channel(); + self.requests.lock().await.insert( + id.clone(), + PendingRequest { + events: Vec::new(), + complete, + }, + ); + { + let mut state = self.state.lock().await; + state.transient.push(Subscription { + id: id.clone(), + filter, + }); + } + let _ = self.wake.try_send(()); - let outcome = tokio::select! { - _ = self.cancel.cancelled() => Err("relay session cancelled".to_string()), - value = tokio::time::timeout(timeout, result) => match value { - Ok(Ok(value)) => value, - Ok(Err(_)) => Err("relay request ended before EOSE".to_string()), - Err(_) => Err("relay request timed out".to_string()), - } - }; + let outcome = tokio::select! { + _ = self.cancel.cancelled() => Err("relay session cancelled".to_string()), + value = tokio::time::timeout(timeout, result) => match value { + Ok(Ok(value)) => value, + Ok(Err(_)) => Err("relay request ended before EOSE".to_string()), + Err(_) => Err("relay request timed out".to_string()), + } + }; + outcome + }) + .await; self.finish_request(&id).await; outcome } @@ -420,6 +456,7 @@ fn start_managed( ) -> Arc { let (wake, wake_rx) = mpsc::channel(1); let session = Arc::new(RelaySession { + signer: signer.clone(), state: Arc::new(Mutex::new(SessionState::default())), requests: Arc::new(Mutex::new(HashMap::new())), archive_events: Arc::new(Mutex::new(None)), @@ -427,13 +464,23 @@ fn start_managed( cancel: CancellationToken::new(), }); - tauri::async_runtime::spawn(run_session( - relay_url, - signer, - auth_tag, - Arc::clone(&session), - wake_rx, - )); + let task_session = Arc::clone(&session); + tauri::async_runtime::spawn(async move { + let _ = signer + .run(async { + run_session( + relay_url, + signer.clone(), + auth_tag, + task_session.clone(), + wake_rx, + ) + .await; + Ok(()) + }) + .await; + task_session.shutdown(); + }); session } diff --git a/desktop/src-tauri/src/owner_authorization.rs b/desktop/src-tauri/src/owner_authorization.rs index 9ab73a6119..b23110b643 100644 --- a/desktop/src-tauri/src/owner_authorization.rs +++ b/desktop/src-tauri/src/owner_authorization.rs @@ -50,12 +50,14 @@ impl OwnerAuthorizationScope { state: &'a AppState, ) -> Result, String> { let guard = self.operation.admit(state)?; + self.signer.check_valid()?; let current = if self.legacy_recovery { state.legacy_local_signer()? } else { state.active_signer()? }; - if current.public_key() != self.signer.public_key() + if current.generation() != self.signer.generation() + || current.public_key() != self.signer.public_key() || crate::relay::relay_api_base_url_with_override(state) != self.relay_base { return Err("owner authorization scope changed; retry operation".into()); @@ -76,6 +78,7 @@ pub(crate) struct AuthorizedAgent { /// Shared production mint boundary for create and both snapshot import callers. /// Human custody comes only from `owner`; fresh keys belong only to the new agent. pub(crate) async fn prepare_agent(owner: &ActiveUserSigner) -> Result { + owner.check_valid()?; let keys = Keys::generate(); let auth_tag = Some(owner.authorize_agent(&keys.public_key(), "").await?); let private_key_nsec = keys.secret_key().to_bech32().map_err(|e| e.to_string())?; diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index b483852ca2..33cdc849ab 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -381,21 +381,7 @@ pub async fn query_relay_at( filters: &[serde_json::Value], ) -> Result, String> { let signer = state.legacy_local_signer()?; - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/query", api_base_url); - let body_bytes = - serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; - let auth = - build_nip98_auth_header_for_signer(&signer, &Method::POST, &url, &body_bytes).await?; - send_query_request( - &state.http_client, - &url, - &auth, - None, - body_bytes, - QUERY_REQUEST_TIMEOUT, - ) - .await + query_relay_at_with_signer(state, api_base_url, filters, &signer, None).await } pub async fn query_relay_at_with_keys( @@ -429,20 +415,25 @@ pub(crate) async fn query_relay_at_with_signer( signer: &ActiveUserSigner, auth_tag: Option<&str>, ) -> Result, String> { - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/query", api_base_url); - let body_bytes = - serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; - let auth = build_nip98_auth_header_for_signer(signer, &Method::POST, &url, &body_bytes).await?; - send_query_request( - &state.http_client, - &url, - &auth, - auth_tag, - body_bytes, - QUERY_REQUEST_TIMEOUT, - ) - .await + signer + .run(async { + crate::relay_admission::wait_for_rate_limit().await; + let url = format!("{}/query", api_base_url); + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| format!("filter serialization failed: {e}"))?; + let auth = build_nip98_auth_header_for_signer(signer, &Method::POST, &url, &body_bytes) + .await?; + send_query_request( + &state.http_client, + &url, + &auth, + auth_tag, + body_bytes, + QUERY_REQUEST_TIMEOUT, + ) + .await + }) + .await } /// Issue an authenticated `POST /query` and parse the response, applying the @@ -671,42 +662,50 @@ pub(crate) async fn submit_signed_event_at_with_auth( signer: &ActiveUserSigner, auth_tag: Option<&str>, ) -> Result { - if event.pubkey != signer.public_key() { - return Err("signed event does not match the publishing identity".to_string()); - } - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/events", api_base_url.trim_end_matches('/')); - let body_bytes = event.as_json().into_bytes(); - crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; - let auth_header = - build_nip98_auth_header_for_signer(signer, &Method::POST, &url, &body_bytes).await?; + signer + .run(async { + if event.pubkey != signer.public_key() { + return Err("signed event does not match the publishing identity".to_string()); + } + crate::relay_admission::wait_for_rate_limit().await; + let url = format!("{}/events", api_base_url.trim_end_matches('/')); + let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes( + &body_bytes, + "signed event submit (keys)", + )?; + let auth_header = + build_nip98_auth_header_for_signer(signer, &Method::POST, &url, &body_bytes) + .await?; - let mut request = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json"); - if let Some(tag) = auth_tag { - request = request.header("x-auth-tag", tag); - } + let mut request = state + .http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } - let response = request - .body(body_bytes) - .send() + let response = request + .body(body_bytes) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + let result: SubmitEventResponse = parse_json_response(response).await?; + + if !result.accepted { + return Err(format!("relay rejected event: {}", result.message)); + } + + Ok(result) + }) .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) } // ── Tests ─────────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 61cea45eae..b6d52fb82f 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -19,36 +19,41 @@ pub(crate) async fn submit_signed_event_at_with_signer( api_base_url: &str, signer: &ActiveUserSigner, ) -> Result { - if event.pubkey != signer.public_key() { - return Err("signed event does not match the publishing identity".to_string()); - } - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/events", api_base_url.trim_end_matches('/')); - let body_bytes = event.as_json().into_bytes(); - crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; - let auth_header = - build_nip98_auth_header_for_signer(signer, &Method::POST, &url, &body_bytes).await?; + signer + .run(async { + if event.pubkey != signer.public_key() { + return Err("signed event does not match the publishing identity".to_string()); + } + crate::relay_admission::wait_for_rate_limit().await; + let url = format!("{}/events", api_base_url.trim_end_matches('/')); + let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; + let auth_header = + build_nip98_auth_header_for_signer(signer, &Method::POST, &url, &body_bytes) + .await?; - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() + let response = state + .http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + let result: SubmitEventResponse = parse_json_response(response).await?; + if !result.accepted { + return Err(format!("relay rejected event: {}", result.message)); + } + + Ok(result) + }) .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) } /// Sign with an explicit identity and POST the event to an explicit relay. diff --git a/desktop/src-tauri/src/user_operation.rs b/desktop/src-tauri/src/user_operation.rs index cb56e3785f..d0e200085a 100644 --- a/desktop/src-tauri/src/user_operation.rs +++ b/desktop/src-tauri/src/user_operation.rs @@ -8,6 +8,7 @@ pub(crate) struct UserOperationScope { pub(crate) owner_pubkey: nostr::PublicKey, pub(crate) relay_url: String, generation: u64, + signer: crate::active_user_signer::ActiveUserSigner, } impl UserOperationScope { @@ -26,6 +27,7 @@ impl UserOperationScope { owner_pubkey: state.identity_public_key()?, relay_url: crate::relay::relay_ws_url_with_override(state), generation, + signer: state.legacy_local_signer()?, }) } @@ -40,6 +42,7 @@ impl UserOperationScope { .operation_generation .lock() .map_err(|e| e.to_string())?; + self.signer.check_valid()?; if *generation != self.generation || state.identity_public_key()? != self.owner_pubkey || crate::relay::relay_ws_url_with_override(state) != self.relay_url @@ -60,6 +63,18 @@ impl<'de, R: tauri::Runtime> tauri::ipc::CommandArg<'de, R> for UserOperationSco .try_get::() .ok_or_else(|| tauri::ipc::InvokeError::from("native app state unavailable"))?; let operation = Self::capture(&state)?; + if operation.signer.generation().is_some() { + let generation = crate::invocation_authority::invocation_generation( + command.message.payload(), + command.message.headers(), + ); + let signer = state.renderer_signer(generation)?; + if signer.generation() != operation.signer.generation() { + return Err( + "renderer operation requires the current native identity generation".into(), + ); + } + } Ok(operation) } } diff --git a/docs/local-user-capabilities.md b/docs/local-user-capabilities.md index 301f2177a8..3a98b1ff34 100644 --- a/docs/local-user-capabilities.md +++ b/docs/local-user-capabilities.md @@ -13,6 +13,7 @@ versions of messages, profiles, snapshots, or agent-management features. | Boundary | Local implementation and callers | Second-branch responsibility | | --- | --- | --- | +| Captured lifetime | `CapabilityLifetime`, optional on `ActiveUserSigner`; absent for local keys; shared signing/query/publication boundaries execute through `run` | Implement immutable validity/cancellation and same-key replacement generation; no credential lookup in shared callers | | Public identity | `AppState::identity_public_key`, cached `ActiveUserSigner::public_key` | Obtain a stable public identity without a local secret | | Event signing | `ActiveUserSigner` wraps `nostr::NostrSigner`; event builders stay unchanged | Implement structured-event signing and verify the returned artifact | | NIP-04/NIP-44 | The existing `NostrSigner` crypto methods | Supply supported ciphers; report unsupported operations explicitly |