Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions crates/core/src/crypto/encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ pub const SECRETSTREAM_ABYTES: usize = 17;
/// `DECRYPTED_CHUNK_SIZE + SECRETSTREAM_ABYTES` (1024 + 17 = 1041).
pub const ENCRYPTED_CHUNK_SIZE: usize = DECRYPTED_CHUNK_SIZE + SECRETSTREAM_ABYTES;

/// Return the exact ciphertext size produced by stream encryption.
///
/// Returns `None` when the result cannot be represented as a `u64`.
pub fn encrypted_stream_size(plaintext_size: u64) -> Option<u64> {
if plaintext_size == 0 {
return Some(SECRETSTREAM_HEADER_SIZE as u64);
}

let chunk_count = plaintext_size.div_ceil(DECRYPTED_CHUNK_SIZE as u64);
plaintext_size
.checked_add(SECRETSTREAM_HEADER_SIZE as u64)?
.checked_add(chunk_count.checked_mul(SECRETSTREAM_ABYTES as u64)?)
}

/// Encrypt a message using XSalsa20-Poly1305.
///
/// # Wire Format
Expand Down Expand Up @@ -550,6 +564,22 @@ mod tests {
assert_eq!(decrypted, Vec::<u8>::new());
}

#[test]
fn test_encrypted_stream_size() {
for plaintext_size in [0, 1, 1024, 1025, 2048, 5000] {
let key = XChatConversationKey::from_bytes(vec![0x42u8; 32]).unwrap();
let plaintext = vec![0xAB; plaintext_size];
let mut encrypted = Vec::new();
encrypt_stream(&key, Cursor::new(plaintext), &mut encrypted).unwrap();

assert_eq!(
encrypted_stream_size(plaintext_size as u64),
Some(encrypted.len() as u64)
);
}
assert_eq!(encrypted_stream_size(u64::MAX), None);
}

#[test]
fn test_encrypt_decrypt_stream_exact_chunk() {
// Exactly one chunk (1024 bytes) — boundary case
Expand Down
7 changes: 7 additions & 0 deletions crates/core/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@

use base64::{engine::general_purpose::STANDARD, Engine as _};

/// Return the exact ciphertext size produced by stream encryption.
///
/// Returns `None` when the result cannot be represented as a `u64`.
pub fn encrypted_stream_size(plaintext_size: u64) -> Option<u64> {
crate::crypto::encryption::encrypted_stream_size(plaintext_size)
}

// Base64 Encoding/Decoding

/// Encode bytes to base64 string.
Expand Down
10 changes: 10 additions & 0 deletions crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,16 @@ public void SignVerify_WrongPublicKey_ReturnsFalse()

// ChatXdkUtilities (stateless helpers)

[Fact]
public void Utilities_EncryptedStreamSize()
{
Assert.Equal(24UL, ChatXdkUtilities.EncryptedStreamSize(0));
Assert.Equal(42UL, ChatXdkUtilities.EncryptedStreamSize(1));
Assert.Equal(1065UL, ChatXdkUtilities.EncryptedStreamSize(1024));
Assert.Equal(1083UL, ChatXdkUtilities.EncryptedStreamSize(1025));
Assert.Throws<ChatXdkException>(() => ChatXdkUtilities.EncryptedStreamSize(ulong.MaxValue));
}

[Fact]
public void Utilities_Base64_RoundTrip()
{
Expand Down
8 changes: 8 additions & 0 deletions crates/dotnet/dotnet/ChatXdk/ChatXdkUtilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -40,6 +41,13 @@ private static byte[] Utf8Z(string s)
return buf;
}

/// <summary>Return the exact ciphertext size produced by stream encryption.</summary>
public static ulong EncryptedStreamSize(ulong plaintextSize)
{
string value = Consume(NativeMethods.chat_xdk_encrypted_stream_size(plaintextSize));
return ulong.Parse(value, CultureInfo.InvariantCulture);
}

/// <summary>Encode bytes to standard base64.</summary>
public static string BytesToBase64(ReadOnlySpan<byte> data)
{
Expand Down
6 changes: 6 additions & 0 deletions crates/dotnet/dotnet/ChatXdk/NativeMethods.g.cs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,12 @@ internal static unsafe partial class NativeMethods
[DllImport(__DllName, EntryPoint = "chat_xdk_bytes_to_base64", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
internal static extern FfiResult chat_xdk_bytes_to_base64(byte* data, nuint data_len);

/// <summary>
/// Return the exact ciphertext size produced by stream encryption.
/// </summary>
[DllImport(__DllName, EntryPoint = "chat_xdk_encrypted_stream_size", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
internal static extern FfiResult chat_xdk_encrypted_stream_size(ulong plaintext_size);

/// <summary>
/// Decode base64 to bytes. On success, `data` holds base64 of the decoded bytes (FFI-safe).
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions crates/dotnet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,17 @@ pub extern "C" fn chat_xdk_bytes_to_base64(data: *const u8, data_len: usize) ->
})
}

/// Return the exact ciphertext size produced by stream encryption.
#[no_mangle]
pub extern "C" fn chat_xdk_encrypted_stream_size(plaintext_size: u64) -> FfiResult {
catch_ffi(
|| match chat_xdk_core::utils::encrypted_stream_size(plaintext_size) {
Some(size) => ok_data(&size.to_string()),
None => err_result("Encrypted stream size overflow"),
},
)
}

/// Decode base64 to bytes. On success, `data` holds base64 of the decoded bytes (FFI-safe).
#[no_mangle]
pub extern "C" fn chat_xdk_base64_to_bytes(b64: *const c_char) -> FfiResult {
Expand Down
11 changes: 11 additions & 0 deletions crates/go/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ pub extern "C" fn chat_xdk_bytes_to_base64(data: *const u8, data_len: usize) ->
})
}

/// Return the exact ciphertext size produced by stream encryption.
#[no_mangle]
pub extern "C" fn chat_xdk_encrypted_stream_size(plaintext_size: u64) -> FfiResult {
catch_ffi(
|| match chat_xdk_core::utils::encrypted_stream_size(plaintext_size) {
Some(size) => ok_data(&size.to_string()),
None => err_result("Encrypted stream size overflow"),
},
)
}

/// Decode base64 to bytes. On success, `data` holds base64 of the decoded bytes (FFI-safe).
#[no_mangle]
pub extern "C" fn chat_xdk_base64_to_bytes(b64: *const c_char) -> FfiResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ FfiResult.ByValue chat_xdk_decrypt_event(

int chat_xdk_matches_registered_key(Pointer handle, Pointer publicKeyB64);

FfiResult.ByValue chat_xdk_encrypted_stream_size(long plaintextSize);

FfiResult.ByValue chat_xdk_bytes_to_base64(Pointer data, long dataLen);

FfiResult.ByValue chat_xdk_base64_to_bytes(Pointer b64);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ public final class ChatXdkUtilities {

private ChatXdkUtilities() {}

/** Return the exact ciphertext size produced by stream encryption. */
public static long encryptedStreamSize(long plaintextSize) {
if (plaintextSize < 0) {
throw new IllegalArgumentException("Plaintext size must be non-negative");
}
String value = FfiStrings.consume(
ChatNative.INSTANCE.chat_xdk_encrypted_stream_size(plaintextSize));
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
throw new ArithmeticException("Encrypted stream size exceeds Long.MAX_VALUE");
}
}

/** Encode bytes to standard base64. */
public static String bytesToBase64(byte[] data) {
if (data == null || data.length == 0) {
Expand Down
12 changes: 12 additions & 0 deletions crates/jvm/java/chatxdk/src/test/java/com/x/chatxdk/ChatTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ void utilitiesBase64RoundTrip() {
assertArrayEquals(raw, ChatXdkUtilities.base64ToBytes(ChatXdkUtilities.bytesToBase64(raw)));
}

@Test
void utilitiesEncryptedStreamSize() {
assertEquals(24, ChatXdkUtilities.encryptedStreamSize(0));
assertEquals(42, ChatXdkUtilities.encryptedStreamSize(1));
assertEquals(1065, ChatXdkUtilities.encryptedStreamSize(1024));
assertEquals(1083, ChatXdkUtilities.encryptedStreamSize(1025));
assertThrows(IllegalArgumentException.class, () -> ChatXdkUtilities.encryptedStreamSize(-1));
assertThrows(
ArithmeticException.class,
() -> ChatXdkUtilities.encryptedStreamSize(Long.MAX_VALUE));
}

@Test
void exportImportRoundTrip() throws Exception {
try (Chat chat = createUnlocked()) {
Expand Down
2 changes: 2 additions & 0 deletions crates/pyo3/python/chat_xdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
bytes_to_hex,
detect_image_dimensions,
detect_mime_type,
encrypted_stream_size,
hex_to_bytes,
)

Expand Down Expand Up @@ -73,6 +74,7 @@ def guesses_remaining(exc):
"bytes_to_hex",
"detect_image_dimensions",
"detect_mime_type",
"encrypted_stream_size",
"guesses_remaining",
"hex_to_bytes",
]
2 changes: 2 additions & 0 deletions crates/pyo3/python/chat_xdk/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ from typing import Optional, Union, overload

__version__: str

def encrypted_stream_size(plaintext_size: int) -> int: ...

# ---------------------------------------------------------------------------
# Classes
# ---------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions crates/pyo3/python/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ def test_base64_roundtrip(self):
self.assertIsInstance(encoded, str)
self.assertEqual(base64_to_bytes(encoded), data)

def test_encrypted_stream_size(self):
from chat_xdk import encrypted_stream_size

self.assertEqual(encrypted_stream_size(0), 24)
self.assertEqual(encrypted_stream_size(1), 42)
self.assertEqual(encrypted_stream_size(1024), 1065)
self.assertEqual(encrypted_stream_size(1025), 1083)
with self.assertRaises(OverflowError):
encrypted_stream_size(2**64 - 1)

def test_base64_invalid_returns_none(self):
from chat_xdk import base64_to_bytes

Expand Down
8 changes: 8 additions & 0 deletions crates/pyo3/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,13 @@ impl Chat {

// Module-level utility functions

/// Return the exact ciphertext size produced by stream encryption.
#[pyfunction]
fn encrypted_stream_size(plaintext_size: u64) -> PyResult<u64> {
chat_xdk_core::utils::encrypted_stream_size(plaintext_size)
.ok_or_else(|| pyo3::exceptions::PyOverflowError::new_err("Encrypted stream size overflow"))
}

/// Encode bytes to base64 string.
#[pyfunction]
fn bytes_to_base64(bytes: &[u8]) -> String {
Expand Down Expand Up @@ -1628,6 +1635,7 @@ fn _native(m: &Bound<'_, pyo3::types::PyModule>) -> PyResult<()> {
m.add_class::<SignatureInfo>()?;

// Utility functions
m.add_function(wrap_pyfunction!(encrypted_stream_size, m)?)?;
m.add_function(wrap_pyfunction!(bytes_to_base64, m)?)?;
m.add_function(wrap_pyfunction!(base64_to_bytes, m)?)?;
m.add_function(wrap_pyfunction!(bytes_to_hex, m)?)?;
Expand Down
3 changes: 3 additions & 0 deletions crates/wasm/js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,9 @@ export declare function createChat(options: CreateChatOptions): Promise<ChatWith
*/
export declare function guessesRemaining(err: unknown): number | null;

/** Return the exact ciphertext size produced by stream encryption. */
export declare function encryptedStreamSize(plaintextSize: number): number;

/** Encode bytes to base64 string. */
export declare function bytesToBase64(bytes: Uint8Array): string;

Expand Down
1 change: 1 addition & 0 deletions crates/wasm/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ export async function createChat(options) {

// Re-export utility functions.
export {
encryptedStreamSize,
bytesToBase64,
base64ToBytes,
bytesToHex,
Expand Down
7 changes: 7 additions & 0 deletions crates/wasm/js/tests/api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import init, {
hexToBytes,
detectMimeType,
detectImageDimensions,
encryptedStreamSize,
} from "../../pkg/chat_xdk_wasm.js";

// Node 18 exposes WebCrypto only via node:crypto; the wasm module's
Expand Down Expand Up @@ -47,6 +48,12 @@ async function main() {
// matches Node's own base64 encoding
assert.equal(b64, Buffer.from(someBytes).toString("base64"));

assert.equal(encryptedStreamSize(0), 24);
assert.equal(encryptedStreamSize(1), 42);
assert.equal(encryptedStreamSize(1024), 1065);
assert.equal(encryptedStreamSize(1025), 1083);
assert.throws(() => encryptedStreamSize(-1), /non-negative safe integer/);

// hex roundtrip
const hex = bytesToHex(someBytes);
assert.equal(typeof hex, "string");
Expand Down
8 changes: 7 additions & 1 deletion crates/wasm/js/tests/wrapper.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";

import init, { Chat, bytesToBase64 } from "../pkg/chat_xdk_wasm.js";
import { ChatWithJuicebox, createChat, guessesRemaining } from "../index.js";
import {
ChatWithJuicebox,
createChat,
encryptedStreamSize,
guessesRemaining,
} from "../index.js";

// Node 18 exposes WebCrypto only via node:crypto; the wasm module's
// random-byte source needs it on the global scope.
Expand Down Expand Up @@ -668,6 +673,7 @@ async function realJuiceboxSingletonTests() {

async function main() {
await delegationTests();
assert.equal(encryptedStreamSize(5000), 5109);
await guessBudgetTests();
await firstBootTests();
await guessesRemainingTests();
Expand Down
25 changes: 25 additions & 0 deletions crates/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ use chat_xdk_core::js::{

// Utility Functions (free functions, not methods)

/// Return the exact ciphertext size produced by stream encryption.
#[wasm_bindgen(js_name = encryptedStreamSize)]
pub fn encrypted_stream_size(plaintext_size: f64) -> Result<f64, JsError> {
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;

if !plaintext_size.is_finite()
|| plaintext_size < 0.0
|| plaintext_size.fract() != 0.0
|| plaintext_size > MAX_SAFE_INTEGER
{
return Err(JsError::new(
"Plaintext size must be a non-negative safe integer",
));
}

let encrypted_size = chat_xdk_core::utils::encrypted_stream_size(plaintext_size as u64)
.ok_or_else(|| JsError::new("Encrypted stream size overflow"))?;
if encrypted_size > MAX_SAFE_INTEGER as u64 {
return Err(JsError::new(
"Encrypted stream size exceeds Number.MAX_SAFE_INTEGER",
));
}
Ok(encrypted_size as f64)
}

/// Encode bytes to base64 string.
#[wasm_bindgen(js_name = bytesToBase64)]
pub fn bytes_to_base64(bytes: &[u8]) -> String {
Expand Down
Loading
Loading