From 530047c5d0f10599b308339a6ba03df29e63cbc7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 25 Mar 2026 01:47:12 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BD=D0=B0=D0=B4=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 13 + app/src/main/cpp/CMakeLists.txt | 33 ++ app/src/main/cpp/crypto.c | 248 +++++++++++ app/src/main/cpp/crypto.h | 33 ++ app/src/main/cpp/rosetta_e2ee.cpp | 390 ++++++++++++++++++ app/src/main/cpp/webrtc/api/array_view.h | 24 ++ .../api/crypto/frame_decryptor_interface.h | 42 ++ .../api/crypto/frame_encryptor_interface.h | 32 ++ app/src/main/cpp/webrtc/api/media_types.h | 14 + app/src/main/cpp/webrtc/rtc_base/ref_count.h | 21 + .../rosetta/messenger/network/CallManager.kt | 293 +++++++++---- .../messenger/network/XChaCha20E2EE.kt | 107 +++++ .../messenger/ui/chats/calls/CallOverlay.kt | 155 +++++-- .../messenger/utils/CrashReportManager.kt | 20 +- 14 files changed, 1326 insertions(+), 99 deletions(-) create mode 100644 app/src/main/cpp/CMakeLists.txt create mode 100644 app/src/main/cpp/crypto.c create mode 100644 app/src/main/cpp/crypto.h create mode 100644 app/src/main/cpp/rosetta_e2ee.cpp create mode 100644 app/src/main/cpp/webrtc/api/array_view.h create mode 100644 app/src/main/cpp/webrtc/api/crypto/frame_decryptor_interface.h create mode 100644 app/src/main/cpp/webrtc/api/crypto/frame_encryptor_interface.h create mode 100644 app/src/main/cpp/webrtc/api/media_types.h create mode 100644 app/src/main/cpp/webrtc/rtc_base/ref_count.h create mode 100644 app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2639c9a..46713c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,19 @@ android { // Optimize Lottie animations manifestPlaceholders["enableLottieOptimizations"] = "true" + + ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } + + externalNativeBuild { + cmake { cppFlags("-std=c++17") } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } } signingConfigs { diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..4ba3120 --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.22.1) +project(rosetta_e2ee LANGUAGES C CXX) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) + +add_library(rosetta_e2ee SHARED + crypto.c + rosetta_e2ee.cpp +) + +target_include_directories(rosetta_e2ee PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +# Hide all C++ symbols to avoid ODR clashes with WebRTC's .so +set_target_properties(rosetta_e2ee PROPERTIES + CXX_VISIBILITY_PRESET hidden + C_VISIBILITY_PRESET hidden +) + +# Match WebRTC SDK build flags: +# -fno-rtti -fno-exceptions — standard WebRTC flags +# -fexperimental-relative-c++-abi-vtables — WebRTC uses relative vtables +# (32-bit offsets instead of 64-bit absolute pointers in vtable). +# Without this, setFrameEncryptor crashes with SIGSEGV because WebRTC +# reads our 64-bit pointers as 32-bit offsets. +target_compile_options(rosetta_e2ee PRIVATE + -fno-rtti + -fno-exceptions + -fexperimental-relative-c++-abi-vtables +) + +find_library(log-lib log) +target_link_libraries(rosetta_e2ee ${log-lib}) diff --git a/app/src/main/cpp/crypto.c b/app/src/main/cpp/crypto.c new file mode 100644 index 0000000..e76d901 --- /dev/null +++ b/app/src/main/cpp/crypto.c @@ -0,0 +1,248 @@ +/** + * Minimal crypto primitives for Rosetta E2EE: + * - HSalsa20 (for nacl.box.before() compatible key exchange) + * - HChaCha20 + ChaCha20-IETF → XChaCha20 (for frame encryption) + * + * Based on the public-domain algorithms by D.J. Bernstein. + */ + +#include "crypto.h" +#include + +/* ── helpers ─────────────────────────────────────────────────── */ + +#define ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n)))) + +static uint32_t load32_le(const uint8_t *p) { + return (uint32_t)p[0] + | (uint32_t)p[1] << 8 + | (uint32_t)p[2] << 16 + | (uint32_t)p[3] << 24; +} + +static void store32_le(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v); + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +/* "expand 32-byte k" as four little-endian uint32 */ +static const uint32_t SIGMA[4] = { + 0x61707865u, 0x3320646eu, 0x79622d32u, 0x6b206574u +}; + +/* ── HSalsa20 (Salsa20 family) ──────────────────────────────── */ + +void rosetta_hsalsa20(uint8_t out[32], + const uint8_t inp[16], + const uint8_t key[32]) +{ + uint32_t x[16]; + x[ 0] = SIGMA[0]; + x[ 1] = load32_le(key + 0); + x[ 2] = load32_le(key + 4); + x[ 3] = load32_le(key + 8); + x[ 4] = load32_le(key + 12); + x[ 5] = SIGMA[1]; + x[ 6] = load32_le(inp + 0); + x[ 7] = load32_le(inp + 4); + x[ 8] = load32_le(inp + 8); + x[ 9] = load32_le(inp + 12); + x[10] = SIGMA[2]; + x[11] = load32_le(key + 16); + x[12] = load32_le(key + 20); + x[13] = load32_le(key + 24); + x[14] = load32_le(key + 28); + x[15] = SIGMA[3]; + + for (int i = 0; i < 20; i += 2) { + /* column round */ + x[ 4] ^= ROTL32(x[ 0] + x[12], 7); + x[ 8] ^= ROTL32(x[ 4] + x[ 0], 9); + x[12] ^= ROTL32(x[ 8] + x[ 4], 13); + x[ 0] ^= ROTL32(x[12] + x[ 8], 18); + x[ 9] ^= ROTL32(x[ 5] + x[ 1], 7); + x[13] ^= ROTL32(x[ 9] + x[ 5], 9); + x[ 1] ^= ROTL32(x[13] + x[ 9], 13); + x[ 5] ^= ROTL32(x[ 1] + x[13], 18); + x[14] ^= ROTL32(x[10] + x[ 6], 7); + x[ 2] ^= ROTL32(x[14] + x[10], 9); + x[ 6] ^= ROTL32(x[ 2] + x[14], 13); + x[10] ^= ROTL32(x[ 6] + x[ 2], 18); + x[ 3] ^= ROTL32(x[15] + x[11], 7); + x[ 7] ^= ROTL32(x[ 3] + x[15], 9); + x[11] ^= ROTL32(x[ 7] + x[ 3], 13); + x[15] ^= ROTL32(x[11] + x[ 7], 18); + /* row round */ + x[ 1] ^= ROTL32(x[ 0] + x[ 3], 7); + x[ 2] ^= ROTL32(x[ 1] + x[ 0], 9); + x[ 3] ^= ROTL32(x[ 2] + x[ 1], 13); + x[ 0] ^= ROTL32(x[ 3] + x[ 2], 18); + x[ 6] ^= ROTL32(x[ 5] + x[ 4], 7); + x[ 7] ^= ROTL32(x[ 6] + x[ 5], 9); + x[ 4] ^= ROTL32(x[ 7] + x[ 6], 13); + x[ 5] ^= ROTL32(x[ 4] + x[ 7], 18); + x[11] ^= ROTL32(x[10] + x[ 9], 7); + x[ 8] ^= ROTL32(x[11] + x[10], 9); + x[ 9] ^= ROTL32(x[ 8] + x[11], 13); + x[10] ^= ROTL32(x[ 9] + x[ 8], 18); + x[12] ^= ROTL32(x[15] + x[14], 7); + x[13] ^= ROTL32(x[12] + x[15], 9); + x[14] ^= ROTL32(x[13] + x[12], 13); + x[15] ^= ROTL32(x[14] + x[13], 18); + } + + /* output words: 0, 5, 10, 15, 6, 7, 8, 9 */ + store32_le(out + 0, x[ 0]); + store32_le(out + 4, x[ 5]); + store32_le(out + 8, x[10]); + store32_le(out + 12, x[15]); + store32_le(out + 16, x[ 6]); + store32_le(out + 20, x[ 7]); + store32_le(out + 24, x[ 8]); + store32_le(out + 28, x[ 9]); +} + +/* ── HChaCha20 (ChaCha20 family) ────────────────────────────── */ + +static void hchacha20(uint8_t out[32], + const uint8_t inp[16], + const uint8_t key[32]) +{ + uint32_t x[16]; + x[ 0] = SIGMA[0]; + x[ 1] = SIGMA[1]; + x[ 2] = SIGMA[2]; + x[ 3] = SIGMA[3]; + x[ 4] = load32_le(key + 0); + x[ 5] = load32_le(key + 4); + x[ 6] = load32_le(key + 8); + x[ 7] = load32_le(key + 12); + x[ 8] = load32_le(key + 16); + x[ 9] = load32_le(key + 20); + x[10] = load32_le(key + 24); + x[11] = load32_le(key + 28); + x[12] = load32_le(inp + 0); + x[13] = load32_le(inp + 4); + x[14] = load32_le(inp + 8); + x[15] = load32_le(inp + 12); + + for (int i = 0; i < 20; i += 2) { + /* column round */ + #define QR(a, b, c, d) \ + a += b; d ^= a; d = ROTL32(d, 16); \ + c += d; b ^= c; b = ROTL32(b, 12); \ + a += b; d ^= a; d = ROTL32(d, 8); \ + c += d; b ^= c; b = ROTL32(b, 7); + + QR(x[0], x[4], x[8], x[12]); + QR(x[1], x[5], x[9], x[13]); + QR(x[2], x[6], x[10], x[14]); + QR(x[3], x[7], x[11], x[15]); + /* diagonal round */ + QR(x[0], x[5], x[10], x[15]); + QR(x[1], x[6], x[11], x[12]); + QR(x[2], x[7], x[8], x[13]); + QR(x[3], x[4], x[9], x[14]); + + #undef QR + } + + /* output words: 0, 1, 2, 3, 12, 13, 14, 15 */ + store32_le(out + 0, x[ 0]); + store32_le(out + 4, x[ 1]); + store32_le(out + 8, x[ 2]); + store32_le(out + 12, x[ 3]); + store32_le(out + 16, x[12]); + store32_le(out + 20, x[13]); + store32_le(out + 24, x[14]); + store32_le(out + 28, x[15]); +} + +/* ── ChaCha20-IETF (RFC 8439) ───────────────────────────────── */ + +static void chacha20_block(uint8_t out[64], + const uint8_t key[32], + uint32_t counter, + const uint8_t nonce[12]) +{ + uint32_t s[16], x[16]; + s[ 0] = SIGMA[0]; + s[ 1] = SIGMA[1]; + s[ 2] = SIGMA[2]; + s[ 3] = SIGMA[3]; + for (int i = 0; i < 8; i++) s[4 + i] = load32_le(key + i * 4); + s[12] = counter; + s[13] = load32_le(nonce + 0); + s[14] = load32_le(nonce + 4); + s[15] = load32_le(nonce + 8); + + memcpy(x, s, sizeof(x)); + + for (int i = 0; i < 20; i += 2) { + #define QR(a, b, c, d) \ + a += b; d ^= a; d = ROTL32(d, 16); \ + c += d; b ^= c; b = ROTL32(b, 12); \ + a += b; d ^= a; d = ROTL32(d, 8); \ + c += d; b ^= c; b = ROTL32(b, 7); + + QR(x[0], x[4], x[8], x[12]); + QR(x[1], x[5], x[9], x[13]); + QR(x[2], x[6], x[10], x[14]); + QR(x[3], x[7], x[11], x[15]); + QR(x[0], x[5], x[10], x[15]); + QR(x[1], x[6], x[11], x[12]); + QR(x[2], x[7], x[8], x[13]); + QR(x[3], x[4], x[9], x[14]); + + #undef QR + } + + for (int i = 0; i < 16; i++) store32_le(out + i * 4, x[i] + s[i]); +} + +static void chacha20_ietf_xor(uint8_t *out, + const uint8_t *in, + size_t len, + const uint8_t nonce[12], + const uint8_t key[32], + uint32_t initial_counter) +{ + uint8_t block[64]; + uint32_t ctr = initial_counter; + size_t off = 0; + + while (off < len) { + chacha20_block(block, key, ctr++, nonce); + size_t chunk = len - off; + if (chunk > 64) chunk = 64; + for (size_t i = 0; i < chunk; i++) { + out[off + i] = in[off + i] ^ block[i]; + } + off += chunk; + } + + memset(block, 0, sizeof(block)); +} + +/* ── XChaCha20 XOR ───────────────────────────────────────────── */ + +void rosetta_xchacha20_xor(uint8_t *out, + const uint8_t *in, + size_t len, + const uint8_t nonce[24], + const uint8_t key[32]) +{ + /* Step 1: derive sub-key with HChaCha20(key, nonce[0..15]) */ + uint8_t subkey[32]; + hchacha20(subkey, nonce, key); + + /* Step 2: ChaCha20-IETF with sub-key and nonce' = [0,0,0,0, nonce[16..23]] */ + uint8_t sub_nonce[12] = {0}; + memcpy(sub_nonce + 4, nonce + 16, 8); + + chacha20_ietf_xor(out, in, len, sub_nonce, subkey, 0); + + memset(subkey, 0, sizeof(subkey)); +} diff --git a/app/src/main/cpp/crypto.h b/app/src/main/cpp/crypto.h new file mode 100644 index 0000000..f4dc338 --- /dev/null +++ b/app/src/main/cpp/crypto.h @@ -0,0 +1,33 @@ +#ifndef ROSETTA_CRYPTO_H +#define ROSETTA_CRYPTO_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * HSalsa20 core — used by nacl.box.before() to derive shared key. + * out: 32 bytes, inp: 16 bytes (nonce, zeros for box.before), key: 32 bytes + */ +void rosetta_hsalsa20(uint8_t out[32], + const uint8_t inp[16], + const uint8_t key[32]); + +/** + * XChaCha20 XOR (encrypt = decrypt, symmetric stream cipher). + * out and in may overlap. nonce: 24 bytes, key: 32 bytes. + */ +void rosetta_xchacha20_xor(uint8_t *out, + const uint8_t *in, + size_t len, + const uint8_t nonce[24], + const uint8_t key[32]); + +#ifdef __cplusplus +} +#endif + +#endif /* ROSETTA_CRYPTO_H */ diff --git a/app/src/main/cpp/rosetta_e2ee.cpp b/app/src/main/cpp/rosetta_e2ee.cpp new file mode 100644 index 0000000..8b024de --- /dev/null +++ b/app/src/main/cpp/rosetta_e2ee.cpp @@ -0,0 +1,390 @@ +/** + * JNI bridge for Rosetta E2EE. + * + * Provides: + * 1. HSalsa20 — for nacl.box.before() compatible key derivation + * 2. XChaCha20 FrameEncryptor / FrameDecryptor — inherits DIRECTLY from + * webrtc::FrameEncryptorInterface / FrameDecryptorInterface so the + * vtable is generated by the compiler, not guessed by us. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* WebRTC M125 interface stubs (exact copies of the real headers) */ +#include "webrtc/api/crypto/frame_encryptor_interface.h" +#include "webrtc/api/crypto/frame_decryptor_interface.h" + +#include "crypto.h" + +#define TAG "RosettaE2EE" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) + +/* ── Diagnostics file — written from native, visible in crash reports ── */ + +static char g_diag_path[512] = {0}; +static int g_diag_fd = -1; + +static void diag_write(const char *fmt, ...) { + if (g_diag_fd < 0) return; + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (n > 0) write(g_diag_fd, buf, n); +} + +/* ── Native crash handler — writes to file before dying ──────── */ + +static char g_crash_path[512] = {0}; +static struct sigaction g_old_sigsegv = {}; +static struct sigaction g_old_sigabrt = {}; + +static void native_crash_handler(int sig, siginfo_t *info, void *ctx) { + if (g_crash_path[0] != 0) { + int fd = open(g_crash_path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) { + const char *msg; + if (sig == SIGSEGV) + msg = "NATIVE CRASH: SIGSEGV (segmentation fault) in rosetta_e2ee\n" + "Fault address: see logcat for details.\n"; + else if (sig == SIGABRT) + msg = "NATIVE CRASH: SIGABRT (abort) in rosetta_e2ee\n"; + else + msg = "NATIVE CRASH: unknown signal in rosetta_e2ee\n"; + write(fd, msg, strlen(msg)); + close(fd); + } + } + LOGE("NATIVE CRASH sig=%d addr=%p", sig, info ? info->si_addr : nullptr); + + struct sigaction *old = (sig == SIGSEGV) ? &g_old_sigsegv : &g_old_sigabrt; + sigaction(sig, old, nullptr); + raise(sig); +} + +/* ════════════════════════════════════════════════════════════════ + * XChaCha20 Encryptor — inherits from the REAL interface + * ════════════════════════════════════════════════════════════════ */ + +class XChaCha20Encryptor final : public webrtc::FrameEncryptorInterface { +public: + explicit XChaCha20Encryptor(const uint8_t key[32]) { + memcpy(key_, key, 32); + } + + /* ── RefCountInterface ─────────────────────────────────────── */ + void AddRef() const override { + ref_.fetch_add(1, std::memory_order_relaxed); + } + + rtc::RefCountReleaseStatus Release() const override { + if (ref_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + delete this; + return rtc::RefCountReleaseStatus::kDroppedLastRef; + } + return rtc::RefCountReleaseStatus::kOtherRefsRemained; + } + + /** + * Frame format: [4-byte counter BE] + [xchacha20_xor(frame)] + * + * Nonce (24 bytes): [0,0,0,0, counter_BE_4bytes, 0,...,0] + * This matches Desktop's layout where nonce[4..7] = timestamp. + * The counter is embedded so the receiver can reconstruct the nonce + * even if frames are dropped/reordered. + */ + int Encrypt(cricket::MediaType /*media_type*/, + uint32_t /*ssrc*/, + rtc::ArrayView /*additional_data*/, + rtc::ArrayView frame, + rtc::ArrayView encrypted_frame, + size_t* bytes_written) override { + const size_t HEADER = 4; // counter prefix + if (frame.size() == 0 || encrypted_frame.size() < frame.size() + HEADER) { + *bytes_written = 0; + return -1; + } + + uint32_t ctr = counter_.fetch_add(1, std::memory_order_relaxed); + + // Write 4-byte counter as big-endian prefix + encrypted_frame.data()[0] = (uint8_t)(ctr >> 24); + encrypted_frame.data()[1] = (uint8_t)(ctr >> 16); + encrypted_frame.data()[2] = (uint8_t)(ctr >> 8); + encrypted_frame.data()[3] = (uint8_t)(ctr); + + // Build nonce from counter (same positions as Desktop's timestamp) + uint8_t nonce[24] = {0}; + nonce[4] = encrypted_frame.data()[0]; + nonce[5] = encrypted_frame.data()[1]; + nonce[6] = encrypted_frame.data()[2]; + nonce[7] = encrypted_frame.data()[3]; + + rosetta_xchacha20_xor(encrypted_frame.data() + HEADER, + frame.data(), frame.size(), nonce, key_); + *bytes_written = frame.size() + HEADER; + + // Diag: log first 3 frames + int n = diag_count_.fetch_add(1, std::memory_order_relaxed); + if (n < 3) { + LOGI("ENC frame#%d: sz=%zu ctr=%u out=%zu", + n, frame.size(), ctr, frame.size() + HEADER); + diag_write("ENC frame#%d: sz=%zu ctr=%u nonce[4..7]=%02x%02x%02x%02x\n", + n, frame.size(), ctr, nonce[4], nonce[5], nonce[6], nonce[7]); + } + return 0; + } + + size_t GetMaxCiphertextByteSize(cricket::MediaType, size_t frame_size) override { + return frame_size + 4; // +4 for counter prefix + } + +protected: + ~XChaCha20Encryptor() override { memset(key_, 0, 32); } + +private: + mutable std::atomic ref_{0}; + mutable std::atomic counter_{0}; + mutable std::atomic diag_count_{0}; + uint8_t key_[32]; +}; + +/* ════════════════════════════════════════════════════════════════ + * XChaCha20 Decryptor — inherits from the REAL interface + * ════════════════════════════════════════════════════════════════ */ + +class XChaCha20Decryptor final : public webrtc::FrameDecryptorInterface { +public: + explicit XChaCha20Decryptor(const uint8_t key[32]) { + memcpy(key_, key, 32); + } + + /* ── RefCountInterface ─────────────────────────────────────── */ + void AddRef() const override { + ref_.fetch_add(1, std::memory_order_relaxed); + } + + rtc::RefCountReleaseStatus Release() const override { + if (ref_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + delete this; + return rtc::RefCountReleaseStatus::kDroppedLastRef; + } + return rtc::RefCountReleaseStatus::kOtherRefsRemained; + } + + /** + * Decrypt frame: read 4-byte counter prefix → derive nonce → decrypt. + * If frame has no prefix (< 5 bytes or from Desktop), fallback to + * nonce derived from additional_data (RTP header) or zeros. + */ + Result Decrypt(cricket::MediaType /*media_type*/, + const std::vector& /*csrcs*/, + rtc::ArrayView additional_data, + rtc::ArrayView encrypted_frame, + rtc::ArrayView frame) override { + + const size_t HEADER = 4; + uint8_t nonce[24] = {0}; + const uint8_t *payload; + size_t payload_sz; + + if (encrypted_frame.size() > HEADER) { + // Android format: [4-byte counter] + [encrypted data] + nonce[4] = encrypted_frame.data()[0]; + nonce[5] = encrypted_frame.data()[1]; + nonce[6] = encrypted_frame.data()[2]; + nonce[7] = encrypted_frame.data()[3]; + payload = encrypted_frame.data() + HEADER; + payload_sz = encrypted_frame.size() - HEADER; + } else { + // Fallback: no counter prefix + payload = encrypted_frame.data(); + payload_sz = encrypted_frame.size(); + } + + if (payload_sz == 0 || frame.size() < payload_sz) { + return {Result::Status::kFailedToDecrypt, 0}; + } + + rosetta_xchacha20_xor(frame.data(), payload, payload_sz, nonce, key_); + + // Diag: log first 3 frames + int n = diag_count_.fetch_add(1, std::memory_order_relaxed); + if (n < 3) { + LOGI("DEC frame#%d: enc_sz=%zu payload=%zu nonce=%02x%02x%02x%02x", + n, encrypted_frame.size(), payload_sz, + nonce[4], nonce[5], nonce[6], nonce[7]); + diag_write("DEC frame#%d: enc_sz=%zu payload=%zu nonce[4..7]=%02x%02x%02x%02x\n", + n, encrypted_frame.size(), payload_sz, + nonce[4], nonce[5], nonce[6], nonce[7]); + } + + return {Result::Status::kOk, payload_sz}; + } + + size_t GetMaxPlaintextByteSize(cricket::MediaType, size_t encrypted_frame_size) override { + return encrypted_frame_size; // >= actual (payload = enc - 4) + } + +protected: + ~XChaCha20Decryptor() override { memset(key_, 0, 32); } + +private: + mutable std::atomic ref_{0}; + mutable std::atomic diag_count_{0}; + uint8_t key_[32]; +}; + +/* ════════════════════════════════════════════════════════════════ + * JNI exports + * ════════════════════════════════════════════════════════════════ */ + +extern "C" { + +/* ── HSalsa20 for nacl.box.before() ──────────────────────────── */ + +JNIEXPORT jbyteArray JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeHSalsa20( + JNIEnv *env, jclass, jbyteArray jRawDh) +{ + jsize len = env->GetArrayLength(jRawDh); + if (len < 32) return nullptr; + + auto *raw = (uint8_t *)env->GetByteArrayElements(jRawDh, nullptr); + uint8_t out[32]; + uint8_t zeros[16] = {0}; + + rosetta_hsalsa20(out, zeros, raw); + + env->ReleaseByteArrayElements(jRawDh, (jbyte *)raw, JNI_ABORT); + + jbyteArray result = env->NewByteArray(32); + env->SetByteArrayRegion(result, 0, 32, (jbyte *)out); + memset(out, 0, 32); + return result; +} + +/* ── Create / destroy encryptor ──────────────────────────────── */ + +JNIEXPORT jlong JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateEncryptor( + JNIEnv *env, jclass, jbyteArray jKey) +{ + jsize len = env->GetArrayLength(jKey); + if (len < 32) return 0; + + auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr); + auto *enc = new XChaCha20Encryptor(key); + env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT); + + // AddRef so the pointer we hand out has ref=1. + // WebRTC's scoped_refptr will AddRef again when it takes ownership. + enc->AddRef(); + + LOGI("Created XChaCha20 encryptor %p", enc); + return reinterpret_cast(enc); +} + +JNIEXPORT void JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseEncryptor( + JNIEnv *, jclass, jlong ptr) +{ + if (ptr == 0) return; + auto *enc = reinterpret_cast(ptr); + enc->Release(); +} + +/* ── Create / destroy decryptor ──────────────────────────────── */ + +JNIEXPORT jlong JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCreateDecryptor( + JNIEnv *env, jclass, jbyteArray jKey) +{ + jsize len = env->GetArrayLength(jKey); + if (len < 32) return 0; + + auto *key = (uint8_t *)env->GetByteArrayElements(jKey, nullptr); + auto *dec = new XChaCha20Decryptor(key); + env->ReleaseByteArrayElements(jKey, (jbyte *)key, JNI_ABORT); + + dec->AddRef(); + + LOGI("Created XChaCha20 decryptor %p", dec); + return reinterpret_cast(dec); +} + +JNIEXPORT void JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseDecryptor( + JNIEnv *, jclass, jlong ptr) +{ + if (ptr == 0) return; + auto *dec = reinterpret_cast(ptr); + dec->Release(); +} + +/* ── Install native crash handler ─────────────────────────────── */ + +JNIEXPORT void JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeInstallCrashHandler( + JNIEnv *env, jclass, jstring jPath) +{ + const char *path = env->GetStringUTFChars(jPath, nullptr); + strncpy(g_crash_path, path, sizeof(g_crash_path) - 1); + env->ReleaseStringUTFChars(jPath, path); + + struct sigaction sa = {}; + sa.sa_sigaction = native_crash_handler; + sa.sa_flags = SA_SIGINFO; + sigemptyset(&sa.sa_mask); + + sigaction(SIGSEGV, &sa, &g_old_sigsegv); + sigaction(SIGABRT, &sa, &g_old_sigabrt); + + LOGI("Native crash handler installed, path=%s", g_crash_path); +} + +/* ── Open diagnostics file for E2EE frame logging ────────────── */ + +JNIEXPORT void JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeOpenDiagFile( + JNIEnv *env, jclass, jstring jPath) +{ + if (g_diag_fd >= 0) { close(g_diag_fd); g_diag_fd = -1; } + + const char *path = env->GetStringUTFChars(jPath, nullptr); + strncpy(g_diag_path, path, sizeof(g_diag_path) - 1); + g_diag_fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + env->ReleaseStringUTFChars(jPath, path); + + if (g_diag_fd >= 0) { + diag_write("=== E2EE DIAGNOSTICS ===\n"); + LOGI("Diag file opened: %s", g_diag_path); + } else { + LOGE("Failed to open diag file: %s", g_diag_path); + } +} + +JNIEXPORT void JNICALL +Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCloseDiagFile( + JNIEnv *, jclass) +{ + if (g_diag_fd >= 0) { + diag_write("=== END ===\n"); + close(g_diag_fd); + g_diag_fd = -1; + } +} + +} /* extern "C" */ diff --git a/app/src/main/cpp/webrtc/api/array_view.h b/app/src/main/cpp/webrtc/api/array_view.h new file mode 100644 index 0000000..142b35f --- /dev/null +++ b/app/src/main/cpp/webrtc/api/array_view.h @@ -0,0 +1,24 @@ +// Minimal stub matching WebRTC M125 api/array_view.h +#ifndef API_ARRAY_VIEW_H_ +#define API_ARRAY_VIEW_H_ + +#include + +namespace rtc { + +template +class ArrayView final { + public: + constexpr ArrayView() noexcept : ptr_(nullptr), size_(0) {} + constexpr ArrayView(T* ptr, size_t size) noexcept : ptr_(ptr), size_(size) {} + constexpr T* data() const { return ptr_; } + constexpr size_t size() const { return size_; } + + private: + T* ptr_; + size_t size_; +}; + +} // namespace rtc + +#endif // API_ARRAY_VIEW_H_ diff --git a/app/src/main/cpp/webrtc/api/crypto/frame_decryptor_interface.h b/app/src/main/cpp/webrtc/api/crypto/frame_decryptor_interface.h new file mode 100644 index 0000000..646d6c4 --- /dev/null +++ b/app/src/main/cpp/webrtc/api/crypto/frame_decryptor_interface.h @@ -0,0 +1,42 @@ +// Minimal stub matching WebRTC M125 api/crypto/frame_decryptor_interface.h +#ifndef API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_ +#define API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_ + +#include +#include +#include + +#include "webrtc/rtc_base/ref_count.h" +#include "webrtc/api/array_view.h" +#include "webrtc/api/media_types.h" + +namespace webrtc { + +class FrameDecryptorInterface : public rtc::RefCountInterface { + public: + struct Result { + enum class Status { kOk = 0, kRecoverable, kFailedToDecrypt }; + + Result(Status s, size_t bw) : status(s), bytes_written(bw) {} + bool IsOk() const { return status == Status::kOk; } + + Status status; + size_t bytes_written; + }; + + virtual Result Decrypt(cricket::MediaType media_type, + const std::vector& csrcs, + rtc::ArrayView additional_data, + rtc::ArrayView encrypted_frame, + rtc::ArrayView frame) = 0; + + virtual size_t GetMaxPlaintextByteSize(cricket::MediaType media_type, + size_t encrypted_frame_size) = 0; + + protected: + ~FrameDecryptorInterface() override {} +}; + +} // namespace webrtc + +#endif // API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_ diff --git a/app/src/main/cpp/webrtc/api/crypto/frame_encryptor_interface.h b/app/src/main/cpp/webrtc/api/crypto/frame_encryptor_interface.h new file mode 100644 index 0000000..3c4c892 --- /dev/null +++ b/app/src/main/cpp/webrtc/api/crypto/frame_encryptor_interface.h @@ -0,0 +1,32 @@ +// Minimal stub matching WebRTC M125 api/crypto/frame_encryptor_interface.h +#ifndef API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_ +#define API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_ + +#include +#include + +#include "webrtc/rtc_base/ref_count.h" +#include "webrtc/api/array_view.h" +#include "webrtc/api/media_types.h" + +namespace webrtc { + +class FrameEncryptorInterface : public rtc::RefCountInterface { + public: + virtual int Encrypt(cricket::MediaType media_type, + uint32_t ssrc, + rtc::ArrayView additional_data, + rtc::ArrayView frame, + rtc::ArrayView encrypted_frame, + size_t* bytes_written) = 0; + + virtual size_t GetMaxCiphertextByteSize(cricket::MediaType media_type, + size_t frame_size) = 0; + + protected: + ~FrameEncryptorInterface() override {} +}; + +} // namespace webrtc + +#endif // API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_ diff --git a/app/src/main/cpp/webrtc/api/media_types.h b/app/src/main/cpp/webrtc/api/media_types.h new file mode 100644 index 0000000..1661a3b --- /dev/null +++ b/app/src/main/cpp/webrtc/api/media_types.h @@ -0,0 +1,14 @@ +// Minimal stub matching WebRTC M125 api/media_types.h +#ifndef API_MEDIA_TYPES_H_ +#define API_MEDIA_TYPES_H_ + +namespace cricket { +enum MediaType { + MEDIA_TYPE_AUDIO, + MEDIA_TYPE_VIDEO, + MEDIA_TYPE_DATA, + MEDIA_TYPE_UNSUPPORTED +}; +} // namespace cricket + +#endif // API_MEDIA_TYPES_H_ diff --git a/app/src/main/cpp/webrtc/rtc_base/ref_count.h b/app/src/main/cpp/webrtc/rtc_base/ref_count.h new file mode 100644 index 0000000..e89e5e9 --- /dev/null +++ b/app/src/main/cpp/webrtc/rtc_base/ref_count.h @@ -0,0 +1,21 @@ +// Minimal stub matching WebRTC M125 rtc_base/ref_count.h +#ifndef RTC_BASE_REF_COUNT_H_ +#define RTC_BASE_REF_COUNT_H_ + +namespace rtc { + +enum class RefCountReleaseStatus { kDroppedLastRef, kOtherRefsRemained }; + +// Must match the EXACT virtual layout of the real rtc::RefCountInterface. +class RefCountInterface { + public: + virtual void AddRef() const = 0; + virtual RefCountReleaseStatus Release() const = 0; + + protected: + virtual ~RefCountInterface() {} +}; + +} // namespace rtc + +#endif // RTC_BASE_REF_COUNT_H_ diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index ef62d7f..266cddc 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -19,10 +19,6 @@ import org.bouncycastle.math.ec.rfc7748.X25519 import org.json.JSONObject import org.webrtc.AudioSource import org.webrtc.AudioTrack -import org.webrtc.FrameCryptor -import org.webrtc.FrameCryptorAlgorithm -import org.webrtc.FrameCryptorFactory -import org.webrtc.FrameCryptorKeyProvider import org.webrtc.IceCandidate import org.webrtc.MediaConstraints import org.webrtc.PeerConnection @@ -120,10 +116,10 @@ object CallManager { private var localAudioTrack: AudioTrack? = null private val bufferedRemoteCandidates = mutableListOf() - // E2EE (FrameCryptor AES-GCM) - private var keyProvider: FrameCryptorKeyProvider? = null - private var senderCryptor: FrameCryptor? = null - private var receiverCryptor: FrameCryptor? = null + // E2EE (XChaCha20 — compatible with Desktop) + private var sharedKeyBytes: ByteArray? = null + private var senderEncryptor: XChaCha20E2EE.Encryptor? = null + private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null private var iceServers: List = emptyList() @@ -132,6 +128,7 @@ object CallManager { initialized = true appContext = context.applicationContext CallSoundManager.initialize(context) + XChaCha20E2EE.initWithContext(context) signalWaiter = ProtocolManager.waitCallSignal { packet -> scope.launch { handleSignalPacket(packet) } @@ -255,16 +252,21 @@ object CallManager { } private suspend fun handleSignalPacket(packet: PacketSignalPeer) { + breadcrumb("SIG: ${packet.signalType} from=${packet.src.take(8)}… phase=${_state.value.phase}") + when (packet.signalType) { SignalType.END_CALL_BECAUSE_BUSY -> { + breadcrumb("SIG: peer busy → reset") resetSession(reason = "User is busy", notifyPeer = false) return } SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> { + breadcrumb("SIG: peer disconnected → reset") resetSession(reason = "Peer disconnected", notifyPeer = false) return } SignalType.END_CALL -> { + breadcrumb("SIG: END_CALL → reset") resetSession(reason = "Call ended", notifyPeer = false) return } @@ -274,16 +276,18 @@ object CallManager { val currentPeer = _state.value.peerPublicKey val src = packet.src.trim() if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) { + breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)") return } when (packet.signalType) { SignalType.CALL -> { if (_state.value.phase != CallPhase.IDLE) { + breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY") val callerKey = packet.src.trim() if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) { ProtocolManager.sendCallSignal( - signalType = SignalType.END_CALL, + signalType = SignalType.END_CALL_BECAUSE_BUSY, src = ownPublicKey, dst = callerKey ) @@ -292,6 +296,7 @@ object CallManager { } val incomingPeer = packet.src.trim() if (incomingPeer.isBlank()) return + breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") role = CallRole.CALLEE resetRtcObjects() setPeer(incomingPeer, "", "") @@ -305,11 +310,16 @@ object CallManager { resolvePeerIdentity(incomingPeer) } SignalType.KEY_EXCHANGE -> { + breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange") handleKeyExchange(packet) } SignalType.CREATE_ROOM -> { val incomingRoomId = packet.roomId.trim() - if (incomingRoomId.isBlank()) return + breadcrumb("SIG: CREATE_ROOM roomId=${incomingRoomId.take(16)}…") + if (incomingRoomId.isBlank()) { + breadcrumb("SIG: CREATE_ROOM IGNORED — empty roomId!") + return + } roomId = incomingRoomId updateState { it.copy( @@ -320,23 +330,35 @@ object CallManager { ensurePeerConnectionAndOffer() } SignalType.ACTIVE_CALL -> Unit - else -> Unit + else -> breadcrumb("SIG: unhandled ${packet.signalType}") } } private suspend fun handleKeyExchange(packet: PacketSignalPeer) { val peerKey = packet.src.trim().ifBlank { _state.value.peerPublicKey } - if (peerKey.isBlank()) return + if (peerKey.isBlank()) { + breadcrumb("KE: ABORT — peerKey blank") + return + } setPeer(peerKey, _state.value.peerTitle, _state.value.peerUsername) val peerPublicHex = packet.sharedPublic.trim() - if (peerPublicHex.isBlank()) return + if (peerPublicHex.isBlank()) { + breadcrumb("KE: ABORT — sharedPublic blank") + return + } + breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…") if (role == CallRole.CALLER) { generateSessionKeys() - val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return + val sharedKey = computeSharedSecretHex(peerPublicHex) + if (sharedKey == null) { + breadcrumb("KE: CALLER — computeSharedSecret FAILED") + return + } setupE2EE(sharedKey) - updateState { it.copy(keyCast = sharedKey.take(32), statusText = "Creating room...") } + breadcrumb("KE: CALLER — E2EE ready, sending KEY_EXCHANGE + CREATE_ROOM") + updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") } val localPublic = localPublicKey ?: return ProtocolManager.sendCallSignal( signalType = SignalType.KEY_EXCHANGE, @@ -355,39 +377,58 @@ object CallManager { if (role == CallRole.CALLEE) { if (localPrivateKey == null || localPublicKey == null) { + breadcrumb("KE: CALLEE — regenerating session keys (were null)") generateSessionKeys() } - val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return + val sharedKey = computeSharedSecretHex(peerPublicHex) + if (sharedKey == null) { + breadcrumb("KE: CALLEE — computeSharedSecret FAILED") + return + } setupE2EE(sharedKey) - updateState { it.copy(keyCast = sharedKey.take(32), phase = CallPhase.CONNECTING) } + breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM") + updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) } } } private suspend fun handleWebRtcPacket(packet: PacketWebRTC) { val phase = _state.value.phase - if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) return - val pc = peerConnection ?: return + if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) { + breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase") + return + } + val pc = peerConnection + if (pc == null) { + breadcrumb("RTC: IGNORED ${packet.signalType} — peerConnection=null!") + return + } when (packet.signalType) { WebRTCSignalType.ANSWER -> { + breadcrumb("RTC: ANSWER received") val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return try { pc.setRemoteDescriptionAwait(answer) remoteDescriptionSet = true flushBufferedRemoteCandidates() + breadcrumb("RTC: ANSWER applied OK, remoteDesc=true") } catch (e: Exception) { - Log.e(TAG, "Failed to set remote answer", e) + breadcrumb("RTC: ANSWER FAILED — ${e.message}") + saveCrashReport("setRemoteDescription(answer) failed", e) } } WebRTCSignalType.ICE_CANDIDATE -> { val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return if (!remoteDescriptionSet) { + breadcrumb("RTC: ICE buffered (remoteDesc not set yet)") bufferedRemoteCandidates.add(candidate) return } + breadcrumb("RTC: ICE added: ${candidate.sdp.take(40)}…") runCatching { pc.addIceCandidate(candidate) } } WebRTCSignalType.OFFER -> { + breadcrumb("RTC: OFFER received (offerSent=$offerSent)") val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return try { pc.setRemoteDescriptionAwait(remoteOffer) @@ -399,8 +440,10 @@ object CallManager { signalType = WebRTCSignalType.ANSWER, sdpOrCandidate = serializeSessionDescription(answer) ) + breadcrumb("RTC: OFFER handled → ANSWER sent") } catch (e: Exception) { - Log.e(TAG, "Failed to handle remote offer", e) + breadcrumb("RTC: OFFER FAILED — ${e.message}") + saveCrashReport("handleOffer failed", e) } } } @@ -422,12 +465,27 @@ object CallManager { private suspend fun ensurePeerConnectionAndOffer() { val peerKey = _state.value.peerPublicKey - if (peerKey.isBlank() || roomId.isBlank()) return - if (offerSent) return + if (peerKey.isBlank() || roomId.isBlank()) { + breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)}… room=${roomId.take(8)}…") + return + } + if (offerSent) { + breadcrumb("PC: ensurePCAndOffer SKIP — offerSent=true") + return + } + breadcrumb("PC: ensurePCAndOffer START role=$role room=${roomId.take(8)}…") ensurePeerFactory() - val factory = peerConnectionFactory ?: return - val pc = peerConnection ?: createPeerConnection(factory) ?: return + val factory = peerConnectionFactory + if (factory == null) { + breadcrumb("PC: ABORT — factory=null") + return + } + val pc = peerConnection ?: createPeerConnection(factory) + if (pc == null) { + breadcrumb("PC: ABORT — createPeerConnection returned null") + return + } if (audioSource == null) { audioSource = factory.createAudioSource(MediaConstraints()) @@ -436,6 +494,7 @@ object CallManager { localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource) localAudioTrack?.setEnabled(!_state.value.isMuted) pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID)) + breadcrumb("PC: audio track added, attaching E2EE…") attachSenderE2EE(pc) } @@ -447,16 +506,20 @@ object CallManager { sdpOrCandidate = serializeSessionDescription(offer) ) offerSent = true + breadcrumb("PC: OFFER sent OK") } catch (e: Exception) { - Log.e(TAG, "Failed to create/send offer", e) + breadcrumb("PC: OFFER FAILED — ${e.message}") + saveCrashReport("createOffer failed", e) } } private fun createPeerConnection(factory: PeerConnectionFactory): PeerConnection? { + breadcrumb("PC: createPeerConnection iceServers=${iceServers.size}") val rtcIceServers = if (iceServers.isNotEmpty()) { iceServers } else { + breadcrumb("PC: no TURN servers — using Google STUN fallback") listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()) } @@ -466,12 +529,19 @@ object CallManager { val observer = object : PeerConnection.Observer { - override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit - override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit + override fun onSignalingChange(newState: PeerConnection.SignalingState?) { + breadcrumb("PC: signalingState=$newState") + } + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { + breadcrumb("PC: iceConnState=$newState") + } override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit - override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) { + breadcrumb("PC: iceGathering=$newState") + } override fun onIceCandidate(candidate: IceCandidate?) { if (candidate == null) return + breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…") ProtocolManager.sendWebRtcSignal( signalType = WebRTCSignalType.ICE_CANDIDATE, sdpOrCandidate = serializeIceCandidate(candidate) @@ -484,9 +554,11 @@ object CallManager { override fun onRenegotiationNeeded() = Unit override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array?) = Unit override fun onTrack(transceiver: RtpTransceiver?) { + breadcrumb("PC: onTrack → attachReceiverE2EE") attachReceiverE2EE(transceiver) } override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { + breadcrumb("PC: connState=$newState") when (newState) { PeerConnection.PeerConnectionState.CONNECTED -> { onCallConnected() @@ -494,7 +566,10 @@ object CallManager { PeerConnection.PeerConnectionState.DISCONNECTED, PeerConnection.PeerConnectionState.FAILED, PeerConnection.PeerConnectionState.CLOSED -> { - resetSession(reason = "Connection lost", notifyPeer = false) + // Dispatch to our scope — this callback fires on WebRTC thread + scope.launch { + resetSession(reason = "Connection lost", notifyPeer = false) + } } else -> Unit } @@ -551,6 +626,7 @@ object CallManager { } private fun resetSession(reason: String?, notifyPeer: Boolean) { + breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") val snapshot = _state.value val wasActive = snapshot.phase != CallPhase.IDLE val peerToNotify = snapshot.peerPublicKey @@ -571,6 +647,7 @@ object CallManager { Log.d(TAG, reason) } resetRtcObjects() + e2eeAvailable = true role = null roomId = "" offerSent = false @@ -585,6 +662,9 @@ object CallManager { private fun resetRtcObjects() { bufferedRemoteCandidates.clear() + // Teardown E2EE BEFORE closing PeerConnection — WebRTC may access + // encryptor/decryptor during close(), causing SIGSEGV if done after. + teardownE2EE() runCatching { localAudioTrack?.setEnabled(false) } runCatching { localAudioTrack?.dispose() } runCatching { audioSource?.dispose() } @@ -592,7 +672,6 @@ object CallManager { localAudioTrack = null audioSource = null peerConnection = null - teardownE2EE() } private fun flushBufferedRemoteCandidates() { @@ -604,7 +683,10 @@ object CallManager { bufferedRemoteCandidates.clear() } - // ── E2EE (FrameCryptor AES-GCM) ───────────────────────────────── + // ── E2EE (XChaCha20 — compatible with Desktop) ────────────────── + + @Volatile + private var e2eeAvailable = true private fun setupE2EE(sharedKeyHex: String) { val keyBytes = sharedKeyHex.hexToBytes() @@ -612,53 +694,107 @@ object CallManager { Log.e(TAG, "E2EE: invalid key (${keyBytes?.size ?: 0} bytes)") return } - val kp = FrameCryptorFactory.createFrameCryptorKeyProvider( - /* sharedKey */ true, - /* ratchetSalt */ ByteArray(0), - /* ratchetWindowSize */ 0, - /* uncryptedMagicBytes */ ByteArray(0), - /* failureTolerance */ 0, - /* keyRingSize */ 1, - /* discardFrameWhenCryptorNotReady */ false - ) - kp.setSharedKey(0, keyBytes.copyOf(32)) - keyProvider = kp - Log.i(TAG, "E2EE key provider created (AES-GCM)") + sharedKeyBytes = keyBytes.copyOf(32) + // Open native diagnostics file for frame-level logging + try { + val dir = java.io.File(appContext!!.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath + XChaCha20E2EE.nativeOpenDiagFile(diagPath) + } catch (_: Throwable) {} + Log.i(TAG, "E2EE key ready (XChaCha20)") + } + + /** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */ + private fun breadcrumb(step: String) { + try { + val dir = java.io.File(appContext!!.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val f = java.io.File(dir, "e2ee_breadcrumb.txt") + // Reset file at start of key exchange + if (step.startsWith("KE:") && step.contains("agreement")) { + f.writeText("") + } + f.appendText("${System.currentTimeMillis()} $step\n") + } catch (_: Throwable) {} + } + + /** Save a full crash report to crash_reports/ */ + private fun saveCrashReport(title: String, error: Throwable) { + try { + val dir = java.io.File(appContext!!.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date()) + val f = java.io.File(dir, "crash_e2ee_$ts.txt") + val sw = java.io.StringWriter() + error.printStackTrace(java.io.PrintWriter(sw)) + f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw") + } catch (_: Throwable) {} } private fun attachSenderE2EE(pc: PeerConnection) { - val factory = peerConnectionFactory ?: return - val kp = keyProvider ?: return + if (!e2eeAvailable) return + val key = sharedKeyBytes ?: return val sender = pc.senders.firstOrNull() ?: return - senderCryptor = FrameCryptorFactory.createFrameCryptorForRtpSender( - factory, sender, "caller", FrameCryptorAlgorithm.AES_GCM, kp - ) - senderCryptor?.setEnabled(true) - Log.i(TAG, "E2EE sender cryptor attached") + try { + breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}") + val enc = XChaCha20E2EE.Encryptor(key) + breadcrumb("2. encryptor created") + val ptr = enc.getNativeFrameEncryptor() + breadcrumb("3. encryptor ptr=0x${ptr.toString(16)}") + if (ptr == 0L) { + saveCrashReport("Encryptor native ptr is 0", RuntimeException("null native ptr")) + return + } + breadcrumb("4. calling sender.setFrameEncryptor…") + sender.setFrameEncryptor(enc) + breadcrumb("5. setFrameEncryptor OK!") + senderEncryptor = enc + } catch (e: Throwable) { + saveCrashReport("attachSenderE2EE failed", e) + Log.e(TAG, "E2EE: sender encryptor failed", e) + e2eeAvailable = false + } } private fun attachReceiverE2EE(transceiver: RtpTransceiver?) { - val factory = peerConnectionFactory ?: return - val kp = keyProvider ?: return + if (!e2eeAvailable) return + val key = sharedKeyBytes ?: return val receiver = transceiver?.receiver ?: return - receiverCryptor = FrameCryptorFactory.createFrameCryptorForRtpReceiver( - factory, receiver, "callee", FrameCryptorAlgorithm.AES_GCM, kp - ) - receiverCryptor?.setEnabled(true) - Log.i(TAG, "E2EE receiver cryptor attached") + try { + breadcrumb("6. decryptor: creating…") + val dec = XChaCha20E2EE.Decryptor(key) + breadcrumb("7. decryptor created") + val ptr = dec.getNativeFrameDecryptor() + breadcrumb("8. decryptor ptr=0x${ptr.toString(16)}") + if (ptr == 0L) { + saveCrashReport("Decryptor native ptr is 0", RuntimeException("null native ptr")) + return + } + breadcrumb("9. calling receiver.setFrameDecryptor…") + receiver.setFrameDecryptor(dec) + breadcrumb("10. setFrameDecryptor OK!") + receiverDecryptor = dec + } catch (e: Throwable) { + saveCrashReport("attachReceiverE2EE failed", e) + Log.e(TAG, "E2EE: receiver decryptor failed", e) + e2eeAvailable = false + } } private fun teardownE2EE() { - runCatching { senderCryptor?.setEnabled(false) } - runCatching { senderCryptor?.dispose() } - runCatching { receiverCryptor?.setEnabled(false) } - runCatching { receiverCryptor?.dispose() } - runCatching { keyProvider?.dispose() } - senderCryptor = null - receiverCryptor = null - keyProvider = null + // Release our ref. WebRTC holds its own ref via scoped_refptr. + // After our Release: WebRTC ref remains. On peerConnection.close() + // WebRTC releases its ref → ref=0 → native object deleted. + runCatching { senderEncryptor?.dispose() } + runCatching { receiverDecryptor?.dispose() } + senderEncryptor = null + receiverDecryptor = null + sharedKeyBytes?.let { it.fill(0) } + sharedKeyBytes = null + runCatching { XChaCha20E2EE.nativeCloseDiagFile() } } private fun generateSessionKeys() { @@ -674,10 +810,27 @@ object CallManager { val privateKey = localPrivateKey ?: return null val peerPublic = peerPublicHex.hexToBytes() ?: return null if (peerPublic.size != 32) return null - val shared = ByteArray(32) - val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, shared, 0) - if (!ok) return null - return shared.toHex() + val rawDh = ByteArray(32) + breadcrumb("KE: X25519 agreement…") + val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0) + if (!ok) { + breadcrumb("KE: X25519 FAILED") + return null + } + breadcrumb("KE: X25519 OK, calling HSalsa20…") + return try { + val naclShared = XChaCha20E2EE.hsalsa20(rawDh) + rawDh.fill(0) + breadcrumb("KE: HSalsa20 OK, key ready") + naclShared.toHex() + } catch (e: Throwable) { + saveCrashReport("HSalsa20 failed", e) + breadcrumb("KE: HSalsa20 FAILED: ${e.message}") + e2eeAvailable = false + val hex = rawDh.toHex() + rawDh.fill(0) + hex + } } private fun serializeSessionDescription(description: SessionDescription): String { diff --git a/app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt b/app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt new file mode 100644 index 0000000..1e0e1d9 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt @@ -0,0 +1,107 @@ +package com.rosetta.messenger.network + +import android.util.Log +import org.webrtc.FrameDecryptor +import org.webrtc.FrameEncryptor + +/** + * XChaCha20-based E2EE compatible with Rosetta Desktop. + * + * Desktop encrypts audio frames using XChaCha20 (libsodium) with a nonce + * derived from the RTP timestamp. The shared key is computed as + * nacl.box.before(peerPub, ownSecret) = HSalsa20(zeros, X25519(sk, pk)). + * + * This class provides: + * - [hsalsa20] — applies HSalsa20 to a raw X25519 shared secret, + * producing the same key as nacl.box.before(). + * - [Encryptor] / [Decryptor] — WebRTC FrameEncryptor / FrameDecryptor + * that use XChaCha20 matching the Desktop implementation. + */ +object XChaCha20E2EE { + + private const val TAG = "XChaCha20E2EE" + + var nativeLoaded: Boolean = false + private set + + private var crashFilePath: String? = null + + fun initWithContext(context: android.content.Context) { + if (!nativeLoaded) return + try { + val dir = java.io.File(context.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val path = java.io.File(dir, "native_crash.txt").absolutePath + crashFilePath = path + nativeInstallCrashHandler(path) + Log.i(TAG, "Native crash handler installed → $path") + } catch (e: Throwable) { + Log.e(TAG, "Failed to install native crash handler", e) + } + } + + init { + try { + System.loadLibrary("rosetta_e2ee") + nativeLoaded = true + Log.i(TAG, "Native library loaded successfully") + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "Failed to load native library rosetta_e2ee", e) + } + } + + /** + * HSalsa20(zeros_16, rawDhShared, sigma) — converts a raw X25519 + * shared secret into the NaCl box-before shared key. + */ + fun hsalsa20(rawDhShared: ByteArray): ByteArray { + require(nativeLoaded) { "Native library not loaded" } + require(rawDhShared.size >= 32) { "Raw DH shared secret must be >= 32 bytes" } + return nativeHSalsa20(rawDhShared) + } + + /** WebRTC [FrameEncryptor] backed by native XChaCha20. */ + class Encryptor(key: ByteArray) : FrameEncryptor { + private val nativePtr: Long + + init { + require(nativeLoaded) { "Native library not loaded" } + nativePtr = nativeCreateEncryptor(key) + Log.i(TAG, "Encryptor created, ptr=0x${nativePtr.toString(16)}") + } + + override fun getNativeFrameEncryptor(): Long = nativePtr + + fun dispose() { + if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr) + } + } + + /** WebRTC [FrameDecryptor] backed by native XChaCha20. */ + class Decryptor(key: ByteArray) : FrameDecryptor { + private val nativePtr: Long + + init { + require(nativeLoaded) { "Native library not loaded" } + nativePtr = nativeCreateDecryptor(key) + Log.i(TAG, "Decryptor created, ptr=0x${nativePtr.toString(16)}") + } + + override fun getNativeFrameDecryptor(): Long = nativePtr + + fun dispose() { + if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr) + } + } + + /* ── JNI ─────────────────────────────────────────────────── */ + + @JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray + @JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long + @JvmStatic private external fun nativeReleaseEncryptor(ptr: Long) + @JvmStatic private external fun nativeCreateDecryptor(key: ByteArray): Long + @JvmStatic private external fun nativeReleaseDecryptor(ptr: Long) + @JvmStatic private external fun nativeInstallCrashHandler(path: String) + @JvmStatic external fun nativeOpenDiagFile(path: String) + @JvmStatic external fun nativeCloseDiagFile() +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt index 1ef310b..787d566 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -17,6 +17,9 @@ import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -93,17 +96,28 @@ fun CallOverlay( Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom)) ) ) { - // ── Encryption badge top center ── + // ── Top bar: "Encrypted" left + QR icon right ── if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) { - Text( - text = "\uD83D\uDD12 Encrypted", - color = Color.White.copy(alpha = 0.4f), - fontSize = 13.sp, + Row( modifier = Modifier + .fillMaxWidth() .align(Alignment.TopCenter) .statusBarsPadding() - .padding(top = 12.dp) - ) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "\uD83D\uDD12 Encrypted", + color = Color.White.copy(alpha = 0.4f), + fontSize = 13.sp, + ) + + // QR grid icon — tap to show popover + if (state.keyCast.isNotBlank()) { + EncryptionKeyButton(keyHex = state.keyCast) + } + } } // ── Center content: rings + avatar + name + status ── @@ -162,14 +176,6 @@ fun CallOverlay( ) } - // Emoji key - if (state.keyCast.isNotBlank() && state.phase == CallPhase.ACTIVE) { - Spacer(modifier = Modifier.height(16.dp)) - val emojis = remember(state.keyCast) { keyToEmojis(state.keyCast) } - if (emojis.isNotBlank()) { - Text(emojis, fontSize = 32.sp, letterSpacing = 4.sp, textAlign = TextAlign.Center) - } - } } // ── Bottom buttons ── @@ -430,16 +436,113 @@ private fun formatCallDuration(seconds: Int): String { return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec) } -private fun keyToEmojis(keyCast: String): String { - val emojis = listOf( - "\uD83D\uDE00", "\uD83D\uDE0E", "\uD83D\uDE80", "\uD83D\uDD12", - "\uD83C\uDF1F", "\uD83C\uDF08", "\uD83D\uDC8E", "\uD83C\uDF40", - "\uD83D\uDD25", "\uD83C\uDF3A", "\uD83E\uDD8B", "\uD83C\uDF0D", - "\uD83C\uDF89", "\uD83E\uDD84", "\uD83C\uDF52", "\uD83D\uDCA1" - ) - val hex = keyCast.replace(Regex("[^0-9a-fA-F]"), "").take(8) - if (hex.length < 8) return "" - return (0 until 4).joinToString(" ") { i -> - emojis[hex.substring(i * 2, i * 2 + 2).toInt(16) % emojis.size] +/** + * QR icon in top-right corner — tap to show encryption key dropdown. + * 1:1 match with Desktop's IconQrcode + Popover. + */ +@Composable +private fun EncryptionKeyButton(keyHex: String) { + var showPopup by remember { mutableStateOf(false) } + + Box { + // QR code icon (matches Desktop IconQrcode size={24} color="white") + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = "Encryption key", + tint = Color.White, + modifier = Modifier + .size(24.dp) + .clickable { showPopup = !showPopup } + ) + + // Dropdown popover (matches Desktop Popover width={300} withArrow) + androidx.compose.material3.DropdownMenu( + expanded = showPopup, + onDismissRequest = { showPopup = false }, + modifier = Modifier + .widthIn(max = 300.dp) + .background(Color(0xFF1E293B)), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "This call is secured by 256 bit end-to-end encryption. " + + "Only you and the recipient can read or listen to the content of this call.", + color = Color.White.copy(alpha = 0.6f), + fontSize = 11.sp, + lineHeight = 15.sp, + modifier = Modifier.weight(1f) + ) + Canvas(modifier = Modifier.size(80.dp)) { + drawKeyGrid(keyHex, size.width, this) + } + } + Spacer(modifier = Modifier.height(8.dp)) + val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .clickable { + clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(keyHex)) + copied = true + } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = if (copied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = "Copy key", + tint = Color.White.copy(alpha = 0.5f), + modifier = Modifier.size(14.dp) + ) + Text( + text = if (copied) "Copied!" else keyHex.take(16) + "..." + keyHex.takeLast(8), + color = Color.White.copy(alpha = 0.4f), + fontSize = 10.sp, + maxLines = 1, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + } + } +} + +/** Palette matching Desktop's Mantine theme.colors.blue[1..5] */ +private val KeyGridPalette = listOf( + Color(0xFFDBE4FF), // blue[1] + Color(0xFFBAC8FF), // blue[2] + Color(0xFF91A7FF), // blue[3] + Color(0xFF748FFC), // blue[4] + Color(0xFF5C7CFA), // blue[5] +) + +/** + * Draw 8x8 color grid — same algorithm as Desktop KeyImage.tsx: + * each character's charCode % palette.size determines the color. + */ +private fun drawKeyGrid(keyHex: String, totalSize: Float, scope: androidx.compose.ui.graphics.drawscope.DrawScope) { + val cells = 8 + val cellSize = totalSize / cells + for (i in 0 until cells * cells) { + val color = if (i < keyHex.length) { + KeyGridPalette[keyHex[i].code % KeyGridPalette.size] + } else { + KeyGridPalette[0] + } + val row = i / cells + val col = i % cells + scope.drawRect( + color = color, + topLeft = Offset(col * cellSize, row * cellSize), + size = androidx.compose.ui.geometry.Size(cellSize, cellSize) + ) } } diff --git a/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt b/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt index 8e1a485..ad113d9 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt @@ -44,8 +44,8 @@ class CrashReportManager private constructor(private val context: Context) : Thr fun getCrashReports(context: Context): List { val crashDir = File(context.filesDir, CRASH_DIR) if (!crashDir.exists()) return emptyList() - - return crashDir.listFiles() + + val reports = crashDir.listFiles() ?.filter { it.extension == "txt" } ?.sortedByDescending { it.lastModified() } ?.map { file -> @@ -54,7 +54,21 @@ class CrashReportManager private constructor(private val context: Context) : Thr timestamp = file.lastModified(), content = file.readText() ) - } ?: emptyList() + }?.toMutableList() ?: mutableListOf() + + // Include native crash report if present + val nativeCrash = File(crashDir, "native_crash.txt") + if (nativeCrash.exists() && nativeCrash.length() > 0) { + reports.add(0, CrashReport( + fileName = "native_crash.txt", + timestamp = nativeCrash.lastModified(), + content = nativeCrash.readText() + )) + // Delete after reading so it doesn't show up again + nativeCrash.delete() + } + + return reports } /**