diff --git a/AGENTS.md b/AGENTS.md index bf66d24..064358a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,10 +60,12 @@ Don't add speculative configurability or one-off helpers. ## 4. Security invariants — never weaken to make something pass -- **Verification is not optional.** `decryptEvent` rejects unverified events by - default; a below-floor key version must never verify; an invalid signature must - never yield plaintext. Verify against caller-supplied signing keys — **never** a - key carried inside the event. Verification covers every signed event type, not +- **Verification is not optional.** `decryptEvent` rejects unverified encrypted + messages and key changes by default, and any event whose signature is present + but invalid; other events without a checkable signature are returned with + `verified: false`, never silently dropped. A below-floor key version must never + verify; an invalid signature must never yield plaintext. Verify against + caller-supplied signing keys — **never** a key carried inside the event. Verification covers every signed event type, not just messages. Signature mismatches are bugs to investigate, not checks to silence. - **Key downgrade protection is automatic.** Conversation-key versions move forward only (monotonic high-water mark held for the `Chat` lifetime); the diff --git a/crates/core/examples/gen_sdk_vectors.rs b/crates/core/examples/gen_sdk_vectors.rs index 82e3d87..b7e16ce 100644 --- a/crates/core/examples/gen_sdk_vectors.rs +++ b/crates/core/examples/gen_sdk_vectors.rs @@ -28,6 +28,8 @@ const EVENT_MESSAGE_TEXT: &str = "fixture event message"; const EVENT_REPLY_TEXT: &str = "fixture reply message"; const EVENT_REPLY_FORGED_PREVIEW_TEXT: &str = "forged preview text"; const EVENT_GARBAGE: &str = "!!!not-an-event!!!"; +const EVENT_UNENCRYPTED_MESSAGE_TEXT: &str = "fixture unencrypted message"; +const EVENT_READ_SEEN_UNTIL_ID: &str = "42"; // Raw Thrift enum values embedded in the failure vector; suites assert the // decoded names ("RateLimitUpsell" / "Premium", camelCased in JS). @@ -174,6 +176,23 @@ fn main() { ) .expect("frame failure event"); + // Unencrypted message and unsigned read receipt: both decode under the + // default reject-unverified policy with `verified: false`. + let event_unencrypted_message_b64 = internals::frame_unencrypted_message( + "plain-msg-1", + EVENT_SENDER_ID, + EVENT_CONVERSATION_ID, + EVENT_UNENCRYPTED_MESSAGE_TEXT, + ) + .expect("frame unencrypted message event"); + let event_unsigned_read_receipt_b64 = internals::frame_unsigned_read_receipt( + "read-msg-1", + EVENT_SENDER_ID, + EVENT_CONVERSATION_ID, + EVENT_READ_SEEN_UNTIL_ID, + ) + .expect("frame unsigned read receipt event"); + let obj = json!({ "identity_private_b64": B64.encode(identity_private), "signing_private_b64": B64.encode(signing_private), @@ -199,6 +218,10 @@ fn main() { "event_signing_key_version": EVENT_SIGNING_KEY_VERSION, "event_recipient_key_version": EVENT_RECIPIENT_KEY_VERSION, "event_message_text": EVENT_MESSAGE_TEXT, + "event_unencrypted_message_b64": event_unencrypted_message_b64, + "event_unencrypted_message_text": EVENT_UNENCRYPTED_MESSAGE_TEXT, + "event_unsigned_read_receipt_b64": event_unsigned_read_receipt_b64, + "event_read_seen_until_id": EVENT_READ_SEEN_UNTIL_ID, }); println!("{}", serde_json::to_string_pretty(&obj).unwrap()); diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index 33befbd..2247370 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -177,10 +177,12 @@ impl Chat { } } - /// When enabled — the default — `decrypt_event` returns an error for any - /// signed event whose signature cannot be verified (invalid, missing, or - /// no matching signing key) instead of returning it with - /// `verified: false`. + /// When enabled — the default — `decrypt_event` returns an error for an + /// encrypted message or key change whose signature cannot be verified + /// (invalid, missing, or no matching signing key), and for any other + /// signed event whose signature is present but invalid. Unencrypted + /// messages and other events without a checkable signature are returned + /// with `verified: false`. See `ChatCore::set_reject_unverified`. pub fn set_reject_unverified(&mut self, reject: bool) { self.inner.set_reject_unverified(reject); } diff --git a/crates/core/src/core.rs b/crates/core/src/core.rs index 20fd878..cb30feb 100644 --- a/crates/core/src/core.rs +++ b/crates/core/src/core.rs @@ -91,8 +91,9 @@ impl std::fmt::Debug for CachedConversationKey { impl ChatCore { /// Create a new `ChatCore` with no keys loaded. /// - /// `reject_unverified` defaults to `true` — unverified events are - /// rejected. Call `set_reject_unverified(false)` to opt out. + /// `reject_unverified` defaults to `true` — unverified encrypted messages + /// and key changes are rejected. Call `set_reject_unverified(false)` to + /// opt out. pub fn new() -> Self { Self { keypair_manager: KeypairManager::new(), @@ -105,13 +106,17 @@ impl ChatCore { } } - /// When enabled — the default — `decrypt_event` returns an error for any - /// signed event type that cannot be positively verified: an invalid - /// signature, a missing signature, no matching signing key (including an - /// empty signing-key list), or an unencrypted message (which carries no - /// verifiable signature). When disabled, such events are returned with - /// `verified: false` instead. Event types that never carry a signature - /// (typing, failure, member-account-delete) are unaffected either way. + /// When enabled — the default — `decrypt_event` returns an error for an + /// encrypted message or key change that cannot be positively verified: + /// an invalid signature, a missing signature, or no matching signing key + /// (including an empty signing-key list). Other signed event types + /// (receipts, deletes, group and settings changes) are rejected only when + /// a signature is present but invalid; without a checkable signature they + /// are returned with `verified: false`. Unencrypted messages carry no + /// signature and are returned with `key_version: None, verified: false`. + /// When disabled, every event is returned, with `verified: false` when it + /// does not verify. Event types that never carry a signature (typing, + /// failure, member-account-delete) are unaffected either way. pub fn set_reject_unverified(&mut self, reject: bool) { self.reject_unverified = reject; } @@ -1392,9 +1397,12 @@ impl ChatCore { /// matches the event's sender, then picks the one matching the version /// embedded in the message signature — the same selection /// [`Self::decrypt_events`] applies. Under the default reject-unverified - /// policy an empty slice makes every signed event fail; only after - /// [`Self::set_reject_unverified`]`(false)` are such events returned with - /// `verified: false`. + /// policy an empty slice makes every encrypted message and key change + /// fail; only after [`Self::set_reject_unverified`]`(false)` are those + /// returned with `verified: false`. Unencrypted messages + /// (`key_version: None`) and other signed event types without a + /// checkable signature are always returned with `verified: false`; a + /// signature that is present but invalid is still rejected. pub fn decrypt_event( &self, event_b64: &str, @@ -1523,7 +1531,7 @@ impl ChatCore { MessageEventDetail::MarkConversationReadEvent(read_event) => { let sig_result = self.verify_event_signature(parsed, detail, signing_keys, None); let verified = matches!(sig_result, Ok(true)); - self.reject_if_unverified(&sig_result, "ReadReceipt")?; + self.reject_if_invalid(&sig_result, "ReadReceipt")?; Event::ReadReceipt(ReadReceiptEvent { meta, verified, @@ -1534,7 +1542,7 @@ impl ChatCore { MessageEventDetail::MarkConversationUnreadEvent(unread_event) => { let sig_result = self.verify_event_signature(parsed, detail, signing_keys, None); let verified = matches!(sig_result, Ok(true)); - self.reject_if_unverified(&sig_result, "MarkedUnread")?; + self.reject_if_invalid(&sig_result, "MarkedUnread")?; Event::MarkedUnread(MarkedUnreadEvent { meta, verified, @@ -1544,7 +1552,7 @@ impl ChatCore { MessageEventDetail::MessageDeleteEvent(del_event) => { let sig_result = self.verify_event_signature(parsed, detail, signing_keys, None); let verified = matches!(sig_result, Ok(true)); - self.reject_if_unverified(&sig_result, "MessageDelete")?; + self.reject_if_invalid(&sig_result, "MessageDelete")?; let delete_for_all = del_event .delete_message_action .as_ref() @@ -1560,7 +1568,7 @@ impl ChatCore { MessageEventDetail::ConversationDeleteEvent(conv_del) => { let sig_result = self.verify_event_signature(parsed, detail, signing_keys, None); let verified = matches!(sig_result, Ok(true)); - self.reject_if_unverified(&sig_result, "ConversationDelete")?; + self.reject_if_invalid(&sig_result, "ConversationDelete")?; let clear_all = conv_del .clear_conversation_options .as_ref() @@ -1589,7 +1597,7 @@ impl ChatCore { MessageEventDetail::GroupChangeEvent(group_change) => { let sig_result = self.verify_event_signature(parsed, detail, signing_keys, None); let verified = matches!(sig_result, Ok(true)); - self.reject_if_unverified(&sig_result, "GroupChange")?; + self.reject_if_invalid(&sig_result, "GroupChange")?; let change = convert_group_change(group_change.group_change.as_ref()); Event::GroupChange(GroupChangeEvent { meta, @@ -1600,7 +1608,7 @@ impl ChatCore { MessageEventDetail::ConversationMetadataChangeEvent(settings) => { let sig_result = self.verify_event_signature(parsed, detail, signing_keys, None); let verified = matches!(sig_result, Ok(true)); - self.reject_if_unverified(&sig_result, "SettingsChange")?; + self.reject_if_invalid(&sig_result, "SettingsChange")?; let change = convert_settings_change(settings.conversation_metadata_change.as_ref()); Event::SettingsChange(SettingsChangeEvent { @@ -1622,22 +1630,20 @@ impl ChatCore { // Unencrypted MCE: conversation_key_version is None — contents // are already plaintext Thrift bytes, no signature to verify. + // They are returned with `key_version: None, verified: false` + // and reject_unverified does not apply to them. let is_unencrypted = mce.conversation_key_version.is_none(); // Verify before decrypt so that an invalid signature never // results in plaintext being returned (when reject_unverified). - // Unencrypted messages carry no signature, so they are treated - // as unverifiable and rejected under reject_unverified too. - let (verified, sig_result) = if is_unencrypted { - (false, Ok(false)) + let verified = if is_unencrypted { + false } else { let r = self.verify_event_signature(parsed, detail, signing_keys, None); - let v = matches!(r, Ok(true)); - (v, r) + self.reject_if_unverified(&r, "Message")?; + matches!(r, Ok(true)) }; - self.reject_if_unverified(&sig_result, "Message")?; - let plaintext = if is_unencrypted { contents.clone() } else { @@ -2651,12 +2657,12 @@ impl ChatCore { Ok(signing.private.clone()) } - /// Enforce `reject_unverified` policy on a signature result. + /// Enforce `reject_unverified` policy on a signature result for an + /// encrypted message or a key change. /// /// When `reject_unverified` is enabled, both `Err(reason)` (signature /// present but cryptographically invalid) and `Ok(false)` (signature - /// missing or no matching key) are rejected for event types that carry - /// a signature. + /// missing or no matching key) are rejected. fn reject_if_unverified( &self, sig_result: &Result, @@ -2679,6 +2685,24 @@ impl ChatCore { } } + /// Enforce `reject_unverified` policy on a signature result for an event + /// that carries no encrypted content (read receipts, deletes, group and + /// settings changes). + /// + /// Only `Err(reason)` (signature present but cryptographically invalid) + /// is rejected. `Ok(false)` passes so the event is returned with + /// `verified: false`; these events may be legitimately unsigned. + fn reject_if_invalid( + &self, + sig_result: &Result, + event_label: &str, + ) -> Result<(), SdkError> { + match sig_result { + Ok(_) => Ok(()), + Err(_) => self.reject_if_unverified(sig_result, event_label), + } + } + /// Verify a message event signature against a set of signing keys. /// /// Selects the key whose `public_key_version` matches the version embedded @@ -4462,20 +4486,23 @@ mod tests { } #[test] - fn decrypt_event_unencrypted_mce_reject_unverified_errors() { - // Unencrypted MCEs carry no signature, so under reject_unverified - // (the default) they are unverifiable and must be rejected. + fn decrypt_event_unencrypted_mce_returned_under_reject_unverified() { + // Unencrypted MCEs carry no signature; under the default + // reject_unverified they are returned flagged, not rejected. let core = ChatCore::new(); core.generate_keypairs().unwrap(); let content = build_plaintext_content("No sig"); let event_b64 = build_test_message_event(&content, None); - let result = core.decrypt_event(&event_b64, &Default::default(), &[]); - assert!( - result.is_err(), - "unencrypted message must be rejected under reject_unverified" - ); + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::Message(msg)) => { + assert_eq!(msg.text(), Some("No sig")); + assert!(msg.key_version.is_none()); + assert!(!msg.verified); + } + other => panic!("Expected unencrypted Event::Message, got {:?}", other), + } } #[test] @@ -4633,9 +4660,7 @@ mod tests { } #[test] - fn reject_unverified_rejects_group_title_change_without_signing_keys() { - // GroupTitleChange with no signing keys — reject_unverified=true - // must reject it (not silently pass it through). + fn reject_unverified_returns_unsigned_group_title_change_unverified() { let mut core = ChatCore::new(); core.generate_keypairs().unwrap(); core.set_reject_unverified(true); @@ -4648,25 +4673,20 @@ mod tests { ); let event_b64 = build_test_event(ThriftDetail::GroupChangeEvent(gc)); - let result = core.decrypt_event(&event_b64, &Default::default(), &[]); - assert!(result.is_err(), "Should reject unsigned GroupChange"); - - // With reject_unverified=false it passes through with verified=false. - core.set_reject_unverified(false); - let event = core - .decrypt_event(&event_b64, &Default::default(), &[]) - .unwrap(); - match event { - Event::GroupChange(gc) => { - assert!(!gc.verified); - assert!(matches!(gc.change, GroupChange::TitleChanged { .. })); + for reject in [true, false] { + core.set_reject_unverified(reject); + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::GroupChange(gc)) => { + assert!(!gc.verified); + assert!(matches!(gc.change, GroupChange::TitleChanged { .. })); + } + other => panic!("Expected Event::GroupChange, got {:?}", other), } - other => panic!("Expected Event::GroupChange, got {:?}", other), } } #[test] - fn reject_unverified_rejects_group_member_add_without_signing_keys() { + fn reject_unverified_returns_unsigned_group_member_add_unverified() { let mut core = ChatCore::new(); core.generate_keypairs().unwrap(); core.set_reject_unverified(true); @@ -4691,12 +4711,50 @@ mod tests { ); let event_b64 = build_test_event(ThriftDetail::GroupChangeEvent(gc)); - let result = core.decrypt_event(&event_b64, &Default::default(), &[]); - assert!(result.is_err(), "Should reject unsigned GroupChange"); + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::GroupChange(gc)) => assert!(!gc.verified), + other => panic!("Expected Event::GroupChange, got {:?}", other), + } + } + + #[test] + fn reject_unverified_returns_unsigned_group_member_remove_unverified() { + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + + let gc = ThriftGCE::new( + Some(crate::thrift::event::GroupChange::GroupMemberRemove( + GroupMemberRemoveChange::new(Some(vec!["removed-user".to_string()])), + )), + None, + ); + let event_b64 = build_test_event(ThriftDetail::GroupChangeEvent(gc)); + + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::GroupChange(gc)) => { + assert!(!gc.verified); + assert!(matches!(gc.change, GroupChange::MembersRemoved { .. })); + } + other => panic!("Expected Event::GroupChange, got {:?}", other), + } + } + + #[test] + fn reject_unverified_returns_unsigned_read_receipt_unverified() { + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + + let read = ThriftMCRE::new(Some("seq-50".to_string()), Some(1700000050000i64)); + let event_b64 = build_test_event(ThriftDetail::MarkConversationReadEvent(read)); + + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::ReadReceipt(r)) => assert!(!r.verified), + other => panic!("Expected Event::ReadReceipt, got {:?}", other), + } } #[test] - fn reject_unverified_rejects_message_delete_without_signing_keys() { + fn reject_unverified_returns_unsigned_message_delete_unverified() { let mut core = ChatCore::new(); core.generate_keypairs().unwrap(); core.set_reject_unverified(true); @@ -4707,12 +4765,65 @@ mod tests { ); let event_b64 = build_test_event(ThriftDetail::MessageDeleteEvent(del)); - let result = core.decrypt_event(&event_b64, &Default::default(), &[]); - assert!(result.is_err(), "Should reject unsigned MessageDelete"); + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::MessageDeleted(md)) => assert!(!md.verified), + other => panic!("Expected Event::MessageDeleted, got {:?}", other), + } + } + + #[test] + fn reject_unverified_rejects_message_delete_with_invalid_signature() { + // A signature that is present and checked against the sender's key + // but does not verify is still rejected. + let mut core = ChatCore::new(); + let reg = core.generate_keypairs().unwrap(); + + let del = ThriftMDE::new( + Some(vec!["seq-99".to_string()]), + Some(DeleteMessageAction::DELETE_FOR_ALL), + ); + let sig = crate::thrift::event::MessageEventSignature::new( + Some("AAAA".to_string()), + Some("pkv".to_string()), + Some(crate::signatures::CURRENT_SIGNATURE_VERSION.to_string()), + None, + None, + ); + let event = ThriftMessageEvent::new( + Some("seq-1".to_string()), + Some("msg-1".to_string()), + Some("sender-1".to_string()), + Some("conv-1".to_string()), + None::, + None::, + Some(ThriftDetail::MessageDeleteEvent(del)), + None::, + Some(sig), + None::, + None::, + ); + let event_b64 = base64_encode(&serialize_thrift(&event).unwrap()); + let signing_keys = [signing_key_entry_for(®, "sender-1")]; + + let err = core + .decrypt_event(&event_b64, &Default::default(), &signing_keys) + .expect_err("invalid MessageDelete signature must be rejected"); + assert!( + err.to_string() + .contains("MessageDelete signature verification failed"), + "unexpected error: {}", + err + ); + + core.set_reject_unverified(false); + match core.decrypt_event(&event_b64, &Default::default(), &signing_keys) { + Ok(Event::MessageDeleted(md)) => assert!(!md.verified), + other => panic!("Expected Event::MessageDeleted, got {:?}", other), + } } #[test] - fn reject_unverified_rejects_group_change_with_no_inner_change() { + fn reject_unverified_returns_group_change_with_no_inner_change_unverified() { let mut core = ChatCore::new(); core.generate_keypairs().unwrap(); core.set_reject_unverified(true); @@ -4720,8 +4831,10 @@ mod tests { let gc = ThriftGCE::new(None::, None); let event_b64 = build_test_event(ThriftDetail::GroupChangeEvent(gc)); - let result = core.decrypt_event(&event_b64, &Default::default(), &[]); - assert!(result.is_err(), "Should reject unsigned GroupChange"); + match core.decrypt_event(&event_b64, &Default::default(), &[]) { + Ok(Event::GroupChange(gc)) => assert!(!gc.verified), + other => panic!("Expected Event::GroupChange, got {:?}", other), + } } /// `generate_keypairs` produces a bidirectional cross-signature. @@ -8023,20 +8136,65 @@ mod tests { } #[test] - fn reject_unverified_rejects_unencrypted_message_by_default() { - // An unencrypted message (no conversation_key_version) carries no - // signature, so under the default reject_unverified it is rejected. + fn reject_unverified_rejects_unsigned_encrypted_message() { let core = ChatCore::new(); core.generate_keypairs().unwrap(); + let ckey = core.generate_conversation_key().unwrap(); + let encrypted = crate::crypto::encryption::encrypt_message( + &ckey, + &build_plaintext_content("ciphertext"), + ) + .unwrap(); + let event_b64 = build_test_message_event(&encrypted, Some("42")); + let conv_keys = [("42".to_string(), ckey)].into_iter().collect(); - let content = build_plaintext_content("plaintext"); - let event_b64 = build_test_message_event(&content, None); + let err = core + .decrypt_event(&event_b64, &conv_keys, &[]) + .expect_err("unsigned encrypted message must be rejected"); + assert!(err + .to_string() + .contains("Message signature could not be verified")); + } - let result = core.decrypt_event(&event_b64, &Default::default(), &[]); - assert!( - result.is_err(), - "unencrypted message must be rejected under default reject_unverified" - ); + #[test] + fn decrypt_events_returns_unencrypted_and_unsigned_events_flagged() { + // Default reject_unverified: an unencrypted message and an unsigned + // read receipt are returned flagged, while an unsigned encrypted + // message in the same batch is still reported as an error. + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + let ckey = core.generate_conversation_key().unwrap(); + + let plain_b64 = build_test_message_event(&build_plaintext_content("plaintext"), None); + let read_b64 = build_test_event(ThriftDetail::MarkConversationReadEvent(ThriftMCRE::new( + Some("seq-50".to_string()), + Some(1700000050000i64), + ))); + let encrypted = crate::crypto::encryption::encrypt_message( + &ckey, + &build_plaintext_content("ciphertext"), + ) + .unwrap(); + let enc_b64 = build_test_message_event(&encrypted, Some("42")); + + let events: Vec<&str> = vec![&plain_b64, &read_b64, &enc_b64]; + let result = core.decrypt_events(&events, &[]); + + assert_eq!(result.messages.len(), 2); + match &result.messages[0].event { + Event::Message(m) => { + assert!(m.key_version.is_none()); + assert!(!m.verified); + assert_eq!(m.text(), Some("plaintext")); + } + other => panic!("Expected Event::Message, got {:?}", other), + } + match &result.messages[1].event { + Event::ReadReceipt(r) => assert!(!r.verified), + other => panic!("Expected Event::ReadReceipt, got {:?}", other), + } + assert_eq!(result.errors.len(), 1); + assert!(result.errors[&2].contains("Message signature could not be verified")); } // ChatCore-level roundtrips: reply / add-reaction / remove-reaction diff --git a/crates/core/src/internals.rs b/crates/core/src/internals.rs index 04d469c..fbfadb3 100644 --- a/crates/core/src/internals.rs +++ b/crates/core/src/internals.rs @@ -14,8 +14,9 @@ use crate::protocol::safe_reader::BoundedProtocol; use crate::protocol::serialization::{base64_decode, base64_encode}; use crate::signatures::ActionSignature; use crate::thrift::event::{ - ConversationKeyChangeEvent, ConversationParticipantKey, FailureType, MessageEvent, - MessageEventDetail, MessageEventSignature, MessageFailureEvent, RateLimitTier, + ConversationKeyChangeEvent, ConversationParticipantKey, FailureType, MarkConversationReadEvent, + MessageCreateEvent, MessageEvent, MessageEventDetail, MessageEventSignature, + MessageFailureEvent, RateLimitTier, }; use crate::types::SendPayload; use std::io::Cursor; @@ -153,6 +154,55 @@ pub fn frame_failure_event( ) } +/// Frame an unencrypted text message (no conversation key version, no +/// signature) in the backend `MessageEvent` envelope (base64). The output is +/// fully deterministic. +pub fn frame_unencrypted_message( + message_id: &str, + sender_id: &str, + conversation_id: &str, + text: &str, +) -> Result { + let mce = MessageCreateEvent::new( + Some(crate::pipeline::build_message_content(text, None, None)?), + None::, + None::, + None::, + None::, + None::, + None::, + None::>, + None, + None, + ); + frame_event( + message_id, + sender_id, + conversation_id, + MessageEventDetail::MessageCreateEvent(mce), + None, + ) +} + +/// Frame a read receipt without a signature in the backend `MessageEvent` +/// envelope (base64). The output is fully deterministic. +pub fn frame_unsigned_read_receipt( + message_id: &str, + sender_id: &str, + conversation_id: &str, + seen_until_sequence_id: &str, +) -> Result { + let read = + MarkConversationReadEvent::new(Some(seen_until_sequence_id.to_string()), None::); + frame_event( + message_id, + sender_id, + conversation_id, + MessageEventDetail::MarkConversationReadEvent(read), + None, + ) +} + /// Bounded untrusted parse of a raw backend event — the entry every base64 /// event goes through before any crypto. Exposed for fuzzing; must never /// panic, whatever the input. diff --git a/crates/core/tests/sdk_vectors.rs b/crates/core/tests/sdk_vectors.rs index 06f664b..e4475ba 100644 --- a/crates/core/tests/sdk_vectors.rs +++ b/crates/core/tests/sdk_vectors.rs @@ -30,6 +30,10 @@ struct Vectors { event_signing_key_version: String, event_recipient_key_version: String, event_message_text: String, + event_unencrypted_message_b64: String, + event_unencrypted_message_text: String, + event_unsigned_read_receipt_b64: String, + event_read_seen_until_id: String, } fn load_vectors() -> Vectors { @@ -236,6 +240,48 @@ fn vectors_failure_event_decodes_type_and_rate_limit_tier() { } } +#[test] +fn vectors_unencrypted_message_and_unsigned_receipt_decode_under_default_policy() { + let v = load_vectors(); + let core = chat_xdk_core::ChatCore::new(); // default reject_unverified = true + + match core + .decrypt_event(&v.event_unencrypted_message_b64, &Default::default(), &[]) + .unwrap() + { + chat_xdk_core::Event::Message(msg) => { + assert_eq!(msg.text(), Some(v.event_unencrypted_message_text.as_str())); + assert!(msg.key_version.is_none()); + assert!(!msg.verified); + } + other => panic!("Expected Event::Message, got {:?}", other), + } + + match core + .decrypt_event(&v.event_unsigned_read_receipt_b64, &Default::default(), &[]) + .unwrap() + { + chat_xdk_core::Event::ReadReceipt(r) => { + assert_eq!( + r.seen_until_id.as_deref(), + Some(v.event_read_seen_until_id.as_str()) + ); + assert!(!r.verified); + } + other => panic!("Expected Event::ReadReceipt, got {:?}", other), + } + + // An encrypted message without a usable signing key is still rejected. + let ckey = + XChatConversationKey::from_bytes(B64.decode(&v.conversation_key_b64).unwrap()).unwrap(); + let conv_keys = [(v.event_conversation_key_version.clone(), ckey)] + .into_iter() + .collect(); + assert!(core + .decrypt_event(&v.event_message_b64, &conv_keys, &[]) + .is_err()); +} + #[test] fn vectors_reply_preview_validation_accepts_genuine_and_rejects_forged() { let v = load_vectors(); diff --git a/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs b/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs index f9600b6..d0427d2 100644 --- a/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs +++ b/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs @@ -51,6 +51,10 @@ private sealed class SdkVectors [JsonPropertyName("event_signing_key_version")] public string EventSigningKeyVersion { get; init; } = ""; [JsonPropertyName("event_recipient_key_version")] public string EventRecipientKeyVersion { get; init; } = ""; [JsonPropertyName("event_message_text")] public string EventMessageText { get; init; } = ""; + [JsonPropertyName("event_unencrypted_message_b64")] public string EventUnencryptedMessageB64 { get; init; } = ""; + [JsonPropertyName("event_unencrypted_message_text")] public string EventUnencryptedMessageText { get; init; } = ""; + [JsonPropertyName("event_unsigned_read_receipt_b64")] public string EventUnsignedReadReceiptB64 { get; init; } = ""; + [JsonPropertyName("event_read_seen_until_id")] public string EventReadSeenUntilId { get; init; } = ""; } private static SdkVectors LoadVectors() @@ -994,6 +998,29 @@ public void Vectors_FailureEvent_DecodesTypeAndRateLimitTier() Assert.Equal(v.EventSenderId, e.GetProperty("sender_id").GetString()); } + // Unencrypted messages and unsigned read receipts decode under the + // default reject-unverified policy, flagged as unverified. + [Fact] + public void Vectors_UnencryptedMessageAndUnsignedReceipt_DecodeByDefault() + { + var v = LoadVectors(); + using var chat = new Chat(); // default reject-unverified policy + + var msg = chat.DecryptEvent( + v.EventUnencryptedMessageB64, (ConversationKeyBundle?)null, Array.Empty()); + Assert.Equal("Message", msg.GetProperty("type").GetString()); + Assert.Equal(v.EventUnencryptedMessageText, + msg.GetProperty("content").GetProperty("text").GetString()); + Assert.Equal(JsonValueKind.Null, msg.GetProperty("key_version").ValueKind); + Assert.False(msg.GetProperty("verified").GetBoolean()); + + var receipt = chat.DecryptEvent( + v.EventUnsignedReadReceiptB64, (ConversationKeyBundle?)null, Array.Empty()); + Assert.Equal("ReadReceipt", receipt.GetProperty("type").GetString()); + Assert.Equal(v.EventReadSeenUntilId, receipt.GetProperty("seen_until_id").GetString()); + Assert.False(receipt.GetProperty("verified").GetBoolean()); + } + // Session identity: SetIdentity supplies sender_id and signing_key_version; // an encrypt with only the conversation key explicit signs with the // session values, and without any identity the call fails loudly. diff --git a/crates/dotnet/dotnet/ChatXdk/Chat.cs b/crates/dotnet/dotnet/ChatXdk/Chat.cs index 85a303b..97a275c 100644 --- a/crates/dotnet/dotnet/ChatXdk/Chat.cs +++ b/crates/dotnet/dotnet/ChatXdk/Chat.cs @@ -197,9 +197,11 @@ public bool HasIdentityKey /// /// When is true — the default — - /// throws for any signed event whose signature cannot - /// be verified (invalid, missing, or no matching signing key) instead of returning - /// it with verified: false. + /// throws for an encrypted message or key change whose + /// signature cannot be verified (invalid, missing, or no matching signing key), and + /// for any other signed event whose signature is present but invalid. Unencrypted + /// messages (key_version: null) and other events without a checkable + /// signature are returned with verified: false. /// public void SetRejectUnverified(bool reject) { @@ -468,9 +470,9 @@ public ConversationKeyBundle ExtractConversationKeys(IEnumerable events) /// /// Signing keys for the sender. The SDK picks the matching version automatically. /// (or an empty list) falls back to the keys stored via - /// ; if none are stored either, every signed event - /// throws under the default reject-unverified policy (only after - /// (false) are such events returned with + /// ; if none are stored either, every encrypted message + /// and key change throws under the default reject-unverified policy (only after + /// (false) are those returned with /// verified: false). /// /// diff --git a/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs b/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs index 5ebac55..b8c7288 100644 --- a/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs +++ b/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs @@ -61,8 +61,10 @@ internal static unsafe partial class NativeMethods /// /// When `reject` is non-zero — the default — `chat_xdk_decrypt_event` returns - /// an error for any signed event whose signature cannot be verified (invalid, - /// missing, or no matching signing key) instead of returning it with + /// an error for an encrypted message or key change whose signature cannot be + /// verified (invalid, missing, or no matching signing key), and for any other + /// signed event whose signature is present but invalid. Unencrypted messages + /// and other events without a checkable signature are returned with /// `verified: false`. /// [DllImport(__DllName, EntryPoint = "chat_xdk_set_reject_unverified", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] @@ -274,9 +276,10 @@ internal static unsafe partial class NativeMethods /// "identity_public_key","identity_public_key_signature"},...]` for the sender. /// The SDK selects the matching version automatically. An empty array falls /// back to the keys stored via `chat_xdk_set_signing_keys`; if none are - /// stored either, signed events fail decryption under the default - /// reject-unverified policy (disable it via `chat_xdk_set_reject_unverified` - /// to have them returned with `verified: false` instead). + /// stored either, encrypted messages and key changes fail decryption under + /// the default reject-unverified policy (disable it via + /// `chat_xdk_set_reject_unverified` to have them returned with + /// `verified: false` instead). /// /// Returns a JSON representation of the decrypted `Event`. /// diff --git a/crates/dotnet/src/lib.rs b/crates/dotnet/src/lib.rs index 2bb6f8c..dded5a2 100644 --- a/crates/dotnet/src/lib.rs +++ b/crates/dotnet/src/lib.rs @@ -157,8 +157,9 @@ fn catch_ffi_or(fallback: T, body: impl FnOnce() -> T) -> T { /// Parse the signing-keys JSON passed across the FFI boundary. /// /// `null`, an empty string, or `[]` yields an empty list — under the default -/// reject-unverified policy signed events then fail decryption; they are -/// returned with `verified: false` only after reject-unverified is disabled. +/// reject-unverified policy encrypted messages and key changes then fail +/// decryption; they are returned with `verified: false` only after +/// reject-unverified is disabled. /// A non-empty but malformed array is a caller error and is surfaced rather /// than silently dropped (which would weaken verification). fn parse_signing_keys(json: &str) -> Result, String> { @@ -482,8 +483,10 @@ pub extern "C" fn chat_xdk_has_identity_key(handle: *const ChatHandle) -> i32 { } /// When `reject` is non-zero — the default — `chat_xdk_decrypt_event` returns -/// an error for any signed event whose signature cannot be verified (invalid, -/// missing, or no matching signing key) instead of returning it with +/// an error for an encrypted message or key change whose signature cannot be +/// verified (invalid, missing, or no matching signing key), and for any other +/// signed event whose signature is present but invalid. Unencrypted messages +/// and other events without a checkable signature are returned with /// `verified: false`. #[no_mangle] pub extern "C" fn chat_xdk_set_reject_unverified(handle: *mut ChatHandle, reject: i32) { @@ -1174,9 +1177,10 @@ pub extern "C" fn chat_xdk_prepare_message_delete( /// "identity_public_key","identity_public_key_signature"},...]` for the sender. /// The SDK selects the matching version automatically. An empty array falls /// back to the keys stored via `chat_xdk_set_signing_keys`; if none are -/// stored either, signed events fail decryption under the default -/// reject-unverified policy (disable it via `chat_xdk_set_reject_unverified` -/// to have them returned with `verified: false` instead). +/// stored either, encrypted messages and key changes fail decryption under +/// the default reject-unverified policy (disable it via +/// `chat_xdk_set_reject_unverified` to have them returned with +/// `verified: false` instead). /// /// Returns a JSON representation of the decrypted `Event`. #[no_mangle] diff --git a/crates/go/src/lib.rs b/crates/go/src/lib.rs index 941bb98..6c8c3a0 100644 --- a/crates/go/src/lib.rs +++ b/crates/go/src/lib.rs @@ -149,9 +149,9 @@ fn catch_ffi_or(fallback: T, body: impl FnOnce() -> T) -> T { /// /// `null`, an empty string, or `[]` yields an empty list — the decrypt calls /// then fall back to the keys stored via `chat_xdk_set_signing_keys`, and -/// with nothing stored either, signed events fail decryption under the -/// default reject-unverified policy (they are returned with -/// `verified: false` only after reject-unverified is disabled). +/// with nothing stored either, encrypted messages and key changes fail +/// decryption under the default reject-unverified policy (they are returned +/// with `verified: false` only after reject-unverified is disabled). /// A non-empty but malformed array is a caller error and is surfaced rather /// than silently dropped (which would weaken verification). fn parse_signing_keys(json: &str) -> Result, String> { @@ -483,8 +483,11 @@ pub extern "C" fn chat_xdk_has_identity_key(handle: *const ChatHandle) -> i32 { /// Enable or disable rejection of unverified events. /// /// When enabled — the default — `chat_xdk_decrypt_event` returns an error for -/// any signed event whose signature cannot be verified (invalid, missing, or -/// no matching signing key) instead of returning it with `verified: false`. +/// an encrypted message or key change whose signature cannot be verified +/// (invalid, missing, or no matching signing key), and for any other signed +/// event whose signature is present but invalid. Unencrypted messages and +/// other events without a checkable signature are returned with +/// `verified: false`. #[no_mangle] pub extern "C" fn chat_xdk_set_reject_unverified(handle: *mut ChatHandle, reject: i32) { catch_ffi_or((), || { @@ -1218,9 +1221,10 @@ pub extern "C" fn chat_xdk_prepare_message_delete( /// `signing_keys_json`: JSON array of `{"user_id","public_key_version","public_key", /// "identity_public_key","identity_public_key_signature"}`. An empty array /// falls back to the keys stored via `chat_xdk_set_signing_keys`; with -/// nothing stored either, signed events fail decryption under the default -/// reject-unverified policy (disable it via `chat_xdk_set_reject_unverified` -/// to have them returned with `verified: false` instead). +/// nothing stored either, encrypted messages and key changes fail decryption +/// under the default reject-unverified policy (disable it via +/// `chat_xdk_set_reject_unverified` to have them returned with +/// `verified: false` instead). /// /// Returns JSON representation of the decrypted Event. #[no_mangle] diff --git a/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Chat.java b/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Chat.java index 3f9c7d8..6ffa0b5 100644 --- a/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Chat.java +++ b/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Chat.java @@ -136,9 +136,11 @@ public boolean hasIdentityKey() { /** * When {@code reject} is true — the default — {@link #decryptEvent} throws - * {@link ChatXdkException} for any signed event whose signature cannot be verified - * (invalid, missing, or no matching signing key) instead of returning it with - * {@code verified: false}. + * {@link ChatXdkException} for an encrypted message or key change whose signature + * cannot be verified (invalid, missing, or no matching signing key), and for any + * other signed event whose signature is present but invalid. Unencrypted messages + * ({@code key_version: null}) and other events without a checkable signature are + * returned with {@code verified: false}. * * @param reject whether to reject events with unverifiable signatures. */ @@ -416,9 +418,9 @@ public ConversationKeyBundle extractConversationKeys(List events) throws * the opt-in key cache ({@link #setCacheKeys}). * @param signingKeys Signing keys for the sender. The SDK picks the matching version * automatically. {@code null} (or an empty list) falls back to the keys stored via - * {@link #setSigningKeys}; if none are stored either, every signed event throws - * under the default reject-unverified policy (only after - * {@link #setRejectUnverified}(false) are such events returned with + * {@link #setSigningKeys}; if none are stored either, every encrypted message and + * key change throws under the default reject-unverified policy (only after + * {@link #setRejectUnverified}(false) are those returned with * {@code verified: false}). * @return A {@link JsonNode} with a {@code "type"} field indicating the event kind * ({@code "Message"}, {@code "KeyChange"}, {@code "GroupChange"}, etc.). diff --git a/crates/jvm/java/chatxdk/src/test/java/com/x/chatxdk/ChatTest.java b/crates/jvm/java/chatxdk/src/test/java/com/x/chatxdk/ChatTest.java index 16be8d6..dc02d7d 100644 --- a/crates/jvm/java/chatxdk/src/test/java/com/x/chatxdk/ChatTest.java +++ b/crates/jvm/java/chatxdk/src/test/java/com/x/chatxdk/ChatTest.java @@ -795,6 +795,28 @@ void vectorsFailureEventDecodesTypeAndRateLimitTier() throws Exception { } } + // Unencrypted messages and unsigned read receipts decode under the default + // reject-unverified policy, flagged as unverified. + @Test + void vectorsUnencryptedMessageAndUnsignedReceiptDecodeByDefault() throws Exception { + JsonNode v = loadVectors(); + try (Chat chat = new Chat()) { // default reject-unverified policy + JsonNode msg = chat.decryptEvent( + v.get("event_unencrypted_message_b64").asText(), (ConversationKeyBundle) null, List.of()); + assertEquals("Message", msg.path("type").asText()); + assertEquals(v.get("event_unencrypted_message_text").asText(), + msg.path("content").path("text").asText()); + assertTrue(msg.path("key_version").isNull()); + assertFalse(msg.path("verified").asBoolean()); + + JsonNode receipt = chat.decryptEvent( + v.get("event_unsigned_read_receipt_b64").asText(), (ConversationKeyBundle) null, List.of()); + assertEquals("ReadReceipt", receipt.path("type").asText()); + assertEquals(v.get("event_read_seen_until_id").asText(), receipt.path("seen_until_id").asText()); + assertFalse(receipt.path("verified").asBoolean()); + } + } + // Session identity: setIdentity supplies senderId and signingKeyVersion; // an encrypt with only the conversation key explicit signs with the // session values, and without any identity the call fails loudly. diff --git a/crates/pyo3/python/tests/test_sdk_vectors.py b/crates/pyo3/python/tests/test_sdk_vectors.py index 0a1796e..bb53ff8 100644 --- a/crates/pyo3/python/tests/test_sdk_vectors.py +++ b/crates/pyo3/python/tests/test_sdk_vectors.py @@ -253,6 +253,28 @@ def test_failure_event_decodes_type_and_rate_limit_tier(self): self.assertEqual(event["rate_limit_tier"], "Premium") self.assertEqual(event["sender_id"], v["event_sender_id"]) + def test_unencrypted_message_and_unsigned_receipt_decode_by_default(self): + from chat_xdk import Chat + + v = _load_vectors() + chat = Chat() # default reject-unverified policy + + message = chat.decrypt_event(v["event_unencrypted_message_b64"], {}, []) + self.assertEqual(message["type"], "Message") + self.assertEqual(message["content"]["text"], v["event_unencrypted_message_text"]) + self.assertIsNone(message["key_version"]) + self.assertFalse(message["verified"]) + + receipt = chat.decrypt_event(v["event_unsigned_read_receipt_b64"], {}, []) + self.assertEqual(receipt["type"], "ReadReceipt") + self.assertEqual(receipt["seen_until_id"], v["event_read_seen_until_id"]) + self.assertFalse(receipt["verified"]) + + # An encrypted message without a usable signing key is still rejected. + result = chat.decrypt_events([v["event_message_b64"]], []) + self.assertEqual(result["messages"], []) + self.assertEqual(list(result["errors"].keys()), ["0"]) + def test_encrypt_reply_derives_preview_from_raw_event(self): from chat_xdk import Chat diff --git a/crates/pyo3/src/lib.rs b/crates/pyo3/src/lib.rs index 5116080..e1e4497 100644 --- a/crates/pyo3/src/lib.rs +++ b/crates/pyo3/src/lib.rs @@ -419,9 +419,12 @@ impl Chat { self.inner.has_identity_key() } - /// When enabled — the default — `decrypt_event` raises for any signed - /// event whose signature cannot be verified (invalid, missing, or no - /// matching signing key) instead of returning it with `verified: false`. + /// When enabled — the default — `decrypt_event` raises for an encrypted + /// message or key change whose signature cannot be verified (invalid, + /// missing, or no matching signing key), and for any other signed event + /// whose signature is present but invalid. Unencrypted messages + /// (``key_version: None``) and other events without a checkable signature + /// are returned with ``verified: False``. fn set_reject_unverified(&mut self, reject: bool) { self.inner.set_reject_unverified(reject); } @@ -708,8 +711,8 @@ impl Chat { /// straight from the X API public keys response. The SDK picks the matching version /// automatically. When omitted (or empty), the keys stored via ``set_signing_keys`` /// are used. Under the default reject-unverified policy no usable signing key makes - /// every signed event raise; only after ``set_reject_unverified(False)`` are such - /// events returned with ``verified: False``. + /// every encrypted message and key change raise; only after + /// ``set_reject_unverified(False)`` are those returned with ``verified: False``. /// /// Returns: /// A dictionary representing the decrypted event. diff --git a/crates/wasm/js/index.d.ts b/crates/wasm/js/index.d.ts index a6fb289..1a87cc5 100644 --- a/crates/wasm/js/index.d.ts +++ b/crates/wasm/js/index.d.ts @@ -752,9 +752,12 @@ export declare class StreamDecryptor { */ interface ChatCrypto { /** - * When enabled — the default — `decryptEvent` throws for any signed event - * whose signature cannot be verified (invalid, missing, or no matching - * signing key) instead of returning it with `verified: false`. + * When enabled — the default — `decryptEvent` throws for an encrypted + * message or key change whose signature cannot be verified (invalid, + * missing, or no matching signing key), and for any other signed event + * whose signature is present but invalid. Unencrypted messages (no + * `keyVersion`) and other events without a checkable signature are + * returned with `verified: false`. */ setRejectUnverified(reject: boolean): void; @@ -819,9 +822,9 @@ interface ChatCrypto { * @param signingKeys - All known signing keys for all participants (with * userId). Omitting this falls back to the keys stored via * `setSigningKeys`. Under the default reject-unverified policy, no signing - * keys from either source makes every signed event land in `errors`; only - * after `setRejectUnverified(false)` are such events returned with - * `verified: false`. + * keys from either source makes every encrypted message and key change land + * in `errors`; only after `setRejectUnverified(false)` are those returned + * with `verified: false`. */ decryptEvents(events: string[], signingKeys?: SigningKeyEntry[]): DecryptEventsResult; @@ -831,9 +834,9 @@ interface ChatCrypto { * Omitting `conversationKeys` falls back to the opt-in key cache * (`setCacheKeys(true)`); omitting `signingKeys` falls back to the keys * stored via `setSigningKeys`. Under the default reject-unverified policy, - * no signing keys from either source makes every signed event throw; only - * after `setRejectUnverified(false)` are such events returned with - * `verified: false`. + * no signing keys from either source makes every encrypted message and key + * change throw; only after `setRejectUnverified(false)` are those returned + * with `verified: false`. */ decryptEvent(eventB64: string, conversationKeys?: ConversationKeyMap | null, signingKeys?: SigningKeyEntry[]): Event; diff --git a/crates/wasm/js/tests/sdk_vectors.test.mjs b/crates/wasm/js/tests/sdk_vectors.test.mjs index 5dee434..5aa0640 100644 --- a/crates/wasm/js/tests/sdk_vectors.test.mjs +++ b/crates/wasm/js/tests/sdk_vectors.test.mjs @@ -151,6 +151,22 @@ async function main() { assert.equal(failure.rateLimitTier, "premium"); assert.equal(failure.senderId, v.event_sender_id); + // Unencrypted messages and unsigned read receipts decode under the default + // reject-unverified policy, flagged with verified: false. + { + const strict = new Chat(); + const plain = strict.decryptEvent(v.event_unencrypted_message_b64, {}, []); + assert.equal(plain.type, "message"); + assert.equal(plain.content.text, v.event_unencrypted_message_text); + assert.equal(plain.keyVersion ?? null, null); + assert.equal(plain.verified, false); + + const receipt = strict.decryptEvent(v.event_unsigned_read_receipt_b64, {}, []); + assert.equal(receipt.type, "readReceipt"); + assert.equal(receipt.seenUntilId, v.event_read_seen_until_id); + assert.equal(receipt.verified, false); + } + // Session identity + opt-in key cache: importKeys(bytes, version) records // the registered key version, decryptEvents populates the cache from the // verified KeyChange, and encryptMessage resolves the omitted identity and diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 9fb99b2..95ffebc 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -154,9 +154,12 @@ impl Chat { } } - /// When enabled — the default — `decryptEvent` throws for any signed - /// event whose signature cannot be verified (invalid, missing, or no - /// matching signing key) instead of returning it with `verified: false`. + /// When enabled — the default — `decryptEvent` throws for an encrypted + /// message or key change whose signature cannot be verified (invalid, + /// missing, or no matching signing key), and for any other signed event + /// whose signature is present but invalid. Unencrypted messages (no + /// `keyVersion`) and other events without a checkable signature are + /// returned with `verified: false`. #[wasm_bindgen(js_name = setRejectUnverified)] pub fn set_reject_unverified(&mut self, reject: bool) { self.inner.set_reject_unverified(reject); @@ -418,9 +421,9 @@ impl Chat { /// filtered to the event's sender and the SDK picks the matching version /// automatically. Omitting it falls back to the keys stored via /// `setSigningKeys`. Under the default reject-unverified policy no - /// signing keys from either source makes every signed event throw; only - /// after `setRejectUnverified(false)` are such events returned with - /// `verified: false`. + /// signing keys from either source makes every encrypted message and key + /// change throw; only after `setRejectUnverified(false)` are those + /// returned with `verified: false`. #[wasm_bindgen(js_name = decryptEvent)] pub fn decrypt_event( &self, diff --git a/docs/API.md b/docs/API.md index 4041f80..cac6ad1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -410,7 +410,7 @@ Decrypts multiple events in one call. Handles everything internally: | Param | JS | Python | Rust | Go | JVM | .NET | Description | |---|---|---|---|---|---|---|---| | events | `string[]` | `list[str]` | `&[&str]` | `[]string` | `List` | `IEnumerable` | All base64-encoded raw events. **Must include KeyChange events** — without them, messages depending on those keys will land in `errors`. The events endpoint returns KeyChange events in **`meta.conversation_key_events`**, separate from the `data` array; concatenate both into this argument. | -| signingKeys | `SigningKeyEntry[]` | `list[dict]` | `&[SigningKeyEntry]` | `[]SigningKeyEntry` or `nil` | `List` or `null` | `IEnumerable?` | Signing keys for **all participants**. The SDK extracts each event's `senderId` internally and filters to the matching keys. Omitting the parameter (or passing `[]` / `nil` / `null`) falls back to the keys stored via `setSigningKeys`; if none are stored either, under the default reject-unverified policy every **signed** event fails decryption and lands in `errors`. Only after `setRejectUnverified(false)` are such events returned with `verified: false`. | +| signingKeys | `SigningKeyEntry[]` | `list[dict]` | `&[SigningKeyEntry]` | `[]SigningKeyEntry` or `nil` | `List` or `null` | `IEnumerable?` | Signing keys for **all participants**. The SDK extracts each event's `senderId` internally and filters to the matching keys. Omitting the parameter (or passing `[]` / `nil` / `null`) falls back to the keys stored via `setSigningKeys`; if none are stored either, under the default reject-unverified policy every **encrypted message and KeyChange** fails decryption and lands in `errors`. Only after `setRejectUnverified(false)` are those returned with `verified: false`. Other signed events are returned with `verified: false` either way (see [Verification policy](#verification-policy)). | **Returns: `DecryptEventsResult`** — never throws/raises. Errors are collected. @@ -563,7 +563,7 @@ Decrypts a single event using conversation keys you already have — cached from |---|---|---|---|---|---|---|---| | eventB64 | `string` | `str` | `&str` | `string` | `String` | `string` | One base64-encoded raw event. | | conversationKeys | `ConversationKeyMap` | `dict` | `&HashMap` | `map[string][]byte` | `Map` or `ConversationKeyBundle` | `Dictionary` or `ConversationKeyBundle` | Pre-extracted keys: **version → raw 32-byte key**. Every binding passes raw key bytes. Omitting it (or passing `{}` / `None` / `nil` / `null`) falls back to the opt-in key cache (`setCacheKeys`); non-message events (typing, read receipts) need no keys at all. | -| signingKeys | `SigningKeyEntry[]` | `list[dict]` | `&[SigningKeyEntry]` | `[]SigningKeyEntry` or `nil` | `List` or `null` | `IEnumerable?` | Signing keys for **the event's sender**. The SDK matches by `publicKeyVersion` against the version in the event's signature. Omitting the parameter (or passing `[]` / `nil` / `null`) falls back to the keys stored via `setSigningKeys`; if none are stored either, under the default reject-unverified policy every **signed** event fails with an error. Only after `setRejectUnverified(false)` are such events returned with `verified: false`. | +| signingKeys | `SigningKeyEntry[]` | `list[dict]` | `&[SigningKeyEntry]` | `[]SigningKeyEntry` or `nil` | `List` or `null` | `IEnumerable?` | Signing keys for **the event's sender**. The SDK matches by `publicKeyVersion` against the version in the event's signature. Omitting the parameter (or passing `[]` / `nil` / `null`) falls back to the keys stored via `setSigningKeys`; if none are stored either, under the default reject-unverified policy every **encrypted message and KeyChange** fails with an error. Only after `setRejectUnverified(false)` are those returned with `verified: false`. Other signed events are returned with `verified: false` either way (see [Verification policy](#verification-policy)). | **Returns: `Event`** directly — or **throws/raises** on failure (unlike `decryptEvents` which collects errors). @@ -574,7 +574,8 @@ decryptEvent( conversationKeys?: ConversationKeyMap | null, // omitted ⇒ opt-in key cache // (setCacheKeys) signingKeys?: SigningKeyEntry[] // omitted ⇒ keys from setSigningKeys; if none - // stored, signed events throw, unless + // stored, encrypted messages and key + // changes throw, unless // setRejectUnverified(false) was called ): Event ``` @@ -586,8 +587,9 @@ decrypt_event( conversation_keys: dict | None = None, # { version: bytes }; None ⇒ opt-in # key cache (set_cache_keys) signing_keys: list[dict] | None = None # None ⇒ keys from set_signing_keys; - # if none stored, signed events raise, - # unless set_reject_unverified(False) + # if none stored, encrypted messages and + # key changes raise, unless + # set_reject_unverified(False) ) -> dict ``` @@ -697,7 +699,21 @@ type ConversationKeyMap = { [version: string]: Uint8Array }; { "1": b"\x01\x02...", "2": b"\x03\x04..." } ``` -**Unencrypted events**: Events without a `conversationKeyVersion` carry no verifiable signature, so under the default reject-unverified policy they are **rejected** (`decryptEvent` throws; `decryptEvents` records them in `errors`). After `setRejectUnverified(false)` they are returned with `keyVersion: null` and `verified: false`. +#### Verification policy + +`setRejectUnverified(true)` (the default) enforces signatures on the events that carry encrypted state. Everything else is returned with an explicit flag rather than dropped, so check `verified` (and `keyVersion` on messages) before acting on an event. + +| Event | Signature verifies | Signature missing / no matching signing key | Signature present but invalid | +|---|---|---|---| +| Encrypted message (`keyVersion` set) | `verified: true` | **rejected** | **rejected** | +| KeyChange | `verified: true` | **rejected** | **rejected** | +| Unencrypted message (`keyVersion: null`) | — (carries no signature) | `verified: false` | — | +| ReadReceipt, MarkedUnread, MessageDeleted, ConversationDeleted, GroupChange, SettingsChange | `verified: true` | `verified: false` | **rejected** | +| Typing, Failure, MemberDeleted | unsigned by protocol, always returned | | | + +"Rejected" means `decryptEvent` throws and `decryptEvents` records the event in `errors`. With `setRejectUnverified(false)` nothing is rejected for signature reasons; events that do not verify are returned with `verified: false`. + +**Unencrypted events**: Events without a `conversationKeyVersion` carry no signature. They are returned with `keyVersion: null` and `verified: false` under either policy; their contents are not authenticated by the sender. ### Message Encryption @@ -1462,7 +1478,7 @@ absent-on-a-preview) as an untrusted preview — render the claim only from the validated original. The validation contract is specified in [CRYPTO.md — Reply preview validation](CRYPTO.md#reply-preview-validation). -**Unencrypted messages**: When `keyVersion` is `null`, the message was delivered without encryption. Because there is no signature to verify, the default reject-unverified policy **rejects** these messages (`decryptEvent` throws; `decryptEvents` records them in `errors`); they are returned only after `setRejectUnverified(false)`, with `verified: false`. When returned, the `content` is parsed identically to encrypted messages. +**Unencrypted messages**: When `keyVersion` is `null`, the message was delivered without encryption. There is no signature to verify, so it is always returned with `verified: false` (see [Verification policy](#verification-policy)); its contents are not authenticated by the sender. The `content` is parsed identically to encrypted messages. ### MessageContent diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 422a660..8b12ba7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -547,9 +547,9 @@ Developer SDK │ Step 4: Unencrypted messages (conversation_key_version is None) │ chat.decrypt_event(event_b64, &keys, &signing_keys) │──────────────────────────────▶│ 1. Detect: conversation_key_version is None - │ │ 2. No signature → rejected under reject_unverified - │ Event::Message │ (default); returned verified=false only when - │ (verified=false) │ reject_unverified is disabled + │ │ 2. No signature to check → returned with + │ Event::Message │ key_version=None, verified=false under + │ (verified=false) │ either reject_unverified setting │◀──────────────────────────────│ ▼ ▼ ``` diff --git a/go/chatxdk/chat.go b/go/chatxdk/chat.go index eddd629..5f0cb77 100644 --- a/go/chatxdk/chat.go +++ b/go/chatxdk/chat.go @@ -100,9 +100,11 @@ func (c *Chat) HasIdentityKey() bool { } // SetRejectUnverified enables or disables rejection of unverified events. -// When enabled — the default — DecryptEvent returns an error for any signed -// event whose signature cannot be verified (invalid, missing, or no matching -// signing key) instead of returning it with Verified=false. +// When enabled — the default — DecryptEvent returns an error for an encrypted +// message or key change whose signature cannot be verified (invalid, missing, +// or no matching signing key), and for any other signed event whose signature +// is present but invalid. Unencrypted messages (nil KeyVersion) and other +// events without a checkable signature are returned with Verified=false. func (c *Chat) SetRejectUnverified(reject bool) { defer runtime.KeepAlive(c) ffiSetRejectUnverified(c.h, reject) @@ -252,8 +254,8 @@ func (c *Chat) ExtractConversationKeys(events []string) (*ConversationKeyBundle, // Errors map, keyed by event index. // // A nil (or empty) signingKeys falls back to the keys stored via -// SetSigningKeys; with nothing stored either, signed events fail decryption -// under the default reject-unverified policy. +// SetSigningKeys; with nothing stored either, encrypted messages and key +// changes fail decryption under the default reject-unverified policy. func (c *Chat) DecryptEvents(events []string, signingKeys []SigningKeyEntry) (*DecryptEventsResult, error) { defer runtime.KeepAlive(c) eventsJSON, err := json.Marshal(events) @@ -402,9 +404,9 @@ func (c *Chat) PrepareMessageDelete(params MessageDeleteParams) (*ActionSignatur // // signingKeys is a list of signing keys for the sender; the SDK picks the // matching version. A nil (or empty) list falls back to the keys stored via -// SetSigningKeys. With nothing stored either, every signed event fails under -// the default reject-unverified policy; only after SetRejectUnverified(false) -// are such events returned with Verified=false. +// SetSigningKeys. With nothing stored either, every encrypted message and key +// change fails under the default reject-unverified policy; only after +// SetRejectUnverified(false) are those returned with Verified=false. func (c *Chat) DecryptEvent(eventB64 string, conversationKeys map[string][]byte, signingKeys []SigningKeyEntry) (*Event, error) { defer runtime.KeepAlive(c) var convKeysJSON, signingKeysJSON string diff --git a/go/chatxdk/chatxdk_test.go b/go/chatxdk/chatxdk_test.go index a84ecbd..70c6386 100644 --- a/go/chatxdk/chatxdk_test.go +++ b/go/chatxdk/chatxdk_test.go @@ -38,6 +38,10 @@ type sdkVectors struct { EventRecipientKeyVersion string `json:"event_recipient_key_version"` EventMessageText string `json:"event_message_text"` EventReplyText string `json:"event_reply_text"` + EventUnencryptedMessageB64 string `json:"event_unencrypted_message_b64"` + EventUnencryptedMessageText string `json:"event_unencrypted_message_text"` + EventUnsignedReadReceiptB64 string `json:"event_unsigned_read_receipt_b64"` + EventReadSeenUntilID string `json:"event_read_seen_until_id"` } // eventSigningKeys builds the SigningKeyEntry list matching the fixture's @@ -1420,6 +1424,52 @@ func TestFailureEventFixtureVector(t *testing.T) { } } +// TestUnencryptedAndUnsignedFixtureVectors pins that unencrypted messages and +// unsigned read receipts decode under the default reject-unverified policy, +// flagged as unverified. +func TestUnencryptedAndUnsignedFixtureVectors(t *testing.T) { + v := loadVectors(t) + + chat := New() // default reject-unverified policy + defer chat.Close() + + event, err := chat.DecryptEvent(v.EventUnencryptedMessageB64, nil, nil) + if err != nil { + t.Fatalf("DecryptEvent(unencrypted message) failed: %v", err) + } + msg := event.AsMessage() + if msg == nil { + t.Fatalf("expected a Message event, got type %q", event.Type) + } + if msg.Text() != v.EventUnencryptedMessageText { + t.Errorf("unexpected text: %q", msg.Text()) + } + if msg.KeyVersion != nil { + t.Errorf("unencrypted message must have no key version, got %q", *msg.KeyVersion) + } + if msg.Verified { + t.Error("unencrypted message must not be verified") + } + + event, err = chat.DecryptEvent(v.EventUnsignedReadReceiptB64, nil, nil) + if err != nil { + t.Fatalf("DecryptEvent(unsigned read receipt) failed: %v", err) + } + if event.Type != "ReadReceipt" { + t.Fatalf("expected a ReadReceipt event, got type %q", event.Type) + } + var receipt ReadReceiptEvent + if err := json.Unmarshal(event.Raw(), &receipt); err != nil { + t.Fatalf("decode read receipt: %v", err) + } + if receipt.SeenUntilID == nil || *receipt.SeenUntilID != v.EventReadSeenUntilID { + t.Errorf("unexpected seen_until_id: %v", receipt.SeenUntilID) + } + if receipt.Verified { + t.Error("unsigned read receipt must not be verified") + } +} + // TestSigningKeyEntryJSONShape guards that SigningKeyEntry serializes the full // 5-field shape the native core requires. The signing-key payload must include // identity_public_key and identity_public_key_signature; the parser requires the diff --git a/go/chatxdk/include/chat_xdk.h b/go/chatxdk/include/chat_xdk.h index 45b2ed3..00dfd54 100644 --- a/go/chatxdk/include/chat_xdk.h +++ b/go/chatxdk/include/chat_xdk.h @@ -160,8 +160,11 @@ int32_t chat_xdk_has_identity_key(const struct ChatHandle *handle); * Enable or disable rejection of unverified events. * * When enabled — the default — `chat_xdk_decrypt_event` returns an error for - * any signed event whose signature cannot be verified (invalid, missing, or - * no matching signing key) instead of returning it with `verified: false`. + * an encrypted message or key change whose signature cannot be verified + * (invalid, missing, or no matching signing key), and for any other signed + * event whose signature is present but invalid. Unencrypted messages and + * other events without a checkable signature are returned with + * `verified: false`. */ void chat_xdk_set_reject_unverified(struct ChatHandle *handle, int32_t reject); @@ -327,9 +330,10 @@ struct FfiResult chat_xdk_prepare_message_delete(const struct ChatHandle *handle * `signing_keys_json`: JSON array of `{"user_id","public_key_version","public_key", * "identity_public_key","identity_public_key_signature"}`. An empty array * falls back to the keys stored via `chat_xdk_set_signing_keys`; with - * nothing stored either, signed events fail decryption under the default - * reject-unverified policy (disable it via `chat_xdk_set_reject_unverified` - * to have them returned with `verified: false` instead). + * nothing stored either, encrypted messages and key changes fail decryption + * under the default reject-unverified policy (disable it via + * `chat_xdk_set_reject_unverified` to have them returned with + * `verified: false` instead). * * Returns JSON representation of the decrypted Event. */ diff --git a/go/chatxdk/libs/darwin_amd64/libchat_xdk_go.a b/go/chatxdk/libs/darwin_amd64/libchat_xdk_go.a index e4f5b0d..b440783 100644 Binary files a/go/chatxdk/libs/darwin_amd64/libchat_xdk_go.a and b/go/chatxdk/libs/darwin_amd64/libchat_xdk_go.a differ diff --git a/go/chatxdk/libs/darwin_arm64/libchat_xdk_go.a b/go/chatxdk/libs/darwin_arm64/libchat_xdk_go.a index c6c8997..87ccd32 100644 Binary files a/go/chatxdk/libs/darwin_arm64/libchat_xdk_go.a and b/go/chatxdk/libs/darwin_arm64/libchat_xdk_go.a differ diff --git a/tests/fixtures/sdk_vectors.json b/tests/fixtures/sdk_vectors.json index 2fb0827..d44b0dd 100644 --- a/tests/fixtures/sdk_vectors.json +++ b/tests/fixtures/sdk_vectors.json @@ -7,6 +7,7 @@ "event_key_change_b64": "CwABAAAAATELAAIAAAAIa2MtbXNnLTELAAMAAAAEMTExMQsABAAAAAkxMTExOjIyMjILAAYAAAANMTcwMDAwMDAwMDAwMAwABwwAAwsAAQAAAAQxMDAxDwACDAAAAAELAAEAAAAEMTExMQsAAgAAAJhCR0hGdmgzaUw3VitCbGZJRnpFM0E2VDBoRjNCbVM1RHhJaWp5R3Z0NEVVbVdKK2FFdWQweGZXSHNXcFJyOVBwU3hZSS9wNHNScGlkS3pkbXFXUmpZOFlVK3pQMFlIeFZidldMNWQ4MERtbEN2TmlBUFV1Ryszbldsdmx2Z3k2VFN5UnlaakZHb3JxUzArbitGVTd6cEU0PQsAAwAAAAExAAAADAAJCwABAAAAVmVpSkx2V1V0cDY1bkxVdWVEaUJDc01FSjJmQWY1c0d4T25aQ050ZHVEaUQxWWZ1UlQzSzhTTjFBcC9yN05YamZ6cEVWaXJCaTc5Q1N2WnE5a3dKaW1nCwACAAAAATELAAMAAAABNwAA", "event_message_b64": "CwABAAAAATELAAIAAAAkMmMyYTFkMzItOTg4Yi00YjBlLTllNjMtYzNiYWFiMzAwM2ZkCwADAAAABDExMTELAAQAAAAJMTExMToyMjIyCwAGAAAADTE3MDAwMDAwMDAwMDAMAAcMAAELAGQAAABNyju49fSvP23md4h8MxYDM7/yP5czAJioJV+XI7e3bBM4gzkCp4+5iBBX4Jyee/0tULcB8Tkkl6qNbcRN9lWROcUxPyPGVVRp7WSqgVoLAGUAAAAEMTAwMQIAZgEAAAwACQsAAQAAAFZ4Rkk3NnA2VzZLY09jN0JGL0NLSjg4Sk5ZYnRvSitaS1VqdFFsTTNzYTRFdUdJZ1F2eHlLSG1tUkxqa0tlRUdMZkNEbU1EZDZTdlFMSTdiZVg0SFhZQQsAAgAAAAExCwADAAAAATcAAA==", "event_message_text": "fixture event message", + "event_read_seen_until_id": "42", "event_recipient_key_version": "1", "event_reply_forged_b64": "CwABAAAAATELAAIAAAAkZGU3YTJiNzgtMTI4Ny00ZGUxLTliYzktNDQ3Mjc4YmYxNDUyCwADAAAABDExMTELAAQAAAAJMTExMToyMjIyCwAGAAAADTE3MDAwMDAwMDAwMDAMAAcMAAELAGQAAAHr2HANJYJJjNJD9rWtkjW68feM+I3vu++fxA1gEMBK2UjdNkd+cTKJ7AmPxxGOPq44F0ssa6ckES6aOLxF4WdMk1oY/hq27JPgyn88b9GVaevklTomM5Wb2WN8vS0CywxWXkY/iy9poJ+GWTGK34KfPen4osu57OO1lde151aOnNcDMj8WUVvR8aQujhYffmRiKzemwv94jdqH8v2oBOLgPEa1Kpt4qCASmDWZKc6nG8raBQxt8vRgl5mabT6tjEqWwjxCb+Jw82MOAR7cA6hM5Xf432cR1oU7DtYj89sOxbGaRVbHhzS1RgS3JrcKHCAvcn/0DHE06WIdL5ZMOPd3f3NMdzix7cZBidVf3Y98ceTzRAkPv+Py5QxbP7ljD0UhShOWfO6sWg69pwFYyl2iXwN87Ymzt+3beiECXFtZzArS3QiOp6ZzFXI4nGfkRP/xvnVQqBvjmbJMgyoPq8d+NMueC50+J1MKaaUZdbM7kWl9ey618OCodX8rdvzREDzWaNbT5r2ZzT+QUC7/UWhJrWR3UJ0cFF7n/Q2ZhF6QjApc1b4zNdt2QkAjXjj0VjTaZm33zcD4nItZHGu/GFesGbPv4QjWGNGMyHA6buZs4uLYePKoIL+junKoRK6rVycn8d6IrxOmzAbQQDYLAGUAAAAEMTAwMQIAZgEAAAwACQsAAQAAAFYrWHRQUVlIOUttZWNvWnN4Vk9ORlplcVRFb2Y3aEwwUDQ0TkxhYUlHN29ORlBKUmN6V2FkT2FWSnZyaFJyYWRZeE5NS0tCUjBVc0sxb0xOWVpJT2M1ZwsAAgAAAAExCwADAAAAATcAAA==", "event_reply_forged_preview_text": "forged preview text", @@ -14,6 +15,9 @@ "event_reply_valid_b64": "CwABAAAAATELAAIAAAAkYzYzYjRmNTgtODU2Yi00YjQ5LTlmNGQtYTVjM2Y3MjM3ZjVmCwADAAAABDExMTELAAQAAAAJMTExMToyMjIyCwAGAAAADTE3MDAwMDAwMDAwMDAMAAcMAAELAGQAAAHtxf5QzAnBtH+rWXANpWMB3o5rf8xexfQC0/3pc/x/AaiqUpquGT+Tl1nDewt2x8BnIHra9V3//pMw1DNZp6X+d0bpJWWwACioojUy7xBmWsHquPCtOyUY40qKBNVtRPdL4LIOQPB/3TFNwqIE1F0WCs6M95T+U60d4/dp0X+OS1drQYQvzeQvOZ18YESWM0NE6L/yx+ugxttHcdFatZkPqAyilu2HYuv8OL2LvV4qWVG6DDg2Ixq/szkbl4Jfu61k613E3pj88avVyyDnCbavOT9T9Y46pznk3heuUJX9nOi1k2Kn5cx2l4SGTMglgILB70sc3qPATyHzflnM9ogqf9y70/N8EUT4FabppDeF8ghpqZs62weEAAjVq70+pFsj8imyYe91jIQiDZtZ8baPMa/O3v7U6soe5S7jbMHUzeU1WqR+Ad2QUT7X9DhSV5FrOaEEq21pDtCM7Ffh0I7VbYZRnmEumHBiLSgvWjqLKomxRnOXanSSSy0YgZbB2kPyrjC7aAnkbgiAZF5UzjO2T2iT+H2PXb821+Nxwf6kpbnZd6eCUSAPt3/s45AGGNol/NaXi446B31Ld6J8BTm5goeMMzPSzTUmEHVWmxF2R1SCYk5ulTqk5B0yJpxoLfKUiHywGfPb6KxNP9LO9wsAZQAAAAQxMDAxAgBmAQAADAAJCwABAAAAVkhGNGlkNDVrWXI1T1R6MUU3b3lxd1JCdnhsUGIyM2pGd3FDbElYS0ZZS0xjdHRhNUlFd0xvdlNOR2ZLdzdVTXJTRDRjWkFOZmJoRzdyQ3lJWk9CbklnCwACAAAAATELAAMAAAABNwAA", "event_sender_id": "1111", "event_signing_key_version": "1", + "event_unencrypted_message_b64": "CwABAAAAATELAAIAAAALcGxhaW4tbXNnLTELAAMAAAAEMTExMQsABAAAAAkxMTExOjIyMjILAAYAAAANMTcwMDAwMDAwMDAwMAwABwwAAQsAZAAAACsMAAEMAAELAAEAAAAbZml4dHVyZSB1bmVuY3J5cHRlZCBtZXNzYWdlAAAAAAAA", + "event_unencrypted_message_text": "fixture unencrypted message", + "event_unsigned_read_receipt_b64": "CwABAAAAATELAAIAAAAKcmVhZC1tc2ctMQsAAwAAAAQxMTExCwAEAAAACTExMTE6MjIyMgsABgAAAA0xNzAwMDAwMDAwMDAwDAAHDAAMCwABAAAAAjQyAAAA", "identity_private_b64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", "identity_public_b64": "BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+eFivOM1drMV7Oy7ZAaDe/UfU=", "identity_public_key_signature_b64": "lwqF3bFJN47NLoYzSyTTCaA+eGe3g2rbDzWc120eJYV3ClHqD1NI79WKL60ciN0fBjhmuAnIq/Beq/GhPUu52A==",