From fe75a493ef5fe7604f08efa4c479b4a4bd74c2b2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 02:09:17 +0100 Subject: [PATCH] Commit immutable attachment identity before message publication --- src/chat/chat_attachment.c | 67 +++++++++++++++++++++++++++++------- tests/test_chat_attachment.c | 65 ++++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/chat/chat_attachment.c b/src/chat/chat_attachment.c index 425a0645..2db1d8a5 100644 --- a/src/chat/chat_attachment.c +++ b/src/chat/chat_attachment.c @@ -78,10 +78,11 @@ static void ak_hex(const uint8_t id[16], char hex[33]) { /* Строгое имя UUID.uenc из подписанного manifest, без разделителей пути. */ int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]) { - uint8_t name[64]; + uint8_t name[64], decoded[16]; char canonical[52]; 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; + if (!id || !pipe || body[0] != '*' || pipe - body != 53 || b64_decode(body + 1, 52, name, sizeof(name)) != 37 || + memcmp(name + 32, ".uenc", 5) || b64_encode(name, 37, canonical, sizeof(canonical)) != 52 || + memcmp(body + 1, canonical, sizeof(canonical))) goto invalid; for (unsigned i = 0; i < 16; i++) { unsigned value = 0; for (unsigned j = 0; j < 2; j++) { @@ -89,8 +90,9 @@ int chat_attachment_identity(const char* body, size_t length, uint8_t id[16]) { 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; + decoded[i] = (uint8_t)value; } + memcpy(id, decoded, sizeof(decoded)); return 0; invalid: DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid encrypted manifest identity bytes=%zu", length); return -1; @@ -372,16 +374,57 @@ fail: /* Сохраняется идентичность подписанного контейнера до выхода сообщения в сеть. */ 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; + if (!inst || !CC(inst) || !CC(inst)->attachment_initialized || CC(inst)->attachment_closing || + !ak_channel(channel) || !id || !message || !hash) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: invalid author binding state/arguments"); return -1; } + sqlite3* db = CC(inst)->db; sqlite3_stmt* st = NULL; + if (!sqlite3_get_autocommit(db)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding rejected inside external transaction channel=%s", channel); return -1; + } + const char* stage = "begin binding"; + int rc = sqlite3_exec(db, "BEGIN IMMEDIATE /* attachment binding */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "prepare binding"; + rc = sqlite3_prepare_v2(db, "UPDATE attachment_keys SET message_id=?,cipher_hash=?" + " WHERE channel=? AND crypto_id=? AND author=? AND length(key)=32" + " AND ((message_id IS NULL AND cipher_hash IS NULL) OR (message_id=? AND cipher_hash=?))", -1, &st, NULL); + if (rc != SQLITE_OK) goto fail; + stage = "bind message"; rc = sqlite3_bind_blob(st, 1, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind hash"; rc = sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind channel"; rc = sqlite3_bind_text(st, 3, channel, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind identity"; rc = sqlite3_bind_blob(st, 4, id, 16, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind author"; rc = sqlite3_bind_int64(st, 5, (sqlite3_int64)inst->node_id); + if (rc != SQLITE_OK) goto fail; + stage = "bind previous message"; rc = sqlite3_bind_blob(st, 6, message, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "bind previous hash"; rc = sqlite3_bind_blob(st, 7, hash, 32, SQLITE_STATIC); + if (rc != SQLITE_OK) goto fail; + stage = "write binding"; rc = sqlite3_step(st); + if (rc != SQLITE_DONE) goto fail; + int changed = sqlite3_changes(db); + stage = "finalize binding"; rc = sqlite3_finalize(st); st = NULL; + if (rc != SQLITE_OK) goto fail; + if (changed != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding rows=%d expected=1 channel=%s", changed, channel); + stage = "row count"; rc = SQLITE_CONSTRAINT; goto fail; + } + stage = "commit binding"; rc = sqlite3_exec(db, "COMMIT /* attachment binding */", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "attachment keys: author binding committed channel=%s message=%02x%02x%02x%02x", + channel, message[0], message[1], message[2], message[3]); return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding channel=%s stage=%s rc=%d (%s) db=%s", + channel, stage, rc, sqlite3_errstr(rc), sqlite3_errmsg(db)); + int final_rc = sqlite3_finalize(st); + if (final_rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment keys: binding cleanup finalize rc=%d", final_rc); + ak_rollback(inst, "author binding", channel); + return -1; } /* Запись поступает только из проверенной истории; размер и hash относятся к ciphertext. */ diff --git a/tests/test_chat_attachment.c b/tests/test_chat_attachment.c index eedf2482..4036c36b 100644 --- a/tests/test_chat_attachment.c +++ b/tests/test_chat_attachment.c @@ -68,8 +68,10 @@ int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*cb)(void*, int, char if (fail_channel_rollback && !strcmp(sql, "ROLLBACK TO delete_topo_channel; RELEASE delete_topo_channel")) { fail_channel_rollback = 0; DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "delete test: injected rollback failure"); return SQLITE_IOERR; } - int completion_scope = fault_prefix && strcmp(fault_prefix, "UPDATE attachment_keys SET decrypting=1 WHERE"); - int targeted = !completion_scope || strstr(sql, "/* attachment completion */"); + int binding_scope = fault_prefix && !strcmp(fault_prefix, "UPDATE attachment_keys SET message_id="); + int completion_scope = fault_prefix && !binding_scope && strcmp(fault_prefix, "UPDATE attachment_keys SET decrypting=1 WHERE"); + int targeted = binding_scope ? strstr(sql, "/* attachment binding */") != NULL : + !completion_scope || strstr(sql, "/* attachment completion */"); if (targeted && !strncmp(sql, "BEGIN IMMEDIATE", strlen("BEGIN IMMEDIATE")) && take(BEGIN)) return SQLITE_BUSY; if (targeted && !strncmp(sql, "COMMIT", strlen("COMMIT")) && take(COMMIT)) return SQLITE_BUSY; if (!strcmp(sql, "ROLLBACK") && fail_rollback && (!completion_scope || completion_transaction)) { @@ -607,6 +609,64 @@ static void test_channel_delete_rollback(void) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS channel delete:14 SQL faults, cascade/Merkle ABORT/IGNORE, veto/foreign transaction/rollback fault; key/files preserved until committed event"); } +static void binding_state(struct fixture* f, const uint8_t message[32], const uint8_t hash[32], int bound) { + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(f->reader, "SELECT message_id,cipher_hash FROM attachment_keys WHERE channel=? AND crypto_id=?", + -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK); + assert(sqlite3_bind_blob(st, 2, f->id, 16, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW); + assert(sqlite3_column_bytes(st, 0) == 32 * bound && sqlite3_column_bytes(st, 1) == 32 * bound); + if (bound) assert(!memcmp(sqlite3_column_blob(st, 0), message, 32) && !memcmp(sqlite3_column_blob(st, 1), hash, 32)); + assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK); +} + +static void test_attachment_binding(void) { + struct fixture f; setup(&f); source(&f); publish(&f); + uint8_t message[32], hash[32]; sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(f.reader, "SELECT message_id,cipher_hash FROM attachment_keys WHERE channel=?", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_bind_text(st, 1, f.channel, -1, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW); + assert(sqlite3_column_bytes(st, 0) == 32 && sqlite3_column_bytes(st, 1) == 32); + memcpy(message, sqlite3_column_blob(st, 0), 32); memcpy(hash, sqlite3_column_blob(st, 1), 32); assert(sqlite3_finalize(st) == SQLITE_OK); + char query[256]; snprintf(query, sizeof(query), "UPDATE attachment_keys SET message_id=NULL,cipher_hash=NULL WHERE channel='%s'", f.channel); + const enum fault points[] = {BIND_1, BEGIN, PREPARE, BIND_2, BIND_3, BIND_4, BIND_5, BIND_6, BIND_7, STEP, FINALIZE, CHANGES, COMMIT}; + fault_prefix = "UPDATE attachment_keys SET message_id="; + for (unsigned i = 0; i < sizeof(points) / sizeof(points[0]); i++) { + sql(f.inst->topo_sqlite_db, query); fault = points[i]; + int rc = chat_attachment_bind(f.inst, f.channel, f.id, message, hash); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "binding test: fault=%d rc=%d consumed=%d events=%u", points[i], rc, fault == NONE, f.events); + assert(rc < 0 && fault == NONE && f.events == 1 && sqlite3_get_autocommit(f.inst->topo_sqlite_db)); + binding_state(&f, message, hash, 0); committed(&f, 0); + assert(!chat_attachment_bind(f.inst, f.channel, f.id, message, hash)); binding_state(&f, message, hash, 1); + assert(!chat_attachment_bind(f.inst, f.channel, f.id, message, hash)); binding_state(&f, message, hash, 1); + } + uint8_t conflicting[32]; memcpy(conflicting, hash, 32); conflicting[0] ^= 1; + assert(chat_attachment_bind(f.inst, f.channel, f.id, message, conflicting) < 0); binding_state(&f, message, hash, 1); + sql(f.inst->topo_sqlite_db, "BEGIN;UPDATE attachment_keys SET ready=8 WHERE channel='900'"); + assert(chat_attachment_bind(f.inst, f.channel, f.id, message, hash) < 0 && !sqlite3_get_autocommit(f.inst->topo_sqlite_db)); + binding_state(&f, message, hash, 1); sql(f.inst->topo_sqlite_db, "ROLLBACK"); committed(&f, 1); + fault_prefix = NULL; cleanup(&f); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment binding:13 SQL faults/retries, exact committed identity, idempotent repeat, conflicting hash and foreign transaction rejected"); +} + +static void test_attachment_identity(void) { + const char* name = "00112233445566778899aabbccddeeff.uenc"; + char body[55] = "*"; uint8_t id[16], unchanged[16]; memset(unchanged, 0xa5, sizeof(unchanged)); + assert(b64_encode((const uint8_t*)name, strlen(name), body + 1, 52) == 52); body[53] = '|'; body[54] = 0; + assert(!chat_attachment_identity(body, 54, id)); + for (unsigned i = 0; i < 16; i++) assert(id[i] == i * 17); + const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + for (int i = 49; i < 64; i++) { + body[50] = alphabet[i]; memcpy(id, unchanged, 16); + assert(chat_attachment_identity(body, 54, id) < 0 && !memcmp(id, unchanged, 16)); + } + body[50] = 'w'; body[51] = '!'; memcpy(id, unchanged, 16); + assert(chat_attachment_identity(body, 54, id) < 0 && !memcmp(id, unchanged, 16)); + body[51] = '='; body[52] = '!'; assert(chat_attachment_identity(body, 54, id) < 0); + body[52] = '='; assert(chat_attachment_identity(body, 54, NULL) < 0); + assert(chat_attachment_identity(NULL, 54, id) < 0); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment identity: canonical base64 only, 15 trailing-bit aliases and invalid padding rejected, output preserved on error"); +} + int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_INFO); utun_instance_set_tun_init_enabled(0); size_t baseline = u_get_allocated_count(); @@ -616,6 +676,7 @@ int main(void) { test_decryption_claim(); test_decryption_completion(); test_completion_controls(); test_completion_stop(); test_worker_error_completion(); test_completion_limit(); test_channel_delete_rollback(); + test_attachment_binding(); test_attachment_identity(); assert(u_get_allocated_count() == baseline); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: independent committed key/metadata before worker, allocations restored"); return 0;