Попытка обновления шифрования звонков и работа над UI
This commit is contained in:
33
app/src/main/cpp/CMakeLists.txt
Normal file
33
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -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})
|
||||
248
app/src/main/cpp/crypto.c
Normal file
248
app/src/main/cpp/crypto.c
Normal file
@@ -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 <string.h>
|
||||
|
||||
/* ── 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));
|
||||
}
|
||||
33
app/src/main/cpp/crypto.h
Normal file
33
app/src/main/cpp/crypto.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#ifndef ROSETTA_CRYPTO_H
|
||||
#define ROSETTA_CRYPTO_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
390
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
390
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
@@ -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 <jni.h>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
#include <vector>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <android/log.h>
|
||||
|
||||
/* 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<const uint8_t> /*additional_data*/,
|
||||
rtc::ArrayView<const uint8_t> frame,
|
||||
rtc::ArrayView<uint8_t> 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<int> ref_{0};
|
||||
mutable std::atomic<uint32_t> counter_{0};
|
||||
mutable std::atomic<int> 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<uint32_t>& /*csrcs*/,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||
rtc::ArrayView<uint8_t> 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<int> ref_{0};
|
||||
mutable std::atomic<int> 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<jlong>(enc);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseEncryptor(
|
||||
JNIEnv *, jclass, jlong ptr)
|
||||
{
|
||||
if (ptr == 0) return;
|
||||
auto *enc = reinterpret_cast<XChaCha20Encryptor *>(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<jlong>(dec);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeReleaseDecryptor(
|
||||
JNIEnv *, jclass, jlong ptr)
|
||||
{
|
||||
if (ptr == 0) return;
|
||||
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(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" */
|
||||
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
@@ -0,0 +1,24 @@
|
||||
// Minimal stub matching WebRTC M125 api/array_view.h
|
||||
#ifndef API_ARRAY_VIEW_H_
|
||||
#define API_ARRAY_VIEW_H_
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace rtc {
|
||||
|
||||
template <typename T>
|
||||
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_
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#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<uint32_t>& csrcs,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||
rtc::ArrayView<uint8_t> 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_
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#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<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> frame,
|
||||
rtc::ArrayView<uint8_t> 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_
|
||||
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
@@ -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_
|
||||
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
@@ -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_
|
||||
@@ -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<IceCandidate>()
|
||||
|
||||
// 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<PeerConnection.IceServer> = 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<out org.webrtc.MediaStream>?) = 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 {
|
||||
|
||||
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
||||
fun getCrashReports(context: Context): List<CrashReport> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user