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/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 02abc985b..333630782 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -67,6 +67,77 @@ 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. +/// +/// 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> { + 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 /// as an idempotency guard — if the event was already stored, `Duplicate` is /// returned and the handler skips execution. @@ -585,6 +656,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 +766,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 +947,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 +1058,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 +1244,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/event.rs b/crates/buzz-relay/src/handlers/event.rs index 1c3f17d66..335b94502 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -185,12 +185,37 @@ pub(crate) async fn dispatch_persistent_event( ); } + // 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: latch lookup failed, treating as encrypted: {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 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_encrypted && state .search_index_tx .try_send(stored_event.clone()) @@ -232,6 +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_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 3b3123e64..c0bc45d59 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -405,6 +405,44 @@ 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), 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. 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, 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 +/// 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 + | KIND_CANVAS + ) +} + /// Check channel membership: member OR open-visibility channel. /// /// Returns `Ok(())` if allowed, `Err(reason)` if denied. @@ -887,73 +925,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 +1462,58 @@ 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 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` + // 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 is_e2e_enforced_content_kind(kind_u32) { + 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; @@ -2199,7 +2230,175 @@ 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-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), 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, + KIND_CANVAS, + ] { + 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 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} carries no free-text body and must not be E2E-gated" + ); + } + } + + /// 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; + /// - 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 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, + 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, + 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 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 { + let channel_scoped = + required_scope_for_kind(kind, &dummy).is_ok() && !is_global_only_kind(kind); + if !channel_scoped { + continue; + } + 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!( + content ^ empty, + "command kind {kind} must be exactly one of content-bearing or \ + empty-body — got content={content}, empty={empty}" + ); + } + } 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. 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..f4dd796e5 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -269,3 +269,47 @@ 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 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())?.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 +/// 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 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())?.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}"))? +} 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/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ea6a9449b..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"; @@ -168,10 +169,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 @@ -243,16 +244,31 @@ export function ChannelScreen({ currentIdentity, ); const toggleReactionMutation = useToggleReactionMutation(); - const deleteMessageMutation = useDeleteMessageMutation(activeChannel); - const editMessageMutation = useEditMessageMutation(activeChannel); + const deleteMessageMutation = useDeleteMessageMutation( + activeChannel, + currentPubkey, + ); + const editMessageMutation = useEditMessageMutation( + activeChannel, + 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], @@ -626,7 +642,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.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 f5a03a9a2..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,18 +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). - queryClient.setQueryData( - channelMessagesKey(channelId), - (current) => { - if (!current) { - return current; - } + // 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 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/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/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/hooks.ts b/desktop/src/features/messages/hooks.ts index 4151b1b0b..8102fbe06 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 queryKey = channelMessagesKey(channel?.id ?? "none", selfPubkey); + 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,13 @@ 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, + selfPubkey, + ); // 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 +225,8 @@ export function useChannelMessagesQuery(channel: Channel | null) { queryClient, channel.id, () => true, + decryptIngested, + selfPubkey, ); } return queryClient.getQueryData(queryKey) ?? mergedHistory; @@ -217,41 +236,52 @@ 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( - channelMessagesKey(channelId), + channelMessagesKey(channelId, selfPubkey), (current = []) => mergeTimelineHistoryMessages(current, history), ); - void backfillAuxForMessages(queryClient, channelId, history); + void backfillAuxForMessages( + queryClient, + channelId, + history, + decryptIngested, + selfPubkey, + ); }); - 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), + channelMessagesKey(channelId, selfPubkey), + (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" || @@ -271,6 +301,7 @@ export function useChannelSubscription(channel: Channel | null) { } }); + // 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; @@ -293,7 +324,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) => { @@ -324,7 +357,7 @@ export function useChannelSubscription(channel: Channel | null) { void cleanup(); } }; - }, [channelId, channelType]); + }, [channelId, channelType, selfPubkey]); } export function useSendMessageMutation( @@ -358,6 +391,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 @@ -374,11 +417,11 @@ 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, - content, + wireContent, parentEventId ?? null, imetaTags, mentionPubkeys, @@ -429,19 +472,25 @@ 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") { return undefined; } - const queryKey = channelMessagesKey(channel.id); + const queryKey = channelMessagesKey(channel.id, identity.pubkey); await queryClient.cancelQueries({ queryKey }); const previousMessages = @@ -518,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({ @@ -531,14 +583,17 @@ 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), ); }, }); } -export function useEditMessageMutation(channel: Channel | null) { +export function useEditMessageMutation( + channel: Channel | null, + selfPubkey?: string, +) { const queryClient = useQueryClient(); return useMutation< @@ -555,13 +610,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.trim()) + : 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) { @@ -569,7 +633,7 @@ export function useEditMessageMutation(channel: Channel | null) { } queryClient.setQueryData( - channelMessagesKey(channel.id), + channelMessagesKey(channel.id, selfPubkey), (current = []) => current.map((message) => { if (message.id !== eventId) return message; @@ -583,7 +647,10 @@ export function useEditMessageMutation(channel: Channel | null) { 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 df0272d73..2a627479c 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -84,6 +84,10 @@ export async function backfillAuxForMessages( queryClient: QueryClient, channelId: string, historyEvents: RelayEvent[], + decryptBatch: (events: RelayEvent[]) => Promise = async ( + events, + ) => events, + selfPubkey?: string, ): Promise { const messageIds = collectMessageIdsForAuxBackfill(historyEvents); if (messageIds.length === 0) { @@ -91,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, @@ -109,8 +113,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/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 47fdaf00e..9e2fe53ce 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -57,8 +57,12 @@ export async function pageOlderMessagesUntilRowFloor( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + 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 }; @@ -78,10 +82,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 +106,13 @@ export async function pageOlderMessagesUntilRowFloor( queryClient.setQueryData(queryKey, (current = []) => mergeTimelineHistoryMessages(current, olderMessages), ); - void backfillAuxForMessages(queryClient, channelId, olderMessages); + void backfillAuxForMessages( + queryClient, + channelId, + olderMessages, + decryptBatch, + selfPubkey, + ); } // Progress guard, not exhaustion: if the oldest timestamp didn't move back 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.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..b5bc3e5e6 --- /dev/null +++ b/desktop/src/features/messages/useDecryptedTargetMessageEvents.ts @@ -0,0 +1,99 @@ +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 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 canDecrypt = + activeChannel !== null && dmPeerPubkey(activeChannel, selfPubkey) !== null; + + const [decryptedEvents, setDecryptedEvents] = React.useState( + [], + ); + + React.useEffect(() => { + if (!canDecrypt || 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, canDecrypt, selfPubkey, targetMessageEvents]); + + return resolveTargetRenderEvents( + activeChannel, + targetMessageEvents, + decryptedEvents, + ); +} diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 50ad7ca9d..031435eda 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); @@ -29,7 +37,7 @@ export function useFetchOlderMessages(channel: Channel | null) { return; } - const queryKey = channelMessagesKey(channelId); + const queryKey = channelMessagesKey(channelId, selfPubkey); const currentMessages = queryClient.getQueryData(queryKey) ?? []; if (currentMessages.length === 0) { @@ -45,6 +53,8 @@ export function useFetchOlderMessages(channel: Channel | null) { queryClient, channelId, () => previousChannelIdRef.current === channelId, + decryptIngested, + selfPubkey, ); if (!more) { hasOlderMessagesRef.current = false; @@ -56,7 +66,7 @@ export function useFetchOlderMessages(channel: Channel | null) { isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channelId, queryClient]); + }, [channelId, queryClient, decryptIngested, selfPubkey]); return { fetchOlder, isFetchingOlder, hasOlderMessages }; } 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 7928d4632..250c456cf 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, @@ -10,22 +11,57 @@ 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[], + selfPubkey?: string, ) { 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") { @@ -77,6 +113,8 @@ export function useLoadMissingAncestors( let isCancelled = false; + const decryptIngested = makeDmIngestDecryptor(activeChannel, selfPubkey); + void Promise.all( [...missingAncestorIds].map(async (eventId) => { try { @@ -89,9 +127,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), - (current = []) => mergeMessages(current, event), + channelMessagesKey(activeChannel.id, selfPubkey), + (current = []) => mergeMessages(current, decrypted), ); } catch (error) { console.error("Failed to load ancestor event", eventId, error); @@ -102,5 +146,5 @@ export function useLoadMissingAncestors( return () => { isCancelled = true; }; - }, [activeChannel, queryClient, resolvedMessages]); + }, [activeChannel, queryClient, resolvedMessages, selfPubkey]); } 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) );