diff --git a/xchat/cryptography-primer.mdx b/xchat/cryptography-primer.mdx index f98c7b364..bcfe4a4ad 100644 --- a/xchat/cryptography-primer.mdx +++ b/xchat/cryptography-primer.mdx @@ -226,6 +226,8 @@ If anything in the signed material changes, verification fails. Only someone wit The Chat XDK signs when you encrypt outbound messages and verifies when you decrypt inbound ones against the sender’s public key material (from the public-key APIs). Verification is **mandatory by default**: the SDK rejects unverified signed events unless you explicitly disable the check (not recommended). Details are in the [Chat XDK](/xchat/xchat-xdk) reference. +Signatures also cover quoted content. A reply embeds the raw **signed** original message it quotes; when the Chat XDK decrypts the reply, it verifies that embedded original and compares the quote against it, reporting the outcome as `reply_preview_validation` (`Valid` / `Invalid`). An `Invalid` outcome means the quote does not match the signed original—treat the quoted material as untrusted, even though the reply itself is verified separately—so no participant can attribute fabricated words to another. + ### Signed state changes (action signatures) Messages are not the only signed material. Every call that changes conversation state—adding or rotating conversation keys, creating a group, adding members—must carry one or more **action signatures**: the sender signs a payload describing exactly what the change does (for a key change, that payload includes the new conversation key itself), and the API rejects the request if the signatures are missing or malformed. diff --git a/xchat/getting-started.mdx b/xchat/getting-started.mdx index 145ece8ed..bcfb7f426 100644 --- a/xchat/getting-started.mdx +++ b/xchat/getting-started.mdx @@ -45,11 +45,10 @@ X Chat apps use two pieces together: ```toml [dependencies] # chat-xdk-core is not yet on crates.io — use the git dependency - chat-xdk-core = { git = "https://github.com/xdevplatform/chat-xdk", tag = "v0.2.1" } + chat-xdk-core = { git = "https://github.com/xdevplatform/chat-xdk", tag = "v0.4.0" } reqwest = { version = "0.12", features = ["blocking", "json"] } serde_json = "1" base64 = "0.22" - uuid = { version = "1", features = ["v4"] } # Required until thrift 0.24 is released on crates.io [patch.crates-io] @@ -75,7 +74,7 @@ X Chat apps use two pieces together: com.x chatxdk - 0.2.1 + 0.4.0 ``` @@ -136,9 +135,9 @@ Create an API client with your **user** OAuth 2.0 access token: This step **loads keys you already have**—use it when this identity completed first-time setup before: - **Secure key backup:** construct the SDK with the `juicebox_config` from your public-key record, then `unlock` with your passcode to recover the private keys (for example, on a new device). -- **Key blob:** `import_keys` with a blob you previously exported via `export_keys`. +- **Key blob:** `import_keys` with a blob you previously exported via `export_keys`, passing the registered key version alongside it (Rust and Go name this variant `import_keys_with_version` / `ImportKeysWithVersion`). -Then set your registered public-key version (`public_key_version` on your record). +Then call **`set_identity(user_id, signing_key_version)`** once, with your user ID and your record's `public_key_version`. This stores the session identity: every later encrypt and prepare call signs as this identity, so you never pass a sender ID or signing key version per call. **Setting up for the first time?** Construct the SDK the same way but skip `unlock`/`import_keys`, and continue to [step 3](#3-create-and-register-keys-first-time-setup) to create, back up, and register your keys. @@ -160,7 +159,9 @@ Then set your registered public-key version (`public_key_version` on your record chat = Chat(json.dumps(record["juicebox_config"])) chat.unlock("YOUR_PASSCODE") # recovers keys stored by setup() during first-time setup (step 3) - chat.set_key_version(signing_key_version) + # Or load a key blob instead of secure key backup: + # chat.import_keys(blob, version=signing_key_version) + chat.set_identity("YOUR_USER_ID", signing_key_version) ``` @@ -181,7 +182,7 @@ Then set your registered public-key version (`public_key_version` on your record getAuthToken: async (realmId) => getRealmTokenFromYourBackend(realmId), }); await chat.unlock('YOUR_PASSCODE'); - chat.setKeyVersion(signingKeyVersion); + chat.setIdentity('YOUR_USER_ID', signingKeyVersion); ``` @@ -189,11 +190,11 @@ Then set your registered public-key version (`public_key_version` on your record use base64::{engine::general_purpose::STANDARD as B64, Engine}; use chat_xdk_core::ChatCore; - let mut chat = ChatCore::new(); + let chat = ChatCore::new(); let blob = B64.decode(std::env::var("PRIVATE_KEYS_B64")?)?; let signing_key_version = std::env::var("SIGNING_KEY_VERSION").unwrap_or_else(|_| "1".into()); - chat.import_keys(&blob)?; - chat.set_key_version(&signing_key_version); + chat.import_keys_with_version(&blob, &signing_key_version)?; + chat.set_identity("YOUR_USER_ID", &signing_key_version); ``` @@ -207,14 +208,16 @@ Then set your registered public-key version (`public_key_version` on your record if err != nil { log.Fatal(err) } - if err := chat.ImportKeys(blob); err != nil { - log.Fatal(err) - } signingKeyVersion := os.Getenv("SIGNING_KEY_VERSION") if signingKeyVersion == "" { signingKeyVersion = "1" } - chat.SetKeyVersion(signingKeyVersion) + if err := chat.ImportKeysWithVersion(blob, signingKeyVersion); err != nil { + log.Fatal(err) + } + if err := chat.SetIdentity(myUserID, signingKeyVersion); err != nil { + log.Fatal(err) + } ``` @@ -224,8 +227,8 @@ Then set your registered public-key version (`public_key_version` on your record using var chat = new Chat(); var signingKeyVersion = Environment.GetEnvironmentVariable("SIGNING_KEY_VERSION") ?? "1"; chat.ImportKeys(Convert.FromBase64String( - Environment.GetEnvironmentVariable("PRIVATE_KEYS_B64")!)); - chat.SetKeyVersion(signingKeyVersion); + Environment.GetEnvironmentVariable("PRIVATE_KEYS_B64")!), signingKeyVersion); + chat.SetIdentity(myUserId, signingKeyVersion); ``` @@ -234,8 +237,8 @@ Then set your registered public-key version (`public_key_version` on your record String signingKeyVersion = Optional.ofNullable(System.getenv("SIGNING_KEY_VERSION")).orElse("1"); try (Chat chat = new Chat()) { - chat.importKeys(Base64.getDecoder().decode(System.getenv("PRIVATE_KEYS_B64"))); - chat.setKeyVersion(signingKeyVersion); + chat.importKeys(Base64.getDecoder().decode(System.getenv("PRIVATE_KEYS_B64")), signingKeyVersion); + chat.setIdentity(myUserId, signingKeyVersion); } ``` @@ -257,6 +260,8 @@ Skip this step if you loaded existing keys in [step 2](#2-initialize-the-chat-xd 2. **Store the private keys** — `setup` with a passcode writes them to secure key backup (clients), or `export_keys` returns a key blob for you to store securely (servers and bots). 3. **Register the public keys** — POST the registration payload to the add-public-key endpoint so others can encrypt to you and verify your signatures. +Finish by calling `set_identity` with the registration's key version, so this session signs as the new identity. + Ready-to-run one-time registration scripts for every binding live under [`chat-xdk/examples`](https://github.com/xdevplatform/chat-xdk/tree/main/examples) (Python, TypeScript, Go, Rust, C#, and Java). Use them instead of hand-rolling the flow below when you just need to onboard a new identity. @@ -284,7 +289,7 @@ Ready-to-run one-time registration scripts for every binding live under [`chat-x ), ) chat.setup("YOUR_PASSCODE") - chat.set_key_version(str(registration.version or signing_key_version)) + chat.set_identity("YOUR_USER_ID", str(registration.version or "1")) ``` @@ -304,7 +309,7 @@ Ready-to-run one-time registration scripts for every binding live under [`chat-x generate_version: registration.generateVersion, }); await chat.setup('YOUR_PASSCODE'); - chat.setKeyVersion(String(registration.version ?? signingKeyVersion)); + chat.setIdentity('YOUR_USER_ID', String(registration.version ?? '1')); ``` @@ -320,6 +325,8 @@ Ready-to-run one-time registration scripts for every binding live under [`chat-x anyhow::bail!("register keys: {}", resp.text()?); } let _blob = chat.export_keys()?; // store securely + let key_version = registration.version.clone().unwrap_or_else(|| "1".into()); + chat.set_identity(&user_id, &key_version); ``` @@ -339,9 +346,15 @@ Ready-to-run one-time registration scripts for every binding live under [`chat-x log.Fatal(err) } resp.Body.Close() - privateKeysB64, _ := chat.ExportKeys() // store securely - _ = privateKeysB64 - chat.SetKeyVersion(signingKeyVersion) + privateKeys, _ := chat.ExportKeys() // store securely + _ = privateKeys + keyVersion := "1" + if registration.Version != nil { + keyVersion = *registration.Version + } + if err := chat.SetIdentity(userID, keyVersion); err != nil { + log.Fatal(err) + } ``` @@ -353,7 +366,7 @@ Ready-to-run one-time registration scripts for every binding live under [`chat-x $"https://api.x.com/2/users/{Uri.EscapeDataString(userId)}/public_keys", content); regResp.EnsureSuccessStatusCode(); var blob = chat.ExportKeys(); // store securely - chat.SetKeyVersion(signingKeyVersion); + chat.SetIdentity(userId, registration.Version ?? "1"); ``` @@ -371,7 +384,7 @@ Ready-to-run one-time registration scripts for every binding live under [`chat-x throw new RuntimeException("register keys: " + regResp.body()); } byte[] blob = chat.exportKeys(); // store securely - chat.setKeyVersion(signingKeyVersion); + chat.setIdentity(myUserId, registration.version != null ? registration.version : "1"); ``` @@ -384,7 +397,7 @@ Use a strong passcode for secure key backup. Losing the passcode or an unprotect ## 4. Set up conversation keys -Call **`prepare_conversation_key_change`** with your user ID, your signing key version, and every participant's identity public key. One call generates a fresh conversation key, encrypts it for each participant, and signs the change. POST the result to the **add conversation keys** endpoint (`POST /2/chat/conversations/{id}/keys`)—the body needs `conversation_key_version`, `conversation_participant_keys` (SDK `encrypted_key` → API `encrypted_conversation_key`), and **`action_signatures`** (required; the API rejects the call without them). Keep the **raw** conversation key for sending. +Call **`prepare_conversation_key_change`** with every participant's identity public key; the sender identity comes from the session you set in step 2. One call generates a fresh conversation key, encrypts it for each participant, and signs the change. POST the result to the **add conversation keys** endpoint (`POST /2/chat/conversations/{id}/keys`)—the body needs `conversation_key_version`, `conversation_participant_keys` (SDK `encrypted_key` → API `encrypted_conversation_key`), and **`action_signatures`** (required; the API rejects the call without them). Keep the **raw** conversation key for sending. The response returns the canonical conversation id (`data.conversation_id`—the hyphen-joined pair for a 1:1, or the g-prefixed id for a group) and the key change's `data.sequence_id`. Use that returned id for subsequent requests instead of reconstructing it client-side. The same call also **rotates** keys later: pass the existing conversation id to `prepare_conversation_key_change` and POST with the newer key version. Rotate when you suspect the conversation key was exposed—rotation protects **future** messages only; messages encrypted under earlier key versions stay readable to anyone who holds those versions. @@ -402,8 +415,6 @@ The response returns the canonical conversation id (`data.conversation_id`—the return {"user_id": user_id, "public_key": r["public_key"], "key_version": r["public_key_version"]} prepared = chat.prepare_conversation_key_change( - "YOUR_USER_ID", - signing_key_version, [public_key_input("YOUR_USER_ID"), public_key_input("RECIPIENT_USER_ID")], # conversation_id=None for a new 1:1; pass the id to rotate later ) @@ -450,8 +461,6 @@ The response returns the canonical conversation id (`data.conversation_id`—the // Omit conversationId for a new 1:1; pass the id to rotate later const prepared = chat.prepareConversationKeyChange({ - senderId: 'YOUR_USER_ID', - signingKeyVersion, publicKeys: [ await publicKeyInput('YOUR_USER_ID'), await publicKeyInput('RECIPIENT_USER_ID'), @@ -484,9 +493,9 @@ The response returns the canonical conversation id (`data.conversation_id`—the ```rust // public_key_inputs: Vec from GET public keys // (user_id, public_key, key_version ← public_key_version) - // new 1:1; set params.conversation_id = Some(id) to rotate later + // New 1:1; set params.conversation_id = Some(id) to rotate later let prepared = chat.prepare_conversation_key_change( - ConversationKeyChangeParams::new(&sender_id, &signing_key_version, public_key_inputs), + ConversationKeyChangeParams::new(public_key_inputs), )?; let participant_keys: Vec<_> = prepared .participant_keys @@ -536,8 +545,6 @@ The response returns the canonical conversation id (`data.conversation_id`—the ```go // KeyVersion comes from the public_key_version field on each record prepared, err := chat.PrepareConversationKeyChange(chatxdk.ConversationKeyChangeParams{ - SenderID: myUserID, - SigningKeyVersion: signingKeyVersion, PublicKeys: []chatxdk.PublicKeyInput{ {UserID: myUserID, PublicKey: myIdentityPubB64, KeyVersion: myKeyVersion}, {UserID: recipientID, PublicKey: theirIdentityPubB64, KeyVersion: theirKeyVersion}, @@ -576,21 +583,18 @@ The response returns the canonical conversation id (`data.conversation_id`—the req.Header.Set("Content-Type", "application/json") resp, err := httpClient.Do(req) // Response data.conversation_id is the canonical id for later requests - // prepared.ConversationKey feeds EncryptMessage _ = resp + convKey := prepared.ConversationKey + convKeyVersion := prepared.ConversationKeyVersion ``` ```csharp // KeyVersion comes from the public_key_version field on each record - var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams { - SenderId = myUserId, - SigningKeyVersion = signingKeyVersion, - PublicKeys = new[] { - new PublicKeyInput { UserId = myUserId, PublicKey = myPk, KeyVersion = myVer }, - new PublicKeyInput { UserId = recipientId, PublicKey = theirPk, KeyVersion = theirVer }, - }, - }); // ConversationId null for a new 1:1; pass the id to rotate later + var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams(new[] { + new PublicKeyInput { UserId = myUserId, PublicKey = myPk, KeyVersion = myVer }, + new PublicKeyInput { UserId = recipientId, PublicKey = theirPk, KeyVersion = theirVer }, + })); // ConversationId null for a new 1:1; set it to rotate later var keysBody = new { conversation_key_version = prepared.ConversationKeyVersion, conversation_participant_keys = prepared.ParticipantKeys.Select(pk => new { @@ -629,12 +633,9 @@ The response returns the canonical conversation id (`data.conversation_id`—the PublicKeyInput theirs = new PublicKeyInput(); theirs.userId = recipientId; theirs.publicKey = theirIdentityPubB64; theirs.keyVersion = theirKeyVersion; - ConversationKeyChangeParams keyParams = new ConversationKeyChangeParams(); - keyParams.senderId = myUserId; - keyParams.signingKeyVersion = signingKeyVersion; - keyParams.publicKeys = List.of(mine, theirs); - // keyParams.conversationId null for a new 1:1; set the id to rotate later - PreparedConversationChange prepared = chat.prepareConversationKeyChange(keyParams); + // conversationId stays null for a new 1:1; set it to rotate later + PreparedConversationChange prepared = + chat.prepareConversationKeyChange(new ConversationKeyChangeParams(List.of(mine, theirs))); List> parts = new ArrayList<>(); for (var pk : prepared.participantKeys) { @@ -677,36 +678,32 @@ The response returns the canonical conversation id (`data.conversation_id`—the ## 5. Send a message -Encrypt with the **raw** conversation key bytes. On the send request, map: +Encrypt with the **raw** conversation key from step 4. The SDK generates the message id (a UUID), embeds it in the signed event, and returns it on the payload—you never mint one yourself. On the send request, map: | Chat XDK field | Request body field | |:---------------|:-------------------| | `encrypted_content` / `encryptedContent` / `EncryptedContent` | `encoded_message_create_event` | | `encoded_event_signature` / `encodedEventSignature` / `EncodedEventSignature` | `encoded_message_event_signature` | -| Your generated id | `message_id` | +| Payload `message_id` / `messageId` / `MessageId` | `message_id` | Use a **hyphenated** conversation id in the URL path when the API requires it (`:` → `-`). The SDK itself is flexible: `encrypt_message` and `encrypt_reply` accept the id in any form you hold—`A:B` from events, `A-B` from listings or URL paths (in either order), or just the recipient's user id—and canonicalize it before signing. Group ids (prefixed with `g`) pass through unchanged. ```python - import uuid from xdk.chat.models import SendMessageRequest - message_id = str(uuid.uuid4()) + # Sender identity resolves from set_identity (step 2) payload = chat.encrypt_message( - message_id, - "YOUR_USER_ID", "CONVERSATION_ID", - conv_key, "Hello!", - conv_key_version, - signing_key_version, + conversation_key=conv_key, + conversation_key_version=conv_key_version, ) client.chat.send_message( "RECIPIENT_USER_ID", SendMessageRequest( - message_id=message_id, + message_id=payload.message_id, # SDK-generated, embedded in the signed event encoded_message_create_event=payload.encrypted_content, encoded_message_event_signature=payload.encoded_event_signature, ), @@ -715,20 +712,15 @@ Use a **hyphenated** conversation id in the URL path when the API requires it (` ```typescript - import { randomUUID } from 'crypto'; - - const messageId = randomUUID(); + // Sender identity resolves from setIdentity (step 2) const payload = chat.encryptMessage({ - messageId, - senderId: 'YOUR_USER_ID', conversationId: 'CONVERSATION_ID', - conversationKey: convKey, text: 'Hello!', + conversationKey: convKey, conversationKeyVersion: convKeyVersion, - signingKeyVersion, }); await client.chat.sendMessage('RECIPIENT_USER_ID', { - message_id: messageId, + message_id: payload.messageId, // SDK-generated, embedded in the signed event encoded_message_create_event: payload.encryptedContent, encoded_message_event_signature: payload.encodedEventSignature, }); @@ -738,18 +730,14 @@ Use a **hyphenated** conversation id in the URL path when the API requires it (` ```rust use chat_xdk_core::EncryptMessageParams; - let message_id = uuid::Uuid::new_v4().to_string(); - let payload = chat.encrypt_message(EncryptMessageParams::new( - &message_id, - &sender_id, - &conversation_id, - conv_key, - "Hello!", - &conv_key_version, - &signing_key_version, - ))?; + // Sender identity resolves from set_identity (step 2) + let payload = chat.encrypt_message( + EncryptMessageParams::new(&conversation_id, "Hello!") + .with_conversation_key(conv_key, &conv_key_version), + )?; let body = serde_json::json!({ - "message_id": message_id, + // SDK-generated, embedded in the signed event + "message_id": payload.message_id, "encoded_message_create_event": payload.encrypted_content, "encoded_message_event_signature": payload.encoded_event_signature, }); @@ -762,18 +750,19 @@ Use a **hyphenated** conversation id in the URL path when the API requires it (` ```go - messageID := uuid.NewString() + // Sender identity resolves from SetIdentity (step 2) payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{ - MessageID: messageID, - SenderID: senderID, ConversationID: conversationID, - ConversationKey: convKey, Text: "Hello!", + ConversationKey: convKey, ConversationKeyVersion: convKeyVersion, - SigningKeyVersion: signingKeyVersion, }) + if err != nil { + log.Fatal(err) + } body, _ := json.Marshal(map[string]string{ - "message_id": messageID, + // SDK-generated, embedded in the signed event + "message_id": payload.MessageID, "encoded_message_create_event": payload.EncryptedContent, "encoded_message_event_signature": payload.EncodedEventSignature, }) @@ -789,18 +778,14 @@ Use a **hyphenated** conversation id in the URL path when the API requires it (` ```csharp - var messageId = Guid.NewGuid().ToString(); - var payload = chat.EncryptMessage(new EncryptMessageParams { - MessageId = messageId, - SenderId = senderId, - ConversationId = conversationId, + // Sender identity resolves from SetIdentity (step 2) + var payload = chat.EncryptMessage(new EncryptMessageParams(conversationId, "Hello!") { ConversationKey = convKey, - Text = "Hello!", ConversationKeyVersion = convKeyVersion, - SigningKeyVersion = signingKeyVersion, }); var sendJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary { - ["message_id"] = messageId, + // SDK-generated, embedded in the signed event + ["message_id"] = payload.MessageId, ["encoded_message_create_event"] = payload.EncryptedContent, ["encoded_message_event_signature"] = payload.EncodedEventSignature, }); @@ -814,19 +799,16 @@ Use a **hyphenated** conversation id in the URL path when the API requires it (` ```java - EncryptMessageParams params = new EncryptMessageParams(); - params.messageId = UUID.randomUUID().toString(); - params.senderId = senderId; - params.conversationId = conversationId; + // Sender identity resolves from setIdentity (step 2) + EncryptMessageParams params = new EncryptMessageParams(conversationId, "Hello!"); params.conversationKey = convKey; - params.text = "Hello!"; params.conversationKeyVersion = convKeyVersion; - params.signingKeyVersion = signingKeyVersion; SendPayload payload = chat.encryptMessage(params); String pathId = conversationId.replace(':', '-'); String sendJson = new ObjectMapper().writeValueAsString(Map.of( - "message_id", params.messageId, + // SDK-generated, embedded in the signed event + "message_id", payload.messageId, "encoded_message_create_event", payload.encryptedContent, "encoded_message_event_signature", payload.encodedEventSignature)); HttpRequest req = HttpRequest.newBuilder() @@ -840,6 +822,10 @@ Use a **hyphenated** conversation id in the URL path when the API requires it (` + +The snippets pass the conversation key explicitly because in this flow you just created it in step 4. Once the key cache is on and a `decrypt_events` pass has verified the conversation's key ([step 6](#6-receive-and-decrypt)), `encrypt_message(conversation_id, text)` alone is enough—the SDK fills in the latest verified key. Retries should resend the **same** encrypted payload, so an id is never minted twice. + + --- ## 6. Receive and decrypt @@ -848,14 +834,14 @@ Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, - Live payload fields: `encoded_event`, optional `conversation_key_change_event` - History: `GET /2/chat/conversations/{id}/events` — prefer **`decrypt_events`** on all events plus `meta.conversation_key_events` -- Pass the sender’s public keys into decrypt for signature verification (map API fields into `SigningKeyEntry`; see [Chat XDK](/xchat/xchat-xdk)) +- Decrypting needs the senders' **signing keys** so the SDK can verify who wrote each message. These are the other participants' *public* keys — fetch them from the same public-keys endpoint you used in step 4 and map the fields into `SigningKeyEntry` (the snippets below include the mapping) +- You can pass the signing keys (and, for `decrypt_event`, the conversation keys) on every call, **or** set two optional session stores once and use the short call forms. The snippets below use the stores: `set_signing_keys(entries)` holds the participants' keys, and `set_cache_keys(true)` (off by default) keeps each conversation's latest **signature-verified** key so later calls can omit key arguments. Both styles verify identically - JavaScript uses camelCase event types (`message`); other languages use `"Message"` and snake_case fields in JSON ```python - conversation_keys = {} # conversation_id -> { version: key_bytes } - + # Once per process: fill the signing-key store and enable the key cache def signing_keys_for(user_id: str) -> list[dict]: resp = client.chat.get_user_public_keys( user_id, @@ -874,25 +860,34 @@ Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, for r in resp.data ] + chat.set_signing_keys( + signing_keys_for("YOUR_USER_ID") + signing_keys_for("RECIPIENT_USER_ID") + ) + chat.set_cache_keys(True) + + # Initial load or pagination: batch decrypt. Conversation keys are + # extracted from the KeyChange events in the batch; per-event failures + # are collected in result["errors"], never raised. + result = chat.decrypt_events(all_events_b64) + for dm in result["messages"]: + event = dm["event"] + if event["type"] == "Message" and event["content"]["content_type"] == "Text": + print(event["sender_id"], event["content"]["text"], event["verified"]) + + # Live traffic: one event at a time def handle_payload(payload: dict): - cid = payload["conversation_id"] if payload.get("conversation_key_change_event"): - conversation_keys[cid] = chat.extract_conversation_keys( - [payload["conversation_key_change_event"]] - )["keys"] - event = chat.decrypt_event( - payload["encoded_event"], - conversation_keys.get(cid, {}), - signing_keys_for(payload["sender_id"]), - ) - if event.get("type") == "Message" and event.get("content", {}).get("content_type") == "Text": - print(event["sender_id"], event["content"]["text"], event.get("verified")) + # A rotation enters the key cache only after its signature + # verifies, which is what decrypt_events does + chat.decrypt_events([payload["conversation_key_change_event"]]) + event = chat.decrypt_event(payload["encoded_event"]) # raises on failure + if event["type"] == "Message" and event["content"]["content_type"] == "Text": + print(event["sender_id"], event["content"]["text"], event["verified"]) ``` ```typescript - const conversationKeys = new Map>(); - + // Once per process: fill the signing-key store and enable the key cache async function signingKeysFor(userId: string) { const resp = await client.chat.getUserPublicKeys(userId, { publicKeyFields: [ @@ -913,24 +908,33 @@ Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, })); } - async function handlePayload(payload: { - conversation_id: string; + chat.setSigningKeys([ + ...(await signingKeysFor('YOUR_USER_ID')), + ...(await signingKeysFor('RECIPIENT_USER_ID')), + ]); + chat.setCacheKeys(true); + + // Initial load or pagination: batch decrypt. Conversation keys are + // extracted from the KeyChange events in the batch; per-event failures + // are collected in result.errors, never thrown. + const result = chat.decryptEvents(allEventsB64); + for (const dm of result.messages) { + if (dm.event.type === 'message' && dm.event.content?.contentType === 'text') { + console.log(dm.event.senderId, dm.event.content.text, dm.event.verified); + } + } + + // Live traffic: one event at a time + function handlePayload(payload: { encoded_event: string; - sender_id: string; conversation_key_change_event?: string; }) { - const cid = payload.conversation_id; if (payload.conversation_key_change_event) { - conversationKeys.set( - cid, - chat.extractConversationKeys([payload.conversation_key_change_event]).keys, - ); + // A rotation enters the key cache only after its signature + // verifies, which is what decryptEvents does + chat.decryptEvents([payload.conversation_key_change_event]); } - const event = chat.decryptEvent( - payload.encoded_event, - conversationKeys.get(cid) ?? {}, - await signingKeysFor(payload.sender_id), - ); + const event = chat.decryptEvent(payload.encoded_event); // throws on failure if (event.type === 'message' && event.content?.contentType === 'text') { console.log(event.senderId, event.content.text, event.verified); } @@ -939,25 +943,48 @@ Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, ```rust - // Build Vec from GET /2/users/{id}/public_keys - // (public_key_version, public_key, signing_public_key, identity_public_key_signature) + // Once per instance: fill the signing-key store (Vec + // from GET /2/users/{id}/public_keys) and enable the key cache + chat.set_signing_keys(participant_signing_keys); + chat.set_cache_keys(true); + + // Initial load: batch decrypt — per-event failures land in result.errors + let result = chat.decrypt_events(&all_events_b64, &[]); + // Live traffic: a rotation enters the key cache only after its + // signature verifies, which is what decrypt_events does if let Some(kc) = key_change_b64.as_deref() { - let extracted = chat.extract_conversation_keys(&[kc]); - conv_keys.extend(extracted.keys); + chat.decrypt_events(&[kc], &[]); } - let event = chat.decrypt_event(&encoded_event, &conv_keys, &sender_signing_keys)?; + let event = chat.decrypt_event(&encoded_event, &Default::default(), &[])?; ``` ```go - if keyChange != "" { - extracted, _ := chat.ExtractConversationKeys([]string{keyChange}) - for v, k := range extracted.Keys { - convKeys[v] = k + // Once per instance: fill the signing-key store ([]SigningKeyEntry + // from GET /2/users/{id}/public_keys) and enable the key cache + if err := chat.SetSigningKeys(participantSigningKeys); err != nil { + log.Fatal(err) + } + chat.SetCacheKeys(true) + + // Initial load: batch decrypt — per-event failures land in result.Errors + result, err := chat.DecryptEvents(allEventsB64, nil) + if err != nil { + log.Fatal(err) + } + for _, dm := range result.Messages { + if dm.Event.Type == "Message" { + fmt.Println(dm.Event.AsMessage().Text()) } } - event, err := chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys) + + // Live traffic: a rotation enters the key cache only after its + // signature verifies, which is what DecryptEvents does + if keyChange != "" { + chat.DecryptEvents([]string{keyChange}, nil) + } + event, err := chat.DecryptEvent(encodedEvent, nil, nil) if err == nil && event.Type == "Message" { fmt.Println(event.AsMessage().Text()) } @@ -965,24 +992,49 @@ Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, ```csharp - if (!string.IsNullOrEmpty(keyChangeB64)) + // Once per instance: fill the signing-key store (SigningKeyEntry list + // from GET /2/users/{id}/public_keys) and enable the key cache + chat.SetSigningKeys(participantSigningKeys); + chat.SetCacheKeys(true); + + // Initial load: batch decrypt — per-event failures land in result.Errors + var result = chat.DecryptEvents(allEventsB64); + foreach (var dm in result.Messages) { - var extracted = chat.ExtractConversationKeys(new[] { keyChangeB64 }); - foreach (var kv in extracted.Keys) - convKeys[kv.Key] = kv.Value; + if (dm.Event.GetProperty("type").GetString() == "Message") + Console.WriteLine(dm.Event.GetProperty("content").GetProperty("text").GetString()); } - var evt = chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys); + + // Live traffic: a rotation enters the key cache only after its + // signature verifies, which is what DecryptEvents does + if (!string.IsNullOrEmpty(keyChangeB64)) + chat.DecryptEvents(new[] { keyChangeB64 }); + var evt = chat.DecryptEvent(encodedEvent); // throws on failure if (evt.GetProperty("type").GetString() == "Message") Console.WriteLine(evt.GetProperty("content").GetProperty("text").GetString()); ``` ```java + // Once per instance: fill the signing-key store (SigningKeyEntry list + // from GET /2/users/{id}/public_keys) and enable the key cache + chat.setSigningKeys(participantSigningKeys); + chat.setCacheKeys(true); + + // Initial load: batch decrypt — per-event failures land in result.errors + DecryptEventsResult result = chat.decryptEvents(allEventsB64, null); + for (DecryptedMessage dm : result.messages) { + if ("Message".equals(dm.event.path("type").asText())) { + System.out.println(dm.event.path("content").path("text").asText()); + } + } + + // Live traffic: a rotation enters the key cache only after its + // signature verifies, which is what decryptEvents does if (keyChangeB64 != null && !keyChangeB64.isEmpty()) { - var extracted = chat.extractConversationKeys(List.of(keyChangeB64)); - convKeys.putAll(extracted.keys); + chat.decryptEvents(List.of(keyChangeB64), null); } - JsonNode evt = chat.decryptEvent(encodedEvent, convKeys, senderSigningKeys); + JsonNode evt = chat.decryptEvent(encodedEvent, (Map) null, null); if ("Message".equals(evt.path("type").asText())) { System.out.println(evt.path("content").path("text").asText()); } @@ -990,33 +1042,15 @@ Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, + +**Serverless or multi-instance?** The signing-key store and key cache live in the SDK instance's memory. Where that doesn't fit—one invocation decrypts, another sends—pass keys explicitly instead: `decrypt_events(events, signing_keys)`, `decrypt_event(event_b64, conversation_keys, signing_keys)`, and the `conversation_key`/`conversation_key_version` overrides on the encrypt methods. Persist the `conversation_keys` returned by `decrypt_events` yourself and pass them back in. + + Complete poll-and-reply bots for every language: [chat-xdk/examples](https://github.com/xdevplatform/chat-xdk/tree/main/examples). --- ## Best practices -- Cache raw conversation keys and sender public keys; refresh on signature verification failures +- Keep the signing-key store fresh: re-call `set_signing_keys` with the full participant set when a sender registers a new key version, and refresh on signature verification failures - Deduplicate live deliveries with `event_uuid` -- Page event history until pagination is complete so key-change metadata is not missed -- Do not log passcodes, private keys, or message plaintext in production -- In web apps, keep OAuth tokens (and key backup realm token minting) on a server; prefer holding private keys only in the client Chat XDK - ---- - -## Next steps - - - - Methods and types for all language bindings - - - Encrypted images and file attachments - - - Multi-participant conversations and metadata - - - Webhooks and activity delivery - - diff --git a/xchat/groups.mdx b/xchat/groups.mdx index a95a6fc53..8e7de8cfc 100644 --- a/xchat/groups.mdx +++ b/xchat/groups.mdx @@ -29,7 +29,7 @@ Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the gro 1. Mint the group id with `POST /2/chat/conversations/group/initialize` — the response's `data.conversation_id` is the g-prefixed id you use everywhere below. 2. Load each member's identity public key and `public_key_version` (`GET` public-key routes under **Encryption keys**; [`GET /2/users/public_keys`](/x-api/users/get-public-keys-for-multiple-users) fetches several users in one request). Verify each record with `verify_key_binding` before using it (see the warning in [Getting Started](/xchat/getting-started#4-set-up-conversation-keys)). -3. Run **`prepare_group_create`** once, with **all** members (including yourself), the g-prefixed id, and the member/admin id lists. One call generates the conversation key, wraps it for every member, and signs the create — it returns **two** action signatures (the conversation-key change and the group create). +3. Run **`prepare_group_create`** once, with **all** members (including yourself), the g-prefixed id, and the member/admin id lists. One call generates the conversation key, wraps it for every member, and signs the create with the session identity from `set_identity` — it returns **two** action signatures (the conversation-key change and the group create). 4. `POST /2/chat/conversations/group` with the group members/admins, `conversation_key_version`, `conversation_participant_keys` (SDK **`encrypted_key`** → API **`encrypted_conversation_key`**), and **both** `action_signatures`. Validation failures come back as stable, human-readable messages, for example `"Too many members: adding these members would exceed the allowed group size."` or `"Cannot add all members: one or more of the requested members cannot be added to this conversation."`. 5. Keep the **raw** conversation key and **version** for encrypt/decrypt. @@ -38,8 +38,9 @@ Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the gro ```python + # chat has keys loaded and set_identity called (see Getting Started) prepared = chat.prepare_group_create( - "YOUR_USER_ID", signing_key_version, member_public_keys, + member_public_keys, group_id, # g-prefixed id from POST /2/chat/conversations/group/initialize member_ids, admin_ids, title="Project team", ) @@ -50,8 +51,9 @@ Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the gro ```typescript + // chat has keys loaded and setIdentity called (see Getting Started) const prepared = chat.prepareGroupCreate({ - senderId: myUserId, signingKeyVersion, publicKeys: memberPublicKeys, + publicKeys: memberPublicKeys, conversationId: groupId, // g-prefixed id from POST /2/chat/conversations/group/initialize memberIds, adminIds, title: 'Project team', }); @@ -60,9 +62,9 @@ Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the gro ```rust + // chat has keys loaded and set_identity called (see Getting Started) let mut params = GroupCreateParams::new( - &sender_id, &signing_key_version, member_public_keys, - &group_id, member_ids, admin_ids, + member_public_keys, &group_id, member_ids, admin_ids, ); params.title = Some("Project team".into()); let prepared = chat.prepare_group_create(params)?; @@ -71,8 +73,8 @@ Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the gro ```go + // chat has keys loaded and SetIdentity called (see Getting Started) prepared, err := chat.PrepareGroupCreate(chatxdk.GroupCreateParams{ - SenderID: myUserID, SigningKeyVersion: signingKeyVersion, PublicKeys: memberPublicKeys, ConversationID: groupID, MemberIDs: memberIDs, AdminIDs: adminIDs, Title: "Project team", }) @@ -83,23 +85,20 @@ Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the gro ```csharp - var prepared = chat.PrepareGroupCreate(new GroupCreateParams { - SenderId = myUserId, SigningKeyVersion = signingKeyVersion, - PublicKeys = memberPublicKeys, ConversationId = groupId, - MemberIds = memberIds, AdminIds = adminIds, Title = "Project team", - }); + // chat has keys loaded and SetIdentity called (see Getting Started) + var prepared = chat.PrepareGroupCreate( + new GroupCreateParams(memberPublicKeys, groupId, memberIds, adminIds) + { + Title = "Project team", + }); // prepared.ActionSignatures has two entries — send both ``` ```java - GroupCreateParams params = new GroupCreateParams(); - params.senderId = myUserId; - params.signingKeyVersion = signingKeyVersion; - params.publicKeys = memberPublicKeys; - params.conversationId = groupId; - params.memberIds = memberIds; - params.adminIds = adminIds; + // chat has keys loaded and setIdentity called (see Getting Started) + GroupCreateParams params = + new GroupCreateParams(memberPublicKeys, groupId, memberIds, adminIds); params.title = "Project team"; PreparedConversationChange prepared = chat.prepareGroupCreate(params); // prepared.actionSignatures has two entries — send both diff --git a/xchat/media.mdx b/xchat/media.mdx index 11896d4a8..840cce6f9 100644 --- a/xchat/media.mdx +++ b/xchat/media.mdx @@ -122,23 +122,19 @@ Use the request bodies on the OpenAPI pages under **API reference → Media**. P ## Send with an attachment -Encrypt with a media attachment, then POST the send-message body (same field mapping as [Getting Started](/xchat/getting-started#5-send-a-message)). +Encrypt with a media attachment, then POST the send-message body (same field mapping as [Getting Started](/xchat/getting-started#5-send-a-message)). The SDK generates the `message_id` and returns it on the payload—send that value, and reuse the same payload on retries so an id is never minted twice. ```python - import uuid from xdk.chat.models import SendMessageRequest - message_id = str(uuid.uuid4()) + # chat has keys loaded and set_identity called (see Getting Started) payload = chat.encrypt_message( - message_id, - sender_id, conversation_id, - raw_conv_key, caption or "", - conversation_key_version, - signing_key_version, + conversation_key=raw_conv_key, + conversation_key_version=conversation_key_version, attachments=[{ "attachment_type": "media", "media_hash_key": media_hash_key, @@ -151,7 +147,7 @@ Encrypt with a media attachment, then POST the send-message body (same field map client.chat.send_message( conversation_id.replace(":", "-"), SendMessageRequest( - message_id=message_id, + message_id=payload.message_id, # generated by the SDK encoded_message_create_event=payload.encrypted_content, encoded_message_event_signature=payload.encoded_event_signature, ), @@ -160,26 +156,23 @@ Encrypt with a media attachment, then POST the send-message body (same field map ```typescript - const messageId = crypto.randomUUID(); + // chat has keys loaded and setIdentity called (see Getting Started) const payload = chat.encryptMessage({ - messageId, - senderId, conversationId, - conversationKey: rawConvKey, text: caption || '', + conversationKey: rawConvKey, conversationKeyVersion, - signingKeyVersion, attachments: [{ - attachmentType: 'media', - mediaHashKey: mediaHashKey, + attachment_type: 'media', + media_hash_key: mediaHashKey, width, height, - filesizeBytes: plaintext.byteLength, + filesize_bytes: plaintext.byteLength, filename: 'photo.jpg', }], }); await client.chat.sendMessage(conversationId.replace(/:/g, '-'), { - message_id: messageId, + message_id: payload.messageId, // generated by the SDK encoded_message_create_event: payload.encryptedContent, encoded_message_event_signature: payload.encodedEventSignature, }); @@ -187,10 +180,23 @@ Encrypt with a media attachment, then POST the send-message body (same field map ```rust - // Set attachments on EncryptMessageParams per chat_xdk_core AttachmentDescriptor::Media - let payload = chat.encrypt_message(params_with_media_attachment)?; + use chat_xdk_core::{AttachmentDescriptor, EncryptMessageParams}; + + // chat has keys loaded and set_identity called (see Getting Started) + let mut params = EncryptMessageParams::new(&conversation_id, caption) + .with_conversation_key(conv_key.to_bytes(), &conversation_key_version); + params.attachments = Some(vec![AttachmentDescriptor::Media { + media_hash_key: media_hash_key.clone(), + width, + height, + filesize_bytes: plaintext.len() as i64, + filename: "photo.jpg".into(), + media_type: None, + duration_millis: None, + }]); + let payload = chat.encrypt_message(params)?; let body = serde_json::json!({ - "message_id": message_id, + "message_id": payload.message_id, // generated by the SDK "encoded_message_create_event": payload.encrypted_content, "encoded_message_event_signature": payload.encoded_event_signature, }); @@ -203,10 +209,12 @@ Encrypt with a media attachment, then POST the send-message body (same field map ```go + // chat has keys loaded and SetIdentity called (see Getting Started) payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{ - MessageID: messageID, SenderID: senderID, ConversationID: conversationID, - ConversationKey: rawConvKey, Text: caption, - ConversationKeyVersion: conversationKeyVersion, SigningKeyVersion: signingKeyVersion, + ConversationID: conversationID, + Text: caption, + ConversationKey: rawConvKey, + ConversationKeyVersion: conversationKeyVersion, Attachments: []chatxdk.AttachmentDescriptor{{ AttachmentType: "media", MediaHashKey: mediaHashKey, @@ -216,42 +224,44 @@ Encrypt with a media attachment, then POST the send-message body (same field map Filename: "photo.jpg", }}, }) - // POST payload.EncryptedContent / EncodedEventSignature to /2/chat/conversations/{id}/messages + // POST payload.MessageID (generated by the SDK), payload.EncryptedContent, + // and payload.EncodedEventSignature to /2/chat/conversations/{id}/messages ``` ```csharp - var payload = chat.EncryptMessage(new EncryptMessageParams { - MessageId = messageId, - SenderId = senderId, - ConversationId = conversationId, + // chat has keys loaded and SetIdentity called (see Getting Started) + var payload = chat.EncryptMessage(new EncryptMessageParams(conversationId, caption ?? "") + { ConversationKey = rawConvKey, - Text = caption ?? "", ConversationKeyVersion = conversationKeyVersion, - SigningKeyVersion = signingKeyVersion, - // Attachments = media descriptor with MediaHashKey, Width, Height, - // FilesizeBytes, and Filename (as in the Go tab above) + Attachments = new[] + { + AttachmentDescriptor.Media(mediaHashKey, width, height, plaintext.Length, "photo.jpg"), + }, }); - // POST EncryptedContent / EncodedEventSignature as for text messages + // POST payload.MessageId (generated by the SDK), payload.EncryptedContent, + // and payload.EncodedEventSignature as for text messages ``` ```java - EncryptMessageParams params = new EncryptMessageParams(); - params.messageId = messageId; - params.senderId = senderId; - params.conversationId = conversationId; + // chat has keys loaded and setIdentity called (see Getting Started) + EncryptMessageParams params = + new EncryptMessageParams(conversationId, caption != null ? caption : ""); params.conversationKey = rawConvKey; - params.text = caption != null ? caption : ""; params.conversationKeyVersion = conversationKeyVersion; - params.signingKeyVersion = signingKeyVersion; - // params.attachments — media type with mediaHashKey, width, height, filename + params.attachments = List.of(AttachmentDescriptor.media( + mediaHashKey, width, height, plaintext.length, "photo.jpg", null, null)); SendPayload payload = chat.encryptMessage(params); - // POST to /2/chat/conversations/{id}/messages + // POST payload.messageId (generated by the SDK), payload.encryptedContent, + // and payload.encodedEventSignature to /2/chat/conversations/{id}/messages ``` +The conversation key pair can be omitted entirely: with `set_cache_keys(true)` enabled, `encrypt_message` resolves the key and version from the conversation's latest verified key change (see [Getting Started](/xchat/getting-started)). + --- ## Download and decrypt diff --git a/xchat/real-time-events.mdx b/xchat/real-time-events.mdx index 1c1c5245f..b2f02a3af 100644 --- a/xchat/real-time-events.mdx +++ b/xchat/real-time-events.mdx @@ -10,7 +10,7 @@ X delivers **`chat.received`**, **`chat.sent`**, and related X Chat activity wit |:------|:-----| | **X Activity API** | `GET /2/activity/stream`; `POST` / `GET` / `PUT` / `DELETE` `/2/activity/subscriptions` (see OpenAPI security per operation) | | **Webhooks** | Optional `POST` / `GET` `/2/webhooks` and `PUT` / `DELETE` `/2/webhooks/{webhook_id}` routes if you terminate on your own HTTPS URL | -| **Chat XDK** | `extract_conversation_keys`, `decrypt_event` / `decrypt_events` | +| **Chat XDK** | `decrypt_event` / `decrypt_events`, with the `set_signing_keys` / `set_cache_keys` session stores | Private X Chat event types need authorization for the user you monitor. Encrypted X Chat file attachments use **`media_hash_key`** and X Chat media download—not Post API `expansions=attachments.media_keys` / `media.fields=variants`. @@ -86,118 +86,76 @@ If you use webhooks, respond to Challenge-Response Checks (GET `crc_token`) with ## 3. Decrypt with the Chat XDK -Live fields: **`payload.encoded_event`**, optional **`payload.conversation_key_change_event`**. Deduplicate on **`event_uuid`**. +Live fields: **`payload.encoded_event`**, optional **`payload.conversation_key_change_event`**. Deduplicate deliveries on **`event_uuid`**; deduplicate messages on the **`message_id`** carried in the decrypted event—it is part of the signed content, while sequence ids are backend-assigned, unsigned metadata. + +The snippets below use the two **optional** session stores for the shortest handler: `set_signing_keys` holds the participants' public keys (fetched once from the [public-keys endpoint](/x-api/chat/get-user-public-keys)), and `set_cache_keys(true)` keeps each conversation's verified key, so `decrypt_event` needs only the event. When a payload carries `conversation_key_change_event`, run it through `decrypt_events` first: that verifies the key change and, with caching on, retains its key for the `decrypt_event` call. Prefer no instance state? Pass the keys per call instead—see the note at the end of this section. JavaScript uses camelCase event types (`message`); other bindings use `"Message"` and snake_case fields. ```python - from chat_xdk import Chat - - chat = Chat(JUICEBOX_CONFIG_JSON) - chat.unlock("YOUR_PASSCODE") - chat.set_key_version(SIGNING_KEY_VERSION) - conversation_keys = {} - - def signing_keys(user_id: str): - resp = api_client.chat.get_user_public_keys( - user_id, - public_key_fields=[ - "public_key_version", "public_key", "signing_public_key", "identity_public_key_signature", - ], - ) - return [ - { - "user_id": user_id, - "public_key_version": r["public_key_version"], - "public_key": r["signing_public_key"], - "identity_public_key": r["public_key"], - "identity_public_key_signature": r["identity_public_key_signature"], - } - for r in resp.data - ] + # chat has keys loaded and set_identity called (see Getting Started) + chat.set_cache_keys(True) + chat.set_signing_keys(participant_signing_keys) # all participants, from the public-key routes data = body.get("data") or {} if data.get("event_type") in ("chat.received", "chat.sent"): p = data.get("payload") or {} - cid = p.get("conversation_id") if p.get("conversation_key_change_event"): - conversation_keys[cid] = chat.extract_conversation_keys( - [p["conversation_key_change_event"]] - )["keys"] - ev = chat.decrypt_event( - p["encoded_event"], - conversation_keys.get(cid, {}), - signing_keys(p["sender_id"]), - ) + # Verify the key change and retain its key in the cache + chat.decrypt_events([p["conversation_key_change_event"]]) + ev = chat.decrypt_event(p["encoded_event"]) + if ev["type"] == "Message": + print(ev["sender_id"], ev["content"]["text"]) ``` ```typescript - import { createChat } from '@xdevplatform/chat-xdk'; - - const chat = await createChat({ - juiceboxConfig: JUICEBOX_CONFIG_JSON, - getAuthToken: async (realmId) => getRealmToken(realmId), - }); - await chat.unlock('YOUR_PASSCODE'); - chat.setKeyVersion(SIGNING_KEY_VERSION); - const conversationKeys = new Map>(); - - async function signingKeys(userId: string) { - const resp = await apiClient.chat.getUserPublicKeys(userId, { - publicKeyFields: [ - 'public_key_version', 'public_key', 'signing_public_key', 'identity_public_key_signature', - ], - }); - return resp.data.map((r: any) => ({ - userId, - publicKeyVersion: r.public_key_version, - publicKey: r.signing_public_key, - identityPublicKey: r.public_key, - identityPublicKeySignature: r.identity_public_key_signature, - })); - } + // chat has keys loaded and setIdentity called (see Getting Started) + chat.setCacheKeys(true); + chat.setSigningKeys(participantSigningKeys); // all participants, from the public-key routes const data = body?.data ?? {}; if (data.event_type === 'chat.received' || data.event_type === 'chat.sent') { const p = data.payload ?? {}; - const cid = p.conversation_id as string; if (p.conversation_key_change_event) { - conversationKeys.set( - cid, - chat.extractConversationKeys([p.conversation_key_change_event]).keys, - ); + // Verify the key change and retain its key in the cache + chat.decryptEvents([p.conversation_key_change_event]); + } + const ev = chat.decryptEvent(p.encoded_event); + if (ev.type === 'message') { + console.log(ev.senderId, ev.content.text); } - const ev = chat.decryptEvent( - p.encoded_event, - conversationKeys.get(cid) ?? {}, - await signingKeys(p.sender_id), - ); } ``` ```rust - // chat: ChatCore or Chat, already unlocked / keys imported + // chat has keys loaded and set_identity called (see Getting Started) + chat.set_cache_keys(true); + chat.set_signing_keys(participant_signing_keys); // all participants + if let Some(kc) = key_change.as_deref() { - let extracted = chat.extract_conversation_keys(&[kc]); - conv_keys.extend(extracted.keys); + // Verify the key change and retain its key in the cache + let _ = chat.decrypt_events(&[kc], &[]); } - // sender_signing_keys from GET /2/users/{sender_id}/public_keys - let event = chat.decrypt_event(&encoded_event, &conv_keys, &sender_signing_keys)?; + // Decrypt with the cached conversation key; verify against the stored signing keys + let event = chat.decrypt_event(&encoded_event, &Default::default(), &[])?; ``` ```go + // chat has keys loaded and SetIdentity called (see Getting Started) + chat.SetCacheKeys(true) + _ = chat.SetSigningKeys(participantSigningKeys) // all participants + if keyChange != "" { - extracted, _ := chat.ExtractConversationKeys([]string{keyChange}) - for v, k := range extracted.Keys { - convKeys[v] = k - } + // Verify the key change and retain its key in the cache + _, _ = chat.DecryptEvents([]string{keyChange}, nil) } - event, err := chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys) + // Decrypt with the cached conversation key; verify against the stored signing keys + event, err := chat.DecryptEvent(encodedEvent, nil, nil) if err == nil && event.Type == "Message" { fmt.Println(event.AsMessage().Text()) } @@ -205,24 +163,33 @@ JavaScript uses camelCase event types (`message`); other bindings use `"Message" ```csharp + // chat has keys loaded and SetIdentity called (see Getting Started) + chat.SetCacheKeys(true); + chat.SetSigningKeys(participantSigningKeys); // all participants + if (!string.IsNullOrEmpty(keyChangeB64)) { - var extracted = chat.ExtractConversationKeys(new[] { keyChangeB64 }); - foreach (var kv in extracted.Keys) - convKeys[kv.Key] = kv.Value; + // Verify the key change and retain its key in the cache + chat.DecryptEvents(new[] { keyChangeB64 }); } - var evt = chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys); + // Decrypt with the cached conversation key; verify against the stored signing keys + var evt = chat.DecryptEvent(encodedEvent); if (evt.GetProperty("type").GetString() == "Message") Console.WriteLine(evt.GetProperty("content").GetProperty("text").GetString()); ``` ```java + // chat has keys loaded and setIdentity called (see Getting Started) + chat.setCacheKeys(true); + chat.setSigningKeys(participantSigningKeys); // all participants + if (keyChangeB64 != null && !keyChangeB64.isEmpty()) { - var extracted = chat.extractConversationKeys(List.of(keyChangeB64)); - convKeys.putAll(extracted.keys); + // Verify the key change and retain its key in the cache + chat.decryptEvents(List.of(keyChangeB64), null); } - JsonNode evt = chat.decryptEvent(encodedEvent, convKeys, senderSigningKeys); + // Decrypt with the cached conversation key; verify against the stored signing keys + JsonNode evt = chat.decryptEvent(encodedEvent, (Map) null, null); if ("Message".equals(evt.path("type").asText())) { System.out.println(evt.path("content").path("text").asText()); } @@ -230,6 +197,8 @@ JavaScript uses camelCase event types (`message`); other bindings use `"Message" +To keep the key maps in your own hands instead, `extract_conversation_keys` decrypts the keys from `conversation_key_change_event` and `decrypt_event` accepts them (and the sender's signing keys) as explicit arguments—an explicit non-empty argument always wins over the stores. + History: [`GET /2/chat/conversations/{id}/events`](/x-api/chat/get-chat-conversation-events) + **`decrypt_events`** — see [Getting Started](/xchat/getting-started#6-receive-and-decrypt). --- @@ -257,6 +226,6 @@ History: [`GET /2/chat/conversations/{id}/events`](/x-api/chat/get-chat-conversa ## Practices - Verify webhook signatures per platform requirements -- Cache conversation keys and sender public keys -- Apply key-change blobs before decrypting dependent messages -- Deduplicate on `event_uuid` +- Set the session stores once: `set_signing_keys` for all participants, `set_cache_keys(true)` for conversation keys +- Apply key-change blobs (via `decrypt_events`) before decrypting dependent messages +- Deduplicate deliveries on `event_uuid` and messages on the signed `message_id` diff --git a/xchat/troubleshooting.mdx b/xchat/troubleshooting.mdx index 202983922..6c4642a69 100644 --- a/xchat/troubleshooting.mdx +++ b/xchat/troubleshooting.mdx @@ -62,55 +62,55 @@ For webhooks, OAuth, HTTP status codes, and rate limits, use the general [X API] -### Encrypt or decrypt fails because keys are not loaded +### Encrypt or decrypt fails because keys or identity are not set -Load private keys first, then set the public-key **version** from your record on X. +Load private keys first, then set the **session identity**—your user id plus the `public_key_version` from your record on X. The `encrypt_*` and `prepare_*` methods sign with it; calling them with no session identity (and no explicit per-call override) is an error. ```python chat.unlock(passcode) # or: chat.import_keys(blob) - chat.set_key_version(signing_key_version) + chat.set_identity(my_user_id, signing_key_version) ``` ```typescript await chat.unlock(passcode); - chat.setKeyVersion(signingKeyVersion); + chat.setIdentity(myUserId, signingKeyVersion); ``` ```rust chat.import_keys(&blob)?; - chat.set_key_version(&signing_key_version); + chat.set_identity(&my_user_id, &signing_key_version); ``` ```go blob, _ := chatxdk.Base64ToBytes(privateKeysB64) _ = chat.ImportKeys(blob) - chat.SetKeyVersion(signingKeyVersion) + _ = chat.SetIdentity(myUserID, signingKeyVersion) ``` ```csharp chat.ImportKeys(blobBytes); - chat.SetKeyVersion(signingKeyVersion); + chat.SetIdentity(myUserId, signingKeyVersion); ``` ```java chat.importKeys(blobBytes); - chat.setKeyVersion(signingKeyVersion); + chat.setIdentity(myUserId, signingKeyVersion); ``` ### Missing conversation key for a message -You do not have the **raw** key for that message’s `conversation_key_version`. +An error like `Message encrypted with key version '…' but no matching key found` means you do not have the **raw** key for that message’s `conversation_key_version`. -1. Decrypt key material from `conversation_key_change_event` (live events) or `meta.conversation_key_events` (history) with `extract_conversation_keys`, **or** include those blobs in `decrypt_events` +1. Decrypt key material from `conversation_key_change_event` (live events) or `meta.conversation_key_events` (history) with `extract_conversation_keys`, **or** include those blobs in `decrypt_events`—with `set_cache_keys(true)` enabled, `decrypt_events` also retains each conversation’s latest verified key so later `decrypt_event` and `encrypt_*` calls can omit it 2. Confirm conversation keys were added for that version and you are still a participant (see [Getting Started](/xchat/getting-started#4-set-up-conversation-keys)) ### Peer has no public keys @@ -132,6 +132,7 @@ They may not have finished onboarding. After they register, load `public_key`, ` Verification is **fail-closed by default** (`reject_unverified = true`): the SDK already rejects unverified signed events, so a failure here means the verification inputs are wrong, not that you need to turn checking on. Common causes: - Missing or incomplete signing-key entry for the **sender** (all fields required by the Chat XDK—see the [Chat XDK](/xchat/xchat-xdk) reference) +- No signing keys passed on the call and none stored via `set_signing_keys` - The sender rotated versions—re-fetch their public keys - A key version below the accepted floor never verifies @@ -170,6 +171,10 @@ The `set_reject_unverified` setter exists to opt **out** of this default (`false +### A reply carries `reply_preview_validation: "Invalid"` + +Decrypted replies may carry `reply_preview_validation` (`"Valid"` / `"Invalid"`; JavaScript uses `'valid'` / `'invalid'`). `Invalid` means the quoted preview inside the message does not match the signed original event it embeds—treat the quote as untrusted and render the quoted content only from the validated original. The message itself is verified separately and is still authentic; nothing throws for an invalid preview. + ### Old events permanently fail verification Errors like `signature missing or no matching signing key` or an ECDSA mismatch on **old** events are permanent. Signatures are immutable and verified by rebuilding the signed payload from the event itself, so an event that was signed over different bytes (or never signed) fails on every future load—no retry, key refresh, or API call can heal it. Treat these events as tombstones, not retryable errors. Rotating the conversation key starts a clean, verifiable history from that point forward; new messages are unaffected. @@ -184,8 +189,8 @@ These mistakes are specific to X Chat encryption (not general HTTP errors): |:------|:----| | Wrong key bytes | Pass the **raw** conversation key bytes into the Chat XDK, not the encrypted key string from the API | | Wrong JSON field names | Map `encrypted_content` → `encoded_message_create_event` and `encoded_event_signature` → `encoded_message_event_signature` | -| Missing message id | Generate `message_id` yourself and send the same value in the request body | -| Version mismatch | Align `conversation_key_version` with the key you use; align signing key version with `set_key_version` / your public-key record | +| Wrong message id | Send the `message_id` from the returned payload—the SDK generates it and embeds it in the signed event, so any other value fails. On retries, reuse the same encrypted payload so the id is never minted twice | +| Version mismatch | Align `conversation_key_version` with the key you use; align the signing key version passed to `set_identity` with your public-key record | | Path id form | URL paths still need the hyphenated conversation id (`:` → `-`), but for signing the SDK accepts any form: `A:B`, `A-B` (either order), or the bare recipient user id—all canonicalize to the same signed bytes | ### API returns 400 for a state-changing call @@ -210,5 +215,5 @@ When investigating crypto failures: - Log conversation ids, event ids, and key **versions** only - Do **not** log plaintext, passcodes, private keys, or full key blobs -- Confirm `set_key_version` matches the `public_key_version` on your public-key record +- Confirm the signing-key version passed to `set_identity` matches the `public_key_version` on your public-key record - For incomplete history, page **all** event pages so key-change metadata is not skipped before decrypting diff --git a/xchat/xchat-xdk.mdx b/xchat/xchat-xdk.mdx index 9518bfa35..dae926384 100644 --- a/xchat/xchat-xdk.mdx +++ b/xchat/xchat-xdk.mdx @@ -32,7 +32,7 @@ App walkthrough: [Getting Started](/xchat/getting-started). Sample bots: [chat-x [dependencies] # chat-xdk-core is not yet on crates.io — use the git dependency. # It exports both ChatCore and the async secure-key-backup Chat type. - chat-xdk-core = { git = "https://github.com/xdevplatform/chat-xdk", tag = "v0.2.1" } + chat-xdk-core = { git = "https://github.com/xdevplatform/chat-xdk", tag = "v0.4.0" } # Required until thrift 0.24 is released on crates.io [patch.crates-io] @@ -58,7 +58,7 @@ App walkthrough: [Getting Started](/xchat/getting-started). Sample bots: [chat-x com.x chatxdk - 0.2.1 + 0.4.0 ``` @@ -70,31 +70,37 @@ App walkthrough: [Getting Started](/xchat/getting-started). Sample bots: [chat-x ## Quick start -Decrypt a backlog, cache keys, decrypt one event, encrypt a reply. Wire the send body to [`POST /2/chat/conversations/{id}/messages`](/x-api/chat/send-chat-message) as in [Getting Started](/xchat/getting-started). +Load keys, set your identity once, decrypt a backlog, decrypt one live event, encrypt a message. Wire the send body to [`POST /2/chat/conversations/{id}/messages`](/x-api/chat/send-chat-message) as in [Getting Started](/xchat/getting-started). + +The snippets use the two **optional** session stores for the shortest call forms: `set_signing_keys` holds the other participants' public keys (fetched from the [public-keys endpoint](/x-api/chat/get-user-public-keys)) so decrypt calls can verify senders without a per-call argument, and `set_cache_keys(true)` lets the SDK remember each conversation's verified key so encrypt calls need only the conversation id and text. Skip either and pass the same values per call instead—both styles verify identically; see [Decrypt](#decrypt). ```python from chat_xdk import Chat - chat = Chat(juicebox_config_json) # or Chat() + import_keys(blob) + chat = Chat(juicebox_config_json) # or Chat() + import_keys(blob, version) chat.unlock("YOUR_PASSCODE") - chat.set_key_version(signing_key_version) - result = chat.decrypt_events(raw_events, signing_keys) + # Session defaults: identity for signing, stored signing keys for + # verification, opt-in cache for conversation keys + chat.set_identity(my_user_id, signing_key_version) + chat.set_signing_keys(signing_keys) # all participants + chat.set_cache_keys(True) + + # Batch-decrypt the backlog; senders verify against the stored keys + result = chat.decrypt_events(raw_events) for dm in result["messages"]: ev = dm["event"] - if ev.get("type") == "Message": - print(ev.get("sender_id"), ev.get("content", {}).get("text")) + if ev["type"] == "Message": + print(ev["sender_id"], ev["content"]["text"]) - cached = result["conversation_keys"]["keys"] - event = chat.decrypt_event(one_event_b64, cached, sender_signing_keys) + # Decrypt one live event with the cached conversation key + event = chat.decrypt_event(one_event_b64) - raw_key = cached[result["conversation_keys"]["latest_version"]] - payload = chat.encrypt_message( - message_id, sender_id, conversation_id, raw_key, "Hi!", - conversation_key_version, signing_key_version, - ) + # Encrypt and sign as the session identity, under the cached key + payload = chat.encrypt_message(event["conversation_id"], "Hi!") + message_id = payload.message_id # SDK-generated — send as message_id ``` @@ -106,38 +112,53 @@ Decrypt a backlog, cache keys, decrypt one event, encrypt a reply. Wire the send getAuthToken: async (realmId) => getRealmToken(realmId), }); await chat.unlock('YOUR_PASSCODE'); - chat.setKeyVersion(signingKeyVersion); - const result = chat.decryptEvents(rawEvents, signingKeys); + // Session defaults: identity for signing, stored signing keys for + // verification, opt-in cache for conversation keys + chat.setIdentity(myUserId, signingKeyVersion); + chat.setSigningKeys(signingKeys); // all participants + chat.setCacheKeys(true); + + // Batch-decrypt the backlog; senders verify against the stored keys + const result = chat.decryptEvents(rawEvents); for (const dm of result.messages) { if (dm.event.type === 'message') { console.log(dm.event.senderId, dm.event.content?.text); } } - const cached = result.conversationKeys.keys; - const event = chat.decryptEvent(oneEventB64, cached, senderSigningKeys); + // Decrypt one live event with the cached conversation key + const event = chat.decryptEvent(oneEventB64); - const rawKey = cached[result.conversationKeys.latestVersion!]; - const payload = chat.encryptMessage({ - messageId, senderId, conversationId, conversationKey: rawKey, text: 'Hi!', - conversationKeyVersion, signingKeyVersion, - }); + // Encrypt and sign as the session identity, under the cached key + const payload = chat.encryptMessage({ conversationId: event.conversationId!, text: 'Hi!' }); + const messageId = payload.messageId; // SDK-generated — send as message_id ``` ```rust - // ChatCore + import_keys, or chat_xdk_core::Chat + unlock().await - let result = chat.decrypt_events(&raw_events, &signing_keys); - let cached = &result.conversation_keys.keys; - let event = chat.decrypt_event(one_event_b64, cached, &sender_signing_keys)?; - // cached values are XChatConversationKey; encrypt_message wants owned bytes - let latest = result.conversation_keys.latest_version.as_deref().unwrap_or_default(); - let conv_key = cached[latest].to_bytes(); - let payload = chat.encrypt_message(EncryptMessageParams::new( - &message_id, &sender_id, &conversation_id, conv_key, "Hi!", - &conversation_key_version, &signing_key_version, - ))?; + // chat_xdk_core::Chat + unlock(b"…").await, or ChatCore + import_keys_with_version + + // Session defaults: identity for signing, stored signing keys for + // verification, opt-in cache for conversation keys + chat.set_identity(my_user_id, signing_key_version); + chat.set_signing_keys(signing_keys); // all participants + chat.set_cache_keys(true); + + // Batch-decrypt the backlog; senders verify against the stored keys + let result = chat.decrypt_events(&raw_events, &[]); + for dm in &result.messages { + if let Event::Message(msg) = &dm.event { + println!("{}: {}", msg.meta.sender_id.as_deref().unwrap_or("?"), msg.text().unwrap_or("")); + } + } + + // Decrypt one live event with the cached conversation key + let event = chat.decrypt_event(one_event_b64, &Default::default(), &[])?; + + // Encrypt and sign as the session identity, under the cached key + let payload = chat.encrypt_message(EncryptMessageParams::new(conversation_id, "Hi!"))?; + let message_id = payload.message_id; // SDK-generated — send as message_id ``` @@ -145,58 +166,90 @@ Decrypt a backlog, cache keys, decrypt one event, encrypt a reply. Wire the send chat := chatxdk.New() defer chat.Close() blob, _ := chatxdk.Base64ToBytes(privateKeysB64) - _ = chat.ImportKeys(blob) - chat.SetKeyVersion(signingKeyVersion) + _ = chat.ImportKeysWithVersion(blob, signingKeyVersion) + + // Session defaults: identity for signing, stored signing keys for + // verification, opt-in cache for conversation keys + chat.SetIdentity(myUserID, signingKeyVersion) + _ = chat.SetSigningKeys(signingKeys) // all participants + chat.SetCacheKeys(true) + + // Batch-decrypt the backlog; senders verify against the stored keys + result, err := chat.DecryptEvents(rawEvents, nil) + for _, dm := range result.Messages { + if dm.Event.Type == "Message" { + fmt.Println(dm.Event.AsMessage().Text()) + } + } - result, err := chat.DecryptEvents(rawEvents, signingKeys) - cached := result.ConversationKeys.Keys - event, err := chat.DecryptEvent(oneEventB64, cached, senderSigningKeys) - rawKey := cached[*result.ConversationKeys.LatestVersion] + // Decrypt one live event with the cached conversation key + event, err := chat.DecryptEvent(oneEventB64, nil, nil) + msg := event.AsMessage() // nil unless event.Type == "Message" + + // Encrypt and sign as the session identity, under the cached key payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{ - MessageID: messageID, SenderID: senderID, ConversationID: conversationID, - ConversationKey: rawKey, Text: "Hi!", - ConversationKeyVersion: conversationKeyVersion, SigningKeyVersion: signingKeyVersion, + ConversationID: *msg.ConversationID, + Text: "Hi!", }) - _ = event - _ = payload + messageID := payload.MessageID // SDK-generated — send as message_id + _ = messageID _ = err ``` ```csharp using var chat = new Chat(); - chat.ImportKeys(privateKeyBytes); - chat.SetKeyVersion(signingKeyVersion); + chat.ImportKeys(privateKeyBytes, signingKeyVersion); + + // Session defaults: identity for signing, stored signing keys for + // verification, opt-in cache for conversation keys + chat.SetIdentity(myUserId, signingKeyVersion); + chat.SetSigningKeys(signingKeys); // all participants + chat.SetCacheKeys(true); + + // Batch-decrypt the backlog; senders verify against the stored keys + var result = chat.DecryptEvents(rawEvents); + foreach (var dm in result.Messages) + { + if (dm.Event.GetProperty("type").GetString() == "Message") + Console.WriteLine(dm.Event.GetProperty("content").GetProperty("text").GetString()); + } - var result = chat.DecryptEvents(rawEvents, signingKeys); - var cached = result.ConversationKeys.Keys; - var evt = chat.DecryptEvent(oneEventB64, cached, senderSigningKeys); - var payload = chat.EncryptMessage(new EncryptMessageParams { - MessageId = messageId, SenderId = senderId, ConversationId = conversationId, - ConversationKey = rawKey, Text = "Hi!", - ConversationKeyVersion = conversationKeyVersion, SigningKeyVersion = signingKeyVersion, - }); + // Decrypt one live event with the cached conversation key + var evt = chat.DecryptEvent(oneEventB64); + var conversationId = evt.GetProperty("conversation_id").GetString()!; + + // Encrypt and sign as the session identity, under the cached key + var payload = chat.EncryptMessage(new EncryptMessageParams(conversationId, "Hi!")); + var messageId = payload.MessageId; // SDK-generated — send as message_id ``` ```java try (Chat chat = new Chat()) { - chat.importKeys(privateKeyBytes); - chat.setKeyVersion(signingKeyVersion); - - DecryptEventsResult result = chat.decryptEvents(rawEvents, signingKeys); - Map cached = result.conversationKeys.keys; - JsonNode event = chat.decryptEvent(oneEventB64, cached, senderSigningKeys); - - EncryptMessageParams params = new EncryptMessageParams(); - params.messageId = messageId; - params.senderId = senderId; - params.conversationId = conversationId; - params.conversationKey = rawKey; - params.text = "Hi!"; - params.conversationKeyVersion = conversationKeyVersion; - params.signingKeyVersion = signingKeyVersion; - SendPayload payload = chat.encryptMessage(params); + chat.importKeys(privateKeyBytes, signingKeyVersion); + + // Session defaults: identity for signing, stored signing keys for + // verification, opt-in cache for conversation keys + chat.setIdentity(myUserId, signingKeyVersion); + chat.setSigningKeys(signingKeys); // all participants + chat.setCacheKeys(true); + + // Batch-decrypt the backlog; senders verify against the stored keys + DecryptEventsResult result = chat.decryptEvents(rawEvents, null); + for (DecryptedMessage dm : result.messages) { + if ("Message".equals(dm.event.path("type").asText())) { + System.out.println(dm.event.path("content").path("text").asText()); + } + } + + // Decrypt one live event with the cached conversation key + JsonNode event = chat.decryptEvent(oneEventB64, (Map) null, null); + String conversationId = event.path("conversation_id").asText(); + + // Encrypt and sign as the session identity, under the cached key + SendPayload payload = chat.encryptMessage(new EncryptMessageParams(conversationId, "Hi!")); + String messageId = payload.messageId; // SDK-generated — send as message_id } ``` @@ -206,7 +259,9 @@ Decrypt a backlog, cache keys, decrypt one event, encrypt a reply. Wire the send ## Lifecycle and keys -Construct the SDK, store private keys (passcode-protected secure key backup or a local key blob), register **public** keys with the Chat API, and set your registered **public-key version** after unlock or import. Call `generate_keypairs` once per device/app identity; post the registration payload to the public-keys endpoint. Use `setup` / `unlock` (and related passcode helpers) for secure key backup on every binding. `export_keys` / `import_keys` (raw key-blob persistence for bots and servers) are available on the **native bindings only**—Python, Go, .NET, JVM, and Rust. The JS/WASM binding does not expose raw key export or import: in a browser any script that reaches the instance could exfiltrate the identity, so JS keeps keys inside secure key backup. A JS server that wants to avoid a backup-realm round-trip per request should reuse one unlocked `Chat` instance across requests, or run a native binding where key blobs are supported. +Construct the SDK, store private keys (passcode-protected secure key backup or a local key blob), register **public** keys with the Chat API, and call **`set_identity(user_id, signing_key_version)`** after unlock or import—it sets the sender and signing-key version every signed action defaults to, so the encrypt and prepare methods work without per-call identity arguments. Call `generate_keypairs` once per device/app identity; post the registration payload to the public-keys endpoint. Use `setup` / `unlock` (and related passcode helpers) for secure key backup on every binding. `export_keys` / `import_keys` (raw key-blob persistence for bots and servers) are available on the **native bindings only**—Python, Go, .NET, JVM, and Rust. The JS/WASM binding does not expose raw key export or import: in a browser any script that reaches the instance could exfiltrate the identity, so JS keeps keys inside secure key backup. A JS server that wants to avoid a backup-realm round-trip per request should reuse one unlocked `Chat` instance across requests, or run a native binding where key blobs are supported. + +The SDK also needs the version the X API reports for your registered public key, so key-change entries targeting other versions are skipped. `set_identity` records it together with the user id; `import_keys` accepts it directly as an optional argument (Rust and Go use `import_keys_with_version` / `ImportKeysWithVersion`). @@ -217,13 +272,13 @@ Construct the SDK, store private keys (passcode-protected secure key backup or a chat = Chat(juicebox_config_json) chat.setup("YOUR_PASSCODE") # first time — generates keypairs # chat.unlock("YOUR_PASSCODE") # later sessions - chat.set_key_version(version) # from add-public-key / get-public-keys response + chat.set_identity(user_id, version) # version from add-public-key / get-public-keys response reg = chat.get_public_keys() # or registration fields from generate_keypairs # Key blob (server / bot) chat2 = Chat() - chat2.import_keys(secret_blob) - chat2.set_key_version(version) + chat2.import_keys(secret_blob, version) + chat2.set_identity(user_id, version) blob = chat2.export_keys() # treat as a password ``` @@ -237,7 +292,7 @@ Construct the SDK, store private keys (passcode-protected secure key backup or a }); await chat.setup('YOUR_PASSCODE'); // await chat.unlock('YOUR_PASSCODE'); - chat.setKeyVersion(version); + chat.setIdentity(userId, version); const publics = chat.getPublicKeys(); // JS/WASM stores keys only through secure key backup — there is no raw key @@ -247,12 +302,12 @@ Construct the SDK, store private keys (passcode-protected secure key backup or a ```rust // chat_xdk_core::Chat — async secure key backup unlock, or ChatCore + import_keys - chat.setup("YOUR_PASSCODE").await?; - // chat.unlock("YOUR_PASSCODE").await?; - chat.set_key_version(&version); + chat.setup(b"YOUR_PASSCODE").await?; + // chat.unlock(b"YOUR_PASSCODE").await?; + chat.set_identity(user_id, version); let publics = chat.get_public_keys()?; let blob = chat.export_keys()?; - chat.import_keys(&blob)?; + chat.import_keys_with_version(&blob, version)?; ``` @@ -262,10 +317,10 @@ Construct the SDK, store private keys (passcode-protected secure key backup or a // Prefer ImportKeys for servers; secure key backup unlock where supported keyBlob, _ := chatxdk.Base64ToBytes(privateKeysB64) - if err := chat.ImportKeys(keyBlob); err != nil { + if err := chat.ImportKeysWithVersion(keyBlob, version); err != nil { log.Fatal(err) } - chat.SetKeyVersion(version) + chat.SetIdentity(userID, version) publics, err := chat.GetPublicKeys() blob, err := chat.ExportKeys() _ = publics @@ -276,9 +331,9 @@ Construct the SDK, store private keys (passcode-protected secure key backup or a ```csharp using var chat = new Chat(); - chat.ImportKeys(privateKeyBytes); + chat.ImportKeys(privateKeyBytes, version); // or secure key backup setup / unlock when config is available - chat.SetKeyVersion(version); + chat.SetIdentity(userId, version); var publics = chat.GetPublicKeys(); var blob = chat.ExportKeys(); ``` @@ -286,8 +341,8 @@ Construct the SDK, store private keys (passcode-protected secure key backup or a ```java try (Chat chat = new Chat()) { - chat.importKeys(privateKeyBytes); - chat.setKeyVersion(version); + chat.importKeys(privateKeyBytes, version); + chat.setIdentity(userId, version); var publics = chat.getPublicKeys(); byte[] blob = chat.exportKeys(); } @@ -303,7 +358,7 @@ Optional: signature verification is **on by default** (`reject_unverified = true ## Conversation keys -Three **prepare** methods each make one call do everything a key change needs: generate a fresh conversation key, encrypt it for every participant (from the public keys you pass), and sign the change. All return the same **`PreparedConversationChange`** shape, ready to POST—rename SDK field `encrypted_key` to **`encrypted_conversation_key`** in `conversation_participant_keys`, and map the action signatures into the required **`action_signatures`** body field. +Three **prepare** methods each make one call do everything a key change needs: generate a fresh conversation key, encrypt it for every participant (from the public keys you pass), and sign the change. The sender identity and signing-key version come from the session (`set_identity`); set `sender_id` / `signing_key_version` on the params to override. All return the same **`PreparedConversationChange`** shape, ready to POST—rename SDK field `encrypted_key` to **`encrypted_conversation_key`** in `conversation_participant_keys`, and map the action signatures into the required **`action_signatures`** body field. | Scenario | Method | Action signatures returned | |:---------|:-------|:---------------------------| @@ -327,7 +382,7 @@ Use `extract_conversation_keys` on key-change event payloads to rebuild `{ keys, # {"user_id": "1215441834412953600", "public_key": "BASE64_IDENTITY_PUBLIC_KEY", "key_version": "1733889755256"}, # {"user_id": "1843439638876491776", "public_key": "BASE64_IDENTITY_PUBLIC_KEY", "key_version": "1766181805686"}, # ] - prepared = chat.prepare_conversation_key_change(my_user_id, signing_key_version, participants) + prepared = chat.prepare_conversation_key_change(participants) # prepared["conversation_key"] — raw bytes for encrypt_message # prepared["participant_keys"] — per-user wraps; rename encrypted_key → encrypted_conversation_key on POST # prepared["action_signatures"] — required on the POST body @@ -342,9 +397,7 @@ Use `extract_conversation_keys` on key-change event payloads to rebuild `{ keys, ```typescript - const prepared = chat.prepareConversationKeyChange({ - senderId: myUserId, signingKeyVersion, publicKeys: participants, - }); + const prepared = chat.prepareConversationKeyChange({ publicKeys: participants }); // prepared.conversationKey — Uint8Array for encryptMessage // prepared.participantKeys / prepared.actionSignatures — POST body fields @@ -357,7 +410,7 @@ Use `extract_conversation_keys` on key-change event payloads to rebuild `{ keys, ```rust let prepared = chat.prepare_conversation_key_change( - ConversationKeyChangeParams::new(&my_user_id, &signing_key_version, participants), + ConversationKeyChangeParams::new(participants), )?; let extracted = chat.extract_conversation_keys(&key_change_blobs); let latest = extracted.latest_version.as_deref().unwrap_or_default(); @@ -368,7 +421,7 @@ Use `extract_conversation_keys` on key-change event payloads to rebuild `{ keys, ```go prepared, err := chat.PrepareConversationKeyChange(chatxdk.ConversationKeyChangeParams{ - SenderID: myUserID, SigningKeyVersion: signingKeyVersion, PublicKeys: participants, + PublicKeys: participants, }) // prepared.ConversationKey feeds EncryptMessage // prepared.ParticipantKeys / prepared.ActionSignatures — POST body fields @@ -382,9 +435,7 @@ Use `extract_conversation_keys` on key-change event payloads to rebuild `{ keys, ```csharp - var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams { - SenderId = myUserId, SigningKeyVersion = signingKeyVersion, PublicKeys = participants, - }); + var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams(participants)); var extracted = chat.ExtractConversationKeys(keyChangeBlobs); var raw = extracted.Keys[extracted.LatestVersion]; var one = chat.DecryptConversationKey(encryptedBlob); @@ -392,11 +443,8 @@ Use `extract_conversation_keys` on key-change event payloads to rebuild `{ keys, ```java - ConversationKeyChangeParams keyParams = new ConversationKeyChangeParams(); - keyParams.senderId = myUserId; - keyParams.signingKeyVersion = signingKeyVersion; - keyParams.publicKeys = participants; - PreparedConversationChange prepared = chat.prepareConversationKeyChange(keyParams); + PreparedConversationChange prepared = + chat.prepareConversationKeyChange(new ConversationKeyChangeParams(participants)); ConversationKeyBundle extracted = chat.extractConversationKeys(keyChangeBlobs); byte[] raw = extracted.keys.get(extracted.latestVersion); byte[] one = chat.decryptConversationKey(encryptedBlob); @@ -410,9 +458,18 @@ For group create and member adds, pass the params each method needs (member/admi ## Decrypt -**`decrypt_events`** is for history and backlog: it pulls conversation keys from the stream, returns decrypted messages, and **collects** per-event errors instead of failing the whole batch. **`decrypt_event`** is for a single live event when you already have a key cache; it raises/throws on failure. +**`decrypt_events`** is for history and backlog: it pulls conversation keys from the stream, returns decrypted messages, and **collects** per-event errors instead of failing the whole batch. **`decrypt_event`** is for a single live event; it raises/throws on failure. + +Pass **signing keys** so the SDK can verify senders. Map API public-key fields into `SigningKeyEntry`: `public_key_version` → `public_key_version` (same name), `signing_public_key` → `public_key`, `public_key` → `identity_public_key`, plus `identity_public_key_signature` and `user_id`. + +Two opt-in session stores let you omit the per-call key arguments: -Pass **signing keys** so the SDK can verify senders. Map API public-key fields into `SigningKeyEntry`: `public_key_version` → `public_key_version` (same name), `signing_public_key` → `public_key`, `public_key` → `identity_public_key`, plus `identity_public_key_signature` and `user_id`. Verification is mandatory by default: omitting or passing an empty signing-key list does **not** skip it—signed events fail (collected in `errors` for `decrypt_events`, thrown for `decrypt_event`). To actually skip verification you must first call `set_reject_unverified(false)` (not recommended in production). +- **`set_signing_keys(entries)`** stores participant signing keys; a decrypt call that omits (or passes an empty) signing-keys argument uses the store instead. Verification itself is unchanged—keys enter the store only through this call, never from the events being decrypted. Each call replaces the previous set. +- **`set_cache_keys(true)`** enables the conversation-key cache (off by default). While enabled, `decrypt_events` caches, per conversation, the latest key whose key change carried a valid signature; `decrypt_event` falls back to it when its conversation-keys argument is omitted, and the encrypt helpers resolve an omitted conversation key from it. Disabling clears the cache. + +An explicit non-empty argument always wins over the stores. Explicit per-call arguments remain first-class—and are the right choice for serverless or multi-instance deployments, where a request can land on a fresh instance whose stores are empty. + +Verification is mandatory by default: omitting signing keys never skips it. With nothing passed and nothing stored, signed events fail (collected in `errors` for `decrypt_events`, thrown for `decrypt_event`). To actually skip verification you must first call `set_reject_unverified(false)` (not recommended in production). @@ -430,11 +487,11 @@ Pass **signing keys** so the SDK can verify senders. Map API public-key fields i log.warning("event %s failed: %s", idx, msg) for dm in result["messages"]: ev = dm["event"] - if ev.get("type") == "Message": - text = ev.get("content", {}).get("text") + if ev["type"] == "Message": + text = ev["content"].get("text") cached = result["conversation_keys"]["keys"] - live = chat.decrypt_event(one_event_b64, cached, signing_keys_for_sender) + live = chat.decrypt_event(one_event_b64, cached, signing_keys) ``` @@ -452,7 +509,7 @@ Pass **signing keys** so the SDK can verify senders. Map API public-key fields i console.warn(`event ${idx} failed: ${msg}`); } const cached = result.conversationKeys.keys; - const live = chat.decryptEvent(oneEventB64, cached, signingKeysForSender); + const live = chat.decryptEvent(oneEventB64, cached, signingKeys); ``` @@ -462,7 +519,7 @@ Pass **signing keys** so the SDK can verify senders. Map API public-key fields i eprintln!("event {idx} failed: {msg}"); } let cached = &result.conversation_keys.keys; - let live = chat.decrypt_event(one_event_b64, cached, &signing_keys_for_sender)?; + let live = chat.decrypt_event(one_event_b64, cached, &signing_keys)?; ``` @@ -472,7 +529,7 @@ Pass **signing keys** so the SDK can verify senders. Map API public-key fields i log.Printf("event %s failed: %s", idx, msg) } cached := result.ConversationKeys.Keys - live, err := chat.DecryptEvent(oneEventB64, cached, signingKeysForSender) + live, err := chat.DecryptEvent(oneEventB64, cached, signingKeys) _ = live _ = err ``` @@ -482,14 +539,14 @@ Pass **signing keys** so the SDK can verify senders. Map API public-key fields i var result = chat.DecryptEvents(rawEvents, signingKeys); foreach (var kv in result.Errors) { /* kv.Key = event index, kv.Value = error */ } var cached = result.ConversationKeys.Keys; - var live = chat.DecryptEvent(oneEventB64, cached, signingKeysForSender); + var live = chat.DecryptEvent(oneEventB64, cached, signingKeys); ``` ```java DecryptEventsResult result = chat.decryptEvents(rawEvents, signingKeys); Map cached = result.conversationKeys.keys; - JsonNode live = chat.decryptEvent(oneEventB64, cached, signingKeysForSender); + JsonNode live = chat.decryptEvent(oneEventB64, cached, signingKeys); ``` @@ -498,9 +555,15 @@ Pass **signing keys** so the SDK can verify senders. Map API public-key fields i ## Encrypt and send helpers -**`encrypt_message`** builds the signed ciphertext for a text message (optional entities, attachments via `media_hash_key`, TTL, notify flags). Map the returned payload into the send-message body: `encrypted_content` → **`encoded_message_create_event`**, `encoded_event_signature` → **`encoded_message_event_signature`**, plus your **`message_id`**. +**`encrypt_message(conversation_id, text)`** builds the signed ciphertext for a text message; optional `entities`, `attachments` (via `media_hash_key`), `should_notify`, and `ttl_msec`. The sender identity resolves from the session (`set_identity`) and the conversation key from the opt-in key cache (`set_cache_keys`)—or pass `sender_id` / `signing_key_version` and `conversation_key` + `conversation_key_version` explicitly. The SDK generates the **`message_id`** (a UUID embedded in the signed event) and returns it on the payload—never mint your own; reuse the same payload on retries so an id is never minted twice. Map the payload into the send-message body: `message_id` → **`message_id`**, `encrypted_content` → **`encoded_message_create_event`**, `encoded_event_signature` → **`encoded_message_event_signature`**. + +**Replies are event-based.** `encrypt_reply(conversation_id, text, reply_to_event)` takes the base64 raw event being replied to. The SDK derives the quoted preview (sequence id, sender, text, entities, attachments) from it and embeds the signed original in the outgoing message so recipients can validate the quote. Pass `reply_to_ckces`—the raw key-change events—when the original was encrypted under an older key version than the reply. When the original was **edited**, pass the raw edit event as `reply_to_edit_event`: the preview then quotes what the message says now (its text and entities come from the edit), and the edit travels alongside the original for the receiver to check. The explicit `reply_to_*` fields remain as overrides for callers that no longer hold the raw event. -Use **`encrypt_reply`**, **`encrypt_add_reaction`**, and **`encrypt_remove_reaction`** for replies and reactions (`sequence_id` targets the parent). **`encrypt` / `decrypt`** are for UTF-8 metadata under the conversation key (for example an encrypted group name)—not message envelopes. **`encrypt_stream` / `decrypt_stream`** encrypt attachment bytes; see [Media](/xchat/media). Low-level **`sign` / `verify` / `verify_key_binding`** support advanced flows; conversation-key changes, group creates, and member adds are signed by the [prepare methods](#conversation-keys). +**Reactions are event-based too.** `encrypt_add_reaction(target_event, emoji)` and `encrypt_remove_reaction(...)` derive the conversation id and target sequence id from the raw event being reacted to; the same params can add and later remove a reaction. Set `conversation_id` and `target_message_sequence_id` explicitly only when you no longer hold the raw event. + +On the receiving side, a decrypted message that quotes a reply carries **`reply_preview_validation`** (`"Valid"` / `"Invalid"`; the JS binding uses `'valid'` / `'invalid'`): the SDK verified the embedded original's signature against your signing keys—never a key carried in the event—decrypted it, and compared the quoted content and author against it. When the preview embeds an edit event, the SDK verifies the edit the same way (same conversation, same author as the original) and checks the quoted text against the edited contents rather than the pre-edit text. The field is absent when the message carries no preview or the preview embeds no original. Treat `Invalid` previews as untrusted: the message itself is authentic, but the quoted material is not—render quotes only from the validated original. + +**`encrypt` / `decrypt`** are for UTF-8 metadata under the conversation key (for example an encrypted group name)—not message envelopes. **`encrypt_stream` / `decrypt_stream`** encrypt attachment bytes; see [Media](/xchat/media). Low-level **`sign` / `verify` / `verify_key_binding`** support advanced flows; conversation-key changes, group creates, and member adds are signed by the [prepare methods](#conversation-keys). The conversation id passed to `encrypt_message` / `encrypt_reply` can be any form you hold—`A:B` from events, `A-B` from listings or URL paths (in either order), or the bare recipient user id—the SDK canonicalizes it before signing. Group ids (prefixed with `g`) pass through unchanged. @@ -508,22 +571,24 @@ The conversation id passed to `encrypt_message` / `encrypt_reply` can be any for ```python payload = chat.encrypt_message( - message_id, sender_id, conversation_id, raw_conversation_key, "Hello", - conversation_key_version, signing_key_version, + conversation_id, "Hello", # Optional keyword args: entities, attachments, should_notify, ttl_msec ) body = { - "message_id": message_id, - "encoded_message_create_event": payload["encrypted_content"], - "encoded_message_event_signature": payload["encoded_event_signature"], + "message_id": payload.message_id, + "encoded_message_create_event": payload.encrypted_content, + "encoded_message_event_signature": payload.encoded_event_signature, } # POST body to /2/chat/conversations/{id}/messages - reply = chat.encrypt_reply( - reply_message_id, sender_id, conversation_id, raw_conversation_key, - "Sounds good", conversation_key_version, signing_key_version, - parent_sequence_id, # reply_to_sequence_id — the message being replied to - ) + # Preview derived from + embedded raw event so recipients can validate; + # add reply_to_ckces=[...] when the original used an older key version + reply = chat.encrypt_reply(conversation_id, "Sounds good", original_event_b64) + + # Conversation and target derived from the raw event + add = chat.encrypt_add_reaction(original_event_b64, "👍") + remove = chat.encrypt_remove_reaction(original_event_b64, "👍") + name_ct = chat.encrypt("Group title", raw_conversation_key) title = chat.decrypt(name_ct, raw_conversation_key) ``` @@ -531,32 +596,52 @@ The conversation id passed to `encrypt_message` / `encrypt_reply` can be any for ```typescript const payload = chat.encryptMessage({ - messageId, senderId, conversationId, conversationKey: rawConversationKey, text: 'Hello', - conversationKeyVersion, signingKeyVersion, + conversationId, + text: 'Hello', + // Optional: entities, attachments, shouldNotify, ttlMsec }); const body = { - message_id: messageId, + message_id: payload.messageId, encoded_message_create_event: payload.encryptedContent, encoded_message_event_signature: payload.encodedEventSignature, }; + // POST body to /2/chat/conversations/{id}/messages + // Preview derived from + embedded raw event so recipients can validate; + // add replyToCkces: [...] when the original used an older key version const reply = chat.encryptReply({ - messageId: replyMessageId, senderId, conversationId, conversationKey: rawConversationKey, - text: 'Sounds good', conversationKeyVersion, signingKeyVersion, - replyToSequenceId: parentSequenceId, // the message being replied to + conversationId, + text: 'Sounds good', + replyToEvent: originalEventB64, }); + + // Conversation and target derived from the raw event + const add = chat.encryptAddReaction({ emoji: '👍', targetEvent: originalEventB64 }); + const remove = chat.encryptRemoveReaction({ emoji: '👍', targetEvent: originalEventB64 }); + const nameCt = chat.encrypt('Group title', rawConversationKey); const title = chat.decrypt(nameCt, rawConversationKey); ``` ```rust - // conv_key: XChatConversationKey from extract_conversation_keys / decrypt_conversation_key - let payload = chat.encrypt_message(EncryptMessageParams::new( - &message_id, &sender_id, &conversation_id, conv_key.to_bytes(), "Hello", - &conversation_key_version, &signing_key_version, + let payload = chat.encrypt_message(EncryptMessageParams::new(conversation_id, "Hello"))?; + // Send body: payload.message_id → message_id, + // payload.encrypted_content → encoded_message_create_event, + // payload.encoded_event_signature → encoded_message_event_signature + + // Preview derived from + embedded raw event so recipients can validate; + // set params.reply_to_ckces when the original used an older key version + let reply = chat.encrypt_reply(EncryptReplyParams::new( + conversation_id, "Sounds good", original_event_b64, ))?; - // Map payload fields into the send-message JSON body as above + + // Conversation and target derived from the raw event + let reaction = EncryptReactionParams::new(original_event_b64, "👍"); + let add = chat.encrypt_add_reaction(&reaction)?; + let remove = chat.encrypt_remove_reaction(&reaction)?; + + // conv_key: XChatConversationKey from extract_conversation_keys / decrypt_conversation_key let name_ct = chat.encrypt("Group title", &conv_key)?; let title = chat.decrypt(&name_ct, &conv_key)?; ``` @@ -564,42 +649,72 @@ The conversation id passed to `encrypt_message` / `encrypt_reply` can be any for ```go payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{ - MessageID: messageID, SenderID: senderID, ConversationID: conversationID, - ConversationKey: rawKey, Text: "Hello", - ConversationKeyVersion: conversationKeyVersion, SigningKeyVersion: signingKeyVersion, + ConversationID: conversationID, + Text: "Hello", }) - // body: message_id, encoded_message_create_event, encoded_message_event_signature + // Send body: payload.MessageID → message_id, + // payload.EncryptedContent → encoded_message_create_event, + // payload.EncodedEventSignature → encoded_message_event_signature + + // Preview derived from + embedded raw event so recipients can validate; + // set ReplyToCkces when the original used an older key version + reply, err := chat.EncryptReply(chatxdk.EncryptReplyParams{ + ConversationID: conversationID, + Text: "Sounds good", + ReplyToEvent: originalEventB64, + }) + + // Conversation and target derived from the raw event + reaction := chatxdk.EncryptReactionParams{Emoji: "👍", TargetEvent: originalEventB64} + add, err := chat.EncryptAddReaction(reaction) + remove, err := chat.EncryptRemoveReaction(reaction) + nameCt, err := chat.Encrypt("Group title", rawKey) title, err := chat.Decrypt(nameCt, rawKey) _ = payload + _ = reply + _ = add + _ = remove _ = title _ = err ``` ```csharp - var payload = chat.EncryptMessage(new EncryptMessageParams { - MessageId = messageId, SenderId = senderId, ConversationId = conversationId, - ConversationKey = rawKey, Text = "Hello", - ConversationKeyVersion = conversationKeyVersion, SigningKeyVersion = signingKeyVersion, - }); - // Map EncryptedContent / EncodedEventSignature into the send-message body + var payload = chat.EncryptMessage(new EncryptMessageParams(conversationId, "Hello")); + // Send body: payload.MessageId → message_id, + // payload.EncryptedContent → encoded_message_create_event, + // payload.EncodedEventSignature → encoded_message_event_signature + + // Preview derived from + embedded raw event so recipients can validate; + // set ReplyToCkces when the original used an older key version + var reply = chat.EncryptReply(new EncryptReplyParams(conversationId, "Sounds good", originalEventB64)); + + // Conversation and target derived from the raw event + var reaction = new EncryptReactionParams(originalEventB64, "👍"); + var add = chat.EncryptAddReaction(reaction); + var remove = chat.EncryptRemoveReaction(reaction); + var nameCt = chat.Encrypt("Group title", rawKey); var title = chat.Decrypt(nameCt, rawKey); ``` ```java - EncryptMessageParams params = new EncryptMessageParams(); - params.messageId = messageId; - params.senderId = senderId; - params.conversationId = conversationId; - params.conversationKey = rawKey; - params.text = "Hello"; - params.conversationKeyVersion = conversationKeyVersion; - params.signingKeyVersion = signingKeyVersion; - SendPayload payload = chat.encryptMessage(params); - // Map to encoded_message_create_event / encoded_message_event_signature on POST + SendPayload payload = chat.encryptMessage(new EncryptMessageParams(conversationId, "Hello")); + // Send body: payload.messageId → message_id, + // payload.encryptedContent → encoded_message_create_event, + // payload.encodedEventSignature → encoded_message_event_signature + + // Preview derived from + embedded raw event so recipients can validate; + // set replyToCkces when the original used an older key version + SendPayload reply = + chat.encryptReply(new EncryptReplyParams(conversationId, "Sounds good", originalEventB64)); + + // Conversation and target derived from the raw event + EncryptReactionParams reaction = new EncryptReactionParams(originalEventB64, "👍"); + SendPayload add = chat.encryptAddReaction(reaction); + SendPayload remove = chat.encryptRemoveReaction(reaction); String nameCt = chat.encrypt("Group title", rawKey); String title = chat.decrypt(nameCt, rawKey); @@ -794,11 +909,11 @@ Base64/hex helpers, MIME sniffing, and image dimensions are available as module- These conceptual types show up across languages (exact field names differ; JS often uses camelCase event discriminators like `message`): -- **SendPayload** — return value of `encrypt_message` and related encrypt helpers; map into the Chat API send body. +- **SendPayload** — return value of `encrypt_message` and the other encrypt helpers: the SDK-generated **`message_id`** (a UUID embedded in the signed event—send it as the message's `message_id` and keep it to dedup), `encrypted_content`, `encoded_event_signature`, signature metadata, `conversation_key_version`, and `should_notify`. Map into the Chat API send body. - **PublicKeyRegistrationPayload** — output of `generate_keypairs` / public-key getters for the add-public-key API. -- **SigningKeyEntry** — sender public material passed into decrypt for signature verification. +- **SigningKeyEntry** — sender public material passed into decrypt for signature verification, or stored via `set_signing_keys`. - **PreparedConversationChange** — output of the three prepare methods: the derived or passed `conversation_id`, the raw `conversation_key` bytes, `conversation_key_version`, `participant_keys` (`user_id`, `encrypted_key`, `public_key_version`), and `action_signatures` (`message_id`, `encoded_message_event_detail`, `signature`, `signature_version`, `public_key_version`, optional `signature_payload`—omitted on key-change signatures because that payload embeds the plaintext key). -- **DecryptEventsResult** — messages, optional errors, and extracted `conversation_keys`. +- **DecryptEventsResult** — messages, optional errors, and extracted `conversation_keys`. Decrypted messages that quote a reply carry `reply_preview_validation` (see [Encrypt and send helpers](#encrypt-and-send-helpers)). For complete field lists, use language stubs in the [chat-xdk repo](https://github.com/xdevplatform/chat-xdk) (`docs/API.md`, `*.pyi`, `index.d.ts`).