Implement durable encrypted attachment grants and requests

This commit is contained in:
evgeny committed 2026-10-18 02:13:28 +02:00
1 parent e45401449e
commit 321ed8e747
2 files changed
+630

No files matched your search

+584
View File
@@ -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 <openssl/rand.h>
#include <openssl/crypto.h>
#include <errno.h>
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);
}
+46
View File
@@ -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