Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7c323f5
core: encrypt_file_with_key and decrypt_file_with_key for files under…
santiagomed Oct 28, 2026
df6c193
tests: drop the Python cache file committed with the fixture script
santiagomed Oct 28, 2026
b51a626
c abi: chat_xdk_encrypt_file_with_key and chat_xdk_decrypt_file_with_key
santiagomed Oct 28, 2026
564b6e9
go: EncryptFileWithKey and DecryptFileWithKey
santiagomed Oct 28, 2026
8db269d
python: encrypt_file_with_key and decrypt_file_with_key, checked agai…
santiagomed Oct 28, 2026
c508b03
js: encryptFileWithKey and decryptFileWithKey
santiagomed Oct 28, 2026
7286a5b
.net: EncryptFileWithKey and DecryptFileWithKey
santiagomed Oct 28, 2026
61916ba
jvm: encryptFileWithKey and decryptFileWithKey
santiagomed Oct 28, 2026
586e0a6
compat: the released 0.5.x packages open the files encrypt_file_with_…
santiagomed Oct 28, 2026
4615c71
chore(go): sync libchat_xdk_ffi.a (linux_amd64 glibc + musl)
github-actions[bot] Oct 28, 2026
c3304b1
chore(go): sync libchat_xdk_ffi.a (darwin arm64 + amd64)
github-actions[bot] Oct 28, 2026
6993883
docs: files under a key in API.md, why.md and the reference docs; re-…
santiagomed Oct 28, 2026
447443e
docs: every 0.7 migration guide moves encrypt_stream and decrypt_stre…
santiagomed Oct 28, 2026
231e2f8
go: EncryptFileWithKey and DecryptFileWithKey docs show keyed literals
santiagomed Oct 28, 2026
e3f42c9
docs: the 0.7 migration guides install 0.7.1, the release with encryp…
santiagomed Oct 28, 2026
8875885
core, docs: any 24 bytes decrypt to an empty file under any key, and …
cursoragent Oct 28, 2026
e2c4094
tests: the file-with-key tests state the X Android format without nam…
cursoragent Oct 28, 2026
65586a7
docs: every guide's received-avatar snippet decrypts the image, then …
cursoragent Oct 28, 2026
ceb0aa2
docs: the 0.7 migration guides, their index and why.md name 0.7.1 thr…
cursoragent Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,20 @@ jobs:
exit 1
fi

# The file format test opens files under a key with libsodium itself
# (PyNaCl) and skips when it is absent — install it so it runs in CI.
- name: Install PyNaCl (libsodium, for the file format test)
run: python -m pip install pynacl

- name: Test (Python SDK)
env:
PYTHONPATH: crates/pyo3/python
run: |
# The suites skip when the extension can't be imported (fine for
# local runs without a build); in CI an import failure must fail
# the job, not silently skip every Python test.
# The suites skip when the extension or PyNaCl can't be imported
# (fine for local runs without a build); in CI an import failure
# must fail the job, not silently skip the tests.
python -c "import chat_xdk"
python -c "import nacl.bindings"
python -m unittest discover -s crates/pyo3/python/tests -v

# The suites stub the Juicebox network client, but the wrapper's
Expand Down Expand Up @@ -195,9 +201,10 @@ jobs:

compat-v05:
# Hard gate for 0.7.0: the released 0.5.x packages read the v0 sends this
# tree makes with every feature switch off. The other direction (this tree
# reads 0.5.x sends, byte for byte on the wire) runs in `cargo test`
# against tests/fixtures/compat_v0_5_*.json.
# tree makes with every feature switch off, and open the files it encrypts
# under a key. The other direction (this tree reads 0.5.x sends, byte for
# byte on the wire, and their files) runs in `cargo test` against
# tests/fixtures/compat_v0_5_*.json.
name: compat with released chatxdk ${{ matrix.version }}
runs-on: ubuntu-latest
strategy:
Expand Down Expand Up @@ -263,7 +270,7 @@ jobs:
"$RUNNER_TEMP/v05/bin/python" "$GITHUB_WORKSPACE/chat-xdk/tests/compat_v05/verify_with_v05.py" "$RUNNER_TEMP/v0_sends.json"

doc-snippets:
# Compiles the 0.7.0 half of every snippet in the migration guides against
# Compiles the 0.7.1 half of every snippet in the migration guides against
# this tree, with each binding's compiler, and runs the C guide's params
# documents through the library (scripts/check_doc_snippets.py), so a
# guide that drifts from the API fails CI.
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ When you add or change SDK behavior:
Keep the public surface small and uniform. The flow every binding exposes:
key management (`import`/`export`, `setup`/`unlock`), conversation keys
(`prepareConversationKeyChange` / `decryptConversationKey`), `encryptMessage`/reply,
the file calls (`encryptFile` / `fileEncryptor` / `decryptFile` / `fileDecryptor`),
the file calls (`encryptFile` / `fileEncryptor` / `decryptFile` / `fileDecryptor`
for a message's files, `encryptFileWithKey` / `decryptFileWithKey` for a file
under a key the app holds, such as a group avatar),
and two decrypt paths whose contracts are **identical across bindings**:

- `decryptEvents({ events, signingKeys })` — batch (initial load / pagination).
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

## [Unreleased]

### Added

`encrypt_file_with_key` and `decrypt_file_with_key` encrypt and decrypt a
file under a key the app already holds, rather than a file a message
carries: a group's avatar image, which X clients encrypt under the
conversation key whose version the avatar change signs next to the avatar
URL, or an app's own files at rest. Every binding has them in its own idiom
(`encryptFileWithKey`, `EncryptFileWithKey`, and
`chat_xdk_encrypt_file_with_key` and `chat_xdk_decrypt_file_with_key` in the
C ABI), taking `EncryptFileWithKeyParams` (`file`, `key`) and
`DecryptFileWithKeyParams` (`encrypted`, `key`) with the raw 32-byte key.
They write the file format of a message's files, the one 0.5.x
`encrypt_stream` wrote: files 0.5.x encrypted with `encrypt_stream` or a
stream encryptor decrypt with `decrypt_file_with_key`, and 0.5.x
`decrypt_stream` decrypts what `encrypt_file_with_key` returns. A key that
isn't 32 bytes fails with `invalid_input`, and bytes that aren't a complete
file encrypted under the key with `decryption`. The migration guides move
each `encrypt_stream` and `decrypt_stream` call to `encrypt_file` or
`encrypt_file_with_key` by the file it encrypts.

### Fixed

- API.md, the migration guides and why.md say to re-encrypt a group's avatar
under the new conversation key after every key rotation, as X clients do:
encrypt the image with `encrypt_file_with_key`, upload it, and send an
avatar change naming the new key version.

## [0.7.0] - 2026-10-27

To move code from 0.5.x, follow your binding's guide in
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ Python/Rust, PascalCase in .NET/Go):
`encryptFile({file, eventB64, mediaHashKey})` with the payload's
`eventB64` (plus `conversationKey` and `conversationKeyVersion` unless the
key cache holds them, as for the message). `decryptFile` opens a received
file from its message's event.
file from its message's event. A file no message carries, such as a
group's avatar image under the conversation key, goes through
`encryptFileWithKey({file, key})` and `decryptFileWithKey({encrypted, key})`.

| Method | Use case |
|--------|----------|
Expand Down
35 changes: 27 additions & 8 deletions crates/core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,14 @@ use crate::keys::juicebox::{JuiceboxApi, JuiceboxClient, JuiceboxConfig, Registe
#[cfg(feature = "juicebox")]
use crate::params::{
ConversationKeyChangeParams, DecryptEventParams, DecryptEventsParams, DecryptFileParams,
EncryptEditParams, EncryptFileParams, EncryptMessageParams, EncryptReactionParams,
EncryptReplyParams, FileDecryptorParams, FileEncryptorParams, GroupAdminsAddParams,
GroupAdminsRemoveParams, GroupAvatarChangeParams, GroupCreateParams,
GroupDescriptionChangeParams, GroupInviteDisableParams, GroupInviteEnableParams,
GroupJoinRejectParams, GroupJoinRequestParams, GroupLeaveParams, GroupMembersChangeParams,
GroupMembersRemoveParams, GroupTitleChangeParams, ImportKeysParams, MarkConversationReadParams,
MessageDeleteParams, MessageTtlChangeParams, MessageTtlRemoveParams, ScreenCaptureParams,
SetIdentityParams,
DecryptFileWithKeyParams, EncryptEditParams, EncryptFileParams, EncryptFileWithKeyParams,
EncryptMessageParams, EncryptReactionParams, EncryptReplyParams, FileDecryptorParams,
FileEncryptorParams, GroupAdminsAddParams, GroupAdminsRemoveParams, GroupAvatarChangeParams,
GroupCreateParams, GroupDescriptionChangeParams, GroupInviteDisableParams,
GroupInviteEnableParams, GroupJoinRejectParams, GroupJoinRequestParams, GroupLeaveParams,
GroupMembersChangeParams, GroupMembersRemoveParams, GroupTitleChangeParams, ImportKeysParams,
MarkConversationReadParams, MessageDeleteParams, MessageTtlChangeParams,
MessageTtlRemoveParams, ScreenCaptureParams, SetIdentityParams,
};
#[cfg(feature = "juicebox")]
use crate::types::*;
Expand Down Expand Up @@ -427,6 +427,25 @@ impl Chat {
self.inner.file_decryptor(params)
}

/// Encrypt a file under a key you already hold, such as a group's
/// avatar image under the conversation key; see
/// [`crate::ChatCore::encrypt_file_with_key`].
pub fn encrypt_file_with_key(
&self,
params: &EncryptFileWithKeyParams,
) -> Result<Vec<u8>, SdkError> {
self.inner.encrypt_file_with_key(params)
}

/// Decrypt a file encrypted under a key you already hold; see
/// [`crate::ChatCore::decrypt_file_with_key`].
pub fn decrypt_file_with_key(
&self,
params: &DecryptFileWithKeyParams,
) -> Result<Vec<u8>, SdkError> {
self.inner.decrypt_file_with_key(params)
}

/// Decrypt a group title or description the X API returns for a
/// conversation; see [`crate::ChatCore::decrypt`].
pub fn decrypt(
Expand Down
11 changes: 7 additions & 4 deletions crates/core/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ use crate::keys::conversation_keys;
use crate::keys::keypair_manager::KeypairManager;
use crate::params::{
ConversationKeyChangeParams, DecryptEventParams, DecryptEventsParams, DecryptFileParams,
EncryptEditParams, EncryptFileParams, EncryptMessageParams, EncryptReactionParams,
EncryptReplyParams, FileDecryptorParams, FileEncryptorParams, GroupCreateParams,
GroupMembersChangeParams, ImportKeysParams, MarkConversationReadParams, MessageDeleteParams,
SetIdentityParams,
DecryptFileWithKeyParams, EncryptEditParams, EncryptFileParams, EncryptFileWithKeyParams,
EncryptMessageParams, EncryptReactionParams, EncryptReplyParams, FileDecryptorParams,
FileEncryptorParams, GroupCreateParams, GroupMembersChangeParams, ImportKeysParams,
MarkConversationReadParams, MessageDeleteParams, SetIdentityParams,
};
use crate::protocol::serialization::{base64_decode, base64_encode};
use crate::thrift::event::{MessageEvent, MessageEventDetail};
Expand Down Expand Up @@ -1829,6 +1829,9 @@ mod send_version_tests;
#[cfg(test)]
mod compat_v05_tests;

#[cfg(test)]
mod file_with_key_tests;

#[cfg(test)]
mod sign_tests;

Expand Down
21 changes: 21 additions & 0 deletions crates/core/src/core/compat_v05_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,27 @@ fn sends_from_0_5_decrypt_and_verify() {
}
}

/// The files 0.5.0 and 0.5.2 encrypted with `encrypt_stream` open with
/// `decrypt_file_with_key` under the same key: a text file of three frames,
/// a file shaped like a group avatar, and an empty file. X Android encrypts
/// group avatars in this format under the raw conversation key.
#[test]
fn files_from_0_5_encrypt_stream_open_with_decrypt_file_with_key() {
let core = ChatCore::new();
for (version, json) in FIXTURES {
let fixture: Value = serde_json::from_str(json).unwrap();
let key = bytes(&fixture, "conversation_key_b64");
for name in ["file", "avatar_file", "empty_file"] {
let file = &fixture[name];
let params = DecryptFileWithKeyParams::new(bytes(file, "ciphertext_b64"), key.clone());
let opened = core
.decrypt_file_with_key(&params)
.unwrap_or_else(|e| panic!("{version} {name}: {e}"));
assert_eq!(opened, bytes(file, "plaintext_b64"), "{version} {name}");
}
}
}

/// The same send made here with every feature switch off.
fn v0_send(core: &ChatCore, fixture: &Value, kind: &str) -> SendPayload {
let conversation = text(fixture, "conversation_id").to_string();
Expand Down
Loading
Loading