diff --git a/crates/core/src/chat.rs b/crates/core/src/chat.rs index a08a6f1..7ea8be9 100644 --- a/crates/core/src/chat.rs +++ b/crates/core/src/chat.rs @@ -64,8 +64,8 @@ use crate::error::{JuiceboxError, KeyError, SdkError}; use crate::keys::juicebox::{JuiceboxApi, JuiceboxClient, JuiceboxConfig, RegisterResult}; #[cfg(feature = "juicebox")] use crate::params::{ - ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, EncryptReplyParams, - GroupCreateParams, GroupMembersChangeParams, + ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, EncryptReactionParams, + EncryptReplyParams, GroupCreateParams, GroupMembersChangeParams, MessageDeleteParams, }; #[cfg(feature = "juicebox")] use crate::types::*; @@ -336,6 +336,17 @@ impl Chat { ) -> Result { self.inner.encrypt_remove_reaction(params) } + /// Encrypt a message edit. + pub fn encrypt_edit(&self, params: &EncryptEditParams) -> Result { + self.inner.encrypt_edit(params) + } + /// Build the signed action for deleting messages from a conversation. + pub fn prepare_message_delete( + &self, + params: &MessageDeleteParams, + ) -> Result { + self.inner.prepare_message_delete(params) + } /// Decrypt an encrypted conversation key (ECIES). pub fn decrypt_conversation_key( &self, diff --git a/crates/core/src/core.rs b/crates/core/src/core.rs index 4e443fc..b8c5c87 100644 --- a/crates/core/src/core.rs +++ b/crates/core/src/core.rs @@ -12,8 +12,8 @@ use crate::error::{CryptoError, SdkError}; use crate::keys::conversation_keys; use crate::keys::keypair_manager::KeypairManager; use crate::params::{ - ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, EncryptReplyParams, - GroupCreateParams, GroupMembersChangeParams, + ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, EncryptReactionParams, + EncryptReplyParams, GroupCreateParams, GroupMembersChangeParams, MessageDeleteParams, }; use crate::protocol::serialization::{base64_decode, base64_encode}; use crate::thrift::event::{MessageEvent, MessageEventDetail}; @@ -1885,21 +1885,143 @@ impl ChatCore { )) } + /// Encrypt a message edit. + /// + /// The edit replaces the target message's text (and entities) for every + /// recipient; it is sent through the same message-create channel as a + /// regular message and carries its own fresh message id. + /// + /// When `target_event` is set, the conversation id and target sequence id + /// are derived from that raw event; explicit fields override them. + pub fn encrypt_edit(&self, params: &EncryptEditParams) -> Result { + let (conversation_id, target_sequence_id) = Self::resolve_event_target( + params.target_event.as_deref(), + params.conversation_id.as_deref(), + params.target_message_sequence_id.as_deref(), + )?; + let sender_id = self.resolve_sender_id(params.sender_id.as_deref())?; + let signing_key_version = + self.resolve_signing_key_version(params.signing_key_version.as_deref())?; + let (ckey, conversation_key_version) = self.resolve_conversation_key( + params.conversation_key.as_deref(), + params.conversation_key_version.as_deref(), + &conversation_id, + &sender_id, + )?; + let signing_kp = self.get_signing_keypair_arc()?; + let content_bytes = crate::pipeline::build_message_edit_content( + &target_sequence_id, + ¶ms.updated_text, + params.entities.as_deref(), + )?; + let message_id = Self::generate_message_id(); + crate::pipeline::encrypt_and_sign(crate::pipeline::EncryptAndSignParams::new( + &ckey, + &signing_kp.private, + &message_id, + &sender_id, + &conversation_id, + &content_bytes, + &conversation_key_version, + &signing_key_version, + )) + } + + /// Build the signed action for deleting messages from a conversation. + /// + /// A delete is a plaintext `MessageDeleteEvent`, not an encrypted + /// message: the result carries the encoded event detail and its + /// signature, ready to submit alongside the delete request. The SDK + /// generates the action's message id; read it back from the result. + pub fn prepare_message_delete( + &self, + params: &MessageDeleteParams, + ) -> Result { + if params.sequence_ids.is_empty() { + return Err(SdkError::InvalidState( + "sequence_ids is empty: pass at least one message to delete".into(), + )); + } + if params.sequence_ids.iter().any(String::is_empty) { + return Err(SdkError::InvalidState( + "sequence_ids contains an empty id: every entry must name a message".into(), + )); + } + if params.conversation_id.is_empty() { + return Err(SdkError::InvalidState( + "conversation_id is empty: pass the conversation the messages belong to".into(), + )); + } + let sender_id = self.resolve_sender_id(params.sender_id.as_deref())?; + let signing_key_version = + self.resolve_signing_key_version(params.signing_key_version.as_deref())?; + let conversation_id = + crate::pipeline::canonical_conversation_id(¶ms.conversation_id, &sender_id); + let signing_key = self.get_signing_private_key()?; + + let delete_action = if params.delete_for_all { + crate::thrift::event::DeleteMessageAction::DELETE_FOR_ALL + } else { + crate::thrift::event::DeleteMessageAction::DELETE_FOR_SELF + }; + let message_id = Self::generate_message_id(); + let mut signature = crate::signatures::build_message_delete_signature( + &signing_key, + &signing_key_version, + &message_id, + &sender_id, + &conversation_id, + ¶ms.sequence_ids, + delete_action.0, + )?; + signature.encoded_message_event_detail = + Self::encode_message_delete_detail(¶ms.sequence_ids, delete_action)?; + Ok(signature) + } + + /// Serialize the `MessageDeleteEvent` the API validates and relays. + /// + /// Encoded as a base64 `MessageEventDetail` carrying the sequence ids and + /// the delete action. + fn encode_message_delete_detail( + sequence_ids: &[String], + delete_action: crate::thrift::event::DeleteMessageAction, + ) -> Result { + let detail = crate::thrift::event::MessageEventDetail::MessageDeleteEvent( + crate::thrift::event::MessageDeleteEvent { + sequence_ids: Some(sequence_ids.to_vec()), + delete_message_action: Some(delete_action), + }, + ); + Ok(base64_encode(&crate::pipeline::serialize_thrift(&detail)?)) + } + /// Resolve a reaction's conversation id and target sequence id from the /// explicit fields or, when unset, from the parsed `target_event`. fn resolve_reaction_target( params: &EncryptReactionParams, ) -> Result<(String, String), SdkError> { - let explicit_conv = params.conversation_id.as_deref().filter(|v| !v.is_empty()); - let explicit_seq = params - .target_message_sequence_id - .as_deref() - .filter(|v| !v.is_empty()); + Self::resolve_event_target( + params.target_event.as_deref(), + params.conversation_id.as_deref(), + params.target_message_sequence_id.as_deref(), + ) + } + + /// Resolve a target message's conversation id and sequence id from the + /// explicit fields or, when unset, from the parsed `target_event`. + fn resolve_event_target( + target_event: Option<&str>, + conversation_id: Option<&str>, + target_message_sequence_id: Option<&str>, + ) -> Result<(String, String), SdkError> { + let explicit_conv = conversation_id.filter(|v| !v.is_empty()); + let explicit_seq = target_message_sequence_id.filter(|v| !v.is_empty()); if let (Some(conv), Some(seq)) = (explicit_conv, explicit_seq) { return Ok((conv.to_string(), seq.to_string())); } - let parsed = match params.target_event.as_deref().filter(|v| !v.is_empty()) { + let parsed = match target_event.filter(|v| !v.is_empty()) { Some(event_b64) => Some(Self::parse_event_b64(event_b64)?), None => None, }; @@ -3274,6 +3396,7 @@ pub(crate) fn parse_message_content(data: &[u8]) -> Result MessageContent::Edit { target_message_id: e.message_sequence_id.unwrap_or_default(), new_text: e.updated_text.unwrap_or_default(), + entities: map_rich_text_entities(e.entities.as_deref()), }, MessageEntryContents::MarkConversationRead(_) => MessageContent::MarkRead, MessageEntryContents::MarkConversationUnread(_) => MessageContent::MarkUnread, @@ -3860,6 +3983,63 @@ mod tests { } } + /// An edit produced by `encrypt_edit` must decrypt and verify like any + /// other message, surfacing the target sequence id, replacement text, and + /// entities. Catches producer/consumer divergence on the edit wire shape. + #[test] + fn encrypt_edit_round_trips_through_decrypt() { + let core = ChatCore::new(); + let reg = core.generate_keypairs().unwrap(); + let ckey = core.generate_conversation_key().unwrap(); + + let mut params = crate::EncryptEditParams::new("", "read https://example.com") + .with_identity("sender-1", "1733889755256") + .with_conversation_key(ckey.to_bytes(), "9001"); + params.conversation_id = Some("conv-1".into()); + params.target_message_sequence_id = Some("seq-orig".into()); + params.entities = Some(vec![crate::types::EntityDescriptor { + start: 5, + end: 24, + entity_type: "url".into(), + }]); + let payload = core.encrypt_edit(¶ms).unwrap(); + + let event_b64 = wrap_signed_payload_with_seq(&payload, "sender-1", "conv-1", "seq-edit"); + let conv_keys = [("9001".to_string(), ckey)].into_iter().collect(); + let signing_keys = [SigningKeyEntry { + user_id: "sender-1".to_string(), + public_key_version: "1733889755256".to_string(), + public_key: reg.public_key.signing_public_key.clone(), + identity_public_key: reg.public_key.public_key.clone(), + identity_public_key_signature: reg.public_key.identity_public_key_signature.clone(), + }]; + + let event = core + .decrypt_event(&event_b64, &conv_keys, &signing_keys) + .unwrap(); + match event { + Event::Message(msg) => { + assert!(msg.verified, "self-signed edit must verify"); + match msg.content { + MessageContent::Edit { + target_message_id, + new_text, + entities, + } => { + assert_eq!(target_message_id, "seq-orig"); + assert_eq!(new_text, "read https://example.com"); + let entities = entities.expect("entities survive the round trip"); + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].start_index, Some(5)); + assert_eq!(entities[0].end_index, Some(24)); + } + other => panic!("expected Edit content, got {:?}", other), + } + } + other => panic!("expected Message, got {:?}", other), + } + } + /// The SDK mints the message id: `encrypt_message` returns a fresh UUID in /// `SendPayload.message_id` on every call, and callers can no longer supply /// one. (That the returned id is the one actually signed is proven by the @@ -6579,6 +6759,104 @@ mod tests { assert_action_signature_round_trips(&core, blocked_add, "user-1", "g555", None); } + #[test] + fn message_delete_signature_verifies_against_own_reconstruction() { + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + + let params = crate::MessageDeleteParams::new( + "222-111", + vec!["seq-10".to_string(), "seq-11".to_string()], + true, + ) + .with_identity("111", "1"); + let sig = core.prepare_message_delete(¶ms).unwrap(); + + // The 1:1 conversation id is signed in canonical colon form even when + // the params carry the hyphen form. + assert_eq!( + sig.signature_payload, + format!( + "MessageDeleteEvent,{},111,111:222,2,seq-10,seq-11", + sig.message_id + ) + ); + + match decode_detail(&sig.encoded_message_event_detail) { + MessageEventDetail::MessageDeleteEvent(del) => { + assert_eq!( + del.sequence_ids, + Some(vec!["seq-10".to_string(), "seq-11".to_string()]) + ); + assert_eq!( + del.delete_message_action, + Some(crate::thrift::event::DeleteMessageAction::DELETE_FOR_ALL) + ); + } + other => panic!("expected MessageDeleteEvent, got {:?}", other), + } + + assert_action_signature_round_trips(&core, &sig, "111", "111:222", None); + } + + #[test] + fn message_delete_for_self_signs_action_one() { + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + + let params = crate::MessageDeleteParams::new("g999", vec!["seq-1".to_string()], false) + .with_identity("111", "1"); + let sig = core.prepare_message_delete(¶ms).unwrap(); + + assert_eq!( + sig.signature_payload, + format!("MessageDeleteEvent,{},111,g999,1,seq-1", sig.message_id) + ); + assert_action_signature_round_trips(&core, &sig, "111", "g999", None); + } + + #[test] + fn prepare_message_delete_rejects_empty_sequence_ids() { + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + + let params = + crate::MessageDeleteParams::new("g999", vec![], true).with_identity("111", "1"); + assert!(core.prepare_message_delete(¶ms).is_err()); + } + + /// Empty id components would sign a degenerate payload with empty + /// comma-separated slots (e.g. `...,111,,2,,seq-2`) that only fails + /// server-side; reject them up front instead. + #[test] + fn prepare_message_delete_rejects_empty_id_components() { + let core = ChatCore::new(); + core.generate_keypairs().unwrap(); + + let empty_conversation = + crate::MessageDeleteParams::new("", vec!["seq-1".to_string()], true) + .with_identity("111", "1"); + let err = core + .prepare_message_delete(&empty_conversation) + .unwrap_err(); + assert!( + err.to_string().contains("conversation_id is empty"), + "unexpected error: {err}" + ); + + let empty_member = crate::MessageDeleteParams::new( + "g999", + vec!["".to_string(), "seq-2".to_string()], + true, + ) + .with_identity("111", "1"); + let err = core.prepare_message_delete(&empty_member).unwrap_err(); + assert!( + err.to_string().contains("empty id"), + "unexpected error: {err}" + ); + } + #[test] fn prepare_group_create_rejects_comma_in_title() { let core = ChatCore::new(); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index eb51130..b27c43b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -135,8 +135,8 @@ pub use chat::Chat; pub use core::ChatCore; pub use crypto::encryption::{StreamDecryptor, StreamEncryptor}; pub use params::{ - ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, EncryptReplyParams, - GroupCreateParams, GroupMembersChangeParams, + ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, EncryptReactionParams, + EncryptReplyParams, GroupCreateParams, GroupMembersChangeParams, MessageDeleteParams, }; pub use types::*; @@ -204,8 +204,9 @@ pub mod prelude { pub use crate::chat::Chat; pub use crate::params::{ - ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, - EncryptReplyParams, GroupCreateParams, GroupMembersChangeParams, + ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, + EncryptReactionParams, EncryptReplyParams, GroupCreateParams, GroupMembersChangeParams, + MessageDeleteParams, }; // Types (always available) diff --git a/crates/core/src/params.rs b/crates/core/src/params.rs index 09c7554..1095869 100644 --- a/crates/core/src/params.rs +++ b/crates/core/src/params.rs @@ -405,6 +405,170 @@ impl std::fmt::Debug for EncryptReactionParams { } } +/// Parameters for [`crate::ChatCore::encrypt_edit`]. +/// +/// The preferred form passes `target_event` — the base64 raw event of the +/// message being edited — and lets the SDK derive the conversation id and +/// target sequence id from it. The explicit field overrides remain for +/// callers that no longer hold the raw event. +/// +/// The conversation key is zeroized when the params are dropped and is +/// redacted from `Debug` output. No `PartialEq` is derived: +/// equality would byte-compare the key non-constant-time. +#[derive(Clone)] +pub struct EncryptEditParams { + /// Base64 of the raw event being edited. The conversation id and target + /// sequence id are derived from it. + pub target_event: Option, + /// The replacement message text. + pub updated_text: String, + /// Rich-text entities for the replacement text; `None` clears any + /// entities the original carried. + pub entities: Option>, + /// ID of the conversation the edit belongs to; derived from + /// `target_event` when unset. + pub conversation_id: Option, + /// The `sequence_id` of the message being edited; derived from + /// `target_event` when unset. + pub target_message_sequence_id: Option, + /// User ID of the sender; resolves from the session identity when unset. + pub sender_id: Option, + /// Version of the signing key used to sign the edit; resolves from the + /// session identity when unset. + pub signing_key_version: Option, + /// Raw 32-byte conversation key used to encrypt the edit content; + /// resolves from the key cache when unset (set together with + /// `conversation_key_version`). + pub conversation_key: Option>, + /// Version of the conversation key used for encryption; resolves from the + /// key cache when unset (set together with `conversation_key`). + pub conversation_key_version: Option, +} + +impl EncryptEditParams { + /// Create params with required fields; all optional fields default to + /// `None`. `target_event` is the base64 raw event being edited; pass an + /// empty string only when supplying `conversation_id` and + /// `target_message_sequence_id` directly instead. + pub fn new(target_event: impl Into, updated_text: impl Into) -> Self { + Self { + target_event: non_empty(target_event), + updated_text: updated_text.into(), + entities: None, + conversation_id: None, + target_message_sequence_id: None, + sender_id: None, + signing_key_version: None, + conversation_key: None, + conversation_key_version: None, + } + } + + /// Set the explicit conversation key and its version. + /// + /// The two travel together: the version names the key the recipients use + /// to decrypt, so setting one without the other is rejected at encrypt + /// time. + pub fn with_conversation_key( + mut self, + conversation_key: Vec, + conversation_key_version: impl Into, + ) -> Self { + self.conversation_key = Some(conversation_key); + self.conversation_key_version = Some(conversation_key_version.into()); + self + } + + /// Set the explicit sender identity, overriding the session identity. + pub fn with_identity( + mut self, + sender_id: impl Into, + signing_key_version: impl Into, + ) -> Self { + self.sender_id = non_empty(sender_id); + self.signing_key_version = non_empty(signing_key_version); + self + } +} + +impl Drop for EncryptEditParams { + fn drop(&mut self) { + self.conversation_key.zeroize(); + } +} + +impl std::fmt::Debug for EncryptEditParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptEditParams") + .field("target_event", &self.target_event) + .field("updated_text", &self.updated_text) + .field("entities", &self.entities) + .field("conversation_id", &self.conversation_id) + .field( + "target_message_sequence_id", + &self.target_message_sequence_id, + ) + .field("sender_id", &self.sender_id) + .field("signing_key_version", &self.signing_key_version) + .field( + "conversation_key", + &self.conversation_key.as_ref().map(|_| "[REDACTED]"), + ) + .field("conversation_key_version", &self.conversation_key_version) + .finish() + } +} + +/// Parameters for [`crate::ChatCore::prepare_message_delete`]. +/// +/// A delete is a signed plaintext event, not an encrypted message, so no +/// conversation key is involved: the result is an action signature the +/// caller submits alongside the delete request. +#[derive(Clone, Debug, PartialEq)] +pub struct MessageDeleteParams { + /// ID of the conversation the messages belong to. + pub conversation_id: String, + /// The `sequence_id`s of the messages to delete. + pub sequence_ids: Vec, + /// Delete for every participant (`true`, own messages only) or only from + /// the caller's view (`false`). + pub delete_for_all: bool, + /// User ID of the sender signing the delete; resolves from the session + /// identity when unset. + pub sender_id: Option, + /// Version of the signing key used to sign the delete; resolves from the + /// session identity when unset. + pub signing_key_version: Option, +} + +impl MessageDeleteParams { + /// Create params with required fields; all optional fields default to `None`. + pub fn new( + conversation_id: impl Into, + sequence_ids: Vec, + delete_for_all: bool, + ) -> Self { + Self { + conversation_id: conversation_id.into(), + sequence_ids, + delete_for_all, + sender_id: None, + signing_key_version: None, + } + } + + /// Set the explicit sender identity, overriding the session identity. + pub fn with_identity( + mut self, + sender_id: impl Into, + signing_key_version: impl Into, + ) -> Self { + self.sender_id = non_empty(sender_id); + self.signing_key_version = non_empty(signing_key_version); + self + } +} + /// Parameters for [`crate::ChatCore::prepare_conversation_key_change`]. #[derive(Clone, Debug, PartialEq)] pub struct ConversationKeyChangeParams { diff --git a/crates/core/src/pipeline.rs b/crates/core/src/pipeline.rs index a2842f4..7734f33 100644 --- a/crates/core/src/pipeline.rs +++ b/crates/core/src/pipeline.rs @@ -16,12 +16,12 @@ use crate::thrift::product::{ AddressRichTextContent, CashtagRichTextContent, EmailRichTextContent, HashtagRichTextContent, MediaAttachment as ThriftMediaAttachmentStruct, MediaDimensions as ThriftMediaDimensions, MediaType as ThriftMediaType, MentionRichTextContent, - MessageAttachment as ThriftMessageAttachment, MessageContents, MessageEntryContents, - MessageEntryHolder, MessageReactionAdd, MessageReactionRemove, PhoneNumberRichTextContent, - PostAttachment as ThriftPostAttachment, ReplyingToPreview as ThriftReplyingToPreview, - RichTextContent as ThriftRichTextContent, RichTextEntity as ThriftRichTextEntity, - UrlAttachment as ThriftUrlAttachment, UrlAttachmentImage as ThriftUrlAttachmentImage, - UrlRichTextContent, + MessageAttachment as ThriftMessageAttachment, MessageContents, MessageEdit, + MessageEntryContents, MessageEntryHolder, MessageReactionAdd, MessageReactionRemove, + PhoneNumberRichTextContent, PostAttachment as ThriftPostAttachment, + ReplyingToPreview as ThriftReplyingToPreview, RichTextContent as ThriftRichTextContent, + RichTextEntity as ThriftRichTextEntity, UrlAttachment as ThriftUrlAttachment, + UrlAttachmentImage as ThriftUrlAttachmentImage, UrlRichTextContent, }; use crate::types::{AttachmentDescriptor, EntityDescriptor, SendPayload, SignatureInfo}; @@ -302,6 +302,30 @@ pub fn build_reaction_add_content( serialize_thrift(&holder) } +/// Build a message-edit content payload. +pub fn build_message_edit_content( + target_message_sequence_id: &str, + updated_text: &str, + entities: Option<&[EntityDescriptor]>, +) -> Result, SdkError> { + // MessageEdit carries entities unboxed, unlike MessageContents. + let entities = entities.map(|descs| { + build_thrift_entities(descs) + .into_iter() + .map(|e| *e) + .collect::>() + }); + let content = MessageEdit::new( + Some(target_message_sequence_id.to_string()), + Some(updated_text.to_string()), + entities, + ); + let holder = MessageEntryHolder::new(Some(Box::new(MessageEntryContents::MessageEdit( + Box::new(content), + )))); + serialize_thrift(&holder) +} + /// Build a reaction-remove content payload. pub fn build_reaction_remove_content( target_message_sequence_id: &str, diff --git a/crates/core/src/signatures.rs b/crates/core/src/signatures.rs index ffdce7a..3228f4b 100644 --- a/crates/core/src/signatures.rs +++ b/crates/core/src/signatures.rs @@ -236,6 +236,39 @@ pub fn build_ckey_change_signature( }) } +/// Build and sign a MessageDelete action signature. +/// +/// Payload format: +/// ```text +/// MessageDeleteEvent,{msg_id},{sender_id},{conv_id},{delete_action},{sequence_ids...} +/// ``` +/// +/// `delete_action` is the wire integer: `1` delete-for-self, `2` +/// delete-for-all. +/// +/// The payload is comma-joined with no escaping, so signing fails with a +/// parse error if any component contains a comma. +pub fn build_message_delete_signature( + signing_key: &XChatPrivateKey, + public_key_version: &str, + message_id: &str, + sender_id: &str, + conversation_id: &str, + sequence_ids: &[String], + delete_action: i32, +) -> Result { + let action_str = delete_action.to_string(); + let mut components: Vec<&str> = vec![ + "MessageDeleteEvent", + message_id, + sender_id, + conversation_id, + &action_str, + ]; + components.extend(sequence_ids.iter().map(|s| s.as_str())); + sign_payload(signing_key, public_key_version, message_id, &components) +} + /// Sign a comma-joined payload and return an [`ActionSignature`]. /// /// Rejects any component containing `,`: the payload has no escaping, so an diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 10d315a..0814573 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -568,6 +568,10 @@ pub enum MessageContent { target_message_id: String, /// The new text. new_text: String, + /// Rich text entities in the new text. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "js", js_camel(wrap))] + entities: Option>, }, /// Conversation marked as read (encrypted marker). diff --git a/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs b/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs index b59501f..7c0fdd6 100644 --- a/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs +++ b/crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs @@ -523,6 +523,76 @@ public void EncryptRemoveReaction_ReturnsValidPayload() Assert.NotEmpty(payload.EncryptedContent); } + // EncryptEdit + + [Fact] + public void EncryptEdit_ReturnsValidPayload() + { + using var chat = CreateUnlocked(); + var ckey = NewConvKey(chat); + + // Explicit-field form: conversation id + target sequence id instead + // of the raw target event. + var payload = chat.EncryptEdit(new EncryptEditParams(targetEvent: null, "see https://example.com") + { + ConversationId = "conv-1", + TargetMessageSequenceId = "seq-99", + Entities = new[] { new EntityDescriptor { Start = 4, End = 23, EntityType = "url" } }, + SenderId = "user-1", + SigningKeyVersion = "s1", + ConversationKey = ckey, + ConversationKeyVersion = "v1", + }); + + Assert.NotEmpty(payload.MessageId); + Assert.NotEmpty(payload.EncryptedContent); + Assert.NotEmpty(payload.Signature); + Assert.NotEmpty(payload.EncodedEventSignature); + } + + // PrepareMessageDelete + + [Fact] + public void PrepareMessageDelete_SignsCanonicalPayload() + { + using var chat = CreateUnlocked(); + + // A 1:1 id is signed in its canonical colon form; delete-for-all + // signs the wire action 2. + var sig = chat.PrepareMessageDelete(new MessageDeleteParams( + "222-111", new[] { "seq-10", "seq-11" }, deleteForAll: true) + { + SenderId = "111", + SigningKeyVersion = "1", + }); + + Assert.NotEmpty(sig.MessageId); + Assert.NotEmpty(sig.EncodedMessageEventDetail); + Assert.NotEmpty(sig.Signature); + Assert.Equal( + $"MessageDeleteEvent,{sig.MessageId},111,111:222,2,seq-10,seq-11", + sig.SignaturePayload); + } + + [Fact] + public void PrepareMessageDelete_ForSelf() + { + using var chat = CreateUnlocked(); + + // Group ids pass through unchanged; delete-for-self signs the wire + // action 1. + var sig = chat.PrepareMessageDelete(new MessageDeleteParams( + "g999", new[] { "seq-1" }, deleteForAll: false) + { + SenderId = "111", + SigningKeyVersion = "1", + }); + + Assert.Equal( + $"MessageDeleteEvent,{sig.MessageId},111,g999,1,seq-1", + sig.SignaturePayload); + } + // Conversation key encrypt / decrypt [Fact] diff --git a/crates/dotnet/dotnet/ChatXdk/Chat.cs b/crates/dotnet/dotnet/ChatXdk/Chat.cs index 62bbd56..d7c267a 100644 --- a/crates/dotnet/dotnet/ChatXdk/Chat.cs +++ b/crates/dotnet/dotnet/ChatXdk/Chat.cs @@ -628,6 +628,24 @@ public PreparedConversationChange PrepareGroupCreate(GroupCreateParams parameter NativeMethods.chat_xdk_prepare_group_create); } + /// + /// Build the signed action for deleting messages from a conversation, + /// ready to submit alongside the delete request. + /// A delete is a signed plaintext event, not an encrypted message, so no + /// conversation key is involved. The SDK generates the action's message + /// id; read it back from the result. + /// + public ActionSignature PrepareMessageDelete(MessageDeleteParams parameters) + { + ThrowIfDisposed(); + var jsonBytes = Utf8Z(BuildPrepareMessageDeleteJson(parameters)); + string resultJson; + fixed (byte* jsonPtr = jsonBytes) + resultJson = ConsumeResult(NativeMethods.chat_xdk_prepare_message_delete(_handle, jsonPtr)); + GC.KeepAlive(this); + return JsonSerializer.Deserialize(resultJson, JsonOpts)!; + } + // Shared helper: marshal the single-JSON params document, call the // native prepare export, deserialise PreparedConversationChange. private PreparedConversationChange CallPrepareEndpoint(string paramsJson, ParamsJsonDelegate fn) @@ -715,6 +733,14 @@ public SendPayload EncryptRemoveReaction(EncryptReactionParams parameters) return CallEncryptEndpoint(json, NativeMethods.chat_xdk_encrypt_remove_reaction); } + /// Encrypt a message edit for the X API. + public SendPayload EncryptEdit(EncryptEditParams parameters) + { + ThrowIfDisposed(); + var json = BuildEncryptEditJson(parameters); + return CallEncryptEndpoint(json, NativeMethods.chat_xdk_encrypt_edit); + } + // Shared helper: marshal JSON, call the delegate, deserialise SendPayload. // FfiResult and ChatHandle are csbindgen-generated types at the ChatXdk namespace level. private delegate FfiResult ParamsJsonDelegate(ChatHandle* handle, byte* paramsJson); @@ -1004,6 +1030,36 @@ private static string BuildReactionJson(EncryptReactionParams p) return JsonSerializer.Serialize(dict, JsonOpts); } + private static string BuildEncryptEditJson(EncryptEditParams p) + { + var dict = new Dictionary + { + ["target_event"] = p.TargetEvent, + ["updated_text"] = p.UpdatedText, + ["entities"] = p.Entities != null ? SerialiseEntities(p.Entities) : null, + ["conversation_id"] = p.ConversationId, + ["target_message_sequence_id"] = p.TargetMessageSequenceId, + ["sender_id"] = p.SenderId, + ["signing_key_version"] = p.SigningKeyVersion, + ["conversation_key"] = p.ConversationKey != null ? Convert.ToBase64String(p.ConversationKey) : null, + ["conversation_key_version"] = p.ConversationKeyVersion, + }; + return JsonSerializer.Serialize(dict, JsonOpts); + } + + private static string BuildPrepareMessageDeleteJson(MessageDeleteParams p) + { + var dict = new Dictionary + { + ["conversation_id"] = p.ConversationId, + ["sequence_ids"] = p.SequenceIds, + ["delete_for_all"] = p.DeleteForAll, + ["sender_id"] = p.SenderId, + ["signing_key_version"] = p.SigningKeyVersion, + }; + return JsonSerializer.Serialize(dict, JsonOpts); + } + private static string BuildPrepareConversationKeyChangeJson(ConversationKeyChangeParams p) { var dict = new Dictionary diff --git a/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs b/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs index c57c6ff..a1e0f1a 100644 --- a/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs +++ b/crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs @@ -249,6 +249,20 @@ internal static unsafe partial class NativeMethods [DllImport(__DllName, EntryPoint = "chat_xdk_prepare_group_create", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern FfiResult chat_xdk_prepare_group_create(ChatHandle* handle, byte* params_json); + /// + /// Build the signed action for deleting messages from a conversation. + /// + /// `params_json` — single JSON document: `conversation_id`, `sequence_ids` + /// (the messages to delete), `delete_for_all` (every participant vs only the + /// caller's view), plus optional `sender_id` / `signing_key_version` (absent + /// resolves from the session identity). + /// + /// Returns JSON `ActionSignature`; the SDK-generated `message_id` is a field + /// on it. + /// + [DllImport(__DllName, EntryPoint = "chat_xdk_prepare_message_delete", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern FfiResult chat_xdk_prepare_message_delete(ChatHandle* handle, byte* params_json); + /// /// Decrypt a webhook event. /// @@ -325,6 +339,22 @@ internal static unsafe partial class NativeMethods [DllImport(__DllName, EntryPoint = "chat_xdk_encrypt_remove_reaction", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern FfiResult chat_xdk_encrypt_remove_reaction(ChatHandle* handle, byte* params_json); + /// + /// Encrypt a message edit for the X API. + /// + /// `params_json` — single JSON document: `updated_text` and `target_event` + /// (base64 raw event being edited; the conversation id and target sequence + /// id are derived from it), plus optional explicit overrides + /// `conversation_id` / `target_message_sequence_id` for callers that no + /// longer hold the raw event, `entities` (`[start, end, type]` tuples for + /// the replacement text), and the optional identity/key overrides described + /// on `chat_xdk_encrypt_message`. + /// + /// Returns JSON `SendPayload`; the SDK-generated `message_id` is a field on it. + /// + [DllImport(__DllName, EntryPoint = "chat_xdk_encrypt_edit", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern FfiResult chat_xdk_encrypt_edit(ChatHandle* handle, byte* params_json); + /// /// Decrypt an ECIES-encrypted conversation key. /// diff --git a/crates/dotnet/dotnet/ChatXdk/Types.cs b/crates/dotnet/dotnet/ChatXdk/Types.cs index 349929a..68589e7 100644 --- a/crates/dotnet/dotnet/ChatXdk/Types.cs +++ b/crates/dotnet/dotnet/ChatXdk/Types.cs @@ -117,7 +117,8 @@ public sealed class SendPayload // Action signatures (group operations) /// - /// Signed action payload authenticating a conversation key change or member add. + /// Signed action payload authenticating a conversation key change, member + /// add, or message delete. /// public sealed class ActionSignature { @@ -619,6 +620,50 @@ public EncryptReactionParams(string? targetEvent, string emoji) public string? ConversationKeyVersion { get; init; } } + /// + /// Parameters for . + /// The preferred form passes the raw event of the message being edited + /// () and lets the SDK derive the conversation id + /// and target sequence id from it; the explicit field overrides remain for + /// callers that no longer hold the raw event. + /// + public sealed class EncryptEditParams + { + /// Create params with the required fields; all optional fields default to null. + /// + /// Base64 raw event being edited. Pass null or "" only when supplying + /// and directly instead. + /// + /// The replacement message text. + public EncryptEditParams(string? targetEvent, string updatedText) + { + TargetEvent = string.IsNullOrEmpty(targetEvent) ? null : targetEvent; + UpdatedText = updatedText; + } + + /// Base64 raw event being edited; the conversation id and target sequence id are derived from it. + public string? TargetEvent { get; } + /// The replacement message text. + public string UpdatedText { get; } + /// Rich-text entities for the replacement text; null clears any entities the original carried. + public IReadOnlyList? Entities { get; init; } + /// ID of the conversation the edit belongs to; derived from when null. + public string? ConversationId { get; init; } + /// Sequence ID of the message being edited; derived from when null. + public string? TargetMessageSequenceId { get; init; } + /// User ID of the sender; resolves from the session identity when null. + public string? SenderId { get; init; } + /// Version of the sender's signing key; resolves from the session identity when null. + public string? SigningKeyVersion { get; init; } + /// + /// Raw 32-byte conversation key; resolves from the key cache when null + /// (set together with ). + /// + public byte[]? ConversationKey { get; init; } + /// Version of the conversation key; resolves from the key cache when null. + public string? ConversationKeyVersion { get; init; } + } + /// Parameters for . public sealed class ConversationKeyChangeParams { @@ -730,4 +775,41 @@ public GroupCreateParams( /// Disappearing-message TTL in milliseconds, if set. public long? TtlMsec { get; init; } } + + /// + /// Parameters for . + /// A delete is a signed plaintext event, not an encrypted message, so no + /// conversation key is involved: the result is an action signature the + /// caller submits alongside the delete request. + /// + public sealed class MessageDeleteParams + { + /// Create params with the required fields; all optional fields default to null. + /// ID of the conversation the messages belong to. + /// Sequence IDs of the messages to delete. + /// + /// Delete for every participant (true, own messages only) or only from + /// the caller's view (false). + /// + public MessageDeleteParams( + string conversationId, + IReadOnlyList sequenceIds, + bool deleteForAll) + { + ConversationId = conversationId; + SequenceIds = sequenceIds; + DeleteForAll = deleteForAll; + } + + /// ID of the conversation the messages belong to. + public string ConversationId { get; } + /// Sequence IDs of the messages to delete. + public IReadOnlyList SequenceIds { get; } + /// Delete for every participant (true, own messages only) or only from the caller's view (false). + public bool DeleteForAll { get; } + /// User ID of the sender signing the delete; resolves from the session identity when null. + public string? SenderId { get; init; } + /// Version of the sender's signing key; resolves from the session identity when null. + public string? SigningKeyVersion { get; init; } + } } diff --git a/crates/dotnet/src/lib.rs b/crates/dotnet/src/lib.rs index bc6dfdd..ac80662 100644 --- a/crates/dotnet/src/lib.rs +++ b/crates/dotnet/src/lib.rs @@ -21,9 +21,9 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use chat_xdk_core::crypto::keys::XChatConversationKey; use chat_xdk_core::keys::juicebox::{JuiceboxClient, JuiceboxConfig}; use chat_xdk_core::{ - AttachmentDescriptor, ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, - EncryptReplyParams, EntityDescriptor, GroupCreateParams, GroupMembersChangeParams, - PublicKeyInput, SigningKeyEntry, + AttachmentDescriptor, ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, + EncryptReactionParams, EncryptReplyParams, EntityDescriptor, GroupCreateParams, + GroupMembersChangeParams, MessageDeleteParams, PublicKeyInput, SigningKeyEntry, }; // FFI result type @@ -290,6 +290,38 @@ struct FfiEncryptReactionParams { conversation_key_version: Option, } +#[derive(serde::Deserialize)] +struct FfiEncryptEditParams { + updated_text: String, + #[serde(default)] + target_event: Option, + #[serde(default)] + entities: Option>, + #[serde(default)] + conversation_id: Option, + #[serde(default)] + target_message_sequence_id: Option, + #[serde(default)] + sender_id: Option, + #[serde(default)] + signing_key_version: Option, + #[serde(default)] + conversation_key: Option, + #[serde(default)] + conversation_key_version: Option, +} + +#[derive(serde::Deserialize)] +struct FfiMessageDeleteParams { + conversation_id: String, + sequence_ids: Vec, + delete_for_all: bool, + #[serde(default)] + sender_id: Option, + #[serde(default)] + signing_key_version: Option, +} + #[derive(serde::Deserialize)] struct FfiConversationKeyChangeParams { public_keys: Vec, @@ -1093,6 +1125,45 @@ pub extern "C" fn chat_xdk_prepare_group_create( }) } +/// Build the signed action for deleting messages from a conversation. +/// +/// `params_json` — single JSON document: `conversation_id`, `sequence_ids` +/// (the messages to delete), `delete_for_all` (every participant vs only the +/// caller's view), plus optional `sender_id` / `signing_key_version` (absent +/// resolves from the session identity). +/// +/// Returns JSON `ActionSignature`; the SDK-generated `message_id` is a field +/// on it. +#[no_mangle] +pub extern "C" fn chat_xdk_prepare_message_delete( + handle: *const ChatHandle, + params_json: *const c_char, +) -> FfiResult { + catch_ffi(|| { + if handle.is_null() { + return err_result("Null handle"); + } + let h = unsafe { &*handle }; + let p: FfiMessageDeleteParams = match parse_params(try_str!(params_json)) { + Ok(p) => p, + Err(e) => return e, + }; + + let mut params = + MessageDeleteParams::new(p.conversation_id, p.sequence_ids, p.delete_for_all); + params.sender_id = p.sender_id; + params.signing_key_version = p.signing_key_version; + + match h.inner.prepare_message_delete(¶ms) { + Ok(signature) => match serde_json::to_string(&signature) { + Ok(json) => ok_data(&json), + Err(e) => err_result(&e.to_string()), + }, + Err(e) => err_from(e), + } + }) +} + /// Decrypt a webhook event. /// /// - `event_b64` — base64-encoded raw event from the webhook. @@ -1357,6 +1428,55 @@ fn encrypt_reaction_impl( } } +/// Encrypt a message edit for the X API. +/// +/// `params_json` — single JSON document: `updated_text` and `target_event` +/// (base64 raw event being edited; the conversation id and target sequence +/// id are derived from it), plus optional explicit overrides +/// `conversation_id` / `target_message_sequence_id` for callers that no +/// longer hold the raw event, `entities` (`[start, end, type]` tuples for +/// the replacement text), and the optional identity/key overrides described +/// on `chat_xdk_encrypt_message`. +/// +/// Returns JSON `SendPayload`; the SDK-generated `message_id` is a field on it. +#[no_mangle] +pub extern "C" fn chat_xdk_encrypt_edit( + handle: *const ChatHandle, + params_json: *const c_char, +) -> FfiResult { + catch_ffi(|| { + if handle.is_null() { + return err_result("Null handle"); + } + let h = unsafe { &*handle }; + let p: FfiEncryptEditParams = match parse_params(try_str!(params_json)) { + Ok(p) => p, + Err(e) => return e, + }; + let ckey_bytes = match decode_opt_ckey_bytes(p.conversation_key.as_deref()) { + Ok(k) => k, + Err(e) => return e, + }; + + let mut params = EncryptEditParams::new(p.target_event.unwrap_or_default(), p.updated_text); + params.entities = p.entities.map(entity_tuples_to_descs); + params.conversation_id = p.conversation_id; + params.target_message_sequence_id = p.target_message_sequence_id; + params.sender_id = p.sender_id; + params.signing_key_version = p.signing_key_version; + params.conversation_key = ckey_bytes; + params.conversation_key_version = p.conversation_key_version; + + match h.inner.encrypt_edit(¶ms) { + Ok(payload) => match serde_json::to_string(&payload) { + Ok(json) => ok_data(&json), + Err(e) => err_result(&e.to_string()), + }, + Err(e) => err_from(e), + } + }) +} + // Conversation key operations /// Decrypt an ECIES-encrypted conversation key. diff --git a/crates/go/src/lib.rs b/crates/go/src/lib.rs index 6c2075f..5a7ef86 100644 --- a/crates/go/src/lib.rs +++ b/crates/go/src/lib.rs @@ -18,9 +18,9 @@ use chat_xdk_core::crypto::keys::XChatConversationKey; #[cfg(feature = "juicebox")] use chat_xdk_core::keys::juicebox::{JuiceboxClient, JuiceboxConfig}; use chat_xdk_core::{ - AttachmentDescriptor, ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, - EncryptReplyParams, EntityDescriptor, GroupCreateParams, GroupMembersChangeParams, - PublicKeyInput, SigningKeyEntry, + AttachmentDescriptor, ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, + EncryptReactionParams, EncryptReplyParams, EntityDescriptor, GroupCreateParams, + GroupMembersChangeParams, MessageDeleteParams, PublicKeyInput, SigningKeyEntry, }; // FFI result type @@ -937,6 +937,38 @@ struct FfiEncryptReactionParams { conversation_key_version: Option, } +#[derive(serde::Deserialize)] +struct FfiEncryptEditParams { + updated_text: String, + #[serde(default)] + target_event: Option, + #[serde(default)] + entities: Option>, + #[serde(default)] + conversation_id: Option, + #[serde(default)] + target_message_sequence_id: Option, + #[serde(default)] + sender_id: Option, + #[serde(default)] + signing_key_version: Option, + #[serde(default)] + conversation_key: Option, + #[serde(default)] + conversation_key_version: Option, +} + +#[derive(serde::Deserialize)] +struct FfiMessageDeleteParams { + conversation_id: String, + sequence_ids: Vec, + delete_for_all: bool, + #[serde(default)] + sender_id: Option, + #[serde(default)] + signing_key_version: Option, +} + #[derive(serde::Deserialize)] struct FfiConversationKeyChangeParams { public_keys: Vec, @@ -1126,6 +1158,45 @@ pub extern "C" fn chat_xdk_prepare_group_create( }) } +/// Build the signed action for deleting messages from a conversation. +/// +/// `params_json` — single JSON document: `conversation_id`, `sequence_ids` +/// (the messages to delete), `delete_for_all` (every participant vs only the +/// caller's view), plus optional `sender_id` / `signing_key_version` (unset +/// resolves from the session identity). +/// +/// Returns JSON `ActionSignature`; the SDK-generated `message_id` is a field +/// on it. +#[no_mangle] +pub extern "C" fn chat_xdk_prepare_message_delete( + handle: *const ChatHandle, + params_json: *const c_char, +) -> FfiResult { + catch_ffi(|| { + if handle.is_null() { + return err_result("Null handle"); + } + let h = unsafe { &*handle }; + let p: FfiMessageDeleteParams = match parse_params(try_str!(params_json)) { + Ok(p) => p, + Err(e) => return e, + }; + + let mut params = + MessageDeleteParams::new(p.conversation_id, p.sequence_ids, p.delete_for_all); + params.sender_id = p.sender_id; + params.signing_key_version = p.signing_key_version; + + match h.inner.prepare_message_delete(¶ms) { + Ok(signature) => match serde_json::to_string(&signature) { + Ok(json) => ok_data(&json), + Err(e) => err_result(&e.to_string()), + }, + Err(e) => err_from(e), + } + }) +} + /// Decrypt a webhook event. /// /// `event_b64`: Base64-encoded event from webhook. @@ -1423,6 +1494,59 @@ fn encrypt_reaction_impl( } } +// Edit encryption + +/// Encrypt a message edit for the X API. +/// +/// `params_json` — single JSON document: `updated_text` plus the edit +/// target: preferably `target_event` (base64 raw event being edited; the +/// conversation id and target sequence id are derived from it), with +/// explicit `conversation_id` / `target_message_sequence_id` overrides for +/// callers that no longer hold the raw event. Optional `entities` +/// (`[start, end, type]` tuples for the replacement text), `sender_id` / +/// `signing_key_version` (unset resolves from the session identity), and +/// `conversation_key` (base64) / `conversation_key_version` (unset resolves +/// from the opt-in key cache). +/// +/// Returns JSON `SendPayload`; the SDK-generated `message_id` is a field on it. +#[no_mangle] +pub extern "C" fn chat_xdk_encrypt_edit( + handle: *const ChatHandle, + params_json: *const c_char, +) -> FfiResult { + catch_ffi(|| { + if handle.is_null() { + return err_result("Null handle"); + } + let h = unsafe { &*handle }; + let p: FfiEncryptEditParams = match parse_params(try_str!(params_json)) { + Ok(p) => p, + Err(e) => return e, + }; + let ckey_bytes = match decode_ckey_bytes(p.conversation_key.as_deref()) { + Ok(k) => k, + Err(e) => return e, + }; + + let mut params = EncryptEditParams::new(p.target_event.unwrap_or_default(), p.updated_text); + params.entities = p.entities.map(entity_tuples_to_descs); + params.conversation_id = p.conversation_id; + params.target_message_sequence_id = p.target_message_sequence_id; + params.sender_id = p.sender_id; + params.signing_key_version = p.signing_key_version; + params.conversation_key = ckey_bytes; + params.conversation_key_version = p.conversation_key_version; + + match h.inner.encrypt_edit(¶ms) { + Ok(payload) => match serde_json::to_string(&payload) { + Ok(json) => ok_data(&json), + Err(e) => err_result(&e.to_string()), + }, + Err(e) => err_from(e), + } + }) +} + // Conversation key operations /// Decrypt an encrypted conversation key. 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 2b2f985..3f9c7d8 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 @@ -556,6 +556,25 @@ public PreparedConversationChange prepareGroupCreate(GroupCreateParams parameter ChatNative.INSTANCE::chat_xdk_prepare_group_create); } + /** + * Build the signed action for deleting messages from a conversation, ready + * to submit alongside the delete request. + * + *

A delete is a signed plaintext event, not an encrypted message, so no + * conversation key is involved. The SDK generates the action's message id; + * read it back from the result. + */ + public ActionSignature prepareMessageDelete(MessageDeleteParams parameters) throws Exception { + throwIfDisposed(); + try (Memory j = FfiStrings.utf8(buildPrepareMessageDeleteJson(parameters))) { + String resultJson = FfiStrings.consume( + ChatNative.INSTANCE.chat_xdk_prepare_message_delete(handle, j)); + return ChatJson.MAPPER.readValue(resultJson, ActionSignature.class); + } finally { + Reference.reachabilityFence(this); + } + } + private PreparedConversationChange callPrepare(String paramsJson, ParamsJsonFn fn) throws Exception { try (Memory j = FfiStrings.utf8(paramsJson)) { String resultJson = FfiStrings.consume(fn.apply(handle, j)); @@ -625,6 +644,12 @@ public SendPayload encryptRemoveReaction(EncryptReactionParams parameters) throw return callEncrypt(buildReactionJson(parameters), ChatNative.INSTANCE::chat_xdk_encrypt_remove_reaction); } + /** Encrypt a message edit for the X API. */ + public SendPayload encryptEdit(EncryptEditParams parameters) throws Exception { + throwIfDisposed(); + return callEncrypt(buildEncryptEditJson(parameters), ChatNative.INSTANCE::chat_xdk_encrypt_edit); + } + @FunctionalInterface private interface ParamsJsonFn { FfiResult.ByValue apply(Pointer h, Pointer json); @@ -957,6 +982,35 @@ private static String buildReactionJson(EncryptReactionParams p) throws Exceptio return ChatJson.MAPPER.writeValueAsString(n); } + private static String buildEncryptEditJson(EncryptEditParams p) throws Exception { + ObjectNode n = ChatJson.MAPPER.createObjectNode(); + n.put("updated_text", p.updatedText); + if (p.targetEvent != null) { + n.put("target_event", p.targetEvent); + } + if (p.entities != null) { + n.set("entities", ChatJson.MAPPER.valueToTree(serializeEntities(p.entities))); + } + if (p.conversationId != null) { + n.put("conversation_id", p.conversationId); + } + if (p.targetMessageSequenceId != null) { + n.put("target_message_sequence_id", p.targetMessageSequenceId); + } + putIdentityAndKey(n, p.senderId, p.signingKeyVersion, p.conversationKey, p.conversationKeyVersion); + return ChatJson.MAPPER.writeValueAsString(n); + } + + private static String buildPrepareMessageDeleteJson(MessageDeleteParams p) throws Exception { + ObjectNode n = ChatJson.MAPPER.createObjectNode(); + n.put("conversation_id", p.conversationId); + n.set("sequence_ids", ChatJson.MAPPER.valueToTree( + p.sequenceIds == null ? List.of() : p.sequenceIds)); + n.put("delete_for_all", p.deleteForAll); + putIdentityAndKey(n, p.senderId, p.signingKeyVersion, null, null); + return ChatJson.MAPPER.writeValueAsString(n); + } + private static String buildPrepareConversationKeyChangeJson(ConversationKeyChangeParams p) throws Exception { ObjectNode n = ChatJson.MAPPER.createObjectNode(); diff --git a/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/ChatNative.java b/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/ChatNative.java index 489e055..027fdae 100644 --- a/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/ChatNative.java +++ b/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/ChatNative.java @@ -66,6 +66,8 @@ FfiResult.ByValue chat_xdk_import_keys_with_version( FfiResult.ByValue chat_xdk_prepare_group_create(Pointer handle, Pointer paramsJson); + FfiResult.ByValue chat_xdk_prepare_message_delete(Pointer handle, Pointer paramsJson); + FfiResult.ByValue chat_xdk_decrypt_event( Pointer handle, Pointer eventB64, Pointer conversationKeysJson, Pointer signingKeysJson); @@ -77,6 +79,8 @@ FfiResult.ByValue chat_xdk_decrypt_event( FfiResult.ByValue chat_xdk_encrypt_remove_reaction(Pointer handle, Pointer paramsJson); + FfiResult.ByValue chat_xdk_encrypt_edit(Pointer handle, Pointer paramsJson); + FfiResult.ByValue chat_xdk_decrypt_conversation_key(Pointer handle, Pointer encryptedKeyB64); FfiResult.ByValue chat_xdk_encrypt(Pointer handle, Pointer plaintext, Pointer conversationKeyB64); diff --git a/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Types.java b/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Types.java index 25d4512..a2ee0a4 100644 --- a/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Types.java +++ b/crates/jvm/java/chatxdk/src/main/java/com/x/chatxdk/Types.java @@ -128,7 +128,7 @@ public static final class SendPayload { public boolean shouldNotify; } - /** Signed action payload authenticating a conversation key change or member add. */ + /** Signed action payload authenticating a conversation key change, member add, or message delete. */ public static final class ActionSignature { /** ID of the message carrying the action. */ @JsonProperty("message_id") @@ -661,6 +661,59 @@ public EncryptReactionParams(String targetEvent, String emoji) { } } + /** + * Parameters for {@link Chat#encryptEdit}. + * + *

The preferred form passes the raw event of the message being edited + * ({@code targetEvent}) and lets the SDK derive the conversation id and + * target sequence id from it; the explicit field overrides remain for + * callers that no longer hold the raw event. + */ + public static final class EncryptEditParams { + /** Base64 raw event being edited; the conversation id and target sequence id are derived from it. */ + public final String targetEvent; + + /** The replacement message text. */ + public final String updatedText; + + /** Rich-text entities for the replacement text; null clears any entities the original carried. */ + public List entities; + + /** ID of the conversation the edit belongs to; derived from {@link #targetEvent} when null. */ + public String conversationId; + + /** Sequence ID of the message being edited; derived from {@link #targetEvent} when null. */ + public String targetMessageSequenceId; + + /** User ID of the sender; resolves from the session identity when null. */ + public String senderId; + + /** Version of the sender's signing key; resolves from the session identity when null. */ + public String signingKeyVersion; + + /** + * Raw 32-byte conversation key; resolves from the key cache when null + * (set together with {@link #conversationKeyVersion}). + */ + public byte[] conversationKey; + + /** Version of the conversation key; resolves from the key cache when null. */ + public String conversationKeyVersion; + + /** + * Create params with the required fields; all optional fields default to null. + * + * @param targetEvent Base64 raw event being edited. Pass null or "" + * only when supplying {@link #conversationId} and + * {@link #targetMessageSequenceId} directly instead. + * @param updatedText The replacement message text. + */ + public EncryptEditParams(String targetEvent, String updatedText) { + this.targetEvent = targetEvent == null || targetEvent.isEmpty() ? null : targetEvent; + this.updatedText = updatedText; + } + } + /** Parameters for {@link Chat#prepareConversationKeyChange}. */ public static final class ConversationKeyChangeParams { /** Public keys for every participant the new key is encrypted for. */ @@ -797,4 +850,42 @@ public GroupCreateParams( this.adminIds = adminIds; } } + + /** + * Parameters for {@link Chat#prepareMessageDelete}. + * + *

A delete is a signed plaintext event, not an encrypted message, so no + * conversation key is involved: the result is an action signature the + * caller submits alongside the delete request. + */ + public static final class MessageDeleteParams { + /** ID of the conversation the messages belong to. */ + public final String conversationId; + + /** Sequence IDs of the messages to delete. */ + public final List sequenceIds; + + /** Delete for every participant (true, own messages only) or only from the caller's view (false). */ + public final boolean deleteForAll; + + /** User ID of the sender signing the delete; resolves from the session identity when null. */ + public String senderId; + + /** Version of the sender's signing key; resolves from the session identity when null. */ + public String signingKeyVersion; + + /** + * Create params with the required fields; all optional fields default to null. + * + * @param conversationId ID of the conversation the messages belong to. + * @param sequenceIds Sequence IDs of the messages to delete. + * @param deleteForAll Delete for every participant (true, own messages + * only) or only from the caller's view (false). + */ + public MessageDeleteParams(String conversationId, List sequenceIds, boolean deleteForAll) { + this.conversationId = conversationId; + this.sequenceIds = sequenceIds; + this.deleteForAll = deleteForAll; + } + } } 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 3c76ec5..890265b 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 @@ -365,6 +365,65 @@ void encryptRemoveReactionReturnsValidPayload() throws Exception { } } + @Test + void encryptEditReturnsValidPayload() throws Exception { + try (Chat chat = createUnlocked()) { + byte[] ckey = newConvKey(chat); + // Explicit-field form: conversation id + target sequence id instead + // of the raw target event. + EncryptEditParams p = new EncryptEditParams(null, "see https://example.com"); + EntityDescriptor url = new EntityDescriptor(); + url.start = 4; + url.end = 23; + url.entityType = "url"; + p.entities = List.of(url); + p.conversationId = "conv-1"; + p.targetMessageSequenceId = "seq-99"; + p.senderId = "111"; + p.signingKeyVersion = "s1"; + p.conversationKey = ckey; + p.conversationKeyVersion = "v1"; + SendPayload payload = chat.encryptEdit(p); + assertFalse(payload.messageId.isEmpty()); + assertFalse(payload.encryptedContent.isEmpty()); + assertFalse(payload.signature.isEmpty()); + assertFalse(payload.encodedEventSignature.isEmpty()); + } + } + + @Test + void prepareMessageDeleteSignsCanonicalPayload() throws Exception { + try (Chat chat = createUnlocked()) { + // A 1:1 id is signed in its canonical colon form; delete-for-all + // signs the wire action 2. + MessageDeleteParams p = new MessageDeleteParams("222-111", List.of("seq-10", "seq-11"), true); + p.senderId = "111"; + p.signingKeyVersion = "1"; + ActionSignature sig = chat.prepareMessageDelete(p); + assertFalse(sig.messageId.isEmpty()); + assertFalse(sig.encodedMessageEventDetail.isEmpty()); + assertFalse(sig.signature.isEmpty()); + assertEquals( + "MessageDeleteEvent," + sig.messageId + ",111,111:222,2,seq-10,seq-11", + sig.signaturePayload); + } + } + + @Test + void prepareMessageDeleteForSelf() throws Exception { + try (Chat chat = createUnlocked()) { + // Group ids pass through unchanged; delete-for-self signs the wire + // action 1. + MessageDeleteParams p = new MessageDeleteParams("g999", List.of("seq-1"), false); + p.senderId = "111"; + p.signingKeyVersion = "1"; + ActionSignature sig = chat.prepareMessageDelete(p); + assertEquals( + "MessageDeleteEvent," + sig.messageId + ",111,g999,1,seq-1", + sig.signaturePayload); + } + } + @Test void utilitiesHexRoundTrip() { byte[] raw = {(byte) 0xde, (byte) 0xad, (byte) 0xbe, (byte) 0xef}; diff --git a/crates/pyo3/python/chat_xdk/__init__.pyi b/crates/pyo3/python/chat_xdk/__init__.pyi index 2637423..d74e0a1 100644 --- a/crates/pyo3/python/chat_xdk/__init__.pyi +++ b/crates/pyo3/python/chat_xdk/__init__.pyi @@ -1,6 +1,6 @@ """Type stubs for chat-xdk Python bindings.""" -from typing import Optional, Union +from typing import Optional, Union, overload __version__: str @@ -117,6 +117,15 @@ class Chat: avatar_url: Optional[str] = None, ttl_msec: Optional[int] = None, ) -> dict: ... + def prepare_message_delete( + self, + conversation_id: str, + sequence_ids: list[str], + delete_for_all: bool, + *, + sender_id: Optional[str] = None, + signing_key_version: Optional[str] = None, + ) -> dict: ... # Events def extract_conversation_keys(self, events: list[str]) -> dict: ... @@ -193,6 +202,34 @@ class Chat: conversation_key: Optional[bytes] = None, conversation_key_version: Optional[str] = None, ) -> SendPayload: ... + # `updated_text` is required at runtime (missing it raises TypeError) + # even though it sits after the optional `target_event` positional; the + # overloads keep static checkers flagging calls that omit it. + @overload + def encrypt_edit( + self, + target_event: str, + updated_text: str, + *, + entities: Optional[list[tuple[int, int, str]]] = None, + sender_id: Optional[str] = None, + signing_key_version: Optional[str] = None, + conversation_key: Optional[bytes] = None, + conversation_key_version: Optional[str] = None, + ) -> SendPayload: ... + @overload + def encrypt_edit( + self, + *, + updated_text: str, + conversation_id: str, + target_message_sequence_id: str, + entities: Optional[list[tuple[int, int, str]]] = None, + sender_id: Optional[str] = None, + signing_key_version: Optional[str] = None, + conversation_key: Optional[bytes] = None, + conversation_key_version: Optional[str] = None, + ) -> SendPayload: ... # Streams def encrypt_stream(self, plaintext: bytes, conversation_key: bytes) -> bytes: ... diff --git a/crates/pyo3/python/tests/test_api.py b/crates/pyo3/python/tests/test_api.py index 35c820e..f21b405 100644 --- a/crates/pyo3/python/tests/test_api.py +++ b/crates/pyo3/python/tests/test_api.py @@ -329,6 +329,40 @@ def test_encrypt_remove_reaction_shape(self): self.assertTrue(payload.signature) self.assertTrue(payload.encoded_event_signature) + def test_encrypt_edit_shape(self): + chat, v = self._unlocked_chat() + + conv_key = base64.b64decode(v["conversation_key_b64"]) + payload = chat.encrypt_edit( + None, "see https://example.com", + entities=[(4, 23, "url")], + conversation_id="conv-1", + target_message_sequence_id="seq-99", + sender_id="111", + signing_key_version="1", + conversation_key=conv_key, + conversation_key_version="1", + ) + + self.assertTrue(payload.encrypted_content) + self.assertTrue(payload.signature) + self.assertTrue(payload.encoded_event_signature) + self.assertTrue(payload.message_id) + + def test_encrypt_edit_requires_updated_text(self): + chat, v = self._unlocked_chat() + + conv_key = base64.b64decode(v["conversation_key_b64"]) + with self.assertRaises(TypeError): + chat.encrypt_edit( + conversation_id="conv-1", + target_message_sequence_id="seq-99", + sender_id="me", + signing_key_version="1", + conversation_key=conv_key, + conversation_key_version="1", + ) + def test_prepare_conversation_key_change_shape(self): chat, v = self._unlocked_chat() @@ -492,6 +526,45 @@ def test_prepare_group_create_empty_title_signs_as_omitted(self): f"title/avatar must sign as the null sentinel, got: {payload}", ) + def test_prepare_message_delete_shape(self): + chat, v = self._unlocked_chat() + + # A 1:1 id is signed in its canonical colon form; delete-for-all + # signs the wire action 2. + sig = chat.prepare_message_delete( + "222-111", + ["seq-10", "seq-11"], + True, + sender_id="111", + signing_key_version="1", + ) + + self.assertTrue(sig["message_id"]) + self.assertTrue(sig["encoded_message_event_detail"]) + self.assertTrue(sig["signature"]) + self.assertEqual( + sig["signature_payload"], + f"MessageDeleteEvent,{sig['message_id']},111,111:222,2,seq-10,seq-11", + ) + + def test_prepare_message_delete_for_self(self): + chat, v = self._unlocked_chat() + + # Group ids pass through unchanged; delete-for-self signs the wire + # action 1. + sig = chat.prepare_message_delete( + "g999", + ["seq-1"], + False, + sender_id="111", + signing_key_version="1", + ) + + self.assertEqual( + sig["signature_payload"], + f"MessageDeleteEvent,{sig['message_id']},111,g999,1,seq-1", + ) + def test_optional_arguments_are_keyword_only(self): chat, v = self._unlocked_chat() diff --git a/crates/pyo3/src/lib.rs b/crates/pyo3/src/lib.rs index 3a06a36..05434dd 100644 --- a/crates/pyo3/src/lib.rs +++ b/crates/pyo3/src/lib.rs @@ -33,8 +33,9 @@ use std::sync::Arc; use chat_xdk_core::keys::juicebox::{JuiceboxClient, JuiceboxConfig}; use chat_xdk_core::{ - AttachmentDescriptor, ConversationKeyChangeParams, EncryptMessageParams, EncryptReactionParams, - EncryptReplyParams, EntityDescriptor, GroupCreateParams, GroupMembersChangeParams, + AttachmentDescriptor, ConversationKeyChangeParams, EncryptEditParams, EncryptMessageParams, + EncryptReactionParams, EncryptReplyParams, EntityDescriptor, GroupCreateParams, + GroupMembersChangeParams, MessageDeleteParams, }; /// Parse the X API Juicebox config JSON into a [`JuiceboxConfig`]. @@ -216,27 +217,35 @@ fn prepared_change_to_pydict( let action_signatures = pyo3::types::PyList::empty(py); for sig in &result.action_signatures { - let dict = pyo3::types::PyDict::new(py); - dict.set_item("message_id", &sig.message_id)?; - dict.set_item( - "encoded_message_event_detail", - &sig.encoded_message_event_detail, - )?; - dict.set_item("signature", &sig.signature)?; - dict.set_item("signature_version", &sig.signature_version)?; - dict.set_item("public_key_version", &sig.public_key_version)?; - // Absent for conversation-key changes: the signed payload embeds the - // plaintext conversation key and is withheld. - if !sig.signature_payload.is_empty() { - dict.set_item("signature_payload", &sig.signature_payload)?; - } - action_signatures.append(dict)?; + action_signatures.append(action_signature_to_pydict(py, sig)?)?; } outer.set_item("action_signatures", action_signatures)?; Ok(outer.into()) } +/// Build the Python dict for a signed action. +fn action_signature_to_pydict<'py>( + py: Python<'py>, + sig: &chat_xdk_core::signatures::ActionSignature, +) -> PyResult> { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("message_id", &sig.message_id)?; + dict.set_item( + "encoded_message_event_detail", + &sig.encoded_message_event_detail, + )?; + dict.set_item("signature", &sig.signature)?; + dict.set_item("signature_version", &sig.signature_version)?; + dict.set_item("public_key_version", &sig.public_key_version)?; + // Absent for conversation-key changes: the signed payload embeds the + // plaintext conversation key and is withheld. + if !sig.signature_payload.is_empty() { + dict.set_item("signature_payload", &sig.signature_payload)?; + } + Ok(dict) +} + /// Convert the core SendPayload to the Python SendPayload with matching shape. fn to_py_send_payload(payload: chat_xdk_core::SendPayload) -> SendPayload { SendPayload { @@ -977,6 +986,71 @@ impl Chat { Ok(to_py_send_payload(payload)) } + /// Encrypt a message edit for the X API. + /// + /// The preferred form passes ``target_event`` — the base64 raw event of + /// the message being edited — so the conversation id and target sequence + /// id are derived from it. The explicit overrides remain for callers that + /// no longer hold the raw event. + /// + /// The SDK generates the message id and returns it in the payload; read it + /// back from ``payload.message_id`` (do not pass one in). + /// + /// Args: + /// target_event: Base64 raw event being edited. Omit it only when + /// supplying ``conversation_id`` and ``target_message_sequence_id``. + /// updated_text: The replacement message text. + /// entities: Optional list of (start, end, entity_type) tuples for + /// rich-text in the replacement text; omitting clears any entities + /// the original carried. Keyword-only, like every optional + /// argument below it. + /// conversation_id: The conversation ID; derived from ``target_event`` + /// when omitted. + /// target_message_sequence_id: Message sequence ID being edited; + /// derived from ``target_event`` when omitted. + /// sender_id: Your user ID; defaults to the ``set_identity`` value. + /// signing_key_version: Your registered signing public key version; + /// defaults to the ``set_identity`` value. + /// conversation_key: Raw 32-byte conversation key (``bytes``). Pass it + /// together with ``conversation_key_version``, or omit both to + /// resolve from the opt-in ``set_cache_keys`` cache. + /// conversation_key_version: Current conversation key version. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (target_event=None, updated_text=None, *, entities=None, conversation_id=None, target_message_sequence_id=None, sender_id=None, signing_key_version=None, conversation_key=None, conversation_key_version=None))] + fn encrypt_edit( + &self, + target_event: Option, + updated_text: Option, + entities: Option>, + conversation_id: Option, + target_message_sequence_id: Option, + sender_id: Option, + signing_key_version: Option, + conversation_key: Option>, + conversation_key_version: Option, + ) -> PyResult { + // `updated_text` sits after the optional `target_event` positional, + // so it carries a `None` sentinel instead of being required in the + // pyo3 signature; a missing text raises `TypeError` like any other + // missing required argument. + let updated_text = updated_text.ok_or_else(|| { + PyErr::new::( + "missing required argument: 'updated_text'", + ) + })?; + let mut params = EncryptEditParams::new(target_event.unwrap_or_default(), updated_text); + params.entities = entities.map(parse_entity_descs); + params.conversation_id = conversation_id; + params.target_message_sequence_id = target_message_sequence_id; + params.sender_id = sender_id; + params.signing_key_version = signing_key_version; + params.conversation_key = conversation_key; + params.conversation_key_version = conversation_key_version; + let payload = self.inner.encrypt_edit(¶ms).map_err(to_py_err)?; + + Ok(to_py_send_payload(payload)) + } + /// Decrypt an encrypted conversation key (ECIES). /// /// Call this once per key version, then pass the result to ``decrypt_event()``. @@ -1276,6 +1350,46 @@ impl Chat { Python::attach(|py| prepared_change_to_pydict(py, &result)) } + /// Build the signed action for deleting messages from a conversation. + /// + /// A delete is a signed plaintext event, not an encrypted message, so no + /// conversation key is involved: the result is a single action-signature + /// dict to submit alongside the delete request. The SDK generates the + /// action's message id; read it back from the result. + /// + /// Args: + /// conversation_id: The conversation the messages belong to. + /// sequence_ids: The ``sequence_id``s of the messages to delete. + /// delete_for_all: Delete for every participant (``True``, own + /// messages only) or only from the caller's view (``False``). + /// sender_id: Your user ID; defaults to the ``set_identity`` value. + /// Keyword-only, like every optional argument below it. + /// signing_key_version: Your registered signing public key version; + /// defaults to the ``set_identity`` value. + /// + /// Returns: + /// A dict with ``message_id``, ``encoded_message_event_detail``, + /// ``signature``, ``signature_version``, ``public_key_version``, + /// ``signature_payload``. + #[pyo3(signature = (conversation_id, sequence_ids, delete_for_all, *, sender_id=None, signing_key_version=None))] + fn prepare_message_delete( + &self, + conversation_id: &str, + sequence_ids: Vec, + delete_for_all: bool, + sender_id: Option, + signing_key_version: Option, + ) -> PyResult> { + let mut params = MessageDeleteParams::new(conversation_id, sequence_ids, delete_for_all); + params.sender_id = sender_id; + params.signing_key_version = signing_key_version; + let signature = self + .inner + .prepare_message_delete(¶ms) + .map_err(to_py_err)?; + Python::attach(|py| Ok(action_signature_to_pydict(py, &signature)?.into())) + } + /// Export private keys as raw bytes for secure storage. /// /// Warning: Handle with care — these are your secret keys! diff --git a/crates/wasm/js/index.d.ts b/crates/wasm/js/index.d.ts index 5f460a1..7797a2e 100644 --- a/crates/wasm/js/index.d.ts +++ b/crates/wasm/js/index.d.ts @@ -64,7 +64,7 @@ export interface SendPayload { } /** - * An action signature authenticating a conversation key change or member add. + * An action signature authenticating a conversation key change, member add, or message delete. */ export interface ActionSignature { messageId: string; @@ -72,7 +72,11 @@ export interface ActionSignature { signature: string; signatureVersion: string; publicKeyVersion: string; - /** Omitted for conversation-key changes; the signed payload embeds the plaintext conversation key. */ + /** + * The comma-separated payload string that was signed; populated for group + * changes and message deletes. Omitted for conversation-key changes, whose + * payload embeds the plaintext conversation key. + */ signaturePayload?: string; } @@ -318,6 +322,58 @@ export interface EncryptReactionParams { conversationKeyVersion?: string | null; } +/** + * Parameters for encryptEdit. + * + * The preferred form passes `targetEvent` — the base64 raw event of the + * message being edited — and lets the SDK derive the conversation id and + * target sequence id from it. + */ +export interface EncryptEditParams { + /** The replacement message text. */ + updatedText: string; + /** Rich-text entities for the replacement text; omitting clears any entities the original carried. */ + entities?: EntityTuple[] | null; + /** Base64 of the raw event being edited; the conversation id and target sequence id are derived from it. */ + targetEvent?: string | null; + /** ID of the conversation the edit belongs to; derived from targetEvent when omitted. */ + conversationId?: string | null; + /** The sequenceId of the message being edited; derived from targetEvent when omitted. */ + targetMessageSequenceId?: string | null; + /** User ID of the sender; resolves from the session identity (setIdentity) when omitted. */ + senderId?: string | null; + /** Version of the signing key used to sign the message; resolves from the session identity when omitted. */ + signingKeyVersion?: string | null; + /** + * Raw 32-byte conversation key used to encrypt the edit content; + * resolves from the opt-in key cache (setCacheKeys) when omitted. Set + * together with conversationKeyVersion. + */ + conversationKey?: Uint8Array | null; + /** Version of the conversation key used for encryption; resolves from the key cache when omitted. */ + conversationKeyVersion?: string | null; +} + +/** + * Parameters for prepareMessageDelete. + * + * A delete is a signed plaintext event, not an encrypted message, so no + * conversation key is involved: the result is an action signature the + * caller submits alongside the delete request. + */ +export interface MessageDeleteParams { + /** ID of the conversation the messages belong to. */ + conversationId: string; + /** The sequenceIds of the messages to delete. */ + sequenceIds: string[]; + /** Delete for every participant (true, own messages only) or only from the caller's view (false). */ + deleteForAll: boolean; + /** User ID of the sender signing the delete; resolves from the session identity (setIdentity) when omitted. */ + senderId?: string | null; + /** Version of the signing key used to sign the delete; resolves from the session identity when omitted. */ + signingKeyVersion?: string | null; +} + /** * Parameters for prepareConversationKeyChange. */ @@ -535,7 +591,7 @@ export interface MessageContent { emoji?: string; /** Present on 'reaction', 'reactionRemoved', and 'edit' content. */ targetMessageId?: string; - /** Present on 'text' content: rich-text entities. */ + /** Present on 'text' and 'edit' content: rich-text entities. */ entities?: unknown[]; /** Present on 'text' content: message attachments. */ attachments?: unknown[]; @@ -799,6 +855,12 @@ interface ChatCrypto { /** Encrypt a reaction-remove. */ encryptRemoveReaction(params: EncryptReactionParams): SendPayload; + /** Encrypt a message edit. */ + encryptEdit(params: EncryptEditParams): SendPayload; + + /** Build the signed action for deleting messages from a conversation. */ + prepareMessageDelete(params: MessageDeleteParams): ActionSignature; + /** Encrypt a stream (e.g. media). */ encryptStream(plaintext: Uint8Array, conversationKey: Uint8Array): Uint8Array; @@ -894,6 +956,8 @@ declare class Chat implements ChatCrypto { encryptReply(params: EncryptReplyParams): SendPayload; encryptAddReaction(params: EncryptReactionParams): SendPayload; encryptRemoveReaction(params: EncryptReactionParams): SendPayload; + encryptEdit(params: EncryptEditParams): SendPayload; + prepareMessageDelete(params: MessageDeleteParams): ActionSignature; encryptStream(plaintext: Uint8Array, conversationKey: Uint8Array): Uint8Array; decryptStream(encrypted: Uint8Array, conversationKey: Uint8Array): Uint8Array; streamEncryptor(conversationKey: Uint8Array): StreamEncryptor; @@ -950,6 +1014,8 @@ export declare class ChatWithJuicebox implements ChatCrypto { encryptReply(params: EncryptReplyParams): SendPayload; encryptAddReaction(params: EncryptReactionParams): SendPayload; encryptRemoveReaction(params: EncryptReactionParams): SendPayload; + encryptEdit(params: EncryptEditParams): SendPayload; + prepareMessageDelete(params: MessageDeleteParams): ActionSignature; encryptStream(plaintext: Uint8Array, conversationKey: Uint8Array): Uint8Array; decryptStream(encrypted: Uint8Array, conversationKey: Uint8Array): Uint8Array; streamEncryptor(conversationKey: Uint8Array): StreamEncryptor; diff --git a/crates/wasm/js/index.js b/crates/wasm/js/index.js index 44ad396..a47dc43 100644 --- a/crates/wasm/js/index.js +++ b/crates/wasm/js/index.js @@ -419,6 +419,8 @@ export class ChatWithJuicebox { encryptReply(params) { return this.#inner.encryptReply(params); } encryptAddReaction(params) { return this.#inner.encryptAddReaction(params); } encryptRemoveReaction(params) { return this.#inner.encryptRemoveReaction(params); } + encryptEdit(params) { return this.#inner.encryptEdit(params); } + prepareMessageDelete(params) { return this.#inner.prepareMessageDelete(params); } encryptStream(plaintext, key) { return this.#inner.encryptStream(plaintext, key); } decryptStream(encrypted, key) { return this.#inner.decryptStream(encrypted, key); } streamEncryptor(key) { return this.#inner.streamEncryptor(key); } diff --git a/crates/wasm/js/tests/api.test.mjs b/crates/wasm/js/tests/api.test.mjs index 2a67b54..3017edf 100644 --- a/crates/wasm/js/tests/api.test.mjs +++ b/crates/wasm/js/tests/api.test.mjs @@ -382,6 +382,81 @@ async function main() { assert.ok(removePayload.signature.length > 0); assert.ok(removePayload.encodedEventSignature.length > 0); + // 4c2. encryptEdit produces a signed payload whose encrypted contents + // decrypt back to the edit: updated text, target sequence id, and the + // [start, end, type] entity tuple all survive the WASM boundary. + const editPayload = chat.encryptEdit({ + senderId: "111", + conversationId: "conv-1", + conversationKey: convKey, + targetMessageSequenceId: "seq-99", + updatedText: "see https://example.com", + entities: [[4, 23, "url"]], + conversationKeyVersion: "1", + signingKeyVersion: "1", + }); + assert.ok(editPayload.encryptedContent.length > 0); + assert.ok(editPayload.signature.length > 0); + assert.ok(editPayload.encodedEventSignature.length > 0); + assert.ok(editPayload.messageId.length > 0); + // Same extraction as 4b2: the ciphertext is the `contents` binary field + // (id 100) of the Thrift MessageCreateEvent, and the edit plaintext is + // all-ASCII Thrift, so chat.decrypt returns it intact as a string. + const editEventBytes = b64ToBytes(editPayload.encryptedContent); + assert.equal(editEventBytes[0], 0x0b); + assert.equal((editEventBytes[1] << 8) | editEventBytes[2], 100); + const editCtLen = + (editEventBytes[3] << 24) | + (editEventBytes[4] << 16) | + (editEventBytes[5] << 8) | + editEventBytes[6]; + const editCiphertext = editEventBytes.subarray(7, 7 + editCtLen); + const decodedEdit = chat.decrypt(bytesToBase64(editCiphertext), convKey); + assert.ok( + decodedEdit.includes("see https://example.com"), + "decrypted edit content must embed the updated text", + ); + assert.ok( + decodedEdit.includes("seq-99"), + "decrypted edit content must embed the target sequence id", + ); + // Thrift RichTextEntity serializes field 1 (i32 startIndex) then field 2 + // (i32 endIndex): the [4, 23, "url"] tuple must land as start=4, end=23. + assert.ok( + decodedEdit.includes( + "\u0008\u0000\u0001\u0000\u0000\u0000\u0004\u0008\u0000\u0002\u0000\u0000\u0000\u0017", + ), + "decrypted edit content must carry the entity's start/end indexes", + ); + + // 4c3. prepareMessageDelete returns a signed action with the encoded + // MessageDeleteEvent detail; 1:1 ids are signed in canonical colon form. + const deleteSig = chat.prepareMessageDelete({ + senderId: "111", + signingKeyVersion: "1", + conversationId: "222-111", + sequenceIds: ["seq-10", "seq-11"], + deleteForAll: true, + }); + assert.ok(deleteSig.messageId.length > 0); + assert.ok(deleteSig.encodedMessageEventDetail.length > 0); + assert.ok(deleteSig.signature.length > 0); + assert.equal( + deleteSig.signaturePayload, + `MessageDeleteEvent,${deleteSig.messageId},111,111:222,2,seq-10,seq-11`, + ); + const deleteForSelfSig = chat.prepareMessageDelete({ + senderId: "111", + signingKeyVersion: "1", + conversationId: "g999", + sequenceIds: ["seq-1"], + deleteForAll: false, + }); + assert.equal( + deleteForSelfSig.signaturePayload, + `MessageDeleteEvent,${deleteForSelfSig.messageId},111,g999,1,seq-1`, + ); + // 4d. prepareGroupMembersChange emits two action signatures (CKCE + member // add) with populated encoded event details and a fresh raw key. const membersPrep = chat.prepareGroupMembersChange({ diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 0d5ae15..7cd3e5a 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -35,8 +35,8 @@ use chat_xdk_core::crypto::keys::XChatConversationKey; // Import generated camelCase JS types from core use chat_xdk_core::js::{ - JsEvent, JsPreparedConversationChange, JsPublicKeyInput, JsPublicKeyRegistrationPayload, - JsSendPayload, JsSigningKeyEntry, + JsActionSignature, JsEvent, JsPreparedConversationChange, JsPublicKeyInput, + JsPublicKeyRegistrationPayload, JsSendPayload, JsSigningKeyEntry, }; // Utility Functions (free functions, not methods) @@ -535,6 +535,48 @@ impl Chat { to_js_value(&js_payload) } + /// Encrypt a message edit. + /// + /// Takes a single params object with camelCase keys: `updatedText`, + /// plus `targetEvent` — the base64 raw event being edited, from which the + /// conversation id and target sequence id are derived — or explicit + /// `conversationId` / `targetMessageSequenceId` overrides. `entities` + /// (array of `[start, end, "type"]` tuples) describes the replacement + /// text; `senderId`, `signingKeyVersion`, `conversationKey` (Uint8Array), + /// and `conversationKeyVersion` resolve from the session identity and key + /// cache when omitted. The SDK generates the message id and returns it as + /// `messageId` on the result. + #[wasm_bindgen(js_name = encryptEdit)] + pub fn encrypt_edit(&self, params: JsValue) -> Result { + let p: JsEncryptEditParams = from_js_params("encryptEdit", params)?; + let payload = self + .inner + .encrypt_edit(&p.into_core()) + .map_err(|e| JsError::new(&format!("{}", e)))?; + let js_payload: JsSendPayload = payload.into(); + to_js_value(&js_payload) + } + + /// Build the signed action for deleting messages from a conversation. + /// + /// Takes a single params object with camelCase keys: `conversationId`, + /// `sequenceIds` (array of message sequence ids), `deleteForAll` + /// (boolean: every participant vs only the caller's view), plus optional + /// `senderId` / `signingKeyVersion` (resolved from the session identity + /// when omitted). Returns the action signature to submit alongside the + /// delete request; the SDK generates the action's message id + /// (`messageId` on the result). + #[wasm_bindgen(js_name = prepareMessageDelete)] + pub fn prepare_message_delete(&self, params: JsValue) -> Result { + let p: JsMessageDeleteParams = from_js_params("prepareMessageDelete", params)?; + let signature = self + .inner + .prepare_message_delete(&p.into_core()) + .map_err(|e| JsError::new(&format!("{}", e)))?; + let js_signature: JsActionSignature = signature.into(); + to_js_value(&js_signature) + } + /// Encrypt a stream (e.g. media). #[wasm_bindgen(js_name = encryptStream)] pub fn encrypt_stream( @@ -1050,6 +1092,60 @@ impl JsEncryptReactionParams { } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsEncryptEditParams { + updated_text: String, + target_event: Option, + entities: Option>, + conversation_id: Option, + target_message_sequence_id: Option, + sender_id: Option, + signing_key_version: Option, + conversation_key: Option>, + conversation_key_version: Option, +} + +impl JsEncryptEditParams { + fn into_core(self) -> chat_xdk_core::EncryptEditParams { + let mut params = chat_xdk_core::EncryptEditParams::new( + self.target_event.unwrap_or_default(), + self.updated_text, + ); + params.entities = self.entities.map(entity_tuples_to_descs); + params.conversation_id = self.conversation_id; + params.target_message_sequence_id = self.target_message_sequence_id; + params.sender_id = self.sender_id; + params.signing_key_version = self.signing_key_version; + params.conversation_key = self.conversation_key; + params.conversation_key_version = self.conversation_key_version; + params + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsMessageDeleteParams { + conversation_id: String, + sequence_ids: Vec, + delete_for_all: bool, + sender_id: Option, + signing_key_version: Option, +} + +impl JsMessageDeleteParams { + fn into_core(self) -> chat_xdk_core::MessageDeleteParams { + let mut params = chat_xdk_core::MessageDeleteParams::new( + self.conversation_id, + self.sequence_ids, + self.delete_for_all, + ); + params.sender_id = self.sender_id; + params.signing_key_version = self.signing_key_version; + params + } +} + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct JsConversationKeyChangeParams { diff --git a/docs/API.md b/docs/API.md index 93af94f..55a8779 100644 --- a/docs/API.md +++ b/docs/API.md @@ -672,6 +672,8 @@ type ConversationKeyMap = { [version: string]: Uint8Array }; | 27 | **encrypt_reply** | `encrypt_reply(EncryptReplyParams) → SendPayload` | `encryptReply(params: EncryptReplyParams) → SendPayload` | `encrypt_reply(conversation_id, text, reply_to_event=None, *, reply_to_edit_event=None, reply_to_ckces=None, reply_to_sequence_id=None, reply_to_sender_id=None, reply_to_text=None, reply_to_entities=None, reply_to_attachments=None, …encrypt_message keyword optionals…) → SendPayload` | `EncryptReply(params EncryptReplyParams) (*SendPayload, error)` | `encryptReply(EncryptReplyParams)` | `EncryptReply(EncryptReplyParams)` | | 28 | **encrypt_add_reaction** | `encrypt_add_reaction(&EncryptReactionParams) → SendPayload` | `encryptAddReaction(params: EncryptReactionParams) → SendPayload` | `encrypt_add_reaction(target_event, emoji, *, conversation_id=None, target_message_sequence_id=None, sender_id=None, signing_key_version=None, conversation_key=None, conversation_key_version=None) → SendPayload` | `EncryptAddReaction(params EncryptReactionParams) (*SendPayload, error)` | `encryptAddReaction(EncryptReactionParams)` | `EncryptAddReaction(EncryptReactionParams)` | | 29 | **encrypt_remove_reaction** | `encrypt_remove_reaction(&EncryptReactionParams) → SendPayload` | `encryptRemoveReaction(params: EncryptReactionParams) → SendPayload` | `encrypt_remove_reaction(…same args as encrypt_add_reaction…) → SendPayload` | `EncryptRemoveReaction(params EncryptReactionParams) (*SendPayload, error)` | `encryptRemoveReaction(EncryptReactionParams)` | `EncryptRemoveReaction(EncryptReactionParams)` | +| 30 | **encrypt_edit** | `encrypt_edit(&EncryptEditParams) → SendPayload` | `encryptEdit(params: EncryptEditParams) → SendPayload` | `encrypt_edit(target_event, updated_text, *, entities=None, conversation_id=None, target_message_sequence_id=None, sender_id=None, signing_key_version=None, conversation_key=None, conversation_key_version=None) → SendPayload` | `EncryptEdit(params EncryptEditParams) (*SendPayload, error)` | `encryptEdit(EncryptEditParams)` | `EncryptEdit(EncryptEditParams)` | +| 31 | **prepare_message_delete** | `prepare_message_delete(&MessageDeleteParams) → ActionSignature` | `prepareMessageDelete(params: MessageDeleteParams) → ActionSignature` | `prepare_message_delete(conversation_id, sequence_ids, delete_for_all, *, sender_id=None, signing_key_version=None) → dict` | `PrepareMessageDelete(params MessageDeleteParams) (*ActionSignature, error)` | `prepareMessageDelete(MessageDeleteParams)` | `PrepareMessageDelete(MessageDeleteParams)` | Required fields of `EncryptMessageParams` (all bindings, names per language idiom): `conversation_id` and `text`. Everything else is an optional override: @@ -733,6 +735,34 @@ being reacted to, and the SDK derives `conversation_id` and `target_message_sequence_id` from it; or set those two fields explicitly instead. +**Edits target an event the same way.** `EncryptEditParams` requires +`updated_text` — the replacement message text — plus the same target choice +as a reaction: `target_event` (the base64 raw event of the message being +edited) or the explicit `conversation_id` / `target_message_sequence_id` +pair. Optional `entities` describe rich text in the replacement text; +omitting them clears any entities the original carried. The edit is +encrypted and signed like a regular message (the identity/key overrides +above apply) and returns a `SendPayload` with its own fresh `message_id`. + +> **Delivery-race warning.** Receiving clients apply an edit to their stored +> copy of the original and park it when the original has not arrived yet; +> the backend stops serving a superseded original, so an edit that reaches a +> client before the original leaves the message permanently invisible on +> that client. Give a freshly sent message a few seconds to be delivered +> before sending its first edit. + +**Message deletes are signed, not encrypted.** `MessageDeleteParams` +requires `conversation_id`, the `sequence_ids` of the messages to delete, +and `delete_for_all` — `true` deletes for every participant (own messages +only), `false` only from the caller's view. No conversation key is involved: +`prepare_message_delete` returns a single `ActionSignature` (the same shape +as the prepare methods' `actionSignatures` entries, with its +`signature_payload` populated) whose `encodedMessageEventDetail` carries the +`MessageDeleteEvent`, ready to submit alongside the delete request. The +optional `sender_id` / `signing_key_version` overrides resolve from the +session identity, and a one-to-one id is signed in its canonical colon form, +as everywhere. + The Rust structs pair a `new(…required…)` constructor with public optional fields: @@ -790,14 +820,14 @@ reaction = chat.encrypt_add_reaction(original_event_b64, "👍") | # | Method | Rust | JS | Python | Go | JVM | .NET | |---|--------|------|-----|--------|-----|------|------| -| 30 | **encrypt_stream** | `(&[u8], &Key) → Vec` | `(Uint8Array, Uint8Array) → Uint8Array` | `(bytes, bytes) → bytes` | `EncryptStream(plaintext, conversationKey []byte) ([]byte, error)` | `encryptStream(byte[], byte[])` → `byte[]` | `EncryptStream(byte[], byte[])` → `byte[]` | -| 31 | **decrypt_stream** | `(&[u8], &Key) → Vec` | `(Uint8Array, Uint8Array) → Uint8Array` | `(bytes, bytes) → bytes` | `DecryptStream(encrypted, conversationKey []byte) ([]byte, error)` | `decryptStream(byte[], byte[])` → `byte[]` | `DecryptStream(byte[], byte[])` → `byte[]` | -| 32 | **stream_encryptor** | `(&Key) → StreamEncryptor` | `streamEncryptor(Uint8Array) → StreamEncryptor` | `stream_encryptor(bytes) → StreamEncryptor` | `StreamEncryptor(conversationKey []byte) (*StreamEncryptor, error)` | `streamEncryptor(byte[]) → StreamEncryptor` | `StreamEncryptor(byte[]) → StreamEncryptor` | -| 33 | **stream_decryptor** | `(&Key) → StreamDecryptor` | `streamDecryptor(Uint8Array) → StreamDecryptor` | `stream_decryptor(bytes) → StreamDecryptor` | `StreamDecryptor(conversationKey []byte) (*StreamDecryptor, error)` | `streamDecryptor(byte[]) → StreamDecryptor` | `StreamDecryptor(byte[]) → StreamDecryptor` | +| 32 | **encrypt_stream** | `(&[u8], &Key) → Vec` | `(Uint8Array, Uint8Array) → Uint8Array` | `(bytes, bytes) → bytes` | `EncryptStream(plaintext, conversationKey []byte) ([]byte, error)` | `encryptStream(byte[], byte[])` → `byte[]` | `EncryptStream(byte[], byte[])` → `byte[]` | +| 33 | **decrypt_stream** | `(&[u8], &Key) → Vec` | `(Uint8Array, Uint8Array) → Uint8Array` | `(bytes, bytes) → bytes` | `DecryptStream(encrypted, conversationKey []byte) ([]byte, error)` | `decryptStream(byte[], byte[])` → `byte[]` | `DecryptStream(byte[], byte[])` → `byte[]` | +| 34 | **stream_encryptor** | `(&Key) → StreamEncryptor` | `streamEncryptor(Uint8Array) → StreamEncryptor` | `stream_encryptor(bytes) → StreamEncryptor` | `StreamEncryptor(conversationKey []byte) (*StreamEncryptor, error)` | `streamEncryptor(byte[]) → StreamEncryptor` | `StreamEncryptor(byte[]) → StreamEncryptor` | +| 35 | **stream_decryptor** | `(&Key) → StreamDecryptor` | `streamDecryptor(Uint8Array) → StreamDecryptor` | `stream_decryptor(bytes) → StreamDecryptor` | `StreamDecryptor(conversationKey []byte) (*StreamDecryptor, error)` | `streamDecryptor(byte[]) → StreamDecryptor` | `StreamDecryptor(byte[]) → StreamDecryptor` | -Methods 30/31 take the **decrypted** conversation key (raw bytes) and process the whole payload in memory. +Methods 32/33 take the **decrypted** conversation key (raw bytes) and process the whole payload in memory. -Methods 32/33 return an incremental object for large payloads: feed chunks with `push`, then call `finish` once. `finish` errors if the stream ended before its final frame (truncation detection), so output from `push` must not be treated as complete until `finish` succeeds. The native bindings (Go, JVM, .NET) free the object via `Close`/`close`/`Dispose`. In JS/WASM, `finish()` consumes and frees the object; call `free()` only when abandoning a stream without finishing it (calling it after `finish()` throws). +Methods 34/35 return an incremental object for large payloads: feed chunks with `push`, then call `finish` once. `finish` errors if the stream ended before its final frame (truncation detection), so output from `push` must not be treated as complete until `finish` succeeds. The native bindings (Go, JVM, .NET) free the object via `Close`/`close`/`Dispose`. In JS/WASM, `finish()` consumes and frees the object; call `free()` only when abandoning a stream without finishing it (calling it after `finish()` throws). **Picking the key.** A conversation can have multiple key versions over its lifetime, and every payload decrypts only with the key of the version it was encrypted under. Each decrypted message event carries that version (`keyVersion` / `key_version`), and `decryptEvents` returns the full version → key map — always select the key by the event's version rather than assuming a single conversation key: @@ -810,8 +840,8 @@ const plaintext = chat.decryptStream(encryptedMedia, key); | # | Method | Rust | JS | Python | Go | JVM | .NET | |---|--------|------|-----|--------|-----|------|------| -| 34 | **encrypt** | `(&str, &Key) → String` | `(string, Uint8Array) → string` | `(str, bytes) → str` | `Encrypt(plaintext string, conversationKey []byte) (string, error)` — returns base64 ciphertext | `encrypt(String, byte[])` → `String` | `Encrypt(string, byte[])` → `string` | -| 35 | **decrypt** | `(&str, &Key) → String` | `(string, Uint8Array) → string` | `(str, bytes) → str` | `Decrypt(ciphertextB64 string, conversationKey []byte) (string, error)` — UTF-8 plaintext | `decrypt(String, byte[])` → `String` | `Decrypt(string, byte[])` → `string` | +| 36 | **encrypt** | `(&str, &Key) → String` | `(string, Uint8Array) → string` | `(str, bytes) → str` | `Encrypt(plaintext string, conversationKey []byte) (string, error)` — returns base64 ciphertext | `encrypt(String, byte[])` → `String` | `Encrypt(string, byte[])` → `string` | +| 37 | **decrypt** | `(&str, &Key) → String` | `(string, Uint8Array) → string` | `(str, bytes) → str` | `Decrypt(ciphertextB64 string, conversationKey []byte) (string, error)` — UTF-8 plaintext | `decrypt(String, byte[])` → `String` | `Decrypt(string, byte[])` → `string` | Encrypt/decrypt arbitrary UTF-8 strings using XSalsa20-Poly1305 with a conversation key. Use for encrypted metadata fields like `group_name`, `group_avatar_url`, and `group_description` returned by the XChat API. @@ -834,16 +864,17 @@ group_name = chat.decrypt(conversation["group_name"], conv_key) | # | Method | Rust | JS | Python | Go | JVM | .NET | |---|--------|------|-----|--------|-----|------|------| -| 36 | **sign** | `(&[u8]) → Vec` | `(Uint8Array) → Uint8Array` | `(bytes) → bytes` | `Sign(data []byte) ([]byte, error)` | `sign(byte[])` → `byte[]` | `Sign(byte[])` → `byte[]` | -| 37 | **verify** | `(pk_b64, &[u8] sig, &[u8] data) → bool` | `(pk_b64, Uint8Array sig, Uint8Array data) → boolean` | `(pk_b64, bytes sig, bytes data) → bool` | `Verify(publicKeyB64 string, signature, data []byte) (bool, error)` | `verify(String publicKeyB64, byte[] signature, byte[] data)` | `Verify(string publicKeyB64, byte[] signature, byte[] data)` | -| 38 | **verify_key_binding** | `(identity_b64, signing_b64, sig_b64) → bool` | `verifyKeyBinding(identityB64, signingB64, sigB64) → boolean` | `verify_key_binding(identity_b64, signing_b64, sig_b64) → bool` | `VerifyKeyBinding(identityB64, signingB64, sigB64 string) (bool, error)` | `verifyKeyBinding(String, String, String)` | `VerifyKeyBinding(string, string, string)` | -| 39 | **matches_registered_key** | `(public_key_b64) → bool` | `matchesRegisteredKey(publicKeyB64) → boolean` | `matches_registered_key(public_key_b64) → bool` | `MatchesRegisteredKey(publicKeyB64 string) (bool, error)` | `matchesRegisteredKey(String)` | `MatchesRegisteredKey(string)` | +| 38 | **sign** | `(&[u8]) → Vec` | `(Uint8Array) → Uint8Array` | `(bytes) → bytes` | `Sign(data []byte) ([]byte, error)` | `sign(byte[])` → `byte[]` | `Sign(byte[])` → `byte[]` | +| 39 | **verify** | `(pk_b64, &[u8] sig, &[u8] data) → bool` | `(pk_b64, Uint8Array sig, Uint8Array data) → boolean` | `(pk_b64, bytes sig, bytes data) → bool` | `Verify(publicKeyB64 string, signature, data []byte) (bool, error)` | `verify(String publicKeyB64, byte[] signature, byte[] data)` | `Verify(string publicKeyB64, byte[] signature, byte[] data)` | +| 40 | **verify_key_binding** | `(identity_b64, signing_b64, sig_b64) → bool` | `verifyKeyBinding(identityB64, signingB64, sigB64) → boolean` | `verify_key_binding(identity_b64, signing_b64, sig_b64) → bool` | `VerifyKeyBinding(identityB64, signingB64, sigB64 string) (bool, error)` | `verifyKeyBinding(String, String, String)` | `VerifyKeyBinding(string, string, string)` | +| 41 | **matches_registered_key** | `(public_key_b64) → bool` | `matchesRegisteredKey(publicKeyB64) → boolean` | `matches_registered_key(public_key_b64) → bool` | `MatchesRegisteredKey(publicKeyB64 string) (bool, error)` | `matchesRegisteredKey(String)` | `MatchesRegisteredKey(string)` | Conversation-key changes, group creates, and group member-adds are signed by the one-call prepare methods (`prepare_conversation_key_change`, `prepare_group_create`, `prepare_group_members_change`), which return the `actionSignatures` ready to POST. Group create and member-add each return two -signatures (a conversation-key change plus the group change). +signatures (a conversation-key change plus the group change). Message deletes +are signed by `prepare_message_delete`, which returns a single `ActionSignature`. ### Utilities @@ -851,12 +882,12 @@ Common helpers exported as module-level functions (Rust crate root / JS module / | # | Function | Rust | JS | Python | Go | JVM | .NET | |---|----------|------|-----|--------|-----|------|------| -| 40 | **bytes_to_base64** | `(&[u8]) → String` | `bytesToBase64(Uint8Array) → string` | `bytes_to_base64(bytes) → str` | `BytesToBase64(data []byte) (string, error)` | `ChatXdkUtilities.bytesToBase64(byte[])` | `ChatXdkUtilities.BytesToBase64(ReadOnlySpan)` | -| 41 | **base64_to_bytes** | `(&str) → Option>` | `base64ToBytes(string) → Uint8Array?` | `base64_to_bytes(str) → bytes?` | `Base64ToBytes(b64 string) ([]byte, error)` | `ChatXdkUtilities.base64ToBytes(String)` | `ChatXdkUtilities.Base64ToBytes(string)` | -| 42 | **bytes_to_hex** | `(&[u8]) → String` | `bytesToHex(Uint8Array) → string` | `bytes_to_hex(bytes) → str` | `BytesToHex(data []byte) (string, error)` | `ChatXdkUtilities.bytesToHex(byte[])` | `ChatXdkUtilities.BytesToHex(ReadOnlySpan)` | -| 43 | **hex_to_bytes** | `(&str) → Option>` | `hexToBytes(string) → Uint8Array?` | `hex_to_bytes(str) → bytes?` | `HexToBytes(hex string) ([]byte, error)` | `ChatXdkUtilities.hexToBytes(String)` | `ChatXdkUtilities.HexToBytes(string)` | -| 44 | **detect_mime_type** | `(&[u8]) → Option<&str>` | `detectMimeType(Uint8Array) → string?` | `detect_mime_type(bytes) → str?` | `DetectMimeType(data []byte) (mime string, err error)` — empty string if unknown | `ChatXdkUtilities.detectMimeType(byte[])` → `String` or `null` | `ChatXdkUtilities.DetectMimeType(ReadOnlySpan)` → `string?` | -| 45 | **detect_image_dimensions** | `(&[u8]) → Option` | `detectImageDimensions(Uint8Array) → {width, height}?` | `detect_image_dimensions(bytes) → (w, h)?` | `DetectImageDimensions(data []byte) (*ImageDimensions, error)` — nil if unknown | `ChatXdkUtilities.detectImageDimensions(byte[])` → `ImageDimensions` or `null` | `ChatXdkUtilities.DetectImageDimensions(ReadOnlySpan)` → `ImageDimensions?` | +| 42 | **bytes_to_base64** | `(&[u8]) → String` | `bytesToBase64(Uint8Array) → string` | `bytes_to_base64(bytes) → str` | `BytesToBase64(data []byte) (string, error)` | `ChatXdkUtilities.bytesToBase64(byte[])` | `ChatXdkUtilities.BytesToBase64(ReadOnlySpan)` | +| 43 | **base64_to_bytes** | `(&str) → Option>` | `base64ToBytes(string) → Uint8Array?` | `base64_to_bytes(str) → bytes?` | `Base64ToBytes(b64 string) ([]byte, error)` | `ChatXdkUtilities.base64ToBytes(String)` | `ChatXdkUtilities.Base64ToBytes(string)` | +| 44 | **bytes_to_hex** | `(&[u8]) → String` | `bytesToHex(Uint8Array) → string` | `bytes_to_hex(bytes) → str` | `BytesToHex(data []byte) (string, error)` | `ChatXdkUtilities.bytesToHex(byte[])` | `ChatXdkUtilities.BytesToHex(ReadOnlySpan)` | +| 45 | **hex_to_bytes** | `(&str) → Option>` | `hexToBytes(string) → Uint8Array?` | `hex_to_bytes(str) → bytes?` | `HexToBytes(hex string) ([]byte, error)` | `ChatXdkUtilities.hexToBytes(String)` | `ChatXdkUtilities.HexToBytes(string)` | +| 46 | **detect_mime_type** | `(&[u8]) → Option<&str>` | `detectMimeType(Uint8Array) → string?` | `detect_mime_type(bytes) → str?` | `DetectMimeType(data []byte) (mime string, err error)` — empty string if unknown | `ChatXdkUtilities.detectMimeType(byte[])` → `String` or `null` | `ChatXdkUtilities.DetectMimeType(ReadOnlySpan)` → `string?` | +| 47 | **detect_image_dimensions** | `(&[u8]) → Option` | `detectImageDimensions(Uint8Array) → {width, height}?` | `detect_image_dimensions(bytes) → (w, h)?` | `DetectImageDimensions(data []byte) (*ImageDimensions, error)` — nil if unknown | `ChatXdkUtilities.detectImageDimensions(byte[])` → `ImageDimensions` or `null` | `ChatXdkUtilities.DetectImageDimensions(ReadOnlySpan)` → `ImageDimensions?` | **Failure modes differ by binding.** On undecodable input, `base64_to_bytes` / `hex_to_bytes` return `None` (Rust `Option`), `undefined` @@ -1394,7 +1425,7 @@ type MessageContent = } | { contentType: 'reaction'; emoji: string; targetMessageId: string } | { contentType: 'reactionRemoved'; emoji: string; targetMessageId: string } - | { contentType: 'edit'; targetMessageId: string; newText: string } + | { contentType: 'edit'; targetMessageId: string; newText: string; entities?: RichTextEntity[] } | { contentType: 'markRead' } | { contentType: 'markUnread' } | { contentType: 'unknown'; typeId?: number }; diff --git a/examples/js/src/chat-core.mjs b/examples/js/src/chat-core.mjs index 8db730a..53ed33d 100644 --- a/examples/js/src/chat-core.mjs +++ b/examples/js/src/chat-core.mjs @@ -201,6 +201,35 @@ export class ChatCore { }; } + /** + * Encrypt + sign a message edit. `targetEvent` is the base64 raw event of + * the message being edited; the conversation id and target sequence id are + * derived from it. + */ + encryptEdit({ targetEvent, updatedText, entities, conversationKey, conversationKeyVersion }) { + const payload = this.#chat.encryptEdit({ + targetEvent, + updatedText, + entities, + conversationKey, + conversationKeyVersion, + }); + return { + // The SDK generates the message id and returns it in the payload. + message_id: payload.messageId, + encoded_message_create_event: payload.encryptedContent, + encoded_message_event_signature: payload.encodedEventSignature, + }; + } + + /** + * Sign a message delete. Returns the action signature to submit alongside + * the delete request. + */ + prepareMessageDelete({ conversationId, sequenceIds, deleteForAll }) { + return this.#chat.prepareMessageDelete({ conversationId, sequenceIds, deleteForAll }); + } + // -- Group management ------------------------------------------------------ /** Prepare a group creation: fresh key + the two required signatures. */ diff --git a/go/chatxdk/chat.go b/go/chatxdk/chat.go index 1bf49c9..eddd629 100644 --- a/go/chatxdk/chat.go +++ b/go/chatxdk/chat.go @@ -367,6 +367,32 @@ func (c *Chat) PrepareGroupCreate(params GroupCreateParams) (*PreparedConversati return &out, nil } +// PrepareMessageDelete builds the signed action for deleting messages from a +// conversation, ready to submit alongside the delete request. +// +// A delete is a signed plaintext event, not an encrypted message, so no +// conversation key is involved. The SDK generates the action's message id; +// read it back from the result. +func (c *Chat) PrepareMessageDelete(params MessageDeleteParams) (*ActionSignature, error) { + defer runtime.KeepAlive(c) + // Sequence ids are a required field; marshal a nil slice as [] rather + // than null. + params.SequenceIDs = orEmpty(params.SequenceIDs) + j, err := json.Marshal(params) + if err != nil { + return nil, err + } + data, err := ffiPrepareMessageDelete(c.h, string(j)) + if err != nil { + return nil, err + } + var out ActionSignature + if err := json.Unmarshal([]byte(data), &out); err != nil { + return nil, err + } + return &out, nil +} + // DecryptEvent decrypts a single webhook event and errors on failure. // // conversationKeys is a map of version -> raw 32-byte conversation key, @@ -486,6 +512,24 @@ func (c *Chat) EncryptRemoveReaction(params EncryptReactionParams) (*SendPayload return &payload, nil } +// EncryptEdit encrypts a message edit for the X API. +func (c *Chat) EncryptEdit(params EncryptEditParams) (*SendPayload, error) { + defer runtime.KeepAlive(c) + j, err := json.Marshal(params) + if err != nil { + return nil, err + } + data, err := ffiEncryptEdit(c.h, string(j)) + if err != nil { + return nil, err + } + var payload SendPayload + if err := json.Unmarshal([]byte(data), &payload); err != nil { + return nil, err + } + return &payload, nil +} + // DecryptConversationKey decrypts an encrypted conversation key (ECIES). // Call this once per key version, then pass the result to DecryptEvent. // Returns the raw 32-byte conversation key. Unlike a string, the byte diff --git a/go/chatxdk/chatxdk_test.go b/go/chatxdk/chatxdk_test.go index 3329be3..1e03433 100644 --- a/go/chatxdk/chatxdk_test.go +++ b/go/chatxdk/chatxdk_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "fmt" "os" "path/filepath" "runtime" @@ -834,6 +835,95 @@ func TestEncryptRemoveReaction(t *testing.T) { } } +func TestEncryptEdit(t *testing.T) { + v := loadVectors(t) + + chat := New() + defer chat.Close() + if err := chat.ImportKeys(v.privateKeys(t)); err != nil { + t.Fatalf("ImportKeys failed: %v", err) + } + + payload, err := chat.EncryptEdit(EncryptEditParams{ + SenderID: "111", + ConversationID: "conv-1", + ConversationKey: v.conversationKey(t), + TargetMessageSequenceID: "seq-99", + UpdatedText: "see https://example.com", + Entities: []EntityTuple{NewEntity(4, 23, "url")}, + ConversationKeyVersion: "1", + SigningKeyVersion: "1", + }) + if err != nil { + t.Fatalf("EncryptEdit failed: %v", err) + } + if payload.EncryptedContent == "" { + t.Error("expected non-empty encrypted_content") + } + if payload.Signature == "" { + t.Error("expected non-empty signature") + } + if payload.EncodedEventSignature == "" { + t.Error("expected non-empty encoded_event_signature") + } + if payload.MessageID == "" { + t.Error("expected non-empty message_id") + } +} + +func TestPrepareMessageDelete(t *testing.T) { + v := loadVectors(t) + + chat := New() + defer chat.Close() + if err := chat.ImportKeys(v.privateKeys(t)); err != nil { + t.Fatalf("ImportKeys failed: %v", err) + } + + // A 1:1 id is signed in its canonical colon form; delete-for-all signs + // the wire action 2. + sig, err := chat.PrepareMessageDelete(MessageDeleteParams{ + SenderID: "111", + SigningKeyVersion: "1", + ConversationID: "222-111", + SequenceIDs: []string{"seq-10", "seq-11"}, + DeleteForAll: true, + }) + if err != nil { + t.Fatalf("PrepareMessageDelete failed: %v", err) + } + if sig.MessageID == "" { + t.Error("expected non-empty message_id") + } + if sig.EncodedMessageEventDetail == "" { + t.Error("expected non-empty encoded_message_event_detail") + } + if sig.Signature == "" { + t.Error("expected non-empty signature") + } + want := fmt.Sprintf("MessageDeleteEvent,%s,111,111:222,2,seq-10,seq-11", sig.MessageID) + if sig.SignaturePayload != want { + t.Errorf("expected signature_payload %q, got %q", want, sig.SignaturePayload) + } + + // Group ids pass through unchanged; delete-for-self signs the wire + // action 1. + selfSig, err := chat.PrepareMessageDelete(MessageDeleteParams{ + SenderID: "111", + SigningKeyVersion: "1", + ConversationID: "g999", + SequenceIDs: []string{"seq-1"}, + DeleteForAll: false, + }) + if err != nil { + t.Fatalf("PrepareMessageDelete (delete-for-self) failed: %v", err) + } + want = fmt.Sprintf("MessageDeleteEvent,%s,111,g999,1,seq-1", selfSig.MessageID) + if selfSig.SignaturePayload != want { + t.Errorf("expected signature_payload %q, got %q", want, selfSig.SignaturePayload) + } +} + func TestPrepareConversationKeyChange(t *testing.T) { v := loadVectors(t) diff --git a/go/chatxdk/ffi.go b/go/chatxdk/ffi.go index 0b2f523..832f03e 100644 --- a/go/chatxdk/ffi.go +++ b/go/chatxdk/ffi.go @@ -212,6 +212,12 @@ func ffiPrepareGroupCreate(h handle, paramsJSON string) (string, error) { return ffiResult(C.chat_xdk_prepare_group_create(h, c)) } +func ffiPrepareMessageDelete(h handle, paramsJSON string) (string, error) { + c := C.CString(paramsJSON) + defer C.free(unsafe.Pointer(c)) + return ffiResult(C.chat_xdk_prepare_message_delete(h, c)) +} + // decrypt func ffiDecryptEvent(h handle, eventB64, convKeysJSON, signingKeysJSON string) (string, error) { @@ -258,6 +264,12 @@ func ffiEncryptRemoveReaction(h handle, paramsJSON string) (string, error) { return ffiResult(C.chat_xdk_encrypt_remove_reaction(h, c)) } +func ffiEncryptEdit(h handle, paramsJSON string) (string, error) { + c := C.CString(paramsJSON) + defer C.free(unsafe.Pointer(c)) + return ffiResult(C.chat_xdk_encrypt_edit(h, c)) +} + func ffiEncryptStream(h handle, plaintextB64, ckeyB64 string) (string, error) { cPT := C.CString(plaintextB64) defer C.free(unsafe.Pointer(cPT)) diff --git a/go/chatxdk/include/chat_xdk.h b/go/chatxdk/include/chat_xdk.h index 082263b..005d3f7 100644 --- a/go/chatxdk/include/chat_xdk.h +++ b/go/chatxdk/include/chat_xdk.h @@ -297,6 +297,20 @@ struct FfiResult chat_xdk_prepare_group_members_change(const struct ChatHandle * struct FfiResult chat_xdk_prepare_group_create(const struct ChatHandle *handle, const char *params_json); +/** + * Build the signed action for deleting messages from a conversation. + * + * `params_json` — single JSON document: `conversation_id`, `sequence_ids` + * (the messages to delete), `delete_for_all` (every participant vs only the + * caller's view), plus optional `sender_id` / `signing_key_version` (unset + * resolves from the session identity). + * + * Returns JSON `ActionSignature`; the SDK-generated `message_id` is a field + * on it. + */ +struct FfiResult chat_xdk_prepare_message_delete(const struct ChatHandle *handle, + const char *params_json); + /** * Decrypt a webhook event. * @@ -374,6 +388,23 @@ struct FfiResult chat_xdk_encrypt_add_reaction(const struct ChatHandle *handle, struct FfiResult chat_xdk_encrypt_remove_reaction(const struct ChatHandle *handle, const char *params_json); +/** + * Encrypt a message edit for the X API. + * + * `params_json` — single JSON document: `updated_text` plus the edit + * target: preferably `target_event` (base64 raw event being edited; the + * conversation id and target sequence id are derived from it), with + * explicit `conversation_id` / `target_message_sequence_id` overrides for + * callers that no longer hold the raw event. Optional `entities` + * (`[start, end, type]` tuples for the replacement text), `sender_id` / + * `signing_key_version` (unset resolves from the session identity), and + * `conversation_key` (base64) / `conversation_key_version` (unset resolves + * from the opt-in key cache). + * + * Returns JSON `SendPayload`; the SDK-generated `message_id` is a field on it. + */ +struct FfiResult chat_xdk_encrypt_edit(const struct ChatHandle *handle, const char *params_json); + /** * Decrypt an encrypted conversation key. * diff --git a/go/chatxdk/libs/darwin_amd64/libchat_xdk_go.a b/go/chatxdk/libs/darwin_amd64/libchat_xdk_go.a index ec284fa..3619abb 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 84eff5b..77ebda4 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/go/chatxdk/libs/linux_amd64/libchat_xdk_go.a b/go/chatxdk/libs/linux_amd64/libchat_xdk_go.a index 5041284..8768610 100644 Binary files a/go/chatxdk/libs/linux_amd64/libchat_xdk_go.a and b/go/chatxdk/libs/linux_amd64/libchat_xdk_go.a differ diff --git a/go/chatxdk/libs/linux_amd64_musl/libchat_xdk_go.a b/go/chatxdk/libs/linux_amd64_musl/libchat_xdk_go.a index 3871586..a530437 100644 Binary files a/go/chatxdk/libs/linux_amd64_musl/libchat_xdk_go.a and b/go/chatxdk/libs/linux_amd64_musl/libchat_xdk_go.a differ diff --git a/go/chatxdk/types.go b/go/chatxdk/types.go index 131703f..e10bc54 100644 --- a/go/chatxdk/types.go +++ b/go/chatxdk/types.go @@ -286,7 +286,48 @@ type EncryptReactionParams struct { ConversationKeyVersion string `json:"conversation_key_version,omitempty"` // empty = key cache } -// ActionSignature authenticates a conversation key change or member add. +// EncryptEditParams are the parameters for EncryptEdit. +// +// The preferred edit target is TargetEvent — the base64 raw event of the +// message being edited — from which the SDK derives ConversationID and +// TargetMessageSequenceID; the explicit fields remain as overrides for +// callers that no longer hold the raw event. +// +// The identity and key fields are optional overrides, as on +// EncryptMessageParams. +type EncryptEditParams struct { + // TargetEvent is the base64 raw event being edited. + TargetEvent string `json:"target_event,omitempty"` + UpdatedText string `json:"updated_text"` + // Entities are rich-text entities for the replacement text; nil clears + // any entities the original carried. + Entities []EntityTuple `json:"entities,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` // empty = derived from TargetEvent + TargetMessageSequenceID string `json:"target_message_sequence_id,omitempty"` // empty = derived from TargetEvent + SenderID string `json:"sender_id,omitempty"` // empty = session identity + SigningKeyVersion string `json:"signing_key_version,omitempty"` // empty = session identity + ConversationKey []byte `json:"conversation_key,omitempty"` // raw 32-byte key; nil = key cache + ConversationKeyVersion string `json:"conversation_key_version,omitempty"` // empty = key cache +} + +// MessageDeleteParams are the parameters for PrepareMessageDelete. +// +// A delete is a signed plaintext event, not an encrypted message, so no +// conversation key is involved. SenderID and SigningKeyVersion are optional +// overrides: empty resolves from the session identity set via SetIdentity. +type MessageDeleteParams struct { + ConversationID string `json:"conversation_id"` + // SequenceIDs are the sequence_ids of the messages to delete. + SequenceIDs []string `json:"sequence_ids"` + // DeleteForAll deletes for every participant (true, own messages only) + // or only from the caller's view (false). + DeleteForAll bool `json:"delete_for_all"` + SenderID string `json:"sender_id,omitempty"` // empty = session identity + SigningKeyVersion string `json:"signing_key_version,omitempty"` // empty = session identity +} + +// ActionSignature authenticates a conversation key change, member add, or +// message delete. type ActionSignature struct { MessageID string `json:"message_id"` EncodedMessageEventDetail string `json:"encoded_message_event_detail"`