/* * dm_core.h — прямой p2p чат между двумя пользователями (DM) * * Личная беседа не создаёт отдельную TOPO_GROUP и не синхронизируется через member_sync/Merkle. * Для доставки использует маршрут из общей CHAT-группы участников. * - conv_id и content_key детерминированно выводятся обеими сторонами * из своих ключей и pubkey пира (см. dm_crypto.h) — без переговоров. * - сообщения идут через etcp_router (прямое соединение или релей), * при недоступности пира — через dm_mailbox (суперузлы). * - содержимое шифруется E2E (AES-256-CCM), запись подписывается Ed25519. * * Два независимых направленных потока (я→пир и пир→я) с монотонным исходящим seq. * Все API и трамплины выполняются в uasync-потоке с живым instance/SQLite/router. * Входные строки/тела заимствуются на время вызова; функции не забирают их владение. * Запросы трамплинов — один блок u_malloc/u_calloc с NUL-строками; обработчик освобождает блок. * Instance должен пережить запрос и его отложенное завершение; остановка сериализуется через uasync. */ #ifndef DM_CORE_H #define DM_CORE_H #include #include #include #include "dm_media.h" struct UTUN_INSTANCE; #ifdef __cplusplus extern "C" { #endif /* ── Каноническое тело DM-сообщения (общий wire-формат dm_core и dm_mailbox) ── * [conv_id:8][seq:8][ts:8][author:8][ct_len:1][ct:ct_len][data_len:2][data_enc:data_len][media?:56][sig:64] * media присутствует только для ct="file", подписан вместе с сообщением. * data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig. * Числа native-endian; seq=1..INT64_MAX, ts — миллисекунды NTP-времени instance. * ETCP_RT_ID_DM=0x34: [MSG=1][тело] либо [ACK=2][квитанция:136]; router-пакет подписан. */ #define DM_MSG_FIXED_HDR 33 /* conv_id+seq+ts+author(32) + ct_len(1) — до ct */ #define DM_MSG_SIG_SIZE 64 #define DM_DATA_MAX 1024 #define DM_RECEIPT_SIZE 136 #define DM_PEER_NAME_MAX 63 /* UTF-8 байты без NUL; сохраняются только целые code points. */ /* Вычислить полную длину канонического тела из его заголовка. * Возвращает 1 и *out_len, либо 0 если буфер мал/повреждён (выход не меняется). * Допускает хвост после тела; для отдельного сообщения вызывающий требует *out_len==avail. * Многобайтные числа имеют порядок байтов хоста; подпись/seq/идентичность здесь не проверяются. */ static inline int dm_msg_len(const uint8_t* msg, size_t avail, size_t* out_len) { if (!msg || !out_len || avail < DM_MSG_FIXED_HDR) return 0; uint8_t ct_len = msg[32]; if (avail < (size_t)DM_MSG_FIXED_HDR + ct_len + 2) return 0; uint16_t data_len; memcpy(&data_len, msg + DM_MSG_FIXED_HDR + ct_len, 2); if (data_len < 16 || data_len > DM_DATA_MAX + 16) return 0; size_t media_len = ct_len == 4 && !memcmp(msg + DM_MSG_FIXED_HDR, "file", 4) ? DM_MEDIA_DESC_SIZE : 0; size_t total = (size_t)DM_MSG_FIXED_HDR + ct_len + 2 + data_len + media_len + DM_MSG_SIG_SIZE; if (avail < total) return 0; *out_len = total; return 1; } /* Выбрать общую CHAT-группу с путём router к peer, сначала preferred (0=без предпочтения). * 1 и *group_id при успехе; 0 при отсутствии пути/ошибке, выход не меняется. * group_id обязателен; наличие пути не гарантирует успешной последующей отправки. */ int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id); /* Проверить точную длину msg/len, conv/author/recipient/seq, descriptor и подпись автора из nodes. * 0=корректно, -1=отказ. Нужна SQLite, владелец dm_core не требуется. * Не расшифровывает тело и не проверяет членство/роль router-отправителя: это обязанность вызывающего. */ int dm_verify_message(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t len); /* Проверить и расшифровать входящее тело для локального узла, атомарно сохранить историю/беседу/media. * 0 и receipt после commit либо точного повтора; -1 при отказе. Для file нужен живой dm_media. * Точный удалённый повтор подтверждается по отпечатку без восстановления истории; конфликт отклоняется. * receipt — обязательные 136 байт; при -1 буфер не использовать. Ошибка подписи квитанции возможна после commit. */ int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len, uint8_t receipt[DM_RECEIPT_SIZE]); /* receipt — ровно 136 доступных байт: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64]. * Проверяет идентичность и подпись B по первым 72 байтам; при msg!=NULL также точное тело msg/len. * 0=корректно, -1=отказ. Не проверяет наличие outbox и ничего не записывает. */ int dm_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE], const uint8_t* msg, size_t len); /* Для локального автора проверить receipt и удалить только точный outbox; история/file receipt сохраняются. * 0=удалено либо outbox уже отсутствует, -1=ошибка/конфликт. Требуется запущенный dm_core. */ int dm_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE]); /* Жизненный цикл в uasync: init после chat_core_init с SQLite/router/ua ядра; 0=успех, -1=ошибка. * Повторный init запущенного владельца возвращает 0. Для file-send/accept нужен также dm_media_init. * destroy отменяет invite/таймер и снимает bind; БД/outbox сохраняются. Вызывать до chat_core_destroy. */ int dm_core_init(struct UTUN_INSTANCE* inst); void dm_core_destroy(struct UTUN_INSTANCE* inst); /* Начать беседу с пользователем (peer). source_ch_id — канал, где нашли target * (общая группа; нужен для маршрутизации и подключения). Создаёт беседу если её * ещё нет, выводит conv_id/ключи. Подключение для передачи медиа принадлежит media. * Снимает hidden без смены поколения; публикует CONV_UPDATED с текущим поколением. * Ключи обязательны, peer_name/source_ch_id могут быть NULL; переданные ключи пира проверяет вызывающий. * Имя ограничено DM_PEER_NAME_MAX байтами по границе UTF-8; некорректный суффикс отбрасывается с WARN. * Возвращает 0 при успехе, <0 при ошибке. * Отказ чтения/проверки ключей не создаёт беседу, не меняет её поля/hidden и не выдаёт CONV_UPDATED. * Создание разрешено только после успешного SELECT без строки; существующие seq сохраняются. */ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id, const uint8_t peer_x25519[32], const uint8_t peer_ed25519[32], const char* peer_name, const char* source_ch_id); /* Отправить сообщение в беседу. Шифрует, подписывает, сохраняет локально и * доставляет (прямое соединение при доступности пира, иначе mailbox). * conv_id — строковое представление (как в dm_conversations.conv_id). * Для ct="file" data=[descriptor:56][attachment_info в формате attachment_encode()], после подготовки dm_media. * Descriptor подписывается открыто, метаданные вложения шифруются; формат — media_async/attachment.h. * Для ct="invite" data=[group_name:utf8][NUL][полная ссылка:utf8], без завершающего NUL. * Имя и ссылка шифруются и подписываются вместе; GUI получает data=ссылка, tags.group_name=имя. * 0 после атомарного commit истории/seq/outbox, -1 при ошибке; доставка подтверждается отдельно. * content_type до 255 байт, data_len до 1024 (для file вместе с descriptor); скрытая беседа отклоняется. */ int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content_type, const uint8_t* data, uint32_t data_len); /* Локальное удаление по dm_history_delete; generation защищает повторно открытую беседу. * CHAT_EVT_DM_DELETE_RESULT: [request_id:8][conv:u64][generation:8][success:u8], всего 25 байт native-endian. */ struct dm_delete_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро до завершения запроса. */ char conv_id[64]; /* Десятичный ID беседы с NUL. */ uint64_t generation, /* Ненулевое ожидаемое поколение. */ request_id; /* Корреляция результата с запросом UI. */ }; void dm_delete_trampoline(void* arg); /* После commit удаления отменить ожидающий invite этой беседы; квитанции/другие беседы не меняет. */ void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv); /* До 200 видимых бесед по last_ts DESC: [{conv_id,peer_node_id,peer_name,last_ts,out_seq,in_seq,generation}]. * buf вмещает JSON и NUL; 0 и *out_len без NUL при успехе, -1 при ошибке. Все вызовы в uasync. * Имя из БД либо nodes нормализуется по правилам dm_start; снимок не меняет сохранённые строки. */ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf_size, size_t* out_len); /* Сообщения беседы в JSON: расшифрованное содержимое, статусы доставки и медиа. * count=1..200 задаёт предел; count<=0 или count>200 означает 200. * offset>=0 отсчитывается от новых сообщений; offset=0 выбирает последние count. * Выбранная страница возвращается по возрастанию ts, seq, dir. * buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке. * pending относится к квитанции сообщения. media_status: delivered/ready/downloading/expired/pending. * hidden здесь не фильтруется; интерфейс выбирает видимую беседу по списку/поколению. */ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int count, int offset, char* buf, size_t buf_size, size_t* out_len); /* ── Трамплины для gui_bridge (GUI → uasync) ── */ /* Начать беседу с узлом из общей группы. Трамплин сам находит x25519/ed25519 * пира в таблице nodes (как headless hc_handle_dm_start), после чего вызывает * dm_start(). Результат — событие CHAT_EVT_DM_CONV_UPDATED. */ struct dm_start_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ uint64_t peer_node_id; /* Другой участник общей группы; ключи читаются из nodes. */ char source_ch_id[64]; /* Десятичный ID общей группы либо пустая строка. */ char peer_name[128]; /* UTF-8 имя с NUL; dm_start ограничит до 63 байт. */ }; void dm_start_trampoline(void* arg); /* Отправить текстовое сообщение в беседу (content_type="text"). */ struct dm_send_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ char conv_id[64]; /* Десятичный ID беседы с NUL. */ uint64_t generation; /* 0: захватить при входе в uasync, до любой фоновой работы. */ uint32_t data_len; /* Число байт текста, до DM_DATA_MAX. */ uint8_t* data; /* Указывает на data_len байт в том же блоке после структуры. */ }; void dm_send_trampoline(void* arg); /* Приглашение в выбранный канал через PM: свежая ссылка после ACK регистрации, * затем dm_send("invite") с названием группы. Получатель фиксирован независимо от текущего UI. * Один ожидающий запрос на instance; результат CHAT_EVT_DM_INVITE_RESULT. * Результат: [request_id:8][result:4][peer:8][conv:8][generation:8], всего 36 байт native-endian. * cancel=1 отменяет только ожидающий request_id; отправленное PM не отзывается. * arg=u_calloc(dm_invite_req); обработчик/финальный callback освобождает arg. */ enum dm_invite_result { DM_INVITE_OK = 0, DM_INVITE_START_ERROR = -1, DM_INVITE_BUILD_ERROR = -2, DM_INVITE_SEND_ERROR = -3, DM_INVITE_CANCELLED = -4, DM_INVITE_BUSY = -5 }; struct dm_invite_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро до итогового callback. */ uint64_t request_id, /* Ненулевой ID; cancel использует тот же ID. */ peer_node_id, /* Фиксированный получатель, не локальный узел. */ channel_id, /* Выбранная активная CHAT-группа отправителя. */ generation; /* Заполняет uasync до регистрации; проверяет callback. */ char source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */ char peer_name[128]; /* UTF-8 имя для новой беседы с NUL. */ int cancel; /* 0=создать, 1=отменить ожидающий request_id. */ struct chat_invite_build* pending; /* Внутренний handle; запрос создаётся обнулённым. */ }; void dm_invite_trampoline(void* arg); /* Запросить последние 200 сообщений со статусами. CHAT_EVT_DM_MESSAGES: * [conv_id_len:1][conv_id:var][generation:8][JSON:utf8]. Все чтения в uasync. * Ненулевое generation проверяется до чтения; 0 захватывает текущее поколение. GUI отбрасывает старый снимок. */ struct dm_messages_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ char conv_id[64]; /* Десятичный ID с NUL, также для запроса маршрута звонка. */ uint64_t generation; /* Поколение выбранного PM; для запроса маршрута не используется. */ }; void dm_messages_trampoline(void* arg); /* 1 при живом BGP-маршруте общей группы, 0 при недоступности/ошибке. peer/group_id обязательны. * При корректных аргументах выходы сначала обнуляются; peer может быть известен и без маршрута. * Трамплин выдаёт CHAT_EVT_DM_CALL_ROUTE: [conv_len:1][conv][peer:8][group:8]; group=0 без маршрута. */ int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id); void dm_call_route_trampoline(void* arg); /* аргумент: dm_messages_req */ /* Снимок бесед публикуется как CHAT_EVT_DM_CONVERSATIONS с JSON без NUL. */ struct dm_conversations_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ }; void dm_conversations_trampoline(void* arg); #ifdef __cplusplus } #endif #endif /* DM_CORE_H */