From 321ed8e747224a74cd73f3ad0490d7ab8f633827 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 18 Oct 2026 02:13:28 +0200 Subject: [PATCH] Implement durable encrypted attachment grants and requests --- src/chat/chat_attachment.c | 584 +++++++++++++++++++++++++++++++++++++ src/chat/chat_attachment.h | 46 +++ 2 files changed, 630 insertions(+) create mode 100644 src/chat/chat_attachment.c create mode 100644 src/chat/chat_attachment.h diff --git a/src/chat/chat_attachment.c b/src/chat/chat_attachment.c new file mode 100644 index 00000000..07fbd342 --- /dev/null +++ b/src/chat/chat_attachment.c @@ -0,0 +1,584 @@ +/* Автор выдаёт случайный ключ отдельному проверенному участнику после решения UI. + * Сеть хранит только контейнеры; decoded-копии не попадают в media_files. */ +#include "chat_attachment.h" +#include "chat_core_priv.h" +#include "chat_event.h" +#include "chat_feedback.h" +#include "member_sync.h" +#include "../utun_instance.h" +#include "../routing_layer/etcp_router.h" +#include "../routing_layer/topo_node_sqlite.h" +#include "../media_delivery/media_index.h" +#include "../media_async/media_async.h" +#include "../../lib/platform_compat.h" +#include "../../lib/mem.h" +#include +#include +#include + +enum { AK_REQUEST = 1, AK_REQUEST_ACK, AK_GRANT, AK_DENY, AK_RESULT_ACK }; +#define AK_BODY 85 +#define AK_WIRE (AK_BODY + 32) + +struct ak_file { + struct UTUN_INSTANCE* inst; + char channel[32], source[1024], cipher[1024], plain[1024], temporary[1040], type[32]; + uint8_t id[16], key[32], message[32], hash[32], reply[32]; + struct attachment_info info; + uint64_t author, request; + int encrypt, error; +}; + +/* Все ошибки SQLite содержат конкретную операцию. */ +static sqlite3_stmt* ak_prepare(struct UTUN_INSTANCE* inst, const char* sql) { + sqlite3_stmt* st = NULL; + if (!CC(inst) || sqlite3_prepare_v2(CC(inst)->db, sql, -1, &st, NULL) != SQLITE_OK) + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: prepare failed sql=%s error=%s", sql, + CC(inst) ? sqlite3_errmsg(CC(inst)->db) : "stopped"); + return st; +} + +/* Statement всегда освобождается. */ +static int ak_step(struct UTUN_INSTANCE* inst, sqlite3_stmt* st) { + if (!st) return -1; + int rc = sqlite3_step(st); + if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: write failed rc=%d: %s", rc, sqlite3_errmsg(CC(inst)->db)); + sqlite3_finalize(st); + return rc == SQLITE_DONE ? 0 : -1; +} + +/* Управление транзакциями без открытых statement. */ +static int ak_exec(struct UTUN_INSTANCE* inst, const char* sql) { + int rc = sqlite3_exec(CC(inst)->db, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: %s failed: %s", sql, sqlite3_errmsg(CC(inst)->db)); + return rc == SQLITE_OK ? 0 : -1; +} + +/* Канал допускает только каноническое десятичное имя. */ +static int ak_channel(const char* channel) { + return channel && channel[0] && channel[0] != '0' && strlen(channel) < 20 && strspn(channel, "0123456789") == strlen(channel); +} + +/* Изменения UI сигнализируются только после commit; секреты в события не входят. */ +static void ak_changed(struct UTUN_INSTANCE* inst, const char* channel) { + uint8_t event[33]; size_t n = strlen(channel); + event[0] = (uint8_t)n; memcpy(event + 1, channel, n); + chat_event_post(inst, CHAT_EVT_ATTACHMENT_KEYS_CHANGED, event, (int)n + 1); +} + +/* UUID не зависит от пользовательского имени или путей. */ +static void ak_hex(const uint8_t id[16], char hex[33]) { + for (unsigned i = 0; i < 16; i++) snprintf(hex + 2 * i, 3, "%02x", id[i]); +} + +/* Строгое имя UUID.uenc из подписанного manifest, без разделителей пути. */ +int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]) { + uint8_t name[64]; + const char* pipe = body ? memchr(body, '|', length) : NULL; + if (!pipe || body[0] != '*' || pipe - body != 53 || b64_decode(body + 1, 52, name, sizeof(name)) != 37 || + memcmp(name + 32, ".uenc", 5)) goto invalid; + for (unsigned i = 0; i < 16; i++) { + unsigned value = 0; + for (unsigned j = 0; j < 2; j++) { + uint8_t c = name[i * 2 + j]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) goto invalid; + value = value * 16 + (c <= '9' ? c - '0' : c - 'a' + 10); + } + id[i] = (uint8_t)value; + } + return 0; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid encrypted manifest identity bytes=%zu", length); return -1; +} + +/* Разделённые деревья исключают раздачу plaintext и удаление его обходом orphan media. */ +static int ak_paths(struct ak_file* job) { + const char* slash = strrchr(CC(job->inst)->db_path, '/'); + char base[512], hex[33]; ak_hex(job->id, hex); + if (slash) snprintf(base, sizeof(base), "%.*s", (int)(slash - CC(job->inst)->db_path), CC(job->inst)->db_path); + else snprintf(base, sizeof(base), "."); + int a = snprintf(job->cipher, sizeof(job->cipher), "%s/media/%s/%s.uenc", base, job->channel, hex); + int b = snprintf(job->plain, sizeof(job->plain), "%s/decrypted/%s/%s.file", base, job->channel, hex); + if (a < 0 || b < 0 || (size_t)a >= sizeof(job->cipher) || (size_t)b >= sizeof(job->plain)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: path too long channel=%s", job->channel); return -1; + } + return 0; +} + +/* Каталоги создаются в worker до первого открытия файла. */ +static int ak_parent(const char* path) { + char parent[1040]; snprintf(parent, sizeof(parent), "%s", path); + for (char* p = parent + 1; *p; p++) { + if (*p != '/') continue; + *p = 0; int rc = utun_mkdir(parent, 0700); *p = '/'; + if (rc && errno != EEXIST) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: mkdir failed path=%s: %s", parent, strerror(errno)); return -1; } + } + return 0; +} + +/* Никаких обращений к БД или инстансу из worker. */ +static void ak_file_work(void* arg) { + struct ak_file* job = arg; job->error = -1; + if (ak_parent(job->plain) || ak_parent(job->cipher)) return; + snprintf(job->temporary, sizeof(job->temporary), "%s.part", job->encrypt ? job->cipher : job->plain); + if (job->encrypt) { + if (ma_copy_file(job->source, job->plain) || encrypted_file_sync(job->plain) || + encrypted_file_write(job->plain, job->temporary, job->key, job->author, job->id, &job->info, job->type)) return; + } else if (encrypted_file_read(job->cipher, job->temporary, job->key, job->author, job->id, job->hash, &job->info, job->type)) return; + if (rename(job->temporary, job->encrypt ? job->cipher : job->plain)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: publish failed: %s", strerror(errno)); return; + } + job->error = 0; +} + +/* Проверка существующей строки предотвращает публикацию после удаления канала. */ +static int ak_present(struct ak_file* job) { + sqlite3_stmt* st = ak_prepare(job->inst, "SELECT 1 FROM attachment_keys WHERE channel=? AND crypto_id=?"); + if (!st) return 0; + sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); + int rc = sqlite3_step(st); sqlite3_finalize(st); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: presence lookup failed rc=%d", rc); + return rc == SQLITE_ROW; +} + +/* Сохранение metadata и открытой копии не меняет сетевой индекс. */ +static void ak_file_done(void* arg, int error) { + struct ak_file* job = arg; struct UTUN_INSTANCE* inst = job->inst; + int valid = CC(inst) && !CC(inst)->attachment_closing && ak_present(job); + int success = !error && !job->error && valid; + if (success && !job->encrypt) { + uint8_t info[ATTACHMENT_WIRE_MAX]; int n = attachment_encode(&job->info, info, sizeof(info)); + sqlite3_stmt* st = n < 0 ? NULL : ak_prepare(inst, + "UPDATE attachment_keys SET info=?,content_type=?,ready=1,decrypting=0,error=0 WHERE channel=? AND crypto_id=?"); + if (st) { + sqlite3_bind_blob(st, 1, info, n, SQLITE_STATIC); sqlite3_bind_text(st, 2, job->type, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 4, job->id, 16, SQLITE_STATIC); + } + success = st && !ak_step(inst, st); + } + if (success && job->encrypt) { + char name[38], hex[33]; ak_hex(job->id, hex); snprintf(name, sizeof(name), "%s.uenc", hex); + struct chat_msg_submit request = { .inst = inst, .data = (uint8_t*)name, .data_len = 37, .request_id = job->request }; + snprintf(request.channel_id, sizeof(request.channel_id), "%s", job->channel); + snprintf(request.content_type, sizeof(request.content_type), "%s", ENCRYPTED_ATTACHMENT_TYPE); + snprintf(request.media_src, sizeof(request.media_src), "%s", job->cipher); + snprintf(request.media_dest, sizeof(request.media_dest), "%s", job->cipher); + memcpy(request.reply_to, job->reply, 32); + chat_core_submit_media_message(inst, &request); + } else if (!success) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: worker failed channel=%s encrypt=%d error=%d worker=%d current=%d", + job->channel, job->encrypt, error, job->error, valid); + if (valid) { + sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_keys SET decrypting=0,error=1 WHERE channel=? AND crypto_id=?"); + if (st) { sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); ak_step(inst, st); } + } + if (job->encrypt) chat_event_submitted(inst, job->request, 0); + } + if (job->encrypt && remove(job->source) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: prepared source cleanup failed"); + if ((!success || !valid) && job->temporary[0] && remove(job->temporary) && errno != ENOENT) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: temporary cleanup failed"); + if (!valid) { + if (remove(job->plain) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: deleted channel plaintext cleanup failed"); + if (job->encrypt && remove(job->cipher) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: deleted channel ciphertext cleanup failed"); + } + if (valid) ak_changed(inst, job->channel); + OPENSSL_cleanse(job->key, 32); u_free(job); +} + +/* Сначала durable ключ, затем подготовка файла; потерять ключ опубликованного файла нельзя. */ +int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const char* source, + const struct attachment_info* info, const char* type, const uint8_t reply[32], uint64_t request) { + if (!inst || !CC(inst) || CC(inst)->attachment_closing || !inst->media_async || !ak_channel(channel) || + !source || strlen(source) >= 1024 || !type || strlen(type) >= 32 || attachment_validate(info)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid send arguments/state"); return -1; + } + struct ak_file* job = u_calloc(1, sizeof(*job)); + if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: send allocation failed"); return -1; } + job->inst = inst; job->encrypt = 1; job->author = inst->node_id; job->request = request; job->info = *info; + snprintf(job->channel, sizeof(job->channel), "%s", channel); snprintf(job->source, sizeof(job->source), "%s", source); + snprintf(job->type, sizeof(job->type), "%s", type); memcpy(job->reply, reply, 32); + uint8_t encoded[ATTACHMENT_WIRE_MAX]; int n = attachment_encode(info, encoded, sizeof(encoded)); + if (RAND_bytes(job->id, 16) != 1 || RAND_bytes(job->key, 32) != 1 || ak_paths(job) || n < 0) goto fail; + sqlite3_stmt* st = ak_prepare(inst, "INSERT INTO attachment_keys(channel,crypto_id,author,key,cipher_path,plain_path,info,content_type,ready)" + " VALUES(?,?,?,?,?,?,?,?,1)"); + if (!st) goto fail; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->id, 16, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)inst->node_id); sqlite3_bind_blob(st, 4, job->key, 32, SQLITE_STATIC); + sqlite3_bind_text(st, 5, job->cipher, -1, SQLITE_STATIC); sqlite3_bind_text(st, 6, job->plain, -1, SQLITE_STATIC); + sqlite3_bind_blob(st, 7, encoded, n, SQLITE_STATIC); sqlite3_bind_text(st, 8, type, -1, SQLITE_STATIC); + if (ak_step(inst, st)) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: key saved before encryption channel=%s", channel); + media_async_submit(inst->media_async, inst->ua, ak_file_work, job, ak_file_done, job); + return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: cannot prepare send channel=%s", channel); + OPENSSL_cleanse(job->key, 32); u_free(job); return -1; +} + +/* Сохраняется идентичность подписанного контейнера до выхода сообщения в сеть. */ +int chat_attachment_bind(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t id[16], + const uint8_t message[32], const uint8_t hash[32]) { + sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_keys SET message_id=?,cipher_hash=?" + " WHERE channel=? AND crypto_id=? AND author=? AND key IS NOT NULL AND (message_id IS NULL OR message_id=?)"); + if (!st) return -1; + sqlite3_bind_blob(st, 1, message, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC); + sqlite3_bind_text(st, 3, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 4, id, 16, SQLITE_STATIC); + sqlite3_bind_int64(st, 5, (sqlite3_int64)inst->node_id); sqlite3_bind_blob(st, 6, message, 32, SQLITE_STATIC); + if (ak_step(inst, st) || sqlite3_changes(CC(inst)->db) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: author key missing or already bound channel=%s", channel); return -1; + } + return 0; +} + +/* Запись поступает только из проверенной истории; размер и hash относятся к ciphertext. */ +int chat_attachment_observe(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], + uint64_t author, const char* json, size_t length) { + if (!CC(inst) || !json || !ak_channel(channel) || length > 65536) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid observed message"); return -1; + } + char* copy = u_malloc(length + 1); + if (!copy) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: message allocation failed"); return -1; } + memcpy(copy, json, length); copy[length] = 0; + if (!strstr(copy, "\"ct\":\"" ENCRYPTED_ATTACHMENT_TYPE "\"")) { u_free(copy); return 0; } + char* body = strstr(copy, "\"d\":\""); char* end = body ? strchr(body + 5, '"') : NULL; + struct media_index_result result = {0}; struct ak_file job = { .inst = inst }; + snprintf(job.channel, sizeof(job.channel), "%s", channel); + int rc = -1; + if (!end) goto done; + body += 5; *end = 0; + if (chat_attachment_identity(body, (size_t)(end - body), job.id) || chat_msg_parse_media_body(body, &result) || ak_paths(&job)) goto done; + sqlite3_stmt* st = ak_prepare(inst, "INSERT INTO attachment_keys(channel,crypto_id,message_id,author,cipher_hash,cipher_path,plain_path)" + " VALUES(?,?,?,?,?,?,?) ON CONFLICT(channel,crypto_id) DO NOTHING"); + if (!st) goto done; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job.id, 16, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)author); + sqlite3_bind_blob(st, 5, result.content_hash, 32, SQLITE_STATIC); sqlite3_bind_text(st, 6, job.cipher, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 7, job.plain, -1, SQLITE_STATIC); rc = ak_step(inst, st); + if (!rc) { + st = ak_prepare(inst, "SELECT 1 FROM attachment_keys WHERE channel=? AND crypto_id=? AND message_id=? AND author=? AND cipher_hash=?"); + if (!st) { rc = -1; goto done; } + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job.id, 16, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)author); + sqlite3_bind_blob(st, 5, result.content_hash, 32, SQLITE_STATIC); + rc = sqlite3_step(st) == SQLITE_ROW ? 0 : -1; sqlite3_finalize(st); + } +done: + if (rc) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: rejected manifest/colliding identity channel=%s author=%llu", channel, + (unsigned long long)author); + media_index_result_free(&result); u_free(copy); return rc; +} + +/* Кодограммы привязаны к immutable сообщению, UUID и полному hash контейнера. */ +static int ak_body(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint8_t cmd, uint8_t wire[AK_WIRE]) { + sqlite3_stmt* st = ak_prepare(inst, "SELECT crypto_id,cipher_hash,key FROM attachment_keys WHERE channel=? AND message_id=?"); + if (!st) return -1; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + int rc = sqlite3_step(st), ok = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 16 && sqlite3_column_bytes(st, 1) == 32 && + (cmd != AK_GRANT || sqlite3_column_bytes(st, 2) == 32); + if (ok) { + memcpy(wire, "AK01", 4); wire[4] = cmd; memcpy(wire + 5, message, 32); + memcpy(wire + 37, sqlite3_column_blob(st, 0), 16); memcpy(wire + 53, sqlite3_column_blob(st, 1), 32); + if (cmd == AK_GRANT) memcpy(wire + AK_BODY, sqlite3_column_blob(st, 2), 32); + } + sqlite3_finalize(st); + if (!ok) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: wire identity/key unavailable channel=%s command=%u rc=%d", channel, cmd, rc); + return ok ? (cmd == AK_GRANT ? AK_WIRE : AK_BODY) : -1; +} + +/* Неподписанные и незашифрованные ответы сервис не принимает. */ +static int ak_send(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer, const uint8_t* body, size_t length) { + uint64_t group = strtoull(channel, NULL, 10); + if (!etcp_router_has_route(inst, group, peer)) return 1; + struct ETCP_ROUTER_CONN* conn = etcp_router_conn_get(inst, group, peer, ETCP_RT_ID_ATTACHMENT_KEYS); + if (!conn) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: router channel allocation failed"); return -1; } + if (!etcp_router_send_q_has_room(inst, group, peer, ETCP_RT_ID_ATTACHMENT_KEYS)) return 1; + return etcp_router_conn_send(conn, body, length, ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED); +} + +/* Очередь содержит только команду; ключ читается при отправке из отдельной таблицы. */ +static int ak_queue(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t peer, int cmd) { + sqlite3_stmt* st = ak_prepare(inst, "INSERT INTO attachment_key_outbox(channel,message_id,peer,command) VALUES(?,?,?,?)" + " ON CONFLICT(channel,message_id,peer) DO UPDATE SET command=excluded.command,retry_at=0"); + if (!st) return -1; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); sqlite3_bind_int(st, 4, cmd); return ak_step(inst, st); +} + +/* Разрешение проверяет полное подписанное членство и immutable историю. */ +static int ak_authorized(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t peer) { + uint8_t key[32]; + if (member_sync_verified_ed25519(inst, channel, peer, key)) return 0; + char sql[256]; snprintf(sql, sizeof(sql), "SELECT 1 FROM msg_%s m JOIN attachment_keys a ON a.message_id=m.message_id" + " AND a.channel=? WHERE m.message_id=? AND m.node_id=? AND a.author=? AND length(a.key)=32", channel); + sqlite3_stmt* st = ak_prepare(inst, sql); + if (!st) return 0; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)inst->node_id); sqlite3_bind_int64(st, 4, (sqlite3_int64)inst->node_id); + int rc = sqlite3_step(st); sqlite3_finalize(st); + if (rc != SQLITE_ROW) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: request has no local author key/message channel=%s peer=%llu rc=%d", + channel, (unsigned long long)peer, rc); + return rc == SQLITE_ROW; +} + +/* Подтверждение протокола посылается только после durable записи. */ +static void ak_ack(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer, const uint8_t* body, uint8_t cmd) { + uint8_t ack[AK_BODY]; memcpy(ack, body, sizeof(ack)); ack[4] = cmd; + int rc = ak_send(inst, channel, peer, ack, sizeof(ack)); + if (rc < 0) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: acknowledgment deferred channel=%s command=%u", channel, cmd); +} + +/* Приём строго адресного E2E control. Дубликат возвращает сохранённое решение. */ +static void ak_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { + struct UTUN_INSTANCE* inst = conn->instance; + uint8_t* d = entry->dgram; + uint64_t peer = 0, group = 0, target = 0; + char channel[32]; + if (!CC(inst) || CC(inst)->attachment_closing || entry->len < ROUTER_SVC_PAYLOAD_OFF + AK_BODY) goto invalid; + memcpy(&peer, d + ROUTER_SVC_SRC_OFF, 8); memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8); memcpy(&target, d + ROUTER_SVC_DST_OFF, 8); + if (target != inst->node_id || peer == inst->node_id || + (d[ROUTER_SVC_FLAGS_OFF] & (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) != (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) goto invalid; + snprintf(channel, sizeof(channel), "%llu", (unsigned long long)group); + uint8_t* body = d + ROUTER_SVC_PAYLOAD_OFF; size_t bytes = entry->len - ROUTER_SVC_PAYLOAD_OFF; + uint8_t cmd = body[4], expected[AK_WIRE]; + if (!ak_channel(channel) || memcmp(body, "AK01", 4) || cmd < AK_REQUEST || cmd > AK_RESULT_ACK || + bytes != (cmd == AK_GRANT ? AK_WIRE : AK_BODY) || ak_body(inst, channel, body + 5, AK_REQUEST, expected) < 0 || + memcmp(body + 5, expected + 5, AK_BODY - 5)) goto invalid; + if (cmd == AK_REQUEST) { + if (!ak_authorized(inst, channel, body + 5, peer) || ak_exec(inst, "SAVEPOINT attachment_request")) goto invalid; + sqlite3_stmt* st = ak_prepare(inst, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at)" + " VALUES(?,?,?,1,0,unixepoch()) ON CONFLICT(channel,message_id,peer) DO NOTHING"); + if (st) { sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); } + if (!st || ak_step(inst, st)) goto rollback_request; + st = ak_prepare(inst, "SELECT state FROM attachment_key_requests WHERE channel=? AND message_id=? AND peer=? AND incoming=1"); + if (!st) goto rollback_request; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); + int rc = sqlite3_step(st), state = rc == SQLITE_ROW ? sqlite3_column_int(st, 0) : -1; sqlite3_finalize(st); + if (state < 0 || (state && ak_queue(inst, channel, body + 5, peer, state == 3 ? AK_DENY : AK_GRANT)) || + ak_exec(inst, "RELEASE attachment_request")) goto rollback_request; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: request saved channel=%s peer=%llu state=%d", channel, (unsigned long long)peer, state); + ak_ack(inst, channel, peer, body, AK_REQUEST_ACK); ak_changed(inst, channel); goto done; +rollback_request: + ak_exec(inst, "ROLLBACK TO attachment_request"); ak_exec(inst, "RELEASE attachment_request"); goto done; + } + if (ak_exec(inst, "SAVEPOINT attachment_result")) goto done; + sqlite3_stmt* st = ak_prepare(inst, "SELECT incoming,state FROM attachment_key_requests WHERE channel=? AND message_id=? AND peer=?"); + if (!st) goto rollback_result; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); + int rc = sqlite3_step(st), incoming = rc == SQLITE_ROW ? sqlite3_column_int(st, 0) : -1, + state = rc == SQLITE_ROW ? sqlite3_column_int(st, 1) : -1; sqlite3_finalize(st); + if (incoming < 0 || (cmd == AK_RESULT_ACK ? !incoming || state == 0 : incoming)) goto rollback_result; + if (cmd == AK_GRANT || cmd == AK_DENY) { + if ((state == 2 && cmd != AK_GRANT) || (state == 3 && cmd != AK_DENY)) goto rollback_result; + if (cmd == AK_GRANT) { + st = ak_prepare(inst, "UPDATE attachment_keys SET key=?,error=0 WHERE channel=? AND message_id=? AND author=?" + " AND (key IS NULL OR key=?)"); + if (!st) goto rollback_result; + sqlite3_bind_blob(st, 1, body + AK_BODY, 32, SQLITE_STATIC); sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, body + 5, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)peer); + sqlite3_bind_blob(st, 5, body + AK_BODY, 32, SQLITE_STATIC); + if (ak_step(inst, st) || sqlite3_changes(CC(inst)->db) != 1) goto rollback_result; + } + } + int next = cmd == AK_REQUEST_ACK ? (state == 0 ? 1 : state) : cmd == AK_RESULT_ACK ? (state == 3 ? 3 : 4) : cmd == AK_GRANT ? 2 : 3; + st = ak_prepare(inst, "UPDATE attachment_key_requests SET state=? WHERE channel=? AND message_id=? AND peer=?"); + if (!st) goto rollback_result; + sqlite3_bind_int(st, 1, next); sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, body + 5, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)peer); + if (ak_step(inst, st)) goto rollback_result; + st = ak_prepare(inst, "DELETE FROM attachment_key_outbox WHERE channel=? AND message_id=? AND peer=? AND command=?"); + if (!st) goto rollback_result; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, body + 5, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); + sqlite3_bind_int(st, 4, cmd == AK_RESULT_ACK ? (state == 3 ? AK_DENY : AK_GRANT) : AK_REQUEST); + if (ak_step(inst, st) || ak_exec(inst, "RELEASE attachment_result")) goto rollback_result; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: control committed channel=%s peer=%llu command=%u state=%d", channel, + (unsigned long long)peer, cmd, next); + if (cmd == AK_GRANT || cmd == AK_DENY) ak_ack(inst, channel, peer, body, AK_RESULT_ACK); + ak_changed(inst, channel); goto done; +rollback_result: + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: unexpected/conflicting control channel=%s peer=%llu command=%u", channel, + (unsigned long long)peer, cmd); + ak_exec(inst, "ROLLBACK TO attachment_result"); ak_exec(inst, "RELEASE attachment_result"); goto done; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: rejected control source=%llu group=%llu bytes=%u", (unsigned long long)peer, + (unsigned long long)group, (unsigned)entry->len); +done: + queue_dgram_free(entry); queue_entry_free(entry); +} + +/* Только явное решение автора создаёт GRANT в durable outbox. */ +int chat_attachment_decide(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t peer, int approve) { + if (!inst || !CC(inst) || CC(inst)->attachment_closing || !ak_channel(channel) || !ak_authorized(inst, channel, message, peer)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: decision rejected by authorization"); return -1; + } + if (ak_exec(inst, "SAVEPOINT attachment_decision")) return -1; + sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_key_requests SET state=? WHERE channel=? AND message_id=? AND peer=? AND incoming=1 AND state=0"); + if (!st) goto rollback; + sqlite3_bind_int(st, 1, approve ? 2 : 3); sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 4, (sqlite3_int64)peer); + if (ak_step(inst, st) || sqlite3_changes(CC(inst)->db) != 1 || ak_queue(inst, channel, message, peer, approve ? AK_GRANT : AK_DENY) || + ak_exec(inst, "RELEASE attachment_decision")) goto rollback; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: author decision channel=%s peer=%llu approve=%d", channel, (unsigned long long)peer, approve); + ak_changed(inst, channel); return 0; +rollback: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: decision failed channel=%s peer=%llu", channel, (unsigned long long)peer); + ak_exec(inst, "ROLLBACK TO attachment_decision"); ak_exec(inst, "RELEASE attachment_decision"); return -1; +} + +/* GUI передаёт зафиксированные ID, результат возвращается после записи решения. */ +void chat_attachment_decision_trampoline(void* arg) { + struct chat_attachment_decision* request = arg; + int rc = chat_attachment_decide(request->inst, request->channel, request->message, request->peer, request->approve); + uint8_t event[34]; size_t n = strlen(request->channel); + event[0] = (uint8_t)n; memcpy(event + 1, request->channel, n); event[n + 1] = rc ? 0 : 1; + chat_event_post(request->inst, CHAT_EVT_ATTACHMENT_KEYS_CHANGED, event, (int)n + 2); u_free(request); +} + +/* Пользовательский интерес хранится отдельно от фоновой storage-копии. */ +void chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]) { + sqlite3_stmt* st = ak_prepare(inst, "UPDATE attachment_keys SET wanted=1,error=0 WHERE channel=? AND message_id=?"); + if (!st) return; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); ak_step(inst, st); +} + +/* Автозагрузка обычного участника запрашивает доступ; storage-реплика хранит только ciphertext. */ +void chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]) { + char sql[256]; snprintf(sql, sizeof(sql), "UPDATE attachment_keys SET downloaded=1,wanted=max(wanted," + "NOT EXISTS(SELECT 1 FROM peers_%s WHERE node_id=? AND storage=1)) WHERE channel=? AND message_id=?", channel); + sqlite3_stmt* st = ak_prepare(inst, sql); + if (!st) return; + sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); sqlite3_bind_text(st, 2, channel, -1, SQLITE_STATIC); + sqlite3_bind_blob(st, 3, message, 32, SQLITE_STATIC); + if (!ak_step(inst, st)) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: ciphertext downloaded channel=%s", channel); ak_changed(inst, channel); } +} + +/* Один проход ограничен; retry_at даёт всем задачам очередь без голодания. */ +static void ak_pump(struct UTUN_INSTANCE* inst) { + for (unsigned i = 0; i < 16; i++) { + sqlite3_stmt* st = ak_prepare(inst, "SELECT channel,message_id,peer,command FROM attachment_key_outbox" + " WHERE retry_at<=unixepoch() ORDER BY retry_at,rowid LIMIT 1"); + if (!st) return; + int rc = sqlite3_step(st); + if (rc != SQLITE_ROW) { sqlite3_finalize(st); if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: outbox read failed rc=%d", rc); return; } + char channel[32]; uint8_t message[32], wire[AK_WIRE]; + snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); memcpy(message, sqlite3_column_blob(st, 1), 32); + uint64_t peer = (uint64_t)sqlite3_column_int64(st, 2); int cmd = sqlite3_column_int(st, 3); sqlite3_finalize(st); + st = ak_prepare(inst, "UPDATE attachment_key_outbox SET retry_at=unixepoch()+5 WHERE channel=? AND message_id=? AND peer=?"); + if (!st) return; + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, message, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); if (ak_step(inst, st)) return; + if (cmd == AK_GRANT && !ak_authorized(inst, channel, message, peer)) continue; + int n = ak_body(inst, channel, message, (uint8_t)cmd, wire); + if (n > 0) { + rc = ak_send(inst, channel, peer, wire, (size_t)n); + if (rc < 0) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: send deferred channel=%s peer=%llu command=%d", channel, (unsigned long long)peer, cmd); + else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "attachment keys: outbox attempt channel=%s peer=%llu command=%d route=%d", channel, + (unsigned long long)peer, cmd, rc == 0); + } + OPENSSL_cleanse(wire, sizeof(wire)); + } +} + +/* Восстанавливаем обработку файлов и запросов из устойчивых данных. */ +static void ak_tick(void* arg) { + struct UTUN_INSTANCE* inst = arg; + if (!CC(inst) || CC(inst)->attachment_closing) return; + CC(inst)->attachment_timer = NULL; + for (unsigned i = 0; i < 4; i++) { + sqlite3_stmt* st = ak_prepare(inst, "SELECT channel,message_id,crypto_id,author,key,cipher_hash,cipher_path,plain_path FROM attachment_keys a" + " WHERE downloaded=1 AND wanted=1 AND ready=0 AND decrypting=0 AND error=0 AND" + " (length(key)=32 OR NOT EXISTS(SELECT 1 FROM attachment_key_requests r WHERE r.channel=a.channel AND r.message_id=a.message_id)) LIMIT 1"); + if (!st) break; + int rc = sqlite3_step(st); + if (rc != SQLITE_ROW) { sqlite3_finalize(st); if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: pending file read failed rc=%d", rc); break; } + struct ak_file* job = u_calloc(1, sizeof(*job)); + if (!job) { sqlite3_finalize(st); DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: receive allocation failed"); break; } + job->inst = inst; + snprintf(job->channel, sizeof(job->channel), "%s", sqlite3_column_text(st, 0)); memcpy(job->message, sqlite3_column_blob(st, 1), 32); + memcpy(job->id, sqlite3_column_blob(st, 2), 16); job->author = (uint64_t)sqlite3_column_int64(st, 3); + int has_key = sqlite3_column_bytes(st, 4) == 32; + if (has_key) memcpy(job->key, sqlite3_column_blob(st, 4), 32); + memcpy(job->hash, sqlite3_column_blob(st, 5), 32); + snprintf(job->cipher, sizeof(job->cipher), "%s", sqlite3_column_text(st, 6)); + snprintf(job->plain, sizeof(job->plain), "%s", sqlite3_column_text(st, 7)); sqlite3_finalize(st); + if (has_key) { + st = ak_prepare(inst, "UPDATE attachment_keys SET decrypting=1 WHERE channel=? AND message_id=?"); + if (st) { sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->message, 32, SQLITE_STATIC); } + if (!st || ak_step(inst, st)) { OPENSSL_cleanse(job->key, 32); u_free(job); break; } + media_async_submit(inst->media_async, inst->ua, ak_file_work, job, ak_file_done, job); + } else { + int failed = ak_exec(inst, "SAVEPOINT attachment_outgoing"); + st = failed ? NULL : ak_prepare(inst, "INSERT INTO attachment_key_requests(channel,message_id,peer,incoming,state,created_at) VALUES(?,?,?,0,0,unixepoch())"); + if (st) { sqlite3_bind_text(st, 1, job->channel, -1, SQLITE_STATIC); sqlite3_bind_blob(st, 2, job->message, 32, SQLITE_STATIC); + sqlite3_bind_int64(st, 3, (sqlite3_int64)job->author); } + if (!failed && (!st || ak_step(inst, st) || ak_queue(inst, job->channel, job->message, job->author, AK_REQUEST) || + ak_exec(inst, "RELEASE attachment_outgoing"))) { + ak_exec(inst, "ROLLBACK TO attachment_outgoing"); ak_exec(inst, "RELEASE attachment_outgoing"); failed = 1; + } + if (!failed) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: decryption request queued channel=%s author=%llu", job->channel, + (unsigned long long)job->author); ak_changed(inst, job->channel); } + u_free(job); if (failed) break; + } + } + ak_pump(inst); + CC(inst)->attachment_timer = uasync_set_timeout(inst->ua, 10000, inst, ak_tick, "attachment keys"); + if (!CC(inst)->attachment_timer) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: timer allocation failed"); +} + +/* Отдельные локальные таблицы никогда не регистрируются в db_sync/Merkle. */ +int chat_attachment_init(struct UTUN_INSTANCE* inst) { + if (!CC(inst) || !inst->media_async) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid initialization"); return -1; } + if (ak_exec(inst, "CREATE TABLE IF NOT EXISTS attachment_keys(" + "channel TEXT NOT NULL,crypto_id BLOB NOT NULL CHECK(length(crypto_id)=16),message_id BLOB,author INTEGER NOT NULL," + "key BLOB CHECK(key IS NULL OR length(key)=32),cipher_hash BLOB,cipher_path TEXT NOT NULL,plain_path TEXT NOT NULL," + "info BLOB,content_type TEXT,ready INTEGER NOT NULL DEFAULT 0,downloaded INTEGER NOT NULL DEFAULT 0," + "wanted INTEGER NOT NULL DEFAULT 0,decrypting INTEGER NOT NULL DEFAULT 0,error INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY(channel,crypto_id),UNIQUE(channel,message_id));" + "CREATE TABLE IF NOT EXISTS attachment_key_requests(channel TEXT NOT NULL,message_id BLOB NOT NULL,peer INTEGER NOT NULL," + "incoming INTEGER NOT NULL,state INTEGER NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel,message_id,peer));" + "CREATE TABLE IF NOT EXISTS attachment_key_outbox(channel TEXT NOT NULL,message_id BLOB NOT NULL,peer INTEGER NOT NULL," + "command INTEGER NOT NULL,retry_at INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(channel,message_id,peer));" + "UPDATE attachment_keys SET decrypting=0; UPDATE attachment_key_outbox SET retry_at=0;")) return -1; + CC(inst)->attachment_closing = 0; + if (etcp_router_bind(inst, ETCP_RT_ID_ATTACHMENT_KEYS, ak_receive)) return -1; + CC(inst)->attachment_timer = uasync_set_timeout(inst->ua, 10000, inst, ak_tick, "attachment keys"); + if (!CC(inst)->attachment_timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: initial timer allocation failed"); return -1; } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: initialized persistent requests and separate ciphertext/plaintext storage"); return 0; +} + +/* Сначала снять сеть/таймер, потом дождаться workers с живым chat_core. */ +void chat_attachment_quiesce(struct UTUN_INSTANCE* inst) { + if (!CC(inst)) return; + CC(inst)->attachment_closing = 1; + if (CC(inst)->attachment_timer) uasync_cancel_timeout(inst->ua, CC(inst)->attachment_timer); + CC(inst)->attachment_timer = NULL; + if (inst->router_bindings.callbacks[ETCP_RT_ID_ATTACHMENT_KEYS]) etcp_router_unbind(inst, ETCP_RT_ID_ATTACHMENT_KEYS); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: quiesced, persistent decisions preserved"); +} + +void chat_attachment_destroy(struct UTUN_INSTANCE* inst) { + chat_attachment_quiesce(inst); +} + +/* Удаление ключей отменяет completion активных workers; они удалят свои результаты. */ +void chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel) { + sqlite3_stmt* st = ak_prepare(inst, "SELECT plain_path FROM attachment_keys WHERE channel=?"); + if (st) { + sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + const char* path = (const char*)sqlite3_column_text(st, 0); + if (path && remove(path) && errno != ENOENT) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: plaintext removal failed path=%s", path); + } + if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: channel cleanup scan failed rc=%d", rc); + sqlite3_finalize(st); + } + const char* tables[] = { "attachment_key_outbox", "attachment_key_requests", "attachment_keys" }; + for (unsigned i = 0; i < 3; i++) { + char sql[128]; snprintf(sql, sizeof(sql), "DELETE FROM %s WHERE channel=?", tables[i]); st = ak_prepare(inst, sql); + if (st) { sqlite3_bind_text(st, 1, channel, -1, SQLITE_STATIC); ak_step(inst, st); } + } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: channel keys and requests removed channel=%s", channel); +} diff --git a/src/chat/chat_attachment.h b/src/chat/chat_attachment.h new file mode 100644 index 00000000..cd5c4ae2 --- /dev/null +++ b/src/chat/chat_attachment.h @@ -0,0 +1,46 @@ +/* Зашифрованные групповые вложения и ручная выдача ключей. + * Все API кроме identity — uasync-поток. Ключи, запросы и outbox локальны. + * init после media_async; quiesce до его destroy, destroy после workers. + * Отдельный router-сервис требует E2E-шифрование и подпись каждого пакета. + * Очереди переживают restart; для доставки нужен живой маршрут обеих сторон. */ +#ifndef CHAT_ATTACHMENT_H +#define CHAT_ATTACHMENT_H +#include "../media_async/attachment.h" +#include "../media_async/encrypted_file.h" +#ifdef __cplusplus +extern "C" { +#endif +struct UTUN_INSTANCE; +int chat_attachment_init(struct UTUN_INSTANCE* inst); +void chat_attachment_quiesce(struct UTUN_INSTANCE* inst); +void chat_attachment_destroy(struct UTUN_INSTANCE* inst); +/* source — собственный подготовленный файл приложения, передаётся во владение. */ +int chat_attachment_send(struct UTUN_INSTANCE* inst, const char* channel, const char* source, + const struct attachment_info* info, const char* type, const uint8_t reply[32], uint64_t request); +/* UUID из подписанного имени контейнера; принимает канонический base64-prefix тела медиа. */ +int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]); +/* Связать заранее сохранённый авторский ключ с подписанным сообщением до публикации. */ +int chat_attachment_bind(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t id[16], + const uint8_t message[32], const uint8_t hash[32]); +/* Учесть проверенное db_sync сообщение; обычные сообщения игнорируются. */ +int chat_attachment_observe(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], + uint64_t author, const char* json, size_t length); +/* После проверенной загрузки: сохранить состояние, запросить ключ либо расшифровать. */ +void chat_attachment_downloaded(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]); +/* Ручная загрузка на storage-узле также означает запрос пользовательского доступа. */ +void chat_attachment_want(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32]); +int chat_attachment_decide(struct UTUN_INSTANCE* inst, const char* channel, const uint8_t message[32], uint64_t peer, int approve); +struct chat_attachment_decision { + struct UTUN_INSTANCE* inst; + char channel[32]; + uint8_t message[32]; + uint64_t peer; + int approve; +}; +void chat_attachment_decision_trampoline(void* arg); +/* Канал удаляется локально: отменить очереди, стереть ключи и частные копии. */ +void chat_attachment_delete_channel(struct UTUN_INSTANCE* inst, const char* channel); +#ifdef __cplusplus +} +#endif +#endif