Commit immutable attachment identity before message publication

This commit is contained in:
evgeny committed 2026-10-27 02:09:17 +01:00
1 parent 6803369a13
commit fe75a493ef
2 files changed
+118 -14

No files matched your search

+55 -12
View File
@@ -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. */
+63 -2
View File
@@ -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;