From 08cc41e69c93dd0a66103d00612f9c39e262e065 Mon Sep 17 00:00:00 2001 From: zombocoder Date: Sat, 1 Aug 2026 17:18:29 +0300 Subject: [PATCH] feat(reader): add support for reading encrypted uncompressed files and validate decryption --- src/lib/bfc_reader.c | 92 +++++++++++++++++++++++++++++++++++ tests/unit/test_reader.c | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/src/lib/bfc_reader.c b/src/lib/bfc_reader.c index c013fab..4660feb 100644 --- a/src/lib/bfc_reader.c +++ b/src/lib/bfc_reader.c @@ -532,6 +532,91 @@ static size_t read_compressed_file(bfc_t* r, bfc_reader_entry_t* entry, uint64_t return copy_size; } +// Read an encrypted, uncompressed object. AEAD covers the whole object, so the +// entire payload has to be decrypted before any slice can be returned. +static size_t read_encrypted_file(bfc_t* r, bfc_reader_entry_t* entry, uint64_t offset, void* buf, + size_t len) { + if (!r->has_encryption_key) { + return 0; // No decryption key available + } + + // Calculate content start position + if (bfc_os_seek(r->file, (int64_t) entry->obj_offset, SEEK_SET) != BFC_OK) { + return 0; + } + + struct bfc_obj_hdr obj_hdr; + if (fread(&obj_hdr, 1, sizeof(obj_hdr), r->file) != sizeof(obj_hdr)) { + return 0; + } + + // Skip name and padding to get to content + uint16_t name_len = obj_hdr.name_len; + if (fseek(r->file, name_len, SEEK_CUR) != 0) { + return 0; + } + + size_t hdr_name_size = sizeof(obj_hdr) + name_len; + size_t padding = bfc_padding_size(hdr_name_size, BFC_ALIGN); + if (padding > 0 && fseek(r->file, (long) padding, SEEK_CUR) != 0) { + return 0; + } + + void* encrypted_data = malloc(obj_hdr.enc_size); + if (!encrypted_data) { + return 0; + } + + size_t encrypted_read = fread(encrypted_data, 1, obj_hdr.enc_size, r->file); + if (encrypted_read != obj_hdr.enc_size) { + free(encrypted_data); + return 0; + } + + // Create decryption key structure + bfc_encrypt_key_t decrypt_key; + if (bfc_encrypt_key_from_bytes(r->encryption_key, &decrypt_key) != BFC_OK) { + free(encrypted_data); + return 0; + } + + bfc_decrypt_result_t decrypt_result = bfc_decrypt_data( + &decrypt_key, encrypted_data, obj_hdr.enc_size, entry->path, strlen(entry->path), 0); + bfc_encrypt_key_clear(&decrypt_key); + free(encrypted_data); + + if (decrypt_result.error != BFC_OK || !decrypt_result.data) { + return 0; + } + + // Validate CRC of decrypted data + bfc_crc32c_ctx_t crc_ctx; + bfc_crc32c_reset(&crc_ctx); + bfc_crc32c_update(&crc_ctx, decrypt_result.data, decrypt_result.decrypted_size); + uint32_t calculated_crc = bfc_crc32c_final(&crc_ctx); + + if (calculated_crc != entry->crc32c) { + free(decrypt_result.data); + return 0; + } + + if (offset >= decrypt_result.decrypted_size) { + free(decrypt_result.data); + return 0; + } + + // Copy requested portion to output buffer + size_t copy_size = len; + if (offset + copy_size > decrypt_result.decrypted_size) { + copy_size = decrypt_result.decrypted_size - offset; + } + + memcpy(buf, (uint8_t*) decrypt_result.data + offset, copy_size); + free(decrypt_result.data); + + return copy_size; +} + size_t bfc_read(bfc_t* r, const char* container_path, uint64_t offset, void* buf, size_t len) { if (!r || !container_path || !buf || len == 0) { return 0; @@ -575,6 +660,13 @@ size_t bfc_read(bfc_t* r, const char* container_path, uint64_t offset, void* buf return read_compressed_file(r, entry, offset, buf, to_read); } + // Handle encrypted (but uncompressed) files. Without this the raw ciphertext + // would be returned to the caller, and a wrong key would go unnoticed because + // the AEAD tag would never be checked. + if (entry->enc != BFC_ENC_NONE) { + return read_encrypted_file(r, entry, offset, buf, to_read); + } + // Calculate file position // Find content start by parsing object header if (bfc_os_seek(r->file, (int64_t) entry->obj_offset, SEEK_SET) != BFC_OK) { diff --git a/tests/unit/test_reader.c b/tests/unit/test_reader.c index fad1cea..e629671 100644 --- a/tests/unit/test_reader.c +++ b/tests/unit/test_reader.c @@ -1268,6 +1268,103 @@ static int test_symlink_partial_read(void) { return 0; } +#ifdef BFC_WITH_SODIUM +// Regression: bfc_read used to skip decryption for encrypted-but-uncompressed +// objects, handing raw ciphertext back to the caller. A wrong key went +// unnoticed too, because the AEAD tag was never verified. +static int test_read_encrypted_uncompressed(void) { + const char* filename = "reader_test_encrypted_plain.bfc"; + const char* content = "encrypted but not compressed payload"; + const size_t content_len = strlen(content); + + // Raw keys rather than passwords: this exercises the same decryption path in + // bfc_read, but skips Argon2id, which takes minutes in a sanitised -O0 build. + uint8_t key[32]; + uint8_t wrong_key[32]; + for (size_t i = 0; i < sizeof(key); i++) { + key[i] = (uint8_t) (i + 1); + wrong_key[i] = (uint8_t) (0xFF - i); + } + + unlink(filename); + + bfc_t* writer = NULL; + int result = bfc_create(filename, 4096, 0, &writer); + assert(result == BFC_OK); + + result = bfc_set_encryption_key(writer, key); + assert(result == BFC_OK); + + // Compression stays off, so this exercises the uncompressed read path. + assert(bfc_get_compression(writer) == BFC_COMP_NONE); + + FILE* temp = tmpfile(); + assert(temp != NULL); + fwrite(content, 1, content_len, temp); + rewind(temp); + + result = bfc_add_file(writer, "secret.txt", temp, 0644, bfc_os_current_time_ns(), NULL); + assert(result == BFC_OK); + fclose(temp); + + result = bfc_finish(writer); + assert(result == BFC_OK); + bfc_close(writer); + + char buffer[256]; + + // Without a key, nothing may be returned. + bfc_t* reader = NULL; + result = bfc_open(filename, &reader); + assert(result == BFC_OK); + assert(bfc_has_encryption(reader) == 1); + + size_t bytes_read = bfc_read(reader, "secret.txt", 0, buffer, sizeof(buffer)); + assert(bytes_read == 0); + bfc_close_read(reader); + + // With the wrong key the AEAD tag must fail, not silently succeed. + reader = NULL; + result = bfc_open(filename, &reader); + assert(result == BFC_OK); + result = bfc_reader_set_encryption_key(reader, wrong_key); + assert(result == BFC_OK); + + bytes_read = bfc_read(reader, "secret.txt", 0, buffer, sizeof(buffer)); + assert(bytes_read == 0); + bfc_close_read(reader); + + // With the right key the plaintext comes back verbatim. + reader = NULL; + result = bfc_open(filename, &reader); + assert(result == BFC_OK); + result = bfc_reader_set_encryption_key(reader, key); + assert(result == BFC_OK); + + memset(buffer, 0, sizeof(buffer)); + bytes_read = bfc_read(reader, "secret.txt", 0, buffer, sizeof(buffer)); + assert(bytes_read == content_len); + buffer[bytes_read] = '\0'; + assert(strcmp(buffer, content) == 0); + + // Partial reads must slice the decrypted plaintext, not the ciphertext. + memset(buffer, 0, sizeof(buffer)); + bytes_read = bfc_read(reader, "secret.txt", 10, buffer, 6); + assert(bytes_read == 6); + buffer[bytes_read] = '\0'; + assert(memcmp(buffer, content + 10, 6) == 0); + + // Reading past the end yields nothing. + bytes_read = bfc_read(reader, "secret.txt", content_len, buffer, sizeof(buffer)); + assert(bytes_read == 0); + + bfc_close_read(reader); + unlink(filename); + + return 0; +} +#endif + int test_reader(void) { if (test_open_container() != 0) return 1; @@ -1319,6 +1416,10 @@ int test_reader(void) { return 1; if (test_symlink_partial_read() != 0) return 1; +#ifdef BFC_WITH_SODIUM + if (test_read_encrypted_uncompressed() != 0) + return 1; +#endif return 0; }