diff --git a/tests/test_encrypted_attachment.c b/tests/test_encrypted_attachment.c new file mode 100644 index 00000000..693cd59b --- /dev/null +++ b/tests/test_encrypted_attachment.c @@ -0,0 +1,302 @@ +/* Реальные A–S–B/C соединения: S хранит только ciphertext. Запросы переживают + * офлайн автора/restart; B получает ключ после решения, C получает отказ. */ +#include +#include +#include +#include +#include "../lib/platform_compat.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "../src/utun_instance.h" +#include "../src/ntp_time.h" +#include "../src/chat/chat_core.h" +#include "../src/chat/chat_attachment.h" +#include "../src/chat/member_sync.h" +#include "../src/chat/chat_setting.h" +#include "../src/routing_layer/topo_node_sqlite.h" +#include "../src/routing_layer/topo_group.h" +#include "../src/routing_layer/etcp_router.h" +#include "../src/transport_layer/secure_channel.h" +#include "../src/transport_layer/etcp.h" +#include "../src/media_async/media_async.h" +#include "../src/media_async/attachment_send.h" +#include "test_utils.h" +#include "test_clock.h" +#include "test_net.h" +#define GROUP 4242424242424244ULL +#define CHANNEL "4242424242424244" +#define FILE_BYTES (3 * 32768 + 17) +enum { A, B, S, M, NODES }; /* M — второй запрашивающий клиент; S — storage+supernode. */ +struct media_test { + struct UASYNC* ua; + struct UTUN_INSTANCE* nodes[NODES]; + struct SC_MYKEYS keys[NODES]; + uint64_t ids[NODES]; + uint16_t ports[NODES]; + uint8_t ed[NODES][32], ch_x[32], ch_ed[32], ch_priv[32], ch_sig[64], message[32]; + char dir[512], file[768]; + unsigned phase, ticks, heartbeats, hold; + int failed; + void* timer; +}; +static int value(struct UTUN_INSTANCE* inst, const char* sql) { + if (!inst) return -1; + sqlite3_stmt* st = NULL; int result = -1; + if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW) + result = sqlite3_column_int(st, 0); + sqlite3_finalize(st); return result; +} +static int live(struct UTUN_INSTANCE* inst, uint64_t peer) { + return inst && etcp_router_get_path(inst, GROUP, peer, ETCP_RT_ID_ATTACHMENT_KEYS).next_hop_node_id != 0; +} +static void failure(struct media_test* t, const char* reason) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "encrypted attachment test failed phase=%u reason=%s", t->phase, reason); + t->failed = 1; uasync_stop(t->ua); +} +static void hex(const uint8_t* p, size_t len, char* out) { + for (size_t i = 0; i < len; i++) snprintf(out + i * 2, 3, "%02x", p[i]); +} +static int start_node(struct media_test* t, int index) { + char path[768], pub[65], priv[65], server[65], db[768]; + hex(t->keys[index].public_key, 32, pub); hex(t->keys[index].private_key, 32, priv); + snprintf(db, sizeof(db), "%s/db%d", t->dir, index); utun_mkdir(db, 0700); + snprintf(path, sizeof(path), "%s/%d.conf", t->dir, index); + FILE* f = fopen(path, "w"); + if (!f) return -1; + fprintf(f, "[global]\nmy_private_key=%s\nmy_public_key=%s\ndb_path=%s\n" + "[server:s1]\naddr=127.0.0.1:%d\ntype=public\n[allowed_keys]\nallow_all=1\n" + "[chatserver]\nstorage_autoload=0\n", priv, pub, db, t->ports[index]); + if (index != S) { + hex(t->keys[S].public_key, 32, server); + fprintf(f, "[client:to_s]\npeer_public_key=%s\nlink=s1:127.0.0.1:0\n", server); + } + fclose(f); + t->nodes[index] = utun_instance_create(t->ua, path); + if (t->nodes[index]) { + t->ports[index] = ss_get_port(&test_etcp_bound_socket(t->nodes[index], "s1")->local_addr); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "encrypted attachment fixture: node=%d port=%u retained for restart", index, t->ports[index]); + } + if (t->nodes[index] && index != S) test_etcp_set_peer(t->nodes[index], "to_s", t->nodes[S], "s1"); + return t->nodes[index] ? utun_instance_init(t->nodes[index]) : -1; +} + +static int setup(struct media_test* t) { + for (int i = 0; i < NODES; i++) { + struct UTUN_INSTANCE* inst = t->nodes[i]; + if (topo_node_sqlite_channel_put(inst->topo_sqlite_db, CHANNEL, "encrypted-attachment-test", t->ids[A], + t->ch_x, NULL, t->ch_ed, i == A ? t->ch_priv : NULL, t->ch_sig)) return -1; + chat_core_ensure_channel_ready(inst, CHANNEL); + for (int j = 0; j < NODES; j++) { + uint8_t msg[512], join[64], adm_sig[64], certificate[64]; + uint64_t ts = ntp_time_get_seconds(inst); + int n = member_sync_build_join_msg(t->ch_x, t->ch_ed, t->ids[j], t->keys[j].public_key, ts, msg, sizeof(msg)); + if (n < 0 || sc_ed25519_sign(t->nodes[j]->my_ed25519_privkey, msg, (size_t)n, join) != SC_OK) return -1; + const char* tags = j == S ? "{\"supernode\":\"yes\",\"storage\":\"yes\",\"ver\":\"1\"}" : "{\"ver\":\"1\"}"; + size_t off = strlen(tags); + memcpy(msg, tags, off); memcpy(msg + off, &t->ids[j], 8); + if (sc_ed25519_sign(t->ch_priv, msg, off + 8, adm_sig) != SC_OK) return -1; + char info[64]; snprintf(info, sizeof(info), "{\"name\":\"node%d\"}", j); + if (member_sync_sign_pubkey(t->ch_priv, t->keys[j].public_key, certificate)) return -1; + if (member_sync_put(inst, CHANNEL, t->ids[j], t->keys[j].public_key, t->ed[j], join, ts, + NULL, 0, info, tags, adm_sig, 0, 0, certificate) < 0) return -1; + } + if (i != S) member_sync_start(inst, t->ids[S], CHANNEL, NULL, NULL); + } + return 0; +} + + +/* Ключи и метаданные до подтверждения отсутствуют даже у скачавшего storage. */ +static int snapshot(struct media_test* t) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(t->nodes[A]->topo_sqlite_db, "SELECT message_id FROM attachment_keys WHERE message_id IS NOT NULL", -1, &st, NULL)) return -1; + int ok = sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32; + if (ok) memcpy(t->message, sqlite3_column_blob(st, 0), 32); + sqlite3_finalize(st); return ok ? 0 : -1; +} +static int readable(struct media_test* t, int index) { + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(t->nodes[index]->topo_sqlite_db, "SELECT plain_path FROM attachment_keys WHERE ready=1 AND length(key)=32", -1, &st, NULL)) return 0; + char path[1024] = ""; + if (sqlite3_step(st) == SQLITE_ROW) snprintf(path, sizeof(path), "%s", sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + FILE* f = path[0] ? fopen(path, "rb") : NULL; + if (!f) return 0; + int ok = 1; + for (unsigned i = 0; i < FILE_BYTES; i++) if (fgetc(f) != (int)((i * 73 + 19) & 255)) { ok = 0; break; } + if (fgetc(f) != EOF) ok = 0; + fclose(f); return ok; +} +static void download(struct media_test* t, int index) { + int id = value(t->nodes[index], "SELECT id FROM msg_" CHANNEL " ORDER BY id LIMIT 1"); + if (id <= 0) { failure(t, "history not available before download"); return; } + chat_core_attachment_download(t->nodes[index], CHANNEL, id); +} +/* Вход ПОСЛЕ router позволяет отдельно проверить требования флагов и точной идентичности. */ +static void inject(struct media_test* t, int source, int target, unsigned flags, int corrupt) { + struct UTUN_INSTANCE* inst = t->nodes[target]; + struct ETCP_CONN* conn = instance_find_conn(inst, t->ids[S]); + sqlite3_stmt* st = NULL; + if (!conn || sqlite3_prepare_v2(t->nodes[A]->topo_sqlite_db, + "SELECT crypto_id,cipher_hash,key FROM attachment_keys", -1, &st, NULL) || sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); failure(t, "control fixture"); return; + } + struct ll_entry* e = ll_alloc_lldgram(ROUTER_SVC_PAYLOAD_OFF + 117); + if (!e) { sqlite3_finalize(st); failure(t, "control allocation"); return; } + memset(e->dgram, 0, ROUTER_SVC_PAYLOAD_OFF + 117); e->len = ROUTER_SVC_PAYLOAD_OFF + 117; + e->dgram[0] = ETCP_RT_ID_ATTACHMENT_KEYS; + memcpy(e->dgram + ROUTER_SVC_SRC_OFF, &t->ids[source], 8); memcpy(e->dgram + ROUTER_SVC_DST_OFF, &inst->node_id, 8); + uint64_t group = GROUP; memcpy(e->dgram + ROUTER_SVC_GROUP_OFF, &group, 8); e->dgram[ROUTER_SVC_FLAGS_OFF] = flags; + uint8_t* b = e->dgram + ROUTER_SVC_PAYLOAD_OFF; + memcpy(b, "AK01", 4); b[4] = 3; memcpy(b + 5, t->message, 32); + memcpy(b + 37, sqlite3_column_blob(st, 0), 16); memcpy(b + 53, sqlite3_column_blob(st, 1), 32); + memcpy(b + 85, sqlite3_column_blob(st, 2), 32); sqlite3_finalize(st); + if (corrupt) b[53] ^= 1; + inst->router_bindings.callbacks[ETCP_RT_ID_ATTACHMENT_KEYS](conn, e); +} +static void tick(void* arg) { + struct media_test* t = arg; t->ticks++; t->heartbeats++; + struct UTUN_INSTANCE* a = t->nodes[A]; + struct UTUN_INSTANCE* b = t->nodes[B]; + struct UTUN_INSTANCE* s = t->nodes[S]; + struct UTUN_INSTANCE* c = t->nodes[M]; + switch (t->phase) { + case 0: + if (live(a, t->ids[S]) && live(b, t->ids[S]) && live(c, t->ids[S])) { + if (setup(t)) { failure(t, "signed group members"); return; } t->phase++; + } + /* UTUN routes precede CHAT routes; setup once physical links are UP. */ + else if (t->ticks == 20 && instance_find_conn(a, t->ids[S]) && instance_find_conn(b, t->ids[S]) && instance_find_conn(c, t->ids[S])) { + if (setup(t)) { failure(t, "signed group members"); return; } t->phase++; + } + break; + case 1: + if (live(a, t->ids[B]) && live(a, t->ids[M])) { + struct attachment_send_req* req = u_calloc(1, sizeof(*req)); + if (!req) { failure(t, "send allocation"); return; } + req->inst = a; req->encrypted = 1; req->info.kind = ATTACHMENT_FILE; + snprintf(req->target, sizeof(req->target), "%s", CHANNEL); + snprintf(req->source, sizeof(req->source), "%s", t->file); + snprintf(req->info.name, sizeof(req->info.name), "private-name.bin"); + attachment_send_trampoline(req); t->phase++; + } + break; + case 2: + if (value(s, "SELECT count(*) FROM msg_" CHANNEL) == 1 && value(b, "SELECT count(*) FROM msg_" CHANNEL) == 1 && + value(c, "SELECT count(*) FROM msg_" CHANNEL) == 1 && !snapshot(t)) { + if (value(b, "SELECT count(*) FROM msg_" CHANNEL " WHERE instr(CAST(data AS TEXT),'private-name.bin')>0") != 0) { + failure(t, "plaintext filename leaked into group history"); return; + } + download(t, S); t->phase++; + } + break; + case 3: + if (value(s, "SELECT downloaded FROM attachment_keys") == 1) { + if (value(s, "SELECT count(*) FROM attachment_key_requests") || value(s, "SELECT count(*) FROM attachment_keys WHERE key IS NOT NULL")) { + failure(t, "storage requested or received a key"); return; + } + utun_instance_destroy(a); t->nodes[A] = NULL; + download(t, B); download(t, M); t->phase++; + } + break; + case 4: + if (value(b, "SELECT count(*) FROM attachment_key_outbox") == 1 && value(c, "SELECT count(*) FROM attachment_key_outbox") == 1) { + if (readable(t, B) || readable(t, M)) { failure(t, "plaintext before author consent"); return; } + if (start_node(t, A)) { failure(t, "author restart"); return; } t->phase++; + } + break; + case 5: + if (value(a, "SELECT count(*) FROM attachment_key_requests WHERE incoming=1 AND state=0") == 2) { + inject(t, S, B, ROUTER_FLAG_ENCRYPTED | ROUTER_FLAG_SIGNED, 0); + inject(t, A, B, ROUTER_FLAG_SIGNED, 0); + inject(t, A, B, ROUTER_FLAG_ENCRYPTED | ROUTER_FLAG_SIGNED, 1); + if (value(b, "SELECT count(*) FROM attachment_keys WHERE key IS NOT NULL") != 0) { failure(t, "forged control accepted"); return; } + utun_instance_destroy(a); t->nodes[A] = NULL; + if (start_node(t, A)) { failure(t, "pending request restart"); return; } t->phase++; + } + break; + case 6: + if (live(a, t->ids[B]) && live(a, t->ids[M])) { + if (value(a, "SELECT count(*) FROM attachment_key_requests WHERE incoming=1 AND state=0") != 2 || + chat_attachment_decide(a, CHANNEL, t->message, t->ids[B], 1) || + chat_attachment_decide(a, CHANNEL, t->message, t->ids[M], 0)) { failure(t, "author decisions after restart"); return; } + t->phase++; + } + break; + case 7: + if (readable(t, B) && value(c, "SELECT state FROM attachment_key_requests WHERE incoming=0") == 3 && + value(a, "SELECT count(*) FROM attachment_key_outbox") == 0) { + if (readable(t, M) || value(c, "SELECT count(*) FROM attachment_keys WHERE key IS NOT NULL") || + value(s, "SELECT count(*) FROM attachment_keys WHERE key IS NOT NULL")) { failure(t, "key disclosed to denied/storage participant"); return; } + for (int i = 0; i < NODES; i++) { + if (value(t->nodes[i], "SELECT count(*) FROM media_files WHERE location NOT LIKE '%.uenc'") != 0) { + failure(t, "plaintext indexed for network distribution"); return; + } + } + utun_instance_destroy(b); t->nodes[B] = NULL; + if (start_node(t, B)) { failure(t, "recipient restart"); return; } t->phase++; + } + break; + case 8: + if (live(b, t->ids[A]) && readable(t, B)) { + inject(t, A, B, ROUTER_FLAG_ENCRYPTED | ROUTER_FLAG_SIGNED, 0); + if (!readable(t, B) || value(b, "SELECT count(*) FROM attachment_key_requests") != 1 || + value(a, "SELECT count(*) FROM attachment_key_requests") != 2) { failure(t, "duplicate grant changed persisted access"); return; } + t->phase++; + } + break; + case 9: + if (++t->hold >= 20) { t->phase++; uasync_stop(t->ua); return; } + break; + } + if (t->failed) return; + if (t->ticks > 12000) { failure(t, "timeout"); return; } + t->timer = uasync_set_timeout(t->ua, 100, t, tick, "encrypted_attachment_test"); +} +int main(void) { + struct media_test t = {0}; + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + test_clock_init(1000); test_clock_sync_wall_time(); + if (getenv("UTUN_TEST_DEBUG")) { + debug_set_category_level_by_name("dm", "debug"); debug_set_category_level_by_name("media", "debug"); + debug_set_category_level_by_name("bgp", "info"); debug_set_category_level_by_name("etcp_route", "info"); + debug_set_category_level_by_name("connection", "debug"); debug_set_category_level_by_name("etcp", "info"); + } + utun_instance_set_tun_init_enabled(0); + snprintf(t.dir, sizeof(t.dir), "/tmp/utun_encrypted_attachment_XXXXXX"); + if (test_mkdtemp(t.dir)) return 1; + for (int i = 0; i < NODES; i++) { + if (sc_generate_keypair(&t.keys[i]) || sc_derive_ed25519_pubkey(t.keys[i].private_key, t.ed[i])) return 1; + t.ids[i] = sc_derive_node_id_from_pubkey(t.keys[i].public_key); + } + struct SC_MYKEYS channel; + if (sc_generate_keypair(&channel)) return 1; + memcpy(t.ch_x, channel.public_key, 32); + if (sc_derive_ed25519_pubkey(channel.private_key, t.ch_ed)) return 1; + uint8_t channel_seed[SHA512_DIGEST_LENGTH]; + SHA512(channel.private_key, 32, channel_seed); + memcpy(t.ch_priv, channel_seed, 32); + uint8_t signed_channel[256]; + size_t off = sizeof(CHANNEL); + memcpy(signed_channel, CHANNEL, off); + const char name[] = "encrypted-attachment-test"; + memcpy(signed_channel + off, name, sizeof(name)); off += sizeof(name); + memcpy(signed_channel + off, &t.ids[A], 8); off += 8; + memcpy(signed_channel + off, t.ch_x, 32); off += 32; memcpy(signed_channel + off, t.ch_ed, 32); off += 32; + if (sc_ed25519_sign(t.ch_priv, signed_channel, off, t.ch_sig)) return 1; + snprintf(t.file, sizeof(t.file), "%s/source.bin", t.dir); + FILE* f = fopen(t.file, "wb"); + if (!f) return 1; + for (unsigned i = 0; i < FILE_BYTES; i++) fputc((int)((i * 73 + 19) & 255), f); + fclose(f); + t.ua = uasync_create(); + if (!t.ua || start_node(&t, S) || start_node(&t, A) || start_node(&t, M) || start_node(&t, B)) return 1; + t.timer = uasync_set_timeout(t.ua, 100, &t, tick, "dm_media_test"); + test_clock_mainloop(t.ua); + for (int i = 0; i < NODES; i++) if (t.nodes[i]) utun_instance_destroy(t.nodes[i]); + uasync_destroy(t.ua, 0); + test_clock_finish(); + printf("test_encrypted_attachment: phase=%u heartbeats=%u %s artifacts=%s\n", t.phase, t.heartbeats, + !t.failed && t.phase == 10 ? "PASS" : "FAIL", t.dir); + return t.failed || t.phase != 10; +} diff --git a/tests/test_encrypted_file.c b/tests/test_encrypted_file.c new file mode 100644 index 00000000..66c153e8 --- /dev/null +++ b/tests/test_encrypted_file.c @@ -0,0 +1,53 @@ +/* Проверка контейнера на границах порций, подмены metadata/header, неверного ключа + * и отсутствия публикации частичного plaintext после любого отказа. */ +#include "../src/media_async/encrypted_file.h" +#include "../src/media_async/media_async.h" +#include "../lib/debug_config.h" +#include "test_utils.h" +#include +#include + +static void roundtrip(const char* dir, unsigned size) { + char source[768], cipher[768], output[768]; + snprintf(source, sizeof(source), "%s/source", dir); snprintf(cipher, sizeof(cipher), "%s/cipher", dir); + snprintf(output, sizeof(output), "%s/output", dir); + FILE* f = fopen(source, "wb"); assert(f); + for (unsigned i = 0; i < size; i++) assert(fputc((i * 73 + 19) & 255, f) != EOF); + assert(!fclose(f)); + uint8_t key[32], wrong[32], id[16], hash[32]; + assert(RAND_bytes(key, 32) == 1 && RAND_bytes(wrong, 32) == 1 && RAND_bytes(id, 16) == 1); + struct attachment_info info = { .kind = ATTACHMENT_FILE, .name = "секретный-документ.bin" }, result; + char type[32]; + assert(!encrypted_file_write(source, cipher, key, 42, id, &info, "application/octet-stream")); + assert(!ma_sha256_file(cipher, hash)); + assert(!encrypted_file_read(cipher, output, key, 42, id, hash, &result, type)); + assert(!strcmp(info.name, result.name) && !strcmp(type, "application/octet-stream")); + f = fopen(output, "rb"); assert(f); + for (unsigned i = 0; i < size; i++) assert(fgetc(f) == (int)((i * 73 + 19) & 255)); + assert(fgetc(f) == EOF); assert(!fclose(f)); assert(!remove(output)); + assert(encrypted_file_read(cipher, output, wrong, 42, id, hash, &result, type) < 0); + assert(ma_file_size(output) < 0); + assert(encrypted_file_read(cipher, output, key, 43, id, hash, &result, type) < 0); + id[0] ^= 1; + assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0); id[0] ^= 1; + /* Подмена metadata даже с заново вычисленным внешним hash не проходит CCM. */ + f = fopen(cipher, "rb+"); assert(f && !fseek(f, 70, SEEK_SET)); + int c = fgetc(f); assert(c != EOF && !fseek(f, 70, SEEK_SET) && fputc(c ^ 1, f) != EOF && !fclose(f)); + assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0); + assert(!ma_sha256_file(cipher, hash)); + assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0 && ma_file_size(output) < 0); + assert(!encrypted_file_write(source, cipher, key, 42, id, &info, "application/octet-stream")); + /* Обрезанный контейнер и лишние байты отвергаются независимо от manifest hash. */ + f = fopen(cipher, "ab"); assert(f && fputc(0, f) != EOF && !fclose(f)); + assert(!ma_sha256_file(cipher, hash)); + assert(encrypted_file_read(cipher, output, key, 42, id, hash, &result, type) < 0 && ma_file_size(output) < 0); + printf("PASS: encrypted container size=%u, metadata, identity, key and integrity checks\n", size); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + char dir[512] = "/tmp/utun_encrypted_file_XXXXXX"; assert(!test_mkdtemp(dir)); + const unsigned sizes[] = { 0, 1, 32767, 32768, 32769, 98321 }; + for (unsigned i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) roundtrip(dir, sizes[i]); + return 0; +}