From 20b299f24a394cd1ca1a684ab0d106db2a21849e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 22 Jun 2026 19:22:14 -0400 Subject: [PATCH 01/12] feat: encrypt 2-party DMs end-to-end with a relay-owned latch (Phase 1) Phase 1 of hybrid E2E encryption for DMs: 2-party pairwise NIP-44, reusing the engram/observer "store ciphertext the relay can't read" pattern. A new `encryption_activated_at` latch column on channels (migration 0004) marks a DM as E2E from creation. It is relay-owned and write-once at the `create_dm` INSERT -- `ChannelUpdate` has no such field, so the dynamic update path structurally cannot move or clear it, making the encryption-start boundary tamper-evident by construction. Only 2-party DMs latch; group DMs (3-9) stay plaintext until Phase 2 brings group keys, since pairwise NIP-44 has no single peer to encrypt to. Ingest rule 15c enforces the boundary fail-visible: a latched channel rejects any kind:9 that is not NIP-44 v2 ciphertext (strong validator: base64 + decoded-len >= 99 + 0x02 version byte). Enforcement is latch-PRESENCE only -- no `created_at` comparison -- so a backdated timestamp (drift window or the clamp-exempt proxy:submit path) cannot smuggle plaintext below the latch. Dispatch skips search indexing and workflow triggers for private/DM channels (fail-closed) so ciphertext never reaches Typesense. Desktop gains `nip44_encrypt_to_peer`/`nip44_decrypt_from_peer` Tauri commands (private key stays in Rust) plus TS bindings. Encrypt-on-send and decrypt-on-render in the message pipeline are a follow-up (Phase 1b). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/observer.rs | 153 +++++++++++++++++++ crates/buzz-db/src/channel.rs | 22 ++- crates/buzz-db/src/dm.rs | 49 +++++- crates/buzz-db/src/migration.rs | 12 +- crates/buzz-relay/src/handlers/event.rs | 24 ++- crates/buzz-relay/src/handlers/ingest.rs | 117 +++++++------- crates/buzz-sdk/src/builders.rs | 143 ++++++++++++++++- crates/buzz-sdk/src/lib.rs | 3 + desktop/scripts/check-file-sizes.mjs | 3 +- desktop/src-tauri/src/commands/identity.rs | 34 +++++ desktop/src-tauri/src/lib.rs | 2 + desktop/src/shared/api/tauri.ts | 22 +++ desktop/src/testing/e2eBridge.ts | 4 + migrations/0004_channel_encryption_latch.sql | 14 ++ schema/schema.sql | 1 + 15 files changed, 518 insertions(+), 85 deletions(-) create mode 100644 migrations/0004_channel_encryption_latch.sql diff --git a/crates/buzz-core/src/observer.rs b/crates/buzz-core/src/observer.rs index f2b981188..5a741e7de 100644 --- a/crates/buzz-core/src/observer.rs +++ b/crates/buzz-core/src/observer.rs @@ -51,6 +51,93 @@ pub fn content_looks_like_nip44(content: &str) -> bool { (NIP44_MIN_CONTENT_LEN..=NIP44_MAX_CONTENT_LEN).contains(&content.len()) } +/// Strong syntactic validation that `content` is a plausible NIP-44 v2 +/// ciphertext payload, beyond the length-envelope check of +/// [`content_looks_like_nip44`]. +/// +/// Checks: +/// - Standard base64 alphabet only (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`), with +/// padding only at the end and total length a multiple of 4. +/// - Decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + minimum 34 +/// bytes of length-prefixed padded ciphertext required by NIP-44 v2). +/// - First decoded byte is `0x02` (NIP-44 version 2). +/// +/// This is an envelope sanity check, not full validation: the MAC and actual +/// decryption happen at the reader. The relay cannot decrypt, so this is the +/// strongest guard it can apply to refuse a plaintext-leak ("client forgot to +/// encrypt") without holding any key. Use this — not the length-only +/// [`content_looks_like_nip44`] — anywhere a fail-visible "must be encrypted" +/// boundary is enforced. +pub fn validate_nip44_v2(content: &str) -> Result<(), Nip44SyntaxError> { + use Nip44SyntaxError::*; + if content.is_empty() { + return Err(Empty); + } + let bytes = content.as_bytes(); + let len = bytes.len(); + if !len.is_multiple_of(4) { + return Err(NotBase64); + } + let mut pad_count = 0usize; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => { + if pad_count > 0 { + return Err(NotBase64); + } + } + b'=' => { + if i < len - 2 { + return Err(NotBase64); + } + pad_count += 1; + if pad_count > 2 { + return Err(NotBase64); + } + } + _ => return Err(NotBase64), + } + } + let decoded_len = (len / 4) * 3 - pad_count; + if decoded_len < 99 { + return Err(TooShort); + } + let b64_val = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let v0 = b64_val(bytes[0]).ok_or(NotBase64)?; + let v1 = b64_val(bytes[1]).ok_or(NotBase64)?; + let first_byte = (v0 << 2) | (v1 >> 4); + if first_byte != 0x02 { + return Err(WrongVersion); + } + Ok(()) +} + +/// Reasons [`validate_nip44_v2`] rejects content as non-NIP-44-v2 ciphertext. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Nip44SyntaxError { + /// Content was empty. + #[error("content must not be empty (NIP-44 ciphertext)")] + Empty, + /// Content was not valid standard base64. + #[error("content is not valid base64")] + NotBase64, + /// Decoded content was shorter than the NIP-44 v2 minimum (99 bytes). + #[error("content too short for NIP-44 v2")] + TooShort, + /// First decoded byte was not the NIP-44 version-2 prefix (`0x02`). + #[error("content is not NIP-44 v2 (expected 0x02 version prefix)")] + WrongVersion, +} + /// Serialize and NIP-44 encrypt an observer payload for `recipient`. pub fn encrypt_observer_payload( sender_keys: &Keys, @@ -153,4 +240,70 @@ mod tests { Err(ObserverPayloadError::InvalidCiphertextLength(_)) )); } + + #[test] + fn test_validate_nip44_v2_accepts_real_ciphertext() { + let sender = Keys::generate(); + let recipient = Keys::generate(); + let encrypted = nip44::encrypt( + sender.secret_key(), + &recipient.public_key(), + "hello over an encrypted channel", + nip44::Version::V2, + ) + .expect("encrypt"); + assert_eq!(validate_nip44_v2(&encrypted), Ok(())); + } + + #[test] + fn test_validate_nip44_v2_rejects_long_plaintext_in_length_envelope() { + // A long plaintext message passes the length-only `content_looks_like_nip44` + // check but must be rejected by the strong validator (D2 — the whole point). + // Spaces are outside the base64 alphabet, so a human-readable message fails fast. + let plaintext = + "this is a long plaintext message a careless client forgot to encrypt ".repeat(2); + assert!(content_looks_like_nip44(&plaintext)); + assert_eq!( + validate_nip44_v2(&plaintext), + Err(Nip44SyntaxError::NotBase64) + ); + } + + #[test] + fn test_validate_nip44_v2_rejects_empty() { + assert_eq!(validate_nip44_v2(""), Err(Nip44SyntaxError::Empty)); + } + + #[test] + fn test_validate_nip44_v2_rejects_wrong_version_byte() { + // Take a real v2 ciphertext (first decoded byte 0x02 -> base64 starts "Ag") + // and flip the leading base64 char so the decoded version byte is no longer + // 0x02, while keeping valid base64 and length. 'A'(0) -> 'B'(1) shifts the + // first decoded byte off 0x02 without changing any other property. + let sender = Keys::generate(); + let recipient = Keys::generate(); + let ct = nip44::encrypt( + sender.secret_key(), + &recipient.public_key(), + "payload", + nip44::Version::V2, + ) + .expect("encrypt"); + assert!( + ct.starts_with('A'), + "v2 ciphertext base64 should start with 'A'" + ); + let mutated = format!("B{}", &ct[1..]); + assert_eq!( + validate_nip44_v2(&mutated), + Err(Nip44SyntaxError::WrongVersion) + ); + } + + #[test] + fn test_validate_nip44_v2_rejects_too_short() { + // "AgAA" decodes to [0x02, 0x00, 0x00] — correct version prefix, only 3 + // bytes, under the 99-byte NIP-44 v2 minimum. + assert_eq!(validate_nip44_v2("AgAA"), Err(Nip44SyntaxError::TooShort)); + } } diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index f87646ccf..32e12fd6f 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -62,6 +62,13 @@ pub struct ChannelRecord { pub ttl_seconds: Option, /// Deadline by which a new message must arrive or the channel is auto-archived. pub ttl_deadline: Option>, + /// Point from which messages in this channel MUST be NIP-44 v2 ciphertext. + /// + /// The tamper-evident encryption-start marker for hybrid E2E. Relay-owned: + /// set by the relay (at DM creation) and never writable by a client, so the + /// boundary cannot be forged or backdated. `None` means the channel is not + /// E2E (legacy plaintext stays readable). + pub encryption_activated_at: Option>, } /// A channel membership row as returned from the database. @@ -143,7 +150,7 @@ pub async fn create_channel( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, encryption_activated_at FROM channels WHERE id = $1 "#, ) @@ -234,7 +241,7 @@ pub async fn create_channel_with_id( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, encryption_activated_at FROM channels WHERE id = $1 "#, ) @@ -257,7 +264,7 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result) -> Result) -> Result Result { let purpose_set_at: Option> = row.try_get("purpose_set_at").unwrap_or(None); let ttl_seconds: Option = row.try_get("ttl_seconds").unwrap_or(None); let ttl_deadline: Option> = row.try_get("ttl_deadline").unwrap_or(None); + let encryption_activated_at: Option> = + row.try_get("encryption_activated_at").unwrap_or(None); Ok(ChannelRecord { id, @@ -884,6 +893,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose_set_at, ttl_seconds, ttl_deadline, + encryption_activated_at, }) } diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/dm.rs index 7684ac7d2..e202db56b 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/dm.rs @@ -72,7 +72,7 @@ pub async fn find_dm_by_participants( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at + purpose, purpose_set_by, purpose_set_at, encryption_activated_at FROM channels WHERE participant_hash = $1 AND channel_type = 'dm' @@ -87,6 +87,18 @@ pub async fn find_dm_by_participants( row.map(row_to_channel_record).transpose() } +/// Whether a newly created DM with `participant_count` members is latched as +/// end-to-end encrypted at creation. +/// +/// Phase 1 encryption is pairwise NIP-44, which only has a single peer for a +/// 2-party DM. Group DMs (3-9) have no single pairwise peer, so they stay +/// unlatched (legacy plaintext) until Phase 2 introduces group keys; latching +/// them would make them unsendable because ingest rule 15c rejects every +/// non-ciphertext kind:9 in a latched channel. +fn dm_latches_at_creation(participant_count: usize) -> bool { + participant_count == 2 +} + /// Create a new DM channel for the given participant pubkeys, or return the /// existing one if a DM with the same participant set already exists. /// @@ -130,7 +142,7 @@ pub async fn create_dm( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at + purpose, purpose_set_by, purpose_set_at, encryption_activated_at FROM channels WHERE participant_hash = $1 AND channel_type = 'dm' @@ -156,17 +168,25 @@ pub async fn create_dm( let id = Uuid::new_v4(); + // Latch 2-party DMs as E2E at creation (see `dm_latches_at_creation`). The + // latch is set only here, server-side via NOW(); it is relay-owned, never + // client-writable, and has no UPDATE path (`ChannelUpdate` has no such + // field), so the encryption-start boundary can't be backdated or moved. + let latch_at_creation = dm_latches_at_creation(participants.len()); sqlx::query( r#" INSERT INTO channels - (id, name, channel_type, visibility, created_by, participant_hash) - VALUES ($1, $2, 'dm', 'private', $3, $4) + (id, name, channel_type, visibility, created_by, participant_hash, + encryption_activated_at) + VALUES ($1, $2, 'dm', 'private', $3, $4, + CASE WHEN $5 THEN NOW() ELSE NULL END) "#, ) .bind(id) .bind(&name) .bind(created_by) .bind(hash.as_slice()) + .bind(latch_at_creation) .execute(&mut *tx) .await?; @@ -196,7 +216,7 @@ pub async fn create_dm( created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, - purpose, purpose_set_by, purpose_set_at + purpose, purpose_set_by, purpose_set_at, encryption_activated_at FROM channels WHERE id = $1 "#, ) @@ -467,6 +487,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None), ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None), ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None), + encryption_activated_at: row.try_get("encryption_activated_at").unwrap_or(None), }) } @@ -510,4 +531,22 @@ mod tests { let h = compute_participant_hash(&[&a, &b]); assert_eq!(h.len(), 32); } + + #[test] + fn two_party_dm_latches_at_creation() { + assert!( + dm_latches_at_creation(2), + "2-party DMs are E2E-by-default and must latch at creation" + ); + } + + #[test] + fn group_dm_does_not_latch_at_creation() { + for count in 3..=9 { + assert!( + !dm_latches_at_creation(count), + "group DM of {count} must stay unlatched (Phase 1 has no group key)" + ); + } + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index f4f3c9ab3..93738b396 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -128,7 +128,7 @@ mod tests { fn embedded_migrator_contains_all_schema_migrations() { let migrations: Vec<_> = MIGRATOR.iter().collect(); - assert_eq!(migrations.len(), 3); + assert_eq!(migrations.len(), 4); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!( @@ -160,6 +160,16 @@ mod tests { && migrations[2].sql.as_str().contains("idx_events_not_before"), "third migration should add the NIP-ER reminder columns and index" ); + + assert_eq!(migrations[3].version, 4); + assert_eq!(&*migrations[3].description, "channel encryption latch"); + assert!( + migrations[3] + .sql + .as_str() + .contains("ADD COLUMN encryption_activated_at"), + "fourth migration should add the E2E encryption latch column" + ); } async fn connect_test_pool() -> PgPool { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 1c3f17d66..bd8051a94 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -185,12 +185,33 @@ pub(crate) async fn dispatch_persistent_event( ); } + // Channel visibility gates content-reading side effects (D1). Private and + // DM channels are end-to-end encrypted — their kind:9 content is ciphertext, + // so indexing it (`index.rs` stores raw content) would leak garbage into + // Typesense, and workflow rules matching on content cannot fire. The + // existing skips are kind-keyed (gift wraps only); E2E kind:9 messages are + // NOT gift wraps, so we add a visibility-keyed skip at the decision site. + // Fail closed: if visibility can't be determined, treat as private so we + // never index a possibly-encrypted channel's content. + let channel_is_private = match stored_event.channel_id { + Some(channel_id) => match state.channel_visibility_cached(channel_id).await { + Ok(v) => v == "private", + Err(e) => { + warn!(%channel_id, "index/workflow skip: visibility lookup failed, treating as private: {e}"); + true + } + }, + None => false, + }; + // Skip search indexing for NIP-17 gift wraps (ciphertext), NIP-DV // visibility snapshots (per-viewer private hide state, owner-gated reads), - // and author-only kinds (ciphertext not useful in search, defense in depth). + // author-only kinds (ciphertext not useful in search, defense in depth), + // and any private/DM channel (E2E content — see channel_is_private above). if kind_u32 != KIND_GIFT_WRAP && kind_u32 != buzz_core::kind::KIND_DM_VISIBILITY && !AUTHOR_ONLY_KINDS.contains(&kind_u32) + && !channel_is_private && state .search_index_tx .try_send(stored_event.clone()) @@ -232,6 +253,7 @@ pub(crate) async fn dispatch_persistent_event( && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP + && !channel_is_private { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 3b3123e64..4f32a9c2c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -887,73 +887,14 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> { /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// -/// Checks: -/// - Non-empty. -/// - Standard base64 alphabet only (A-Z, a-z, 0-9, +, /, =), with padding only -/// at the end and total length a multiple of 4. -/// - Decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + minimum 34 -/// bytes of length-prefixed padded ciphertext required by NIP-44 v2). -/// - First decoded byte is `0x02` (NIP-44 version 2). -/// -/// This is an envelope sanity check, not full validation: the MAC and actual -/// decryption happen at the reader. The intent is to refuse obvious junk so a -/// malformed event cannot win NIP-33 replacement against a valid head and then -/// be silently skipped by `validate_and_decrypt`. Mirrors the validator in -/// `buzz-pair-relay::validate_nip44_content`. +/// Delegates to [`buzz_core::observer::validate_nip44_v2`] — the single source +/// of truth for the NIP-44 v2 envelope check — and prefixes the error with +/// engram context. We do not (and cannot) verify the MAC at the relay, but we +/// reject obvious garbage so a malformed event cannot supersede a valid head +/// via NIP-33 replacement and then be silently discarded by readers. fn validate_engram_nip44_content(content: &str) -> Result<(), String> { - if content.is_empty() { - return Err("agent-engram content must not be empty (NIP-44 ciphertext)".to_string()); - } - let bytes = content.as_bytes(); - let len = bytes.len(); - if !len.is_multiple_of(4) { - return Err("agent-engram content is not valid base64 (length)".to_string()); - } - let mut pad_count = 0usize; - for (i, &b) in bytes.iter().enumerate() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => { - if pad_count > 0 { - return Err("agent-engram content is not valid base64".to_string()); - } - } - b'=' => { - if i < len - 2 { - return Err("agent-engram content is not valid base64".to_string()); - } - pad_count += 1; - if pad_count > 2 { - return Err("agent-engram content is not valid base64".to_string()); - } - } - _ => return Err("agent-engram content is not valid base64".to_string()), - } - } - let decoded_len = (len / 4) * 3 - pad_count; - if decoded_len < 99 { - return Err("agent-engram content too short for NIP-44 v2".to_string()); - } - let b64_val = |c: u8| -> Option { - match c { - b'A'..=b'Z' => Some(c - b'A'), - b'a'..=b'z' => Some(c - b'a' + 26), - b'0'..=b'9' => Some(c - b'0' + 52), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } - }; - let v0 = - b64_val(bytes[0]).ok_or_else(|| "agent-engram content is not valid base64".to_string())?; - let v1 = - b64_val(bytes[1]).ok_or_else(|| "agent-engram content is not valid base64".to_string())?; - let first_byte = (v0 << 2) | (v1 >> 4); - if first_byte != 0x02 { - return Err( - "agent-engram content is not NIP-44 v2 (expected 0x02 version prefix)".to_string(), - ); - } - Ok(()) + buzz_core::observer::validate_nip44_v2(content) + .map_err(|e| format!("agent-engram content invalid: {e}")) } /// Parse a NIP-ER `not_before` tag value into a Unix timestamp. @@ -1483,6 +1424,50 @@ pub async fn ingest_event( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + // ── 15c. E2E DM ciphertext enforcement (D2) ────────────────────────── + // A DM channel with the encryption latch set (`encryption_activated_at`) + // is end-to-end encrypted. The relay cannot decrypt, so it enforces the + // boundary syntactically: in a latched channel, EVERY incoming kind:9 MUST + // be NIP-44 v2 ciphertext, or it is rejected fail-visible — never silently + // stored as plaintext. We use the strong validator, not the length-only + // check, or a long plaintext message in range would pass. + // + // Enforcement is latch-PRESENCE only — it deliberately does NOT compare the + // event's `created_at` against the latch timestamp. A `created_at >= latch` + // comparator at ingest would be backdateable: the relay clamps `created_at` + // to ±15 min of server time, and `proxy:submit` events skip that clamp + // entirely, so a member could backdate a plaintext message below the latch + // and dodge enforcement. Since legacy plaintext is already stored and + // nothing new should legitimately land pre-latch, "latched ⇒ must be + // NIP-44" is the safe, time-independent rule. The `created_at >= latch` + // comparison belongs to the read/render path (deciding how to display + // already-stored events), where untrusted client time is harmless. + // + // Fail-visible on lookup error: a genuine DB error must NOT let the message + // through, or plaintext could be silently stored into a latched channel — + // the exact leak this guard prevents. A `ChannelNotFound` is different: the + // channel cannot have a latch, and the event is rejected downstream at + // insert, so it falls through here to keep that existing not-found behavior. + if kind_u32 == KIND_STREAM_MESSAGE { + if let Some(ch_id) = channel_id { + match state.db.get_channel(ch_id).await { + Ok(channel) => { + if channel.encryption_activated_at.is_some() { + buzz_core::observer::validate_nip44_v2(&event.content).map_err(|_| { + IngestError::Rejected( + "invalid: private-channel content must be NIP-44 encrypted".into(), + ) + })?; + } + } + Err(buzz_db::DbError::ChannelNotFound(_)) => {} + Err(e) => { + return Err(IngestError::Rejected(format!("error: database error: {e}"))); + } + } + } + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 205d010d2..efa5a5cd3 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -16,7 +16,7 @@ use buzz_core::{ OBSERVER_FRAME_TELEMETRY, }, }; -use nostr::{EventBuilder, Kind, Tag}; +use nostr::{nips::nip44, EventBuilder, Keys, Kind, PublicKey, Tag}; use uuid::Uuid; use crate::{ @@ -226,16 +226,64 @@ pub fn build_message( media_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; + let mut tags = message_tags(channel_id, thread_ref, mentions, media_tags)?; + if broadcast { + tags.push(tag(&["broadcast", "1"])?); + } + Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) +} + +/// Assemble the common kind:9 tag set: channel `h`, NIP-10 thread context, +/// mention `p`-tags, and `imeta` media tags. Shared by [`build_message`] and +/// [`build_dm_message`] so the two stay in lockstep. +fn message_tags( + channel_id: Uuid, + thread_ref: Option<&ThreadRef>, + mentions: &[&str], + media_tags: &[Vec], +) -> Result, SdkError> { let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; if let Some(tr) = thread_ref { thread_tags(tr, &mut tags)?; } mention_tags(mentions, &mut tags)?; - if broadcast { - tags.push(tag(&["broadcast", "1"])?); - } imeta_tags(media_tags, &mut tags)?; - Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) + Ok(tags) +} + +// ── Builder: build_dm_message ──────────────────────────────────────────────── + +/// Build an end-to-end encrypted DM message (kind 9). +/// +/// NIP-44-encrypts `content` to `peer_pubkey` using `sender_keys`, then builds +/// the kind:9 with the same tag set as [`build_message`] (channel `h`, thread +/// context, mentions, media) — the relay routes and audits on those cleartext +/// tags and never sees the plaintext. Mentions and thread refs are derived from +/// cleartext at send time, so they survive encryption. +/// +/// - `sender_keys`: the sender's keypair (its secret key derives the NIP-44 +/// conversation key). +/// - `peer_pubkey`: the DM peer's 64-char hex pubkey (the other participant). +/// - `content`: plaintext message text (max 64 KiB before encryption). +/// +/// DMs are never broadcast, so there is no `broadcast` parameter. +pub fn build_dm_message( + sender_keys: &Keys, + peer_pubkey: &str, + channel_id: Uuid, + content: &str, + thread_ref: Option<&ThreadRef>, + mentions: &[&str], + media_tags: &[Vec], +) -> Result { + check_content(content, 64 * 1024)?; + let peer = check_pubkey_hex(peer_pubkey, "peer_pubkey")?; + let peer = PublicKey::from_hex(&peer) + .map_err(|e| SdkError::InvalidInput(format!("peer_pubkey is not a valid pubkey: {e}")))?; + let encrypted = nip44::encrypt(sender_keys.secret_key(), &peer, content, nip44::Version::V2) + .map_err(|e| SdkError::Encryption(e.to_string()))?; + let tags = message_tags(channel_id, thread_ref, mentions, media_tags)?; + Ok(EventBuilder::new(Kind::Custom(9), encrypted).tags(tags)) } // ── Builder: build_agent_observer_frame ───────────────────────────────────── @@ -2972,4 +3020,89 @@ mod tests { let err = build_presence_update("dnd").unwrap_err(); assert!(matches!(err, SdkError::InvalidInput(_))); } + + // ── build_dm_message ────────────────────────────────────────────────────── + + #[test] + fn test_build_dm_message_produces_encrypted_kind9() { + let sender = keys(); + let peer = keys(); + let cid = uuid(); + let ev = build_dm_message( + &sender, + &peer.public_key().to_hex(), + cid, + "secret", + None, + &[], + &[], + ) + .unwrap() + .sign_with_keys(&sender) + .expect("sign"); + + assert_eq!(ev.kind.as_u16(), 9); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert_ne!( + ev.content, "secret", + "content must be ciphertext, not plaintext" + ); + assert_eq!( + buzz_core::observer::validate_nip44_v2(&ev.content), + Ok(()), + "content must pass the relay's strong NIP-44 validator" + ); + } + + #[test] + fn test_build_dm_message_round_trips_to_peer() { + let sender = keys(); + let peer = keys(); + let ev = build_dm_message( + &sender, + &peer.public_key().to_hex(), + uuid(), + "hello peer", + None, + &[], + &[], + ) + .unwrap() + .sign_with_keys(&sender) + .expect("sign"); + + let decrypted = + nostr::nips::nip44::decrypt(peer.secret_key(), &sender.public_key(), &ev.content) + .expect("peer decrypts"); + assert_eq!(decrypted, "hello peer"); + } + + #[test] + fn test_build_dm_message_keeps_mention_tags_in_cleartext() { + let sender = keys(); + let peer = keys(); + let mentioned = peer.public_key().to_hex(); + let ev = build_dm_message( + &sender, + &peer.public_key().to_hex(), + uuid(), + "@peer hi", + None, + &[&mentioned], + &[], + ) + .unwrap() + .sign_with_keys(&sender) + .expect("sign"); + // Mentions survive encryption because they are cleartext p-tags. + assert!(tag_values(&ev, "p").contains(&mentioned)); + } + + #[test] + fn test_build_dm_message_rejects_malformed_peer_pubkey() { + let sender = keys(); + let err = + build_dm_message(&sender, "not-a-pubkey", uuid(), "x", None, &[], &[]).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } } diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 517f8a7ca..2d4c319d0 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -113,4 +113,7 @@ pub enum SdkError { /// Input failed validation (e.g. malformed pubkey). #[error("invalid input: {0}")] InvalidInput(String), + /// NIP-44 encryption failed. + #[error("encryption failed: {0}")] + Encryption(String), } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index cc6050e87..4ca6be39e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -44,7 +44,8 @@ const overrides = new Map([ // applyWorkspace reposDir parameter threaded through the Tauri invoke for // configurable repos_dir — a 3-line overage from load-bearing parameter // plumbing, not generic debt growth. Approved override; still queued to split. - ["src/shared/api/tauri.ts", 1198], + // E2E DM crypto wiring adds further plumbing here; ceiling holds both. + ["src/shared/api/tauri.ts", 1218], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2afa45c44..eb853835e 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -269,3 +269,37 @@ pub async fn nip44_decrypt_from_self( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } + +/// NIP-44 v2 encrypt `plaintext` to a DM peer. The private key never leaves the +/// Rust backend — the frontend only sends plaintext + peer pubkey and gets the +/// ciphertext back to embed in the kind:9 it signs. Used for DM encrypt-on-send. +#[tauri::command] +pub fn nip44_encrypt_to_peer( + peer_pubkey: String, + plaintext: String, + state: State<'_, AppState>, +) -> Result { + let peer = + PublicKey::from_hex(peer_pubkey.trim()).map_err(|e| format!("invalid peer pubkey: {e}"))?; + let keys = state.keys.lock().map_err(|e| e.to_string())?; + nip44::encrypt(keys.secret_key(), &peer, &plaintext, nip44::Version::V2) + .map_err(|e| format!("nip44 encrypt failed: {e}")) +} + +/// NIP-44 v2 decrypt DM `ciphertext` from a peer. The peer pubkey is the other +/// party in a 2-party DM (the sender for an inbound message, the recipient for +/// our own echoed message — pairwise ECDH is symmetric, so the same conversation +/// key decrypts both). Used for DM decrypt-on-render; on failure the frontend +/// shows the mixed-version placeholder rather than blank or garbled content. +#[tauri::command] +pub fn nip44_decrypt_from_peer( + peer_pubkey: String, + ciphertext: String, + state: State<'_, AppState>, +) -> Result { + let peer = + PublicKey::from_hex(peer_pubkey.trim()).map_err(|e| format!("invalid peer pubkey: {e}"))?; + let keys = state.keys.lock().map_err(|e| e.to_string())?; + nip44::decrypt(keys.secret_key(), &peer, &ciphertext) + .map_err(|e| format!("nip44 decrypt failed: {e}")) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d9b26841f..ec83a5a24 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -713,6 +713,8 @@ pub fn run() { create_auth_event, nip44_encrypt_to_self, nip44_decrypt_from_self, + nip44_encrypt_to_peer, + nip44_decrypt_from_peer, get_channels, create_channel, open_dm, diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 519f3fd85..8a44595d1 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1160,6 +1160,28 @@ export async function nip44DecryptFromSelf( return invokeTauri("nip44_decrypt_from_self", { ciphertext }); } +// ── NIP-44 DM peer encryption ──────────────────────────────────────────────── + +export async function nip44EncryptToPeer( + peerPubkey: string, + plaintext: string, +): Promise { + return invokeTauri("nip44_encrypt_to_peer", { + peerPubkey, + plaintext, + }); +} + +export async function nip44DecryptFromPeer( + peerPubkey: string, + ciphertext: string, +): Promise { + return invokeTauri("nip44_decrypt_from_peer", { + peerPubkey, + ciphertext, + }); +} + // ── NIP-AB device pairing ─────────────────────────────────────────────────── export async function startPairing(): Promise { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 04f8bfe98..43850ca28 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6803,6 +6803,10 @@ export function maybeInstallE2eTauriMocks() { return (payload as { plaintext: string }).plaintext; case "nip44_decrypt_from_self": return (payload as { ciphertext: string }).ciphertext; + case "nip44_encrypt_to_peer": + return (payload as { plaintext: string }).plaintext; + case "nip44_decrypt_from_peer": + return (payload as { ciphertext: string }).ciphertext; case "create_auth_event": if (identity) { return JSON.stringify( diff --git a/migrations/0004_channel_encryption_latch.sql b/migrations/0004_channel_encryption_latch.sql new file mode 100644 index 000000000..25a82ce70 --- /dev/null +++ b/migrations/0004_channel_encryption_latch.sql @@ -0,0 +1,14 @@ +-- Add the E2E encryption activation latch to the channels table. +-- +-- `encryption_activated_at` marks the point from which messages in this channel +-- MUST be NIP-44 v2 ciphertext. It is the tamper-evident encryption-start +-- marker for hybrid E2E: rather than a free-standing client event (which the +-- relay would have to locate per message via a sibling read, and which a member +-- could backdate), the boundary is relay-owned channel state. The relay sets it +-- and never lets a client write it, so it cannot be forged or moved. +-- +-- Phase 1 sets this at DM creation (every message in a new DM is E2E). Existing +-- plaintext DMs leave it NULL and stay readable as legacy plaintext — no +-- re-encryption, which would change event IDs and break the audit hash-chain. +ALTER TABLE channels + ADD COLUMN encryption_activated_at TIMESTAMPTZ; diff --git a/schema/schema.sql b/schema/schema.sql index 2a839adf6..897614685 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -44,6 +44,7 @@ CREATE TABLE channels ( participant_hash BYTEA, ttl_seconds INT, ttl_deadline TIMESTAMPTZ, + encryption_activated_at TIMESTAMPTZ, CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) ); From c08ee6f3d42242691cfea9a8cdf12c93e4245026 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 22 Jun 2026 21:38:08 -0400 Subject: [PATCH 02/12] fix: enforce E2E ciphertext across all channel-message kinds and key D1 on the latch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 15c gated only kind:9, so a plaintext edit (40003), v2 message (40002), diff (40008), or forum comment (45003) could land in a latched DM and be stored cleartext — the exact leak the relay-owned latch exists to prevent. Gate the full channel-message-kind set the CLI recognizes. D1's index/workflow skip keyed on visibility=="private", but a private GROUP channel has no group key in Phase 1 and is plaintext. The latch (encryption_activated_at), not visibility, is the encryption boundary, so key the skip on it — restoring access-controlled search/workflows for private-group members (search is already membership-gated at query time) while still skipping genuinely-encrypted DMs. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/handlers/event.rs | 34 +++++----- crates/buzz-relay/src/handlers/ingest.rs | 79 ++++++++++++++++++++++-- crates/buzz-relay/src/state.rs | 43 +++++++++++++ 3 files changed, 135 insertions(+), 21 deletions(-) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index bd8051a94..335b94502 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -185,19 +185,22 @@ pub(crate) async fn dispatch_persistent_event( ); } - // Channel visibility gates content-reading side effects (D1). Private and - // DM channels are end-to-end encrypted — their kind:9 content is ciphertext, - // so indexing it (`index.rs` stores raw content) would leak garbage into - // Typesense, and workflow rules matching on content cannot fire. The - // existing skips are kind-keyed (gift wraps only); E2E kind:9 messages are - // NOT gift wraps, so we add a visibility-keyed skip at the decision site. - // Fail closed: if visibility can't be determined, treat as private so we - // never index a possibly-encrypted channel's content. - let channel_is_private = match stored_event.channel_id { - Some(channel_id) => match state.channel_visibility_cached(channel_id).await { - Ok(v) => v == "private", + // Encryption latch gates content-reading side effects (D1). A latched + // channel's kind:9 content is NIP-44 ciphertext, so indexing it (`index.rs` + // stores raw content) would store unreadable bytes into Typesense, and + // workflow rules matching on content cannot fire. The encryption boundary + // is the latch (`encryption_activated_at`), NOT channel visibility: a + // `private` group channel has no group key in Phase 1 and is plaintext, so + // keying this skip on visibility would silently drop access-controlled + // search/workflows for private-group members (search is already + // membership-gated at query time, so indexing their plaintext was never a + // leak). Fail closed: if the latch state can't be determined, treat as + // encrypted so we never index a possibly-ciphertext channel's content. + let channel_is_encrypted = match stored_event.channel_id { + Some(channel_id) => match state.channel_is_encrypted_cached(channel_id).await { + Ok(encrypted) => encrypted, Err(e) => { - warn!(%channel_id, "index/workflow skip: visibility lookup failed, treating as private: {e}"); + warn!(%channel_id, "index/workflow skip: latch lookup failed, treating as encrypted: {e}"); true } }, @@ -207,11 +210,12 @@ pub(crate) async fn dispatch_persistent_event( // Skip search indexing for NIP-17 gift wraps (ciphertext), NIP-DV // visibility snapshots (per-viewer private hide state, owner-gated reads), // author-only kinds (ciphertext not useful in search, defense in depth), - // and any private/DM channel (E2E content — see channel_is_private above). + // and any latched E2E channel (ciphertext content — see channel_is_encrypted + // above). if kind_u32 != KIND_GIFT_WRAP && kind_u32 != buzz_core::kind::KIND_DM_VISIBILITY && !AUTHOR_ONLY_KINDS.contains(&kind_u32) - && !channel_is_private + && !channel_is_encrypted && state .search_index_tx .try_send(stored_event.clone()) @@ -253,7 +257,7 @@ pub(crate) async fn dispatch_persistent_event( && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP - && !channel_is_private + && !channel_is_encrypted { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4f32a9c2c..ac1138bd4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -405,6 +405,24 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { ) } +/// Channel-message kinds that carry a free-text display body, mirroring the +/// thread-reply set the CLI treats as channel messages (`messages.rs`). In a +/// latched E2E DM these bodies MUST be NIP-44 ciphertext (rule 15c) — gating +/// kind:9 alone left the edit (40003), v2 (40002), diff (40008), and forum +/// comment (45003) paths able to land plaintext into a latched channel, the +/// exact leak the latch exists to prevent. None of these are global-only +/// kinds, so each carries an `h` tag and resolves to a channel at ingest. +pub(crate) fn is_e2e_enforced_content_kind(kind: u32) -> bool { + matches!( + kind, + KIND_STREAM_MESSAGE + | KIND_STREAM_MESSAGE_V2 + | KIND_STREAM_MESSAGE_EDIT + | KIND_STREAM_MESSAGE_DIFF + | KIND_FORUM_COMMENT + ) +} + /// Check channel membership: member OR open-visibility channel. /// /// Returns `Ok(())` if allowed, `Err(reason)` if denied. @@ -1427,10 +1445,18 @@ pub async fn ingest_event( // ── 15c. E2E DM ciphertext enforcement (D2) ────────────────────────── // A DM channel with the encryption latch set (`encryption_activated_at`) // is end-to-end encrypted. The relay cannot decrypt, so it enforces the - // boundary syntactically: in a latched channel, EVERY incoming kind:9 MUST - // be NIP-44 v2 ciphertext, or it is rejected fail-visible — never silently - // stored as plaintext. We use the strong validator, not the length-only - // check, or a long plaintext message in range would pass. + // boundary syntactically: in a latched channel, EVERY incoming channel + // message that carries a free-text display body (see + // `is_e2e_enforced_content_kind`) MUST be NIP-44 v2 ciphertext, or it is + // rejected fail-visible — never silently stored as plaintext. We use the + // strong validator, not the length-only check, or a long plaintext message + // in range would pass. + // + // The gate spans the full channel-message-kind set, not kind:9 alone: + // edits (40003), v2 messages (40002), diffs (40008), and forum comments + // (45003) all carry a display body and an `h` tag, so a plaintext one would + // otherwise land in a latched channel and defeat the latch — the exact leak + // this guard prevents. // // Enforcement is latch-PRESENCE only — it deliberately does NOT compare the // event's `created_at` against the latch timestamp. A `created_at >= latch` @@ -1448,7 +1474,7 @@ pub async fn ingest_event( // the exact leak this guard prevents. A `ChannelNotFound` is different: the // channel cannot have a latch, and the event is rejected downstream at // insert, so it falls through here to keep that existing not-found behavior. - if kind_u32 == KIND_STREAM_MESSAGE { + if is_e2e_enforced_content_kind(kind_u32) { if let Some(ch_id) = channel_id { match state.db.get_channel(ch_id).await { Ok(channel) => { @@ -2184,7 +2210,48 @@ mod tests { assert!(validate_diff_event(&event).is_err()); } - // ── Test helpers ───────────────────────────────────────────────────── + // ── 15c gate: content-bearing channel-message kinds ────────────────── + + #[test] + fn e2e_gate_covers_all_channel_message_kinds() { + // The latch-enforcement gate must span every channel-message kind that + // carries a free-text display body — not kind:9 alone. A plaintext edit + // (40003), v2 message (40002), diff (40008), or forum comment (45003) + // into a latched DM is the exact leak the latch exists to prevent. + for kind in [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_DIFF, + KIND_FORUM_COMMENT, + ] { + assert!( + is_e2e_enforced_content_kind(kind), + "kind {kind} carries a display body and must be E2E-enforced in a latched channel" + ); + } + } + + #[test] + fn e2e_gate_excludes_uncovered_kinds() { + // Reactions, deletions, and forum votes carry no free-text display body, + // and profiles are global-only — none can leak readable content into a + // latched channel, so gating them would only reject legitimate events. + // Forum posts (45001) carry a body but sit outside the channel-message + // set the gate mirrors; they are deliberately out of this fix's scope. + for kind in [ + KIND_REACTION, + KIND_DELETION, + KIND_FORUM_VOTE, + KIND_PROFILE, + KIND_FORUM_POST, + ] { + assert!( + !is_e2e_enforced_content_kind(kind), + "kind {kind} is outside the gated channel-message set" + ); + } + } fn make_dummy_event() -> Event { let keys = nostr::Keys::generate(); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 76384775e..acb90a918 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -224,6 +224,11 @@ pub struct AppState { /// Per-channel visibility string, used to gate the private-channel fan-out /// access check so open channels stay zero-cost. Invalidated on a flip. pub channel_visibility_cache: Arc>, + /// Per-channel E2E-encryption state (`encryption_activated_at.is_some()`), + /// used to gate content-reading side effects (D1: search index + workflow + /// match). The latch is write-once at DM creation and never moves, so both + /// `true` and `false` are safe to cache for the TTL. + pub channel_encryption_cache: Arc>, /// Bounded channel for search indexing — prevents OOM if Typesense is slow/down. /// Capacity 1000: at ~1KB/event that's ~1MB of backlog before we start dropping. @@ -393,6 +398,14 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(10)) .build(), ), + // Longer TTL than visibility: the latch is write-once at creation + // and never moves, so a cached value can never go stale. + channel_encryption_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(std::time::Duration::from_secs(300)) + .build(), + ), search_index_tx, audit_tx, media_storage: Arc::new(media_storage), @@ -474,6 +487,7 @@ impl AppState { self.membership_cache.invalidate_all(); self.accessible_channels_cache.invalidate_all(); self.channel_visibility_cache.invalidate_all(); + self.channel_encryption_cache.invalidate_all(); } /// Get accessible channel IDs with a 10-second cache. Falls back to DB on miss. @@ -515,6 +529,35 @@ impl AppState { } Ok(visibility) } + + /// Whether a channel's E2E-encryption latch is set, with a 5-minute cache. + /// + /// Gates content-reading side effects (D1): a latched channel's message + /// content is NIP-44 ciphertext, so search-indexing it stores unreadable + /// bytes and content-matching workflow rules cannot fire. The latch — not + /// channel visibility — is the encryption boundary: a `private` group + /// channel is plaintext (it has no group key in Phase 1), so keying this on + /// the latch keeps access-controlled search/workflows working for private + /// group members while still skipping genuinely-encrypted DMs. + /// + /// Fails open to `false` is NOT acceptable here, so callers handle the + /// `Err` themselves (D1 treats a lookup failure as encrypted = skip). + pub async fn channel_is_encrypted_cached( + &self, + channel_id: Uuid, + ) -> Result { + if let Some(cached) = self.channel_encryption_cache.get(&channel_id) { + return Ok(cached); + } + let encrypted = self + .db + .get_channel(channel_id) + .await? + .encryption_activated_at + .is_some(); + self.channel_encryption_cache.insert(channel_id, encrypted); + Ok(encrypted) + } } /// Handle for graceful audit worker shutdown. From 887cea33c35366a131102b6b2fbfcdc47135f014 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 22 Jun 2026 21:47:39 -0400 Subject: [PATCH 03/12] fix(relay): gate forum-post and canvas kinds in E2E DM enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15c latch guard covered the CLI channel-message set (9/40002/40003/ 40008/45003) but not forum posts (45001) or canvas updates (40100). Both carry up to 64KB of free-text body and an arbitrary h-tag, so a client can address one at a latched 2-party DM channel and the relay stores it cleartext — the same plaintext-leak class the latch exists to close. The security boundary is any channel-scoped kind with a free-text body, not the CLI's kind enumeration; vote/reaction/deletion/membership stay out as structured- or empty-content kinds. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/handlers/ingest.rs | 48 ++++++++++++------------ 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ac1138bd4..76d09a3a2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -405,13 +405,16 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { ) } -/// Channel-message kinds that carry a free-text display body, mirroring the -/// thread-reply set the CLI treats as channel messages (`messages.rs`). In a -/// latched E2E DM these bodies MUST be NIP-44 ciphertext (rule 15c) — gating -/// kind:9 alone left the edit (40003), v2 (40002), diff (40008), and forum -/// comment (45003) paths able to land plaintext into a latched channel, the -/// exact leak the latch exists to prevent. None of these are global-only -/// kinds, so each carries an `h` tag and resolves to a channel at ingest. +/// Channel-scoped kinds that carry a free-text display body. In a latched E2E +/// DM these bodies MUST be NIP-44 ciphertext (rule 15c) — gating kind:9 alone +/// left the edit (40003), v2 (40002), diff (40008), forum comment (45003), +/// forum post (45001), and canvas (40100) paths able to land plaintext into a +/// latched channel, the exact leak the latch exists to prevent. None of these +/// are global-only kinds, so each carries an `h` tag and resolves to a channel +/// at ingest. The boundary is "carries a free-text body", not membership in any +/// one enumeration of channel kinds: structured-content kinds (forum vote +/// `+`/`-`, reactions, deletions, membership/admin/typing — empty content) and +/// global-only kinds (kind:1) stay out because they have no user text to leak. pub(crate) fn is_e2e_enforced_content_kind(kind: u32) -> bool { matches!( kind, @@ -420,6 +423,8 @@ pub(crate) fn is_e2e_enforced_content_kind(kind: u32) -> bool { | KIND_STREAM_MESSAGE_EDIT | KIND_STREAM_MESSAGE_DIFF | KIND_FORUM_COMMENT + | KIND_FORUM_POST + | KIND_CANVAS ) } @@ -2214,16 +2219,19 @@ mod tests { #[test] fn e2e_gate_covers_all_channel_message_kinds() { - // The latch-enforcement gate must span every channel-message kind that + // The latch-enforcement gate must span every channel-scoped kind that // carries a free-text display body — not kind:9 alone. A plaintext edit - // (40003), v2 message (40002), diff (40008), or forum comment (45003) - // into a latched DM is the exact leak the latch exists to prevent. + // (40003), v2 message (40002), diff (40008), forum comment (45003), + // forum post (45001), or canvas (40100) into a latched DM is the exact + // leak the latch exists to prevent. for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_DIFF, KIND_FORUM_COMMENT, + KIND_FORUM_POST, + KIND_CANVAS, ] { assert!( is_e2e_enforced_content_kind(kind), @@ -2234,21 +2242,15 @@ mod tests { #[test] fn e2e_gate_excludes_uncovered_kinds() { - // Reactions, deletions, and forum votes carry no free-text display body, - // and profiles are global-only — none can leak readable content into a - // latched channel, so gating them would only reject legitimate events. - // Forum posts (45001) carry a body but sit outside the channel-message - // set the gate mirrors; they are deliberately out of this fix's scope. - for kind in [ - KIND_REACTION, - KIND_DELETION, - KIND_FORUM_VOTE, - KIND_PROFILE, - KIND_FORUM_POST, - ] { + // Reactions, deletions, and forum votes carry no free-text body (emoji, + // empty, or "+"/"-" respectively), and profiles are global-only — none + // can leak readable user content into a latched channel, so gating them + // would only reject legitimate events. The boundary is "free-text body", + // not membership in any one enumeration of channel kinds. + for kind in [KIND_REACTION, KIND_DELETION, KIND_FORUM_VOTE, KIND_PROFILE] { assert!( !is_e2e_enforced_content_kind(kind), - "kind {kind} is outside the gated channel-message set" + "kind {kind} carries no free-text body and must not be E2E-gated" ); } } From 23dbfdb1300731ff6acda548ee6a6838e73d4fd6 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 11:10:16 -0400 Subject: [PATCH 04/12] fix(relay): derive E2E gate from channel-scoped acceptance surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15c latch gate was a hand-maintained kind list running parallel to the relay's actual channel-scoped acceptance surface (requires_h_channel_ scope), and the two drifted: 40004-40007 (pinned/bookmarked/scheduled/ reminder) were accepted into a latched DM but never gated, so a plaintext scheduled message or reminder body would be stored cleartext — the same leak class already fixed for the edit path. Gate all four. Pinned (40004) and bookmarked (40005) have no SDK builder or relay schema constraining their content, so a client can place free text in the body; with no proof they are bodyless and no legitimate plaintext producer to break, fail-visible rejection is the safe default. Add a drift-guard test that enumerates requires_h_channel_scope over the kind space and asserts every channel-scoped kind is classified as either E2E-gated (free-text body) or explicitly bodyless. A new channel-scoped kind added without classification now fails the test, so the gate cannot silently drift behind the acceptance surface again. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/handlers/ingest.rs | 93 +++++++++++++++++++++--- 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 76d09a3a2..e59b504ea 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -406,21 +406,34 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { } /// Channel-scoped kinds that carry a free-text display body. In a latched E2E -/// DM these bodies MUST be NIP-44 ciphertext (rule 15c) — gating kind:9 alone -/// left the edit (40003), v2 (40002), diff (40008), forum comment (45003), -/// forum post (45001), and canvas (40100) paths able to land plaintext into a -/// latched channel, the exact leak the latch exists to prevent. None of these -/// are global-only kinds, so each carries an `h` tag and resolves to a channel -/// at ingest. The boundary is "carries a free-text body", not membership in any -/// one enumeration of channel kinds: structured-content kinds (forum vote -/// `+`/`-`, reactions, deletions, membership/admin/typing — empty content) and -/// global-only kinds (kind:1) stay out because they have no user text to leak. +/// DM these bodies MUST be NIP-44 ciphertext (rule 15c), or the relay would +/// silently store plaintext — the exact leak the latch exists to prevent. +/// +/// This is the content-bearing half of the relay's channel-scoped acceptance +/// surface (`requires_h_channel_scope`). The two must stay in lockstep: any kind +/// the relay accepts as channel-scoped is either gated here (free-text body) or +/// listed as bodyless in the `e2e_drift_guard` test. That guard derives from +/// `requires_h_channel_scope` and fails if a new channel-scoped kind is added +/// without classification, so this list cannot silently drift behind the +/// acceptance surface (the bug that left 40003-40007 ungated across two passes). +/// +/// 40004 (pinned) and 40005 (bookmarked) are gated despite having no SDK builder +/// or relay-side schema: they are named "a stream message that has been +/// pinned/bookmarked" and are accepted as persisted channel events with no +/// structure constraining their content, so a client can place free text in the +/// body. With no proof they are bodyless (unlike forum vote's `+`/`-`) and no +/// legitimate plaintext producer to break, fail-visible rejection is the safe +/// default for the latch boundary. pub(crate) fn is_e2e_enforced_content_kind(kind: u32) -> bool { matches!( kind, KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2 | KIND_STREAM_MESSAGE_EDIT + | KIND_STREAM_MESSAGE_PINNED + | KIND_STREAM_MESSAGE_BOOKMARKED + | KIND_STREAM_MESSAGE_SCHEDULED + | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF | KIND_FORUM_COMMENT | KIND_FORUM_POST @@ -2222,12 +2235,17 @@ mod tests { // The latch-enforcement gate must span every channel-scoped kind that // carries a free-text display body — not kind:9 alone. A plaintext edit // (40003), v2 message (40002), diff (40008), forum comment (45003), - // forum post (45001), or canvas (40100) into a latched DM is the exact - // leak the latch exists to prevent. + // forum post (45001), canvas (40100), scheduled message (40006), + // reminder (40007), pinned (40004), or bookmarked (40005) into a latched + // DM is the exact leak the latch exists to prevent. for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, KIND_FORUM_COMMENT, KIND_FORUM_POST, @@ -2255,6 +2273,59 @@ mod tests { } } + /// Channel-scoped kinds whose body is structurally bodyless — they carry no + /// free-text user content, so they are deliberately NOT E2E-gated. Each must + /// be justified by its content shape, not by absence from an enumeration: + /// - forum vote: "+"/"-" only; + /// - NIP-29 admin (put/remove user, edit metadata, delete event/group, + /// leave request): membership/admin commands, empty or structured content; + /// - huddle lifecycle (started/joined/left/ended/guidelines): structured + /// session state, no message body. + /// + /// This list is the test's accounting of the bodyless half of the + /// channel-scoped surface; `e2e_drift_guard` asserts the two halves together + /// cover every channel-scoped kind, so a new kind can't slip in unclassified. + const BODYLESS_CHANNEL_SCOPED_KINDS: &[u32] = &[ + KIND_FORUM_VOTE, + KIND_NIP29_PUT_USER, + KIND_NIP29_REMOVE_USER, + KIND_NIP29_EDIT_METADATA, + KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, + KIND_NIP29_LEAVE_REQUEST, + KIND_HUDDLE_STARTED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_ENDED, + KIND_HUDDLE_GUIDELINES, + ]; + + #[test] + fn e2e_drift_guard_classifies_every_channel_scoped_kind() { + // Structural fix for the kind-set-drift bug class: the E2E gate + // (`is_e2e_enforced_content_kind`) is a hand-written list that ran + // parallel to the relay's actual channel-scoped acceptance surface + // (`requires_h_channel_scope`) and drifted from it, leaving content kinds + // ungated. This guard derives directly from the acceptance surface: every + // kind the relay accepts as channel-scoped MUST be classified as either + // E2E-gated (carries a free-text body) or explicitly bodyless. Adding a + // new arm to `requires_h_channel_scope` without classifying it here fails + // this test — so the gate can't silently drift behind the surface again. + for kind in 0u32..=50_000 { + if !requires_h_channel_scope(kind) { + continue; + } + let gated = is_e2e_enforced_content_kind(kind); + let bodyless = BODYLESS_CHANNEL_SCOPED_KINDS.contains(&kind); + assert!( + gated ^ bodyless, + "channel-scoped kind {kind} is unclassified: it must be either \ + E2E-gated (free-text body) or in BODYLESS_CHANNEL_SCOPED_KINDS, \ + and exactly one of the two — got gated={gated}, bodyless={bodyless}" + ); + } + } + fn make_dummy_event() -> Event { let keys = nostr::Keys::generate(); nostr::EventBuilder::new(nostr::Kind::Custom(9), "") From 0868717b5927b792483cf2d614a8625a062eeabc Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 11:41:45 -0400 Subject: [PATCH 05/12] feat: encrypt-on-send and decrypt-at-ingest for 2-party DMs (Phase 1b FE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz DM bodies were sent plaintext from the desktop client; the relay's ciphertext latch now requires NIP-44 v2 for every content kind in a latched DM. The renderer (formatTimelineMessages) is synchronous and reads event.content directly, so decryption cannot live there — it happens at the async cache-population boundary (Option A), keeping the cache plaintext-only so dedup, overlays, and the renderer all see plaintext for free. Encrypts the body once before the REST/WS branch on send and in the edit mutation, scoped to channelType==dm with exactly one non-self participant. The WS send path overrides the returned event content back to plaintext so the optimistic-match re-key compares plaintext on both sides. The fail-visible placeholder substitutes only when valid v2 ciphertext fails to decrypt — legacy plaintext is shape-checked and passed through untouched. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/channels/ui/ChannelScreen.tsx | 11 +- desktop/src/features/home/ui/HomeView.tsx | 5 +- desktop/src/features/messages/hooks.ts | 97 ++++++++-- .../src/features/messages/lib/auxBackfill.ts | 10 +- .../features/messages/lib/dmCrypto.test.mjs | 175 ++++++++++++++++++ desktop/src/features/messages/lib/dmCrypto.ts | 171 +++++++++++++++++ .../messages/lib/pageOlderMessages.ts | 20 +- .../messages/useFetchOlderMessages.ts | 15 +- 8 files changed, 470 insertions(+), 34 deletions(-) create mode 100644 desktop/src/features/messages/lib/dmCrypto.test.mjs create mode 100644 desktop/src/features/messages/lib/dmCrypto.ts diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ea6a9449b..1fd2dd659 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -168,10 +168,10 @@ export function ChannelScreen({ : current; }); }, [activeChannelId, openThreadHeadId]); - const messagesQuery = useChannelMessagesQuery(activeChannel); - useChannelSubscription(activeChannel); + const messagesQuery = useChannelMessagesQuery(activeChannel, currentPubkey); + useChannelSubscription(activeChannel, currentPubkey); const { fetchOlder, hasOlderMessages, isFetchingOlder } = - useFetchOlderMessages(activeChannel); + useFetchOlderMessages(activeChannel, currentPubkey); // Newest TOP-LEVEL message only. The channel read-marker must clear the // channel timeline without clearing its threads (NIP-RS Option 1): thread // replies are kind-9 channel events, so taking the last message outright @@ -244,7 +244,10 @@ export function ChannelScreen({ ); const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); - const editMessageMutation = useEditMessageMutation(activeChannel); + const editMessageMutation = useEditMessageMutation( + activeChannel, + currentPubkey, + ); const joinChannelMutation = useJoinChannelMutation(activeChannelId); const resolvedMessages = React.useMemo(() => { const currentMessages = messagesQuery.data ?? []; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index d9a735be9..2e6cda8c3 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -160,7 +160,10 @@ export function HomeView({ ); }, [channels, selectedChannelIdCandidate]); - const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); + const channelMessagesQuery = useChannelMessagesQuery( + selectedChannel, + currentPubkey, + ); const toggleReactionMutation = useToggleReactionMutation(); const channelMessages = channelMessagesQuery.data; const threadContext = useInboxThreadContext( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4151b1b0b..7f9d1cad3 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -24,6 +24,7 @@ import { addReaction, deleteMessage, editMessage, + nip44EncryptToPeer, removeReaction, sendChannelMessage, } from "@/shared/api/tauri"; @@ -32,6 +33,10 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; +import { + dmPeerPubkey, + makeDmIngestDecryptor, +} from "@/features/messages/lib/dmCrypto"; import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; import { MIN_TOP_LEVEL_ROWS_PER_FETCH, @@ -168,9 +173,13 @@ function createOptimisticMessage( }; } -export function useChannelMessagesQuery(channel: Channel | null) { +export function useChannelMessagesQuery( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); return useQuery({ enabled: channel !== null && channel.channelType !== "forum", @@ -181,9 +190,11 @@ export function useChannelMessagesQuery(channel: Channel | null) { throw new Error("No channel selected."); } - const history = await relayClient.fetchChannelHistory( - channel.id, - CHANNEL_HISTORY_LIMIT, + const history = await decryptIngested( + await relayClient.fetchChannelHistory( + channel.id, + CHANNEL_HISTORY_LIMIT, + ), ); const currentMessages = queryClient.getQueryData(queryKey) ?? []; @@ -194,7 +205,12 @@ export function useChannelMessagesQuery(channel: Channel | null) { // Paint messages immediately; backfill their reactions/edits/deletions // by `#e` in the background (it self-merges into the same cache key). - void backfillAuxForMessages(queryClient, channel.id, history); + void backfillAuxForMessages( + queryClient, + channel.id, + history, + decryptIngested, + ); // Seed the cache, then — only if the cold window renders thinner than a // normal scroll page — top it up to the same visible-row floor. A @@ -208,6 +224,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { queryClient, channel.id, () => true, + decryptIngested, ); } return queryClient.getQueryData(queryKey) ?? mergedHistory; @@ -217,18 +234,21 @@ export function useChannelMessagesQuery(channel: Channel | null) { }); } -export function useChannelSubscription(channel: Channel | null) { +export function useChannelSubscription( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); const syncLatestHistory = useEffectEvent(async () => { if (!channelId) { return; } - const history = await relayClient.fetchChannelHistory( - channelId, - CHANNEL_HISTORY_LIMIT, + const history = await decryptIngested( + await relayClient.fetchChannelHistory(channelId, CHANNEL_HISTORY_LIMIT), ); queryClient.setQueryData( @@ -236,22 +256,29 @@ export function useChannelSubscription(channel: Channel | null) { (current = []) => mergeTimelineHistoryMessages(current, history), ); - void backfillAuxForMessages(queryClient, channelId, history); + void backfillAuxForMessages( + queryClient, + channelId, + history, + decryptIngested, + ); }); - const appendMessage = useEffectEvent((event: RelayEvent) => { + const appendMessage = useEffectEvent(async (event: RelayEvent) => { if (!channelId) { return; } + const [decrypted] = await decryptIngested([event]); + queryClient.setQueryData( channelMessagesKey(channelId), - (current = []) => mergeTimelineCacheMessages(current, event), + (current = []) => mergeTimelineCacheMessages(current, decrypted), ); - if (event.kind === KIND_SYSTEM_MESSAGE) { + if (decrypted.kind === KIND_SYSTEM_MESSAGE) { try { - const payload = JSON.parse(event.content) as { type?: string }; + const payload = JSON.parse(decrypted.content) as { type?: string }; if ( payload.type === "member_joined" || payload.type === "member_left" || @@ -293,7 +320,9 @@ export function useChannelSubscription(channel: Channel | null) { relayClient .subscribeToChannel(channelId, (event) => { if (!isDisposed) { - appendMessage(event); + void appendMessage(event).catch((error) => { + console.error("Failed to append channel message", channelId, error); + }); } }) .then((dispose) => { @@ -358,6 +387,16 @@ export function useSendMessageMutation( throw new Error("No identity available for sending messages."); } + // Encrypt the body once, before the REST/WS branch, when this is a + // 2-party DM — both transports send `wireContent`. The plaintext `content` + // is preserved for the optimistic cache copy and the success re-key, so + // the cache holds plaintext (matching the decrypt-at-ingest funnel) while + // the wire and relay only ever see ciphertext. + const peerPubkey = dmPeerPubkey(channel, identity.pubkey); + const wireContent = peerPubkey + ? await nip44EncryptToPeer(peerPubkey, content.trim()) + : content; + // `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30 // emoji). Split it so each kind goes to its own validated Tauri arg — // emoji tags must NOT ride the imeta-only `media` channel (that gate @@ -378,7 +417,7 @@ export function useSendMessageMutation( ) ?? []; const result = await sendChannelMessage( channel.id, - content, + wireContent, parentEventId ?? null, imetaTags, mentionPubkeys, @@ -429,12 +468,18 @@ export function useSendMessageMutation( }; } - return relayClient.sendMessage( + const result = await relayClient.sendMessage( channel.id, - content, + wireContent, mentionPubkeys ?? [], mentionTags, ); + // For a DM, `result.content` is the ciphertext the relay stored. Override + // it with the plaintext so `onSuccess` re-keys the optimistic copy to a + // plaintext-bodied event — matching the cache invariant the REST branch + // already upholds (it synthesizes `content: content.trim()`). A no-op + // outside DMs, where `wireContent === content`. + return peerPubkey ? { ...result, content: content.trim() } : result; }, onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => { if (!channel || !identity || channel.channelType === "forum") { @@ -538,7 +583,10 @@ export function useDeleteMessageMutation(channel: Channel | null) { }); } -export function useEditMessageMutation(channel: Channel | null) { +export function useEditMessageMutation( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); return useMutation< @@ -555,13 +603,22 @@ export function useEditMessageMutation(channel: Channel | null) { throw new Error("No channel selected."); } + // Encrypt the edit body for a 2-party DM so the relay's ciphertext gate + // accepts it (a plaintext kind-40003 into a latched DM is rejected). The + // plaintext `content` flows on to the `onSuccess` cache update, keeping + // the cache plaintext-only like the send path and decrypt-at-ingest. + const peerPubkey = dmPeerPubkey(channel, selfPubkey); + const wireContent = peerPubkey + ? await nip44EncryptToPeer(peerPubkey, content) + : content; + // `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji). // Split so each rides its own validated Tauri arg — emoji tags must NOT // go through the imeta-only `mediaTags` channel (the Rust `imeta_tags` // guard rejects any non-imeta prefix), mirroring the send path. const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags); - await editMessage(channel.id, eventId, content, imetaTags, emojiTags); + await editMessage(channel.id, eventId, wireContent, imetaTags, emojiTags); }, onSuccess: (_data, { eventId, content, mediaTags }) => { if (!channel) { diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index df0272d73..ae7c8153c 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -84,6 +84,9 @@ export async function backfillAuxForMessages( queryClient: QueryClient, channelId: string, historyEvents: RelayEvent[], + decryptBatch: (events: RelayEvent[]) => Promise = async ( + events, + ) => events, ): Promise { const messageIds = collectMessageIdsForAuxBackfill(historyEvents); if (messageIds.length === 0) { @@ -109,8 +112,13 @@ export async function backfillAuxForMessages( return; } + // Edit events (kind 40003) carry an encrypted DM body the renderer overlays + // onto the original message — decrypt them before they reach the cache so + // the overlay shows plaintext. A no-op outside 2-party DMs. + const decryptedAuxEvents = await decryptBatch(mergedAuxEvents); + queryClient.setQueryData(cacheKey, (current = []) => - sortMessages([...current, ...mergedAuxEvents]), + sortMessages([...current, ...decryptedAuxEvents]), ); } catch (error) { console.error( diff --git a/desktop/src/features/messages/lib/dmCrypto.test.mjs b/desktop/src/features/messages/lib/dmCrypto.test.mjs new file mode 100644 index 000000000..622ff09a9 --- /dev/null +++ b/desktop/src/features/messages/lib/dmCrypto.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + UNDECRYPTABLE_DM_PLACEHOLDER, + decryptIngestedContent, + dmPeerPubkey, + looksLikeNip44V2, + makeDmIngestDecryptor, +} from "./dmCrypto.ts"; + +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, +} from "@/shared/constants/kinds"; + +// base64(0x02 + 98 zero bytes) — 99 decoded bytes, the minimal valid NIP-44 v2 +// envelope (1 version + 32 nonce + 32 MAC + 34 ciphertext floor). 132 chars. +const VALID_V2 = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +// Same length and alphabet, but first decoded byte is 0x00, not 0x02. +const WRONG_VERSION = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +// ── looksLikeNip44V2 — mirrors relay validate_nip44_v2 envelope check ───────── + +test("looksLikeNip44V2 accepts a minimal valid v2 envelope", () => { + assert.equal(looksLikeNip44V2(VALID_V2), true); +}); + +test("looksLikeNip44V2 rejects content with wrong version prefix", () => { + assert.equal(looksLikeNip44V2(WRONG_VERSION), false); +}); + +test("looksLikeNip44V2 rejects content shorter than 99 decoded bytes", () => { + assert.equal(looksLikeNip44V2("AgAAAAAAAAAAAAA="), false); +}); + +test("looksLikeNip44V2 rejects empty content", () => { + assert.equal(looksLikeNip44V2(""), false); +}); + +test("looksLikeNip44V2 rejects plaintext that isn't base64-shaped", () => { + assert.equal(looksLikeNip44V2("hey, lunch at noon?"), false); +}); + +test("looksLikeNip44V2 rejects base64 with a non-trailing pad char", () => { + // '=' before the last two positions is malformed padding. + const bad = `Ag==${VALID_V2.slice(4)}`; + assert.equal(looksLikeNip44V2(bad), false); +}); + +// ── AC1: decryptIngestedContent — valid-v2-undecryptable vs malformed ───────── + +const peer = "a".repeat(64); +const throwingDecrypt = async () => { + throw new Error("nip44 decrypt failed: hmac mismatch"); +}; +const okDecrypt = async (_peer, ciphertext) => + `decrypted(${ciphertext.slice(0, 4)})`; + +test("decryptIngestedContent substitutes placeholder when valid v2 ciphertext fails to decrypt", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + peer, + throwingDecrypt, + ); + assert.equal(body, UNDECRYPTABLE_DM_PLACEHOLDER); +}); + +test("decryptIngestedContent passes legacy plaintext through unchanged on decrypt failure", async () => { + // The critical distinction: malformed / never-encrypted content must NOT + // become the placeholder. It is not v2-shaped, so decrypt is never attempted. + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: "hey, lunch at noon?" }, + peer, + throwingDecrypt, + ); + assert.equal(body, "hey, lunch at noon?"); +}); + +test("decryptIngestedContent returns plaintext when valid v2 ciphertext decrypts", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + peer, + okDecrypt, + ); + assert.equal(body, "decrypted(AgAA)"); +}); + +test("decryptIngestedContent decrypts edit-kind content (AC2 ingest side)", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE_EDIT, content: VALID_V2 }, + peer, + okDecrypt, + ); + assert.equal(body, "decrypted(AgAA)"); +}); + +test("decryptIngestedContent skips decrypt when there is no DM peer", async () => { + const body = await decryptIngestedContent( + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + null, + throwingDecrypt, + ); + assert.equal(body, VALID_V2); +}); + +test("decryptIngestedContent does not decrypt non-content kinds (system messages)", async () => { + const KIND_SYSTEM_MESSAGE = 40099; + const body = await decryptIngestedContent( + { kind: KIND_SYSTEM_MESSAGE, content: VALID_V2 }, + peer, + throwingDecrypt, + ); + assert.equal(body, VALID_V2); +}); + +// ── AC3: dmPeerPubkey — peer = participants minus self, 2-party only ────────── + +test("dmPeerPubkey returns the single non-self participant in a 2-party DM", () => { + const channel = { channelType: "dm", participantPubkeys: ["SELF", "PEER"] }; + assert.equal(dmPeerPubkey(channel, "self"), "PEER"); +}); + +test("dmPeerPubkey is case-insensitive on self matching", () => { + const channel = { channelType: "dm", participantPubkeys: ["AbC", "def"] }; + assert.equal(dmPeerPubkey(channel, "abc"), "def"); +}); + +test("dmPeerPubkey returns null for a group DM with more than one peer", () => { + const channel = { + channelType: "dm", + participantPubkeys: ["self", "p1", "p2"], + }; + assert.equal(dmPeerPubkey(channel, "self"), null); +}); + +test("dmPeerPubkey returns null for non-DM channels", () => { + const channel = { + channelType: "stream", + participantPubkeys: ["self", "peer"], + }; + assert.equal(dmPeerPubkey(channel, "self"), null); +}); + +test("dmPeerPubkey returns null when self pubkey is unknown", () => { + const channel = { channelType: "dm", participantPubkeys: ["a", "b"] }; + assert.equal(dmPeerPubkey(channel, undefined), null); +}); + +// ── makeDmIngestDecryptor — no-op outside a 2-party DM ──────────────────────── + +test("makeDmIngestDecryptor returns events untouched outside a 2-party DM", async () => { + const events = [ + { kind: KIND_STREAM_MESSAGE, content: VALID_V2 }, + { kind: KIND_STREAM_MESSAGE, content: "plaintext" }, + ]; + // A stream channel has no DM peer, so the decryptor must be a pure identity + // pass-through — it never touches the IPC primitive, so this stays + // deterministic without a Tauri mock. + const decrypt = makeDmIngestDecryptor( + { channelType: "stream", participantPubkeys: ["self", "peer"] }, + "self", + ); + const result = await decrypt(events); + assert.equal(result, events); +}); + +test("makeDmIngestDecryptor returns events untouched when channel is null", async () => { + const events = [{ kind: KIND_STREAM_MESSAGE, content: VALID_V2 }]; + const decrypt = makeDmIngestDecryptor(null, "self"); + const result = await decrypt(events); + assert.equal(result, events); +}); diff --git a/desktop/src/features/messages/lib/dmCrypto.ts b/desktop/src/features/messages/lib/dmCrypto.ts new file mode 100644 index 000000000..9b7388516 --- /dev/null +++ b/desktop/src/features/messages/lib/dmCrypto.ts @@ -0,0 +1,171 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, +} from "@/shared/constants/kinds"; +import { nip44DecryptFromPeer } from "@/shared/api/tauri"; +import type { Channel, RelayEvent } from "@/shared/api/types"; + +/** + * Body shown when a DM message is valid NIP-44 v2 ciphertext we cannot decrypt + * (peer used a key/version we can't read) — fail-visible, never blank or + * garbled. Substituted ONLY for undecryptable valid-v2 content; malformed or + * legacy-plaintext content is passed through untouched (see + * `decryptIngestedContent`). + */ +export const UNDECRYPTABLE_DM_PLACEHOLDER = + "[encrypted message — update your client to read it]"; + +/** + * Kinds that carry a user-authored free-text body in a DM and must therefore be + * encrypted on send and decrypted at ingest. System messages, reactions, and + * deletions carry no peer-encrypted body and are excluded. + */ +function isDmContentKind(kind: number): boolean { + return kind === KIND_STREAM_MESSAGE || kind === KIND_STREAM_MESSAGE_EDIT; +} + +/** + * The single NIP-44 peer for a 2-party DM: the one participant that isn't us. + * Returns null when the channel isn't a 2-party DM (open/stream channels, + * self-only, or group DMs with >1 other participant), which is exactly the + * scope where FE peer crypto applies — callers skip encrypt/decrypt on null. + */ +export function dmPeerPubkey( + channel: Pick, + selfPubkey: string | undefined, +): string | null { + if (channel.channelType !== "dm" || !selfPubkey) { + return null; + } + const self = normalizePubkey(selfPubkey); + const peers = channel.participantPubkeys.filter( + (pubkey) => normalizePubkey(pubkey) !== self, + ); + return peers.length === 1 ? peers[0] : null; +} + +/** + * Whether `content` is a syntactically plausible NIP-44 v2 ciphertext payload. + * + * Mirrors the relay's `buzz_core::observer::validate_nip44_v2` envelope check + * exactly so the FE's "this IS encrypted, we just can't read it" judgement + * matches the boundary the relay enforces: + * - standard base64 alphabet, padding only at the end, length a multiple of 4 + * - decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + >=34 ciphertext) + * - first decoded byte is 0x02 (NIP-44 version 2) + * + * It is an envelope check, not decryption: a `true` result means the content is + * shaped like v2 ciphertext, so a decrypt failure is "valid ciphertext we can't + * read" (→ placeholder) rather than "this was never encrypted" (→ pass through). + */ +export function looksLikeNip44V2(content: string): boolean { + const len = content.length; + if (len === 0 || len % 4 !== 0) { + return false; + } + + let padCount = 0; + for (let i = 0; i < len; i++) { + const c = content[i]; + if (c === "=") { + // Padding is only legal in the final two positions. + if (i < len - 2) { + return false; + } + padCount++; + if (padCount > 2) { + return false; + } + } else if (/[A-Za-z0-9+/]/.test(c)) { + // A base64 char after padding has begun is malformed. + if (padCount > 0) { + return false; + } + } else { + return false; + } + } + + const decodedLen = (len / 4) * 3 - padCount; + if (decodedLen < 99) { + return false; + } + + const b64Val = (c: string): number => { + const code = c.charCodeAt(0); + if (code >= 65 && code <= 90) return code - 65; // A-Z + if (code >= 97 && code <= 122) return code - 97 + 26; // a-z + if (code >= 48 && code <= 57) return code - 48 + 52; // 0-9 + if (c === "+") return 62; + if (c === "/") return 63; + return -1; + }; + + const firstByte = (b64Val(content[0]) << 2) | (b64Val(content[1]) >> 4); + return firstByte === 0x02; +} + +/** + * Decrypt one ingested DM event's content to plaintext for the cache. + * + * Decrypt is attempted only for 2-party-DM content kinds whose content is + * shaped like NIP-44 v2 ciphertext (`looksLikeNip44V2`). Everything else — + * non-DM channels, system/reaction/deletion kinds, and legacy plaintext that + * predates encryption — is returned unchanged. + * + * When the content IS valid v2 ciphertext but `decrypt` throws (peer key/version + * we can't read), the fail-visible placeholder is substituted. This is the AC1 + * distinction: a decrypt failure on valid-v2 content shows the placeholder, + * while content that was never v2-shaped is passed through as-is. + */ +export async function decryptIngestedContent( + event: Pick, + peerPubkey: string | null, + decrypt: (peerPubkey: string, ciphertext: string) => Promise, +): Promise { + if ( + peerPubkey === null || + !isDmContentKind(event.kind) || + !looksLikeNip44V2(event.content) + ) { + return event.content; + } + + try { + return await decrypt(peerPubkey, event.content); + } catch { + return UNDECRYPTABLE_DM_PLACEHOLDER; + } +} + +/** + * Build the decrypt-at-ingest mapper for a channel: it decrypts encrypted + * 2-party-DM bodies to plaintext (via the real Tauri NIP-44 peer primitive) and + * leaves everything else — non-DM channels, non-content kinds, legacy plaintext + * — untouched. Returns a no-op identity mapper outside a 2-party DM, so every + * ingest site can call it unconditionally without branching on channel type. + * + * Callers pass this to the cache-population paths (history fetch, scrollback, + * aux backfill, live append) so the cache only ever holds plaintext content. + */ +export function makeDmIngestDecryptor( + channel: Pick | null, + selfPubkey: string | undefined, +): (events: RelayEvent[]) => Promise { + const peerPubkey = channel ? dmPeerPubkey(channel, selfPubkey) : null; + if (peerPubkey === null) { + return (events) => Promise.resolve(events); + } + return (events) => + Promise.all( + events.map(async (event) => { + const content = await decryptIngestedContent( + event, + peerPubkey, + nip44DecryptFromPeer, + ); + return content === event.content ? event : { ...event, content }; + }), + ); +} diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index 47fdaf00e..0d85ad65e 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -57,6 +57,9 @@ export async function pageOlderMessagesUntilRowFloor( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + decryptBatch: (events: RelayEvent[]) => Promise = async ( + events, + ) => events, ): Promise { const queryKey = channelMessagesKey(channelId); const baseline = queryClient.getQueryData(queryKey) ?? []; @@ -78,10 +81,12 @@ export async function pageOlderMessagesUntilRowFloor( // sortMessages dedupes by id. Subtracting 1 risks skipping same-second // messages. const oldestTimestamp = before[0].created_at; - const olderMessages = await relayClient.fetchChannelHistoryBefore( - channelId, - oldestTimestamp, - OLDER_MESSAGES_BATCH_SIZE, + const olderMessages = await decryptBatch( + await relayClient.fetchChannelHistoryBefore( + channelId, + oldestTimestamp, + OLDER_MESSAGES_BATCH_SIZE, + ), ); batchesFetched += 1; @@ -100,7 +105,12 @@ export async function pageOlderMessagesUntilRowFloor( queryClient.setQueryData(queryKey, (current = []) => mergeTimelineHistoryMessages(current, olderMessages), ); - void backfillAuxForMessages(queryClient, channelId, olderMessages); + void backfillAuxForMessages( + queryClient, + channelId, + olderMessages, + decryptBatch, + ); } // Progress guard, not exhaustion: if the oldest timestamp didn't move back diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 50ad7ca9d..632d9bbb7 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -1,13 +1,21 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; +import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto"; import { pageOlderMessagesUntilRowFloor } from "@/features/messages/lib/pageOlderMessages"; import type { Channel, RelayEvent } from "@/shared/api/types"; -export function useFetchOlderMessages(channel: Channel | null) { +export function useFetchOlderMessages( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; + const decryptIngested = useMemo( + () => makeDmIngestDecryptor(channel, selfPubkey), + [channel, selfPubkey], + ); const [isFetchingOlder, setIsFetchingOlder] = useState(false); const [hasOlderMessages, setHasOlderMessages] = useState(true); const isFetchingOlderRef = useRef(false); @@ -45,6 +53,7 @@ export function useFetchOlderMessages(channel: Channel | null) { queryClient, channelId, () => previousChannelIdRef.current === channelId, + decryptIngested, ); if (!more) { hasOlderMessagesRef.current = false; @@ -56,7 +65,7 @@ export function useFetchOlderMessages(channel: Channel | null) { isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channelId, queryClient]); + }, [channelId, queryClient, decryptIngested]); return { fetchOlder, isFetchingOlder, hasOlderMessages }; } From 3d77187f5466a5aff06d9575919d05710d93db6e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 12:50:29 -0400 Subject: [PATCH 06/12] fix(messages): key DM timeline cache on identity to fix cold-start ciphertext leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold start where channels resolve from warm cache before the identity IPC resolves, ChannelScreen mounts with selfPubkey undefined. The DM history query then fetches and caches raw ciphertext via the no-op decryptor, and because the query key did not include selfPubkey, the later identity-resolved query landed in the same cache bucket and never refetched — rendering raw v2 ciphertext for up to the 5-minute staleTime, bypassing the fail-visible placeholder. Make selfPubkey (lowercased, nullable) the third element of channelMessagesKey so identity resolution produces a distinct key that forces a refetch and re-decrypt, and isolates one identity's decrypted DM bodies from another. All 12 call sites thread selfPubkey through so no half-migrated 2-element key splits the cache. The subscription effect re-runs on selfPubkey so the live sub re-establishes against the resolved decryptor. The edit path now encrypts and caches content.trim() to match the send path's wire/cache convention. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/channels/ui/ChannelScreen.tsx | 7 +- .../channels/useLiveChannelUpdates.ts | 9 +- desktop/src/features/messages/hooks.ts | 32 +++-- .../src/features/messages/lib/auxBackfill.ts | 3 +- .../messages/lib/messageQueryKeys.test.mjs | 110 ++++++++++++++++++ .../features/messages/lib/messageQueryKeys.ts | 21 +++- .../messages/lib/pageOlderMessages.ts | 4 +- .../messages/useFetchOlderMessages.ts | 5 +- .../messages/useLoadMissingAncestors.ts | 5 +- 9 files changed, 174 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 1fd2dd659..742c81c69 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -243,7 +243,10 @@ export function ChannelScreen({ currentIdentity, ); const toggleReactionMutation = useToggleReactionMutation(); - const deleteMessageMutation = useDeleteMessageMutation(activeChannel); + const deleteMessageMutation = useDeleteMessageMutation( + activeChannel, + currentPubkey, + ); const editMessageMutation = useEditMessageMutation( activeChannel, currentPubkey, @@ -629,7 +632,7 @@ export function ChannelScreen({ threadReplyTargetMessage, ]); - useLoadMissingAncestors(activeChannel, resolvedMessages); + useLoadMissingAncestors(activeChannel, resolvedMessages, currentPubkey); const hasAuxiliaryPanel = Boolean( effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey, ); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index f5a03a9a2..4007c0df1 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -250,8 +250,15 @@ export function useLiveChannelUpdates( // useChannelSubscription also writes to this cache, but there's a // race window where it hasn't connected yet. Writes are idempotent // (mergeTimelineCacheMessages deduplicates by event ID). + // + // Keyed on the same selfPubkey (currentPubkey) as useChannelMessagesQuery + // so this write lands in the identity-scoped bucket the renderer reads — + // not a stale 2-element key that would orphan the event. The `if (!current)` + // guard means this only appends to an already-populated cache, so it never + // seeds a DM bucket with the still-ciphertext event ahead of the decrypting + // subscription path. queryClient.setQueryData( - channelMessagesKey(channelId), + channelMessagesKey(channelId, options.currentPubkey), (current) => { if (!current) { return current; diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 7f9d1cad3..8102fbe06 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -178,7 +178,7 @@ export function useChannelMessagesQuery( selfPubkey?: string, ) { const queryClient = useQueryClient(); - const queryKey = channelMessagesKey(channel?.id ?? "none"); + const queryKey = channelMessagesKey(channel?.id ?? "none", selfPubkey); const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); return useQuery({ @@ -210,6 +210,7 @@ export function useChannelMessagesQuery( channel.id, history, decryptIngested, + selfPubkey, ); // Seed the cache, then — only if the cold window renders thinner than a @@ -225,6 +226,7 @@ export function useChannelMessagesQuery( channel.id, () => true, decryptIngested, + selfPubkey, ); } return queryClient.getQueryData(queryKey) ?? mergedHistory; @@ -252,7 +254,7 @@ export function useChannelSubscription( ); queryClient.setQueryData( - channelMessagesKey(channelId), + channelMessagesKey(channelId, selfPubkey), (current = []) => mergeTimelineHistoryMessages(current, history), ); @@ -261,6 +263,7 @@ export function useChannelSubscription( channelId, history, decryptIngested, + selfPubkey, ); }); @@ -272,7 +275,7 @@ export function useChannelSubscription( const [decrypted] = await decryptIngested([event]); queryClient.setQueryData( - channelMessagesKey(channelId), + channelMessagesKey(channelId, selfPubkey), (current = []) => mergeTimelineCacheMessages(current, decrypted), ); @@ -298,6 +301,7 @@ export function useChannelSubscription( } }); + // biome-ignore lint/correctness/useExhaustiveDependencies: selfPubkey is an intentional re-subscribe trigger, not a read — the effect-event closures (syncLatestHistory, appendMessage) read the latest selfPubkey, so biome sees it as unused in the effect body. When identity resolves during a cold start the live sub must re-establish against the resolved decryptor; the initial-history ciphertext is separately re-keyed by the query-key change (channelMessagesKey now includes selfPubkey). useEffect(() => { if (!channelId || channelType === "forum") { return; @@ -353,7 +357,7 @@ export function useChannelSubscription( void cleanup(); } }; - }, [channelId, channelType]); + }, [channelId, channelType, selfPubkey]); } export function useSendMessageMutation( @@ -413,7 +417,7 @@ export function useSendMessageMutation( if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { const cachedMessages = queryClient.getQueryData( - channelMessagesKey(channel.id), + channelMessagesKey(channel.id, identity.pubkey), ) ?? []; const result = await sendChannelMessage( channel.id, @@ -486,7 +490,7 @@ export function useSendMessageMutation( return undefined; } - const queryKey = channelMessagesKey(channel.id); + const queryKey = channelMessagesKey(channel.id, identity.pubkey); await queryClient.cancelQueries({ queryKey }); const previousMessages = @@ -563,7 +567,10 @@ export function useToggleReactionMutation() { }); } -export function useDeleteMessageMutation(channel: Channel | null) { +export function useDeleteMessageMutation( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); return useMutation({ @@ -576,7 +583,7 @@ export function useDeleteMessageMutation(channel: Channel | null) { onSuccess: (_data, { eventId }) => { if (!channel) return; queryClient.setQueryData( - channelMessagesKey(channel.id), + channelMessagesKey(channel.id, selfPubkey), (current = []) => current.filter((message) => message.id !== eventId), ); }, @@ -609,7 +616,7 @@ export function useEditMessageMutation( // the cache plaintext-only like the send path and decrypt-at-ingest. const peerPubkey = dmPeerPubkey(channel, selfPubkey); const wireContent = peerPubkey - ? await nip44EncryptToPeer(peerPubkey, content) + ? await nip44EncryptToPeer(peerPubkey, content.trim()) : content; // `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji). @@ -626,7 +633,7 @@ export function useEditMessageMutation( } queryClient.setQueryData( - channelMessagesKey(channel.id), + channelMessagesKey(channel.id, selfPubkey), (current = []) => current.map((message) => { if (message.id !== eventId) return message; @@ -640,7 +647,10 @@ export function useEditMessageMutation( const nextTags = mediaTags ? applyEditTagOverlay(message.tags, mediaTags) : message.tags; - return { ...message, content, tags: nextTags }; + // Trim to match the encrypted wire body (content.trim()) so the + // cached plaintext and the relay-stored ciphertext decode to the + // same string — mirroring the send path's trim convention. + return { ...message, content: content.trim(), tags: nextTags }; }), ); }, diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index ae7c8153c..2a627479c 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -87,6 +87,7 @@ export async function backfillAuxForMessages( decryptBatch: (events: RelayEvent[]) => Promise = async ( events, ) => events, + selfPubkey?: string, ): Promise { const messageIds = collectMessageIdsForAuxBackfill(historyEvents); if (messageIds.length === 0) { @@ -94,7 +95,7 @@ export async function backfillAuxForMessages( } try { - const cacheKey = channelMessagesKey(channelId); + const cacheKey = channelMessagesKey(channelId, selfPubkey); const cachedEvents = queryClient.getQueryData(cacheKey) ?? []; const auxEvents = await relayClient.fetchAuxEventsByReference( channelId, diff --git a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs index 34c535b5a..6a59069b8 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs +++ b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + channelMessagesKey, mergeTimelineHistoryMessages, normalizeTimelineMessages, } from "./messageQueryKeys.ts"; +import { makeDmIngestDecryptor } from "./dmCrypto.ts"; const CHANNEL_ID = "timeline-window-test"; const PUBKEY = "a".repeat(64); @@ -231,3 +233,111 @@ test("sortMessages tiebreaks same-second events on id, order-independent", () => assert.deepEqual(forward, reverse); assert.deepEqual(forward, [a.id, b.id, c.id]); }); + +// ── Identity-load cold-start race: ciphertext must never render ─────────────── +// +// On a cold start where channels resolve from warm cache BEFORE the identity +// IPC resolves, ChannelScreen mounts with selfPubkey===undefined. The DM +// history query then fetches ciphertext, "decrypts" it via the no-op decryptor +// (no peer without selfPubkey), and caches RAW ciphertext. When identity +// resolves the query must re-fetch + re-decrypt so the rendered cache ends with +// plaintext — never the raw v2 ciphertext. The mechanism that forces the +// re-fetch is selfPubkey being part of the query key, so the resolved-identity +// query is a DISTINCT key React Query treats as a fresh fetch. + +// Minimal valid NIP-44 v2 envelope: base64(0x02 + 98 zero bytes), 99 decoded +// bytes. Mirrors the relay's validate_nip44_v2 floor, so the ingest decryptor +// treats it as "encrypted DM body to decrypt" rather than legacy plaintext. +const V2_CIPHERTEXT = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const DM_CHANNEL = { + id: "dm-channel-id", + channelType: "dm", + participantPubkeys: ["a".repeat(64), "b".repeat(64)], +}; +const SELF = "a".repeat(64); + +function dmEvent(content) { + return { + id: "evt".padEnd(64, "0"), + pubkey: "b".repeat(64), + created_at: 5_000, + kind: 9, + tags: [["h", DM_CHANNEL.id]], + content, + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +// Mirror useChannelMessagesQuery.queryFn's cache population for a given identity +// against a simple key->value store, so a key change is a fresh cache bucket +// exactly as React Query would treat it. +async function runHistoryFetch(store, channel, selfPubkey, fetchedEvents) { + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); + const key = JSON.stringify(channelMessagesKey(channel.id, selfPubkey)); + const history = await decryptIngested(fetchedEvents); + const current = store.get(key) ?? []; + const merged = mergeTimelineHistoryMessages(current, history); + store.set(key, merged); + return key; +} + +test("cold-start identity-load race does not leave ciphertext in the rendered DM cache", async () => { + const store = new Map(); + + // 1. Cold start: identity not yet resolved (selfPubkey undefined). The query + // runs with a no-op decryptor and caches the raw v2 ciphertext. + const preIdentityKey = await runHistoryFetch(store, DM_CHANNEL, undefined, [ + dmEvent(V2_CIPHERTEXT), + ]); + assert.equal( + store.get(preIdentityKey)[0].content, + V2_CIPHERTEXT, + "pre-identity fetch caches ciphertext under the undefined-identity key", + ); + + // 2. Identity resolves. The query key now includes selfPubkey, so this is a + // DISTINCT bucket React Query refetches into. With selfPubkey===undefined + // NOT in the key, this would land in the SAME bucket and never refetch — + // leaving the ciphertext from step 1 rendered. + const resolvedKey = await runHistoryFetch( + store, + DM_CHANNEL, + SELF, + // Identity-resolved decryptor would decrypt; here we stand in the + // plaintext the real Tauri decrypt would produce. + [dmEvent("hey, lunch at noon?")], + ); + + assert.notEqual( + resolvedKey, + preIdentityKey, + "resolved-identity query key must differ from the undefined-identity key " + + "so React Query refetches+re-decrypts instead of serving stale ciphertext", + ); + assert.equal( + store.get(resolvedKey)[0].content, + "hey, lunch at noon?", + "the rendered (resolved-identity) cache bucket holds plaintext, not ciphertext", + ); +}); + +test("channelMessagesKey isolates two identities so one peer's ciphertext is never served to another", () => { + const a = channelMessagesKey(DM_CHANNEL.id, "a".repeat(64)); + const b = channelMessagesKey(DM_CHANNEL.id, "b".repeat(64)); + assert.notDeepEqual( + a, + b, + "distinct identities must key into distinct DM caches", + ); +}); + +test("channelMessagesKey normalizes selfPubkey casing so a re-cased identity reuses its cache", () => { + const lower = channelMessagesKey(DM_CHANNEL.id, "a".repeat(64)); + const upper = channelMessagesKey(DM_CHANNEL.id, "A".repeat(64)); + assert.deepEqual( + lower, + upper, + "casing variants of the same pubkey must produce the same key (no cache split)", + ); +}); diff --git a/desktop/src/features/messages/lib/messageQueryKeys.ts b/desktop/src/features/messages/lib/messageQueryKeys.ts index 2e4c5c65b..2df6e34ae 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.ts +++ b/desktop/src/features/messages/lib/messageQueryKeys.ts @@ -14,8 +14,25 @@ import { const MAX_TIMELINE_MESSAGES = 2_000; -export function channelMessagesKey(channelId: string) { - return ["channel-messages", channelId] as const; +/** + * The timeline cache key for a channel, scoped to the viewing identity. + * + * `selfPubkey` is part of the key because a DM's cached bodies are decrypted + * for a specific identity at ingest. Keying on it means a cold start that + * mounts before the identity IPC resolves (selfPubkey undefined → no-op + * decryptor → raw ciphertext) caches under a DISTINCT key from the resolved + * identity — so when identity arrives React Query refetches and re-decrypts + * instead of rendering the stale ciphertext for 5 minutes. It also isolates + * identities so one account's decrypted DM bodies are never served to another. + * + * Normalized to lowercase so a re-cased pubkey reuses the same cache bucket. + */ +export function channelMessagesKey(channelId: string, selfPubkey?: string) { + return [ + "channel-messages", + channelId, + selfPubkey ? selfPubkey.toLowerCase() : null, + ] as const; } export function dedupeMessagesById(messages: RelayEvent[]) { diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index 0d85ad65e..9e2fe53ce 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -60,8 +60,9 @@ export async function pageOlderMessagesUntilRowFloor( decryptBatch: (events: RelayEvent[]) => Promise = async ( events, ) => events, + selfPubkey?: string, ): Promise { - const queryKey = channelMessagesKey(channelId); + const queryKey = channelMessagesKey(channelId, selfPubkey); const baseline = queryClient.getQueryData(queryKey) ?? []; if (baseline.length === 0) { return { hasOlderMessages: false }; @@ -110,6 +111,7 @@ export async function pageOlderMessagesUntilRowFloor( channelId, olderMessages, decryptBatch, + selfPubkey, ); } diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 632d9bbb7..031435eda 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -37,7 +37,7 @@ export function useFetchOlderMessages( return; } - const queryKey = channelMessagesKey(channelId); + const queryKey = channelMessagesKey(channelId, selfPubkey); const currentMessages = queryClient.getQueryData(queryKey) ?? []; if (currentMessages.length === 0) { @@ -54,6 +54,7 @@ export function useFetchOlderMessages( channelId, () => previousChannelIdRef.current === channelId, decryptIngested, + selfPubkey, ); if (!more) { hasOlderMessagesRef.current = false; @@ -65,7 +66,7 @@ export function useFetchOlderMessages( isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channelId, queryClient, decryptIngested]); + }, [channelId, queryClient, decryptIngested, selfPubkey]); return { fetchOlder, isFetchingOlder, hasOlderMessages }; } diff --git a/desktop/src/features/messages/useLoadMissingAncestors.ts b/desktop/src/features/messages/useLoadMissingAncestors.ts index 7928d4632..c80cf940b 100644 --- a/desktop/src/features/messages/useLoadMissingAncestors.ts +++ b/desktop/src/features/messages/useLoadMissingAncestors.ts @@ -13,6 +13,7 @@ import type { Channel, RelayEvent } from "@/shared/api/types"; export function useLoadMissingAncestors( activeChannel: Channel | null, resolvedMessages: RelayEvent[], + selfPubkey?: string, ) { const queryClient = useQueryClient(); const requestedAncestorIdsRef = React.useRef>(new Set()); @@ -90,7 +91,7 @@ export function useLoadMissingAncestors( } queryClient.setQueryData( - channelMessagesKey(activeChannel.id), + channelMessagesKey(activeChannel.id, selfPubkey), (current = []) => mergeMessages(current, event), ); } catch (error) { @@ -102,5 +103,5 @@ export function useLoadMissingAncestors( return () => { isCancelled = true; }; - }, [activeChannel, queryClient, resolvedMessages]); + }, [activeChannel, queryClient, resolvedMessages, selfPubkey]); } From e2ac66bf28ee3dddcfe7b1ad647c4c3ddc2ea44b Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 13:14:13 -0400 Subject: [PATCH 07/12] fix(messages): decrypt residual DM cache writers to close ciphertext leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cache-population paths bypassed makeDmIngestDecryptor and wrote raw NIP-44 v2 ciphertext into the rendered DM timeline bucket, the same leak class as the identity-load cold-start race. useLoadMissingAncestors fetched a missing thread ancestor and merged it raw — deterministically reachable by deep-linking to a reply whose parent is older than the window. useLiveChannelUpdates' dual-write (a belt-and-suspenders against the useChannelSubscription connect window, PR #410) merged the raw live event; on an id collision the last writer wins, so a raw event arriving after the decrypting path could clobber the decrypted copy with ciphertext until the 5-min staleTime. Both now route the event through makeDmIngestDecryptor before merge — a no-op outside a 2-party DM, so uniform across channel types. The dual-write is kept (option a) rather than dropped (option b) because its connect-window race protection is real coverage the decrypting subscription does not provide during that window. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/useLiveChannelUpdates.test.mjs | 92 ++++++++++++++++++ .../channels/useLiveChannelUpdates.ts | 39 +++++--- .../messages/useLoadMissingAncestors.test.mjs | 96 +++++++++++++++++++ .../messages/useLoadMissingAncestors.ts | 11 ++- 4 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 desktop/src/features/channels/useLiveChannelUpdates.test.mjs create mode 100644 desktop/src/features/messages/useLoadMissingAncestors.test.mjs diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs new file mode 100644 index 000000000..c66815ae2 --- /dev/null +++ b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; +import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto"; +import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; + +// Minimal valid NIP-44 v2 envelope (see messageQueryKeys.test.mjs). +const V2_CIPHERTEXT_LIVE = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const DM_CHANNEL_LIVE = { + id: "dm-live-channel-id", + channelType: "dm", + participantPubkeys: ["a".repeat(64), "b".repeat(64)], +}; +const SELF_LIVE = "a".repeat(64); +const PEER_LIVE = "b".repeat(64); + +function liveDmEvent(content) { + return { + id: "live".padEnd(64, "0"), + pubkey: PEER_LIVE, + created_at: 6_000, + kind: 9, + tags: [["h", DM_CHANNEL_LIVE.id]], + content, + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +// Mirror useLiveChannelUpdates' handleIncomingMessage timeline-cache write: +// decrypt via makeDmIngestDecryptor, then merge under the `if (!current)` +// guard. RED form (no decrypt) merges the raw event. +async function liveTimelineWrite(store, dmChannel, currentPubkey, event) { + const key = JSON.stringify( + channelMessagesKey(event.tags[0][1], currentPubkey), + ); + const [decrypted] = await makeDmIngestDecryptor( + dmChannel, + currentPubkey, + )([event]); + const current = store.get(key); + if (!current) { + return key; + } + store.set(key, mergeTimelineCacheMessages(current, decrypted)); + return key; +} + +test("live dual-write decrypts a DM event so it cannot clobber the decrypted copy with ciphertext", async () => { + const store = new Map(); + const key = JSON.stringify(channelMessagesKey(DM_CHANNEL_LIVE.id, SELF_LIVE)); + + // The decrypting useChannelSubscription seeds the bucket with plaintext-X. + store.set(key, [{ ...liveDmEvent("dinner at 7?"), content: "dinner at 7?" }]); + + // The live dual-write then fires for the SAME event id, carrying raw + // ciphertext. On the id collision the last writer wins — so without + // decryption this would replace plaintext-X with ciphertext-X. + await liveTimelineWrite( + store, + DM_CHANNEL_LIVE, + SELF_LIVE, + liveDmEvent(V2_CIPHERTEXT_LIVE), + ); + + const cached = store.get(key); + assert.equal(cached.length, 1, "id collision keeps a single row"); + assert.notEqual( + cached[0].content, + V2_CIPHERTEXT_LIVE, + "the live dual-write must not clobber plaintext with raw ciphertext", + ); +}); + +test("live dual-write never SEEDS an absent DM bucket (guard preserved)", async () => { + const store = new Map(); + // Bucket not yet seeded by the decrypting path: the guard returns early, so + // even a ciphertext event must not create a bucket here. + const key = await liveTimelineWrite( + store, + DM_CHANNEL_LIVE, + SELF_LIVE, + liveDmEvent(V2_CIPHERTEXT_LIVE), + ); + assert.equal( + store.has(key), + false, + "an absent bucket is never seeded by the dual-write", + ); +}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 4007c0df1..186f71cd8 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { channelsQueryKey } from "@/features/channels/hooks"; import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; +import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { getChannelIdFromTags, @@ -248,25 +249,37 @@ export function useLiveChannelUpdates( // Merge into the timeline cache for the active channel. // useChannelSubscription also writes to this cache, but there's a - // race window where it hasn't connected yet. Writes are idempotent - // (mergeTimelineCacheMessages deduplicates by event ID). + // race window where it hasn't connected yet (PR #410). Writes are + // idempotent (mergeTimelineCacheMessages deduplicates by event ID). // // Keyed on the same selfPubkey (currentPubkey) as useChannelMessagesQuery // so this write lands in the identity-scoped bucket the renderer reads — // not a stale 2-element key that would orphan the event. The `if (!current)` // guard means this only appends to an already-populated cache, so it never - // seeds a DM bucket with the still-ciphertext event ahead of the decrypting - // subscription path. - queryClient.setQueryData( - channelMessagesKey(channelId, options.currentPubkey), - (current) => { - if (!current) { - return current; - } + // seeds a DM bucket ahead of the decrypting subscription path. + // + // Decrypt before merge: a DM body is NIP-44 v2 ciphertext, and this path + // catches the same CHANNEL_EVENT_KINDS/#h events as the decrypting + // useChannelSubscription. Without decryption a raw event arriving here + // *after* the decrypting path wrote plaintext-X would CLOBBER it on the + // id collision (mergeMessagesWithNormalizer keeps the last writer). The + // decryptor is a no-op outside a 2-party DM, so this is uniform/safe. + const dmChannel = dmChannelMap.get(channelId) ?? null; + void makeDmIngestDecryptor( + dmChannel, + options.currentPubkey, + )([event]).then(([decrypted]) => { + queryClient.setQueryData( + channelMessagesKey(channelId, options.currentPubkey), + (current) => { + if (!current) { + return current; + } - return mergeTimelineCacheMessages(current, event); - }, - ); + return mergeTimelineCacheMessages(current, decrypted); + }, + ); + }); }); const handleMentionEvent = React.useEffectEvent((event: RelayEvent) => { diff --git a/desktop/src/features/messages/useLoadMissingAncestors.test.mjs b/desktop/src/features/messages/useLoadMissingAncestors.test.mjs new file mode 100644 index 000000000..300b503b1 --- /dev/null +++ b/desktop/src/features/messages/useLoadMissingAncestors.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; +import { + decryptIngestedContent, + makeDmIngestDecryptor, +} from "@/features/messages/lib/dmCrypto"; +import { mergeMessages } from "@/features/messages/hooks"; + +// base64(0x02 + 98 zero bytes) — minimal valid NIP-44 v2 envelope, so +// looksLikeNip44V2 treats it as an encrypted DM body to decrypt, not legacy +// plaintext. Matches the fixture in messageQueryKeys.test.mjs. +const V2_CIPHERTEXT = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const DM_CHANNEL = { + id: "dm-channel-id", + channelType: "dm", + participantPubkeys: ["a".repeat(64), "b".repeat(64)], +}; +const SELF = "a".repeat(64); +const PEER = "b".repeat(64); + +function ancestorEvent(content) { + return { + id: "anc".padEnd(64, "0"), + pubkey: PEER, + created_at: 4_000, + kind: 9, + tags: [["h", DM_CHANNEL.id]], + content, + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +// Mirror useLoadMissingAncestors' fetched-ancestor cache write: decrypt the +// fetched event, then mergeMessages into the channel cache. RED form (no +// decrypt) writes the raw event; the fix routes it through the decryptor. +async function loadAncestorIntoCache(store, channel, selfPubkey, event) { + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); + const key = JSON.stringify(channelMessagesKey(channel.id, selfPubkey)); + const [decrypted] = await decryptIngested([event]); + const current = store.get(key) ?? []; + store.set(key, mergeMessages(current, decrypted)); + return key; +} + +test("missing DM ancestor is decrypted before it lands in the rendered cache, never raw ciphertext", async () => { + const store = new Map(); + + // A fetched ancestor whose body is valid v2 ciphertext. Routed through the + // ingest decryptor with a resolved identity, the rendered cache must NOT end + // holding the raw ciphertext. + const key = await loadAncestorIntoCache( + store, + DM_CHANNEL, + SELF, + ancestorEvent(V2_CIPHERTEXT), + ); + + const cached = store.get(key); + assert.equal(cached.length, 1, "the ancestor is cached"); + assert.notEqual( + cached[0].content, + V2_CIPHERTEXT, + "raw NIP-44 v2 ciphertext must never be written into the rendered DM cache", + ); +}); + +test("decryptIngestedContent turns a valid-v2 ancestor body into the decrypted plaintext", async () => { + // Independent proof that the decryptor TRANSFORMS valid-v2 ciphertext (not a + // no-op passthrough), with an injected decrypt standing in for Tauri NIP-44. + const content = await decryptIngestedContent( + ancestorEvent(V2_CIPHERTEXT), + PEER, + async () => "decrypted ancestor body", + ); + assert.equal(content, "decrypted ancestor body"); +}); + +test("missing ancestor in a non-DM channel is passed through unchanged", async () => { + const store = new Map(); + const streamChannel = { + id: "stream-channel-id", + channelType: "stream", + participantPubkeys: [], + }; + // Outside a 2-party DM the decryptor is an identity no-op: a v2-shaped body + // here is NOT an encrypted DM, so it must pass through verbatim. + const key = await loadAncestorIntoCache(store, streamChannel, SELF, { + ...ancestorEvent(V2_CIPHERTEXT), + tags: [["h", streamChannel.id]], + }); + assert.equal(store.get(key)[0].content, V2_CIPHERTEXT); +}); diff --git a/desktop/src/features/messages/useLoadMissingAncestors.ts b/desktop/src/features/messages/useLoadMissingAncestors.ts index c80cf940b..c0337d175 100644 --- a/desktop/src/features/messages/useLoadMissingAncestors.ts +++ b/desktop/src/features/messages/useLoadMissingAncestors.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { mergeMessages } from "@/features/messages/hooks"; +import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto"; import { getChannelIdFromTags, getThreadReference, @@ -78,6 +79,8 @@ export function useLoadMissingAncestors( let isCancelled = false; + const decryptIngested = makeDmIngestDecryptor(activeChannel, selfPubkey); + void Promise.all( [...missingAncestorIds].map(async (eventId) => { try { @@ -90,9 +93,15 @@ export function useLoadMissingAncestors( return; } + // Decrypt before caching: a DM ancestor is a NIP-44 v2 ciphertext + // body, so it must route through the same decryptor as every other + // ingest site or it lands raw in the rendered bucket (the decryptor + // is a no-op outside a 2-party DM, so this is uniform/safe). + const [decrypted] = await decryptIngested([event]); + queryClient.setQueryData( channelMessagesKey(activeChannel.id, selfPubkey), - (current = []) => mergeMessages(current, event), + (current = []) => mergeMessages(current, decrypted), ); } catch (error) { console.error("Failed to load ancestor event", eventId, error); From 7bb94fcb083149cf92ee45b8ab357e3607f5155a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 13:43:14 -0400 Subject: [PATCH 08/12] fix(desktop): decrypt deep-link/search targets before the rendered DM timeline Deep-link, thread-ancestor, and search-hit targets fetched by ChannelRouteScreen land in targetMessageEvents as RAW RelayEvents and ChannelScreen's resolvedMessages memo merges them into the rendered list with no decrypt. For a DM the body is NIP-44 v2 ciphertext, so it rendered garbled and, on an id collision, clobbered the decrypted cache copy (the merge keeps the last writer). A new useDecryptedTargetMessageEvents hook is the single choke point downstream of all three target setters (the mount-seed useState initializer is synchronous and cannot decrypt in place), decrypting via makeDmIngestDecryptor before the merge; non-DM targets pass through synchronously to avoid a held-back first paint. Also resets the requestedAncestorIdsRef dedup in useLoadMissingAncestors on selfPubkey change, not just channel change: a cold-start ancestor fetched while identity is undefined was recorded as done, so after identity resolved the effect skipped re-fetching it into the live [...,pubkey] bucket and the ancestor silently went missing from the thread. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/channels/ui/ChannelScreen.tsx | 16 ++- .../messages/ancestorTracking.test.mjs | 44 +++++++ .../useDecryptedTargetMessageEvents.test.mjs | 107 ++++++++++++++++++ .../useDecryptedTargetMessageEvents.ts | 64 +++++++++++ .../messages/useLoadMissingAncestors.ts | 44 ++++++- 5 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/messages/ancestorTracking.test.mjs create mode 100644 desktop/src/features/messages/useDecryptedTargetMessageEvents.test.mjs create mode 100644 desktop/src/features/messages/useDecryptedTargetMessageEvents.ts diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 742c81c69..d1b1b0c78 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -43,6 +43,7 @@ import { resolveTimelineLoadingLatch, selectTimelineLoadingState, } from "@/features/messages/lib/timelineLoadingState"; +import { useDecryptedTargetMessageEvents } from "@/features/messages/useDecryptedTargetMessageEvents"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; @@ -252,13 +253,22 @@ export function ChannelScreen({ currentPubkey, ); const joinChannelMutation = useJoinChannelMutation(activeChannelId); + // Decrypt deep-link / search-hit targets before the rendered merge: they + // arrive raw, so for a DM the ciphertext would render garbled and clobber the + // decrypted cache copy on an id collision. This is the single choke point for + // every target contributor. + const decryptedTargetMessageEvents = useDecryptedTargetMessageEvents( + activeChannel, + targetMessageEvents, + currentPubkey, + ); const resolvedMessages = React.useMemo(() => { const currentMessages = messagesQuery.data ?? []; - if (!activeChannel || targetMessageEvents.length === 0) { + if (!activeChannel || decryptedTargetMessageEvents.length === 0) { return currentMessages; } - return targetMessageEvents.reduce(mergeMessages, currentMessages); - }, [activeChannel, messagesQuery.data, targetMessageEvents]); + return decryptedTargetMessageEvents.reduce(mergeMessages, currentMessages); + }, [activeChannel, messagesQuery.data, decryptedTargetMessageEvents]); const messageAuthorPubkeys = React.useMemo( () => collectMessageAuthorPubkeys(resolvedMessages), [resolvedMessages], diff --git a/desktop/src/features/messages/ancestorTracking.test.mjs b/desktop/src/features/messages/ancestorTracking.test.mjs new file mode 100644 index 000000000..d8f57e02b --- /dev/null +++ b/desktop/src/features/messages/ancestorTracking.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldResetAncestorTracking } from "@/features/messages/useLoadMissingAncestors"; + +// useLoadMissingAncestors records fetched ancestor ids so it never re-fetches +// the same id. That tracking must reset when the IDENTITY scope of the cache +// bucket changes, not only when the channel changes — otherwise a cold-start +// ancestor fetched while selfPubkey is undefined (a no-op decrypt that lands +// raw under the [...,null] bucket) is recorded as "done", and after identity +// resolves the effect SKIPS it: the ancestor is never re-fetched/re-decrypted +// into the rendered [...,pubkey] bucket and silently goes missing. + +test("ancestor tracking resets when selfPubkey resolves from undefined to a pubkey", () => { + assert.equal( + shouldResetAncestorTracking( + { channelId: "c1", selfPubkey: undefined }, + { channelId: "c1", selfPubkey: "a".repeat(64) }, + ), + true, + "cold-start identity resolution must reset so the ancestor is re-fetched", + ); +}); + +test("ancestor tracking resets when the active channel changes", () => { + assert.equal( + shouldResetAncestorTracking( + { channelId: "c1", selfPubkey: "a".repeat(64) }, + { channelId: "c2", selfPubkey: "a".repeat(64) }, + ), + true, + ); +}); + +test("ancestor tracking does NOT reset when neither channel nor identity changed", () => { + assert.equal( + shouldResetAncestorTracking( + { channelId: "c1", selfPubkey: "a".repeat(64) }, + { channelId: "c1", selfPubkey: "a".repeat(64) }, + ), + false, + "a stable scope must keep the dedup so the same ancestor is not refetched every render", + ); +}); diff --git a/desktop/src/features/messages/useDecryptedTargetMessageEvents.test.mjs b/desktop/src/features/messages/useDecryptedTargetMessageEvents.test.mjs new file mode 100644 index 000000000..6b066cb02 --- /dev/null +++ b/desktop/src/features/messages/useDecryptedTargetMessageEvents.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { makeDmIngestDecryptor } from "@/features/messages/lib/dmCrypto"; +import { mergeMessages } from "@/features/messages/hooks"; + +// Minimal valid NIP-44 v2 envelope (see messageQueryKeys.test.mjs). +const V2_CIPHERTEXT = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const DM_CHANNEL = { + id: "dm-target-channel-id", + channelType: "dm", + participantPubkeys: ["a".repeat(64), "b".repeat(64)], +}; +const STREAM_CHANNEL = { + id: "stream-target-channel-id", + channelType: "stream", + participantPubkeys: [], +}; +const SELF = "a".repeat(64); +const PEER = "b".repeat(64); + +function targetEvent(content) { + return { + id: "tgt".padEnd(64, "0"), + pubkey: PEER, + created_at: 5_000, + kind: 9, + tags: [["h", DM_CHANNEL.id]], + content, + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +// Mirror useDecryptedTargetMessageEvents + ChannelScreen's resolvedMessages +// reduce: decrypt the target events, then merge them into the (already +// decrypted) current messages exactly as the render path does. The RED form +// skips the decrypt and merges the raw target. +async function resolveRenderedTimeline( + channel, + selfPubkey, + currentMessages, + targetMessageEvents, +) { + const decryptIngested = makeDmIngestDecryptor(channel, selfPubkey); + const decryptedTargets = await decryptIngested(targetMessageEvents); + return decryptedTargets.reduce(mergeMessages, currentMessages); +} + +test("DM route-target event is decrypted before it reaches the rendered timeline, never raw ciphertext", async () => { + const rendered = await resolveRenderedTimeline( + DM_CHANNEL, + SELF, + [], + [targetEvent(V2_CIPHERTEXT)], + ); + + assert.equal(rendered.length, 1, "the target row is spliced into the list"); + assert.notEqual( + rendered[0].content, + V2_CIPHERTEXT, + "a DM route-target must not render raw ciphertext", + ); +}); + +test("DM route-target does not clobber an already-decrypted copy of the same id with ciphertext", async () => { + // The decrypted cache already holds plaintext-X (e.g. history fetched it). + const decryptedCopy = { + ...targetEvent("dinner at 7?"), + content: "dinner at 7?", + }; + + const rendered = await resolveRenderedTimeline( + DM_CHANNEL, + SELF, + [decryptedCopy], + [targetEvent(V2_CIPHERTEXT)], + ); + + assert.equal(rendered.length, 1, "id collision keeps a single row"); + assert.notEqual( + rendered[0].content, + V2_CIPHERTEXT, + "the raw target must not clobber the decrypted copy", + ); +}); + +test("non-DM route-target with a v2-shaped body passes through verbatim", async () => { + const streamTarget = { + ...targetEvent(V2_CIPHERTEXT), + tags: [["h", STREAM_CHANNEL.id]], + }; + + const rendered = await resolveRenderedTimeline( + STREAM_CHANNEL, + SELF, + [], + [streamTarget], + ); + + assert.equal( + rendered[0].content, + V2_CIPHERTEXT, + "outside a 2-party DM the decryptor is a no-op and content is untouched", + ); +}); diff --git a/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts b/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts new file mode 100644 index 000000000..b93c0b024 --- /dev/null +++ b/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts @@ -0,0 +1,64 @@ +import * as React from "react"; + +import { + dmPeerPubkey, + makeDmIngestDecryptor, +} from "@/features/messages/lib/dmCrypto"; +import type { Channel, RelayEvent } from "@/shared/api/types"; + +/** + * Decrypt deep-link / search-hit target events before they reach the rendered + * timeline. + * + * The route layer fetches deep-link targets, thread ancestors, and search hits + * as RAW RelayEvents (no decrypt) and threads them down as `targetMessageEvents`. + * `ChannelScreen` merges those into the rendered list — so for a DM, where the + * body is NIP-44 v2 ciphertext, the raw target would render garbled and, on an + * id collision, CLOBBER the decrypted cache copy (the merge keeps the last + * writer). + * + * This is the single choke point for that whole class: every contributor flows + * through the one `targetMessageEvents` array, so decrypting it here once covers + * the synchronous mount-seed, the cached-search-hit path, and the async fetch + * path uniformly — a decrypt split across the individual setters would miss the + * synchronous mount-seed and leak on a search-jump first paint. + * + * Outside a 2-party DM (`dmPeerPubkey` null) there is nothing to decrypt, so the + * events pass through synchronously with no held-back frame. Inside a DM the + * events are held back (empty) until the async decrypt resolves, so raw + * ciphertext never paints. + */ +export function useDecryptedTargetMessageEvents( + activeChannel: Channel | null, + targetMessageEvents: RelayEvent[], + selfPubkey: string | undefined, +): RelayEvent[] { + const needsDecrypt = + activeChannel !== null && dmPeerPubkey(activeChannel, selfPubkey) !== null; + + const [decryptedEvents, setDecryptedEvents] = React.useState( + [], + ); + + React.useEffect(() => { + if (!needsDecrypt || targetMessageEvents.length === 0) { + return; + } + + let isCancelled = false; + const decryptIngested = makeDmIngestDecryptor(activeChannel, selfPubkey); + void decryptIngested(targetMessageEvents).then((decrypted) => { + if (!isCancelled) { + setDecryptedEvents(decrypted); + } + }); + + return () => { + isCancelled = true; + }; + }, [activeChannel, needsDecrypt, selfPubkey, targetMessageEvents]); + + // Outside a DM there is nothing to decrypt: pass the events through directly so + // a non-DM deep-link splices its target on first paint with no held-back frame. + return needsDecrypt ? decryptedEvents : targetMessageEvents; +} diff --git a/desktop/src/features/messages/useLoadMissingAncestors.ts b/desktop/src/features/messages/useLoadMissingAncestors.ts index c0337d175..250c456cf 100644 --- a/desktop/src/features/messages/useLoadMissingAncestors.ts +++ b/desktop/src/features/messages/useLoadMissingAncestors.ts @@ -11,6 +11,34 @@ import { import { getEventById } from "@/shared/api/tauri"; import type { Channel, RelayEvent } from "@/shared/api/types"; +/** The scope that the requested-ancestor dedup set is valid for. */ +interface AncestorScope { + channelId: string | null; + selfPubkey: string | undefined; +} + +/** + * Whether the requested-ancestor dedup tracking must reset. + * + * The dedup set keys "ancestor already fetched" by id, but a fetched ancestor + * lands in `channelMessagesKey(channelId, selfPubkey)` — a bucket scoped by + * BOTH channel AND identity. So the set is only valid within a single + * (channel, identity) scope. On a cold start an ancestor fetched while + * `selfPubkey` is undefined no-op-decrypts into the orphaned `[...,null]` + * bucket yet gets recorded as done; without resetting on the identity flip the + * effect would skip re-fetching it into the live `[...,pubkey]` bucket and the + * ancestor would silently go missing from the thread. + */ +export function shouldResetAncestorTracking( + previous: AncestorScope, + next: AncestorScope, +): boolean { + return ( + previous.channelId !== next.channelId || + previous.selfPubkey !== next.selfPubkey + ); +} + export function useLoadMissingAncestors( activeChannel: Channel | null, resolvedMessages: RelayEvent[], @@ -18,16 +46,22 @@ export function useLoadMissingAncestors( ) { const queryClient = useQueryClient(); const requestedAncestorIdsRef = React.useRef>(new Set()); - const previousChannelIdRef = React.useRef(null); + const previousScopeRef = React.useRef({ + channelId: null, + selfPubkey: undefined, + }); React.useEffect(() => { - const activeChannelId = activeChannel?.id ?? null; - if (previousChannelIdRef.current === activeChannelId) { + const scope: AncestorScope = { + channelId: activeChannel?.id ?? null, + selfPubkey, + }; + if (!shouldResetAncestorTracking(previousScopeRef.current, scope)) { return; } - previousChannelIdRef.current = activeChannelId; + previousScopeRef.current = scope; requestedAncestorIdsRef.current.clear(); - }, [activeChannel?.id]); + }, [activeChannel?.id, selfPubkey]); React.useEffect(() => { if (!activeChannel || activeChannel.channelType === "forum") { From 9d919af4eb7b8521a967a58e345414446db82dd6 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 14:06:08 -0400 Subject: [PATCH 09/12] fix(desktop): hold back DM render targets until identity resolves useDecryptedTargetMessageEvents keyed its render disposition on dmPeerPubkey, which is null until selfPubkey resolves. A real 2-party DM during the cold-start pre-identity window therefore took the raw targetMessageEvents passthrough, rendering NIP-44 v2 ciphertext on first paint and clobbering the decrypted cache copy on id collision. Unlike the cache path, these targets are component state merged directly onto the rendered timeline with no [...,null] vs [...,pubkey] bucket-orphaning to discard the raw write. Key the hold-back on channel shape (2-party DM) instead of the peer pubkey so a DM holds its targets back until decrypt runs, including before identity is known. Group DMs and non-DM channels are not peer-encrypted and still pass through synchronously. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../messages/targetRenderDisposition.test.mjs | 95 +++++++++++++++++++ .../useDecryptedTargetMessageEvents.ts | 55 +++++++++-- 2 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/messages/targetRenderDisposition.test.mjs diff --git a/desktop/src/features/messages/targetRenderDisposition.test.mjs b/desktop/src/features/messages/targetRenderDisposition.test.mjs new file mode 100644 index 000000000..85b87d4d7 --- /dev/null +++ b/desktop/src/features/messages/targetRenderDisposition.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveTargetRenderEvents } from "@/features/messages/useDecryptedTargetMessageEvents"; + +// useDecryptedTargetMessageEvents decides what the rendered timeline sees for +// deep-link / search-hit target events. The dangerous branch is a DM whose +// identity has not resolved yet (cold-start selfPubkey === undefined): the hook +// must HOLD BACK (render nothing) until decrypt is possible, never pass the raw +// NIP-44 v2 ciphertext target through to the render merge. Unlike the cache +// path, these targets are component state merged directly onto resolvedMessages +// with no [...,null] vs [...,pubkey] bucket-orphaning to catch a raw write. + +const V2_CIPHERTEXT = + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const DM_CHANNEL = { + id: "dm-target-channel-id", + channelType: "dm", + participantPubkeys: ["a".repeat(64), "b".repeat(64)], +}; +const STREAM_CHANNEL = { + id: "stream-target-channel-id", + channelType: "stream", + participantPubkeys: [], +}; + +function rawTarget() { + return { + id: "tgt".padEnd(64, "0"), + pubkey: "b".repeat(64), + created_at: 5_000, + kind: 9, + tags: [["h", DM_CHANNEL.id]], + content: V2_CIPHERTEXT, + sig: "mocksig".repeat(20).slice(0, 128), + }; +} + +test("DM with unresolved identity holds back the raw target instead of rendering ciphertext", () => { + // selfPubkey undefined -> decrypt not yet possible -> decryptedEvents still []. + const rendered = resolveTargetRenderEvents(DM_CHANNEL, [rawTarget()], []); + + assert.deepEqual( + rendered, + [], + "a DM must hold back its targets until identity resolves; raw ciphertext must never reach the render path", + ); +}); + +test("DM with resolved identity renders the decrypted targets", () => { + const decrypted = [{ ...rawTarget(), content: "dinner at 7?" }]; + const rendered = resolveTargetRenderEvents( + DM_CHANNEL, + [rawTarget()], + decrypted, + ); + + assert.deepEqual( + rendered, + decrypted, + "once identity resolves the hook surfaces the decrypted target set", + ); +}); + +test("non-DM target passes through synchronously with no held-back frame", () => { + const streamTarget = { ...rawTarget(), tags: [["h", STREAM_CHANNEL.id]] }; + const rendered = resolveTargetRenderEvents( + STREAM_CHANNEL, + [streamTarget], + [], + ); + + assert.deepEqual( + rendered, + [streamTarget], + "outside a DM there is nothing to decrypt, so the target splices on first paint", + ); +}); + +test("group DM (>2 participants) is not peer-encrypted and passes through", () => { + const groupDm = { + id: "group-dm-channel-id", + channelType: "dm", + participantPubkeys: ["a".repeat(64), "b".repeat(64), "c".repeat(64)], + }; + const groupTarget = { ...rawTarget(), tags: [["h", groupDm.id]] }; + const rendered = resolveTargetRenderEvents(groupDm, [groupTarget], []); + + assert.deepEqual( + rendered, + [groupTarget], + "a group DM is not 2-party peer-encrypted, so holding it back would drop its targets", + ); +}); diff --git a/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts b/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts index b93c0b024..b5bc3e5e6 100644 --- a/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts +++ b/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts @@ -23,17 +23,50 @@ import type { Channel, RelayEvent } from "@/shared/api/types"; * path uniformly — a decrypt split across the individual setters would miss the * synchronous mount-seed and leak on a search-jump first paint. * - * Outside a 2-party DM (`dmPeerPubkey` null) there is nothing to decrypt, so the - * events pass through synchronously with no held-back frame. Inside a DM the - * events are held back (empty) until the async decrypt resolves, so raw - * ciphertext never paints. + * Outside a DM there is nothing to decrypt, so the events pass through + * synchronously with no held-back frame. Inside a DM the events are held back + * (empty) until the async decrypt resolves — INCLUDING the cold-start window + * before identity resolves (`selfPubkey === undefined`), where decrypt is not + * yet possible. Unlike the cache path, these targets are component state merged + * directly onto the rendered timeline with no `[...,null]` vs `[...,pubkey]` + * bucket-orphaning to discard a raw write, so a DM must never pass a target + * through un-decrypted — it holds back until identity lands and decrypt runs. */ + +/** + * What the rendered timeline should see for the fetched route/search targets, + * given the decrypted set the effect has produced so far. + * + * A 2-party DM is the peer-encrypted case: its targets are NIP-44 ciphertext + * and must be held back (the decrypted set is empty until decrypt resolves, and + * stays empty during the pre-identity cold-start window where decrypt is not + * yet possible) so raw ciphertext never reaches the render merge. Everything + * else — non-DM channels and group DMs (>2 participants), which are not peer- + * encrypted — passes through synchronously so a deep-link splices its target on + * first paint with no held-back frame. + * + * Keyed on channel shape (`channelType` + participant count), NOT on whether a + * peer pubkey currently resolves: the peer resolves only once `selfPubkey` is + * known, so keying on it would pass raw ciphertext through during cold start — + * the leak this hook exists to close. + */ +export function resolveTargetRenderEvents( + activeChannel: Channel | null, + targetMessageEvents: RelayEvent[], + decryptedEvents: RelayEvent[], +): RelayEvent[] { + const isTwoPartyDm = + activeChannel?.channelType === "dm" && + activeChannel.participantPubkeys.length === 2; + return isTwoPartyDm ? decryptedEvents : targetMessageEvents; +} + export function useDecryptedTargetMessageEvents( activeChannel: Channel | null, targetMessageEvents: RelayEvent[], selfPubkey: string | undefined, ): RelayEvent[] { - const needsDecrypt = + const canDecrypt = activeChannel !== null && dmPeerPubkey(activeChannel, selfPubkey) !== null; const [decryptedEvents, setDecryptedEvents] = React.useState( @@ -41,7 +74,7 @@ export function useDecryptedTargetMessageEvents( ); React.useEffect(() => { - if (!needsDecrypt || targetMessageEvents.length === 0) { + if (!canDecrypt || targetMessageEvents.length === 0) { return; } @@ -56,9 +89,11 @@ export function useDecryptedTargetMessageEvents( return () => { isCancelled = true; }; - }, [activeChannel, needsDecrypt, selfPubkey, targetMessageEvents]); + }, [activeChannel, canDecrypt, selfPubkey, targetMessageEvents]); - // Outside a DM there is nothing to decrypt: pass the events through directly so - // a non-DM deep-link splices its target on first paint with no held-back frame. - return needsDecrypt ? decryptedEvents : targetMessageEvents; + return resolveTargetRenderEvents( + activeChannel, + targetMessageEvents, + decryptedEvents, + ); } From a720a4baf0070ccc1eddcee79c9dea4e4cfd13ea Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 16:11:55 -0400 Subject: [PATCH 10/12] fix(relay): enforce E2E latch on command-path bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command-kind events (WORKFLOW_DEF/TRIGGER, APPROVAL_GRANT/DENY) short-circuit at is_command_kind before the 15c ciphertext gate, so plaintext YAML, trigger inputs, and approval notes could be persisted into a latched (encryption_activated_at-set) DM channel — defeating the latch. The drift guard keyed off the narrow requires_h_channel_scope proxy, letting command kinds escape classification. Add a body-shape latch check (empty or NIP-44 v2, else reject fail-visible) at the command path, mirroring the 15c gate's invariant. The rule is kind-agnostic, so it cannot drift and catches plaintext smuggled into nominally-structured kinds. Repoint e2e_drift_guard to the real acceptance surface (required_scope_for_kind().is_ok() && !is_global_only_kind()) with a 4-bucket exactly-one classification. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/handlers/command_executor.rs | 146 ++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 118 +++++++++++--- 2 files changed, 242 insertions(+), 22 deletions(-) diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 02abc985b..e0b30c604 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -67,6 +67,73 @@ enum PersistResult { Inserted(sqlx::Transaction<'static, sqlx::Postgres>), } +/// Disposition of a command body submitted into a latched (E2E-encrypted) +/// channel. Mirrors the 15c ingest gate's invariant — "private-channel content +/// must be NIP-44 encrypted" — but for the command path, which short-circuits +/// at `ingest.rs` BEFORE that gate (`is_command_kind` → `handle_command`), so +/// command bodies never reach it. This is the choke-point equivalent. +/// +/// The rule is body-shape, NOT per-kind: classifying by kind would re-create +/// the drift-guard footgun this fix exists to close. A nominally-structured +/// command that smuggles plaintext is caught for free. +/// +/// - empty → Ok: structured commands (DM_OPEN/ADD_MEMBER/HIDE, and +/// TRIGGER/APPROVAL with no inputs/note) legitimately carry no body. +/// - valid NIP-44 v2 → Ok: an encrypted body is the encrypted boundary holding. +/// - non-empty plaintext → Err: the leak — reject fail-visible. +/// +/// `validate_nip44_v2("")` returns `Err(Empty)`, so empty MUST be special-cased +/// here rather than delegated to the validator wholesale. +fn latched_body_disposition(content: &str) -> Result<(), IngestError> { + if content.is_empty() { + return Ok(()); + } + buzz_core::observer::validate_nip44_v2(content).map_err(|_| { + IngestError::Rejected("invalid: private-channel content must be NIP-44 encrypted".into()) + }) +} + +/// Enforce [`latched_body_disposition`] when the resolved target channel is +/// latched (`encryption_activated_at.is_some()`). A `None` channel or a +/// not-found channel passes through (cannot be latched, mirrors the 15c gate's +/// `ChannelNotFound` fall-through); a genuine DB error is fail-VISIBLE — it must +/// not let plaintext slip into a latched channel. +async fn enforce_latched_body( + state: &Arc, + content: &str, + channel_id: Option, +) -> Result<(), IngestError> { + let Some(ch_id) = channel_id else { + return Ok(()); + }; + match state.db.get_channel(ch_id).await { + Ok(channel) if channel.encryption_activated_at.is_some() => { + latched_body_disposition(content) + } + Ok(_) => Ok(()), + Err(buzz_db::DbError::ChannelNotFound(_)) => Ok(()), + Err(e) => Err(IngestError::Rejected(format!("error: database error: {e}"))), + } +} + +/// Resolve an approval's target channel via its workflow, then enforce the +/// latched-body rule on the approval note. Approvals reference no `h` tag of +/// their own — the channel is the workflow's (`get_workflow().channel_id`). +/// A workflow with no channel (global) cannot be latched, so it passes through. +async fn enforce_latched_approval_note( + state: &Arc, + content: &str, + workflow_id: Uuid, +) -> Result<(), IngestError> { + let channel_id = state + .db + .get_workflow(workflow_id) + .await + .ok() + .and_then(|w| w.channel_id); + enforce_latched_body(state, content, channel_id).await +} + /// Persist a command event inside a transaction. Returns the OPEN transaction /// as an idempotency guard — if the event was already stored, `Duplicate` is /// returned and the handler skips execution. @@ -585,6 +652,10 @@ async fn handle_workflow_def( )); } + // Latched-channel boundary: reject plaintext YAML into an E2E DM BEFORE + // parsing — a 64KB plaintext body must not be parsed, only refused. + enforce_latched_body(state, &event.content, Some(channel_id)).await?; + // 3. Parse YAML from event.content let (def, definition_json_str) = buzz_workflow::WorkflowEngine::parse_yaml(&event.content) .map_err(|e| IngestError::Rejected(format!("invalid: workflow YAML parse error: {e}")))?; @@ -691,6 +762,10 @@ async fn handle_workflow_trigger( )); } + // Latched-channel boundary: trigger inputs (event.content, parsed as JSON + // below) must be empty or NIP-44 in a latched channel — never plaintext. + enforce_latched_body(state, &event.content, workflow.channel_id).await?; + // Persist the command event — returns open transaction let tx = match persist_command_event(state, event).await? { PersistResult::Duplicate => { @@ -868,6 +943,10 @@ async fn handle_approval_grant( // 4. Validate caller is authorized approver check_approver_spec(&approval.approver_spec, &self_hex)?; + // Latched-channel boundary: the approval note (event.content) must be empty + // or NIP-44 in the workflow's latched channel — never plaintext. + enforce_latched_approval_note(state, &event.content, approval.workflow_id).await?; + // Persist the command event — returns open transaction let tx = match persist_command_event(state, event).await? { PersistResult::Duplicate => { @@ -975,6 +1054,10 @@ async fn handle_approval_deny( // 4. Validate caller is authorized approver check_approver_spec(&approval.approver_spec, &self_hex)?; + // Latched-channel boundary: the approval note (event.content) must be empty + // or NIP-44 in the workflow's latched channel — never plaintext. + enforce_latched_approval_note(state, &event.content, approval.workflow_id).await?; + // Persist the command event — returns open transaction let tx = match persist_command_event(state, event).await? { PersistResult::Duplicate => { @@ -1157,3 +1240,66 @@ async fn resume_workflow_after_approval( .await; engine.finalize_run(run_id, result, existing_trace).await; } + +#[cfg(test)] +mod tests { + use super::*; + + /// The leak: a plaintext workflow YAML body in a latched channel must be + /// REJECTED, not silently stored. Pre-fix this body reached `parse_yaml` and + /// was persisted as plaintext — the CRITICAL leak this fix closes. + #[test] + fn test_latched_workflow_def_plaintext_yaml_is_rejected() { + let yaml = "name: leak\non: manual\nsteps:\n - run: echo hi\n".repeat(1000); + assert!(matches!( + latched_body_disposition(&yaml), + Err(IngestError::Rejected(_)) + )); + } + + /// A plaintext approval note in a latched channel must be REJECTED. + #[test] + fn test_latched_approval_plaintext_note_is_rejected() { + let note = "approved because the deploy looked fine to me"; + assert!(matches!( + latched_body_disposition(note), + Err(IngestError::Rejected(_)) + )); + } + + /// Plaintext JSON trigger inputs in a latched channel must be REJECTED. + /// JSON braces/quotes/spaces fall outside the base64 alphabet, so the strong + /// validator refuses them. + #[test] + fn test_latched_workflow_trigger_plaintext_inputs_are_rejected() { + let inputs = r#"{"env": "prod", "force": true}"#; + assert!(matches!( + latched_body_disposition(inputs), + Err(IngestError::Rejected(_)) + )); + } + + /// A structured command with no body (DM_ADD_MEMBER, an empty-inputs TRIGGER, + /// a no-note APPROVAL) is ACCEPTED in a latched channel — the inverse-failure + /// guard: the rule must not over-block legitimate empty-body commands. + #[test] + fn test_latched_empty_body_command_is_accepted() { + assert!(latched_body_disposition("").is_ok()); + } + + /// A genuinely NIP-44 v2 encrypted body is ACCEPTED in a latched channel — + /// the encrypted boundary holding is the success path. + #[test] + fn test_latched_nip44_ciphertext_body_is_accepted() { + let sender = nostr::Keys::generate(); + let recipient = nostr::Keys::generate(); + let ciphertext = nostr::nips::nip44::encrypt( + sender.secret_key(), + &recipient.public_key(), + "encrypted approval note", + nostr::nips::nip44::Version::V2, + ) + .expect("encrypt"); + assert!(latched_body_disposition(&ciphertext).is_ok()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e59b504ea..c0bc45d59 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -410,12 +410,14 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { /// silently store plaintext — the exact leak the latch exists to prevent. /// /// This is the content-bearing half of the relay's channel-scoped acceptance -/// surface (`requires_h_channel_scope`). The two must stay in lockstep: any kind -/// the relay accepts as channel-scoped is either gated here (free-text body) or -/// listed as bodyless in the `e2e_drift_guard` test. That guard derives from -/// `requires_h_channel_scope` and fails if a new channel-scoped kind is added +/// surface. The `e2e_drift_guard` test derives the full surface directly — +/// `required_scope_for_kind(..).is_ok() && !is_global_only_kind(..)` — and +/// asserts every channel-scoped kind lands in exactly one bucket: gated here, +/// bodyless, or (for kinds that short-circuit before the 15c gate) enforced at +/// the command path. The guard fails if a new channel-scoped kind is added /// without classification, so this list cannot silently drift behind the -/// acceptance surface (the bug that left 40003-40007 ungated across two passes). +/// acceptance surface (the bug that left 40003-40007, then the command kinds, +/// ungated across earlier passes). /// /// 40004 (pinned) and 40005 (bookmarked) are gated despite having no SDK builder /// or relay-side schema: they are named "a stream message that has been @@ -2280,11 +2282,19 @@ mod tests { /// - NIP-29 admin (put/remove user, edit metadata, delete event/group, /// leave request): membership/admin commands, empty or structured content; /// - huddle lifecycle (started/joined/left/ended/guidelines): structured - /// session state, no message body. + /// session state, no message body; + /// - deletion (kind:5): references targets by `e` tag; any reason text is + /// not channel-display content, and gating would break deletes in a DM; + /// - reaction (kind:7): emoji or "+"/"-"; resolves its channel via + /// `derive_reaction_channel`, and gating would break DM reactions; + /// - gift wrap (kind:1059): NIP-59 sealed envelope, already ciphertext; + /// - NIP-29 create-group (9007) / join-request (9021): channel-lifecycle + /// commands, structured/empty content, no message body. /// /// This list is the test's accounting of the bodyless half of the - /// channel-scoped surface; `e2e_drift_guard` asserts the two halves together - /// cover every channel-scoped kind, so a new kind can't slip in unclassified. + /// channel-scoped surface; `e2e_drift_guard` asserts every channel-scoped + /// kind lands in exactly one classification bucket, so a new kind can't slip + /// in unclassified. const BODYLESS_CHANNEL_SCOPED_KINDS: &[u32] = &[ KIND_FORUM_VOTE, KIND_NIP29_PUT_USER, @@ -2298,30 +2308,94 @@ mod tests { KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_DELETION, + KIND_REACTION, + KIND_GIFT_WRAP, + KIND_NIP29_CREATE_GROUP, + KIND_NIP29_JOIN_REQUEST, ]; + /// Command kinds whose body carries free text the relay reads in plaintext + /// (workflow YAML, trigger JSON inputs, approval note). They short-circuit at + /// the `is_command_kind` branch in `handle_event` BEFORE the 15c gate, so + /// they CANNOT be E2E-gated there. The latched-channel boundary is enforced + /// for them at the command path instead (`enforce_latched_body` / + /// `enforce_latched_approval_note` in `command_executor`), via the body-shape + /// rule "empty or NIP-44, else reject" — not a per-kind list, so it cannot + /// drift. + const COMMAND_CONTENT_BEARING_KINDS: &[u32] = &[ + KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, + KIND_APPROVAL_GRANT, + KIND_APPROVAL_DENY, + ]; + + /// Command kinds that carry no free-text body — DM management commands keyed + /// by tags (member pubkey, channel ref), structured/empty content. They also + /// short-circuit before the 15c gate; the command-path body-shape rule admits + /// their empty content unchanged, so they are never gated. + const COMMAND_EMPTY_BODY_KINDS: &[u32] = &[KIND_DM_OPEN, KIND_DM_ADD_MEMBER, KIND_DM_HIDE]; + #[test] fn e2e_drift_guard_classifies_every_channel_scoped_kind() { // Structural fix for the kind-set-drift bug class: the E2E gate // (`is_e2e_enforced_content_kind`) is a hand-written list that ran - // parallel to the relay's actual channel-scoped acceptance surface - // (`requires_h_channel_scope`) and drifted from it, leaving content kinds - // ungated. This guard derives directly from the acceptance surface: every - // kind the relay accepts as channel-scoped MUST be classified as either - // E2E-gated (carries a free-text body) or explicitly bodyless. Adding a - // new arm to `requires_h_channel_scope` without classifying it here fails - // this test — so the gate can't silently drift behind the surface again. + // parallel to a NARROWER proxy (`requires_h_channel_scope`) than the + // relay's actual channel-scoped acceptance surface, and drifted from it — + // leaving command content kinds (WORKFLOW_DEF, APPROVAL_*) ungated. This + // guard derives directly from the REAL surface: a kind that resolves a + // `channel_id` and is accepted (`required_scope_for_kind(..).is_ok()`) and + // is not global-only. Every such kind MUST land in exactly one bucket: + // 1. 15c-gated (free-text body, reaches the 15c gate); + // 2. bodyless (no free-text content); + // 3. command content-bearing (free-text body, gated at the command path); + // 4. command empty-body (structured command, no body). + // Adding a new accepted channel-scoped kind without classifying it here + // fails this test — so the gate can't silently drift behind the surface. + let dummy = make_dummy_event(); for kind in 0u32..=50_000 { - if !requires_h_channel_scope(kind) { + let channel_scoped = + required_scope_for_kind(kind, &dummy).is_ok() && !is_global_only_kind(kind); + if !channel_scoped { continue; } - let gated = is_e2e_enforced_content_kind(kind); - let bodyless = BODYLESS_CHANNEL_SCOPED_KINDS.contains(&kind); + let buckets = [ + is_e2e_enforced_content_kind(kind), + BODYLESS_CHANNEL_SCOPED_KINDS.contains(&kind), + COMMAND_CONTENT_BEARING_KINDS.contains(&kind), + COMMAND_EMPTY_BODY_KINDS.contains(&kind), + ]; + let matched = buckets.iter().filter(|b| **b).count(); + assert_eq!( + matched, 1, + "channel-scoped kind {kind} must land in EXACTLY ONE classification \ + bucket [15c-gated, bodyless, command-content-bearing, \ + command-empty-body] — matched {matched}: {buckets:?}" + ); + } + } + + #[test] + fn e2e_drift_guard_command_kinds_partitioned() { + // The 7 command kinds partition into content-bearing (latch-enforced at + // the command path) XOR empty-body — no overlap, full coverage. This + // pins the command-path side of the surface independently of the 15c + // gate, which command kinds never reach. + for kind in [ + KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, + KIND_APPROVAL_GRANT, + KIND_APPROVAL_DENY, + KIND_DM_OPEN, + KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, + ] { + let content = COMMAND_CONTENT_BEARING_KINDS.contains(&kind); + let empty = COMMAND_EMPTY_BODY_KINDS.contains(&kind); assert!( - gated ^ bodyless, - "channel-scoped kind {kind} is unclassified: it must be either \ - E2E-gated (free-text body) or in BODYLESS_CHANNEL_SCOPED_KINDS, \ - and exactly one of the two — got gated={gated}, bodyless={bodyless}" + content ^ empty, + "command kind {kind} must be exactly one of content-bearing or \ + empty-body — got content={content}, empty={empty}" ); } } From d3f8c0fe4e488d6d4446902928520915424ab592 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 16:31:10 -0400 Subject: [PATCH 11/12] fix(relay): fail-visible on DB error resolving approval-note channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enforce_latched_approval_note swallowed every get_workflow error via .ok(), collapsing a transient DB error to None and skipping the latch check. A PgPool blip during channel resolution would let a plaintext approval note reach a latched DM — the leak this PR closes, reopened on the security boundary itself. Match enforce_latched_body's posture: NotFound passes (no resolvable channel, cannot be latched), any other DbError is fail-visible. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/handlers/command_executor.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index e0b30c604..333630782 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -120,18 +120,22 @@ async fn enforce_latched_body( /// latched-body rule on the approval note. Approvals reference no `h` tag of /// their own — the channel is the workflow's (`get_workflow().channel_id`). /// A workflow with no channel (global) cannot be latched, so it passes through. +/// +/// The `get_workflow` lookup honors the same fail-VISIBLE posture as +/// [`enforce_latched_body`]: a missing workflow (`NotFound`) cannot be latched +/// and passes, but a genuine DB error rejects rather than silently skipping the +/// check — otherwise a transient pool blip would let a plaintext note slip into +/// a latched channel. async fn enforce_latched_approval_note( state: &Arc, content: &str, workflow_id: Uuid, ) -> Result<(), IngestError> { - let channel_id = state - .db - .get_workflow(workflow_id) - .await - .ok() - .and_then(|w| w.channel_id); - enforce_latched_body(state, content, channel_id).await + match state.db.get_workflow(workflow_id).await { + Ok(w) => enforce_latched_body(state, content, w.channel_id).await, + Err(buzz_db::DbError::NotFound(_)) => Ok(()), + Err(e) => Err(IngestError::Rejected(format!("error: database error: {e}"))), + } } /// Persist a command event inside a transaction. Returns the OPEN transaction From 57e3b58716e06d587daf8a062c2e51109b27e0bb Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 23 Jun 2026 21:16:38 -0400 Subject: [PATCH 12/12] refactor(desktop): move DM peer crypto off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nip44_encrypt_to_peer / nip44_decrypt_from_peer commands ran the CPU-bound NIP-44 encrypt/decrypt synchronously while holding the keys lock on the main thread. #1222 already moved the equivalent *_self commands off-thread via async + spawn_blocking; these DM commands predate that change and were left on the asymmetric path. Mirror the *_self template so the hottest DM paths (encrypt-on-send, decrypt-on-render) no longer block the main thread. Callers are unchanged — both already await through invokeTauri. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/identity.rs | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index eb853835e..f4dd796e5 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -274,16 +274,21 @@ pub async fn nip44_decrypt_from_self( /// Rust backend — the frontend only sends plaintext + peer pubkey and gets the /// ciphertext back to embed in the kind:9 it signs. Used for DM encrypt-on-send. #[tauri::command] -pub fn nip44_encrypt_to_peer( +pub async fn nip44_encrypt_to_peer( peer_pubkey: String, plaintext: String, state: State<'_, AppState>, ) -> Result { let peer = PublicKey::from_hex(peer_pubkey.trim()).map_err(|e| format!("invalid peer pubkey: {e}"))?; - let keys = state.keys.lock().map_err(|e| e.to_string())?; - nip44::encrypt(keys.secret_key(), &peer, &plaintext, nip44::Version::V2) - .map_err(|e| format!("nip44 encrypt failed: {e}")) + let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + + tauri::async_runtime::spawn_blocking(move || { + nip44::encrypt(keys.secret_key(), &peer, &plaintext, nip44::Version::V2) + .map_err(|e| format!("nip44 encrypt failed: {e}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } /// NIP-44 v2 decrypt DM `ciphertext` from a peer. The peer pubkey is the other @@ -292,14 +297,19 @@ pub fn nip44_encrypt_to_peer( /// key decrypts both). Used for DM decrypt-on-render; on failure the frontend /// shows the mixed-version placeholder rather than blank or garbled content. #[tauri::command] -pub fn nip44_decrypt_from_peer( +pub async fn nip44_decrypt_from_peer( peer_pubkey: String, ciphertext: String, state: State<'_, AppState>, ) -> Result { let peer = PublicKey::from_hex(peer_pubkey.trim()).map_err(|e| format!("invalid peer pubkey: {e}"))?; - let keys = state.keys.lock().map_err(|e| e.to_string())?; - nip44::decrypt(keys.secret_key(), &peer, &ciphertext) - .map_err(|e| format!("nip44 decrypt failed: {e}")) + let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + + tauri::async_runtime::spawn_blocking(move || { + nip44::decrypt(keys.secret_key(), &peer, &ciphertext) + .map_err(|e| format!("nip44 decrypt failed: {e}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? }